并发模型与事件循环


2021-03-10 上次更新时间:4/29/2022, 9:34:08 AM 0

JavaScript有一个基于事件循环的并发模型,事件循环负责执行代码、收集和处理事件以及执行队列中的子任务。这个模型与其它语言中的模型截然不同,比如 C 和 Java。

# 基础概念

# 进程与线程

  • 进程(progress):进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。它是操作系统动态执行的基本单元,在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。

  • 线程(thread):是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。

更多专业名词介绍请看:《专业名词》

# javascript是单线程

JavaScript 引擎是单线程,也就是说每次只能执行一项任务,其他任务都得按照顺序排队等待被执行,只有当前的任务执行完成之后才会往下执行下一个任务。

JavaScript的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?

为了利用多核CPU的计算能力,HTML5提出Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM。所以,这个新标准并没有改变JavaScript单线程的本质。

# 内核(浏览器内核)

浏览器是多进程的,每个进程有多个线程在工作。

浏览器内核主要包括三个部分:

  • 排版渲染引擎
  • JS 引擎:解析 Javascript 语言
  • 其他

我们常说的v8引擎就是 chrome浏览器的 JavaScript引擎。

《五大主流浏览器及四大内核》

# js引擎(JS Engine)

JavaScript 引擎并不是独立运行的,它运行在宿主环境中,对多数开发者来说通常就是Web 浏览器。经过最近几年的发展,JavaScript 已经超出了浏览器的范围,进入了其他环境,比如通过像 Node.js 这样的工具进入服务器领域、智能设备、机器人等各种各样的设备中。

所有宿主环境都有一个共同“点”(thread,也指线程),即它们都提供了一种机制来处理程序中多个块的执行,且执行每块时调用 JavaScript 引擎,这种机制被称为事件循环(eventLoop)

  • js引擎只有一个线程,它维护一个消息队列,当前函数栈执行完成之后就去不断地取消息队列中的消息(回调),取到了就执行
  • js引擎只负责取消息,不负责生产消息。

# js运行时(JS Runtime)

在 JSAPI 中,JS Runtime 代表 JavaScript 引擎实例的顶级对象。一个程序通常只有一个JSRuntime,即使它有很多线程。

所有 JavaScript 代码和大多数 JSAPI 调用都运行在 JSContext 中。JS Context 是 JS Runtime 的子级,是一个可以运行脚本的上下文环境。它包含全局对象、执行堆栈、异常处理、错误报告和某些语言选项是基于Per-的JSContext。创建上下文后,可以将上下文多次用于不同的脚本或JSAPI查询。例如,浏览器可能会为每个HTML页面创建一个单独的上下文。页面中的每个脚本都可以使用相同的上下文。

例如,JS 可以调用浏览器提供的 API,如 window 对象,DOM 相关 API 等。这些 API 并不是由V8引擎提供的,是存在与浏览器当中的。同样,在Node.js中,可以把Node的各种库提供的API称为RunTime。所以可以这么理解,Chrome和Node.js都采用相同的V8引擎,但拥有不同的运行环境(RunTime Environments)

  • JSRuntime 负责给 js 引擎线程发送消息。

比如浏览器DOM事件发送一条鼠标点击的消息(浏览器子线程和js引擎线程的IPC通信),那么js引擎在执行完函数栈之后就会取到这条鼠标点击信息,执行消息(即回调);

比如node运行时读取文件,执行系统调用,完成后发送读取文件完成的消息,之后的过程同上。js运行时只负责生产消息,不负责取消息.

# 阻塞与非阻塞

  • 阻塞调用是指调用结果返回之前,当前线程会被挂起
  • 非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程

一般来说非阻塞性的任务采取同步的方式,直接在主线程的执行栈完成。阻塞性的任务都会采用异步来执行。

区别

  • 阻塞和非阻塞强调的是程序在等待调用结果(消息,返回值)时的状态
  • 同步和异步强调的是消息通信机制 (synchronous communication/ asynchronous communication)

# 运行时概念

运行时概念

  • 堆:一块存储对象的内存区域(基础数据类型存在栈中)
  • 栈:遵从 后进先出(LIFO) 原则的有序集合(函数调用时形成了栈)
  • 队列:遵循 先进先出(FIFO) 原则的一组有序的项(队列是在运行时才会创建的)

# 栈(Stack)

函数调用形成了一个由若干帧组成的栈。

JavaScript中函数的执行过程,其实就是一个入栈出栈的过程。当脚本要调用一个函数时,JS解析器把该函数推入栈中(push)并执行当函数运行结束后,JS解析器将它从堆栈中推出(pop)。

function foo(b) {
  let a = 10;
  return a + b + 11;
}

function bar(x) {
  let y = 3;
  return foo(x * y);
}

setTimeout(() => {
    console.log(1111)
}, 0)

console.log(bar(7)); // 返回 42
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

当调用 bar 时,第一个包含了 bar 的参数和局部变量。 当 bar 调用 foo 时,第二个帧被创建并被压入栈中,放在第一个帧之上,帧中包含 foo 的参数和局部变量。当 foo 执行完毕然后返回时,第二个帧就被弹出栈(剩下 bar 函数的调用帧 )。当 bar 也执行完毕然后返回时,第一个帧也被弹出,栈就被清空了。

