本文主要针对 GKarch 相关文章留作笔记,仅在原文基础上记录了自己的理解与摘抄部分片段。
遵循原作者的 CC 3.0 协议。
如果想要了解更加详细的文章信息内容,请访问下列地址进行学习。
原文章地址:https://blog.gkarch.com/threading/part3.html
一、基于事件的异步模式
-
基于事件的异步模式 (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 失去响应。
|
请发表评论