C#的多线程处理
多线程处理的目的和方式
操作系统通常使用时间分片的机制来模拟多个线程并发运行,从一个线程速度极快的切换到另一个线程,给人的感觉就是同时执行。
处理器在切换到下一个线程之前,执行一个特定的时间周期称为时间片。在一个给定的内核中改换执行线程的动作称为上下文切换。
多线程的性能问题
上下文切换是有代价的
- 必须将CPU当前的内部状态保存到内存,还必须加载与新线程关联的状态,线程多的情况下,时间都被浪费在上下文切换
- 时间分片本身对性能也有很大影响
线程处理的问题
// 一个简单的银行操作
if (bankAccounts.Checking.Balance >= 1000.0m)
{
bankAccounts.Checking.Balance -= 1000.0m
bankAccounts.Saving.Balance += 1000.0m
}
- 不要无根据的以为普通线程中的原子性操作在多线程代码中也是原子性的
- 不要以为所有线程看到的都是一致的共享内存
- 确保同时拥有多个锁的代码总是以相同的顺序获取它们
- 避免所有竞态条件,即程序行为不能受操作系统调度线程的方式的影响
大多数操作都不是原子性的
两个线程同时执行,都验证了余额是足够的,但是只够一次转账
竞态条件所造成的不确定性
操作系统会在它认为合适的任何时间,在任何两个线程之间进行上下文切换
所以以上情况,如果哪一个线程走完,哪个线程没走完,都是不确定了。
如果出错的几率很小,但是绝对有可能出问题,这是很糟糕的一件事
内存模型的复杂性
现代处理器访问一个对象的时候不再访问主内存,而是访问处理器的"高速缓存",这个缓存会定时与主内存同步。
在两个不同的处理器上,两个线程以为自己在读写相同的位置,其实看到的不是对方对那个位置的实时更新。
锁定造成死锁
C#的lock语句将一部分代码设置为"关键代码",操作系统一次只允许一个线程访问,其他奖将被挂起
操作系统还会确保在遇到锁的时候处理器的高速缓存还能正确同步
假如不同线程以不同的顺序来获取锁,就有可能发生死锁,这个时候,线程会被冻结,彼此等待对方释放它们的锁
System.Threading
使用System.Threading.Thread进行异步操作
ThreadStart threadStart = DoWork;
Thread thread = new Thread(threadStart);
thread.Start(); // 开始执行新的线程,然后控制返回,继续执行当前线程
thread.Join(); // 阻塞调用线程,直到某个线程终止时为止
线程管理
一些基本的方法和属性
- Join(): 使一个线程等待另一个线程。让操作系统暂停执行当前线程,直到另一个线程终止。可以添加一个时间参数,过期不候。
- IsBackGround: 默认前台线程。前台线程都结束,进程终止。不关注后台进程。设置后台状态Thread.IsBackGround = true;
- Priority: 操作系统倾向于把时间片拨给高优先级的线程
- ThreadState: 全面的线程状态
- IsAlive: 检测一个线程是否还活着
让线程进入睡眠的一些注意事项
- 调用Thread.Sleep()实际上就是告诉操作系统,这段时间不要给这个线程调度任何时间片
- 睡眠时间不是精确的,只会大于等于指定的时间,具体是多少时间是无法确定的
- 好的用处就是将睡眠时间设置为0,相当于告诉操作系统,当前时间剩下的时间片就送给其他线程
不要终止线程
Thread的Abort方法一旦执行,它造成运行时在线程中引发ThreadAbortException异常。但是无论是否捕捉异常,都会重新引发确保线程被销毁
避免在中止线程,可能会发生不可预测的结果,使程序不稳定
不要终止的部分原因:
1. 方法只是承诺终止线程,不保证成功。如果在finally块中,或者非托管代码中执行,都无法引发Exception
2. 被中断的线程可能正在执行lock的关键代码。会因为这个异常而中断,这时候别的线程允许进入,并查看执行到一半的异常状态。是危险和不正确的
3. 线程终止时,CLR保证自己内部数据结构不会损坏,但是BCL没有做出这个保证。可能导致数据结构或者BCL数据结构损坏,其他线程或者终止线程的finally块看到损坏的状态,要么崩溃要么行为错误。
线程池处理
创建线程代码高昂,而且每个线程都要占用大量虚拟内存(默认1M)
ThreadPool.QueueUserWorkItem(DoWorkInPool, "+"); // 线程池调用
public static void DoWorkInPool(object state)
{
// ...
}
线程池能在单处理器和多处理器计算机获得更好的执行效率,是通过重用线程(而不是每个异步调用都重新构造线程)获得的
线程池假定调度的所有工作都能很快结束,假定所有工作的运行时间都比较短。防止过度的时间分片,如果线程池被耗时的长时间运行工作占用,正在排队的工作必然会被延迟。
- 要用线程池向处理器受任务高效的分配处理时间
- 避免将池中的工作线程分配给IO受限或者长时间运行的任务,而是改为TPL(任务并行库)
异步任务
多线程的复杂性
- 监视异步操作的状态,知道它在何时完成。为了判断一个异步操作何时完成,最好不要轮询,也不要阻塞并等待。
- 线程池。避免了启动和终止的巨大开销。防止系统将大多数时间花在线程的切换上而不是运行上。
- 避免死锁:在避免死锁的同时,防止数据同时被两个不同的线程访问。
- 为不同的操作提供原子性并同步数据访问。为不同的操作组提供同步,锁定机制防止两个不同的线程同时访问数据。
从Thread到Task
- 每次开始异步创建一个Task,然后告诉任务调度器有异步工作要做
- 默认是从线程池请求一个工作者线程,可能是当前任务结束再开始新任务,也可能将新任务调度到特定处理器上。自行判断是重用还是创建新的。
- 任务可以理解成将委托从同步执行模式转变成异步
异步任务
启动任务将从线程池获取一个新线程,创建第二个控制点,并在那个线程执行委托
// 调用Task.Run 成为热任务,几乎立刻开始执行委托
Task task = Task.Run(()=>{
// do sth
});
// 冷任务则需要显式触发
Task task1 = new Task(() => { });
// Func<string>
Task<string> task = Task.Run<string>(() => { return "Hello"; });
// 另一种形式
Task<string> task = Task.Factory.StartNew<string>(() => "Hello");
// 返回结果
Console.WriteLine(task.IsCompleted);
Console.WriteLine(task.Result);
Console.WriteLine(task.IsCompleted);
读取task.Result的时候会自动造成线程阻塞,直到结果可用。不需要wait
等待分配任务完成
task.Wait(); // 等待目标任务完成
Task.WaitAll(); // 等待所有任务完成
Task.WaitAny(task,task,task); // 等待部分任务完成
需要注意的几个点
任务结束,IsCompleted会变成true,无论是正常结束还是出错引发异常终止。
- 可以通过Status获取状态
- 只要为RanToCompletion、Canceled、Faulted,IsCompleted就为true
- 可用属性ID作为唯一标识。静态属性Task.CurrentId返回当前正在执行的Task的标志符
- 可用AsyncState为任务关联额外的数据。
任务延续
Task taskA = Task.Run(() => { Thread.Sleep(3000); Console.WriteLine("A"); });
Task taskB = taskA.ContinueWith(aaa => { Console.WriteLine("B"); });
Task taskC = taskA.ContinueWith(aaa => { Console.WriteLine("C"); });
Task.WaitAll(taskB, taskC);
- 当任务A完成之后,才会执行后面的任务,可以建立任意长度的连续任务链
- 后面的任务自动以异步方式执行。
- 同一个先驱的多个延续任务的执行顺序是不确定的。(上面的B/C永远晚于A,但是BC的顺序不确定)
- 可以通过TaskContinuationOptions增加一些延续任务的可选项。
使用AggregareException处理Task上的未处理异常
- CLR1.0所有未处理异常会终止线程,但不终止应用程序
- CLR2.0开始,线程上未处理异常被认为严重错误,会触发Windows错误报告对话框,造成程序异常终止
- 异步任务中的异常会用一个"catchall"异常处理程序来包装委托,记录细节,防止CLR终止进程
- AggregareException收集所有任务上的异常,抛出集合异常
- 内部异常通过 AggregareException.InnerException获取
- AggregareException的每个异常都需要指定一个表达式,调用Handle,这是一个断言,为true代表处理所有异常。为false的话,Handle将会引发新的异常。
- AggregareException.Flatten使内部包装的异常都删除,内部异常都到第一级
使用ContinueWith观察未处理的异常
使用task.Exception属性来获取原始任务上的未处理异常,如果原始任务的异常完全没有被观察到
- 它不会在任务中被捕获
- 永远观察不到任务完成
- 出错的COntinueWith永远观察不到,可能造成异常未被完全处理,变成进程级别的异常
但是都可以使用TaskScheduler.UnobservedTaskException登记未处理的异常
处理Thread上的未处理异常
AppDimain中的线程发生的所有未处理的异常都会触发UnhandledException事件,但是这个机制的目的是通知,而不是恢复并继续执行
try
{
AppDomain.CurrentDomain.UnhandledException += (s, e) =>
{
Console.WriteLine($"unhandled {s} {e}");
Thread.Sleep(4000);
};
Thread thread = new Thread(() => { throw new InvalidCastException(); });
thread.Start();
Thread.Sleep(6000);
}
finally
{
Console.WriteLine("finally");
}
Console.WriteLine("Hell");
Console.ReadKey();
- 首先在主线程创建线程,开始执行,新线程异常,调用事件处理异常,然后进入4秒休眠
- 控制权回到主线程,睡眠6秒
- 4秒之后异常处理完成,未处理异常导致进程崩溃
- 主线程被终结,finally块无法执行
取消任务
CancellationTokenSource cancellationToken = new CancellationTokenSource();
Task task = Task.Run(() => {}, cancellationToken.Token);
cancellationToken.Cancel(); // 取消任务
支持取消的任务需要监视一个CancellationToken对象,任务定期轮询它,检查是否发出了取消请求
- 提供给异步任务的是CancellationToken,而不是CancellationTokenSource
- 是CancellationToken是结构,所以能复制值。所以cancellationToken.Token返回的是副本,所以是线程安全的
- CancellationToken.Register可以登记一个操作,在标记为已取消的时候调用。
- 如果取消任务会造成某些破坏。有一个隐式引发的异常,从CancellationToken.ThrowIfCancellationRequested报告
- 在引发TaskCanceledException的任务上调用Wait或者获取Result,结果和在任务中引发其他异常是一样的。AggregateException
Task.Run是Task.Factory.StartNew的简化形式
- .Net4.5新增的简单调用方式
- 大部分情况都应该默认Task.Run方式,偶尔特殊需求不一样,例如使用TaskCreationOPtions控制任务,指定调度器,或者性能的考虑要传递对象状态们就可以考虑使用另一种方式
长时间运行的任务
- 如果知道任务长时间运行,需要告诉调度器。这样可能会分配一个专用线程,而不是使用线程池
- 时间分片的时间也会多分配一些
Task.Factory.StartNew(() => { }, TaskCreationOptions.LongRunning);
注意事项:
1. 长时间的任务要告诉调度器,使其恰当的管理
2. 尽可能少的使用LongRunning
对任务进行资源清理
Task在等待完成时分配一个WaitHandle,如果没有显式调用Dispose,就会在程序退出的自动WaitHandle终结器调用。
会造成句柄存活时间变长,GC效率变低。但是影响都不大,除非性能十分严苛,否则没有必要处理。
本作品采用 知识共享署名-相同方式共享 4.0 国际许可协议 进行许可。