反编译的常见错误_在 .NET 常见的 Async/Await 使用问题

93f82b90cafc11a3612f0e9ace10743c.png

做一个 .NET 的开发者,我相信大家对 async / await 两个关键字非常熟悉,对它们用法也应该是驾轻就熟了。虽然使用 async 让我们的开发过程更加轻松,代码变得清晰,更能清楚的表达业务逻辑,但是引入 async / await 并不是完全无痛的。大部分情况下它们都运行的很好,但是稍一不注意就被带到沟里,下面提到的一些问题都是我自己在工作中经常会忽略的一些情况,所以有必要记录下来提醒自己。

先说说常见的 async/await 用法。

async Task<User> GetUserFromApi(string userId)
{
    var json = await UserApi.UserJsonById(userId);
    return JsonConvert.DeserializeObject<User>(json);
}

就如大家所知道,async 关键字会把这个方法编译成一个实现了 IAsyncStateMachine 的结构,反编译后的代码大概跟下面差不多

[CompilerGenerated]
private struct <GetUserFromApi>d__1 : IAsyncStateMachine
{
	void IAsyncStateMachine.MoveNext() { ... }
	[DebuggerHidden]
	void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine){ ... }
	public int <>1__state;
	public AsyncTaskMethodBuilder<User> <>t__builder;
	public string userId;
	private TaskAwaiter<string> <>u__1;
}

当然我们今天不是来讨论 async / await 的实现细节的,已经有大量的文章已经为我们分析了这些细节。

而在这里我则是要讨论几个在使用 async / await 中常见的一些容易让大家忽视的问题。

在合适的地方使用 .ConfigureAwait(false)

await UserApi.UserJsonById(userId).ConfigureAwait(false);

在我们使用 await 时,在执行完异步任务后,系统会尝试返回之前的线程继续执行。使用 .ConfigureAwait(false) 后系统在执行完异步任务后不会尝试返回之前线程,而是在线程池里面找一个空闲的线程执行接下来的代码,在非UI线程执行 await 任务时非常有用。因为调用异步任务的线程此时可能正在忙着其它事情,没有空接着执行后面的代码。

需要注意的是 .NET Core 上的情况是已经没有 SynchronizationContext 这个机制了,所以理论上无论有没有使用 .ConfigureAwait(false) 结果都一样,但是当你使用 .NET Standard 开发类库时仍建议你在合适的地方使用 .ConfigureAwait(false) ,因为你的类库可能会因为被基于 .NET Framework 的程序使用而造成问题。

更多的细节可以看看这篇文章,里面有对 ConfigureAwait 有更深入的讨论:

Async, Await, and ConfigureAwait – Oh My!​www.skylinetechnologies.com
f95f411c0a4ff68454c25a864eb36a76.png

避免多余的 async 和 await

如果你的函数调用一个异步函数,你的返回值和异步的返回值是一样的,如下:

async Task<User> GetCurrentUser() { return await GetUserById(this.userId); }

async Task<User> GetUserById(string userId) { ... }

这种情况下你可以直接拿掉你函数声明中的 async 和函数中的 await 关键字。这样子能避免生成一个 IAsyncStateMachine 和无用的上下文切换:

Task<User> GetCurrentUser() { return GetUserById(this.userId); }

async Task<User> GetUserById(string userId) { ... }

值得注意的是如果你的函数中存在一个 using 或者 try catch 的话应避免这么做,因为如果不使用 await 的话直接返回 Task ,你其实已经离开 using/try 这个作用域了。比如说如果你调用的函数抛出异常,以下的代码就不会如你预期般捕获这个异常了。

//错误的示范,在这种情况下应该添加 async 和 await 关键字
Task<User> GetCurrentUser()
{
    try
    {
        return GetUserById(this.userId);
    }
    catch (Exception e)
    {
        //没有 await ,所以不可能会捕获到任何异常,这里永远不会被执行
        Console.WriteLine("Exception:", e);
        return null;
    }
}

async Task<User> GetUserById(string userId) { ... } //该函数可能会 throw Exception

使用 ValueTask

