整个系列的目录

  • 1.What is asynchronous programming? (JavaScript)
  • 1.1怎么在JavaScript中进行异步编程?
  • 2.其他语言的异步编程
  • 3.多线程编程 (c++) a.什么是多线程?与硬件的关系是什么? b.使用多线程意味着什么? c.使用多线程的目标是什么?
  • 4.其他语言的多线程编程
  • 5.多线程与异步编程的关系 (主要讲GoRotine,分析源码,看一下rust有没有改进)
  • 6.GPU并行编程

介绍

背景(异步&多线程)

这个系列主要是讲异步和多线程。异步和多线程本质上来讲都是为了充分的利用计算资源。

当CPU单核主频的发展速度达到瓶颈之后,往多核CPU发展。最近一些年GPU的并行矩阵运算给尝试学习和LLM的发展有着极大的促进作用。我这一个系列主要是讲CPU,暂时并不涉及GPU。目的主要是介绍一下异步和多线程编程,以及他们之间的关系。

那么异步编程是指什么?它和多线程是什么关系?多线程与硬件的关系是什么?异步编程&多线程编程与并行计算又有什么关系?我们将通过这个系列搞清楚这些问题。

(如果我发现了一些错误或者一些新的观点,我会更新已经发布过的文章内容,或者变更这个系列文章的结构)

in this article

在这篇文章里面我会介绍:

  1. 什么是异步编程?为什么需要它?
  2. JavaScript中的异步编程 a. 回调函数 b. 事件循环(源码)
  3. 下一篇文章

You can find all my demo codes in MyGithub.

实验环境

OS: Windows 11 21H2 22000.2538

Node.js: 20.13.1

什么是异步编程?

什么是异步

异步的反面是同步。我们先看一个生活中的例子:我去邮局寄信找朋友借钱,然后我收到支票后把它存到银行。我可以用同步异步的方式来实现:

Basic Introduction to Async and Sync

Figure 1. Basic Introduction to Async and Sync

我用Node.js写了一个示例程序,来看看执行结果点击查看源码 MailDemo

Synchronized Mailing Demo

Figure 2. Synchronized Mailing Demo

Asynchronized Mailing Demo

Figure 3. Synchronized Mailing Demo

所以我们能看到同步和异步最大的差别就是我(寄件人)在把邮件给邮局后是否在邮局等待朋友的回信还是可以去做其他的事情

从这个实例可以看出,很明显的在异步的邮寄过程当中,这个安排更合理。在同步的邮寄程序中有个看似非常奇怪的问题:我为什么把邮件给邮局后,我为什么还要在邮局傻傻的等? 我明明可以去吃饭,逛街,看电影做很多事情。朋友回复邮件了,邮局可以打电话通知我,我再去取。

所以在邮局等待是一个不合理的操作。有一个词叫阻塞经常和同步编程一起出现,阻塞意味着主人翁没有做任何事情的在等待一个结果。发生在现实生活中不合理,同样在程序中也不合理,所以异步编程会解决这个问题。

在工业界有个领域叫统筹规划,简单来讲就是就是让资源(包括机器、人力等)保持高效运转,从而在相同的时间内完成更多的任务。(在这里我希望大家可以不要像机器一样一直运转,在享受生活的同时,也能赚到很多钱。是的,统筹规划不应该应该到人身上。)

回到编程我们看一看异步同步的时序图。

Synchronized Mailing Sequence Diagram

Figure 4. Synchronized Mailing Sequence Diagram

Asynchronized Mailing Sequence Diagram

Figure 5. Asynchronized Mailing Sequence Diagram

通过这两张时序图可以更加清晰的看到,异步更合理。因为在邮局寄售的过程中我可以同时回家吃饭、逛街做很多事情,没有必要在邮局等候。

那寄信这个活动有什么特点呢? 1. 是别人(邮局)来寄,不需要我自己 2. 等待回信需要比较长的时间

异步编程

在编程里面有一些和寄信一样类似的操作,如:文件读写,网络请求(TCP,HTTP,DNS) 等等。

  • 文件读写:磁盘控制器去帮我们找到我们想要的内容
  • 网络请求:服务器在处理请求
  • Timer也是,Timer就是不需要等待任何返回,就是单纯的等待。

磁盘控制器,服务器这些就是第三方,相当于之前例子中的邮局。处理这些任务的时间长短关键在于他们,我是无能为力的。

总结一下:异步编程就是让主线程在等待某些任务在第三方处理时去做其他的任务

JavaScript中的异步编程

我为什么要讲JavaScript中的异步编程?

因为JavaScript最早是设计成单线程的,在Browser里面是基于事件响应型的模型,而且网络请求(年纪大点的人应该听过Ajax)又多,所以一旦有点阻塞,Browser来不及处理用户的Click和界面的渲染,整个UI就会处于无响应的状态,用户体验就会极差。(JavaScript浏览器环境除了主线程,会有一些其他的辅助线程)

