併發程式設計-11.取消非同步工作

F(x)_King發表於2024-03-30

取消託管執行緒

取消 .NET 中的非同步工作基於取消令牌的使用。 令牌是一個簡單的物件,用於指示已向另一個執行緒發出取消請求。 CancellationTokenSource 物件管理這些請求幷包含一個令牌。 如果要使用同一觸發器取消多個操作,則應向所有要取消的執行緒提供相同的令牌。

CancellationTokenSource 例項具有 Token 屬性,用於訪問 CancellationToken 屬性並將其傳遞給一個或多個非同步操作。 取消請求只能從 CancellationTokenSource 物件發出。 提供給其他操作的 CancellationToken 屬性接收取消訊號,但無法啟動取消。

CancellationTokenSource 實現 IDisposable 介面,因此在釋放託管資源時請務必呼叫 Dispose。 如果對您的工作流程實用,則最好使用 using 語句或塊來自動處理令牌源。

重要的是要理解取消不是在偵聽程式碼上強制執行的。 接收取消請求的非同步程式碼必須確定它當前是否可以取消其工作。 它可能決定立即取消、完成一些中間任務後取消,或者完成其工作並忽略該請求。 例程忽略取消請求可能有正當理由。 工作可能已接近完成,或者在當前狀態下取消將導致某些資料損壞。 取消的決定必須由請求者和收聽者共同做出。

讓我們看一個示例,理解如何協作取消 ThreadPool 執行緒上的後臺執行緒正在處理的某些工作:

  1. 在 Visual Studio 中,建立一個新的 .NET 6 控制檯應用程式,名為 CancelThreadsConsoleApp.
  2. 新增一個名為 ManagedThreadsExample 的新類。
  3. ManagedThreadsExample 類中建立一個名為 ProcessText 的方法:
