浅谈自己对并发模型和事件循环event loop的理解

383 阅读13分钟

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…


朴灵老师对其的评注

www.pianshen.com/article/139…


宏任务和微任务(有待后续修正......)

常见宏任务有:

  1. script (可以理解为外层同步代码)
  2. setTimeout/setInterval
  3. setImmediate(Node.js)
  4. I/O
  5. UI事件
  6. postMessage

常见微任务有:

  1. Promise
  2. process.nextTick(Node.js)
  3. Object.observe
  4. 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,基本对应我所理解的不是仅存在单个队列,各种任务都有对应的队列,但是我们可以归结到一个队列中。


仅是个人理解,想必有很多不正确的地方,敬请指出。