为什么不设计成多线程的?处理多线程极其的麻烦,会让编程者掉更多的头发。相比之下异步模型会编写起来会比多线程编程简单很多。

比如:一个Web page在Browser里面请求一个REST API(HTTP): Mail, 需要等待2s才能回复数据。 - 如果是同步:在这2s内,用户点击页面是没有响应的,因为主线程在这2s内只能等待Mail的返回,无法做其他的事情。 - 如果是异步:这2s内主线程是空闲的,完全可以处理用户的任何请求和渲染页面,从而不会出现UI无响应的情况。那如果在这2s内,如:1.2秒时,Mail突然返回了数据,会打断其他的任务吗?不会。因为1.2后接受到Mail的返回时JS会把Mail的回调函数插入事件循环,等待当前任务执行完毕后才会从事件循环中取出Mail的回调函数并执行。

我们刚刚碰到了两个新的名词:回调函数事件循环。我们来解释一下JavaScript中是怎么用他们来实现异步编程的。

回调函数

从现在开始我们只讲异步模型了,请忘掉同步模型。

什么是回调函数?如图5所示,"Saved the cheque to my bank account"就是回调函数,因为只有等待朋友把包含cheque的邮件寄回来了,才可以执行,而且回调函数的调用(通知)方应该是第三方的邮局,因为它能精准的知道回复邮件什么时候到。

让我们继续从代码层面来看一下寄信的例子 点击查看源码 MailDemo CallBack

CallBack Code

Figure 6. CallBack code in mailing example

我们可以看到callBackFromPostOffice()就是CallBack函数,Post Office收到信之后给我发了个消息,然后我自己调用的这个函数。(因为我的Demo是用多线程编写,所以用消息进行通信是很重要的一方式,尽量避免在新线程里面直接调用主线程的函数。JavaScript是单线程。)

我的Demo因为是单一事件,所以在Post Office拿到回复邮件后直接调用了唯一的回调函数,但是一般的用户页面上充斥着各种不同的事件,那这些回调函数存储在什么地方呢?怎么处理这些回调函数呢?

JavaScript使用队列(先进先出)存储这些回调函数。然后事件循环会不断的查询Post Office有没有获取到邮件,一旦获取就会把回调函数callBackFromPostOffice()插入到Queue里面,然后又会查看Queue里面有没有等待执行的回调函数,有就执行。

总结一下:回调函数就是等拿到结果数据之后对该数据进行处理的函数(把朋友寄回的支票存到银行)。

事件循环(EventLoop)

如果让我起名字可能会起TaskLoop,因为它的核心作用就是不断的去执行回调函数,如果我们把一个回调函数当作一个任务(Task)的话,大部分时间处理的就是Task。当然它还会检查朋友的邮件有没有寄回来(IO操作有没有完成,定时器有没有归零)。

当然JavaScript最早是应用于Browser,大部分的task都是来自用户的操作:点击,鼠标经过,页面加载等待。这些都是事件,为了异步处理这些事件,这个loop所以叫做EventLoop。

我把之前的邮寄的例子改变成了用事件循环来实现,来看一下 点击查看源码 MailDemo EventLoop

EventLoop Code

Figure 7. EventLoop in mailing example

实事上,我们执行了eventLoop.addTask()之后,callBackFromPostOffice()并不会马上执行,要等待eventLoop.run()执行检查Queue时发现有等待执行的任务(回调函数)才会执行。

JavaScript EventLoop在Browser和Node.js中的实现是我这个示例的增强版(JavaScript只是一个语言,具体的语法解析和上体的功能实现需要一个Runtime如Chrome或者Node.js,他们的语法解析都是用的V8)。只是事件循环的任务类型和触发时间会比我这个示例复杂很多,而且Browser和Node.js也不一样。

事件循环是什么?用户需要异步执行某些函数时把回调函数插入队列,事件循环里面会检查该队列,如果有待执行任务就去执行。对于异步的IO操作,比如文件和网络操作,也会定期检查操作有没有成功,成功则会在事件循环中插入回调函数。我们接下来看Node.js是怎么实现的。

Node.js

我们先来看一下流程图,Node.js的事件循环和异步操作由UV库来管理。图8对应的代码请参考Node.js EventLoop

EventLoop in Node.js

Figure 8. EventLoop in Node.js

查看图9有关Node.js Loop中的数据结构源码(Linux平台) | 查看图8有关Node.js EventLoop源码

EventLoop in Node.js

Figure 9. Main relationship of the EventLoop in Node.js

*当所有的IO处理完毕,回调函数执行完毕,EventLoop便会退出

我来讲一下这个事件循环的每一步(timers=>Pending Queue=>Idle Handlers=>Prepare Handlers=>IO Poll=>Check=>Close):

