浅谈JS 事件循环机制

238 阅读11分钟

「这是我参与2022首次更文挑战的第12天,活动详情查看:2022首次更文挑战」。

JS 事件循环

谈到JS事件循环,必须要先了解一下进程和线程的概念。

进程和线程

进程(Process)负责为程序的运行提供必备的环境,是 cpu 资源分配的最小单位(是能拥有资源和独立运行的最小单位)。

线程(Thread)是计算机中最小的计算单位,线程负责执行进程中的程序。

线程是 cpu 调度的最小单位(线程是建立在进程的基础上的一次程序运行单位,一个进程中可以有多个线程)。

一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。

进程和线程的区别和联系

进程有独立的地址空间,一个进程崩溃后,在保护模式下不会对其它进程产生影响,而线程只是一个进程中的不同执行路径。线程有自己的堆栈和局部变量,但线程之间没有单独的地址空间,所以多进程的程序要比多线程的程序健壮,但在进程切换时,耗费资源较大,效率要差一些。

线程是处理器调度的基本单位,但是进程不是。

  • 地址空间:同一进程的线程共享本进程的地址空间,而进程之间则是独立的地址空间。
  • 资源拥有:同一进程内的线程共享本进程的资源如内存、I/O、cpu 等,但是进程之间的资源是独立的。
  • 一个进程崩溃后,在保护模式下不会对其他进程产生影响,但是一个线程崩溃整个进程都死掉。所以多进程要比多线程健壮。
  • 进程切换时,消耗的资源大,效率高。所以涉及到频繁的切换时,使用线程要好于进程。同样如果要求同时进行并且又要共享某些变量的并发操作,只能用线程不能用进程
  • 执行过程:每个独立的进程有一个程序运行的入口、顺序执行序列和程序入口。但是线程不能独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制。

引用一个接地气的比喻可能细究起来不是特别贴切,但总的来说挺形象:进程=火车线程=车厢

  • 线程在进程下行进(单纯的车厢无法运行)
  • 一个进程可以包含多个线程(一辆火车可以有多个车厢)
  • 不同进程间数据很难共享(一辆火车上的乘客很难换到另外一辆火车,比如站点换乘)
  • 同一进程下不同线程间数据很易共享(A 车厢换到 B 车厢很容易)
  • 进程要比线程消耗更多的计算机资源(采用多列火车相比多个车厢更耗资源)
  • 进程间不会相互影响,一个线程挂掉可能导致整个进程挂掉(一列火车不会影响到另外一列火车,但是如果一列火车上中间的一节车厢着火了,可能会影响到所有车厢)
  • 进程可以拓展到多机,进程最适合多核(不同火车可以开在多个轨道上,同一火车的车厢不能在行进的不同的轨道上)
  • 进程使用的内存地址可以上锁,即一个线程使用某些共享内存时,其他线程必须等它结束,才能使用这一块内存。(比如火车上的洗手间)-"互斥锁"
  • 进程使用的内存地址可以限定使用量(比如火车上的餐厅,最多只允许多少人进入,如果满了需要在门口等,等有人出来了才能进去)-“信号量”

至此我们对进程和线程有了一定的了解,是时候进入到事件循环了!

单线程

单线程就是同一时间只能做一件事,这个限制简化了编程方式,避免了并发问题的发生。 JS 是单线程。

多线程

优点:

  • 能适当提高程序的执行效率
  • 能适当提高资源的利用率(CPU,内存)
  • 线程上的任务执行完成后,线程会自动销毁

缺点:

  • 开启线程需要占用一定的内存空间(默认情况下,每一个线程都占 512 KB)
  • 如果开启大量的线程,会占用大量的内存空间,降低程序的性能
  • 线程越多,CPU 在调用线程上的开销就越大
  • 程序设计更加复杂,比如线程间的通信、多线程的数据共享

NodeJS 事件循环

