多線程編程
多線程編程模式
.NET 中,有三種異步編程模式,分別是基於任務的異步模式(TAP)、基於事件的異步模式(EAP)、異步編程模式(APM)。
- 基於任務的異步模式 (TAP) :.NET 推薦使用的異步編程方法,該模式使用單一方法表示異步操作的開始和完成。包括我們常用的 async 、await 關鍵字,屬於該模式的支持。
- 基於事件的異步模式 (EAP) :是提供異步行為的基於事件的舊模型。《C#多線程(12):線程池》中提到過此模式,.NET Core 已經不支持。
- 異步編程模型 (APM) 模式:也稱為 IAsyncResult 模式,,這是使用 IAsyncResult 接口提供異步行為的舊模型。.NET Core 也不支持,請參考 《C#多線程(12):線程池》。
前面,我們學習了三部分的內容:
- 線程基礎:如何創建線程、獲取線程信息以及等待線程完成任務;
- 線程同步:探究各種方式實現進程和線程同步,以及線程等待;
- 線程池:線程池的優點和使用方法,基於任務的操作;
這篇開始探究任務和異步,而任務和異步是十分復雜的,內容錯綜復雜,筆者可能講不好。。。
探究優點
在前面中,學習多線程(線程基礎和線程同步),一共寫了 10 篇,寫了這么多代碼,我們現在來探究一下多線程編程的復雜性。
- 傳遞數據和返回結果
傳遞數據倒是沒啥問題,只是難以獲取到線程的返回值,處理線程的異常也需要技巧。
- 監控線程的狀態
新建新的線程后,如果需要確定新線程在何時完成,需要自旋或阻塞等方式等待。
- 線程安全
設計時要考慮如果避免死鎖、合理使用各種同步鎖,要考慮原子操作,同步信號的處理需要技巧。
- 性能
玩多線程,最大需求就是提升性能,但是多線程中有很多坑,使用不當反而影響性能。
我們通過使用線程池,可以解決上面的部分問題,但是還有更加好的選擇,就是 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
,此屬性用於創建和配置 Task
和 Task<TResult>
實例的工廠方法。
使用https://docs.microsoft.com/zh-cn/dotnet/api/system.threading.tasks.taskfactory.startnew?view=netcore-3.1#--可以添加任務。
官方推薦使用 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);
代碼示例如下:
按下回車鍵的時候記得切換字母模式。
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 | 強制異步執行添加到當前任務的延續任務。 |
這個枚舉在 TaskFactory
和 TaskFactory<TResult>
、Task
和 Task<TResult>
、
StartNew()
、FromAsync()
、TaskCompletionSource<TResult>
等地方可以使用到。
子任務使用了 TaskCreationOptions.AttachedToParent ,並不是指父任務要等待子任務完成后,父任務才能繼續完往下執行;而是指父任務如果先執行完畢,那么必須等待子任務完成后,父任務才算完成。
這里來探究 TaskCreationOptions.AttachedToParent
的使用。代碼示例如下:
// 父子任務
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));
});
task.Start();
task.Wait();
Console.ReadKey();
而 TaskCreationOptions.DenyChildAttach
則不允許其它任務附加到外層任務中。
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));
}
}, TaskCreationOptions.AttachedToParent);
task1.Start();
Console.WriteLine("最外層任務");
Thread.Sleep(TimeSpan.FromSeconds(1));
}, TaskCreationOptions.DenyChildAttach); // 不收兒子
task.Start();
task.Wait();
Console.ReadKey();
}
然后,這里也學習了一個新的 Task 方法:Wait()
等待 Task 完成執行過程。Wait()
也可以設置超時時間。
如果父任務是通過調用 Task.Run 方法而創建的,則可以隱式阻止子任務附加到其中。
任務返回結果以及異步獲取返回結果
要獲取任務返回結果,要使用泛型類或方法創建任務,例如 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