public static void ProcessText(object? cancelToken)
{
    var token = cancelToken as CancellationToken?;
    string text = "";
    for (int x = 0; x < 75000; x++)
    {
        if (token != null && token.Value.IsCancellationRequested)
        {
            Console.WriteLine($"Cancellation request
            received. String value: {text}");
            break;
        }
        text += x + " ";
        Thread.Sleep(500);
    }
}

此方法將迭代器變數 x 的值附加到 text 的字串變數,直到收到取消請求。 有一個 Thread.Sleep(500) 語句允許呼叫方法有一段時間來取消操作。
4. 接下來,在 Program.cs 中建立一個名為 CancelThread 的方法:

private static void CancelThread()
{
    using CancellationTokenSource tokenSource = new();
    Console.WriteLine("Starting operation.");
    ThreadPool.QueueUserWorkItem(newWaitCallback(ManagedThreadsExample.ProcessText), 
                                 tokenSource.Token);
    Thread.Sleep(5000);
    Console.WriteLine("Requesting cancellation.");
    tokenSource.Cancel();
    Console.WriteLine("Cancellation requested.");
}

此方法呼叫 ThreadPool.QueueUserWorkItemProcessText 方法在 ThreadPool 執行緒中排隊。 該方法還從 tokenSource.Token 接收取消令牌。 等待五秒後,呼叫 tokenSource.CancelProcessText 將收到取消請求。
請注意,tokenSource 是在 using 語句中建立的。 這確保了當它超出範圍時將被正確處置。

  1. Program.cs 中的 Main 方法中新增對 CancelThread 的呼叫:
static void Main(string[] args)
{
    CancelThread();
    Console.ReadKey();
}
  1. 最後,執行應用程式並觀察控制檯輸出:

圖 11.1 – 執行 CancelThreadsConsoleApp 專案

image

取消並行工作

在本節中,我們將介紹一些取消並行操作的示例。 有一些操作屬於這個領域。 有屬於 System.Threading.Tasks.Parallel 類一部分的靜態並行操作,並且有 PLINQ 操作。 這兩種型別都使用 CancellationToken 屬性,正如我們在上一節的託管執行緒示例中所使用的那樣。 但是,處理取消請求略有不同。 讓我們看一個例子來理解其中的差異。

取消並行迴圈

在本節中,我們將建立一個示例來說明如何取消 Parallel.For 迴圈。 Parallel.ForEach 方法使用相同的取消方法。 執行以下步驟:

  1. 開啟上一節中的 CancelThreadsConsoleApp 專案。
  2. ManagedThreadsExample 類中,建立一個具有以下實現的新 ProcessTextParallel 方法:
public static void ProcessTextParallel(object? cancelToken)
{
    var token = cancelToken as CancellationToken?;
    if (token == null) return;
    string text = "";
    ParallelOptions options = new()
    {
        CancellationToken = token.Value,
        MaxDegreeOfParallelism = Environment.ProcessorCount
    };
    try
    {
        Parallel.For(0, 75000, options, (x) =>
        {
            text += x + " ";
            Thread.Sleep(500);
        });
    }
    catch (OperationCanceledException e)
    {
        Console.WriteLine($"Text value: {text}.{Environment.NewLine} Exceptionencountered: {e.Message}");
    }
}

本質上,前面的程式碼與我們上一個示例中的 ProcessText 方法執行相同的操作。 它將一個數值附加到文字變數,直到請求取消為止。 讓我們來看看差異:

  • 首先,我們將 token.Value 設定為 ParallelOptions 物件的 CancellationToken 屬性。 這些選項作為第三個引數傳遞給 Parallel.For 方法。
  • 第二個主要區別是我們透過捕獲 OperationCanceledException 型別來處理取消請求。 當 Program.cs 中的其他程式碼請求取消時,將丟擲此異常型別。
  1. 接下來,將名為 CancelParallelFor 的方法新增到 Program.cs
private static void CancelParallelFor()
{
    using CancellationTokenSource tokenSource = new();
    Console.WriteLine("Press a key to start, then press 'x' to send cancellation.");
    Console.ReadKey();
    Task.Run(() =>
    {
        if (Console.ReadKey().KeyChar == 'x')
        	tokenSource.Cancel();
        Console.WriteLine();
        Console.WriteLine("press a key");
    });
    ManagedThreadsExample.ProcessTextParallel(tokenSource.Token);
}

在此方法中,指示使用者按某個鍵開始操作,並在準備取消操作時按 X 鍵。 處理從控制檯接收 按鍵 x 併傳送 Cancel 請求的程式碼在另一個執行緒上執行,以便保持當前執行緒可以自由地呼叫 ProcessTextParallel
4. 最後,更新 Main 方法以呼叫 CancelParallelFor 並註釋掉對 CancelThread 的呼叫:

static void Main(string[] args)
{
    //CancelThread();
    CancelParallelFor();
    Console.ReadKey();
}
  1. 現在執行該專案。 按照提示取消 Parallel.For 迴圈,並檢查輸出:

圖 11.2 – 從控制檯取消 Parallel.For 迴圈

image

請注意這些數字根本沒有按順序排列。 在本例中,Parallel.For 操作似乎使用了兩個不同的執行緒。 第一個執行緒從 0 開始,而第二個執行緒則對從 37500 開始的整數進行操作。這是提供給方法引數的最大值 75000 的中點。

取消 PLINQ 查詢

取消 PLINQ 查詢也可以透過捕獲 OperationCanceledException 型別來實現。 但是,您可以在查詢中呼叫 WithCancellation,而不是使用與並行迴圈一起使用的 ParallelOptions 物件。

要理解如何取消 PLINQ 查詢,讓我們看一個示例:

  1. 透過向 ManagedThreadsExample 類新增名為 ProcessNumsPlinq 的方法來啟動此示例:
public static void ProcessNumsPlinq(object?
                                    cancelToken)
{
    int[] input = Enumerable.Range(1, 25000000).ToArray();
    var token = cancelToken as CancellationToken?;
    if (token == null) return;
    int[]? result = null;
    try
    {
        result =   (from value in input.AsParallel()
                    .WithCancellation(token.Value)
                    where value % 7 == 0
                    orderby value
                    select value).ToArray();
    }
    catch (OperationCanceledException e)
    {
        Console.WriteLine($"Exception encountered:   {e.Message}");
    }
}

此方法建立一個包含 2500 萬個整數的陣列,並使用 PLINQ 查詢來確定其中哪些可被 7 整除。 token.Value 將傳遞到查詢中的 WithCancellation 操作。 當取消請求引發異常時,異常詳細資訊將寫入控制檯。

  1. 接下來,將名為 CancelPlinq 的方法新增到 Program.cs
private static void CancelPlinq()
{
    using CancellationTokenSource tokenSource = new();
    Console.WriteLine("Press a key to start.");
    Console.ReadKey();
    Task.Run(() =>
             {
                 Thread.Sleep(100);
                 Console.WriteLine("Requesting cancel.");
                 tokenSource.Cancel();
                 Console.WriteLine("Cancel requested.");
             });
    ManagedThreadsExample.ProcessNumsPlinq (tokenSource.Token);
}

這次,取消將在 100 毫秒後自動觸發。

  1. 更新 Main 方法以呼叫 CancelPlinq,並執行應用程式:

圖 11.3 – 在控制檯應用程式中取消 PLINQ 操作

image

與前面的示例不同,沒有要檢查的查詢輸出。 您無法從 PLINQ 查詢獲取部分輸出。 結果變數將為空。

發現執行緒取消的模式

有多種方法可以偵聽來自執行緒或任務的取消請求。 到目前為止,我們已經看到了透過處理 OperationCanceledException 型別或檢查 IsCancellationRequested 的值來管理這些請求的示例。 檢查 IsCancellationRequested 的模式(通常在迴圈內)稱為輪詢。 首先,我們將看到這種模式的另一個例子。 我們要檢查的第二種模式是透過註冊回撥方法來接收通知。 我們將在本節中介紹的最後一種模式是使用 ManualResetEvent 或 ManualResetEventSlim 監聽帶有等待控制代碼的取消請求。

透過輪詢取消

在本節中,我們將建立另一個使用輪詢取消後臺任務的示例。 前面的輪詢示例在 ThreadPool 執行緒的後臺執行緒中執行。 此示例還將啟動 ThreadPool 執行緒,但它將利用 Task.Run 來啟動後臺執行緒。 我們將建立並處理一百萬個 System.Drawing.Point 物件,查詢 Point.X 值小於 50 的物件。使用者可以選擇按 X 鍵取消處理:

  1. 首先建立一個名為 CancellationPatterns 的新 .NET 控制檯應用程式專案
  2. 在專案中新增一個名為 PollingExample 的新類
  3. 將名為GeneratePoints 的私有靜態方法新增到PollingExample。 這將生成我們想要的帶有隨機 X 值的 Point 物件的數量:
private static List<Point> GeneratePoints(int count)
{
    var rand = new Random();
    var points = new List<Point>();
    for (int i = 0; i <= count; i++)
    {
        points.Add(new Point(rand.Next(1, count * 2),   100));
    }
    return points;
}
  1. 不要忘記新增一條using語句來使用Point型別:
using System.Drawing;
  1. 接下來,向 PollingExample 新增一個名為 FindSmallXValues 的私有靜態方法。該方法迴圈遍歷點列表並輸出 X 值小於 50 的點。每次迴圈時,它都會檢查令牌是否取消並退出 迴圈發生時:
private static void FindSmallXValues(List<Point> points, CancellationToken token)
{
    foreach (Point point in points)
    {
        if (point.X < 50)
        {
            Console.WriteLine($"Point with small X coordinate found. Value: {point.X}");
        }
        if (token.IsCancellationRequested)
        {
            break;
        }
        Thread.SpinWait(5000);
    }
}

在迴圈末尾新增 Thread.SpinWait 語句,為使用者提供一些時間來取消操作。

  1. PollingExample 新增一個名為 CancelWithPolling 的公共靜態方法:
public static void CancelWithPolling()
{
    using CancellationTokenSource tokenSource = new();
    Task.Run(() => FindSmallXValues(GeneratePoints (1000000), tokenSource.Token), tokenSource .Token);
    if (Console.ReadKey(true).KeyChar == 'x')
    {
        tokenSource.Cancel();
        Console.WriteLine("Press a key to quit");
    }
}

上述方法建立 CancellationTokenSource 物件並將其傳遞給 FindSmallXValuesTask.Run。 如果您想取消任務,您可以呼叫 token.ThrowIfCancellationRequested,而不是在 IsCancellationRequested 變為 true 時跳出迴圈。 這會在任務中引發異常。 然後,CancelWithPolling 方法需要在 Task.Run 呼叫周圍有一個 try/catch 塊。 無論如何,對所有多執行緒程式碼使用異常處理是最佳實踐。 在這種情況下,您將有兩個異常處理程式:一個用於處理OperationCanceledException,第二個用於處理AggregateException

此外,CancelWithPolling 方法具有確定使用者何時按 X 鍵取消操作的程式碼。

  1. 最後,開啟 Program.cs 並新增一些程式碼來執行示例:
using CancellationPatterns;
Console.WriteLine("Hello, World! Press a key to start,then press 'x' to cancel.");
Console.ReadKey();
PollingExample.CancelWithPolling();
Console.ReadKey();
  1. 現在執行應用程式,並檢查輸出:

圖 11.4 – 執行取消輪詢示例

image

根據您在取消之前等待的時間,該過程可能會找到不同數量的點。

透過回撥取消

.NET 中的某些程式碼支援註冊回撥方法以取消處理。 System.Net.WebClient 是支援透過回撥取消的一類。 在此示例中,我們將使用 WebClient 開始下載檔案。 三秒後下載將被取消。

1.開啟CancellationPatterns專案並新增一個名為CallbackExample的新類

  1. 首先新增名為 GetDownloadFileName 的方法來構建下載檔案的路徑。 我們將其下載到執行程式集的同一資料夾中:
private static string GetDownloadFileName()
{
    string path = System.Reflection.Assembly.GetAssembly(typeof(CallbackExample)).Location;
    string folder = Path.GetDirectoryName(path);
    return Path.Combine(folder, "audio.flac");
}
  1. 接下來,新增一個名為 DownloadAudioAsync 的非同步方法。 該方法將處理檔案下載和取消。 有多個異常處理程式可以捕獲 DownloadFileTaskAsync 方法可能引發的任何型別的異常。 反過來,它們都會丟擲一個由父方法處理的 OperationCanceledException 型別:
private static async Task DownloadAudioAsync
    (CancellationToken token)
{
    const string url = "https://archive.org/download/ lp_the-odyssey_homer-anthony-quayle/disc1/ lp_the-odyssey_homer-anthony-quayle _disc1side1.flac";
    using WebClient webClient = new();
    token.Register(webClient.CancelAsync);
    try
    {
        await webClient.DownloadFileTaskAsync(url,  GetDownloadFileName());
    }
    catch (WebException we)
    {
        if (we.Status == WebExceptionStatus.RequestCanceled)
            throw new OperationCanceledException();
    }
    catch (AggregateException ae)
    {
        foreach (Exception ex in ae.InnerExceptions)
        {
            if (ex is WebException exWeb &&
                exWeb.Status == WebExceptionStatus
                .RequestCanceled)
                throw new OperationCanceled
                Exception();
        }
    }
    catch (TaskCanceledException)
    {
        throw new OperationCanceledException();
    }
}

4.為WebClient型別新增using語句:

using System.Net;
  1. 現在新增一個名為 CancelWithCallback 的公共非同步方法。 此方法呼叫 DownloadAudioAsync,等待三秒鐘,然後對 CancellationTokenSource 物件呼叫 Cancel。 在 try 塊中等待任務意味著我們可以直接處理 OperationCanceledException 型別。 如果您使用task.Wait,則必須捕獲AggregateException 並檢查InnerException 物件之一是否是OperationCanceledException 型別:
public static async Task CancelWithCallback()
{
    using CancellationTokenSource tokenSource = new();
    Console.WriteLine("Starting download");
    var task = DownloadAudioAsync(tokenSource.Token);
    tokenSource.Token.WaitHandle.WaitOne (TimeSpan.FromSeconds(3));
    tokenSource.Cancel();
    try
    {
        await task;
    }
    catch (OperationCanceledException ex)
    {
        Console.WriteLine($"Download canceled. Exception: {ex.Message}");
    }
}

在此步驟中,可能需要調整 tokenSource.Token.WaitHandle.WaitOne 呼叫中的秒數。 時間可能會根據計算機的下載速度和處理速度而有所不同。 如果您在控制檯輸出中沒有看到“下載已取消”訊息,請嘗試調整該值。

6.最後,註釋掉Program.cs中現有的程式碼,新增以下程式碼來呼叫CallbackExample類:

using CancellationPatterns;
await CallbackExample.CancelWithCallback();
Console.ReadKey();
  1. 現在執行應用程式,並檢查輸出:

圖 11.5 – 使用 CancellationToken 和回撥取消下載

image

您可以透過檢視執行程式集的資料夾來驗證下載是否已開始且未完成。 您應該會看到一個名為 audio.flac 的檔案,檔案大小為 0 KB。 您可以安全地刪除此檔案,因為如果您嘗試再次下載它,可能會導致異常。

使用等待控制代碼取消

在本節中,我們將使用 ManualResetEventSlim 取消不會響應使用者輸入的後臺任務。 該物件具有設定和重置事件來啟動/恢復或暫停操作。 當操作尚未開始或已暫停時,呼叫 ManualResetEventSlim.Wait 將導致操作在該語句上暫停,直到另一個執行緒呼叫 Set 來開始或恢復處理。

此示例將迭代超過 100,000 個整數,並將每個偶數輸出到控制檯。 藉助 ManualResetEventSlim 物件和 CancellationToken,可以啟動、暫停、恢復或取消此過程。 讓我們在我們的專案中嘗試這個例子:

  1. 首先將 WaitHandleExample 類新增到 CancellationPatterns 專案中。
  2. 在新類中新增一個名為resetEvent的私有變數:
private static ManualResetEventSlim resetEvent = new(false);
  1. 將名為 ProcessNumbers 的私有靜態方法新增到類中。 此方法迭代數字,並且僅在 resetEvent.Wait 允許其繼續時才繼續處理:
private static void ProcessNumbers(IEnumerable<int>   numbers, CancellationToken token)
{
    foreach (var number in numbers)
    {
        if (token.IsCancellationRequested)
        {
            Console.WriteLine("Cancel requested");
            token.ThrowIfCancellationRequested();
        }
        try
        {
            resetEvent.Wait(token);
        }
        catch (OperationCanceledException)
        {
            Console.WriteLine("Operation canceled.");
            break;
        }
        if (number % 2 == 0)
            Console.WriteLine($"Found even number:  {number}");  Thread.Sleep(500);
    }
}
  1. 接下來,將名為 CancelWithResetEvent 的公共靜態非同步方法新增到類中。 此方法建立要處理的數字列表,在 Task.Run 呼叫中呼叫 ProcessNumbers,並使用 while 迴圈偵聽使用者輸入:
public static async Task CancelWithResetEvent()
{
    using CancellationTokenSource tokenSource = new();
    var numbers = Enumerable.Range(0, 100000);
    _ = Task.Run(() => ProcessNumbers(numbers,
                                      tokenSource.Token), tokenSource.Token);
    Console.WriteLine("Use x to cancel, p to pause, or s to start or resume,");
    Console.WriteLine("Use any other key to quit the   program.");
    bool running = true;
    while (running)
    {
        char key = Console.ReadKey(true).KeyChar;
        switch (key)
        {
            case 'x':
                tokenSource.Cancel();
                break;
            case 'p':
                resetEvent.Reset();
                break;
            case 's':
                resetEvent.Set();
                break;
            default:
                running = false;
                break;
        }
        await Task.Delay(100);
    }
}
  1. 最後,更新 Program.cs 以包含以下程式碼:
using CancellationPatterns;
await WaitHandleExample.CancelWithResetEvent();
Console.ReadKey();
  1. 執行程式進行測試。 按照控制檯提示啟動、暫停、恢復和取消該程序:

圖 11.6 – 在控制檯中測試 CancelWithResetEvent 方法

image

您應該在控制檯輸出中看到在取消操作之前已找到多個事件號。 完成的處理量可能會因計算機的處理器而異。

處理多個取消來源

後臺任務可以利用 CancellationTokenSource 從儘可能多的源接收取消請求。 靜態 CancellationTokenSource.CreateLinkedTokenSource 方法接受 CancellationToken 物件陣列來建立一個新的 CancellationTokenSource 物件,如果任何源令牌收到取消請求,該物件將通知我們取消。

讓我們看一個如何在 CancellationPatterns 專案中實現這一點的簡單示例:

  1. 首先,開啟 PollingExample 類。 我們將建立接受 CancellationTokenSource 引數的 CancelWithPolling 方法的過載。CancelWithPolling 的兩個過載如下所示:
public static void CancelWithPolling()
{
    using CancellationTokenSource tokenSource = new();
    CancelWithPolling(tokenSource);
}
public static void CancelWithPolling
    (CancellationTokenSource tokenSource)
{
    Task.Run(() => FindSmallXValues(GeneratePoints(1000000), tokenSource.Token),  tokenSource.Token);
    if (Console.ReadKey(true).KeyChar == 'x')
    {
        tokenSource.Cancel();
        Console.WriteLine("Press a key to quit");
    }
}
  1. 接下來,新增一個名為 MultipleTokensExample 的新類。
  2. MultipleTokensExample 類中建立名為 CancelWithMultipleTokens 的方法。 該方法接受parentToken作為引數,建立自己的tokenSource,然後將它們組合成一個combinedSource物件傳遞給CancelWithPolling方法:
public static void CancelWithMultipleTokens
    (CancellationToken parentToken)
{
    using CancellationTokenSource tokenSource = new();
    using CancellationTokenSource combinedSource =
        CancellationTokenSource.CreateLinked
        TokenSource(parentToken, tokenSource   .Token);
    PollingExample.CancelWithPolling(combinedSource);
    Thread.Sleep(1000);
    tokenSource.Cancel();
}

我們正在呼叫 tokenSource.Cancel,但如果在三個 CancellationTokenSource 物件中的任何一個上呼叫 Cancel,則 CancellWithPolling 中的處理將收到取消請求。

  1. Program.cs 中新增一些程式碼來呼叫 CancelWithMultipleTokens
using CancellationPatterns;
CancellationTokenSource tokenSource = new();
MultipleTokensExample.CancelWithMultipleTokens
(tokenSource.Token);
Console.ReadKey();
  1. 執行該程式,您應該看到類似於您在發現執行緒取消模式部分的透過輪詢取消小節中看到的輸出。

嘗試更改用於呼叫取消的 CancellationTokenSource 物件。 無論取消請求的來源如何,輸出都應保持不變。
如果您在任務中引發異常,後臺任務也將結束。 這與結束後臺處理具有類似的效果,但 TaskStatus 將顯示為Faulted(故障)而不是Canceled(取消)。

相關文章