顯示具有 Task 標籤的文章。 顯示所有文章
顯示具有 Task 標籤的文章。 顯示所有文章

2022年7月27日 星期三

C# 非同步 : Task.Run 要在哪裡傳入與檢查 CancellationToken

Task.Run 要在哪裡傳入與檢查 CancellationToken

這篇文章將會說明,對於一開始學習 Task.Run 方法與想要透過 CancellationToken 取消權杖來取消工作的開發者,將會有個迷失點,這個問題在於,當查看 Task.Run 方法 文件的時候,將會看到 Run(Action, CancellationToken) 這個函式簽章,而這個方法將透過執行緒集區佇列內進行排隊,取得一個可用執行緒,並且將 Action 這個委派方法交由這個執行緒來執行,並傳回代表該工作的 Task 物件;這個方法的第二個參數,則是一個 [CancellationToken] 型別的參數,代表一個取消權杖可用來在工作尚未開始之前取消工作。

若沒有看到上面最後一段說明,絕大部分的開發者都會以為,只要在呼叫 Task.Run 方法的時候,將取消權杖傳入進去,在任何時候就可以透過產生該取消權證的 CancellationTokenSource 這個型別的物件,發出呼叫 CancellationTokenSource.Cancel 方法 , 送出與傳遞取消要求,此時,這個 Task.Run 這個非同步工作,就會取消執行了。

從上面的說明可以看出不是這樣的運作的,當呼叫 Task.Run 方法所傳入的取消權杖,僅會在要呼叫 Task.Run 方法前,這個取消權杖就已將發出取消請求了,所以,一旦呼叫了 Task.Run 的方法,就不會去取得一個非同步的執行緒,並且開始執行所指定的委派方法,如此,可以節省系統資源的使用。

現在來測試這樣的說明,首先,建立一個 主控台應用程式,其程式碼如下:

static void Main(string[] args)
{
    CancellationTokenSource cts = new CancellationTokenSource();
    CancellationToken token = cts.Token;
    cts.Cancel();
    Console.WriteLine($"1 建立非同步工作");
    var task = Task.Run(() =>
    {
        Console.WriteLine($"  2 非同步工作已經開始執行");
        Thread.Sleep(3000);
        Console.WriteLine($"  3 非同步工作已經結束執行");
    }, token);
    Console.WriteLine($"4 主執行緒休息 2 秒");
    Thread.Sleep(2000);
    Console.WriteLine($"5 主執行緒即將結束執行");
    Console.WriteLine("Press any key for continuing...");
    Console.ReadKey();
}

這是一個非常經典的取消權杖的程式設計準則,在程式要開始執行之前,首先建立一個 CancellationTokenSource 物件,這個物件可用於取得取消權杖與可以發出取消請求的方法,接著,將取消權杖透過 CancellationToken token = cts.Token 敘述來取得,有了取消權杖,之後面可以將取消權杖傳遞到其他的非同步方法或者非同工作內。

在這個時候,將會呼叫 cts.Cancel() 方法,對於該取消權杖發出取消工作的請求,此時,該取消權杖處於已經取消的狀態下,接著,透過 Task.Run 方法建立一個非同步工作,這個非同步工作所指定的委派方法將會休息三秒鐘後,就會結束執行。

同時,在主執行緒端,一旦非同步工作建立與開始執行後,將會休息 2 秒鐘

現在,執行這個專案,將會得到底下的執行結果

1 建立非同步工作
4 主執行緒休息 2 秒
5 主執行緒即將結束執行
Press any key for continuing...

從執行結果的輸出內容可以看出,這個非同步工作物件所指定的委派方法,是沒有被執行的,也就是說,雖然呼叫了 Task.Run 方法,系統並沒有從執行緒集區內取得一個執行緒來執行指定的委派方法程式碼。

現在將測試程式碼修改成為如下:

static void Main(string[] args)
{
    CancellationTokenSource cts = new CancellationTokenSource();
    CancellationToken token = cts.Token;
    Console.WriteLine($"1 建立非同步工作");
    var task = Task.Run(() =>
    {
        Console.WriteLine($"  2 非同步工作已經開始執行");
        Thread.Sleep(3000);
        Console.WriteLine($"  3 非同步工作已經結束執行");
    }, token);
    Console.WriteLine($"4 主執行緒休息 1 秒");
    cts.CancelAfter(2000);
    Thread.Sleep(1000);
    Console.WriteLine($"5 主執行緒即將結束執行");
    Console.WriteLine("Press any key for continuing...");
    Console.ReadKey();
}

