在前一篇文章中,我們探討了非同步程式設計的基本概念,並介紹了如何使用 Task
、Task<T>
、async
和 await
來設計非同步操作。然而,非同步程式設計並非總是那麼直截了當。在實際開發中,開發者經常會遇到一些挑戰,這些挑戰主要來自於高併發、多執行緒以及非同步操作的特性。今天我們將深入探討這些問題,並提供解決方案。
當多個非同步操作同時訪問共享資源時,可能會發生資源競爭,這會導致數據不一致或資料破壞。這種情況經常出現在多個非同步任務同時讀寫相同的變數或對象時。
在非同步程式中,我們可以使用 SemaphoreSlim
或 lock
關鍵字來確保資源不會被同時訪問。對於非同步程式設計,SemaphoreSlim
是一個較為推薦的選擇,因為它允許非同步等待並且可以指定同時訪問的最大數量。
SemaphoreSlim
與 lock
的比較SemaphoreSlim
和 lock
都是用來實現同步和併發控制的工具,但它們的應用場景和機制有所不同。以下是兩者的主要差異:
lock
(Monitor):
範例:
private static readonly object _lockObject = new object();
public void CriticalSection()
{
lock (_lockObject)
{
// 只有一個執行緒能進入這段代碼
}
}
SemaphoreSlim
:
範例:
private static SemaphoreSlim _semaphore = new SemaphoreSlim(3); // 最多允許3個執行緒
public async Task AccessResourceAsync()
{
await _semaphore.WaitAsync();
try
{
// 同時最多允許 3 個執行緒進入這段代碼
}
finally
{
_semaphore.Release();
}
}
lock
:
lock
是排他鎖,只有一個執行緒可以進入被保護的區域,其他執行緒必須等待。SemaphoreSlim
:
SemaphoreSlim
可以允許多個執行緒同時進入臨界區,但會限制同時進入的最大數量。lock
:
SemaphoreSlim
:
lock
:
lock
是同步的,只能在同步的代碼塊中使用,不能直接應用於 async
/await
非同步程式設計。await
等非同步操作結合使用。SemaphoreSlim
:
SemaphoreSlim
支援非同步操作,可以使用 WaitAsync
進行非同步等待,與 async
/await
非常兼容。lock
:
lock
是一個輕量級的排他鎖,對於簡單的同步操作來說性能開銷很低。SemaphoreSlim
:
SemaphoreSlim
是一個相對較重的同步工具,適合控制併發數量的場景,但開銷比 lock
稍大。如果你需要在非同步操作中限制同時併發數量,推薦使用 SemaphoreSlim
;如果只是單純的排他鎖,同步場景下 lock
足以應對。
SemaphoreSlim
進行同步控制public class Inventory
{
private int _stock = 100;
private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1);
public async Task<bool> PurchaseItemAsync(int quantity)
{
await _semaphore.WaitAsync(); // 等待鎖定
try
{
if (_stock >= quantity)
{
_stock -= quantity;
return true;
}
else
{
return false; // 庫存不足
}
}
finally
{
_semaphore.Release(); // 釋放鎖定
}
}
}
這個範例中,SemaphoreSlim
確保同一時間只有一個非同步操作能夠訪問並修改 _stock
變數,從而避免了競爭條件(Race Condition)。
非同步程式設計中的錯誤處理與同步程式略有不同。非同步操作經常涉及 I/O 操作(例如網絡請求、文件讀寫),這些操作更容易產生異常。如何有效地捕捉並處理這些異常是非同步設計中的一個重要挑戰。
try-catch
處理異常非同步方法中的異常可以像同步方法一樣使用 try-catch
捕捉。不過,為了確保所有異常都能被捕獲,我們需要確保異常發生的地方包含在 await
語句中。
public async Task<string> FetchDataAsync(string url)
{
try
{
using (var client = new HttpClient())
{
var response = await client.GetStringAsync(url);
return response;
}
}
catch (HttpRequestException ex)
{
// 處理網絡請求異常
return $"Request error: {ex.Message}";
}
catch (Exception ex)
{
// 處理其他異常
return $"General error: {ex.Message}";
}
}
這個範例展示了如何捕捉並處理 HttpClient
發出的異常。在非同步環境中,所有的異常處理應該放置在 await
的上下文中,這樣才能確保異常被正確捕捉。
在非同步操作中,尤其是涉及網絡或外部系統時,短暫性故障是常見的。例如,一個網絡請求可能因為臨時網絡中斷而失敗,但過幾秒再試就能成功。為了增加系統的穩定性,我們可以實現一個重試機制,在操作失敗後自動重新嘗試多次。
我們可以設計一個簡單的重試機制,指定最大重試次數,並在每次重試前添加一個延遲時間來防止頻繁重試導致的負擔。
public async Task<string> FetchDataWithRetryAsync(string url, int maxRetryCount = 3, int delayMilliseconds = 2000)
{
int retryCount = 0;
while (retryCount < maxRetryCount)
{
try
{
using (var client = new HttpClient())
{
var response = await client.GetStringAsync(url);
return response;
}
}
catch (HttpRequestException ex)
{
retryCount++;
if (retryCount == maxRetryCount)
{
return $"Request error after {maxRetryCount} retries: {ex.Message}";
}
// 等待一段時間後重試
await Task.Delay(delayMilliseconds);
}
catch (Exception ex)
{
// 非網絡異常,直接終止重試並返回錯誤
return $"General error: {ex.Message}";
}
}
return $"Failed to fetch data after {maxRetryCount} retries.";
}
maxRetryCount
: 指定最大重試次數。在這個範例中,系統最多會嘗試 3 次請求。delayMilliseconds
: 每次重試之間的延遲時間,這可以防止重試過於頻繁,導致伺服器過載或無效重試。catch
區塊:
HttpRequestException
,系統會等待指定的延遲時間後進行重試。這樣的重試機制確保了非同步操作在遇到暫時性失敗時不會立即放棄,而是給予足夠的機會來恢復並完成任務。
你可以將重試機制封裝成一個通用的方法,這樣在每次調用外部 API 時都可以輕鬆使用這個重試機制。這個方法可以接受一個非同步的委派,並根據需要自動進行重試。
public static class RetryHelper
{
public static async Task<T> ExecuteWithRetryAsync<T>(Func<Task<T>> action, int maxRetryCount = 3, int delayMilliseconds = 2000)
{
int retryCount = 0;
while (retryCount < maxRetryCount)
{
try
{
// 嘗試執行傳入的非同步操作
return await action();
}
catch (HttpRequestException ex)
{
retryCount++;
if (retryCount == maxRetryCount)
{
throw new Exception($"Request failed after {maxRetryCount} retries: {ex.Message}");
}
// 在重試之前延遲一段時間
await Task.Delay(delayMilliseconds);
}
catch (Exception)
{
// 非網絡異常,直接拋出不重試
throw;
}
}
throw new Exception("Unreachable code"); // 理論上不應該執行到這裡
}
}
public async Task<string> FetchDataAsync(string url)
{
return await RetryHelper.ExecuteWithRetryAsync(async () =>
{
using (var client = new HttpClient())
{
var response = await client.GetStringAsync(url);
return response;
}
});
}
RetryHelper
類: 包含一個靜態的 ExecuteWithRetryAsync
方法,它接受一個 Func<Task<T>>
作為參數,並包含重試的邏輯。action
參數: 這是需要重試的非同步操作,通過委派的方式傳遞。在範例中,這是 HttpClient.GetStringAsync
的調用。HttpRequestException
時,會等待指定時間(delayMilliseconds
)後重試,最多進行 maxRetryCount
次重試。T
的結果,這樣可以靈活適用於各種非同步操作。你可以用這個通用的方法來包裝任何需要重試的非同步 API 調用。無論是 HttpClient
進行的網絡請求,還是其他外部系統的調用,都可以使用這個 RetryHelper
來增加穩定性。
maxRetryCount
和 delayMilliseconds
替換為配置項,讓它們更加靈活。這樣一來,你在所有需要重試的地方都可以使用這個通用的重試機制,既簡化了程式碼,也增加了應用的穩定性。
在高併發環境下,若不控制非同步任務的數量,系統資源很可能會被耗盡,導致崩潰或性能急劇下降。典型場景是同時發起大量的網絡請求或資料庫操作,這可能會導致伺服器超載。
SemaphoreSlim
進行限流可以使用 SemaphoreSlim
來控制非同步任務的最大併發數量,從而避免系統資源被過度使用。
public class DataFetcher
{
private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(5); // 限制同時進行 5 個請求
public async Task FetchDataFromMultipleSourcesAsync(List<string> urls)
{
var tasks = urls.Select(async url =>
{
await _semaphore.WaitAsync(); // 等待獲取許可證
try
{
await FetchDataAsync(url);
}
finally
{
_semaphore.Release(); // 完成後釋放許可證
}
});
await Task.WhenAll(tasks);
}
private async Task FetchDataAsync(string url)
{
// 模擬網絡請求
await Task.Delay(1000);
Console.WriteLine($"資料從 {url} 取得");
}
}
此範例中,SemaphoreSlim
被用來限制同時進行的非同步操作數量,從而防止系統被過度使用。即使傳入的 URL 很多,這個範例也會保證同一時間最多只有 5 個請求在進行。
非同步死鎖是一個常見但難以診斷的問題,通常發生在非同步方法調用同步方法時。非同步操作涉及上下文切換,如果沒有正確的配置或誤用了 ConfigureAwait(false)
,那麼非同步方法可能會等待某個鎖,而該鎖永遠不會釋放。
ConfigureAwait(false)
在不需要恢復到原來的同步上下文時,應該使用 ConfigureAwait(false)
,避免不必要的上下文切換,從而減少死鎖的風險。
ConfigureAwait(false)
防止死鎖public async Task<string> FetchDataWithoutDeadlockAsync(string url)
{
using (var client = new HttpClient())
{
var response = await client.GetStringAsync(url).ConfigureAwait(false); // 防止死鎖
return response;
}
}
ConfigureAwait(false)
的作用是告訴編譯器在非同步操作完成後不需要切換回調用時的上下文,這可以有效避免某些情況下的死鎖。
有時,我們需要自定義一個非同步鎖來確保多個非同步操作不會同時進行訪問某個資源。這裡是一個自製的 AsyncLock
實現,這比 lock
關鍵字更靈活,且適用於非同步場景。
AsyncLock
public class AsyncLock
{
private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1);
public async Task<Releaser> LockAsync()
{
await _semaphore.WaitAsync();
return new Releaser(_semaphore);
}
public struct Releaser : IDisposable
{
private readonly SemaphoreSlim _semaphore;
public Releaser(SemaphoreSlim semaphore)
{
_semaphore = semaphore;
}
public void Dispose()
{
_semaphore.Release();
}
}
}
// 使用自定義 AsyncLock
public class SharedResource
{
private readonly AsyncLock _asyncLock = new AsyncLock();
public async Task AccessSharedResourceAsync()
{
using (await _asyncLock.LockAsync())
{
// 在此範圍內,保證只有一個非同步操作能夠進行資源訪問
await Task.Delay(1000); // 模擬資源訪問
Console.WriteLine("訪問共享資源");
}
}
}
這個 AsyncLock
實現基於 SemaphoreSlim
,確保在多個非同步操作中,只有一個操作可以同時訪問共享資源,從而避免了競爭條件和死鎖。
非同步程式設計無疑可以顯著提升應用的性能和響應速度,特別是在處理 I/O 密集型任務和高併發請求時。然而,非同步編程也帶來了一些新的挑戰,如資源競爭、異常處理、併發控制和死鎖等問題。
我們學習了 SemaphoreSlim
與 lock
的差異,如何使用 SemaphoreSlim
和自定義的 AsyncLock
來防止資源競爭。同時,我們也探討了 ConfigureAwait(false)
在防止死鎖方面的重要性。
非同步程式設計的挑戰並不可怕,只要掌握了正確的工具與方法,就能夠構建出高效、穩定的應用程式。在明天的篇章,會把非同步的程式與概念用在API的開發設計上,正式介紹非同步API設計概念。