事件循环是了解 Node.js 最重要的方面之一。 因为它阐明了 Node.js 如何做到异步且具有非阻塞的 I/O,所以它基本上阐明了 Node.js 的“杀手级应用”,正是这一点使它成功了。

Node.js® is a JavaScript runtime built on Chrome’s V8 JavaScript engine. Node.js uses an event-driven, non-blocking I/O model that makes it lightweight and efficient.

Nodejs 是事件驱动、非阻塞 I/O、高效、轻量,是单线程且支持高并发的脚本语言。那为什么一个单线程的效率可以这么高,同时处理数万级的并发而不会造成阻塞呢?就是我们下面所说的——事件驱动/事件循环(Event Loop)。

在大多数浏览器中,每个浏览器选项卡都有一个事件循环,以使每个进程都隔离开,并避免使用无限的循环或繁重的处理来阻止整个浏览器的网页。

Node.js 的运行机制如下。

(1)V8 引擎解析 JavaScript 脚本。

(2)解析后的代码,调用 Node API。

(3)libuv 库负责 Node API 的执行。它将不同的任务分配给不同的线程,形成一个 Event Loop(事件循环),以异步的方式将任务的执行结果返回给 V8 引擎。

(4)V8 引擎再将结果返回给用户。

首先,Nodejs 的单线程指的是主线程是单线程,由主线程按编码顺序一步步往下执行程序代码,如果遇到同步代码阻塞,主线程被占用,后面的程序都会被挂起等待前面的同步执行完成后再执行。

事件循环阻塞

任何需要花费一定事件(例如:定时器,网络请求等)才能将控制权返回给事件循环主线程的 js 代码,都会阻塞页面中其它 js 代码的执行,甚至阻塞 UI 线程,并且用户无法单击浏览、滚动页面等。

我们知道 js 的特点是 I/O 非阻塞,网络请求、文件系统操作等,所以被阻塞属于异常。 js 中出现了如此多的基于回调(promise 和 async/await),为了避免阻塞。

事件循环过程

两种事件循环过程的解释,一种是基于堆栈和队列,另一种是基于线程(池)。

事件循环——基于堆栈&队列

每个 Node.js 进程有一个主线程即调用堆栈(同步任务),此外还分别维护了一个消息队列作业队列(异步任务)。

调用堆栈是一个 LIFO(last in, first out)后进先出队列。

每个 Node.js 进程只有一个主线程在执行程序代码,形成一个调用堆栈(execution context stack)。

当程序执行时,事件循环会不断的检查调用堆栈,它会将找到的所有函数调用添加到堆栈中,并按顺序执行每个函数。而每次迭代中的事件循环都会查看调用堆栈中是否有东西并执行它直到调用堆栈为空。

事件循环会赋予调用堆栈优先级,优先处理调用堆栈中的所有程序,执行完成后便开始处理“消息队列”中的程序。

当调用到 setTimeout()时,浏览器或 Nodejs 会启用定时器,当定时器到期时,事件循环将回调函数放入到“消息队列”中排队。事件循环不会等待诸如setTimeout()fetch或其它函数来完成它们自身的工作,因为它们是由浏览器提供的,并且位于它们自身的线程中。

ECMAScript 2015 引入了作业队列的概念,Promise 使用了该队列。 作业队列会在当前函数结束之前 resolve 的 Promise 会在当前函数之后被立即执行。

执行优先级:调用堆栈 > 作业队列(Promise) > 消息队列(setTimeout、fetch)

所谓"回调函数"(callback),就是那些会被主线程挂起来的代码。异步任务必须指定回调函数,当主线程开始执行异步任务,就是执行对应的回调函数。

事件循环——基于线程

Event Loop is a programming construct that waits for and dispatches events or messages in a program.

另外一种基于线程(池)的事件循环过程的解释:

1、每个 Node.js 进程只有一个主线程在执行程序代码,所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。

2、主线程之外,还维护了一个"任务队列"(Task Queue)。当用户的网络请求或者其它的异步操作到来时,node 都会把它放到任务队列之中,此时并不会立即执行它,代码也不会被阻塞,继续往下走,直到主线程代码执行完毕。

