异步编程指南

Posted 积少成多

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了异步编程指南相关的知识,希望对你有一定的参考价值。

异步编程具有传染性

一旦采用异步编程模型,所有调用者应该也是异步的。因为只有整个调用链都采用异步编程模型,才能充分发挥异步编程的优势。在很多情况下,部分异步的效果甚至不如完全同步。因此,最好一次性将所有内容都改成异步编程模型。

❌ BAD 这个例子使用了Task.Result,导致当前线程被阻塞以等待结果。.

public int DoSomethingAsync()

    var result = CallDependencyAsync().Result;
    return result + 1;

✅ GOOD 这个例子使用了await关键字来等待调用CallDependencyAsync方法的结果。

public async Task<int> DoSomethingAsync()

    var result = await CallDependencyAsync();
    return result + 1;

Async void

在ASP.NET Core应用程序中使用async void永远都是不好的,应该尽量避免。通常情况下,开发者会在控制器操作触发请求后立即返回,不需要等待响应 时使用Async Void方法。但是,如果出现异常,则会导致整个进程崩溃。

❌ BAD Async Void方法无法被追踪,因此未处理的异常可能会导致应用程序崩溃。

public class MyController : Controller

    [HttpPost("/start")]
    public IActionResult Post()
    
        BackgroundOperationAsync();
        return Accepted();
    
    
    public async void BackgroundOperationAsync()
    
        var result = await CallDependencyAsync();
        DoSomething(result);
    
 

✅ GOOD 使用返回任务(Task)的方法更好,因为未处理的异常会触发TaskScheduler.UnobservedTaskException事件。

public class MyController : Controller

    [HttpPost("/start")]
    public IActionResult Post()
    
        Task.Run(BackgroundOperationAsync);
        return Accepted();
    
    
    public async Task BackgroundOperationAsync()
    
        var result = await CallDependencyAsync();
        DoSomething(result);
    

TaskScheduler.UnobservedTaskException += (sender, args) =>

    // 记录未处理的任务异常
    foreach (var exception in args.Exception.InnerExceptions)
    
        logger.LogError(exception, "Unobserved task exception occurred.");
    
   
    // 标记异常已处理
    args.SetObserved();
;

 

对于预先计算或者计算非常简单的数据,应优先考虑使用Task.FromResult而不是Task.Run。

Task.FromResult方法是一个静态方法,用于创建一个已经完成的Task对象,并将结果作为返回值封装在Task中。由于Task已经完成,因此当等待Task对象时,将立即返回结果,而不需要开启新的线程。

相比之下,Task.Run方法会在ThreadPool上启动一个新的任务,并且该任务的执行需要时间和资源。对于预先计算或者计算非常简单的数据,使用Task.Run来启动这样的任务可能是浪费资源的。

❌ BAD 这个示例浪费了一个线程池线程来返回一个计算非常简单的值。

public class MyLibrary

   public Task<int> AddAsync(int a, int b)
   
       return Task.Run(() => a + b);
   
 

✅ GOOD 这个示例使用Task.FromResult来返回一个计算非常简单的值。由于不需要额外的线程,因此它不会占用任何多余的系统资源。

public class MyLibrary

   public Task<int> AddAsync(int a, int b)
   
       return Task.FromResult(a + b);
   

 ✅ GOOD在上个示例中,我们使用Task.FromResult创建一个Task<int>对象来返回计算结果,但是这会分配一个额外的对象(Task)。现在,我们可以使用ValueTask<int>来改善这个方法。在这个示例中,我们返回了一个ValueTask<int>对象,它不需要分配任何Task对象。 此外,当这个方法被同步调用时,它也可以提高性能,因为它不需要等待Task对象被调度和执行,它可以直接返回封装在ValueTask<int>中的结果。这个改进对于高性能应用程序非常有用。

