JavaScript有一个基于事件循环的并发模型。
事件循环负责执行代码、收集和处理事件以及执行队列中的子任务。
运行时概念
可视化描述
简单理解该图,queue是message队列,主线程从message队列中按顺序去除message并处理,处理过程中会创建栈帧,和堆对象。
一直取出queue中的message,知道空为止。
栈
官方的说明
function foo(b) {
let a = 10;
return a + b + 11;
}
function bar(x) {
let y = 3;
return foo(x * y);
}
console.log(bar(7)); // 返回 42
当调用 bar 时,第一个帧被创建并压入栈中,帧中包含了 bar 的参数和局部变量。当 bar 调用 foo 时,第二个帧被创建并被压入栈中,放在第一个帧之上,帧中包含 foo 的参数和局部变量。当 foo 执行完毕然后返回时,第二个帧就被弹出栈(剩下 bar 函数的调用帧)。当 bar 也执行完毕然后返回时,第一个帧也被弹出,栈就被清空了。
堆
对象被分配在堆中,堆是一个用来表示一大块(通常是非结构化的)内存区域的计算机术语。
在JavaScript中对象在堆中的存储方式是非顺序非结构的,无序的。他们的指针(引用)地址在栈内存中,堆内存存放对象数据体。
队列
一个JavaScript运行时包含了一个待处理信息的消息队列。每一个消息都关联着一个用以处理这个消息的回调函数。
在事件循环期间的某个时刻,运行时会从最先进入队列的消息开始处理队列中的信息。被处理的信息会被移出队列,并作为输入参数来调用与之关联的的函数。正如前面所提到的,调用一个函数总是会其创造一个新的栈帧。
函数的处理会一致进行到执行栈再次为空为止;然后事件循环将会处理队列中的下一个信息。(如果还有的话)
第一次理解
按我目前的理解,队列处理的信息对应着每一段执行代码,可以是执行调用函数的命令,或者是表达式,都是一种对信息的处理。在我看来,有点像代码预处理,事先会把代码的执行顺序做好排序一条一条插入队列中;队列中对信息的处理只能从队头开始,一条一条处理,而且JavaScript是单线程的,没有像C可能被其他线程抢占;而函数的调用相对应也会在栈创建新的帧,当函数的处理完成(对应执行栈为空),也就是处理完了一条信息,事件循环就会处理队列中下一个信息。
在MDN上看了《并发模型与事件循环》的运行时概念和事件循环,得到的理解,不太准确,去查阅一些大牛的理解,看到了阮一峰老师,发现自己的理解与其大相径庭。在看别人的文章,我习惯抱着学习的心态先把自己的观点放低一点,最后再做比较和综合理解。
第二次理解
新的理解:主线程执行机制,查看宏任务和微任务,在我看来,可以把宏任务分为同步宏任务和异步宏任务、异步微任务,事件的循环大致是,执行同步宏任务过程中,遇到异步宏任务,把其放到队列中。
昨天看完阮一峰老师的《什么是 Event Loop?》和他后一年更正的《JavaScript 运行机制详解:再谈Event Loop》得到的一点不完全理解,尝试着边写边理解他的观点,但是越看越觉得与MDN的概念不一样,甚至感觉有点脱离了计算机组成原理和操作系统,这就让我很迷惑,一度怀疑自己是不是完全理解错了方向。但之后我找到了阮一峰老师让朴灵老师评注的文章。
第三次理解
回看我第一次的理解,我理解的方向应该是没有大问题的,但是理解的内容是不太对的。
首先得清楚一个概念,JavaScript本身是没有执行机制的,所说的执行机制是JavaScript的运行环境的执行机制,准确的讲,应该是:Runtime的执行机制,也就是一开始所提及的运行时概念。
JavaScript的单线程特点,没有太多的评议,只需要知道,JavaScript本身是没有异步任务的,但可以通过某种手段达到异步的目的。阮一峰老师说得已经足够去理解。
关于event loop
首先提及一个任务队列的概念,朴灵老师提到
任务队列既不是事件的队列,也不是消息的队列。
任务队列就是你在主线程上的一切调用。
这里与MDN上队列的描述有点不一样,以下是我的观点:
在MDN上队列上存放的是message,对于这个的理解。我目前认为这是一种抽象概念,所有的完整事件或者回调函数都会被认为是一个message,要知道JavaScript是单线程的,主线程上同一时刻只能有一个message在处理,这个队列也就是主线程执行的等待队列。主线程不断检查message队列并处理message就是event loop。
但是我们要了解到,这个是一个抽象的概念,并不是说只有一个队列。或者说是单看一个队列是不足以去理解的,我们可以想象它不同类型的事件有不同的队列。但归根到底,还是理解总结为一个队列。
所谓的事件驱动,就是将一切抽象为事件。IO操作完成是一个事件,用户点击一次鼠标是事件,Ajax完成了是一个事件,一个图片加载完成是一个事件。
朴灵老师的这一段话,理解出来也符合我自己的观点,只不过我更想贴近MDN上的说法,将一切抽象为消息。
队列的知识大概了解之后,就能好好谈谈同步与异步的问题
同步是默认的运行机制,所有的message都是同步到队列中,等待主线程处理完上一个message。异步任务的产生,是通过一些手段实现的。
到这里就可以提到宏任务和微任务的概念
按照单线程的特点,队列是按顺序执行的,也就是执行同步任务,但是异步任务又是如何生成的呢。我的理解,异步任务大致可以分为宏任务和微任务(下面有常见的一些宏任务和微任务),异步任务是不会紧接着上一个同步任务同样加入message队列;异步任务比如setTimeout它本身是在某个事件的代码中间,但是在执行到它时并没有加入到message队列中,而是等待传入的delay参数的时间后,再实际加入message队列中。
而且它的处理机制是等待message队列为空和栈为空时,他才会触发delay后加入到message。宏任务队列和微任务队列就是用来理解这些异步任务的处理时机。
上面的图简述了所有的任务都会加入到message队列中,异步任务总是会让同步任务先,而异步任务中的宏任务总会让微任务先行。然后形成一个message队列,主线程按序执行。
但一个图感觉并不能完善表述出来。
这个图简述了执行的流程,有点像让行机制,但感觉还是上面的图更清楚,算了,有问题再改把。
注意:处理过程中,遇到的异步代码,会加入到微任务或者宏任务中,也是为什么宏任务执行时会查看有没有微任务,因为一个宏任务里面还可能存在宏任务和微任务。并不是执行完了微任务,新加入的微任务就不去检查。
宿主环境提供的叫宏任务,由语言标准提供的叫微任务。
有一些文章说明了同步任务也是宏任务,这一点我不太认同,同步任务不加入到宏任务里去理解会更通俗易懂,异步任务分为宏任务和微任务,也就是说异步任务也是有分优先级。
阮一峰老师的文章
www.ruanyifeng.com/blog/2014/1…
朴灵老师对其的评注
宏任务和微任务(有待后续修正......)
常见宏任务有:
- script (可以理解为外层同步代码)
- setTimeout/setInterval
- setImmediate(Node.js)
- I/O
- UI事件
- postMessage
常见微任务有:
- Promise
- process.nextTick(Node.js)
- Object.observe
- MutaionObserver
事件循环event loop
MDN的描述
类似以下代码的机制
while (queue.waitForMessage()) {
queue.processNextMessage();
}
"执行至完成"
每一个消息完整地执行后,其它消息才会被执行。这为程序的分析提供了一些优秀的特性,包括:当一个函数执行时,它不会被抢占,只有在它运行完毕之后才会去运行任何其他的代码,才能修改这个函数操作的数据。
按照我上面的理解,消息队列中消息是一个固定的处理顺序。只不过同步任务与异步任务加入到队列中的时机不一样。
这个模型的一个缺点在于当一个消息需要太长时间才能处理完毕时,Web 应用程序就无法处理与用户的交互,例如点击或滚动。为了缓解这个问题,浏览器一般会弹出一个“这个脚本运行时间过长”的对话框。
一个良好的习惯是缩短单个消息处理时间,并在可能的情况下将一个消息裁剪成多个消息。
MDN给出了一个方案来处理单个脚本时间过长的问题。
添加消息
在浏览器里,每当一个事件发生并且有一个事件监听器绑定在该事件上时,一个消息就会被添加进消息队列。如果没有事件监听器,这个事件将会丢失。所以当一个带有点击事件处理器的元素被点击时,就会像其他事件一样产生一个类似的消息。
这里提及
如果没有时间监听器
相当于定义了的函数,并没有调用,也就没有产生事件。
函数 setTimeout 接受两个参数:待加入队列的消息和一个时间值(可选,默认为 0)。这个时间值代表了消息被实际加入到队列的最小延迟时间。如果队列中没有其它消息并且栈为空,在这段延迟时间过去之后,消息会被马上处理。但是,如果有其它消息,setTimeout 消息必须等待其它消息处理完。因此第二个参数仅仅表示最少延迟时间,而非确切的等待时间。
注意:这里的最小延迟时间基本可以直接理解。
零延迟
零延迟并不意味着回调会立即执行。以 0 为第二参数调用 setTimeout 并不表示在 0 毫秒后就立即调用回调函数。
例子我就不放了。需要看的,直接在MDN上看就可以了。
基本上,setTimeout 需要等待当前队列中所有的消息都处理完毕之后才能执行,即使已经超出了由第二参数所指定的时间。
这段话看看就好了,没啥需要特别理解的。
多个运行时互相通信
一个 web worker 或者一个跨域的 iframe 都有自己的栈、堆和消息队列。两个不同的运行时只能通过 postMessage 方法进行通信。如果另一个运行时侦听 message 事件,则此方法会向该运行时(Runtime)添加消息。
永不阻塞
JavaScript 的事件循环模型与许多其他语言不同的一个非常有趣的特性是,它永不阻塞。处理 I/O 通常通过事件和回调来执行,所以当一个应用正等待一个 IndexedDB 查询返回或者一个 XHR 请求返回时,它仍然可以处理其它事情,比如用户输入。
由于历史原因有一些例外,如 alert 或者同步 XHR,但应该尽量避免使用它们。注意,例外的例外也是存在的(但通常是实现错误而非其它原因)。
永不阻塞
也就是异步任务给同步任务让行的理解,所以不阻塞。
最后,细读朴灵老师的最后的话
- 准确讲,使用事件驱动的系统中,必然有非常非常多的事件。如果事件都产生,都要主循环去处理,必然会导致主线程繁忙。那对于应用层的代码而言,肯定有很多不关心的事件(比如只关心点击事件,不关心定时器事件)。这会导致一定浪费。
- 这篇文章里没有讲到的一个重要概念是watcher。观察者。
- 事实上,不是所有的事件都放置在一个队列里。
- 不同的事件,放置在不同的队列。
- 当我们没有使用定时器时,则完全不用关心定时器事件这个队列
- 当我们进行定时器调用时,首先会设置一个定时器watcher。事件循环的过程中,会去调用该watcher,检查它的事件队列上是否产生事件(比对时间的方式)
- 当我们进行磁盘IO的时候,则首先设置一个io watcher,磁盘IO完成后,会在该io watcher的事件队列上添加一个事件。事件循环的过程中从该watcher上处理事件。处理完已有的事件后,处理下一个watcher
- 检查完所有watcher后,进入下一轮检查
- 对某类事件不关心时,则没有相关watcher
这里提及的
那对于应用层的代码而言,肯定有很多不关心的事件。这会导致一定浪费。
我对此还不能很好地理解,还需要更进一步探索。
但后续提到的watcher,基本对应我所理解的不是仅存在单个队列,各种任务都有对应的队列,但是我们可以归结到一个队列中。
仅是个人理解,想必有很多不正确的地方,敬请指出。