# 浏览器与Node的事件循环(Event Loop)

Event Loop 即事件循环,是指浏览器或 Node 的一种解决 javaScript 单线程运行时不会阻塞的一种机制,也就是我们经常使用异步的原理。

# 进程与线程

上面说了 js 单线程执行的,那什么是线程和进程?

进程是 CPU 资源分配的最小单位;线程是 CPU 调度的最小单位。一个进程由一个或多个线程组成,线程是一个进程中代码的不同执行路线,并且一个进程的内存空间是共享的,每个线程都可用这些共享内存。

进程描述了 CPU 在运行指令及加载和保存上下文所需的时间,放在应用上来说就代表了一个程序。线程是进程中的更小单位,描述了执行一段指令所需的时间

把这些概念拿到浏览器中来说,当你打开一个 Tab 页时,其实就是创建了一个进程,一个进程中可以有多个线程,比如渲染线程、JS 引擎线程、HTTP 请求线程、定时触发器线程、事件触发线程等等。当你发起一个请求时,其实就是创建了一个线程,当请求结束后,该线程可能就会被销毁。

上文说到了 JS 引擎线程和渲染线程,大家应该都知道,在 JS 运行的时候可能会阻止 UI 渲染,这说明了两个线程是互斥的。这其中的原因是因为 JS 可以修改 DOM,如果在 JS 执行的时候 UI 线程还在工作,就可能导致不能安全的渲染 UI。这其实也是一个单线程的好处,得益于 JS 是单线程运行的,可以达到节省内存,节约上下文切换时间。

总结:

  • 进程是操作系统分配资源的基本单位,具有独立的内存空间和系统资源。
  • 线程是进程中的执行单元,共享进程的内存空间和系统资源,可以并发执行。
  • 此外协程:是一种用户态的轻量级线程,由程序员控制,可以手动挂起和恢复执行,适用于高并发和高效的任务调度。

# 浏览器架构,都有什么进程和线程

现代浏览器通常采用多进程架构,其中包括以下主要进程和线程:

  1. 浏览器进程(Browser Process):也称为主进程或渲染进程管理器,负责协调和管理其他进程。它处理用户界面、用户输入、网络请求和文件访问等操作。每个浏览器标签页通常对应一个渲染进程。

  2. 渲染进程(Renderer Process):每个标签页都有一个独立的渲染进程,负责处理页面的渲染和用户交互。渲染进程包含多个线程,包括以下几个主要线程:

  • 主线程(Main Thread):负责解析HTML、CSS,构建DOM树和渲染页面。
  • JavaScript引擎线程:负责执行JavaScript代码。
  • 事件线程(Event Thread):处理用户交互事件,如鼠标点击、键盘输入等。
  • 布局线程(Layout Thread):负责计算页面布局。
  • 绘制线程(Paint Thread):将页面内容绘制到屏幕上。
  1. GPU进程(GPU Process):负责处理页面中需要使用图形加速的任务,如绘制3D效果、视频解码等。它与渲染进程协同工作,通过硬件加速提高图形性能。

  2. 网络进程(Network Process):负责处理网络请求和响应,包括DNS解析、HTTP请求等。它与渲染进程分离,提供独立的网络功能。

  3. 插件进程(Plugin Process):负责运行浏览器插件,如Flash插件。由于插件的安全性和稳定性问题,现代浏览器逐渐减少对插件的支持。

# 进程、线程之间如何通信

进程和线程之间可以使用多种通信机制进行交互和数据传输。以下是常见的进程和线程之间通信的方式:

  • 共享内存(Shared Memory):进程或线程可以通过共享内存区域来实现数据共享。多个进程或线程可以访问相同的内存区域,从而实现数据的传递和共享。这种方式效率高,但需要进行同步和互斥操作以避免数据竞争。

  • 消息队列(Message Queue):进程或线程可以通过消息队列发送和接收消息。消息队列是一个存储消息的缓冲区,发送方将消息放入队列,接收方从队列中读取消息。消息队列可以实现异步通信和解耦,但需要定义消息格式和处理机制。

  • 管道(Pipe):管道是一种半双工的通信机制,可以在进程或线程之间传递数据。管道可以是匿名管道(在同一进程的不同线程之间使用)或命名管道(在不同进程之间使用)。管道通常用于有关联关系的进程或线程之间的通信。

  • 套接字(Socket):套接字是一种网络通信机制,可以在不同主机或同一主机的不同进程之间进行通信。进程或线程可以通过套接字进行网络通信,发送和接收数据。

  • 信号量(Semaphore):信号量用于控制对共享资源的访问。进程或线程可以使用信号量来同步和互斥访问共享资源。

  • 文件和管道(File and Pipe):进程或线程可以通过读写文件或管道来进行通信。一个进程或线程将数据写入文件或管道,另一个进程或线程从文件或管道中读取数据。

