P1 线程(Thread):创建线程
什么是线程Thread
- 线程是一个可执行路径,它可以独立于其它线程执行
- 每个线程都在操作系统的进程(Process)内执行,而操作系统进程提供了程序运行的独立环境。
- 单线程应用,在进程的独立环境里 只跑一个线程,所以该线程拥有独占权。
- 多线程应用,单个进程中会跑多个线程,它们会共享当前的执行环境(尤其是内存)
- 例如,一个线程在后台读取数据,另一个线程在数据到达后进行展示。
- 这个数据就被称作是共享的状态。
例子:
class Program
{
static void Main(string[] args)
{
Thread thread = new Thread(WriteY); //开启一个新的线程 Thread
thread.Name = "Y Thread...";
thread.Start();
for (int i = 0; i < 1000; i++)
Console.WriteLine("x");
Console.ReadKey();
}
private static void WriteY()
{
for(int i = 0; i < 1000; i++)
{
Console.WriteLine("y");
}
}
}
- 在单核计算机上,操作系统必须为每个线程分配“时间片”(在Windows中通常为20毫秒)来模拟并发,从而导致重复的x和y块。
- 在多核或多处理器计算机上,这两个线程可以真正地并行执行(可能受到计算机上其他活动进程的竞争)。
术语:线程被抢占
- 线程在什么时候可以称为被抢占了:它的执行与另一个线程上代码的执行交织的那一刻。
线程的一些属性
- 线程一旦开始执行,IsAlive就是true,线程结束就变成false。
- 线程结束的条件就是:线程构造函数传入的委托结束了执行。
- 线程一旦结束,就无法再重启。
- 每个线程都有个Name属性,通常用于调试
- 线程Name只能设置一次,以后更改会抛出异常。(System.InvalidOperationException:“该属性已经设置,不能修改。”)
- 静态的Thread.CurrentThread属性,会返回当前执行的线程。
P2 Thread.Join()&Thread.Sleep()
Join and Sleep
-
调用Join方法,就可以等待另一个线程结束。
例子:
class Program
{
static void Main(string[] args)
{
Thread t = new Thread(WriteY);
t.Start();
t.Join();//当前线程会等待t线程执行结束
Console.WriteLine("线程结束");
Console.Read();
}
private static void WriteY()
{
for(int i = 0; i < 1000; i++)
{
Console.Write("y");
}
}
}
class Program
{
static Thread thread1, thread2;
static void Main(string[] args)
{
thread1 = new Thread(ThreadProc);
thread1.Name = nameof(thread1);
thread1.Start();
thread2 = new Thread(ThreadProc);
thread2.Name = nameof(thread2);
thread2.Start();
Console.Read();
}
private static void ThreadProc()
{
Console.WriteLine($"\nCurrent Thread:{Thread.CurrentThread.Name}");
if (Thread.CurrentThread.Name == nameof(thread1) &&
thread2.ThreadState != ThreadState.Unstarted)
thread2.Join();
Thread.Sleep(4000);
Console.WriteLine($"\nCurrent thread:{Thread.CurrentThread.Name}");
Console.WriteLine($"Thread1:{thread1.ThreadState}");
Console.WriteLine($"Thread2:{thread2.ThreadState}");
}
}
输出内容:
Current Thread:thread1
Current Thread:thread2
Current thread:thread2
Thread1:WaitSleepJoin
Thread2:Running
Current thread:thread1
Thread1:Running
Thread2:Stopped
添加超时
- 调用Join的时候,可以设置一个超时,用毫秒或者TimeSpan都可以。
- 如果返回true,那就是线程结束了,如果超时了,就返回false。
例子:
class Program
{
static Thread thread1, thread2;
static void Main(string[] args)
{
thread1 = new Thread(ThreadProc);
thread1.Name = nameof(thread1);
thread1.Start();
thread2 = new Thread(ThreadProc);
thread2.Name = nameof(thread2);
thread2.Start();
Console.Read();
}
private static void ThreadProc()
{
Console.WriteLine($"\nCurrent Thread:{Thread.CurrentThread.Name}");
if (Thread.CurrentThread.Name == nameof(thread1) &&
thread2.ThreadState != ThreadState.Unstarted)
if (thread2.Join(2000))
Console.WriteLine("线程2结束");
else
Console.WriteLine("线程2超时了");
Thread.Sleep(4000);
Console.WriteLine($"\nCurrent thread:{Thread.CurrentThread.Name}");
Console.WriteLine($"Thread1:{thread1.ThreadState}");
Console.WriteLine($"Thread2:{thread2.ThreadState}");
}
}
输出:
Current Thread:thread1
Current Thread:thread2
线程2超时了
Current thread:thread2
Thread1:WaitSleepJoin
Thread2:Running
Current thread:thread1
Thread1:Running
Thread2:Stopped
-
Thread.Sleep()方法会暂停当前的线程,并等待一段时间。
-
注意:
- Thread.Sleep(0)这样调用会导致线程立即放弃本身当前的时间片,自动将CPU移交给其他线程。
- Thread.Yield()做同样的事情,但是它只会把执行交给同一处理器上的其它线程。
- 当等待Sleep或Join的时候,线程处于阻塞的状态。
Sleep(0)或Yield有时在高级性能调试的生产代码中很有用。它也是一个很好的诊断工具,有助于发现线程安全问题:
如果在代码中的任何地方插入Thread.Yield()就破坏了程序,那么你的程序几乎肯定有bug。
P3 阻塞Blocking
阻塞
-
如果线程的执行由于某种原因导致暂停,那么就认为该线程被阻塞了。
- 例如在Sleep或者通过Join等待其它线程结束。
-
被阻塞的线程会立即将其处理器的时间片生成给其它线程,从此就不再消耗处理器时间,直到满足其阻塞条件为止。
-
可以通过ThreadState这个属性来判断线程是否处于被阻塞的状态:
bool blocked = (thread.ThreadState & ThreadState.WaitSleepJoin) != 0;
ThreadState
-
ThreadState是一个flags enum,通过按位的形式,可以合并数据的选项。
// // 摘要: // 指定 System.Threading.Thread 的执行状态。 [ComVisible(true)] [Flags] public enum ThreadState { // // 摘要: // 线程已启动且尚未停止。 Running = 0, // // 摘要: // 正在请求线程停止。 这仅用于内部。 StopRequested = 1, // // 摘要: // 正在请求线程挂起。 SuspendRequested = 2, // // 摘要: // 线程正作为后台线程执行(相对于前台线程而言)。 此状态可以通过设置 System.Threading.Thread.IsBackground 属性来控制。 Background = 4, // // 摘要: // 尚未对线程调用 System.Threading.Thread.Start 方法。 Unstarted = 8, // // 摘要: // 线程已停止。 Stopped = 16, // // 摘要: // 线程已被阻止。 这可能是调用 System.Threading.Thread.Sleep(System.Int32) 或 System.Threading.Thread.Join、请求锁定(例如通过调用 // System.Threading.Monitor.Enter(System.Object) 或 System.Threading.Monitor.Wait(System.Object,System.Int32,System.Boolean))或在线程同步对象上(例如 // System.Threading.ManualResetEvent)等待的结果。 WaitSleepJoin = 32, // // 摘要: // 线程已挂起。 Suspended = 64, // // 摘要: // 已对线程调用了 System.Threading.Thread.Abort(System.Object) 方法,但线程尚未收到试图终止它的挂起的 System.Threading.ThreadAbortException。 AbortRequested = 128, // // 摘要: // 线程状态包括 System.Threading.ThreadState.AbortRequested 并且该线程现在已死,但其状态尚未更改为 System.Threading.ThreadState.Stopped。 Aborted = 256 }
- 四个最有用的值之一:Unstarted、Running、WaitSleepJoin和Stopped
- ThreadState属性可用于诊断的目的,但不适用于同步,因为线程状态可能会在测试ThreadState和对该信息进行操作之间发生变化。
接触阻塞
- 当遇到下列四种情况的时候,就会接触阻塞:
- 阻塞条件被满足
- 操作超时
- 通过Thread.Interrupt()进行打断
- 通过Thread.Abort()进行中止
上下文切换
- 当线程阻塞或解除阻塞时,操作系统将执行上下文切换。这会产生少量开销,通常为1或2微秒。
I/O-bound vs Compute-bound(或CPU-Bound)
- 一个花费大部分时间等待某事发生的操作称为I/O-bound
- I/O绑定操作通常涉及输入或输出,但这不是硬性要求:Thread.Sleep()也被视为I/O-bound
- 相反,一个花费大部分时间执行CPU密集型工作的操作称为Compute-bound。
阻塞 vs 忙等待(自旋)
Blocking vs Spinning
-
IO-bound操作的工作方式有两种:
- 在当前线程上同步的等待
- Console.ReadLine(),Thread.Sleep(),Thread.Join()
- 异步的操作,在稍后操作完成时触发一个回调动作。
- 在当前线程上同步的等待
-
同步等待的I/O-bound操作将大部分时间花在阻塞线程上。
-
它们也可以周期性的在一个循环里进行“打转(自旋)”
while(DateTime.Now < nextStartTime);
-
在忙等待和阻塞方面有一些细微差别。
- 首先,如果希望条件很快得到满足(可能在几微秒之内),则短暂自旋可能会很有效,因为它避免了上下文切换的开销和延迟。
- .Net Framework提供了特殊的方法和类来提供帮助SpinLock和SpinWait。
- 其次,阻塞也不是零成本。这是因为每个线程在生存期间会占用大约1MB内存,并会给CLR和操作系统带来持续的管理开销。
- 因此,在需要处理成百上千个并发操作的大量I/O-bound程序的上下文中,阻塞可能会很麻烦。
- 所以,此类程序需要使用基于回调的方法,在等待时完全撤销其线程。
- 首先,如果希望条件很快得到满足(可能在几微秒之内),则短暂自旋可能会很有效,因为它避免了上下文切换的开销和延迟。
P4 什么是线程安全
本地 vs 共享的状态
Local 本地独立
- CLR为每个线程分配自己的内存栈(Stack),以便使本地变量保持独立。
Shared共享
- 如果多个线程都引用到同一个对象的实例,那么它们就共享了数据。
- 被Lambda表达式或匿名委托所捕获的本地变量,会被编译器转化为字段(field),所以也会被共享。
- 静态字段(field)也会在线程间共享数据。
线程安全 Thread Safety
- 尽可能的避免使用共享状态。
锁定与线程安全简介
- 在读取和写入共享数据的时候,通过使用一个互斥锁(exclusive lock)。
- C#使用lock语句来加锁。
- 当两个线程同时竞争一个锁的时候(锁可以基于任何引用类型对象),一个线程会等待或阻塞,直到锁变成可用状态。
- 在多线程上下文中,以这种方式避免不确定性的代码就叫做线程安全。
- Lock不是线程安全的银弹,很容易忘记对字段加锁,lock也会引起一些问题(死锁)。
P5 向线程传递数据&异常处理
向线程传递数据
- 如果你想往线程的启动方法里传递参数,最简单的方式是使用lambda表达式,在里面使用参数调用方法。(例子Lambda)
- 甚至可以把整个逻辑都放在lambda里面。
向线程传递数据在C#3.0之前
-
在C#3.0之前,没有lambda表达式。可以使用Thread的Start方法来传递参数。
-
Thread的重载构造函数可以接受下列两个委托之一作为参数:
public delegate void ThreadStart(); public delegate void ParameterizedThreadStart(object obj);
Lambda表达式与被捕获的变量
- 使用Lambda表达式可以很简单的给Thread传递参数。但是线程开始后,可能会不小心修改了被捕获的变量,这要多加注意。
异常处理
- 创建线程时在作用范围内的try/catch/finally块,在线程开始执行后就与线程无关了。
- 在WPF、WinForm里,可以订阅全局异常处理事件:
- Application.DispatcherUnhandledException
- Application.ThreadException
- 在通过消息循环调用的程序的任何部分发生未处理的异常后,将触发这些异常。
- 但是非UI线程上的未处理异常,并不会触发它。
- 而任何线程有任何未处理的异常都会触发
- AppDomain.CurrentDomain.UnhandledException
P6 前台线程 vs 后台线程
Foreground vs Background Threads
- 默认情况下,手动创建的线程就是前台线程。
- 只要有前台线程在运行,那么应用程序就会一直处于活动状态。
- 但是后台线程却不行。
- 一旦所有的前台线程停止,那么应用程序就停止了。
- 任何的后台线程也会突然终止。
- 注意:线程的前台、后台状态与它的优先级无关(所分配的执行时间)。
- 可以通过IsBackground属性判断线程是否是后台线程。
- 进程以这种形式终止的时候,后台线程执行栈中的finally块就不会被执行了。
- 如果想让它执行,可以在退出程序时使用Join来等待后台线程
- 应用程序无法正常退出的一个常见原因是还有活跃的前台线程。
P7 线程优先级
线程优先级
- 线程的优先级(Thread的Priority属性)决定了相对于操作系统中其它活跃线程所占的执行时间。
- 优先级分为:
- enum ThreadPriority{Lowest, BelowNormal, Normal, AboveNormal, Highest}
提升线程优先级
-
提升线程优先级的时候需特别注意,因为它可能“饿死”其它线程。
-
如果想让某线程(Thread)的优先级比其它进程(Process)中的线程(Thread)高,那就必须提升进程(Process)的优先级。
-
使用System.Diagnostics下的Process类。
using (Process p = Process.GetCurrentProcess()) { p.PriorityClass = ProcessPriorityClass.High; }
-
-
这可以很好地用于只做少量工作且需要较低延迟的非UI进程。
-
对于需要大量计算的应用程序(尤其是有UI的应用程序),提高进程优先级可能会使其它进程饿死,从而降低整个计算机的速度。
P8 信号介绍
信号 Signaling
- 有时,需要让某个线程一直处于等待的状态,直至接收到其它线程发来的通知。这就叫做signaling(发送信号)。
- 最简单的信号结构就是ManualResetEvent。
- 调用它上面的WaitOne方法会阻塞当前的线程,直到另一个线程通过调用Set方法来开启信号。
- 调用完Set之后,信号会处于“打开”的状态。可以通过调用Reset方法将其再次关闭。
P9 富客户端应用处理耗时操作的一种办法
富客户端应用程序的线程
- 在WPF、UWP、WinForm等类型的程序中,如果在主线程执行耗时的操作,就会导致整个程序无响应。因为主线程同时还需要处理消息循环,而渲染和鼠标键盘事件处理等工作都是消息循环来执行的。
- 针对这种耗时的操作,一种流行的做法是启用一个worker线程。
- 执行完操作后,再更新到UI
- 富客户端应用的线程模型通常是:
- UI元素和控件只能从创建它们的线程来进行访问(通常是主UI线程)。
- 当想从worker线程更新UI的时候,必须把请求交给UI线程。
- 比较底层的实现是:
- WPF,在元素的Dispatcher对象上调用BeginInvoke或Invoke。
- WinForm,调用控件的BeginInvoke或Invoke。
- UWP,调用Dispatcher对象上的RunAsync或Invoke。
- 所有这些方法都接收一个委托。
- BeginInvoke或RunAsync通过将委托排队到UI线程的消息队列来执行工作。
- Invoke执行相同的操作,但随后会进行阻塞,直到UI线程读取并处理消息。
- 因此,Invoke允许从方法中获取返回值。
- 如果不需要返回值,BeginInvoke/RunAsync更可取,因为它不会阻塞调用方法,也不会引入死锁的可能性。
P10 Synchronization Context
Synchronization Contexts 同步上下文
- 在System.Threading;下有一个类:SynchronizationContext,它使得Thread Marshaling得到泛化。
- 针对移动、桌面(WPF、UWP,WinForms)等富客户端应用的API,它们都定义和示例化了SynchronizationContext的子类。
- 可以通过静态属性SynchronizationContext.Current来获得(当运行在UI线程时)
- 捕获该属性让你可以在稍后的时候从worker线程向UI线程发送数据。
- 调用Post就相当于调用Dispatch或Control上面的BeginInvoke方法。
- 还有一个Send方法,它等价于Invoke方法。
P11 线程池
线程池 Thread Pool
- 当开始一个线程的时候,将花费几百微秒来组织类型一下的内容:
- 一个新的局部变量栈(Stack)
- 线程池就可以节省这种开销:
- 通过预先创建一个可循环使用线程的池来减少这一开销。
- 线程池对于高效的并行编程和细粒度并发是必不可少的。
- 它允许在不被线程启动的开销淹没的情况下运行短期操作。
使用线程池需要注意的几点
- 不可以设置线程的Name
- 池线程都是后台线程
- 阻塞池线程可使性能降级
- 你可以*的更改池线程的优先级
- 当它释放回池的时候优先级将还原为正常状态
- 可以通过Thread.CurrentThread.IsThreadPoolThread属性来判断是否执行在池线程上。
进入线程池
- 最简单的、显式的在池线程运行代码的方式就是使用Task.Run
谁使用了线程池
- WCF、Remoting、ASP.Net、ASMX Web Services应用服务器
- System.Timers.Timer、System.Threading.Timer
- 并行编程结构
- BackgroundWorker类(现在很多余)
- 异步委托(现在很多余)
线程池中的整洁
- 线程池提供了另一个功能,即确保临时超出计算-Bound的工作不会导致CPU超额订阅。
- CPU超额订阅:活跃的线程超过CPU的核数,操作系统就需要对线程进行时间切片。
- 超额订阅对性能影响很大,时间切片需要昂贵的上下文切换,并且可能使CPU缓存失效,而CPU缓存对于现代处理器的性能至关重要。
CLR的策略
- CLR通过对任务排队并对其启动进行节流限制来避免线程池中的超额订阅。
- 它首先运行尽可能多的并发任务(只要还有CPU核),然后通过爬山算法调整并发级别,并在特定方向上不断调整工作负载。
- 如果吞吐量提高,它将继续朝同一方向(否则将反转)。
- 这确保它始终追随最佳性能曲线,即使面对计算机上竞争的进程活动时也是如此。
- 如果下面两点能满足,那么CLR的策略将发挥出最佳效果:
- 工作项大多是短时间运行的(<250毫秒,或者理想情况下<100毫秒),因此CLR有很多机会进行测量和调整。
- 大部分时间都被阻塞的工作项不会主宰线程池。
- 如果想充分利用CPU,那么保持线程池的“整洁”是非常重要的。
P12 开始一个Task
Thread的问题
- 线程(Thread)是用来创建并发(concurrency)的一种低级别工具,它有一些限制,尤其是:
- 虽然开始线程的时候可以方便的传入数据,但是当Join的时候,很难从线程获得返回值。
- 可能需要设置一些共享字段。
- 如果操作抛出异常,捕获和传播该异常都很麻烦。
- 无法告诉线程在结束时开始做另外的工作,你必须进行Join操作(在进程中阻塞当前的线程)
- 很难使用较小的并发(concurrent)来组建大型的并发。
- 导致了对手动同步的更大依赖以及随之而来的问题。
- 虽然开始线程的时候可以方便的传入数据,但是当Join的时候,很难从线程获得返回值。
Task Class
- Task类可以很好的解决上述问题
- Task是一个相对高级的抽象:它代表了一个并发操作(concurrent)
- 该操作可能由Thread支持,或不由Thread支持
- Task是可组合的(可使用Continuation把他们串成链)
- Task可以使用线程池来减少启动延迟
- 使用TaskCompletionSource,Tasks可以利用回调的方式,在等待I/O绑定操作时完全避免线程。
开始一个Task
Task.Run
- Task类在System.Threading.Tasks命名空间下。
- 开始一个Task最简单的办法就是使用Task.Run(.Net4.5,4.0的时候是Task.Factory.StartNew)这个静态方法:
- 传入一个Action委托即可。
- Task默认使用线程池,也就是后台线程:
- 当主线程结束时,你创建的所有tasks都会结束。
- Task.Run返回一个Task对象,可以使用它来监视其过程
- 在Task.Run之后,我们没有调用Start,因为该方法创建的是“热”任务(hot task)
- 可以通过Task的构造函数创建“冷”任务(cold task),但是很少这样做。
- 在Task.Run之后,我们没有调用Start,因为该方法创建的是“热”任务(hot task)
- 可以通过Task的Status属性来跟踪task的执行状态。
Wait 等待
- 调用task的Wait方法会进行阻塞直到操作完成。
- 相当于调用thread上的Join方法。
- Wait也可以让你指定一个超时时间和一个取消令牌来提前结束等待。
Long-running tasks 长时间运行的任务
-
默认情况下,CLR在线程池中运行Task,这非常适合短时间运行的Compute-Bound类工作。
-
针对长时间运行的任务或者阻塞操作,你可以不采用线程池。
Task task = Task.Factory.StartNew(() => { }, TaskCreationOptions.LongRunning);
-
如果同时运行多个long-running tasks(尤其是其中有处于阻塞状态的),那么性能将会受很大影响,这时有比TaskCreationOptions.LongRunning更好的办法:
- 如果任务是IO-Bound,TaskCompletionSource和异步函数可以让你用回调(Coninuations)代替线程来实现并发。
- 如果任务是Compute-Bound,生产者/消费者队列允许你对任务的并发性进行限流,避免把其它线程和进程饿死。
P13 Task的返回值
Task的返回值
- Task有一个泛型子类叫做Task
,它允许发出一个返回值。 - 使用Func
委托或兼容的Lambda表达式来调用Task.Run就可以得到Task . - 随后,可以通过Result属性来获取返回的结果。
- 如果这个Task还没有完成操作,访问Result属性会阻塞该线程直到task完成操作。
- Task
可以看作是一种所谓的“未来/许诺”(future、promise),在它里面包裹着一个Reuslt,在稍后的时候就会变得可用。
P14 Task的异常
Task异常
- 与Thread不一样,Task可以很方便的传播异常
- 如果你的Task里面抛出了一个未处理的异常(故障),那么该异常就会重新被抛出给:
- 调用了Wait()的地方
- 访问了Task
的Result属性的地方。
- 如果你的Task里面抛出了一个未处理的异常(故障),那么该异常就会重新被抛出给:
- CLR将异常包裹在AggregateException里,以便在并行编程场景中发挥很好的作用。
- 无需重新抛出异常,通过Task的IsFaulted和IsCanceled属性也可以检测出Task是否发生了故障:
- 如果两个属性都返回false,那么就没有错误发生。
- 如果IsCanceled为True,那就说明一个OperationCanceledException为该Task抛出了。
- 如果IsFaulted为true,那就说明另一个类型的异常被抛出了,而Exception属性也将指明错误。
异常与“自治”的Task
- 自治的,“设置完就不管了”的Task。就是指不通过调用Wait()方法、Result属性或continuation进行回合的任务。
- 针对自治的Task,需要像Thread一样,显式的处理异常,避免发生“悄无声息的故障”。
- 自治Task上未处理的异常称为未观察到的异常。
未观察到的异常
- 可以通过全局的TaskScheduler.UnobservedTaskException来订阅未观察到的异常
- 关于什么是“未观察到的异常”,有一些细微的差别:
- 使用超时进行等待的Task,如果在超时后发生故障,那么它将会产生一个“未观察到的异常”
- 在Task发生故障后,如果访问Task的Exception属性,那么该异常就被认为是“已观察到的”。