同步 I/O 反模式

當 I/O 完成時封鎖呼叫執行緒,可降低效能並影響垂直延展性。

問題說明

同步 I/O 作業會在 I/O 完成時封鎖呼叫執行緒。 呼叫端執行緒會進入等候狀態,而且無法在此間隔期間執行有用的工作,而浪費處理資源。

I/O 的常見範例包括:

  • 擷取或保存資料至資料庫或任何類型的永續性儲存體。
  • 將要求傳送至 Web 服務。
  • 張貼訊息或從佇列擷取訊息。
  • 寫入或讀取本機檔案。

此反模式通常會發生,因為:

  • 這似乎是執行作業最直覺的方式。
  • 應用程式需要來自要求的回應。
  • 應用程式會使用僅提供 I/O 同步方法的程式庫。
  • 外部程式庫會在內部執行同步 I/O 作業。 單一同步 I/O 呼叫可以封鎖整個呼叫鏈結。

下列程式碼會將檔案上傳至 Azure Blob 儲存體。 有兩個地方的程式碼區塊會等候同步 I/O、 CreateIfNotExists 方法和 UploadFromStream 方法。

var blobClient = storageAccount.CreateCloudBlobClient();
var container = blobClient.GetContainerReference("uploadedfiles");

container.CreateIfNotExists();
var blockBlob = container.GetBlockBlobReference("myblob");

// Create or overwrite the "myblob" blob with contents from a local file.
using (var fileStream = File.OpenRead(HostingEnvironment.MapPath("~/FileToUpload.txt")))
{
    blockBlob.UploadFromStream(fileStream);
}

以下是等候外部服務回應的範例。 方法 GetUserProfile 會呼叫傳回 UserProfile 的遠端服務。

public interface IUserProfileService
{
    UserProfile GetUserProfile();
}

public class SyncController : ApiController
{
    private readonly IUserProfileService _userProfileService;

    public SyncController()
    {
        _userProfileService = new FakeUserProfileService();
    }

    // This is a synchronous method that calls the synchronous GetUserProfile method.
    public UserProfile GetUserProfile()
    {
        return _userProfileService.GetUserProfile();
    }
}

您可以在這裡 找到這兩個範例 的完整程式碼。

如何修正問題

以非同步作業取代同步 I/O 作業。 這會釋放目前的執行緒以繼續執行有意義的工作,而不是封鎖,並協助改善計算資源的使用率。 以非同步方式執行 I/O 特別有效率,可處理來自用戶端應用程式的要求意外激增。

許多程式庫都提供方法的同步和非同步版本。 盡可能使用非同步版本。 以下是將檔案上傳至 Azure Blob 儲存體之上一個範例的非同步版本。

var blobClient = storageAccount.CreateCloudBlobClient();
var container = blobClient.GetContainerReference("uploadedfiles");

await container.CreateIfNotExistsAsync();

var blockBlob = container.GetBlockBlobReference("myblob");

// Create or overwrite the "myblob" blob with contents from a local file.
using (var fileStream = File.OpenRead(HostingEnvironment.MapPath("~/FileToUpload.txt")))
{
    await blockBlob.UploadFromStreamAsync(fileStream);
}

運算子會在 await 執行非同步作業時,將控制權傳回呼叫環境。 這個語句之後的程式碼會當做非同步作業完成時執行的接續。

設計良好的服務也應該提供非同步作業。 以下是傳回使用者設定檔之 Web 服務的非同步版本。 方法 GetUserProfileAsync 取決於具有非同步版本的 User Profile 服務。

public interface IUserProfileService
{
    Task<UserProfile> GetUserProfileAsync();
}

public class AsyncController : ApiController
{
    private readonly IUserProfileService _userProfileService;

    public AsyncController()
    {
        _userProfileService = new FakeUserProfileService();
    }

    // This is a synchronous method that calls the Task based GetUserProfileAsync method.
    public Task<UserProfile> GetUserProfileAsync()
    {
        return _userProfileService.GetUserProfileAsync();
    }
}

對於未提供非同步作業版本的程式庫,可能會針對選取的同步方法建立異步包裝函式。 請謹慎遵循此方法。 雖然它可以改善叫用非同步包裝函式之執行緒的回應性,但它實際上會耗用更多資源。 可能會建立額外的執行緒,而且有與同步處理此執行緒所完成工作相關聯的額外負荷。 此部落格文章會討論一些取捨: 我是否應該公開同步方法的非同步包裝函式?