这些通信机制的选择取决于具体的应用场景和需求。不同的通信方式有不同的特点和适用性,开发人员需要根据实际情况选择合适的通信机制来实现进程和线程之间的交互。

# 如果某个线程挂掉了,这个进程会挂掉吗?如果某个线程修改了内存,另一个线程能感知到吗?

如果某个线程挂掉了,通常情况下不会导致整个进程挂掉。进程中的线程是相互独立的执行单元,一个线程的崩溃通常只会影响到该线程本身,而不会影响其他线程或整个进程。其他线程仍然可以继续执行,除非它们依赖于已经崩溃的线程的结果或资源。

当一个线程修改了内存,其他线程可以感知到这种修改。这是因为线程在同一个进程中共享相同的内存空间。当一个线程修改了内存中的数据,其他线程可以读取到这些修改后的数据。然而,需要注意的是,多个线程同时对相同的内存位置进行读写操作可能会导致竞态条件和数据不一致的问题。因此,在多线程编程中,通常需要使用同步机制(如互斥锁、信号量等)来确保线程之间的正确协作和数据一致性。

# 计算机的原码、反码、补码

原码、反码和补码是计算机中用于表示有符号整数的编码方式。

  • 原码(Sign-Magnitude):原码是最基本的表示方法,其中最高位表示符号位(0 表示正数,1 表示负数),其余位表示数值的绝对值。例如,+5 的原码为 00000101,-5 的原码为 10000101。

  • 反码(Ones' Complement):反码是在原码的基础上,对负数的数值部分取反。即正数的反码与原码相同,负数的反码为符号位不变,数值部分取反。例如,+5 的反码仍为 00000101,-5 的反码为 11111010。

  • 补码(Two's Complement):补码是在反码的基础上,对负数的数值部分进行加 1 操作。即正数的补码与原码相同,负数的补码为符号位不变,数值部分取反后再加 1。例如,+5 的补码仍为 00000101,-5 的补码为 11111011。

补码表示方法具有以下优点:

  • 0 的表示唯一,没有正零和负零的区别。
  • 补码的加法可以直接使用二进制的加法器进行计算,无需额外的符号位处理。
  • 补码表示了一个数值范围从最小负数到最大正数,没有溢出的问题。
  • 补码表示方法在计算机中广泛应用,因为它简化了运算和表示的逻辑,同时能够有效地处理负数和溢出的情况。

# 并发和并行的区别

关键区别在于并发是指同时处理多个任务的能力,而并行是指同时执行多个任务的能力。

并发强调任务之间的交替执行和任务切换,而并行强调任务之间的同时执行。两者常常结合使用,以提高系统的性能和响应能力。

# 事件循环 Event Loop

在 JavaScript 中,任务被分为两种,一种宏任务(MacroTask)也叫Task,一种叫微任务(MicroTask)。

MacroTask(宏任务)

script 全部代码、setTimeout、setInterval、setImmediate(浏览器暂时不支持,只有IE10支持,具体可见MDN)、I/O、UI Rendering。

MicroTask(微任务)

Process.nextTick(Node独有)、Promise、Object.observe(废弃)、MutationObserver(具体使用方式查看 这里 (opens new window)

# 浏览器中的 Event Loop

Javascript 有一个 main thread 主线程和 call-stack 调用栈(执行栈),所有的任务都会被放到调用栈等待主线程执行。

# JS调用栈

JS调用栈采用的是后进先出的规则,当函数执行的时候,会被添加到栈的顶部,当执行栈执行完成后,就会从栈顶移出,直到栈内被清空。

# 同步任务和异步任务

Javascript单线程任务被分为同步任务和异步任务,同步任务会在调用栈中按照顺序等待主线程依次执行,异步任务会在异步任务有了结果后(例如 setTimeout 的等待时间结束),将注册的回调函数放入任务队列中等待主线程空闲的时候(调用栈被清空),被读取到栈内等待主线程的执行。

任务队列 Task Queue,即队列,先进先出,即异步任务先进来先执行。

# 任务执行步骤

首先要明白:

  • JS分为同步任务和异步任务
  • 同步任务都在主线程上执行,形成一个执行栈
  • 主线程之外,事件触发线程管理着一个任务队列,只要异步任务有了运行结果,就在任务队列之中放置一个事件
  • 一旦执行栈中的所有同步任务执行完毕(此时JS引擎空闲),系统就会读取任务队列,将可运行的异步任务添加到可执行栈中,开始执行

具体执行步骤(运行机制):

  • 执行一个宏任务(栈中没有就从事件队列中获取)
  • 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中
  • 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
  • 当前宏任务执行完毕,开始检查渲染,然后GUI线程接管渲染 渲染完毕后,JS线程继续接管,开始下一个宏任务(从事件队列中获取)

案例一:

console.log('script start');

setTimeout(function() {
  console.log('setTimeout');
}, 0);

Promise.resolve().then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});
console.log('script end');

/*
script start
script end
promise1
promise2
setTimeout
*/
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

这题比较简单,就不解析了

案例二:

//请写出输出内容
async function async1() {
    console.log('async1 start');
    await async2();
    console.log('async1 end');
}
async function async2() {
	console.log('async2');
}

console.log('script start');

setTimeout(function() {
    console.log('setTimeout');
}, 0)

async1();

new Promise(function(resolve) {
    console.log('promise1');
    resolve();
}).then(function() {
    console.log('promise2');
});
console.log('script end');


/*
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout
*/
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
29
30
31
32
33
34
35
36
37

解析:

  1. 首先,事件循环从宏任务 (macrotask) 队列开始,这个时候,宏任务队列中,只有一个 script(整体代码)任务;当遇到任务源 (task source) 时,则会先分发任务到对应的任务队列中去。

  2. 然后我们看到首先定义了两个 async 函数,接着往下看,然后遇到了 console 语句,直接输出 script start。输出之后,script 任务继续往下执行,遇到 setTimeout,其作为一个宏任务源,则会先将其任务分发到对应的队列中:

  3. script 任务继续往下执行,执行了async1()函数,前面讲过 async 函数中在 await 之前的代码是立即执行的,所以会立即输出 async1 start。

遇到了 await 时,会将 await 后面的表达式执行一遍,所以就紧接着输出 async2,然后将 await 后面的代码也就是 console.log('async1 end') 加入到 microtask 中的 Promise 队列中,接着跳出async1函数来执行后面的代码。

  1. script任务继续往下执行,遇到 Promise 实例。由于Promise中的函数是立即执行的,而后续的 .then 则会被分发到 microtask 的 Promise 队列中去。所以会先输出 promise1,然后执行 resolve,将 promise2 分配到对应队列。

  2. script任务继续往下执行,最后只有一句输出了 script end,至此,全局任务就执行完毕了。

根据上述,每次执行完一个宏任务之后,会去检查是否存在 Microtasks;如果有,则执行 Microtasks 直至清空 Microtask Queue。

  1. 因而在 script 任务执行完毕之后,开始查找清空微任务队列。此时,微任务中, Promise 队列有的两个任务 async1 end 和 promise2,因此按先后顺序输出 async1 end,promise2。当所有的 Microtasks 执行完毕之后,表示第一轮的循环就结束了。

  2. 第二轮循环依旧从宏任务队列开始。此时宏任务中只有一个 setTimeout,取出直接输出即可,至此整个流程结束。

案例三:

const first = () => (new Promise((resolve,reject)=>{
    console.log(3);
    let p = new Promise((resolve, reject)=>{
         console.log(7);
        setTimeout(()=>{
           console.log(5);
           resolve(6); 
        },0)
        resolve(1);
    }); 
    resolve(2);
    p.then((arg)=>{
        console.log(arg);
    });

}));

first().then((arg)=>{
    console.log(arg);
});
console.log(4);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

简单解析:

第一轮事件循环:

先执行宏任务,主script ,new Promise 立即执行,输出【3】,执行p这个 new Promise 操作,输出【7】,发现 setTimeout,将回调放入下一轮任务队列(Event Queue),p 的 then,姑且叫做then1,放入微任务队列,发现 first 的 then,叫 then2,放入微任务队列。执行 console.log(4),输出【4】,宏任务执行结束。

再执行微任务,执行 then1,输出【1】,执行 then2,输出【2】。到此为止,第一轮事件循环结束。开始执行第二轮。

第二轮事件循环:

先执行宏任务里面的,也就是 setTimeout 的回调,输出【5】。resovle 不会生效,因为 p 这个 Promise 的状态一旦改变就不会在改变了。 所以最终的输出顺序是:3、7、4、1、2、5。

# Node 中的 Event Loop

Node 中的 Event Loop 和浏览器中的是完全不相同的东西。

node

Node.js 采用 V8 作为 js 的解析引擎,而 I/O 处理方面使用了自己设计的 libuv,libuv 是一个基于事件驱动的跨平台抽象层,封装了不同操作系统一些底层特性,对外提供统一的 API(时间,非阻塞的网络,异步文件操作,子进程等等),Event Loop 也是它里面的实现。

Node.js的运行机制如下:

  • V8 引擎解析 JavaScript 脚本。
  • 解析后的代码,调用 Node API。
  • libuv 库负责 Node API 的执行。它将不同的任务分配给不同的线程,形成一个 Event Loop(事件循环),以异步的方式将任务的执行结果返回给 V8 引擎。
  • V8 引擎再将结果返回给用户。

node

Node 的 Event Loop 分为 6 个阶段,它们会按照顺序反复运行。每当进入某一个阶段的时候,都会从对应的回调队列中取出函数去执行。当队列为空或者执行的回调函数数量到达系统设定的阈值,就会进入下一阶段。

# timer

timers 阶段会执行 setTimeout 和 setInterval 回调,并且是由 poll 阶段控制的。

同样,在 Node 中定时器指定的时间也不是准确时间,只能是尽快执行

# I/O

I/O 阶段会处理一些上一轮循环中的少数未执行的 I/O 回调

# idle, prepare

idle, prepare 阶段内部实现,仅node内部使用,这里就不详细讲了。

# poll

poll 是一个至关重要的阶段,这一阶段中,系统会做两件事情

  • 回到 timer 阶段执行回调
  • 执行 I/O 回调

并且在进入该阶段时如果没有设定了 timer 的话,会发生以下两件事情:

  • 如果 poll 队列不为空,会遍历回调队列并同步执行,直到队列为空或者达到系统限制
  • 如果 poll 队列为空时,会有两件事发生:
    • 如果有 setImmediate 回调需要执行,poll 阶段会停止并且进入到 check 阶段执行回调
    • 如果没有 setImmediate 回调需要执行,会等待回调被加入到队列中并立即执行回调,这里同样会有个超时时间设置防止一直等待下去

当然设定了 timer 的话且 poll 队列为空,则会判断是否有 timer 超时,如果有的话会回到 timer 阶段执行回调。

# check

check 阶段执行 setImmediate() 的回调

# close callbacks

close callbacks 阶段执行 socket 的 close 事件

在以上的内容中,我们了解了 Node 中的 Event Loop 的执行顺序,接下来我们将会通过代码的方式来深入理解这块内容。

首先在有些情况下,定时器的执行顺序其实是随机的:

setTimeout(() => {
    console.log('setTimeout')
}, 0)
setImmediate(() => {
    console.log('setImmediate')
})
1
2
3
4
5
6

首先你需要知道:

  • setTimeout 和 setImmediate 二者非常相似,区别主要在于调用时机不同。
  • setImmediate 设计在poll阶段完成时执行,即check阶段;
  • setTimeout 设计在poll阶段为空闲时,且设定时间到达后执行,但它在timer阶段执行

对于以上代码来说,setTimeout 可能执行在前,也可能执行在后:

  • setTimeout(fn, 0) === setTimeout(fn, 1),这是由源码决定的
  • 进入事件循环也是需要成本的,如果在准备时候花费了大于 1ms 的时间,那么在 timer 阶段就会直接执行 setTimeout 回调
  • 那么如果准备时间花费小于 1ms,那么就是 setImmediate 回调先执行了

当然在某些情况下,他们的执行顺序一定是固定的,比如以下代码:

const fs = require('fs')

fs.readFile(__filename, () => {
    setTimeout(() => {
        console.log('timeout');
    }, 0)
    setImmediate(() => {
        console.log('immediate')
    })
})
1
2
3
4
5
6
7
8
9
10

在上述代码中,setImmediate 永远先执行。因为两个代码写在 IO 回调中,IO 回调是在 poll 阶段执行,当回调执行完毕后队列为空,发现存在 setImmediate 回调,所以就直接跳转到 check 阶段去执行回调了。

上面介绍的都是 macrotask 的执行情况,对于 microtask 来说,它会在以上每个阶段完成前清空 microtask 队列:

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

Promise.resolve().then(function() {
  console.log('promise1')
})
1
2
3
4
5
6
7

对于以上代码来说,其实和浏览器中的输出是一样的,microtask 永远执行在 macrotask 前面。

# process.nextTick

最后我们来讲讲 Node 中的 process.nextTick,这个函数其实是独立于 Event Loop 之外的,它有一个自己的队列,当每个阶段完成后,如果存在 nextTick 队列,就会清空队列中的所有回调函数,并且优先于其他 microtask 执行。

setTimeout(() => {
 console.log('timer1')
 Promise.resolve().then(function() {
   console.log('promise1')
 })
}, 0)

process.nextTick(() => {
 console.log('nextTick')
 process.nextTick(() => {
   console.log('nextTick')
   process.nextTick(() => {
     console.log('nextTick')
     process.nextTick(() => {
       console.log('nextTick')
     })
   })
 })
})
// nextTick=>nextTick=>nextTick=>nextTick=>timer1=>promise1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

对于以上代码,大家可以发现无论如何,永远都是先把 nextTick 全部打印出来。

# node 中的宏任务(MacroTask)和微任务(MicroTask)

Node端事件循环中的异步队列也是这两种:macro(宏任务)队列和 micro(微任务)队列:

  • 常见的 macro-task 比如:setTimeout、setInterval、 setImmediate、script(整体代码)、 I/O 操作等

  • 常见的 micro-task 比如: process.nextTick、new Promise().then(回调)等

# 案例

案例一:

console.log('start')
setTimeout(() => {
  console.log('timer1')
  Promise.resolve().then(function() {
    console.log('promise1')
  })
}, 0)
setTimeout(() => {
  console.log('timer2')
  Promise.resolve().then(function() {
    console.log('promise2')
  })
}, 0)
Promise.resolve().then(function() {
  console.log('promise3')
})
console.log('end')
//start=>end=>promise3=>timer1=>timer2=>promise1=>promise2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
  1. 一开始执行栈的同步任务(这属于宏任务)执行完毕后(依次打印出 start end,并将 2 个 timer 依次放入 timer 队列),会先去执行微任务(这点跟浏览器端的一样),所以打印出 promise3

  2. 然后进入 timers 阶段,执行 timer1 的回调函数,打印 timer1,并将promise.then回调放入 microtask 队列,同样的步骤执行 timer2,打印 timer2;这点跟浏览器端相差比较大,timers 阶段有几个 setTimeout/setInterval 都会依次执行,并不像浏览器端,每执行一个宏任务后就去执行一个微任务。

案例二:

async function async1() {
    console.log('async1 start');
    await async2();
    console.log('async1 end');
}

async function async2() {
    new Promise(function(resolve) {
        console.log('promise1');
        resolve();
        console.log("promiseResolve")
    }).then(function() {
        setTimeout(function() {
            console.log('setTimeout1');
        })
        console.log('promise2');
    });
}

console.log('script start');

setTimeout(function() {
    console.log('setTimeout2');
}, 0)

async1();

process.nextTick(() => {
    console.log("nextTick");
})

new Promise(function(resolve) {
    console.log('promise3');
    resolve();
    setTimeout(() => {
        console.log('setTimeout3')
    })
}).then(function() {
    console.log('promise4');
})
.then(() => console.log('promise5'))
.then(() => console.log('promise6'))

console.log('script end');

/*
script start
async1 start
promise1
promiseResolve
promise3
nextTick // nextTick有自己的队列,优先于其它微任务先执行
promise2
script end
async1 end
promise4
promise5
promise6
setTimeout2
setTimeout3
setTimeout1
*/
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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62

这题和平时浏览器执行的顺序一样,多了一个 nextTick

参考文章:

前端面试之道 (opens new window)