[C#]剖析异步编程语法糖: async和await

时间:2022-07-25 08:20:56

一、难以被接受的async

自从C#5.0,语法糖大家庭又加入了两位新成员: async和await。

然而从我知道这两个家伙之后的很长一段时间,我甚至都没搞明白应该怎么使用它们,这种全新的异步编程模式对于习惯了传统模式的人来说实在是有些难以接受,不难想象有多少人仍然在使用手工回调委托的方式来进行异步编程。
C#中的语法糖非常多,从自动属性到lock、using,感觉都很好理解很容易就接受了,为什么偏偏async和await就这么让人又爱又恨呢?

我想,不是因为它不好用(相反,理解了它们之后是非常实用又易用的),而是因为它来得太迟了!
传统的异步编程在各种语言各种平台前端后端差不多都是同一种模式,给异步请求传递一个回调函数,回调函数中再对响应进行处理,发起异步请求的地方对于返回值是一无所知的。我们早就习惯了这样的模式,即使这种模式十分蹩脚。
而async和await则打破了请求发起与响应接收之间的壁垒,让整个处理的逻辑不再跳过来跳过去,成为了完全的线性流程!线性才是人脑最容易理解的模式!

广告时间:
[C#]async和await刨根问底
这篇随笔把本文未解决的问题都搞定了,并且对async和await的总体面貌做了最终总结,对调查过程没有兴趣希望直接看结果的可以直接戳进去~

二、理解async,谁被异步了

如果对于Java有一定认识,看到async的使用方法应该会觉得有些眼熟吧?

//Java
synchronized void sampleMethod() { }
// C#
async void SampleMethod() { }

说到这里我想对MS表示万分的感谢,幸好MS的设计师采用的简写而不是全拼,不然在没有IDE的时候(比如写上面这两个示例的时候)我不知道得检查多少次有没有拼错同步或者异步的单词。。。
Java中的synchronized关键字用于标识一个同步块,类似C#的lock,但是synchronized可以用于修饰整个方法块。
而C#中async的作用就是正好相反的了,它是用于标识一个异步方法。
同步块很好理解,多个线程不能同时进入这一区块,就是同步块。而异步块这个新东西就得重新理解一番了。

先看看async到底被编译成了什么吧:

 .method private hidebysig
instance void SampleMethod () cil managed
{
.custom instance void [mscorlib]System.Runtime.CompilerServices.AsyncStateMachineAttribute::.ctor(class [mscorlib]System.Type) = (
1f 2e 6f 6d 2b
3c 6d 6c 4d 6f 3e 5f
5f
)
.custom instance void [mscorlib]System.Diagnostics.DebuggerStepThroughAttribute::.ctor() = ( )
// Method begins at RVA 0x20b0
// Code size 46 (0x2e)
.maxstack
.locals init (
[] valuetype Test.Program/'<SampleMethod>d__0',
[] valuetype [mscorlib]System.Runtime.CompilerServices.AsyncVoidMethodBuilder
) IL_0000: ldloca.s
IL_0002: ldarg.0
IL_0003: stfld class Test.Program Test.Program/'<SampleMethod>d__0'::'<>4__this'
IL_0008: ldloca.s
IL_000a: call valuetype [mscorlib]System.Runtime.CompilerServices.AsyncVoidMethodBuilder [mscorlib]System.Runtime.CompilerServices.AsyncVoidMethodBuilder::Create()
IL_000f: stfld valuetype [mscorlib]System.Runtime.CompilerServices.AsyncVoidMethodBuilder Test.Program/'<SampleMethod>d__0'::'<>t__builder'
IL_0014: ldloca.s
IL_0016: ldc.i4.m1
IL_0017: stfld int32 Test.Program/'<SampleMethod>d__0'::'<>1__state'
IL_001c: ldloca.s
IL_001e: ldfld valuetype [mscorlib]System.Runtime.CompilerServices.AsyncVoidMethodBuilder Test.Program/'<SampleMethod>d__0'::'<>t__builder'
IL_0023: stloc.1
IL_0024: ldloca.s
IL_0026: ldloca.s
IL_0028: call instance void [mscorlib]System.Runtime.CompilerServices.AsyncVoidMethodBuilder::Start<valuetype Test.Program/'<SampleMethod>d__0'>(!!&)
IL_002d: ret
} // end of method Program::SampleMethod

不管你们吓没吓到,反正我第一次看到是吓了一大跳。。。之前的空方法SampleMethod被编译成了这么一大段玩意。
另外还生成了一个名叫'<SampleMethod>d__0'的内部结构体,整个Program类的结构就像这样:
[C#]剖析异步编程语法糖: async和await

其他的暂时不管,先尝试把上面这段IL还原为C#代码:

 void SampleMethod()
{
'<SampleMethod>d__0' local0;
AsyncVoidMethodBuilder local1; local0.'<>4_this' = this;
local0.'<>t__builder' = AsyncVoidMethodBuilder.Create();
local0.'<>1_state' = -; local1 = local0.'<>t__builder';
local1.Start(ref local0);
}

跟进看Start方法:

 // System.Runtime.CompilerServices.AsyncVoidMethodBuilder
[__DynamicallyInvokable, DebuggerStepThrough]
public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine
{
this.m_coreState.Start<TStateMachine>(ref stateMachine);
}

继续跟进:

 // System.Runtime.CompilerServices.AsyncMethodBuilderCore
[DebuggerStepThrough, SecuritySafeCritical]
internal void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine
{
if (stateMachine == null)
{
throw new ArgumentNullException("stateMachine");
}
Thread currentThread = Thread.CurrentThread;
ExecutionContextSwitcher executionContextSwitcher = default(ExecutionContextSwitcher);
RuntimeHelpers.PrepareConstrainedRegions();
try
{
ExecutionContext.EstablishCopyOnWriteScope(currentThread, false, ref executionContextSwitcher);
stateMachine.MoveNext();
}
finally
{
executionContextSwitcher.Undo(currentThread);
}
}

注意到上面黄底色的stateMachine就是自动生成的内部结构体'<SampleMethod>d__0',再看看自动生成的MoveNext方法,IL就省了吧,直接上C#代码:

 void MoveNext()
{
bool local0;
Exception local1; try
{
local0 = true;
}
catch (Exception e)
{
local1 = e;
this.'<>1__state' = -;
this.'<>t__builder'.SetException(local1);
return;
} this.'<>1__state' = -;
this.'<>t__builder'.SetResult()
}

因为示例是返回void的空方法,所以啥也看不出来,如果在方法里头稍微加一点东西,比如这样:

async void SampleMethod()
{
Thread.Sleep();
Console.WriteLine("HERE");
}

然后再看看SampleMethod的IL:

 .method private hidebysig
instance void SampleMethod () cil managed
{
.custom instance void [mscorlib]System.Runtime.CompilerServices.AsyncStateMachineAttribute::.ctor(class [mscorlib]System.Type) = (
1f 2e 6f 6d 2b
3c 6d 6c 4d 6f 3e 5f
5f
)
.custom instance void [mscorlib]System.Diagnostics.DebuggerStepThroughAttribute::.ctor() = ( )
// Method begins at RVA 0x20bc
// Code size 46 (0x2e)
.maxstack
.locals init (
[] valuetype Test.Program/'<SampleMethod>d__0',
[] valuetype [mscorlib]System.Runtime.CompilerServices.AsyncVoidMethodBuilder
) IL_0000: ldloca.s
IL_0002: ldarg.0
IL_0003: stfld class Test.Program Test.Program/'<SampleMethod>d__0'::'<>4__this'
IL_0008: ldloca.s
IL_000a: call valuetype [mscorlib]System.Runtime.CompilerServices.AsyncVoidMethodBuilder [mscorlib]System.Runtime.CompilerServices.AsyncVoidMethodBuilder::Create()
IL_000f: stfld valuetype [mscorlib]System.Runtime.CompilerServices.AsyncVoidMethodBuilder Test.Program/'<SampleMethod>d__0'::'<>t__builder'
IL_0014: ldloca.s
IL_0016: ldc.i4.m1
IL_0017: stfld int32 Test.Program/'<SampleMethod>d__0'::'<>1__state'
IL_001c: ldloca.s
IL_001e: ldfld valuetype [mscorlib]System.Runtime.CompilerServices.AsyncVoidMethodBuilder Test.Program/'<SampleMethod>d__0'::'<>t__builder'
IL_0023: stloc.1
IL_0024: ldloca.s
IL_0026: ldloca.s
IL_0028: call instance void [mscorlib]System.Runtime.CompilerServices.AsyncVoidMethodBuilder::Start<valuetype Test.Program/'<SampleMethod>d__0'>(!!&)
IL_002d: ret
} // end of method Program::SampleMethod

看出来什么变化了吗?????看不出来就对了,因为啥都没变。
那追加的代码跑哪去了?!在这呢:

 void MoveNext()
{
bool local0;
Exception local1; try
{
local0 = true;
Thread.Sleep();
Console.WriteLine("HERE");
}
catch (Exception e)
{
local1 = e;
this.'<>1__state' = -;
this.'<>t__builder'.SetException(local1);
return;
} this.'<>1__state' = -;
this.'<>t__builder'.SetResult()
}

至今为止都没看到异步在哪发生,因为事实上一直到现在确实都是同步过程。Main方法里这么写:

static void Main(string[] args)
{
new Program().SampleMethod();
Console.WriteLine("THERE");
Console.Read();
}

运行结果是这样的:

HERE
THERE

"THERE"被"HERE"阻塞了,并没有异步先行。
虽然到此为止还没看到异步发生,但是我们可以得出一个结论:
async不会导致异步

到底怎么才能异步?还是得有多个线程才能异步嘛,是时候引入Task了:

async void SampleMethod()
{
Task.Run(() =>
{
Thread.Sleep();
Console.WriteLine("HERE");
});
}

Main方法不变,运行结果是这样的:

THERE
HERE

当然,把SampleMethod前头的async去掉也可以得到同样的结果。。。
所以async貌似是个鸡肋啊?然而并不是这样的!

三、理解await,是谁在等

继续改造上面的SampleMethod,不过现在还得加一个GetHere的方法了:

async void SampleMethod()
{
Console.WriteLine(await GetHere());
} Task<string> GetHere()
{
return Task.Run(() =>
{
Thread.Sleep();
return "HERE";
});
}

Main方法仍然不变,运行结果也没有变化。但是现在就不能去掉async了,因为没有async的方法里头不允许await!
首先要注意的是,GetHere方法的返回值是Task<string>,而从运行结果可以看出来WriteLine的重载版本是string参数,至于为什么,之后再看。
这一次的结论很容易就得出了,很明显主线程没有等SampleMethod返回就继续往下走了,而调用WriteLine的线程则必须等到"HERE"返回才能接收到实参。
那么,WriteLine又是哪个线程调用的?
这一次可以轻车熟路直接找MoveNext方法了。需要注意的是,现在Program类里头已经变成了这副德性:

[C#]剖析异步编程语法糖: async和await

这个时候try块里头的IL已经膨胀到了50行。。。还原为C#后如下:

 bool '<>t__doFinallyBodies';
Exception '<>t__ex';
int CS$$;
TaskAwaiter<string> CS$$;
TaskAwaiter<string> CS$$; try
{
'<>t__doFinallyBodies' = true;
CS$$ = this.'<>1__state';
if (CS$$ != )
{
CS$$ = this.'<>4__this'.GetHere().GetAwaiter();
if (!CS$$.IsCompleted)
{
this.'<>1__state' = ;
this.'<>u__$awaiter1' = CS$$;
this.'<>t__builder'.AwaitUnsafeOnCompleted(ref CS$$, ref this);
'<>t__doFinallyBodies' = false;
return;
}
}
else
{
CS$$ = this.'<>u__$awaiter1';
this.'<>u__$awaiter1' = CS$$;
this.'<>1__state' = -;
} Console.WriteLine(CS$$.GetResult());
}

貌似WriteLine仍然是主线程调用的?!苦苦等待返回值的难道还是主线程?!

四、异步如何出现

感觉越看越奇怪了,既然主线程没有等SampleMethod返回,但是主线程又得等到GetResult返回,那么异步到底是怎么出现的呢?
注意到第20行的return,主线程跑进了这一行自然就直接返回了,从而不会发生阻塞。
那么新的问题又来了,既然MoveNext在第20行就直接return了,谁来再次调用MoveNext并走到第30行?

MoveNext方法是实现自IAsyncStateMachine接口,借助于ILSpy的代码解析,找到了三个调用方:

[C#]剖析异步编程语法糖: async和await

第一个是之前看到的,SampleMethod内部调用到的方法,后两个是接下来需要跟踪的目标。
调试模式跟到AsyncMethodBuilderCore的内部,然后在InvokeMoveNext和Run方法的首行打断点,设置命中条件为打印默认消息并继续执行。
最后在Main函数和lambda表达式的首行也打上同样的断点并设置打印消息。F5执行,然后可以在即时窗口中看到如下信息:

Function: Test.Program.Main(string[]), Thread: 0xE88 主线程
Function: Test.Program.GetHere.AnonymousMethod__3(), Thread: 0x37DC 工作线程
Function: System.Runtime.CompilerServices.AsyncMethodBuilderCore.MoveNextRunner.Run(), Thread: 0x37DC 工作线程
Function: System.Runtime.CompilerServices.AsyncMethodBuilderCore.MoveNextRunner.InvokeMoveNext(object), Thread: 0x37DC 工作线程

这样至少弄明白了一点,"HERE"是由另一个工作线程返回的。
看不明白的是,为什么lambda的执行在两次MoveNext被调用之前。。。从调用堆栈也得到有用的信息,这个问题以后有空再深究吧。。。

五、Task<TResult> to TResult

正如之前所说,GetHere方法的返回值是Task<string>,WriteLine接收的实参是string,这是怎么做到的呢?
关键当然就是调用GetHere时候用的await了,如果去掉await,就会看到这样的结果:

System.Threading.Tasks.Task`[System.String]
THERE

这一次GetHere的返回又跑到"THERE"的前头了,因为没有await就没有阻塞,同时GetHere的本质也暴露了,返回值确确实实就是个Task。
这个时候再去看MoveNext里头的代码就会发现,try块里的代码再次变清净了。。。而这一次WriteLine的泛型参数就变成了object。
关键中的关键在于,这一个版本中不存在TaskAwaiter,也不存在TaskAwaiter.GetResult(详情参见上一段代码第30行)。
GetResult的实现如下:

 // System.Runtime.CompilerServices.TaskAwaiter<TResult>
[__DynamicallyInvokable, TargetedPatchingOptOut("Performance critical to inline across NGen image boundaries")]
public TResult GetResult()
{
TaskAwaiter.ValidateEnd(this.m_task);
return this.m_task.ResultOnSuccess;
}

这就是Task<TResult>转变为TResult的地方了。

六、使用示例

扯了这么多,扯得这么乱,我自己都晕乎了。。。
到底该怎么用嘛,看示例吧:

 void PagePaint()
{
Console.WriteLine("Paint Start");
Paint();
Console.WriteLine("Paint End");
} void Paint()
{
Rendering("Header");
Rendering(RequestBody());
Rendering("Footer");
} string RequestBody()
{
Thread.Sleep();
return "Body";
}

假设有这么个页面布局的方法,依次对头部、主体和底部进行渲染,头部和底部是固定的内容,而主体需要额外请求。
这里用Sleep模拟网络延时,Rendering方法其实也就是对Console.WriteLine的简单封装而已。。。
PagePaint运行过后,结果是这样的:

Paint Start
Header
Body
Footer
Paint End

挺正常的结果,但是Header渲染完以后页面就阻塞了,这个时候用户没法对Header进行操作。
于是就进行这样的修正:

 async void Paint()
{
Rendering("Header");
Rendering(await RequestBody());
Rendering("Footer");
} async Task<string> RequestBody()
{
return await Task.Run(() =>
{
Thread.Sleep();
return "Body";
});
}

运行结果变成了这样:

Paint Start
Header
Paint End
Body
Footer

这样就能在Header出现之后不阻塞主线程了。

不过呢,Footer一直都得等到Body渲染完成后才能被渲染,这个逻辑现在看来还没问题,因为底部要相对于主体进行布局。
然而我这时候又想给页面加一个广告,而且是fixed定位的那种,管啥头部主体想盖住就盖住,你们在哪它不管。
比如这样写:

 async void Paint()
{
Rendering(await RequestAds());
Rendering("Header");
Rendering(await RequestBody());
Rendering("Footer");
}

出现了很严重的问题,头部都得等广告加载好了才能渲染,这样显然是不对的。
所以应该改成这样:

 async void Paint()
{
PaintAds();
Rendering("Header");
Rendering(await RequestBody());
Rendering("Footer");
} async void PaintAds()
{
string ads = await Task.Run(() =>
{
Thread.Sleep();
return "Ads";
});
Rendering(ads);
}

这样的运行结果就算令人满意了:

Paint Start
Header
Paint End
Ads
Body
Footer

最后想说的是,看IL比看bytecode实在麻烦太多了,CSC对代码动的手脚比JavaC多太多了。。。然而非常值得高兴的是,MS所做的这一切,都是为了让我们写的代码更简洁易懂,我们需要做的,就是把这些语法糖好好地利用起来。