timers: 执行setTimtout()和setInterval()的设置的回调函数。 这里有两个有意思的事情: EventLoop Timer in Node.js

Figure 9.1 EventLoop Timer in Node.js

  1. timers对应的数据结构是小项堆。堆这种结构是部分排序,在处理timer时,它每次只需取出最小的timer,也就是最近那个 (点击查看图9.1关于EventLoop Timer源码)
  2. 让我们来复习一下堆排序的时间复杂度:要为两个主要步骤建立树取出元素。假设有n个元素,建立树(插入所有元素)的时间复杂度是O(n),取出所有元素包含重建树的阶段的时间复杂度是O(nlogn)。堆排序是部分排序,取出一次调整一次树,不像其他的排序算法一次排序后,取出所有的时间复杂度就是O(n),单次O(1)。
  3. Node.js的timer是基于遍历机制,而且很可能被其他的回调函数甚至另一个timer的回调函数阻塞。所以我们可以确认的是,setTimer()设置的定时器肯定是不准确的。要设置很精确的计时器那又是一个话题了。

Pending Queue: 执行IO Poll阶段里面遗留的一些任务,一般来讲IO Poll阶段如果发现IO操作完成会即时处理掉对应的CallBack函数,但是有些情况会需要触发新的回调函数如出错了。就会放在Pending Queue,在下一轮来处理。(其实现在的代码会在IO Poll阶段后执行8轮的Pending Queue的清空,我也很好奇这个数字8是怎么来的。具体可以参考图8中间的代码)

Idle Handers&Prepare Handlers: 由UV库内部使用。

IO Poll: 这里就是去看一下这些IO操作有没有完成,完成了就执行对应的回调函数,需要进一步处理就会建新的回调函数放在Pending Queue里面。比如看读取某个文件有没有读完,HTTP的API请求有没有返回结果,进行的DNS查询有没有返回结果等等。

这一步可是非常关键,也是非常影响性能的一个阶段,在Linux平台上用了epoll模型,当年Nginx用epoll实现了极高的性能。Windows有对应的完成端口(IOCP).Mac的叫Kqueue。这里的细节我会在下一篇文章介绍。

Check: 执行setImmediate()设置的回调函数。

Close: 执行关闭请求的回调函数,如socket.on('Close', ...)。与网络操作相关,与文件操作无关。

微任务

在JavaScript语言中,除了用setTimeout(),setInterval()和setImmediate()设置自定义的异步函数,还可以用process.nextTick(), Promise()和Await()[Promise()的高级实现]来把自己想执行的异步回调函数(可以是任何函数)插入事件循环。关于Promise和Await的细节我会在下一篇文章中提及。

process.nextTick()和Promise.then()插入的异步函数我们就称之为微任务。上节中介绍的那些任务(setTimeout()、setInterval()、IO和setImmediate())我们称之为宏任务。那微任务和宏任务都在什么时候执行呢?

Tasks Execution Process in Node.js

Figure 10. Tasks Execution Process in Node.js

我们可以看到,微任务的优先级是非常高的,会在同步代码时间执行完后立刻执行。而且在每种任务如: setTimeout(), setInterval(), I/O Poll执行完后都会检查一下有没有微任务,有的话立即执行。

注意:

  1. process.nextTick()插入的函数优先级高于Promise.then()插入的函数。
  2. 在每执行完一个任务会就会检查一下微任务队列,并不是执行完某种宏任务队列后才会检查微任务Related Pull Request

我自己写了一个程序来检测一下不同的异步任务的执行顺序点击查看源码

Tasks Execution Order in Node.js

Figure 10. Tasks Execution Order in Node.js

我们可以和之前的代码流程图做个比较,完全一致。

Browser

在浏览器中事件循环有一些不一样:

  1. 没有process.nextEvent()
  2. 有MutationObserver()任务,主要用来监控网络上元素属性的变化,也属于微任务
  3. 有渲染任务(其实Browser是有专门的渲染线程的,是接收主线程渲染任务发送的指令,然后渲染线程负责具体的实施,为了高效渲染不阻塞主线程)
  4. 把同步代码执行一次就算第一个宏任务
  5. Event Loop里面执行顺序是一个宏任务=>执行所有微任务=>所有的渲染任务。执行顺序和Node.js类似,但是Node.js里面会先把所有微任务执行一次,而且没有渲染任务。

总结

这篇文章我们主要介绍什么是异步编程。通过一个邮局寄信的例子,回调函数,再到Node.js中的事件循环讲角了从生活层面到代码层面的异步是什么样子的。

下一篇文章我们会讲怎么用JavaScript进行异步编程?和一些相关的底层实现。

如果您发现我的文章中有错误或者您有什么新的观点可以随时联系我。

relative resources

GPU programming from Jim Fan


Comments

comments powered by Disqus