C#多執行緒(13):任務基礎①

痴者工良發表於2020-04-28

多執行緒程式設計

多執行緒程式設計模式

.NET 中,有三種非同步程式設計模式,分別是基於任務的非同步模式(TAP)、基於事件的非同步模式(EAP)、非同步程式設計模式(APM)。

  • 基於任務的非同步模式 (TAP) :.NET 推薦使用的非同步程式設計方法,該模式使用單一方法表示非同步操作的開始和完成。包括我們常用的 async 、await 關鍵字,屬於該模式的支援。
  • 基於事件的非同步模式 (EAP) :是提供非同步行為的基於事件的舊模型。《C#多執行緒(12):執行緒池》中提到過此模式,.NET Core 已經不支援。
  • 非同步程式設計模型 (APM) 模式:也稱為 IAsyncResult 模式,,這是使用 IAsyncResult 介面提供非同步行為的舊模型。.NET Core 也不支援,請參考 《C#多執行緒(12):執行緒池》

前面,我們學習了三部分的內容:

  • 執行緒基礎:如何建立執行緒、獲取執行緒資訊以及等待執行緒完成任務;
  • 執行緒同步:探究各種方式實現程式和執行緒同步,以及執行緒等待;
  • 執行緒池:執行緒池的優點和使用方法,基於任務的操作;

這篇開始探究任務和非同步,而任務和非同步是十分複雜的,內容錯綜複雜,筆者可能講不好。。。

探究優點

在前面中,學習多執行緒(執行緒基礎和執行緒同步),一共寫了 10 篇,寫了這麼多程式碼,我們現在來探究一下多執行緒程式設計的複雜性。

  1. 傳遞資料和返回結果

傳遞資料倒是沒啥問題,只是難以獲取到執行緒的返回值,處理執行緒的異常也需要技巧。

  1. 監控執行緒的狀態

新建新的執行緒後,如果需要確定新執行緒在何時完成,需要自旋或阻塞等方式等待。

  1. 執行緒安全

設計時要考慮如果避免死鎖、合理使用各種同步鎖,要考慮原子操作,同步訊號的處理需要技巧。

  1. 效能

玩多執行緒,最大需求就是提升效能,但是多執行緒中有很多坑,使用不當反而影響效能。

