一些前提是:
- js 一开始被设计的时候就是没有多线程的,用于来浏览器的表单进行交互,而交互是事件触发的,所以在js最初被设计的时候就是有事件循环的概念的。
- bellard天才程序员设计的quickjs是一个非常小的js引擎,支持的api目前也只是一些文件操作,基本也都是同步的,但是分析代码,他依然有一个基本的事件循环机制。
在nodejs中执行代码,如果执行完,如果你提供了定时器,开启了服务器,有了异步读取文件的操作,这个程序并不会立即结束,这些异步的操作,都成为了一个事件,加入到了事件循环的队列中,只有当这个事件发生了操作,js调用对应的回调函数。如果是像网络Io这样的事件,这个事件处理完,会再次进入队列。在别的编程语言中,事件循环虽然本身可能不存在,但是可以通过开启多线程,让其中一个线程不断的循环做一些事情,从而达到事件循环的目的。
宏任务和微任务
不同环境下js,可能有不同的引擎环境实现,但是都肯定会有一个事件循环队列。这个队列中的事件,我们可以称之为任务。
下面是一些并不严谨的总结:
有些环境下,可能不只有一个事件任务队列,nodejs和浏览器都有两个任务队列,一个叫宏任务队列,一个叫微任务队列,为什么会需要两个呢。其实一开始,我也不相信有两个,一个不就够了。当我遇到一个并发问题的时候,我才发现,确实存在。这个下面会说。
i/o 指的是输入和输出,在linux所有设备都表示成文件了,i/o也可以仅仅表示为文件的输入和输出。
异步i/o就是指的是对文件输入和输出的异步。主线程发起调用,提供数据源(内存变量),事件循环库(libuv库去具体执行)。执行完,给调用的线程一个回调函数通知。调用回调函数的线程的时候,这个线程没有在执行别的东西。已经全部执行完了。
宏任务队列(io队列),微任务队列。宏任务执行开始后,就是i/o完成了,就会执行微任务栈,一直把所有的微任务都执行完,如果中间有新的微任务,会继续执行新加的微任务。微任务队列执行全部结束,宏任务事件驱动才会查看下一个宏任务有没有完成,然后去执行它的宏任务。值得一提的是,js脚本主体的执行,可以看作一个宏任务的调用。所以这里面的微任务会被全部执行。
所以理论上来说,微任务队列中的任务,都是由一个宏任务生成的。(微任务生成微任务也算宏任务生成的)。
所有的await都会被翻译成,在await后的代码,是then内的代码。对于两个数据库的查询,肯定是这个查询后面的所有函数都会被执行,如果await不涉及i/o操作,也就是宏任务,那么所有的微任务都会被按个执行完,全部执行完。如果触发了宏任务,那么属于这个awiat宏任务后面的所有代码都需要被等待了。
遇到的并发问题
我是遇到了一个并发问题,才去了解这个机制,并写了这篇博客。这是个模拟函数
let a = 0;
async set(a1,a2) {
a = a1+a2;
}
async sample() {
const model = await this.queryData();
await set(model.a,a);
}
- sample会被触发多次,这会产生多个任务进入事件循环
[s1,s2]
;这里的s1,s2代表queryData这个数据库的异步查询,await this.queryData()会把这一行后面的代码翻译成,this.queryData().then(model=>{await set(model.a);})
; - 如果await this.queryData()执行完以后,直接执行
await set(model.a);
就不会出现什么问题。 - 但是如果只有一个任务队列,
await set(model.a);
也会成为一个任务,进入到队列里[s2,s3,s4]
;这样就会出现,两个任务的参数a1,a2都是一样的。但是其实是有前后顺序的,假设model.a是1,就会出现两次set执行,都是 a = 1 + 0;而不是第一次1 + 0,第二次1+1; - 查阅了相关资源,和自己是有两个队列的,数据库i/o类型的任务会进入宏队列,像set函数这样的会被翻译成promise,只会进入微队列,只要是i/o类型的后面的await不是宏任务类型的查询,都会进入微队列,只要微队列不为空,就不会触发下一个宏任务队列的任务执行,且如果遇到了宏任务,那么就会触发新的宏任务,微任务队列中的执行其实已经全部结束了。最难理解的是await和最后翻译的代码对应的then的关系,但是可以简单的理解微,await的是i/o类型的宏任务,只要后面调用的都是await微任务类型的函数,就会一直执行下去,直到下一个宏任务await出现。
promise不会出现一直等待的情况,微任务是不会出现的,js引擎会执行这个promise的内部,如果全部执行完,并没有生成新的定时器或者别的任务,这个就执行完了。
还有个有意思的地方是,为什么单线程的js也会出现并发问题,这不是多线程才会出现的吗。我们知道,对于java来说,对于cpu密集型的任务,线程多了是没有什么好处的,但是i/o型的话,是线程越多越好。但是java21有了虚拟线程,这其实就是协程,在go,python中也存在着协程,他们其实和nodejs的事件循环是同一个东西,每个虚拟的线程,都是一个事件循环中的任务,事件循环本质是异步i/o。在js中,这个不知道为啥不叫协程,而是直接叫事件循环,没关系,反正我们知道了,其实也具体线程的概念,所以并发问题在所难免。只要,在多个函数的栈帧(函数调用所在的栈)会用到公用变量,就有并发问题,不管是不是真的多线程,抽象出来的线程,也是线程。
总结
- 任务,js的所有系统i/o调用都是异步的,每个调用都会进入事件队列,成为一个任务,这个任务有参数,有回调函数。
- 宏任务,系统i/o类型的调用。Nodejs是基于libuv库实现的。libuv库是一个跨平台的,支持所有类型的i/o的异步库(文件,网络,…)
- 微任务,js内部依靠promise的实现的。
- 宏任务和微任务的关系,一个宏任务执行完,这个宏任务内的回调函数内,触发的所有微任务函数,都会被全部执行完,或者遇到新的宏任务,才会执行宏任务队列中新的任务。