iT邦幫忙

2024 iThome 鐵人賽

DAY 11
1

Day 11: 非同步程式設計的挑戰與解決方案

在前一篇文章中,我們探討了非同步程式設計的基本概念,並介紹了如何使用 TaskTask<T>asyncawait 來設計非同步操作。然而,非同步程式設計並非總是那麼直截了當。在實際開發中,開發者經常會遇到一些挑戰,這些挑戰主要來自於高併發、多執行緒以及非同步操作的特性。今天我們將深入探討這些問題,並提供解決方案。


非同步程式設計的四大挑戰


1. 資源競爭與併發控制

當多個非同步操作同時訪問共享資源時,可能會發生資源競爭,這會導致數據不一致或資料破壞。這種情況經常出現在多個非同步任務同時讀寫相同的變數或對象時。

解決方案:使用鎖機制進行同步

在非同步程式中,我們可以使用 SemaphoreSlimlock 關鍵字來確保資源不會被同時訪問。對於非同步程式設計,SemaphoreSlim 是一個較為推薦的選擇,因為它允許非同步等待並且可以指定同時訪問的最大數量。

SemaphoreSlimlock 的比較

SemaphoreSlimlock 都是用來實現同步和併發控制的工具,但它們的應用場景和機制有所不同。以下是兩者的主要差異:

1. 基本功能

  • lock(Monitor):

    • 用來保護一段代碼,使得在同一時刻只有一個執行緒可以進入這段代碼區塊。
    • 是一個排他鎖(exclusive lock),只能保證一個執行緒進入臨界區。
    • 只能用於單個執行緒的鎖定,不能控制併發的數量。

    範例

    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();
        }
    }
    
    

2. 併發數量控制

  • lock:
    • lock 是排他鎖,只有一個執行緒可以進入被保護的區域,其他執行緒必須等待。
    • 沒有辦法允許多個執行緒同時進行。
  • SemaphoreSlim:
    • SemaphoreSlim 可以允許多個執行緒同時進入臨界區,但會限制同時進入的最大數量。
    • 適合於控制資源訪問,如限制同時訪問的數據庫連接或網絡請求數。

3. 使用場景

  • lock:
    • 當需要確保同一時刻只有一個執行緒可以進入某段代碼區塊,並防止多個執行緒同時執行同一段代碼時使用。
    • 適用於簡單的同步保護,如保護共享變量或對象的訪問。
  • SemaphoreSlim:
    • 當你需要限制同時進入某段代碼的執行緒數量時使用,例如限制同時處理的併發任務數量。
    • 適合資源的併發控制,例如限制同時使用的網絡連接數或併發 I/O 操作。

4. 非同步支持

  • lock:
    • lock 是同步的,只能在同步的代碼塊中使用,不能直接應用於 async/await 非同步程式設計。
    • 無法與 await 等非同步操作結合使用。
  • SemaphoreSlim:
    • SemaphoreSlim 支援非同步操作,可以使用 WaitAsync 進行非同步等待,與 async/await 非常兼容。
    • 非常適合用於非同步場景中的併發控制。

5. 性能與資源開銷

  • 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)。


2. 錯誤處理與異常恢復

非同步程式設計中的錯誤處理與同步程式略有不同。非同步操作經常涉及 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.";
}

重點說明:

  1. maxRetryCount: 指定最大重試次數。在這個範例中,系統最多會嘗試 3 次請求。
  2. delayMilliseconds: 每次重試之間的延遲時間,這可以防止重試過於頻繁,導致伺服器過載或無效重試。
  3. catch 區塊:
    • 如果發生 HttpRequestException,系統會等待指定的延遲時間後進行重試。
    • 如果是其他類型的異常(如系統錯誤),重試機制將不再繼續,而是立即返回錯誤。

應用場景:

  1. 短暫性網絡故障:當網絡環境不穩定時,可以通過重試機制來提高請求成功率。
  2. 外部 API:當使用第三方 API 時,短暫的服務中斷可能會導致請求失敗。使用重試機制可以確保應用程式更加穩定和可靠。

這樣的重試機制確保了非同步操作在遇到暫時性失敗時不會立即放棄,而是給予足夠的機會來恢復並完成任務。

更進階一點 把重試機制做成共用方法:

你可以將重試機制封裝成一個通用的方法,這樣在每次調用外部 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;
        }
    });
}

說明:

  1. RetryHelper: 包含一個靜態的 ExecuteWithRetryAsync 方法,它接受一個 Func<Task<T>> 作為參數,並包含重試的邏輯。
  2. action 參數: 這是需要重試的非同步操作,通過委派的方式傳遞。在範例中,這是 HttpClient.GetStringAsync 的調用。
  3. 重試邏輯:
    • 當捕捉到 HttpRequestException 時,會等待指定時間(delayMilliseconds)後重試,最多進行 maxRetryCount 次重試。
    • 如果是其他異常(如程式內部的系統異常),不會進行重試,直接終止並拋出異常。
  4. 返回值: 該方法會返回指定類型 T 的結果,這樣可以靈活適用於各種非同步操作。

應用場景:

你可以用這個通用的方法來包裝任何需要重試的非同步 API 調用。無論是 HttpClient 進行的網絡請求,還是其他外部系統的調用,都可以使用這個 RetryHelper 來增加穩定性。

還能進一步擴展:

  1. 日志記錄: 你可以在重試的過程中加入日志,記錄每次重試的次數和失敗原因,便於調試。
  2. 可配置重試次數和延遲: 你可以將 maxRetryCountdelayMilliseconds 替換為配置項,讓它們更加靈活。
  3. 加入自定義異常處理: 如果你想針對不同的異常類型使用不同的重試策略,也可以擴展這個方法,根據不同的異常進行不同的處理。

這樣一來,你在所有需要重試的地方都可以使用這個通用的重試機制,既簡化了程式碼,也增加了應用的穩定性。


3. 併發控制與限流

在高併發環境下,若不控制非同步任務的數量,系統資源很可能會被耗盡,導致崩潰或性能急劇下降。典型場景是同時發起大量的網絡請求或資料庫操作,這可能會導致伺服器超載。

解決方案:使用 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 個請求在進行。


4. 非同步死鎖

非同步死鎖是一個常見但難以診斷的問題,通常發生在非同步方法調用同步方法時。非同步操作涉及上下文切換,如果沒有正確的配置或誤用了 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 密集型任務和高併發請求時。然而,非同步編程也帶來了一些新的挑戰,如資源競爭、異常處理、併發控制和死鎖等問題。

我們學習了 SemaphoreSlimlock 的差異,如何使用 SemaphoreSlim 和自定義的 AsyncLock 來防止資源競爭。同時,我們也探討了 ConfigureAwait(false) 在防止死鎖方面的重要性。

非同步程式設計的挑戰並不可怕,只要掌握了正確的工具與方法,就能夠構建出高效、穩定的應用程式。在明天的篇章,會把非同步的程式與概念用在API的開發設計上,正式介紹非同步API設計概念。


上一篇
Day 10: C# 非同步程式設計
下一篇
Day 12: 非同步 API 設計與風格
系列文
使用 C# 從零開始玩轉 Web API,從基礎到微服務與雲端部署的全面探索22
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言