3、主线程代码执行完毕完成后,然后通过事件循环机制,开始到任务队列的开头取出第一个事件,从线程池中分配一个线程去执行这个事件,接下来继续取出第二个事件,再从线程池中分配一个线程去执行,然后第三个,第四个。主线程不断的检查任务队列中是否有未执行的事件,直到任务队列中所有事件都执行完毕,此后每当有新的事件加入到任务队列中,都会通知主线程按顺序取出交 Event Loop 处理。当有事件执行完毕后,会通知主线程,主线程执行回调,线程归还给线程池。

4、主线程不断重复上面的第三步。

我们所看到的 js 单线程是指一个主线程,本质上的异步操作还是由线程池完成的,node 将所有的阻塞操作都交给了内部的线程池去实现,本身只负责不断的往返调度,并没有进行真正的 I/O 操作,从而实现异步非阻塞 I/O,这便是 node 单线程和事件驱动的精髓之处了。

面试题练习

题目 1:

new Promise((resolve) => {
  console.log('promise')
  resolve(5)
}).then((value) => {
  console.log('then回调', value)
})

function foo() {
  console.log('foo')
}

setTimeout(() => {
  console.log('setTimeout')
})

foo()

🐾 打印结果:

promise
foo
then回调 5
setTimeout

题目 2:

console.log('start')
// timer1
setTimeout(() => {
  console.log('children2')
  // promise1
  Promise.resolve().then(() => {
    console.log('children3')
    // timer2
    setTimeout(() => {
      console.log('children1')
    }, 0)
  })
}, 0)

// promise2
new Promise(function (resolve, reject) {
  console.log('children4')
  //timer3
  setTimeout(function () {
    console.log('children5')
    resolve('children6')
  }, 0)
}).then((res) => {
  console.log('children7')
  // timer4
  setTimeout(() => {
    console.log(res)
  }, 0)
})

事件循环开始:

第一轮:首先,调用堆栈执行同步任务输出 start,遇到 timer1,将其回调函数丢到消息队列中,然后执行 promise2,输出 children4,继续将 timer3 的回调函数丢到消息队列中(resolve 都没有执行所以还轮不到执行 promise2 的回调函数.then),此轮调用堆栈中的程序执行完成,此时:作业队列空,消息队列中有:timer1、timer3,然后开始依次执行消息队列中的任务。

第二轮:执行消息队列中的 timer1 的回调函数,输出children2,遇到 promise1,将其回调函数丢到作业队列中,此轮调用堆栈程序执行完成,此时:作业队列中有 promise1,消息队列中有:timer3,优先执行作业队列 promise1。

第三轮:开始执行 promise1 的回调函数,输出children3,遇到 timer2 将其回调函数丢到消息队列中,此轮调用堆栈程序执行完成,此时:作业队列空,消息队列中有:timer3、timer2。

第三轮:开始执行 timer3 的回调函数,输出children5,然后执行 resolve('children6'),输出children7,遇到timer4,将 timer4回调函数丢到消息队列,此轮调用堆栈执行完成,此时:作业队列空,消息队列中有:timer2、timer4。

第四轮:开始执行 timer2 的回调函数输出children1。此轮调用堆栈执行完成,此时:作业队列空,消息队列中有:timer4。

第五轮:开始执行 timer4 的回调函数输出children6,此轮调用堆栈执行完成,此时:作业队列空,消息队列空,循环结束。

🐾 打印结果:

start
children4
children2
children3
children5
children7
children1
children6

结语

🌈Nodejs 基础系列,欢迎你来 🍭 多多交流,技术始于需求源于分享~

参考:

-Nodejs 官方教程

-blog.csdn.net/weixin_3968…

-www.ruanyifeng.com/blog/2014/1…

如果有错别字或者不对的地方欢迎指出,将在第一时间改正,如果有更好的实现或想法希望留下你的评论 🔥