【译】异步编程-技巧和窍门

234 阅读8分钟

C#异步/等待模式的出现引入了编写良好且可靠的并行代码的新方法,但是,随着创新不断发生,它也引入了将许多的的新方法。很多时候,当尝试使用async / await解决多线程问题时,程序员不仅不解决旧问题,还创建新的问题,当死锁,饥饿和竞争条件仍然存在时,甚至更难找到它们。

所以我只是想在这里分享我的一些经验。也许它将使某人的生活更轻松。但是首先,让我们先学习一个简短的历史课程,看看异步/等待模式是如何在我们的生活中出现的,以及借助它可以解决哪些问题。这一切都从回调开始,当我们只有一个函数时,作为第二个动作,我们传递动作,此后称为。就像是:

void Foo(Type parameter, Action callback) {...}
void Bar() {
    some code here
    ...
    Foo(parameter, () => {...});
}

这很酷,但是这些回调结构有很大的增长趋势。

然后是Microsoft APM(异步编程模型),它在回调,异常方面也或多或少存在类似问题,并且仍不清楚在哪个线程代码中执行。下一步是实现基于EAP事件的异步模式。EAP引入了已知的异步命名约定,其中最简单的类可能只有一个MethodName Async方法和一个相应的MethodName Completed事件,但仍然感觉很像回调。这很有趣,因为它表明现在名称中具有异步的所有内容都将返回任务。

public class AsyncExample  
{  
    // Synchronous methods.  
    public int Method1(string param);  
    public void Method2(double param);  
  
    // Asynchronous methods.  
    public void Method1Async(string param);  
    public void Method1Async(string param, object userState);  
    public event Method1CompletedEventHandler Method1Completed;  
   
    public bool IsBusy { get; }   
}

最后,我们来谈谈我们都喜欢并知道的TAP或任务异步模式,对吗?TAP中的异步方法在Async操作名称之后包含后缀,该后缀用于返回等待类型的方法,例如TaskTask.ResultValueTaskValueTaskResult。通过这种方法,引入了一个Task对象,与上面的模式相比,它给我们带来了很多好处。

  • 代码执行状态,例如 Cancelled, Faulted, RanToCompletion
  • 明确的任务取消 CancellationToken
  • TaskScheduler 这有助于代码执行上下文

现在,通过简短的历史介绍,让我们跳到一些更实际的事情,这些事情可以使我们的生活更轻松一些。我将尝试并提及一些我最常用的最重要的做法。

.ConfigureAwait(假)
我经常从同事那里听到消息,也经常在帖子中读到,您遇到了死锁问题,可以.ConfigureAwait(false)在任何地方使用它,您会没事的,我真的不同意。尽管使用.ConfigureAwait(false)可能确实有益,但牢记某些事情始终很重要。例如,ConfigureAwait在需要上下文的方法中,在等待之后有代码时,不应使用。CLR控制将在await关键字之后执行哪个线程代码,而.ConfigureAwait(false)我们基本上是说我们不在乎在await关键字之后执行哪个线程代码。这意味着,如果我们使用UI或在ASP.Net中进行操作,则可以使用HttpContext.Current或构建http响应,我们始终需要在主线程中继续执行。但是,如果您正在编写一个库,但不确定如何使用该库,则使用它是一个好主意.ConfigureAwait(false)-通过ConfigureAwait这种方式使用,可以实现少量的并行处理:某些异步代码可以并行运行主线程,而不是不断地给它添加一些工作要做。

CancellationToken
CancellationToken通常,将类与任务一起使用是一个好主意,此机制是控制任务执行流程的简便实用工具,特别适用于可能被用户停止的长执行方法。这可能是繁重的计算过程,长时间运行的数据库请求或仅仅是网络请求。

public async Task MakeACallClickAsync(CancellationToken cancellationToken)
{
  try
  {
    var result = await GetResultAsync(cancellationToken);
  }
  catch (OperationCanceledException) // includes TaskCanceledException
  {
    MessageBox.Show("Your operation was canceled.");
  }
}


public async Task<MyResult> GetResultAsync(CancellationToken cancellationToken)
{
  try
  {
    return await httpClient.SendAsync(httpRequestMessage, cancellationToken);
  }
  catch (OperationCanceledException)
  {
    // perform your cleanup if necessary    
  }
}

请注意,有两种取消异常类型:TaskCanceledExceptionOperationCanceledExceptionTaskCanceledException派生自OperationCanceledException。因此,在编写用于处理已取消操作的失败的catch块时,最好捕获OperationCanceledException,否则某些取消事件会在您的catch块中漏出并导致无法预测的结果。

.Result / Wait()
使用这种方法非常简单-尽量避免使用它,除非您对自己的工作有100%的把握,但仍然如此await。微软表示,这Wait(TimeSpan)是一种同步方法,可使调用线程等待当前任务实例完成。
还记得我们提到过,任务总是在上下文中执行的,而clr控件将在其中执行线程连续吗?看下面的代码:

// Service method.
public async Task<JsonResult> GetJsonAsync(Uri uri)
{
  
  var client = _httpClientFactory.CreateClient();  
  var stream = await _httpClient.GetStreamAsync("https://really-huge.json");
  using var sr = new StreamReader(stream);
	using var reader = new JsonTextReader(sr);
  while (reader.Read())
  {
	.... get json result
  }
  return jsonResult;  
}