# 堆(Heap)

对象被分配在堆中,堆是一个用来表示一大块(通常是非结构化的)内存区域的计算机术语。

堆的存储方式:无序的键值对。堆的存取方式跟顺序没有关系,不局限出入口。

# 队列(Queue)

不同的叫法:消息队列 = 任务队列 = 回调队列

JavaScript 运行时包含了一个待处理消息的消息队列。每一个消息都关联着一个用以处理这个消息的回调函数。

在事件循环期间的某个时刻,运行时会从最先进入队列的消息开始处理队列中的消息。被处理完的消息会被移出队列,并作为输入参数来调用与之关联的函数。正如前面所提到的,调用一个函数总是会为其创造一个新的栈帧。

函数的处理会一直进行到执行栈再次为空为止;然后事件循环将会处理队列中的下一个消息(如果还有的话)

# 同步与异步

  • 同步(Synchronous):在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务

  • 异步(Asynchronous):不进入主线程、而进入"任务队列"(task queue)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。

js 常见的异步任务:

  • Ajax 回调
  • Dom事件操作
  • setTimeOut
  • promise的then方法
  • Node读取文件

# 事件循环

不同叫法:事件循环机制 = 事件轮询机制 = event loop

Event Loop 是 javascript 运行的核心。了解它能帮助你写出更优质的代码,也避免写出有隐藏bug的代码。

一旦有事件需要运行,事件循环就会运行,直到队列清空。事件循环的每一轮称为一个tick。用户交互、IO 和定时器会向事件队列中加入事件。

事件循环机制在浏览器和node下的标准不一样,下面一一讲解。

# 浏览器的EventLoop

浏览器的 EventLoop 是基于Html5标准

事件循环模型图:

在浏览器的事件循环中,异步队列的任务又分为两种:宏任务和微任务。宏任务队列可以有多个,微任务队列只有一个。

宏任务(macro-task)

# 浏览器 Node 说明
同步代码 Y Y 可看做整块script代码
UI rendering Y Y
I/O Y Y
setTimeout Y Y
setInterval Y Y
requestAnimationFrame Y N
setImmediate N Y

微任务(micro-task)

# 浏览器 Node 说明
process.nextTick N Y
Promise 后的then语句 Y Y
MutationObserver Y Y html5新特性,注意兼容性
queueMicrotask Y Y html5新特性,注意兼容性

宏任务和微任务一般可以做以上区分,但不同的环境可能会有表现差异。

同步与异步、宏任务与微任务是两个不同维度的描述,它们不是互斥的。

# 执行顺序

先同步再异步,在此基础上先宏任务再微任务。以此类推,不断循环。

  • 从上往下,同步直接执行,异步分发 MacroTask 或者 microtask
  • 碰到 MacroTask 直接执行,并且把回调函数放入 MacroTask 执行队列中(下次事件循环执行);碰到 microtask 直接执行。把回调函数放入 microtask 执行队列中(本次事件循环执行)
  • 当同步任务执行完毕后,去执行微任务 microtask。(microtask队列清空)
  • 由此进入下一轮事件循环:执行宏任务 MacroTask (setTimeout,setInterval,callback)

举个例子:

console.log('同步宏任务 start')

setTimeout(function () {
    new Promise(function (resolve, reject) {
        console.log('异步宏任务 promise');
        resolve();
    }).then(function () {
        console.log('异步微任务then')
    })
    console.log('异步宏任务');
}, 0)

queueMicrotask(() => {
    console.log('同步微任务 - 1')}
);

new Promise(function (resolve, reject) {
    console.log('同步宏任务 promise');
    resolve();
}).then(function () {
    console.log('同步微任务then')
})

queueMicrotask(() => {
    console.log('同步微任务 - 2')}
);

console.log('同步宏任务 end')
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

输出结果:

// 同步宏任务 start
// 同步宏任务 promise
// 同步宏任务 end
// 同步微任务 - 1
// 同步微任务then
// 同步微任务 - 2
// 异步宏任务 promise
// 异步宏任务
// 异步微任务then
1
2
3
4
5
6
7
8
9

更多代码示例请看:《浏览器事件循环机制(EventLoop)代码示例》

# Node的Eventloop

事件循环是 Node.js 处理非阻塞 I/O 操作的机制。Node的事件循环基于 libuv(跨平台异步IO库)。

Node 的 event loop 分为6个阶段:

  • 定时器:本阶段执行已经被 setTimeout() 和 setInterval() 的调度回调函数。
  • 待定回调:执行延迟到下一个循环迭代的 I/O 回调。
  • idle, prepare:仅系统内部使用。
  • 轮询:检索新的 I/O 事件;执行与 I/O 相关的回调(几乎所有情况下,除了关闭的回调函数,那些由计时器和 setImmediate() 调度的之外),其余情况 node 将在适当的时候在此阻塞。
  • 检测:setImmediate() 回调函数在这里执行。
  • 关闭的回调函数:一些关闭的回调函数,如:socket.on('close', ...)。

待更新...

参考文章:

上次更新时间: 4/29/2022, 9:34:08 AM