C# 异步多线程的本质,上下文流转和同步

引言

.NET 同僚对于async和await的话题真的是经久不衰,这段时间又看到了关于这方面的讨论,最终也没有得出什么结论,其实要弄懂这个东西,并没有那么复杂,简单的从本质上来讲,就是一句话,async 和await异步的本质就是状态机+线程环境上下文的流转,由状态机向前推进执行,上下文进行环境切换。

在状态机向前推进的时候第一次的movenext会将当前线程的环境上下文保存起来,然后由TaskScheduler调度是否去线程池拿新线程执行这个task,等到后续推进到最后的movenext的时候,里面设置好结果,异常之后,回调则需要运行在调用await之前的环境上下文中去,这里说的是环境上下文,而并非是线程。.

所以当前环境上下文在await之前是A线程的上下文,在遇到await结束之后可能是B线程的环境上下文,并且异步是异步,线程是线程,异步不一定多线程,这两个不是等价的,针对async和await的源码刨析可以看一下之前写的博客https://www.cnblogs.com/1996-Chinese-Chen/p/15594498.html,这篇文章针对源码讲了一部分,可能不是很明了,只讲了async await执行的一个顺序对于环境上下文没有过多的描述,接下来,我会讲一些环境上下文,同步上下文的知识,以及在cs程序中,框架对于同步上下文的封装。

环境上下文ExecutionContext

ExecutionContext表示管理当前线程的执行上下文。针对此类,官网的解释是该 ExecutionContext 类为与逻辑执行线程相关的所有信息提供单个容器。在.NET Framework中,这包括安全上下文、调用上下文和同步上下文。

在.NET Core 中,不支持安全上下文和调用上下文,但是,模拟上下文和区域性通常通过执行上下文流动。

简单来说,这个类就是存放当前线程所有环境信息的容器,在net framework 和net core中,略有不同,后者不包括同步上下文,关于同步上下文和ExecutionContext,可以看看官网的另一篇比较好的文章https://devblogs.microsoft.com/pfxteam/executioncontext-vs-synchronizationcontext/ 这篇文章,对于async await异步和上下文做了更加详细的解释。

那么在刚开始我们说了异步的本质之一就是上下文流转,那么什么是流转呢,怎么流转,这个类代表的存放当前线程信息的容器,那我们复制一份这个容器,然后放到另一个线程去,那另一个线程就可以获取到我们上一个线程内部的所有的信息,简单理解就是,搬家的时候我把我的所有东西打包放在我的新房子,那这个新房子也有了我搬家之前的那些信息,这个就是上下文流转,接下来,我们看一下实际在代码中的例子

