C# 异步编程基础(一)线程和阻塞
此入门教程是记录下方参考资料视频的过程
开发工具:Visual Studio 2019
目录
C# 异步编程基础(六)Continuation 继续/延续 、TaskCompletionSource、实现 Task.Delay
C# 异步编程基础(十) 取消(cancellation)、进度报告、TAP(Task-Based Asynchronous Pattern)、Task组合器
线程
- 线程是一个可执行路径,它可以独立于其它线程执行
- 每一个线程都在操作系统的进程(Process)内支线,而操作系统进程提供了程序运行的独立环境
- 单线程应用,在进程的独立环境里只跑一个线程,所以该线程拥有独占权
- 多线程应用,单个进程中会跑多个线程,它们会共享当前的执行环境(尤其是内存)
例如,一个线程在后台读取数据,另一个线程在数据到达后进行展示
这个数据就被称作是共享的状态 - 例子:
- 在单核计算机上,操作系统必须为每个线程分配“时间片”(在Windows中通常为20毫秒)来模拟并发,从而导致重复的x和y块
- 在多核或多处理器计算机上,这两个线程可以真正地并行执行(可能受到计算机上其它活动进程的竞争)
- 在本例中,由于控制台处理并发请求的机制的微妙性,您仍然会得到重复的x和y块
public static void Main()
{
//开辟了一个新的线程 Thread
Thread t=new Thread(WriteY);
t.Start();//运行WirteY
Console.WriteLine("Thread t has ended!");
//同时在主线程也做一些工作
for(int i=0;i<10;i++)
{
Console.Write("x");
}
}
public static void WriteY()
{
for(int i=0;i<1000;i++)
{
Console.Write("y");
}
}
- 术语:线程被抢占
线程在这个时候就可以被称为抢占了:它的执行与另外一个线程上代码的执行交织的那一点
线程的一些属性
- 线程一旦开始执行,IsAlive就是true,线程结束就变成false
- 线程结束的条件就是:线程构造函数传入的委托结束了执行
- 线程一旦结束,就无法再重启
- 每个线程都有个Name属性,通常用于调试
线程Name只能设置一次,以后更改就会抛出异常 - 静态的Thread.CurrentThread属性,会返回当前执行的线程
例子
public static void Main()
{
//开辟了一个新的线程 Thread
Thread t=new Thread(WriteY);
t.Name="Y Thread ...";
t.Start();//运行WirteY
Console.WriteLine("Thread.CurrentThread.Name");
//同时在主线程也做一些工作
for(int i=0;i<10;i++)
{
Console.Write("x");
}
}
public static void WriteY()
{
Console.WriteLine(Thread.CurrentThread.Name);
for(int i=0;i<1000;i++)
{
Console.Write("y");
}
}
结果
Join and Sleep
- 调用Join方法,就可以等待另一个线程结束
可以理解为“等待该线程终止”,也就是在子线程调用了Join()方法后面的代码,只有等到子线程结束了才能执行。
例子
public static void Main()
{
Thread t=new Thread(Go);
t.Start();
t.Join();
Console.WriteLine("Thread t has ended!");
}
public static void Go()
{
for(int i=0;i<1000;i++)
{
Console.Write("y");
}
}
结果
- 添加超时:
调用Join的时候,可以设置一个超时,用毫秒或者TimeSpan都可以
如果返回true,那就是线程结束了
如果超时了(在限制时间内未完成),就返回false
例子
static Thread thread1, thread2;
public static void Main()
{
thread1 = new Thread(ThreadProc);
thread1.Name = "Thread1";
thread1.Start();
thread2 = new Thread(ThreadProc);
thread2.Name = "Thread2";
thread2.Start();
}
private static void ThreadProc()
{
Console.WriteLine("\nCurrent thread:{0}", Thread.CurrentThread.Name);
if (Thread.CurrentThread.Name == "Thread1" && thread2.ThreadState != ThreadState.Unstarted)
{
if (thread2.Join(2000))
{
Console.WriteLine("Thread2 has termminated.");
}
else
{
Console.WriteLine("The timeout has elapsed and Thread1 will resume.");
}
}
Thread.Sleep(4000);
Console.WriteLine("\nCurrent thread:{0}", Thread.CurrentThread.Name);
Console.WriteLine("Thread1:{0}", thread1.ThreadState);
Console.WriteLine("Thread2:{0}", thread2.ThreadState);
}
结果
- Thread.Sleep()方法会暂停当前的线程,并等一段时间,参数可以时毫秒,也可以是TimeSpan
例子
public static void Main()
{
for(int i=0;i<5;i++)
{
Console.WriteLine("Sleep for 2 seconds");
Thread.Sleep(2000);
}
Console.WriteLine("Main thread exits");
}
结果就是每过 2000 毫秒输出一个 Sleep for 2 seconds ,最后输出 Main thread exits
- 注意:
- Thread.Sleep(0)这样调用会导致线程立即放弃当前的时间片,自动将CPU移交给其它线程
- Thread.Yield()做同样的事情,但是它只会把执行交给同一处理器上的其它线程
- 当等待Sleep或Join的时候,线程处于阻塞的状态
- Sleep(0)或Yield有时在高级性能调试的生产代码中很有用。它也是一个很好的诊断工具,有助于发现线程安全问题:
如果在代码中的任何地方插入Threa.Yield()就破坏了程序,那么你的程序几乎肯定有bug
阻塞 Blocking
- 如果线程的执行由于某种原因被导致暂定,那么就认定该线程被阻塞了
例如在Sleep()或者通过Join()等待其它线程的结束 - 被阻塞的线程会立即将其处理器的时间片生成给其它线程,从此不会再消耗处理器时间,直到满足其阻塞条件为止
可以通过ThreadState这个属性来判断线程是否处于被阻塞的状态
bool blocked=(someThread.ThreadState & ThreadState.WaitSleepJoin)!=0;
必须这样写
ThreadState
- ThreadState是一个flags enum,通过按位的形式,可以合并数据的选项
- 但是它大部分的枚举值都没什么用,下面的代码将ThreadState剥离为四个最有用的值之一:Unstarted、Running、WaitSleepJoin和Stopped
public static ThreadState SimpleThreadState(ThreadState ts)
{
return ts&(ThreadState.Unstarted|ThreadState.WaitSleepJoin|ThreadState.Stopped);
}
- ThreadState属性可用于诊断的目的,但不适用于同步,因为线程状态可能会在测试ThreadState和对该信息进行操作之间发生变化
解除阻塞 Unblocking
- 当遇到下列四种情况的时候,就会解除阻塞:
阻塞条件被满足
操作超时(如果设置超时的话)
通过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)
{
Thread.Sleep(100);
}
//彻底的忙等待(自旋),CPU一直在工作
while(DateTime.Now < nextStartTime);
- 在忙等待和阻塞方面有有一些细微差别
首先,如果您希望条件很快得到满足(可能在几微秒之内),则短暂自旋可能会很有效,因为它避免了上下文切换的开销和延迟
.NET Framework提供了特殊的方法和类来提供帮助SpinLock和SpinWait
其次,阻塞也不是零成本。这是因为每个线程在生存期间会占用大约 1 MB的内存,并会给CLR和操作系统带来持续的管理开销
因此,在需要处理成百上千个并发操作的大量I/O-bound程序的上下文中,阻塞可能会很麻烦
所以,此类程序需要使用基于回调的方法,在等待时完全撤销其线程