目录
一、多线程基础知识
任务是可能出现高延迟的工作单元,作用是产生结果值或者希望的副作用。任务和线程的区别是:任务代表需要执行的一件工作,而线程代表做这件工作的工作者。
多线程处理主要用于两个方面:实现多任务和解决延迟。
操作系统通过时间分片机制模拟多个线程并发运行。处理器执行一个线程的时间周期称为时间片或量子,在某个核心上更改执行线程的行动称为上下文切换。(上下文切换是有代价的)
无论是真正的多核并行运行,还是使用时间分片技术模拟,我们说“一起”进行的两个操作是并发。实现并发操作需要异步调用,被调用的操作的执行和完成都独立于调用它的控制流。异步分配的工作与当前控制流并行执行就实现了并发性。
并行编程是指将一个问题分解成较小的部分,异步发起对每一部分的处理,最终使它们全部并发执行。
多线程程序比单线程复杂的根本原因在于单线程程序中一些成立的假设在多线程中变得不成立了,问题包括缺乏原子性、竞态条件、复杂的内存模型以及死锁。
竞态条件:当两个或多个线程竞争同一资源时,如果对资源的访问顺序敏感,就称存在竞态条件。
死锁:假如不同线程以不同顺序获取锁,线程就会被冻结,彼此等待对方释放它们的锁,就可能发生死锁
线程A | 线程B |
获得A上的锁 | 获得B上的锁 |
请求B上的锁 | 请求A上的锁 |
死锁,等待B | 死锁,等待A |
二、System.Threading
public const int Repetition = 500;
public static void Main(string[] args)
{
ThreadStart threadStart = DoWork;
Thread thread = new Thread(threadStart);
thread.Start();
for (int i = 0; i < Repetition; i++)
{
Console.Write("-");
}
thread.Join(); //告诉主线程等待工作者线程完成
}
private static void DoWork()
{
for(int i = 0; i < Repetition; i++)
{
Console.Write('+');
}
}
(一)线程管理
Thread包含大量的方法和属性来管理线程的执行:
Join:使一个线程等待另一个线程。它会告诉操作系统暂停当前线程,直到另一个线程终止。重载版本可以指定等待的最长时间。
IsBackground:新线程默认为“前台”线程,操作系统将在进行的所有前台线程完成后终止进程。可将线程的IsBackground属性设置为true,从而将线程标记为“后台”线程。后台线程运行时,操作系统允许进程终止。不过,最好不要半路中断任何线程,而是在进程退出前显示终止每个线程。
Priority:为线程设置优先级(Lowest、BelowNormal、Normal、AboveNormal、Highest)。操作系统倾向于将时间片调拨给高优先级的线程。如果线程优先级设置不当,可能会出现“饥饿”情况,即高优先级线程一直允许,低优先级线程一直等待。
ThreadState:包含全面的线程状态。也可以通过IsAlive了解一个线程是否还“活着”。
(二)Thread.Sleep()
Thread.Sleep()使当前线程进入睡眠——告诉操作系统在指定的时间内不要为该线程调度任何时间片。
Thread.Sleep()不能作为高精度的计时器使用。
将睡眠时间设置为0,相当于告诉操作系统“当前线程剩下的时间片就送给其他线程了”,然后,该线程会被正常调度,不会发生更多延迟。
(三)Thread.Abort()
Thread.Abort()一旦执行,就会尝试销毁线程,会导致“运行时”在线程中抛出“ThreadAbortExeception”异常。该异常可被捕获,但即使被捕获并被忽略,还是会自动重新抛出以确保线程事实上被销毁。
尽量不要试图中断线程,原因如下:
Thread.Abort()方法只尝试中断,不保证成功。例如,线程控制点在finally块中,运行时不会抛出ThreadAbortExeception,因为当前可能在允许关键的清理代码,不应该被打断。在非托管代码中也不会被抛出,否则会破坏CLR本身。CLR会推迟到控制离开finally块或回到托管代码后才抛出异常。但这也是无法保证的,被中断的线程可能在finally块中包含无限循环。
中断的线程可能正在执行由lock语句 保护的关键代码,lock阻止不了异常,关键代码会因异常中断,lock对象自动释放,允许正在等待这个锁的其他代码进入关键区域。所以,中断线程可能会造成危险和不正确。
线程中断时,CLR保证自己的内部数据结构不会损坏,但BCL没有做出这个保证。在错误的时候抛出异常,中断线程可能会使数据或者BCL的数据结构处于损坏状态。在其他线程或者中断线程的finally块中运行的代码可能看到损坏的状态,最后要么崩溃,要么行为错误。
(四)线程池处理
线程池:开发者不直接分配线程,而是告诉线程池想要执行上面工作,工作完成后,线程不是终止,而是回到线程池中,从而节省更多工作来临时分配新线程的开销。
线程池的效率通过重用线程来获得。
public const int Repetition = 500;
public static void Main(string[] args)
{
//将方法排入队列以便执行。 此方法在有线程池线程变得可用时执行。
ThreadPool.QueueUserWorkItem(DoWork, '+');
for(int i = 0; i < Repetition; i++)
{
Console.Write("-");
}
Thread.Sleep(1000);
}
private static void DoWork(object state)
{
for(int i = 0; i < Repetition; i++)
{
Console.Write(state);
}
}
三、异步任务
多线程编程的复杂性主要来自一下几个方面:
- 监视异步操作的状态,知道它于何时完成
- 线程池。避免启动和终止线程的巨大开销,避免创建太多线程,防止系统将太多时间花在线程的切换而不是运行上
- 避免死锁
- 为不同的操作提供原子性并同步数据访问
(一)从Thread到Task
在.Net Framework 4和后续版本中,TPL(任务并行库)不是每次开始异步工作时都创建一个线程,而是创建一个Task,并告诉任务调度器有异步工作要执行。此时任务调度器可能采取多种策略,但默认是从线程池请求一个工作者线程。线程池会自行判断怎么做最高效--可能在当前任务结束后再运行新任务,或者将新任务的工作者线程调度给特定处理器,线程池还会判断是创建全新线程还是重用之前已结束运行的现有线程。
调用Task.Run(),作为实参传递的Action几乎立刻开始执行,这称为“热任务”,“冷任务” 则需要显式触发之后才开始异步工作。
调用Run()之后无法确定“热”任务的确切状态。
调用Wait()将强迫主线程等待分配给任务的所有工作完成。一个常见的情况是等待一组任务完成,或等待其中一个完成,当前线程才能继续,分别使用Task.WaitAll() 和Task.WaitAny()。
假如任务执行的工作要返回结果,可用Task<T>类型来异步运行一个Func<T>,然后从一个线程轮询它,看它是否完成,完成就获取结果。
使用轮询需要谨慎,任务被调度给一个工作者线程,意味着当前线程会一直循环,直到工作者线程上的工作结束,但可能会无谓地消耗CPU资源。如果不将任务调度给工作者线程,而是将任务调度给当前线程,在某个未来的时间执行,这样的轮询就会变得非常危险。当前线程将一直处于对任务的轮询中。之所以会成为无限循环,是因为除非当前线程退出循环,否则任务不会结束。
Task<TResult> 包含一个Result属性,可用从中获取由Task<TResult>执行的Func<TResult>返回值。
Task<T>除了IsCompleted和 Result 属性,还有几个其他地方需要注意:
任务完成后,IsComp