当你的 async 函数中存在一个没有 await 的分支,这个分支十有八九会被执行,你应该使用一个 ValueTask,因为它是 struct 在栈上分配,能够避免 GC,比如:

//这里使用 ValueTask 而非 Task
async ValueTask<string> GetCurrentUserName()
{
    if (CurrentUser != null)
        return CurrentUser.UserName;

    try
    {
        return await GetUserNameById(this.userId);
    }
    catch (Exception e)
    {
        Console.WriteLine("Exception:", e);
        return null;
    }
}

正确的避免使用 async void

在实际编写代码中大家都知道要避免使用 async void,但是很多时候确实是有 async void 的正确使用场合,比如下面的代码:

//Startup.cs
public Startup()
{
    //在构建函数中不可以 async await
    Log("startup ctor");
}
async void Log(string message)
{
    await LogBuffer.Push(message);
}

当时如上面所见,这里的 async void 似乎没有什么问题,我们大多数情况下就是下 log 一下,并不关心它什么时候执行完,也不关心他的返回值,而且在构建函数中也不可以使用 async / await。但是仍然 async void 会造成很多“诡异”错误,因为没有“等待”异步函数执行,但是实际上异步函数已经在后台线程执行,所以下面的代码是问题:

//Startup.cs
public Startup()
{
    Log("startup ctor");

    //LogBuffer不是线程安全的,下面的代码会出现什么情况很难预料
    LogBuffer.Clear();
    LogBuffer.Push("other message");
}
async void Log(string message)
{
    await LogBuffer.Push(message);
}

你也许会说,我知道 Log 是一个 async void 方法,在使用的时候会注意,不会出现上面的情况。但是如果其它人引用你的库,他看到的 Log 函数的声明是这样的:

fcb7351f41966de9152fa0a826f33c01.png
不太容易注意到这是个异步方法

对于一个 C# 程序员,这里很容易被认为是一个正常函数而非 async void,这样会出现很多很难预料的情况,比如 try catch 捕捉不到错误之类的。

为了解决上面提出的问题,可以参考 youtube 上一个开发者建议的方法,使用一个 SafeFireAndForget 扩展函数。

https://www.youtube.com/watch?v=J0mcYVxJEl0​www.youtube.com
//Startup.cs
public Startup()
{
    Log("startup ctor").SafeFireAndForget();
}
//改成 async Task
async Task Log(string message)
{
    await LogBuffer.Push(message);
}

3fb338c3bb7499dda780985acd6ca7a3.png
变成 async Task 后,一目了然

这样做可以让使用 Log 的人看到这是一个异步方法(Task),而且在使用 Log 的地方也很容易注意到 SafeFireAndForget 进而提醒使用者这里是一个async Task 。

SafeFireAndForget 的代码你可以从这里找到:https://github.com/brminnick/AsyncAwaitBestPractices/blob/master/Src/AsyncAwaitBestPractices/SafeFireAndForgetExtensions.cs

不要使用 .Result 或者 .Wait()

有时候会看到有人使用这样的代码:

UpdateUserPasswordById(this.userId, newPassword).Wait();
// -- or --
var user = GetUserById(this.userId).Result;

使用 .Result 或者 .Wait() ,这样会在阻塞当前线程同时仍然会启动另外一个后台线程去执行这个函数,得不偿失,大部分情况下我们只需要直接使用 await 关键字即可。

在非常特殊的情况下,如果非得阻塞当前线程去执行这个 Task,可以考虑使用 .GetAwaiter().GetResult()。两者效果基本一样,阻塞当前线程同时会启动另外一个后台线程。但是如果使用 .Result 或 .Wait() 发生错误时会抛出 AggregateExecption ,而使用 .GetAwaiter().GetResult(),则是会抛出实际的 Excepiton ,Excepiton 中的堆栈也符合我们所期望的。

最后,async / await 关键字的加入极大的提升开发效率的同时,也让代码的表达能力更强。但是由于这个语法糖非常的“甜”,一旦出现问题很容易把我们弄晕。所以如有遗漏欢迎补充。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值