.net core底层入门学习笔记(八)
本篇主要记录.net中的异步操作
文章目录
前言
异步操作表示,执行某项操作以后不等操作结束,但可以额外在操作结束后收到通知。
一、阻塞操作与事件循环
前面提到线程是CPU可以执行的一段任务。线程可以进入休眠,等待被操作系统唤醒继续执行,切换线程有一定的性能成本。
很多时候程序需要调用一些阻塞操作(比如一些IO,一些网络连接等),来让操作系统调度执行时使得线程进入等待状态,等到操作完成后再重新把线程放入等待运行的队列中等待唤醒执行。这样做会带来问题,一旦这样的阻塞操作很多,量很大,意味着要开启很多个线程,而开启线程的内存消耗,CPU占用,以及切换线程等都会造成性能问题。
为了解决这个问题,最初的办法就是使用事件循环机制,例如跨平台的select接口,Linux的epoll接口,OSX的kqueue接口。程序需要使用一个或多个线程用于获取事件,然后替换执行阻塞操作换为非阻塞操作,注册事件用于在处理完成后接收通知。这样发出阻塞操作的线程,可以变为1个,且1个线程就可以同时创建多个阻塞操作。在循环中不断接收并处理事件,针对不同事件,以及事件回调的数据,执行对应的回调,完成整个操作。
基于事件循环编写程序有一定难度,且很多代码结构是相似的,因此在此基础上(事件循环基础)封装一套框架,称为异步操作,异步操作提供了基于回调的机制,会先执行非阻塞操作,注册事件并关联回调,接收到事件后自动调用之前关联的回调。著名的异步操作框架:C语言libevent,C++的ASIO,JAVA的Netty。部分操作系统也提供了原生接口,Windows的IOCP,Linux的AIO。
二、.net中的异步编程模型
.net框架本身就支持基于回调的异步操作:APM,例如TCP连接函数等,可使用这个模式,再回调中再次回调,从而连成一串复杂的处理流程(现在基本上放弃这种回调地狱式写法了)
.net中的异步编程模型,基于不同操作系统的不同实现,在运行时内部有一定数量线程用于等待事件和执行回调。
注意:调用回调的线程与执行异步回调的线程不一定相同。异步编程模型基于事件循环机制实现,部分线程专门用于等待和处理事件,调用回调则使用.net内部的线程池,不直接使用处理回调事件的线程来调用回调的原因是,可能会影响其他待处理事件。
三、任务并行库
使用基于回调的异步编程模型通用性不够强,且每个异步回调都需要编写自己独有的回调函数。
.net在此基础上封装了一层以任务为基础的接口,即现在大名鼎鼎的Task。
注意:Task基于异步编程模型,异步编程模型,基于事件循环回调机制。
任务并行库的最大特点是分了执行异步操作与注册回调的处理。使得任何异步操作都有相同的方法注册回调、等待结束、处理错误。
异步操作返回System.Threading.Tasks.Task类型,或Task<.T>类型,前者用于表示异步操作没有返回值,后者表示又T类型的返回值。利用返回的Task类型,调用ContinueWith方法可以注册在异步操作完成后的调用的回调方法。注意任务并行库调用回调的线程与执行异步操作的线程不一定相同,但创建Task时可传入基于TaskScheduler类型的对象自定义调度方式。
任务并行库的实现原理
任务并行库在Task类中的结构:
- m_action,在任务运行中的委托对象,如果使用的是承诺-将来模式,则这里为null
- m_continuationObject,任务完成后的回调,可能为null,单个回调或回调列表
- m_contingentProperties,保存不常用的项目,需要时会分配,主要有以下几种:1.m_capturedContext,创建任务时捕捉的执行上下文;2.m_completionEvent,需要同步等待任务结束时,创建的事件对象;3.m_exceptionsHolder,保存任务执行过程中发生的异常;4.m_completionCountdown:当前未完成的子任务数量+1,+1代表了自己;5.m_exceptionalChildren,发生异常的子任务列表;6.m_parent,父任务。
任务并行库使用的两种方式:
1.直接指定委托,调用Task.Run(Action)和Task.Factory.StartNew(Action)开始,调用task.continueWith注册回调,委托Action会放入内部的线程池进行调用,任务结束后调用注册的回调。
2.当作承诺对象使用,承诺-将来模式,把异步分为两个对象,承诺对象负责设置结果或发生异常,用户执行完操作后使用这个对象来设置结果或异常;将来对象,用于注册回调,并接受承诺对象设置的结果或异常。通常用法是获取一个承诺对象,与之关联将来对象,将来对象设置回调,承诺对象在异步完成后设置结果。
在任务并行库中,承诺对象与将来对象都在Task类中实现,但承诺对象不对外开放,只能通过TaskCompletionSouce类调用承诺对象,使用方式:
var promise = new TaskCompletionSouce<object>();
//获取TaskCompletionSouce中的Task为将来对象
var future = promise.Task;
//将来对象的回调注册
future.ContinueWith(arr =>{
...
})
//一波异步操作,通过TaskCompletionSouce设置承诺对象的结果
promise.SetResult(null);
任务并行库,还提供了子任务的支持,父任务会等待所有子任务完成才完成,并且子任务发生的异常会传递到父任务的回调中。
async与await关键字的实现
之前在ET杂记中有一篇专门讲述ET中ETTask如何实现异步的笔记,可以结合一起看,下面先贴出代码查看.net中如何利用状态机帮我们实现了async与await机制。
namespace dotcore_test
{
class Program
{
static void Main(string[] args)
{
Task task = ConnetTest(IPAddress.Any, 8888, 5);
}
private static async Task ConnetTest(IPAddress address, int port, int x)
{
var tcpClient = new TcpClient();
try
{
await tcpClient.ConnectAsync(address,port);
var bytes = BitConverter.GetBytes(x);
var stream = tcpClient.GetStream();
await stream.WriteAsync(bytes, 0, bytes.Length);
Console.WriteLine("connet and send {0}");
}
catch (SocketException e)
{
Console.WriteLine(e);
throw;
}
}
}
}
// Decompiled with JetBrains decompiler
// Type: dotcore_test.Program
// Assembly: dotcore_test, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
// MVID: 9AFBF37E-94F4-4084-9701-74571DD02522
// Assembly location: E:\dotcore_test\bin\Debug\net5.0\dotcore_test.dll
// Compiler-generated code is shown
using System;
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
namespace dotcore_test
{
internal class Program
{
private static void Main(string[] args)
{
Program.ConnetTest(IPAddress.Any, 8888, 5);
}
[AsyncStateMachine(typeof (Program.<ConnetTest>d__1))]
[DebuggerStepThrough]
private static Task ConnetTest(IPAddress address, int port, int x)
{
Program.<ConnetTest>d__1 stateMachine = new Program.<ConnetTest>d__1();
stateMachine.<>t__builder = AsyncTaskMethodBuilder.Create();
stateMachine.address = address;
stateMachine.port = port;
stateMachine.x = x;
stateMachine.<>1__state = -1;
stateMachine.<>t__builder.Start<Program.<ConnetTest>d__1>(ref stateMachine);
return stateMachine.<>t__builder.Task;
}
public Program()
{
base.\u002Ector();
}
[CompilerGenerated]
private sealed class <ConnetTest>d__1 : IAsyncStateMachine
{
public int <>1__state;
public AsyncTaskMethodBuilder <>t__builder;
public IPAddress address;
public int port;
public int x;
private TcpClient <tcpClient>5__1;
private byte[] <bytes>5__2;
private NetworkStream <stream>5__3;
private SocketException <e>5__4;
private TaskAwaiter <>u__1;
public <ConnetTest>d__1()
{
base.\u002Ector();
}
void IAsyncStateMachine.MoveNext()
{
int num1 = this.<>1__state;
try
{
switch (num1)
{
case 0:
case 1:
try
{
TaskAwaiter awaiter1;
int num2;
TaskAwaiter awaiter2;
switch (num1)
{
case 0:
awaiter1 = this.<>u__1;
this.<>u__1 = new TaskAwaiter();
this.<>1__state = num2 = -1;
break;
case 1:
awaiter2 = this.<>u__1;
this.<>u__1 = new TaskAwaiter();
this.<>1__state = num2 = -1;
goto label_11;
default:
awaiter1 = this.<tcpClient>5__1.ConnectAsync(this.address, this.port).GetAwaiter();
if (!awaiter1.IsCompleted)
{
this.<>1__state = num2 = 0;
this.<>u__1 = awaiter1;
Program.<ConnetTest>d__1 stateMachine = this;
this.<>t__builder.AwaitUnsafeOnCompleted<TaskAwaiter, Program.<ConnetTest>d__1>(ref awaiter1, ref stateMachine);
return;
}
break;
}
awaiter1.GetResult();
this.<bytes>5__2 = BitConverter.GetBytes(this.x);
this.<stream>5__3 = this.<tcpClient>5__1.GetStream();
awaiter2 = this.<stream>5__3.WriteAsync(this.<bytes>5__2, 0, this.<bytes>5__2.Length).GetAwaiter();
if (!awaiter2.IsCompleted)
{
this.<>1__state = num2 = 1;
this.<>u__1 = awaiter2;
Program.<ConnetTest>d__1 stateMachine = this;
this.<>t__builder.AwaitUnsafeOnCompleted<TaskAwaiter, Program.<ConnetTest>d__1>(ref awaiter2, ref stateMachine);
return;
}
label_11:
awaiter2.GetResult();
Console.WriteLine("connet and send {0}");
this.<bytes>5__2 = (byte[]) null;
this.<stream>5__3 = (NetworkStream) null;
break;
}
catch (SocketException ex)
{
this.<e>5__4 = ex;
Console.WriteLine((object) this.<e>5__4);
throw;
}
default:
this.<tcpClient>5__1 = new TcpClient();
goto case 0;
}
}
catch (Exception ex)
{
this.<>1__state = -2;
this.<tcpClient>5__1 = (TcpClient) null;
this.<>t__builder.SetException(ex);
return;
}
this.<>1__state = -2;
this.<tcpClient>5__1 = (TcpClient) null;
this.<>t__builder.SetResult();
}
[DebuggerHidden]
void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine)
{
}
}
}
}
具体的逻辑慢慢看,总能看懂,需要注意的点:
1.执行状态机时,有可能awaiter中包含的任务已经完成,需要设置状态机状态。
2.初始状态为-1,如果处于等待awaiter1时,状态机状态为0,如果处于等待awaiter2时,状态机状态为1
3.this.<>t__builder.AwaitUnsafeOnCompleted此函数,内部有许多流转,最重要功能就是设置回调。
async与await可以与任务并行库解耦,定义了Awaitable-Awaiter模式,awaitable对象负责创建与获取awaiter对象。如果一个对象内,定义了GetAwaiter方法,且此方法返回了一个实现了INoifyCompletion接口的对象(即Awaiter对象),那么此对象就是waitable对象,这样可以脱离任务并行库使用async与await。ET中就实现了自己的一套异步方式方式。
额外的Awaiter对象可以直接实现ICriticalNotifyCompletion接口,这个接口继承INoifyCompletion接口。区别是实现ICriticalNotifyCompletion接口不会恢复执行上下文。
堆积的协程与无堆的协程
不占用线程的异步函数称为协程(注意Unity中实现了协程的一种)。堆积的协程,通过用户层线程调度实现,需要依赖栈空间的方式,例如go中的协程,每次执行异步操作,保存当前寄存器与栈空间地址,恢复后再切换回来。无堆的协程,通过回调实现,不需要依赖栈空间。堆积的协程优点:无需动态分配内存保存回调数据,无堆携程优点:占用内存小,且无需支持栈空间扩张。
四、异步本地变量与执行上下文
线程本地变量不适用于异步操作代码,因为异步操作(特指使用任务并行库的异步操作)中执行异步的线程和回调的线程不一定相同。
.net实现了异步本地变量,通过执行上下文(注意区分线程上下文)实现。每个托管线程对象都会保存一个执行上下文对象(用于保存异步本地变量的数据类型)。
任务并行库创建Task时会记录当前托管线程的执行上下文,并且在执行回调之前先备份当前线程的执行上下文,然后恢复之前Task记录的执行上下文,执行完回调之后,恢复之前备份的当前线程的执行上下文。
具体步骤:
- 当前托管线程的执行上下文保存在Thread.m_ExecutionContext成员中;
- 默认执行上下文保存在全局变量ExecutionContext.Default,里面没有任何异步本地变量
- 创建Task时,调用ExecutionContext.Capture()方法获取当前托管线程的执行上下文:1.如果当前没有禁止捕捉,则返回当前线程的执行上下文(如果当前执行上下文为null则返回默认执行上下文)2.禁止捕捉则返回null
- 如果返回的执行上下文不为默认执行上下文,且不为null,则将当前执行上下文记录到Task.m_contingentProperties.m_capturedContext成员中
- 异步操作完成后调用回调时,检查Task中记录的执行上下文是否为null:1.如果为null则直接执行回调;2.如果不为null,则调用ExecutionContext.RunInternal方法并传入执行上下文与回调。具体:备份当前的执行上下文(因为可能已经换了一个托管线程了),设置当前执行上下文为传入的执行上下文,执行委托(回调),执行完毕后恢复当前上下文为备份的上下文。
注意,执行上下文是一个不变对象,每次修改异步本地变量都会创建一个新的执行上下文覆盖当前的执行上下文,且修改不会反应到调用来源。调用异步之前,可以调用ExecutionContext.SuppressFlow()禁止捕捉当前执行上下文,即回调时无法恢复异步执行变量值。禁止捕捉,可以通过SuppressFlow()返回值AsyncFlowControl类型的Undo()恢复捕捉。
五、同步上下文
前面提到的都是资源被多个线程访问,提供的安全机制;另外一种安全机制就是让特定资源只能被指定的线程访问,实现了让指定线程执行指定操作。如何在.net中的一个线程,指定另外一个线程,倚靠的是同步上下文机制。
同步上下文分为两个部分,一个发送部分,一个接受部分。.net运行时提供了发送部分的基础类,如果想使用同步上下文,必须提供自己的实现。发送有两种方式:Send,将委托发送到指定位置,并等待执行完毕,Post,将委托发送到指定位置,但不等待执行完毕。
每个线程都可以有自己同步上下文,也可以没有,有些特殊线程自带同步上下文实现(例如WinForm线程)。
默认情况下,await会自动捕捉之前的线程同步上下文,调用回调时(即await后续的代码执行),会自动使用之前记录的同步上下文来进行回调处理。具体可看下面的逻辑
class Program
{
class TestSynchronizationContext:SynchronizationContext
{
public override void Send(SendOrPostCallback d, object? state)
{
Console.WriteLine("({0} trhead88888)",Thread.CurrentThread.ManagedThreadId);
}
public override void Post(SendOrPostCallback d, object? state)
{
Console.WriteLine("({0} trhead000000)",Thread.CurrentThread.ManagedThreadId);
_workItems.Enqueue((d, state));
}
private readonly ConcurrentQueue<(SendOrPostCallback Callback, object State)> _workItems;
public TestSynchronizationContext()
{
_workItems = new ConcurrentQueue<(SendOrPostCallback Callback, object State)>();
var thread = new Thread(StartLoop);
Console.WriteLine("TestSynchronizationContext.ThreadId:{0}", thread.ManagedThreadId);
thread.Start();
void StartLoop()
{
while (true)
{
if (_workItems.TryDequeue(out var workItem))
{
workItem.Callback(workItem.State);
}
}
}
}
}
static async Task Main(string[] args)
{
var context = new TestSynchronizationContext();
SynchronizationContext.SetSynchronizationContext(context);
Console.WriteLine("({0} trhead11111)",Thread.CurrentThread.ManagedThreadId);
await Task.Delay(1000);
Console.WriteLine("({0} trhead22222)",Thread.CurrentThread.ManagedThreadId);
}
结果展示:
TestSynchronizationContext.ThreadId:5
(1 trhead11111)
(6 trhead000000)
(5 trhead22222)
上面构建了一个同步上下文,并设置了当前线程的上下文为同步上下文,同时构造函数内,开启一个新5号线程,不断从_workItems队列中取出回调进行执行,看到新开的线程id是5。
还可以看到的是,调用await,会默认将后续代码封装成回调,然后调用同步上下文的Post进行处理(经过反复验证,发现只有在设置了自定义同步上下文中的线程使用await Task时,才会用Post处理,第一次await时,换了新线程后,再调用await时,新线程需要再次设置同步上下文,才能保证第二次await又能调用同步上下文的Post隐式处理)。如果没有设置同步上下文,则使用默认的同步上下文。正如之前说的await Task回调执行的,并不一定是之前的线程,这里执行回调(即Post的线程是6号),但是使用的同步上下文确实是之前设置的同步上下文,能够将其回调放入_workItems中。然后再同步上下文构造函数中的5号线程中,不断取出回调执行,执行Console.WriteLine("({0} trhead22222)"的是5号线程。
结论:
1.使用await方式,执行回调的不一定是之前的线程,但是这个线程的同步上下文是之前线程设置的同步上下文。利用此特性,可以让所有awaiter后的代码全部都回到某个线程进行执行。
2.注意在函数中首次使用await Task时,执行回调的线程(可能与之前的线程不一样,具体得看Task或者类Task的实现机制),会利用当前上下文的Post执行,第二个await不会再进入Post了(因为是新的线程,新线程没有设置同步上下文),所以建议如果需要走同步上下文的方式,使用显示同步上下文的Post或Send方式。
3.每个线程都可以有一个同步对象,使用通信时,可以使用变量的方式,让线程之间通过这个变量来Post或Send,然后处理回调都放到一个统一的线程中,避免了线程之间资源访问问题
4.使用同步上下文,需要注意死锁问题,特别是使用Send方法,需要等待执行完毕,而其他线程又发送委托到同步上下文中进行等待,最佳使用方式,就是在同步上下文执行委托的线程中,不进行阻塞操作。
总结
本篇主要记录了,异步操作的.net实现,基于事件循环机制;简化异步操作的,任务并行库Task;基于Task实现的强大async与await语法,及其内部状态机的实现机制;执行上下文实现的异步本地变量(Task会自动捕捉替换);同步上下文实现的,指定线程执行指定操作(await会利用当前线程的同步上下文Post执行回调)。