前半段幾乎沒有變動,除了將一開始就發出取消權杖的呼叫,將其程式碼移除了。

在非同步工作建立之後,使用了 cts.CancelAfter(2000); 敘述,將會於 2 秒鐘之後,對取消權杖發出取消的請求通知,這個敘述執行完後,並不會暫停2秒鐘,而會繼續往下執行,現在,在主執行緒內,將會休息一秒鐘後,準備要結束執行。

底下是執行這個專案的螢幕輸出結果

1 建立非同步工作
4 主執行緒休息 1 秒
  2 非同步工作已經開始執行
5 主執行緒即將結束執行
Press any key for continuing...
  3 非同步工作已經結束執行

從執行結果可以看出,雖然在呼叫 Task.Run 方法的時候,有傳入取消權杖,不過,請求取消的動作,是在呼叫 Task.Run 之後,因此,在 Task.Run 所傳入的取消權杖是沒有效用的,結論就是,想要建立一個非同步工作,讓這個非同步工作具有取消權杖的效果,建議如下:

  • 在 Task.Run 方法內,傳入取消權杖
  • 在 Task.Run 的委派方法內,也要輪詢檢查取消權杖是否已經發出取消請求

接著,將測試程式碼修改如下:

static void Main(string[] args)
{
    CancellationTokenSource cts = new CancellationTokenSource();
    CancellationToken token = cts.Token;
    Console.WriteLine($"1 建立非同步工作");
    var task = Task.Run(() =>
    {
        Console.WriteLine($"  2 非同步工作已經開始執行");
        token.ThrowIfCancellationRequested();
        Thread.Sleep(3000);
        token.ThrowIfCancellationRequested();
        Console.WriteLine($"  3 非同步工作已經結束執行");
    }, token);
    Console.WriteLine($"4 主執行緒休息 1 秒");
    cts.CancelAfter(2000);
    Thread.Sleep(1000);
    Console.WriteLine($"5 主執行緒即將結束執行");
    Console.WriteLine("Press any key for continuing...");
    Console.ReadKey();
}

在這裡將會在非同步工作內的委派方法中,使用輪詢 Polling 方式,檢查取消權杖的狀態,這裡使用了 token.ThrowIfCancellationRequested(); 敘述,一旦有取消請求發出,則將會拋出例外異常,結束這個非同步工作的執行。

底下是執行結果

1 建立非同步工作
4 主執行緒休息 1 秒
  2 非同步工作已經開始執行
5 主執行緒即將結束執行
Press any key for continuing...

從執行結果可以看出,當發出取消請求之後,這個取消權杖被設定為取消狀態,而這個非同的工作也就拋出例外異常,而中止了。

最後,再來將程式碼改成底下內容:

static async Task Main(string[] args)
{
    CancellationTokenSource cts = new CancellationTokenSource();
    CancellationToken token = cts.Token;
    Console.WriteLine($"1 建立非同步工作 {DateTime.Now.TimeOfDay}");
    var task = Task.Run(async () =>
    {
        Console.WriteLine($"  2 非同步工作已經開始執行 {DateTime.Now.TimeOfDay}");
        token.ThrowIfCancellationRequested();
        await Task.Delay(3000, token);
        token.ThrowIfCancellationRequested();
        Console.WriteLine($"  3 非同步工作已經結束執行 {DateTime.Now.TimeOfDay}");
    }, token);
    Console.WriteLine($"4 主執行緒休息 1 秒 {DateTime.Now.TimeOfDay}");
    cts.CancelAfter(2000);
    try
    {
        await task;
    }
    catch (Exception ex)
    {
        Console.WriteLine($"5 非同步工作取消了 {ex.GetType().ToString()} : {DateTime.Now.TimeOfDay}");
    }
    Console.WriteLine($"6 主執行緒即將結束執行 {DateTime.Now.TimeOfDay}");
    Console.WriteLine("Press any key for continuing...");
    Console.ReadKey();
}

在上面的程式碼,對 Console.WriteLine 方法內,將會加入當時呼叫的時間點內容,這裡是要來判斷在非同步工作內委派方法,在等待 3 秒鐘的時候,是否不會在 3 秒內的任何時間點,若接收到取消通知,都會立即中止等候,直接結束這個非同步工作。

對於在非同步工作內的委派方法,原先使用 Thread.Sleep(3000) 的封鎖等待的呼叫,將會改成 await Task.Delay(3000, token); 非封鎖式的呼叫,並且在 Delay 方法的後面,傳入取消權杖,要 Task.Delay 這個方法要能夠關注取消權杖是否發出請求。