[以上總結可參考《C# 7.0本質論》19.3節,《C# 7.0核心技術指南》14.3 節]

我們通過使用執行緒池,可以解決上面的部分問題,但是還有更加好的選擇,就是 Task(任務)。另外 Task 也是非同步程式設計的基礎型別,後面很多內容要圍繞 Task 展開。

原理的東西,還是多參考微軟官方文件和書籍,筆者講得不一定準確,而且不會深入說明這些。

任務操作

任務(Task)實在太多 API 了,也有各種騷操作,要講清楚實在不容易,我們要慢慢來,一點點進步,一點點深入,多寫程式碼測試。

下面與筆者一起,一步步熟悉、摸索 Task 的 API。

兩種建立任務的方式

通過其建構函式建立一個任務,其建構函式定義為:

public Task (Action action);

其示例如下:

    class Program
    {
        static void Main()
        {
            // 定義兩個任務
            Task task1 = new Task(()=> 
            {
                Console.WriteLine("① 開始執行");
                Thread.Sleep(TimeSpan.FromSeconds(1));

                Console.WriteLine("① 執行中");
                Thread.Sleep(TimeSpan.FromSeconds(1));

                Console.WriteLine("① 執行即將結束");
            });

            Task task2 = new Task(MyTask);
            // 開始任務
            task1.Start();
            task2.Start();

            Console.ReadKey();
        }

        private static void MyTask()
        {
            Console.WriteLine("② 開始執行");
            Thread.Sleep(TimeSpan.FromSeconds(1));

            Console.WriteLine("② 執行中");
            Thread.Sleep(TimeSpan.FromSeconds(1));

            Console.WriteLine("② 執行即將結束");
        }
    }

.Start() 方法用於啟動一個任務。微軟文件解釋:啟動 Task,並將它安排到當前的 TaskScheduler 中執行。

TaskScheduler 這個東西,我們後面講,別急。

另一種方式則使用 Task.Factory,此屬性用於建立和配置 TaskTask<TResult> 例項的工廠方法。

使用https://docs.microsoft.com/zh-cn/dotnet/api/system.threading.tasks.taskfactory.startnew?view=netcore-3.1#--可以新增任務。

當需要對長時間執行、計算限制的任務(計算密集型)進行精細控制時才使用 StartNew() 方法;
官方推薦使用 Task.Run 方法啟動計算限制任務。
Task.Factory.StartNew() 可以實現比 Task.Run() 更細粒度的控制。

Task.Factory.StartNew() 的過載方法是真的多,你可以參考: https://docs.microsoft.com/zh-cn/dotnet/api/system.threading.tasks.taskfactory.startnew?view=netcore-3.1#--

這裡我們使用兩個過載方法編寫示例:

public Task StartNew(Action action);
public Task StartNew(Action action, TaskCreationOptions creationOptions);

程式碼示例如下:

    class Program
    {
        static void Main()
        {
            // 過載方法 1
            Task.Factory.StartNew(() =>
            {
                Console.WriteLine("① 開始執行");
                Thread.Sleep(TimeSpan.FromSeconds(1));

                Console.WriteLine("① 執行中");
                Thread.Sleep(TimeSpan.FromSeconds(1));

                Console.WriteLine("① 執行即將結束");
            });

            // 過載方法 1
            Task.Factory.StartNew(MyTask);

            // 過載方法 2
            Task.Factory.StartNew(() =>
            {
                Console.WriteLine("① 開始執行");
                Thread.Sleep(TimeSpan.FromSeconds(1));

                Console.WriteLine("① 執行中");
                Thread.Sleep(TimeSpan.FromSeconds(1));

                Console.WriteLine("① 執行即將結束");
            },TaskCreationOptions.LongRunning);

            Console.ReadKey();
        }

        // public delegate void TimerCallback(object? state);
        private static void MyTask()
        {
            Console.WriteLine("② 開始執行");
            Thread.Sleep(TimeSpan.FromSeconds(1));

            Console.WriteLine("② 執行中");
            Thread.Sleep(TimeSpan.FromSeconds(1));

            Console.WriteLine("② 執行即將結束");
        }
    }

通過 Task.Factory.StartNew() 方法新增的任務,會進入執行緒池任務佇列然後自動執行,不需要手動啟動。

TaskCreationOptions.LongRunning 是控制任務建立特性的列舉,後面講。

Task.Run() 建立任務

Task.Run() 建立任務,跟 Task.Factory.StartNew() 差不多,當然 Task.Run() 還有很多過載方法和騷操作,我們後面再來學。

Task.Run() 建立任務示例程式碼如下:

        static void Main()
        {
            Task.Run(() =>
            {
                Console.WriteLine("① 開始執行");
                Thread.Sleep(TimeSpan.FromSeconds(1));

                Console.WriteLine("① 執行中");
                Thread.Sleep(TimeSpan.FromSeconds(1));

                Console.WriteLine("① 執行即將結束");
            });
            Console.ReadKey();
        }

取消任務和控制任務的建立

取消任務,《C#多執行緒(12):執行緒池》 中說過一次,不過控制太自由,全靠任務本身自覺判斷是否取消。

這裡我們通過 Task 來實現任務的取消,其取消是實時的、自動的,並且不需要手工控制。

其建構函式如下:

public Task StartNew(Action action, CancellationToken cancellationToken);

程式碼示例如下:

按下Enter鍵的時候記得切換字母模式。

    class Program
    {
        static void Main()
        {
            Console.WriteLine("任務開始啟動,按下任意鍵,取消執行任務");
            CancellationTokenSource cts = new CancellationTokenSource();
            Task.Factory.StartNew(MyTask, cts.Token);

            Console.ReadKey();

            cts.Cancel();       // 取消任務
            Console.ReadKey();
        }

        // public delegate void TimerCallback(object? state);
        private static void MyTask()
        {
            Console.WriteLine(" 開始執行");
            int i = 0;
            while (true)
            {
                Console.WriteLine($" 第{i}次任務");
                Thread.Sleep(TimeSpan.FromSeconds(1));

                Console.WriteLine("     執行中");
                Thread.Sleep(TimeSpan.FromSeconds(1));

                Console.WriteLine("     執行結束");
                i++;
            }
        }
    }

前面建立任務的時候,我們碰到了 TaskCreationOptions.LongRunning 這個列舉型別,這個列舉用於控制任務的建立以及設定任務的行為。

其列舉如下:

列舉 說明
AttachedToParent 4 指定將任務附加到任務層次結構中的某個父級。
DenyChildAttach 8 指定任何嘗試作為附加的子任務執行的子任務都無法附加到父任務,會改成作為分離的子任務執行。
HideScheduler 16 防止環境計劃程式被視為已建立任務的當前計劃程式。
LongRunning 2 指定任務將是長時間執行的、粗粒度的操作,涉及比細化的系統更少、更大的元件。
None 0 指定應使用預設行為。
PreferFairness 1 提示 TaskScheduler 以一種儘可能公平的方式安排任務。
RunContinuationsAsynchronously 64 強制非同步執行新增到當前任務的延續任務。

這個列舉在 TaskFactoryTaskFactory<TResult>TaskTask<TResult>

StartNew()FromAsync()TaskCompletionSource<TResult> 等地方可以使用到。

這裡來探究 TaskCreationOptions.AttachedToParent的使用。程式碼示例如下:

        static void Main()
        {
            //兩個任務沒有從屬關係,是獨立的
            Task task = new Task(() =>
            {
                // 非子任務
                Task task1 = new Task(() =>
                {
                    Thread.Sleep(TimeSpan.FromSeconds(1));
                    for (int i = 0; i < 5; i++)
                    {
                        Console.WriteLine("內層任務1");
                        Thread.Sleep(TimeSpan.FromSeconds(0.5));
                    }
                });
                task1.Start();
                for (int i = 0; i < 5; i++)
                {
                    Console.WriteLine("外層任務");
                    Thread.Sleep(TimeSpan.FromSeconds(0.5));
                }
            });
            task.Start();
            task.Wait();
            Console.WriteLine("\n-------------------\n");

            // 父子任務
            task = new Task(() =>
           {
                // TaskCreationOptions.AttachedToParent
                // 將此任務附加到父任務中
                // 父任務需要等待所有子任務完成後,才能繼續往下執行
                Task task1 = new Task(() =>
               {
                   Thread.Sleep(TimeSpan.FromSeconds(1));
                   for (int i = 0; i < 5; i++)
                   {
                       Console.WriteLine("  內層任務1");
                       Thread.Sleep(TimeSpan.FromSeconds(0.5));
                   }
               }, TaskCreationOptions.AttachedToParent);
               task1.Start();

               Console.WriteLine("最外層任務");
               Thread.Sleep(TimeSpan.FromSeconds(1));
           });

            task.Start();
            task.Wait();

            Console.ReadKey();
        }

TaskCreationOptions.DenyChildAttach 則不允許其它任務附加到外層任務中。

        static void Main()
        {
            // 不允許出現父子任務
            Task task = new Task(() =>
            {
                // TaskCreationOptions.AttachedToParent
                // 將此任務附加到父任務中
                // 父任務需要等待所有子任務完成後,才能繼續往下執行
                Task task1 = new Task(() =>
                {
                    Thread.Sleep(TimeSpan.FromSeconds(1));
                    for (int i = 0; i < 5; i++)
                    {
                        Console.WriteLine("  內層任務1");
                        Thread.Sleep(TimeSpan.FromSeconds(0.5));
                    }
                }, TaskCreationOptions.AttachedToParent);
                task1.Start();

                Console.WriteLine("最外層任務");
                Thread.Sleep(TimeSpan.FromSeconds(1));
            }, TaskCreationOptions.AttachedToParent); // 不收兒子

            task.Start();
            task.Wait();

            Console.ReadKey();
        }

然後,這裡也學習了一個新的 Task 方法:Wait() 等待 Task 完成執行過程。Wait() 也可以設定超時時間。

TaskCreationOptions 列舉適合用來組合任務。

任務返回結果以及非同步獲取返回結果

要獲取任務返回結果,要使用泛型類或方法建立任務,例如 Task<Tresult>Task.Factory.StartNew<TResult>()Task.Run<TResult>

通過 其泛型的 的 Result 屬性,可以獲得返回結果。

非同步獲取任務執行結果:

    class Program
    {
        static void Main()
        {
            // *******************************
            Task<int> task = new Task<int>(() =>
            {
                return 666;
            });
            // 執行
            task.Start();
            // 獲取結果,屬於非同步
            int number = task.Result;

            // *******************************
            task = Task.Factory.StartNew<int>(() =>
            {
                return 666;
            });

            // 也可以非同步獲取結果
            number = task.Result;

            // *******************************
            task = Task.Run<int>(() =>
              {
                  return 666;
              });

            // 也可以非同步獲取結果
            number = task.Result;
            Console.ReadKey();
        }
    }

如果要同步的話,可以改成:

            int number = Task.Factory.StartNew<int>(() =>
            {
                return 666;
            }).Result;

捕獲任務異常

進行中的任務發生了異常,不會直接丟擲來阻止主執行緒執行,當獲取任務處理結果或者等待任務完成時,異常會重新丟擲。

示例如下:

        static void Main()
        {
            // *******************************
            Task<int> task = new Task<int>(() =>
            {
                throw new Exception("反正就想彈出一個異常");
            });
            // 執行
            task.Start();
            Console.WriteLine("任務中的異常不會直接傳播到主執行緒");
            Thread.Sleep(TimeSpan.FromSeconds(1));

            // 當任務發生異常,獲取結果時會彈出
            int number = task.Result;

            // task.Wait(); 等待任務時,如果發生異常,也會彈出

            Console.ReadKey();
        }

亂丟擲異常不是很好的行為噢~可以改成如下:

        static void Main()
        {
            Task<Program> task = new Task<Program>(() =>
            {
                try
                {
                    throw new Exception("反正就想彈出一個異常");
                    return new Program();
                }
                catch
                {
                    return null;
                }
            });
            task.Start();

            var result = task.Result;
            if (result is null)
                Console.WriteLine("任務執行失敗");
            else Console.WriteLine("任務執行成功");

            Console.ReadKey();
        }

全域性捕獲任務異常

TaskScheduler.UnobservedTaskException 是一個事件,其委託定義如下:

public delegate void EventHandler<TEventArgs>(object? sender, TEventArgs e);

下面是一個示例:

請釋出程式後,開啟目錄執行程式。

    class Program
    {
        static void Main()
        {
            TaskScheduler.UnobservedTaskException += MyTaskException;

            Task.Factory.StartNew(() =>
             {
                 throw new ArgumentNullException();
             });
            Thread.Sleep(100);
            GC.Collect();
            GC.WaitForPendingFinalizers();

            Console.WriteLine("Done");
            Console.ReadKey();
        }
        public static void MyTaskException(object sender, UnobservedTaskExceptionEventArgs eventArgs)
        {
            // eventArgs.SetObserved();
            ((AggregateException)eventArgs.Exception).Handle(ex =>
            {
                Console.WriteLine("Exception type: {0}", ex.GetType());
                return true;
            });
        }
    }

TaskScheduler.UnobservedTaskException 到底怎麼用,筆者不太清楚。而且效果難以觀察。

請參考:

https://stackoverflow.com/search?q=TaskScheduler.UnobservedTaskException

相關文章