public class TestTask{    public static AsyncLocal<int> id;}
private async void button1_Click(object sender, EventArgs e){    //var sss = new MyTask(() => { Console.WriteLine(111); });    //await sss;    var exce = ExecutionContext.IsFlowSuppressed();    TestTask.id = new AsyncLocal<int>() { Value = 1 };    // var asynclo=ExecutionContext.SuppressFlow();    var con1 = ExecutionContext.Capture();    var a = ExecutionContext.SuppressFlow();    exce = ExecutionContext.IsFlowSuppressed();    await Task.Delay(1000);    var con2 = ExecutionContext.Capture();    ExecutionContext.Run(con2, s =>    {        var sss = TestTask.id.Value;    }, null);    await Task.Delay(1000);    ExecutionContext.Restore(con1);    var sssa = TestTask.id.Value;}

在上面的代码中,我首先定义了一个AsyncLocal 存放int类型的一个变量,在winform中我界面添加一个按钮,在点击事件中写下了如下代码,在第一行代码中调用了ExecutionContext.IsFlowSuppressed方法,这个方法是判断是否停止当前上下文的流转,在刚开始运行的时候,这个返回结果是False,说明我们没有停止流转,是可以正常流转,在第二行代码中,我们给AsyncLocal变量赋值,设置Value为1;

第三行中,我们使用了ExecutionContext.Capture方法,这个方法是捕获当前上下文信息,然后赋值给了con1变量,在往下走,我们调用了SuppressFlow方法,这个方法是我们阻止了当前上下文的流转,也就是说这个上下文是和await之后的上下文是不一样的,然后我们在判断IsFlowSuppressed的时候返回的就是true了,停止了流转,然后我们异步Delay1秒,然后我们捕获异步之后的当前线程的上下文信息,然后在这里我们捕获我们这个线程的上下文信息,接下来调用了ExecutionContext.Run方法,这个方法是将第二个参数的委托代码,运行在指定的上下文中去,这块这个run方法我们用不用其实都不影响演示效果,在这代码中,我们获取到id.Value就和上面的不一样获取的是默认值0,而不是上面定义的1,这就是因为我们停止了上下文流转,导致await前后不是同一个上下文,所以获取不到这个Value,如果我们不调用SuppressFlow,那在await之后就是上一个的上下文信息,获取到的Value也就是原来的1,在往下走,我们在Delay一下,在调用Restore方法,这个方法是将当前线程的上下文替换为指定的上下文信息,将指定上下文信息还原到当前线程,然后在获取的Value就是1了。

在ExectuionContext方法中有几个方法,就是Capture这个是静态捕获当前上下文信息,CreateCopy这个是实例方法,返回当前上下文信息的副本,IsFlowSuppressFlow判断是否停止上下文流转,SuppressFlow是停止上下文流转,Restore是将捕获的上下文信息还原到当前线程,当然了还有一个方法,和SuppressFlow方法对应,一个停止一个是恢复,叫RestoreFlow回复当前上下文在异步线程之间的流动,但是呢在async这个场景中是不适合这种情况的,是有一个报错,这个报错是当前上下文并没有停止上下文流转,这个是为什么呢,且听我娓娓道来。

我们都知道,线程的发展是Thread,Threadpool,再到现在的Task,然后Task是基于Threadpool封装的,那么我们在使用await Task之后的线程,是由Threadpool指定的,那他指定的线程不一定是await前的线程,就导致了你await之后恢复上下文流动的时候提示你上下文并没有停止流动,因为线程不一样导致的这个问题,就是说你SuppressFlow是另一个线程,await之后的是另一个线程,你RestoreFlow另一个线程,那肯定会报错啊,所以我们是需要使用Restore方法,将我们之前捕获的上下文信息还原到当前线程,这样,我们后续在获取Value的时候就可以获取到结果了。

这块还需要讲解一个问题就是,在上一段中,我们说了,Task的线程都是由Threadpool分配的,就会导致某些代码执行的线程是由Threadpool分配,那这个问题就导致了原有的Thread方面的东西是不能做线程数据传递的,例如,ThreadLocal,ThreadStaticAttribute特性,这些都是不能玩了的,因为使用Task,Threadpool的线程,我们不知道前后是否一样,那ThreadLocal和ThreadStatic 每一个线程是每一个线程的数据我们就会获取不到,这一点,大家在使用的时候还需要了解到。

SynchronizationContext

上面讲的ExecutionContext可以叫是线程环境上下文,SynchronizationContext提供在各种同步模型中传播同步上下文的基本功能。可以称它为线程同步上下文。如果ExectuionContext是整个环境信息的容器,那这个类是暴露给你整个环境信息的接口,虽然Execution也可以做不同线程之间的同步,但是你把所有的都暴露那总归是不好的,你能把你家的东西都让他知道吗,很显然不能,这个SynchronizationContext每个线程都可以设置自己的同步上下文信息,可以重写这个类,也可以就使用这个类去进行异步或者同步的分派信息到某个线程的上下文中去,同步使用Send方法,传入SendOrPostCallBack委托和委托需要的参数。

如果我们在线程中获取SynchronizationContext.Current的时候为空,null,我们可以创建一个SynchronizationContext的变量,var context=new SynchronizationContext();然后调用SynchronizationContext.SetSynchronizationContext(context);为当前线程设置同步上下文,需要在其他线程同步的时候 只需要context.Post方法或者context.Send方法即可同步。

此外,在CS程序中,winform,wpf都由针对SynchronizationContext类重写以便实现框架层面的需要,因为在cs程序中,所有控件的创建修改删除,等操作,都应该是由UI线程去完成,如果跨线程则会报错,同时在cs程序中使用了async和await,在await之后的环境上下文和同步上下文都是await之前的数据,所以在cs中await之后操作UI是不会有任何问题的,如果是需要在子线程中操作UI控件,则需要获取SynchronizationContext.Current对象获取当前同步上下文,或者使用winform重写之后的类WinformSynchronizationContext.Current获取同步上下文对象,然后去进行Post或者Send操作UI控件就不会报错。

在微信群讨论的时候,群友们在讨论跨线程操作的问题,便说到了这块,另外有个老哥说到,在子线程创建控件对象添加到窗体中,然后在操作的时候会报错,针对这个,我测试了之后,在子线程中创建TextBox,主线程给Text赋值,不会报错导致一场,然后我就猜测控件都是继承于Control类,那应该是Control类和SynchronizationContext类做了关联,导致虽然是子线程创建的对象,但是同样是属于主线程的,随后我去翻看了源码,验证了我的猜想。在下面的图中,如果我们在子线程new TextBox(),是走到了Contrl()这个构造方法,然后走到了internal Control的构造方法,参数autoInstallSyncContext是true,

C# 异步多线程的本质,上下文流转和同步

然后调用了WindowsFormSynchronizationContext.InstallIfNeeded()方法,在这个方法我们最终看到子线程创建的控件最终还是属于UI线程的同步上下文的,为此我用代码做了验证。

C# 异步多线程的本质,上下文流转和同步
C# 异步多线程的本质,上下文流转和同步

在代码中执行这段代码,在Task.Run里面加入断点,就可以看到,在new TextBox之前,SynchronizationContext.Current获取到的是null,在之后获取到的是WindowsFormsSynchronizationContext的对象,由此可以看出所有的Control控件,哪怕都在子线程中创建,其也依旧属于UI线程。

await AddText();this.Controls.Add(TextBox) ;JextBox.Text = "111”;
public  Task AddText(){    var con=WindowsFormsSynchronizationContext.Current;    return  Task.Run(() =>    {        var c = SynchronizationContext.Current;        TextBox = new TextBox();        var b = SynchronizationContext.Current;    });}

结语

对于async和await,更深层次的其实还是上下文流转,用不用新线程,是有TaskScheduler决定,线程复用是有ThreadPool决定,并且,异步不一定开启新线程,那不然委托异步,控件异步 是不是都开了新线程。