在主執行緒端,也將休息2秒的敘述,改成 await task; 這個敘述,並且將其包裹在 try catch 敘述內,因為,一旦這個非同步工作有取消動作產生,將會拋出例外異常,所以,將會在此捕捉這個例外異常,查看非同步工作內的委派方法,何時觸發了取消動作。

現在,可以來執行這個專案,得到底下的執行結果

1 建立非同步工作 16:22:37.3960409
4 主執行緒休息 1 秒 16:22:37.4027808
  2 非同步工作已經開始執行 16:22:37.4033487
5 非同步工作取消了 System.Threading.Tasks.TaskCanceledException : 16:22:39.4463615
6 主執行緒即將結束執行 16:22:39.4464650
Press any key for continuing...

從執行結果可以看出,非同步工作內的委派方法於 37 秒的時候開始執行,接著要休息 3 秒鐘,因此,這個非同步工作應該會於 40 秒的時候結束執行。

不過,在主執行緒端,將會於 2 秒鐘之後發出取消請求,因此,這個非同步工作將會於 39 秒的時候拋出例外異常,這裡看到的例外異常是 TaskCanceledException ,這代表表示用來傳達工作取消的例外狀況;從捕捉到例外異常的時間點,也正好是 39 秒,符合這個程式的設計。

結論

當進行非同步工作設計的時候,若有取消請求的設計需求,還是需要在非同步工作內的委派方法裡面,輪詢取消權杖,適時做出處置,這點功夫是不能缺少的。 










2022年7月25日 星期一

C# 非同步 : 在進行 WPF / Windows Forms / Xamarin.Forms 專案開發,可以回到 UI 執行緒來更新 UI 控制項的相同作法

 

在進行 WPF / Windows Forms / Xamarin.Forms 專案開發,可以回到 UI 執行緒來更新 UI 控制項的相同作法

在以往,若再不同 GUI 開發框架下,想要在非同步的多執行緒程式碼下,更新 UI 控制項的屬性,若當前的執行緒不是所謂的 主執行緒 或者 稱之為 UI 執行緒,將會得到 System.InvalidOperationException: '呼叫執行緒無法存取此物件,因為此物件屬於另一個執行緒。' 這樣的例外異常訊息,所以,大家第一時間想到的就是每個開發框架都會有提供這樣相對應的程式碼,可以指定一段委派方法,讓這些程式碼在 UI 執行緒下來執行,而不是在多執行緒下的某個執行緒來執行,例如:

  • Windows Forms

    這裡將會再多執行緒程式碼內,使用 Control.Invoke 方法 來做到,這個方法可以傳入一個委派方法,而該委派方法的程式碼將會在 主執行緒 或者 UI 執行緒 下來執行

  • WPF Windows Presentation Foundation

    在這個開發框架下,將會使用 Dispatcher.Invoke 方法 來做到,同樣的,這個方法可以傳入一個委派方法,而該委派方法的程式碼將會在 主執行緒 或者 UI 執行緒 下來執行

而是否有個簡單與通用的設計做法,不論在哪個開發框架下,都可以使用相同的程式設計代碼,指定一個委派方法,讓這個委派方法可以在 UI 執行緒下來執行。

建立一個 WPF 的專案

為了要說明上面描述的內容,首先,建立一個 WPF 專案

在這個 WPF 專案內找到 [MainWindow.xaml] 檔案,打開這個檔案,使用底下的 XAML 來替換

<Window x:Class="WpfApp2.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WpfApp2"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Grid>
        <TextBlock x:Name="textBlock" Text="Start"
                   FontSize="30" />
        <Button x:Name="btn" Content="OK" Width="200" Height="70"
                Click="btn_Click"
                />
    </Grid>
</Window>

在這個 XAML 檔案內,將會宣告兩個 UI 控制項,一個是 [TextBlock] ,一個是 [Button] 。

在這裡的設計情境將會是,當使用者按下這個 OK 按鈕之後,便會開始執行一個非同步的作業,在這個非同步的多執行緒程式碼中,將會建立一個迴圈,反覆執行 10 次,每次的迴圈,將會把當前的迴圈索引值,指定到 [textBlock] 這個 UI 控制項內,如此,便可以從螢幕上看到當前的迴圈索引值是多少。

在這裡將會分別採用 ThreadPool 執行緒集區 與 Task 工作來實作這個非同步之多執行緒作業,並且比較兩者的差異。

使用工作物件 Task.Run 來建立一個非同步作業

切換到 [MainWindow.xaml] 的 Code Behind 程式碼編輯視窗內,將會看到下面的程式碼

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Navigation;
using System.Windows.Shapes;

