C#异步提示和技巧
原文地址:https://cpratt.co/async-tips-tricks/
将sync方法运行为“async”
Task.Run(() => DoSyncStuff());
从技术上讲,这是假的异步。它仍然阻塞,但它运行在后台线程上。这对于防止使用桌面/移动应用程序阻止UI线程非常有用。在Web应用程序上下文中,这几乎没有意义,因为每个线程都来自同一个池,用于处理主(请求)线程来自的请求,并且在完成所有操作之前不会返回响应。
将async方法作为同步运行
对于这个,我们有一个从微软借来的助手类。它看起来是各种命名空间,但总是作为内部命名空间,因此您不能直接从框架中使用它。
public static class AsyncHelper
{
private static readonly TaskFactory _taskFactory = new
TaskFactory(CancellationToken.None,
TaskCreationOptions.None,
TaskContinuationOptions.None,
TaskScheduler.Default);
public static TResult RunSync<TResult>(Func<Task<TResult>> func)
=> _taskFactory
.StartNew(func)
.Unwrap()
.GetAwaiter()
.GetResult();
public static void RunSync(Func<Task> func)
=> _taskFactory
.StartNew(func)
.Unwrap()
.GetAwaiter()
.GetResult();
}
然后
AsyncHelper.RunSync(() => DoAsyncStuff());
放弃上下文
当您await
进行异步操作时,默认情况下会传递调用代码的上下文。这可能会对性能产生不小的影响。如果您以后不需要恢复该上下文,那么它只是浪费了资源。您可以通过附加ConfigureAwait(false)
到您的通话来阻止此行为:
await DoSomethingAsync().ConfigureAwait(false);
你应该总是这样做,除非有特定的理由保持上下文。某些情况包括您需要访问特定GUI组件或需要从控制器操作返回响应时。
但重要的是,每个操作都有自己的上下文,因此您可以安全地ConfigureAwait(false)
在需要维护上下文的代码调用的异步方法中使用; 你只是无法使用ConfigureAwait(false)
该方法本身。例如:
public async Task<IActionResult> Foo()
{
// No `ConfigureAwait(false)` here
await DoSomethingAsync();
return View();
}
...
public async Task DoSomethingAsync()
{
// This is fine
await DoSomethingElseAsync().ConfigureAwait(false);
}
因此,您可以并且应该将需要维护上下文的多个异步操作分解为单独的方法,因此您只需要保留上下文一次,而不是N次。例如:
public async Task<IActionResult> Foo()
{
await DoFirstThingAsync();
await DoSecondThingAsync();
await DoThirdThingAsync();
return View();
}
在这里,每个操作都获得调用代码的上下文的副本,并且由于我们需要该上下文,因此使用ConfigureAwait(false)
不是一个选项。但是,通过重构以下代码,我们只需要调用代码的上下文的单个副本。
public async Task DoThingsAsync()
{
await DoFirstThingAsync().ConfigureAwait(false);
await DoSecondThingAsync().ConfigureAwait(false);
await DoThirdThingAsync().ConfigureAwait(false);
}
public async Task<IActionResult> Foo()
{
await DoThingsAsync();
return View();
}
异步和垃圾收集
在同步代码中,局部变量进入堆栈并在超出范围时被丢弃。但是,由于在等待异步操作时发生上下文切换,因此必须保留这些局部变量。框架通过将它们添加到堆上的结构来实现此目的。这样,当执行返回到调用代码时,可以恢复本地。但是,在代码中进行的操作越多,就必须将更多内容添加到堆中,从而导致更频繁的GC循环。其中一些可能是不可避免的,但是当您要等待异步操作时,您应该注意无用的变量赋值。例如,代码如:
var today = DateTime.Today;
var todayString = today.ToString("MMMM d, yyyy");
这将导致两个不同的值进入堆,而如果您只需要todayString
,只需将代码重写为:
var todayString = DateTime.Today.ToString("MMMM d, yyyy");
除非有人告诉你,这是你没有想到的事情之一。
取消异步工作
C#中异步的一个好处是可以取消任务。如果用户在UI中取消任务,导航离开网页等,这允许您中止任务。要启用取消,您的异步方法应接受CancellationToken
参数。
public async Task DoSomethingAsync(CancellationToken cancellationToken)
{
...
}
然后,该取消令牌应该传递给该方法调用的任何其他异步操作。如果可以并且希望取消,则该方法的责任是启用取消。并非所有异步任务都可以取消。一般来说,是否可以取消任务取决于该方法是否具有接受的重载CancellationToken
。
取消无法取消的任务
在某些情况下,如果方法未提供接受的重载,您仍可以取消任务CancellationToken
。你并没有真正取消这项任务,但是根据实施情况,你可能会中止它,但仍然可以有效地获得相同的结果。例如,该ReadAsStringAsync
方法HttpContent
没有接受的重载CancellationToken
。但是,如果您丢弃了HttpResponseMessage
,则会中止读取内容的尝试。
try
{
using (var response = await httpClient.GetAsync(new Uri("https://www.google.com")))
using (cancellationToken.Register(response.Dispose))
{
return await response.Content.ReadAsStringAsync();
}
}
catch (ObjectDisposedException)
{
if (cancellationToken.IsCancellationRequested)
throw new OperationCanceledException();
throw;
}
从本质上讲,我们使用CancellationToken
调用Dispose
的HttpResponseMessage
情况下,如果它取消。这将导致ReadAsStringAsync
抛出一个ObjectDisposedException
。我们捕获了这个异常,如果CancellationToken
已经取消,我们会抛出异常OperationCanceledException
。
这种方法的关键在于能够处理某些父对象,这会导致无法取消的方法引发异常。它不适用于所有内容,但可以在某些情况下为您提供帮助。
等待async
/ await
关键字
可以使用以下任一方法编写异步方法:
public async Task FooAsync()
{
await DoSomethingAsync();
}
public Task BarAsync()
{
return DoSomethingAsync();
}
首先,在方法中等待异步操作,然后在返回到调用代码之前将结果包装在另一个任务中。在第二步中,直接返回异步操作的任务。如果你有一个只调用另一个异步方法的异步方法(通常是异步重载的情况),那么你应该忽略async
/ await
keywords,就像上面的第二种方法一样。
处理异步方法中的异常
使用async
关键字的方法可以安全地抛出异常。编译器将负责将异常包装在一个Task
。
public async Task FooAsync()
{
// This is fine
throw new Exception("All your bases are belong to us.");
}
但是,Task
没有async
关键字的返回方法应该返回一个Task
例外。
public Task FooAsync()
{
try
{
// Code that throws exception
}
catch (Exception e)
{
return Task.FromException(e);
}
}
在实现方法的同步和异步版本时减少重复代码
通常在开发方法的同步和异步版本时,您会发现两个实现之间唯一真正的区别是,一个调用各种方法的异步版本,而另一个调用同步版本。当实现几乎相同时,除了使用async / await之外,您可以利用各种“黑客”来分解重复的代码。我发现的最好和最少“hacky”方法被称为“Flag Argument Hack”。本质上,您引入了一个布尔值,指示该方法是应该使用同步还是异步访问,然后相应地进行分支:
private async Task<string> GetStringCoreAsync(bool sync, CancellationToken cancellationToken)
{
return sync
? SomeLibrary.GetString()
: await SomeLibrary.GetStringAsync(cancellationToken).ConfigureAwait(false);
}
public string GetString()
=> GetStringCoreAsync(true, CancellationToken.None)
.ConfigureAwait(false)
.GetAwaiter()
.GetResult();
public Task<string> GetStringAsync()
=> GetStringAsync(CancellationToken.None);
public Task<string> GetStringAsync(CancellationToken cancellationToken)
=> GetStringCoreAsync(false, cancellationToken);
这似乎是很多代码,所以让我们解开它。首先,我们有一个私人方法GetStringCoreAsync
。这是我们分解公共代码的地方。在这里,我们只是调用其他一些具有同步和异步方法的库来获取某种字符串。不可否认,对于这种简单化的东西,你真的不应该使用这个hack,而应该只是让每个方法直接调用它的相应对应物。但是,我不想通过引入过于复杂的实现来阻碍理解。正如您所看到的,这里的要点是我们正在分支sync
使用库中的同步或异步方法的值。只要您等待异步方法,这将正常工作,这意味着此私有方法需要具有async
关键字。我们'CancellationToken
如果内部使用的异步方法是可取消的。
接下来,我们只有调用私有方法的同步和异步实现。对于同步版本,我们需要Task
从私有方法中解包返回的内容。为此,我们使用该GetAwaiter().GetResult()
模式安全地阻止异步调用。这里没有死锁的危险,因为虽然私有方法是异步的,但是当我们传递true
时sync
,实际上并没有使用异步方法。我们还ConfigureAwait(false)
用来防止附加同步上下文,因为它完全没有必要膨胀:这里没有线程切换的可能性。
异步实现相当不起眼。CancellationToken.None
如果没有传递取消令牌,则会有一个超时传递默认值,然后实际实现只是false
为sync
参数调用私有方法并包含取消令牌。
有一种思想流派认为方法不应该像这样的布尔分支。如果您有两组独立的逻辑,那么您应该有两个单独的方法。这有一些道理,但我认为必须权衡逻辑实际上有多么不同。因此,如果您有大量重复的逻辑,这是分解公共代码的好方法。但是,它应该是这方面的最后手段。如果代码的某些部分是CPU绑定的或以其他方式同步运行,那么您应该首先尝试将这些代码部分分解出来。你的同步和异步方法之间可能仍然存在一些重复,但是如果你可以将大部分内容都放到可以使用的方法而不诉诸黑客,那么这就是最佳路径。
还有一个论点要说,如果你有那么多的逻辑,你的方法可能首先做得太多了。你必须让自己的判断规则。有时候做这样的事情实际上是最好的路径,但是在使用这种方法之前你应该仔细评估是否是这种情况。
控制台应用程序中的异步
class Program
{
static void Main(string[] args)
{
MainAsync(args).GetAwaiter().GetResult();
}
static async Task MainAsync(string[] args)
{
// await something
}
}
对于它的价值,C#7.1承诺Async Main支持,所以你只需:
class Program
{
static async Task Main(string[] args)
{
// await something
}
}
但是,在撰写本文时,这不起作用。不过,这真的只是语法糖。当编译器遇到异步Main时,它只是将它包装在常规同步Main中,就像在第一个代码示例中一样。
确保异步不会阻止
有很多术语与C#中的异步混淆。您听说同步代码会阻塞该线程,而异步代码则不会。这实际上不是真的。无论线程是否被阻止,实际上与同步还是异步都没有任何关系。它进入讨论的唯一原因是,如果你的目标是不阻塞线程,async至少比同步更好,因为有时候,在某些情况下,它可能只是在不同的线程上运行。如果您的异步方法中有任何同步代码(任何不等待其他内容的代码),那么代码将始终运行同步。此外,如果等待的内容已经完成,则异步操作可以运行同步。最后,async不能确保工作不会在同一个线程上实际完成。它只是为线程切换开辟了可能性。
如果你需要确保异步操作不会阻塞线程,例如对于你想要保持GUI线程打开的桌面或移动应用程序,那么你应该使用:
Task.Run(() => DoStuffAsync());
等待。这与我们上面用来运行同步“async”不一样吗?是的。同样的原则适用:Task.Run
将运行您在新线程上传递给它的委托。反过来,这意味着它不会在当前线程上运行。
使同步操作Task
兼容
大多数异步方法返回Task
,但并非所有Task
返回方法都必须是异步的。这可能有点令人费解。比方说,你需要实现一个返回的方法Task
或,但你实际上并不有什么异步做。Task<TResult>
public Task DoSomethingAsync(CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested)
{
return Task.FromCanceled(cancellationToken);
}
try
{
DoSomething();
return Task.FromResult(0);
}
catch (Exception e)
{
return Task.FromException(e);
}
}
首先,这确保了操作没有被取消。如果有,则返回已取消的任务。然后,我们需要做的同步工作包含在一个try..catch
块中。如果抛出异常,我们将需要返回一个包含该异常的错误任务。最后,如果它正确完成,我们将返回一个已完成的任务。
重要的是要意识到这实际上并不是异步。DoSomething
仍然是同步并将阻止。但是,现在它可以像处理异步一样处理,因为它返回一个任务,就像它应该的那样。你为什么要这样做?好吧,一个例子是在实现适配器模式时,您正在适应的其中一个源不提供异步API。您仍然必须满足接口,但您应该注释该方法以表明它实际上不是异步。那些想要在他们不需要阻塞线程的情况下使用这种方法的人可以选择通过将其作为委托传递来调用它Task.Run
。
任务返回“热”
C#中异步编程的一个方面并不是很明显,即任务返回“热门”或已经开始。该await
关键字用于暂停代码,直到任务完成,但实际上并未启动它。当您同时查看对运行任务的影响时,这会变得非常有趣。
await FooAsync();
await BarAsync();
await BazAsync();
这里,三个任务串行运行。只有在FooAsync
完成后才会BarAsync
启动,同样,BazAsync
直到BarAsync
完成才会启动。这是由于正在等待内联任务。现在,请考虑以下代码:
var fooTask = FooAsync();
var barTask = BarAsync();
var bazTask = BazAsync();
await fooTask;
await barTask;
await bazTask;
在这里,任务现在并行运行。这是因为这三个都是在所有三个人随后等待之前开始的,因为他们又回来了。
考虑到Task.WhenAll
存在,这似乎有点反直觉。如果所有任务都已在运行,为什么需要该功能?简单地说,Task.WhenAll
作为一种等待完成一组任务的方式存在,以便在所有结果都准备好之前代码不会继续。
var factor1Task = GetFactor1Async();
var factor2Task = GetFactor2Task();
await Tasks.WhenAll(factor1Task, factor2Task);
var value = factor1Task.Result * factor2Task.Result;
由于两个任务都需要在我们运行乘法线之前完成,因此我们可以暂停,直到两个任务完成等待Task.WhenAll
。否则,它并不重要。事实上,Task.WhenAll
如果您等待两个任务而不是Result
直接调用,您甚至可以放弃:
var value = (await factor1Task) * (await factor2Task);
无论多长时间,它真的只是一个品味问题而不是任何东西。尽管如此,重要的是要意识到任务立即开始,而不是等待它们的行为导致它们开始。相反,等待只是阻止代码继续前进,直到任务完成。