public class MyLibrary

   public ValueTask<int> AddAsync(int a, int b)
   
       return new ValueTask<int>(a + b);
   

 

 在执行需要占用很长时间的工作时,尽可能避免使用Task.Run方法。

 Task.Run方法是用于将一个操作分配到线程池上的异步方法,它通常用于在后台线程上执行短时间运行的非阻塞操作。但是,如果您需要执行一个需要长时间运行的操作,而且该操作会阻塞线程,则使用Task.Run可能会导致一些问题。这是因为它会占用线程池中有限的线程资资源,从而影响应用程序的响应性能。

如果阻塞线程,线程池会增长,但是这是一种不好的编程实践。

Task.Factory.StartNew 方法是一个强大的工具,可用于在多线程应用程序中以异步方式执行代码。TaskCreationOptions.LongRunning 选项可以指示方法使用一个长时间运行的线程来执行任务,从而避免占用线程池中的宝贵资源。但是,要正确使用此选项,需要考虑多种参数和配置。

不要在异步代码中使用TaskCreationOptions.LongRunning选项,因为这样会创建一个新的线程,在第一次 await 后就会被销毁。

 

应避免使用 Task.Result 和 Task.Wait。

在异步编程中,Task.Result 和 Task.Wait 方法可以用于等待任务完成,并返回其结果。但是,这种做法可能会导致应用程序死锁,因为它会阻塞当前线程并等待任务完成,而另一个任务或系统资源可能正在等待该线程释放。

相反,应该优先使用 await 操作符来等待任务完成。await 操作符可以暂停当前方法的执行并允许其他代码在该方法的上下文中运行,从而提高应用程序的响应性和并发性。此外,await 操作符也可以将异常传播回调用者,以便更好地处理错误情况。

如果确实需要等待任务完成而无法使用 await 操作符,则应尽量避免在 UI 线程或 ASP.NET 应用程序中使用 Task.Wait 或 Task.Result 方法。这些方法可能会导致应用程序出现死锁、线程池饱和或性能下降的问题。取而代之,可以考虑使用 Task.ConfigureAwait(false) 将等待操作切换到后台线程,并指定 CancellationToken 以避免无限期地等待任务完成。

总之,在异步编程中,应该优先使用 await 操作符来等待任务完成,并避免使用 Task.Result 和 Task.Wait 方法,以提高应用程序的可靠性和性能。

❌ BAD

public string DoOperationBlocking()

    // Bad - Blocking the thread that enters.
    // DoAsyncOperation will be scheduled on the default task scheduler, and remove the risk of deadlocking.
    // In the case of an exception, this method will throw an AggregateException wrapping the original exception.
    return Task.Run(() => DoAsyncOperation()).Result;


public string DoOperationBlocking2()

    // Bad - Blocking the thread that enters.
    // DoAsyncOperation will be scheduled on the default task scheduler, and remove the risk of deadlocking.
    // In the case of an exception, this method will throw the exception without wrapping it in an AggregateException.
    return Task.Run(() => DoAsyncOperation()).GetAwaiter().GetResult();


public string DoOperationBlocking3()

    // Bad - Blocking the thread that enters, and blocking the threadpool thread inside.
    // In the case of an exception, this method will throw an AggregateException containing another AggregateException, containing the original exception.
    return Task.Run(() => DoAsyncOperation().Result).Result;


public string DoOperationBlocking4()

    // Bad - Blocking the thread that enters, and blocking the threadpool thread inside.
    return Task.Run(() => DoAsyncOperation().GetAwaiter().GetResult()).GetAwaiter().GetResult();


public string DoOperationBlocking5()

    // Bad - Blocking the thread that enters.
    // Bad - No effort has been made to prevent a present SynchonizationContext from becoming deadlocked.
    // In the case of an exception, this method will throw an AggregateException wrapping the original exception.
    return DoAsyncOperation().Result;


public string DoOperationBlocking6()

    // Bad - Blocking the thread that enters.
    // Bad - No effort has been made to prevent a present SynchonizationContext from becoming deadlocked.
    return DoAsyncOperation().GetAwaiter().GetResult();


