本文主要針對 GKarch 相關文章留作筆記,僅在原文基礎上記錄了自己的理解與摘抄部分片段。 遵循原作者的 "CC 3.0 協議" 。 如果想要瞭解更加詳細的文章信息內容,請訪問下列地址進行學習。 原文章地址: "https://blog.gkarch.com/threading/part3.htm ...
本文主要針對 GKarch 相關文章留作筆記,僅在原文基礎上記錄了自己的理解與摘抄部分片段。
遵循原作者的 CC 3.0 協議。
如果想要瞭解更加詳細的文章信息內容,請訪問下列地址進行學習。
一、基於事件的非同步模式
基於事件的非同步模式 (event-based asynchronous pattern) 提供了簡單的方式,讓類型提供多線程的能力而不需要顯式啟動線程。
- 協作取消模型。
- 工作線程完成時安全更新 UI 的能力。
- 轉發異常到完成事件。
EAP 僅是一個模式,需要開發人員自己實現。
EAP 一般會提供一組成員,在其內部管理工作線程,例如
WebClient
類型就使用的 EAP 模式進行設計。// 下載數據的同步版本。 public byte[] DownloadData (Uri address); // 下載數據的非同步版本。 public void DownloadDataAsync (Uri address); // 下載數據的非同步版本,支持傳入 token 標識任務。 public void DownloadDataAsync (Uri address, object userToken); // 完成時候的事件,當任務取消,出現異常或者更新 UI 操作都可以才該事件內部進行操作。 public event DownloadDataCompletedEventHandler DownloadDataCompleted; public void CancelAsync (object userState); // 取消一個操作 public bool IsBusy { get; } // 指示是否仍在運行
通過
Task
可以很方便的實現 EAP 模式類似的功能。
二、BackgroundWorker
BackgroundWorker
是一個通用的 EAP 實現,提供了下列功能。- 協作取消模型。
- 工作線程完成時安全更新 UI 的能力。
- 轉發異常到完成事件。
- 報告工作進度的協議。
BackgroundWorker
使用線程池來創建線程,所以不應該在BackgroundWorker
的線程上調用Abort()
方法。
2.1 使用方法
實例化
BackgroundWorker
對象,並且掛接DoWork
事件。調用
RunWorkerAsync()
可以傳遞一個object
參數,以上則是BackgroundWorker
的最簡使用方法。可以為
BackgroundWorker
對象掛接RunWorkerCompleted
事件,在該事件內部可以對工作線程執行後的異常與結果進行檢查,並且可以直接在該事件內部安全地更新 UI 組件。如果需要支持取消功能,則需要將
WorkerSupportsCancellation
屬性置為true
。這樣在DoWork()
事件當中就可通過檢查對象的CancellationPending
屬性來確定是否被取消,如果是則將Cancel
置為true
並結束工作事件。調用
CancelAsync
來請求取消。開發人員不一定需要在
CancellationPending
為true
時才取消任務,隨時可以通過將Cancel
置為true
來終止任務。如果需要添加工作進度報告,則需要將
WorkerReportsProgress
屬性置為true
,併在DoWork
事件中周期性地調用ReportProcess()
方法來報告工作進度。同時掛接ProgressChanged
事件,在其內部可以安全地更新 UI 組件,例如設置進度條 Value 值。下列代碼即是上述功能的完整實現。
class Program { static void Main() { var backgroundTest = new BackgroundWorkTest(); backgroundTest.Run(); Console.ReadLine(); } } public class BackgroundWorkTest { private readonly BackgroundWorker _bw = new BackgroundWorker(); public BackgroundWorkTest() { // 綁定工作事件 _bw.DoWork += BwOnDoWork; // 綁定工作完成事件 _bw.WorkerSupportsCancellation = true; _bw.RunWorkerCompleted += BwOnRunWorkerCompleted; // 綁定工作進度更新事件 _bw.WorkerReportsProgress = true; _bw.ProgressChanged += BwOnProgressChanged; } private void BwOnProgressChanged(object sender, ProgressChangedEventArgs e) { Console.WriteLine($"當前進度:{e.ProgressPercentage}%"); } private void BwOnRunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e) { if (e.Cancelled) { Console.WriteLine("任務已經被取消。"); } if (e.Error != null) { Console.WriteLine("執行任務的過程中出現了異常。"); } // 在當前線程可以直接更新 UI 組件的數據 Console.WriteLine($"執行完成的結果:{e.Result}"); } public void Run() { _bw.RunWorkerAsync(10); } private void BwOnDoWork(object sender, DoWorkEventArgs e) { // 這裡是工作線程進行執行的 Console.WriteLine($"需要計算的數據值為:{e.Argument}"); for (int i = 0; i <= 100; i += 20) { if (_bw.CancellationPending) { e.Cancel = true; return; } _bw.ReportProgress(i); } // 傳遞完成的數據給完成事件 e.Result = 1510; } }
BackgroundWorker
不是密閉類,用戶可以繼承自BackgroundWorker
類型,並重寫其DoWork()
方法以達到自己的需要。
三、線程的中斷與中止
所有 阻塞 方法在解除阻塞的條件沒有滿足,並且其沒有指定超時時間的情況下,會永久阻塞。
開發人員可以通過
Thread.Interrupt()
與Thread.Abort()
方法來解除阻塞。在使用線程中斷與中止方法的時候,應該十分謹慎,這可能會導致一些意想不到的情況發生。
為了演示上面所說的概念,可以編寫如下代碼進行測試。
class Program { static void Main() { var test = new ThreadInterrupt(); test.Run(); Console.ReadLine(); } } public class ThreadInterrupt { public void Run() { var testThread = new Thread(WorkThread); testThread.Start(); // 中斷指定的線程 testThread.Interrupt(); } private void WorkThread() { try { // 永遠阻塞 Thread.Sleep(Timeout.Infinite); } catch (ThreadInterruptedException e) { Console.WriteLine("產生了中斷異常."); } Console.WriteLine("線程執行完成."); } }
3.1 中斷
- 在一個阻塞線程上調用
Thread.Interrupt()
方法,會導致該線程拋出ThreadInterruptedException
異常,並且強制釋放線程。 - 中斷線程時,除非沒有對
ThreadInterruptedException
進行處理,否則是不會導致阻塞線程結束的。 - 隨意中斷一個線程是十分危險的,我們可以通過信號構造或者取消構造。哪怕是使用
Thread.Abort()
來中止線程,都比中斷線程更加安全。 - 因為隨意中斷線程會導致調用棧上面的任何框架,或者第三方的方法意外接收到中斷。
3.2 中止
Thread.Abort()
方法在 .NET Core 當中無法使用,調用該方法會拋出Thread abort is not supported on this platform.
錯誤。
- 在一個阻塞線程上調用
Thread.Abort()
方法,效果與中斷相似,但會拋出一個ThreadAbortException
異常。 - 該異常在
catch
塊結束之後會被重新拋出。 - 未經處理的
ThreadAbortException
是僅有的兩個不會導致應用程式關閉的異常之一。 - 中止與中斷最大的不同是,中止操作會立即在執行的地方拋出異常。例如中止發生在
FileStream
的構造期間,可能會導致一個非托管文件句柄保持打開狀態導致記憶體泄漏。
四、安全取消
與實現了 EAP 模式的
BackgroundWorker
類型一樣,我們可以通過協作模式,使用一個標識來優雅地中止線程。其核心思路就是封裝一個取消標記,將其傳入到線程當中,線上程執行時可以通過這個取消標記來優雅中止。
class Program { static void Main() { var test = new CancelTest(); test.Run(); Console.ReadLine(); } } public class CancelToken { private readonly object _selfLocker = new object(); private bool _cancelRequest = false; /// <summary> /// 當前操作是否已經被取消。 /// </summary> public bool IsCancellationRequested { get { lock (_selfLocker) { return _cancelRequest; } } } /// <summary> /// 取消操作。 /// </summary> public void Cancel() { lock (_selfLocker) { _cancelRequest = true; } } /// <summary> /// 如果操作已經被取消,則拋出異常。 /// </summary> public void ThrowIfCancellationRequested() { lock (_selfLocker) { if (_cancelRequest) { throw new OperationCanceledException("操作被取消."); } } } } public class CancelTest { public void Run() { var cancelToken = new CancelToken(); var workThread = new Thread(() => { try { Work(cancelToken); } catch (OperationCanceledException e) { Console.WriteLine("任務已經被取消。"); } }); workThread.Start(); Thread.Sleep(1000); cancelToken.Cancel(); } private void Work(CancelToken token) { // 模擬耗時操作 while (true) { token.ThrowIfCancellationRequested(); try { RealWork(token); } finally { // 清理資源 } } } private void RealWork(CancelToken token) { token.ThrowIfCancellationRequested(); Console.WriteLine("我是真的在工作..."); } }
4.1 取消標記
在 .NET 提供了
CancellationTokenSource
和CancellationToken
來簡化取消操作。如果需要使用這兩個類,則只需要實例化一個
CancellationTokenSource
對象,並將其Token
屬性傳遞給支持取消的方法,在需要取消的使用調用 Source 的Cancel()
即可。// 偽代碼 var cancelSource = new CancellationTokenSource(); // 啟動線程 new Thread(() => work(cancelSource.Token)).Start(); // Work 方法的定義 void Work(CancellationToken cancelToken) { cancelToken.ThrowIfCancellationRequested(); } // 需要取消的時候,調用 Cancel 方法。 cancelSource.Cancel();
五、延遲初始化
延遲初始化的作用是緩解類型構造的開銷,尤其是某個類型的構造開銷很大的時候可以按需進行構造。
// 原始代碼 public class Foo { public readonly Expensive Expensive = new Expensive(); } public class Expensive { public Expensive() { // ... 構造開銷極大 } } // 按需構造 public class LazyFoo { private Expensive _expensive; public Expensive Expensive { get { if(_expensive == null) _expensive = new Expensive(); } } } // 按需構造的線程安全版本 public class SafeLazyFoo { private Expensive _expensive; private readonly object _lazyLocker = new object(); public Expensive Expensive { get { lock(_lazyLocker) { if(_expensive == null) { _expensive = new Expensive(); } } } } }
在 .NET 4.0 之後提供了一個
Lazy<T>
類型,可以免去上面複雜的代碼編寫,並且也實現了雙重鎖定模式。通過在創建
Lazy<T>
實例時傳遞不同的bool
參數來決定是否創建線程安全的初始化模式,傳遞了true
則是線程安全的,傳遞了false
則不是線程安全的。public class LazyExpensive { } public class LazyTest { // 線程安全版本的延遲初始化對象。 private Lazy<LazyExpensive> _lazyExpensive = new Lazy<LazyExpensive>(()=>new LazyExpensive(),true); public LazyExpensive LazyExpensive => _lazyExpensive.Value; }
5.1 LazyInitializer
LazyInitializer
是一個靜態類,基本與Lazy<T>
相似,但是提供了一系列的靜態方法,在某些極端情況下可以改善性能。public class LazyFactoryTest { private LazyExpensive _lazyExpensive; // 雙重鎖定模式。 public LazyExpensive LazyExpensive { get { LazyInitializer.EnsureInitialized(ref _lazyExpensive, () => new LazyExpensive()); return _lazyExpensive; } } }
LazyInitializer
提供了一個競爭初始化的版本,這種在多核處理器(線程數與核心數相等)的情況下速度比雙重鎖定技術要快。volatile Expensive _expensive; public Expensive Expensive { get { if (_expensive == null) { var instance = new Expensive(); Interlocked.CompareExchange (ref _expensive, instance, null); } return _expensive; } }
六、線程局部存儲
某些數據不適合作為全局遍歷和局部變數,但是在整個調用棧當中又需要進行共用,是與執行路徑緊密相關的。所以這裡來說,應該是在代碼的執行路徑當中是全局的,這裡就可以通過線程來達到數據隔離的效果。例如線程 A 調用鏈是這樣的 A() -> B() -> C()。
對靜態欄位增加
[ThreadStatic]
,這樣每個線程就會擁有獨立的副本,但僅適用於靜態欄位。[ThreadStatic] static int _x;
.NET 提供了一個
ThreadLocal<T>
類型可以用於靜態欄位和實例欄位的線程局部存儲。// 靜態欄位存儲 static ThreadLocal<int> _x = new ThreadLocal<int>(() => 3); // 實例欄位存儲 var localRandom = new ThreadLocal<Random>(() => new Random());
ThreadLocal<T>
的值是 延遲初始化 的,第一次被使用的時候 才通過工廠進行初始化。我們可以使用
Thread
提供的Thread.GetData()
與Thread.SetData()
方法來將數據存儲線上程數據槽當中。同一個數據槽可以跨線程使用,而且它在不同的線程當中數據仍然是獨立的。
通過
LocalDataStoreSolt
可以構建一個數據槽,通過Thread.GetNamedDataSlot("securityLevel")
來獲得一個命名槽,可以通過Thread.FreeNameDataSlot("securityLevel")
來釋放。如果不需要命名槽,也可以通過
Thread.AllocateDataSlot()
來獲得一個匿名槽。class Program { static void Main() { var test = new ThreadSlotTest(); test.Run(); Console.ReadLine(); } } public class ThreadSlotTest { // 創建一個命名槽。 private LocalDataStoreSlot _localDataStoreSlot = Thread.GetNamedDataSlot("命名槽"); // 創建一個匿名槽。 private LocalDataStoreSlot _anonymousDataStoreSlot = Thread.AllocateDataSlot(); public void Run() { new Thread(NamedThreadWork).Start(); new Thread(NamedThreadWork).Start(); new Thread(AnonymousThreadWork).Start(); new Thread(AnonymousThreadWork).Start(); // 釋放命名槽。 Thread.FreeNamedDataSlot("命名槽"); } // 命名槽測試。 private void NamedThreadWork() { // 設置命名槽數據 Thread.SetData(_localDataStoreSlot,DateTime.UtcNow.Ticks); var data = Thread.GetData(_localDataStoreSlot); Console.WriteLine($"命名槽數據:{data}"); ContinueNamedThreadWork(); } private void ContinueNamedThreadWork() { Console.WriteLine($"延續方法中命名槽的數據:{Thread.GetData(_localDataStoreSlot)}"); } // 匿名槽測試。 private void AnonymousThreadWork() { // 設置匿名槽數據 Thread.SetData(_anonymousDataStoreSlot,DateTime.UtcNow.Ticks); var data = Thread.GetData(_anonymousDataStoreSlot); Console.WriteLine($"匿名槽數據:{data}"); ContinueAnonymousThreadWork(); } private void ContinueAnonymousThreadWork() { Console.WriteLine($"延續方法中匿名槽的數據:{Thread.GetData(_anonymousDataStoreSlot)}"); } }
七、定時器
7.1 多線程定時器
- 多線程定時器使用線程池觸發時間,也就意味著
Elapsed
事件可能會在不同線程當中觸發。 System.Threading.Timer
是最簡單的多線程定時器,而System.Timers.Timer
則是對於該計時器的封裝。- 多線程定時器的精度大概在
10
~20
ms。
7.2 單線程定時器
- 單線程定時器依賴於 UI 模型的底層消息迴圈機制,所以其
Tick
事件總是在創建該定時器的線程觸發。 - 單線程定時器關聯的事件可以安全地操作 UI 組件。
- 精度比多線程定時器更低,而且更容易使 UI 失去響應。