C# 多线程学习笔记 - 3
本文主要针对 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 失去响应。