public string DoOperationBlocking7()

    // Bad - Blocking the thread that enters.
    // Bad - No effort has been made to prevent a present SynchonizationContext from becoming deadlocked.
    var task = DoAsyncOperation();
    task.Wait();
    return task.GetAwaiter().GetResult();

 

在使用超时的 CancellationTokenSource 时,应该始终在使用后将其释放(Dispose),以避免资源泄露。

using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)))

    // perform long-running operation here
    // and check for cancellation if possible:
    while (!cts.Token.IsCancellationRequested)
    
        // do some work here...
    

在使用 CancellationToken 来取消操作时,应该始终将令牌传递给 API,以确保可以正确地取消操作。

CancellationToken 是用于取消操作的一个标准机制。当操作正在执行时,如果取消令牌被请求,则可以使用 IsCancellationRequested 属性检查令牌是否已被取消,并相应地停止该操作。

许多 .NET 标准库中的方法和 API 都支持 CancellationToken 参数,以便在取消操作时使用。如果不传递 CancellationToken 到这些 API,则可能会导致无法正确取消操作,从而影响应用程序的性能。

using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)))

    using (var client = new HttpClient())
    
        var response = await client.GetAsync("https://example.com", cts.Token);
        // process the response here...
    

 

 

 

 

 


回调地狱——JavaScript异步编程指南

什么是 “回调地狱”?

在 JavaScript 中,我们经常通过回调来实现异步逻辑,一旦嵌套层级多了,代码结构就容易变得很不直观,最后看起来像这样:

fs.readdir(source, function (err, files) {  if (err) {
    console.log('Error finding files: ' + err)
  } else {
    files.forEach(function (filename, fileIndex) {
      console.log(filename)
      gm(source + filename).size(function (err, values) {        if (err) {
          console.log('Error identifying file size: ' + err)
        } else {
          console.log(filename + ' : ' + values)
          aspect = (values.width / values.height)
          widths.forEach(function (width, widthIndex) {
            height = Math.round(width / aspect)
            console.log('resizing ' + filename + 'to ' + height + 'x' + height)            this.resize(width, height).write(dest + 'w' + width + '_' + filename, function(err) {              if (err) console.log('Error writing file: ' + err)
            })
          }.bind(this))
        }
      })
    })
  }
})

金字塔形状和结尾的一大堆  }) ,这就是萌萌的回调地狱。

这是许多开发者都很容易犯的一个错误,希望以一种在视觉上从上往下执行的方式来编写 JavaScript,最终便制造了回调地狱。

在一些其它的编程语言(如 C、Ruby、Python)中,会确保第 1 行代码已执行完成,并且文件也已加载完毕之后,才开始执行第 2 行代码。但如你所知,JavaScript 并非如此。

 

什么是回调?

回调(callbacks)只是函数的一种用法的通用称呼,在 JavaScript 中,并没有一个特定的东西叫 “回调”,它仅仅是一个约定好的称呼。

不同于那些立即返回结果的函数,回调函数需要一定的时间来获得结果。

“asynchronous(异步)” ,也叫 “async”,表示 “需要耗费一定的时间” 或者 “发生在未来,而不是现在”。

在处理 I/O 时,通常会使用到回调,如下载、读取文件、与数据库交互等。

调用一个普通的函数时,我们可以直接使用其返回值:

var result = multiplyTwoNumbers(5, 10)
console.log(result)// 控制台打印出 50

而异步回调函数,不会立即返回结果:

var photo = downloadPhoto('http://coolcats.com/cat.gif')// photo 未定义!

下载 gif 文件可能需要很长的时间,而你肯定不希望程序在下载过程中处于暂停(即 “block(阻塞)”)状态。

你可以把下载完成后需要执行的操作存放在一个函数中,这就是回调函数。把它传递给  downloadPhoto ,当下载完成时, downloadPhoto  会执行这个回调函数(callback,call you back later),并把 error(错误信息)或 photo(图片数据)传递给它。

downloadPhoto('http://coolcats.com/cat.gif', handlePhoto)function handlePhoto (error, photo) {  if (error) console.error('下载出错!', error)  else console.log('下载完成', photo)
}