// Controller method.
public class MyController : Controller
{

  [HttpGet]
  public string Get()
  {
    var jsonTask = GetJsonAsync(Uri.Parse("https://somesource.com"));
    return jsonTask.Result.ToString();
  }
}
  • 在控制器中,我们称为GetJsonAsync(ASP.NET上下文)。
  • http请求_httpClient.GetStreamAsync("https//really-huge.json")已启动
  • 然后GetStreamAsync返回未完成的任务,指示请求未完成。
  • GetJsonAsync等待GetStreamAsync返回的任务。上下文已保存,将用于继续运行GetJsonAsync方法。GetJsonAsync返回未完成的Task,指示GetJsonAsync方法尚未完成。
  • 通过jsonTask.Result.ToString();在控制器中使用,我们可以同步阻止GetJsonAsync返回的Task。这将阻塞主上下文线程。
  • 在某个时刻,GetStreamAsync将完成并且其Task将完成,在此之后,GetJsonAsync准备好继续,但是它等待上下文可用,以便可以在上下文中执行。然后我们有一个死锁,因为控制器方法在等待*GetJsonAsync完成的同时阻塞了上下文线程,而GetJsonAsync在等待上下文可用以便可以完成。

这种死锁不容易发现,并且经常会引起很多不适,这就是为什么不建议使用Wait()和.Result的原因。

Task.Yield()
老实说,这是一个小技巧,我没用太多,但是拥有您的武器库是一件好事。这个问题是,当使用async/await,并不能保证您await FooAsync()实际使用时会异步运行。内部实现可以使用完全同步的路径自由返回。假设我们有一些方法:

async Task MyMethodAsync() {
    someSynchronousCode();
    await AnotherMethodAsync();
    continuationCode();
}

看起来我们没有在这里阻塞任何东西,并且希望await AnotherMethodAsync();也可以异步运行,但是让我们看一下幕后会发生什么。当我们的代码被编译时,我们可以得到类似于下面的代码(非常简化):

async Task MyMethodAsync() {
  
  someSynchronousCode();
  
  var awaiter = AnotherMethodAsync().GetAwaiter();
  
  if (!awaiter.isCompleted)
  {
    compilerLogic(continuationCode()); // asynchronous code
  }
  else
  {
    continuationCode(); // synchronous code
  }
}

这是发生了什么:

  • omeSynchronousCode()将按预期同步运行。
  • 然后AnotherMethodAsync() 是同步执行,那么我们得到与.GetAwaiter一个awaiter对象()
  • 通过检查waiter.IsCompleted,我们可以查看任务是否完成
  • 如果任务完成,那么我们只需同步运行continuationCode()
  • 如果任务未完成,则continuationCode()计划在任务上下文中执行

结果,即使await仍然可以同步执行代码,并且通过使用代码,await Task.Yield()我们始终可以保证!awaiter.IsCompleted并强制该方法异步完成。有时它可以有所作为,例如在UI线程中,我们可以确保我们长时间不忙。

ContinueWith()

常见的情况是,一个操作完成后,我们需要调用第二个操作并将数据传递给它。传统上,回调方法用于运行延续。在任务并行库中,延续任务提供了相同的功能。Task类型公开的多个重载ContinueWith。此方法创建一个新任务,该任务将在另一个任务完成时进行安排。让我们看一下一个非常常见的情况,我们正在下载图像并对每个图像进行一些处理。我们需要按顺序进行处理,但是我们希望尽可能多的并发下载,而且还要ThreadPool对下载的图像执行计算密集型处理:

List<Task<Bitmap>> imageTasks = urls.Select(u => 
          GetBitmapAsync(imageUrl)
          .ContinueWith((t) => {
              if (t.Status == TaskStatus.RanToCompletion) {
                    ConvertImage(t.Result)
              }
              else if (t.Status == TaskStatus.Faulted) {
                    _logger.Log(t.Exception.GetBaseException().Message);
              }
          })).ToList();

while(imageTasks.Count > 0)
{
    try
    {
        Task<Bitmap> imageTask = await Task.WhenAny(imageTasks);
        imageTasks.Remove(imageTask);

        Bitmap image = await imageTask;
        .... add orr store the image .....
        .... .AddImage(image);
    }
    catch{}
}

在这种情况下,使用起来非常方便,ContinueWith()因为与之后的代码不同awaitContinueWith()逻辑将在线程池上执行,这是我们进行计算并防止主线程阻塞所需要的。
请注意,Task.ContinueWith 将对上一个对象的引用传递给用户委托。如果先前的对象是System.Threading.Tasks.Task对象,并且任务运行完毕,则可以执行任务的Task.Result属性。实际上,.Result属性会阻塞,直到任务完成为止,但是ContinueWith()当任务状态更改时,也会由另一个Task调用。这就是为什么我们首先检查状态,然后才进行处理或记录错误的原因。

原文相关

 原文作者:Eduard Los
 原文地址:https://medium.com/@eddyf1xxxer/bi-directional-streaming-and-introduction-to-grpc-on-asp-net-core-3-0-part-2-d9127a58dcdb