以下是非同步包裝函式圍繞同步方法的範例。

// Asynchronous wrapper around synchronous library method
private async Task<int> LibraryIOOperationAsync()
{
    return await Task.Run(() => LibraryIOOperation());
}

現在呼叫的程式碼可以等候包裝函式:

// Invoke the asynchronous wrapper using a task
await LibraryIOOperationAsync();

考量

  • I/O 作業預期會非常短暫,而且不太可能因為同步作業而造成爭用。 例如,讀取 SSD 磁片磁碟機上的小型檔案。 將工作分派至另一個執行緒,並在工作完成時與該執行緒同步處理的額外負荷,可能會超過非同步 I/O 的優點。 不過,這些案例相對罕見,而且大部分的 I/O 作業都應該以非同步方式完成。

  • 改善 I/O 效能可能會導致系統的其他部分成為瓶頸。 例如,解除封鎖執行緒可能會導致共用資源的並行要求數量較高,進而導致資源耗盡或節流。 如果問題發生,您可能需要相應放大網頁伺服器或分割區資料存放區的數目,以減少爭用。

如何偵測問題

對於使用者,應用程式可能會定期沒有回應。 應用程式可能會因為逾時例外狀況而失敗。 這些失敗也可能傳回 HTTP 500(內部伺服器)錯誤。 在伺服器上,線上程變成可用之前,可能會封鎖連入用戶端要求,導致要求佇列長度過長,並顯示為 HTTP 503(服務無法使用)錯誤。

您可以執行下列步驟來協助找出問題:

  1. 監視生產系統,並判斷封鎖的背景工作執行緒是否限制輸送量。

  2. 如果要求因為執行緒不足而遭到封鎖,請檢閱應用程式以判斷哪些作業可能以同步方式執行 I/O。

  3. 執行執行同步 I/O 之每個作業的受控制負載測試,以瞭解這些作業是否會影響系統效能。

診斷範例

下列各節會將這些步驟套用至稍早所述的範例應用程式。

監視網頁伺服器效能

針對 Azure Web 應用程式和 Web 角色,值得監視 IIS Web 服務器的效能。 特別是,請注意要求佇列長度,以建立要求是否在高活動期間封鎖等候可用執行緒。 您可以啟用 Azure 診斷來收集此資訊。 如需詳細資訊,請參閱

檢測應用程式,以查看要求在接受之後如何處理。 追蹤要求的流程有助於識別它是否正在執行緩慢執行的呼叫,並封鎖目前的執行緒。 執行緒分析也可以反白顯示正在封鎖的要求。

負載測試應用程式

下圖顯示稍早所顯示的同步 GetUserProfile 方法效能,在最多 4000 個並行使用者的不同負載下。 應用程式是 azure 雲端服務 Web 角色中執行的 ASP.NET 應用程式。

Performance chart for the sample application performing synchronous I/O operations

同步作業會硬式編碼為睡眠 2 秒,以模擬同步 I/O,因此最小回應時間略超過 2 秒。 當負載達到大約 2500 個並行使用者時,平均回應時間會達到高原,不過每秒的要求量會繼續增加。 請注意,這兩個量值的縮放比例為對數。 此點與測試結尾之間的每秒要求數目加倍。

隔離時,不一定能從這項測試中清楚同步 I/O 是否為問題。 在較重的負載下,應用程式可能會到達臨界點,其中網頁伺服器無法及時處理要求,導致用戶端應用程式收到逾時例外狀況。

連入要求會由 IIS 網頁伺服器排入佇列,並交給在 ASP.NET 執行緒集區中執行的執行緒。 由於每個作業會同步執行 I/O,因此執行緒會遭到封鎖,直到作業完成為止。 隨著工作負載的增加,最後會配置和封鎖執行緒集區中的所有 ASP.NET 執行緒。 此時,任何進一步的連入要求都必須在佇列中等候可用的執行緒。 隨著佇列長度的成長,要求會開始逾時。

實作解決方案並驗證結果

下一個圖表顯示負載測試非同步程式碼版本的結果。

Performance chart for the sample application performing asynchronous I/O operations

輸送量要高得多。 在與上一次測試相同的持續時間內,系統會成功處理輸送量的近十倍增加,如每秒要求中測量的量值。 此外,平均回應時間相對常數,且比上一個測試小約 25 倍。