namespace WpfApp2
{
    /// <summary>
    /// Interaction logic for MainWindow.xaml
    /// </summary>
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
        }

        private void btn_Click(object sender, RoutedEventArgs e)
        {
        }
    }
}

現在,在 btn_Click 按鈕事件常式內,使用 Task.Run 來建立一個非同步工作物件,做到上面所提到的一個迴圈與更新迴圈索引值到畫面上的需求

將底下的程式碼加入到 btn_Click 事件內,雖然這裡使用了 await Task.Delay(1000); 這樣的敘述,但不影響整個實驗結果,當然,可以改寫成為封鎖 Block 等待 Task.Delay(1000).Wait(); 敘述。

private void btn_Click(object sender, RoutedEventArgs e)
{
    var task = Task.Run(async () =>
    {
        for (int i = 0; i < 10; i++)
        {
            await Task.Delay(1000);
            textBlock.Text = i.ToString();
        }
    });
}

現在,可以執行這個 WPF 專案,一旦應用程式啟動成功後,點選視窗中的 OK 按鈕

沒意外的話,應用程式沒有當掉,並且螢幕也沒有任何變化,為什麼會是這樣的情況呢?

若讀者對於 非同步工作 Task 這個類別夠孰悉的話,將會知道,這個工作物件在非同執行所傳入的委派方法的時候,若該委派方法產生了例外異常,這個工作執行個體將會蒐集當時的例外異常資訊,並且不會讓整個應用程式造成崩潰,這也就是為什麼會有這樣的執行結果了。

若你對於 非同步工作 Task 不太孰悉,建議可以來參加我開設的 精準解析 .NET Task 工作 課程

現在,將 btn_Click 這個事件委派方法內,使用 執行緒 集區來建立一個非同步多執行緒作業,看看剛剛用於 Task 物件內的相同委派方法程式碼,在執行緒下會有甚麼結果呢?

請將底下程式碼替換掉 btn_Click 事件內的程式碼

private void btn_Click(object sender, RoutedEventArgs e)
{
    ThreadPool.QueueUserWorkItem(async _ =>
    {
        for (int i = 0; i < 10; i++)
        {
            await Task.Delay(1000);
            textBlock.Text = i.ToString();
        }
    });
}

讓我們來執行這個專案,並且按下 OK 按鈕,看看會有甚麼情況發生。

很不幸的,當按下 OK 按鈕的之後,這個應用程式崩潰了,Visual Studio 2022 出現底下的畫面

Visual Studio 2022

例外異常的訊息文字為

System.InvalidOperationException: '呼叫執行緒無法存取此物件,因為此物件屬於另一個執行緒。'

從這裡的錯誤訊息可以得知,在 GUI 應用程式下,例如 Windows Forms , WPF , Xamarin.Forms 開發框架下, 想要修改 UI 控制項的屬性,該段程式碼必須要在 UI 執行緒下來執行,否則,就會造成上面的錯誤與得到上面的錯誤訊息。

為了要解決這個問題,在這裡將會採用一種做法,這種作法將會適用於不同 UI 開發框架下,皆可使用。

這個方法就是採用 同步內容 SynchronizationContext ,請將底下程式碼替換掉 btn_Click 方法內的程式碼

private void btn_Click(object sender, RoutedEventArgs e)
{
    #region 記錄下當前的 SynchronizationContext
    SynchronizationContext context = SynchronizationContext.Current;
    #endregion
    ThreadPool.QueueUserWorkItem(async _ =>
    {
        for (int i = 0; i < 10; i++)
        {
            await Task.Delay(1000);
            #region 透過同步內容來更新 UI 屬性
            context.Post(_ =>
            {
               textBlock.Text = i.ToString();
            }, null);
            #endregion
        }
    });
}

上面的程式碼會在使用者點選 OK 按鈕之後開始執行,首先,使用 SynchronizationContext.Current 取得當前的 SynchronizationContext 物件,從微軟文件上得知,這個類別提供在各種同步處理模式中散佈同步處理內容的基本功能,而這個物件內有個 SynchronizationContext.Post(SendOrPostCallback, Object) 方法 ,其會將非同步訊息分派至同步處理內容。

了解完成 SynchronizationContext 這個物件用法,可以來執行這個專案,將會看到底下的正確無誤的執行過程。

因此,若想要使用單一做法,在不同 UI 開發框架下做到非同步多執行緒程式碼,可以順利更新 UI 控制項內容,可以使用 SynchronizationContext 來嘗試看看。