console.log('开始下载')

理解回调最大的难点,在于搞清楚程序运行时代码的执行顺序。在这个例子中主要有三个关键点:首先声明了  handlePhoto  函数,然后调用了  downloadPhoto  函数并将  handlePhoto  作为回调函数传递给它,最后 “开始下载” 被打印出来。

注意此时  handlePhoto  还没有被调用,只是创建并作为回调函数传递给了  downloadPhoto ,在  downloadPhoto  完成任务后才会被执行,这取决于网速有多快。

这个例子想要传达两个重要的概念:

  • 回调函数  handlePhoto  只是存放操作的一个途径,可以让这些操作在一段时间后(满足了特定条件)才被执行。

  • 代码执行的顺序不是按照视觉上的自上而下,而是基于逻辑的完成时机跳跃式触发。

如何处理回调地狱?

回调地狱的产生源于开发经验的不足,幸运的是想要写好这些代码并不困难。你只要遵循下面三个原则:

 

1、避免函数嵌套

下面是一段杂乱的代码,使用 browser-request 向服务器发起一个 AJAX 请求:

var form = document.querySelector('form')
form.onsubmit = function (submitEvent) {  var name = document.querySelector('input').value
  request({
    uri: "http://example.com/upload",
    body: name,
    method: "POST"
  }, function (err, response, body) {    var statusMessage = document.querySelector('.status')    if (err) return statusMessage.value = err
    statusMessage.value = body
  })
}
代码中有两个匿名函数,来给它们起个名字吧!
var form = document.querySelector('form')
form.onsubmit = function formSubmit (submitEvent) {  var name = document.querySelector('input').value
  request({
    uri: "http://example.com/upload",
    body: name,
    method: "POST"
  }, function postResponse (err, response, body) {    var statusMessage = document.querySelector('.status')    if (err) return statusMessage.value = err
    statusMessage.value = body
  })
}

如你所见,给函数命名非常简单,却立竿见影:

  • 带有描述性含义的函数名,让代码更容易阅读

  • 出现异常时,可以在堆栈中查看到一个确切的函数名而不是 “anonymous”

  • 可以很方便地移动函数,然后通过函数名来引用

现在,我们可以把这些函数移到外层:

document.querySelector('form').onsubmit = formSubmitfunction formSubmit (submitEvent) {  var name = document.querySelector('input').value
  request({
    uri: "http://example.com/upload",
    body: name,
    method: "POST"
  }, postResponse)
}function postResponse (err, response, body) {  var statusMessage = document.querySelector('.status')  if (err) return statusMessage.value = err
  statusMessage.value = body
}
注意这里把函数声明移到了文件的底部,这得益于函数声明提升(function hoisting)。

2、模块化

这是最重要的一点:人人皆可搞模块(即代码库)。

引用(node.js 项目的)Isaac Schlueter 的话:“编写职责单一的小模块,组装起来以实现更大的功能。回调地狱你不去碰它,就不会掉进去。”

让我们从上面的代码中提取出样板代码,拆分成两个文件,把它变成一个模块。我将展示一个模块模式,它既可用于浏览器,也可用于服务端。

新建一个文件叫  formuploader.js ,包含了从上面的代码中提取出来的两个函数:

module.exports.submit = formSubmitfunction formSubmit (submitEvent) {  var name = document.querySelector('input').value
  request({
    uri: "http://example.com/upload",
    body: name,
    method: "POST"
  }, postResponse)
}function postResponse (err, response, body) {  var statusMessage = document.querySelector('.status')  if (err) return statusMessage.value = err
  statusMessage.value = body
}

 module.exports  是 node.js 模块系统的一个用法,适用于 node、Electron 和使用 browserify 的浏览器。我非常喜欢这种模块化风格,因为它适用范围广、易于理解、而且不需要复杂的配置文件或脚本。

现在我们有了  formuploader.js (并且作为页面的一个外联脚本已加载完成),我们只需要引入(require)这个模块并使用它!

