處理長時間工作時,使用者或應用程式本身可能因為某些原因而需要取消執行中的背景工作(例如執行時間太長,使用者不想等了)。如果應用程式有提供中途取消工作的機制,使用者會覺得更方便、體貼,同時也可以減少運算資源的浪費。

.NET Framework 提供的是一種互助式的工作取消機制。所謂互助,指的是啟動工作的上游執行緒(註1)和執行工作的下游執行緒兩造雙方相互配合來實現取消工作的機制。雙方協議是否取消工作,以及真正執行取消的動作,主要是透過 System.Threading 命名空間裡的兩個類別來達成:CancellationTokenSource 和 CancellationToken。
註1:「上游執行緒」指的是發起非同步呼叫請求的執行緒,緣由參見上一篇筆記

先來看 CancellationTokenSource 類別的幾個常用屬性和方法:

public sealed class CancellationTokenSource : IDisposable
{
   public Boolean IsCancellationRequested { get; }
   public CancellationToken Token { get; }
 
   public void Cancel();  // 內部會呼叫底下的 Cancel 版本,並傳入 false。
   public void Cancel(Boolean throwOnFirstException);
   ...
}

其中的 Cancel 方法就是用來讓上游執行緒提出取消工作的請求。也就是說,上游建立工作執行緒時,必須先建立一個 CancellationTokenSource 物件,然後在想要取消工作時呼叫其 Cancel 方法,像這樣:

    CancellationTokenSource cts = new CancellationTokenSource();
    .....
    cts.Cancel();

那麼,當上游執行緒發出取消工作的訊號,下游的工作執行緒如何得知?

回頭看一下剛才的 CancellationTokenSource 類別,它有一個 IsCancellationRequested 屬性,代表目前的工作是否要取消。所以上游只要在建立工作執行緒時一併將 CancellationTokenSource 物件當作自訂參數傳遞給負責該項工作的回呼函式就行了。參考以下範例:

class Program
{
    static void Main(string[] args)
    {
        CancellationTokenSource cts = new CancellationTokenSource();
        ThreadPool.QueueUserWorkItem(MyTask, cts);
        ......
        cts.Cancel();  // 這會使 cts 的 IsCancellationRequested 屬性變成 true。
    }

    static void MyTask(object state)
    {
        var cts = state as CancellationTokenSource;
        if (cts.IsCancellationRequested)
        {
            // 這裡撰寫取消工作的程式碼。
        }
    }
}

也就是說,任何工作執行緒只要能夠拿到上游建立的 CancellationTokenSource 物件,就能夠呼叫其 Cancel 方法來取消工作。然而,有時候我們不希望賦予工作執行緒罷工的權利,此時就不應傳遞 CancellationTokenSource 物件給工作執行緒,而要改為傳遞 CancellationTokenSource 的 Token 屬性,也就是 CalcellationToken 結構(是個實質型別〔value type〕)。

CalcellationToken 結構也有 IsCancellationRequested 屬性,作用與 CancellationTokenSource 的 IsCancellationRequested 相同。底下是一個比較完整的範例,可觀察實際運行的結果:

class Program
{
    static void Main(string[] args)
    {
        CancellationTokenSource cts = new CancellationTokenSource();
        ThreadPool.QueueUserWorkItem(state => MyTask(cts.Token));

        Console.WriteLine("按 Enter 鍵可取消背景工作...");
        Console.ReadLine();
        cts.Cancel();

        Console.ReadLine();
    }

    static void MyTask(CancellationToken token)
    {
        for (int i = 0; i < 1000; i++)
        {
            if (token.IsCancellationRequested)
            {
                Console.WriteLine("使用者要求取消工作!");
                return;
            }
            Console.WriteLine(i);
            Thread.Sleep(200);
        }
        Console.WriteLine("MyTask 工作執行完畢。");
    }
}

注意這次 MyTask 接受的參數型別是 CancellationToken,該參數是在主執行緒建立工作執行緒時傳入。執行結果如下圖:


和 CancellationTokenSource 類別比起來,CancellationToken 較輕盈、功能也較少(無法取消工作);而且從型別名稱也可以看得出來,其目的就是單純用來當作取消工作的旗號。故若無其他原因,當上游執行緒想要提供取消工作的機制時,通常會選擇將 CancellationToken 傳遞給下游的工作執行緒。

接著進一步來看 CancellationToken 結構的幾個常用成員:

public struct CancellationToken
{
    public static CancellationToken None { get; }

    public Boolean IsCancellationRequested { get; } // 工作是否已被要求取消。
    public void ThrowIfCancellationRequested();  // 若工作取消則拋出異常。
 
    public Boolean CanBeCanceled { get; }  // 較少用到。
    public CancellationTokenRegistration Register(Action callback); 
    // Register 方法有多種版本,這裡僅列出最簡單的。
}

CancellationToken 有一個靜態屬性叫作 None,它會傳回一個 CancellationToken 結構的執行個體,而且已經預先設定成無法取消,所以適合用在不需要取消工作的場合。以剛才的範例來說,Main 函式如果不需要提供取消工作的機制,則可以改成這樣:

static void Main(string[] args)
{
    ThreadPool.QueueUserWorkItem(state => MyTask(CancellationToken.None));
    Console.ReadLine();
}

如此一來,上一個範例程式的回呼函式 MyTask 無須任何修改,仍能照常運作,而且主執行緒也無須額外建立 CancellationTokenSource 物件,省了一點工夫。這是因為透過 CancellationToken.None 取得的執行個體有兩個特性:

  • 其 IsCancellationRequested 屬性值必定為 false;
  • 其 CanBeCanceled 屬性值必定為 false,代表不可取消。

如先前提過的,想要取消工作時,是透過 CancellationTokenSource 物件的 Cancel 方法來通知工作執行緒,然而這種利用 CancellationToken.None 所取得的特殊執行個體並沒有關聯至任何 CancellationTokenSource 物件,自然也就無法取消工作了。

工作取消時請通知我

CancellationToken 的 Register 方法提供了訂閱「工作取消」的窗口。也就是說,你可以利用此方法註冊一個或多個回呼函式,以便在工作取消時收到通知。這個回呼函式必須以 Action 委派型別的方式傳入,範例如下:

class Program
{
    static void Main(string[] args)
    {
        CancellationTokenSource cts = new CancellationTokenSource();

        cts.Token.Register(() => Console.WriteLine("工作取消 callback #1.")); 
        cts.Token.Register(() => Console.WriteLine("工作取消 callback #2.")); 

        ThreadPool.QueueUserWorkItem(state => MyTask(cts.Token));

        Console.WriteLine("按 Enter 鍵可取消背景工作...");
        Console.ReadLine();
        cts.Cancel();

        Console.ReadLine();
    }

    static void MyTask(CancellationToken token)
    {
        for (int i = 0; i < 1000; i++)
        {
            if (token.IsCancellationRequested)
            {
                Console.WriteLine("使用者要求取消工作!");
                return;
            }
            Console.WriteLine(i);
            Thread.Sleep(200);
        }
        Console.WriteLine("MyTask 工作執行完畢。");
    }
}

此範例呼叫了兩次 Register,亦即註冊兩個回呼函式。執行結果如下圖所示:


從執行結果的畫面截圖不難發現,若呼叫多次 Register 方法來註冊多個回呼函式,那麼當工作取消時,這些函式都會被逐一呼叫,而且呼叫順序與註冊時的順序相反(我們了解這點就好,別依賴此順序來撰寫特殊的程式邏輯)。

另一個需要特別說明的部分,是這些回呼函式的錯誤處理方式。預設情況下,如果 Register 方法所註冊的回呼函式拋出異常(exception),此異常會先被暫存起來,等到其他回呼函式都全部執行完畢,之後才一口氣把先前捕捉到的異常拋出來。如果想要改變此預設行為,那麼在取消工作的時候,亦即呼叫 CancellationTokenSource 的 Cancel 方法時,就必須使用下面這個有帶參數的版本,並且指定 throwOnFirstException 參數為 true。

public void Cancel(bool throwOnFirstException);

參數 throwOnFirstException 代表是否要在碰到第一個異常的時候就立即拋出。若為 true,則當某個回呼函式拋出異常,其餘回呼函式就不會被呼叫,而且該異常會被立刻往外層拋送,直到有人處理。

最後要提的一個細節是,Register 方法會傳回一個 CancellationTokenRegistration 型別的物件。你可以透過呼叫此物件的 Dispose 方法來移除先前註冊的回呼函式。例如:

CancellationTokenSource cts = new CancellationTokenSource();
var cbReg = cts.Token.Register(() => Console.WriteLine("Cancellation callback.")); 

...... 

cbReg.Dispose();

這裡只介紹了 Register 方法的基本用法,其他多載版本及詳細說明請參閱 MSDN 線上文件:http://msdn.microsoft.com/zh-tw/library/dd321790.aspx

工作逾時

CancellationTokenSource 還有提供逾時自動取消工作的功能,讓我們可以預先設定一個逾時時間,若工作執行緒在這個限定時間內還沒跑完,就自動取消工作。

此逾時時間可透過 CancellationTokenSource 的建構函式或 CancelAfter 方法來設定。以下範例採用建構函式來設定工作逾時時間。

class Program
{
    static void Main(string[] args)
    {
        // 建立 CancellationTokenSource 物件時,順便設定逾時時間為 2 秒。
        CancellationTokenSource cts = new CancellationTokenSource(2000);

        // 測試 cancellation callback。
        cts.Token.Register(() => Console.WriteLine("背景工作因為逾時而取消!"));

        ThreadPool.QueueUserWorkItem(state => MyTask(cts.Token));

        Console.ReadLine();
    }

    static void MyTask(CancellationToken token)
    {
        for (int i = 0; i < 1000; i++)
        {
            if (token.IsCancellationRequested)
            {
                return;
            }
            Console.WriteLine(i);
            Thread.Sleep(200);
        }
        Console.WriteLine("MyTask 工作執行完畢。");
    }
}

執行結果:

小結

ThreadPool.QueueUserWorkItem 與其相關用法大致就整理到這篇吧!

參考資料