共用方式為


同步 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 應用程式。

執行同步 I/O 作業之範例應用程式的效能圖表

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

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

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

實作解決方案並驗證結果

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

執行異步 I/O 作業之範例應用程式的效能圖表

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