程序的具体代码如下:

var formUploader = require('formuploader')
document.querySelector('form').onsubmit = formUploader.submit

程序仅仅只需要两行代码,而且还有以下好处:

  • 对于新的开发者更加容易理解——他们不用深陷于 “被迫通读全部  formuploader  函数”

  •  formuploader  可以用于其它地方而不需要复制代码,而且也更容易分享到 github 或 npm

 

3、处理每一个错误

错误有许多类型:语法错误(通常只要运行程序就能被捕获)、运行时错误(程序运行正常但存在一些 bug 会引起逻辑混乱)、平台错误(如无效的文件权限、硬件驱动失效、网络连接异常等)。这一部分主要针对最后一类错误。

前面两个原则可以让你的代码更具可读性,而这个原则,可以让你的代码更具稳定性。

回调函数被定义和分配后,会在后台执行,然后成功完成或者失败中止。任何有经验的开发者都会告诉你:你永远无法预测错误何时会发生,你只能假设它一定会发生。

对于回调函数中错误的处理方式,最流行的是 Node.js 风格:回调函数的第一个参数永远是 “error”。

var fs = require('fs')

fs.readFile('/Does/not/exist', handleFile) function handleFile (error, file) {   if (error) return console.error('卧槽,出错了', error)   // 正常,可以在代码中使用 `file` 了
 }

把第一个参数设置为  error ,是鼓励你记得处理错误的一个简单的约定。如果把它设置为第二个参数,你可能会把代码写成  function handleFile(file){} ,而忽略了错误处理。

编码规范检查工具(Code linters)也可以通过配置来帮助你记得处理回调错误。使用最简单的一个是 standard,你只需要在代码目录中执行  $ standard  命令,它就会把代码中没有处理错误的回调函数全部显示出来。

 

要点

  1. 不要嵌套函数,给函数命名并移到外层

  2. 利用函数声明提升(function hoisting)特性,把函数移到不显眼的位置

  3. 处理每个回调函数中的 每一个错误,使用检查工具(如 standard)来帮助你更好地完成这个工作

  4. 创建可重用的函数并放到一个模块中,可以提高代码可读性。代码柯里化也有利于错误处理、编写测试用例、创建一个稳定且文档化的公共 API,此外也便于维护和重构

避免回调地狱的最有效的方法就是 把函数移出去,让程序逻辑更加清晰易懂,新的开发者不用费劲通读所有的函数细节以理解程序意图。

可以先从把函数移到文件尾部开始。然后尝试把它们移到另一个文件里,通过相对路径进行引用,如  require('./photo-helpers.js') 。最后把它们移到一个独立的模块中,像  require('image-resize')  来引用。

这是一些创建模块的实践法则:

  • 把一些常用的代码封装成函数

  • 当函数(或者一组具有相同主题功能的函数)足够大的时候,把它们移到另一个文件里,使用  module.exports  来暴露接口,通过相对路径进行引用。

  • 如果有一些代码可以跨项目使用,给它写个说明文件(readme)、测试用例、 package.json ,并发布到 github 和 npm。

  • 一个好的模块应该是轻量的、且聚焦于解决某一个问题

  • 一个模块文件不要超过 150 行代码

  • 模块文件的目录层级不要嵌套超过一层,如果发生这种情况,可能它就处理太多事情了

  • 让更有经验的开发者给你演示下优秀模块的例子,直到你了解它们应该是什么样子的。如果一个模块需要花费超过几分钟的时间去理解它是干嘛的,那就不是一个多么好的模块。


以上是关于异步编程指南的主要内容,如果未能解决你的问题,请参考以下文章

[译] 回调地狱——JavaScript异步编程指南

libuv 中文编程指南

Java多线程编程模式实战指南一:Active Object模式(上)

Java多线程编程模式实战指南一:Active Object模式(下)

表格存储新手指南:Java SDK异步接口的使用

Java深层系列「并发编程系列」让我们一起探索一下CompletionService的技术原理和使用指南