JavaScript 异步编程指南 - 从引擎到运行时这些事件循环概念了解下~

小知识,大挑战!本文正在参与「程序员必备小知识」创作活动

在 《JavaScript 异步编程指南》的上个模块中,我主要讲解了异步编程的基本应用,在这个模块系列中我想来聊聊事件循环,英文称为 EventLoop。 ​

相信这个名字对于参加过 JavaScript 面试的同学(包括前端或后端 Node.js)而言不会陌生。 ​

讨论事件循环的文章很多,成系列的倒不是很多见,我将事件循环放在《JavaScript 异步编程指南》系列的第二个模块展开讨论,也是希望能够对 JavaScript 异步编程有个更深刻的理解。 ​

学习事件循环前置知识

JavaScript 这门编程语言,既可以在客户端浏览器上运行,也可以在服务端 Node.js 上运行。我想以一种自己理解的角度来讲,所以上来不会直接去讲浏览器中的 EventLoop 或 Node.js 中的 EventLoop。 ​

事件循环中的一些概念,无论是在浏览器或 Node.js 中我们去学习事件循环时,这些都是通用的,了解这些概念对于后面的学习也会相对轻松些。 ​

单线程、调用栈、堆、队列、Eventloop 这些词通过可视化界面描述看起来就像下图展示的,但是它们之间的关系是怎么样呢?接下来我会分别的去介绍。 image.png

为什么是单线程?

JavaScript 是单线程的,此时,是否有疑问为什么是单线程呢?多线程处理效率不是更高吗? ​

需要从浏览器说起,在浏览器环境中对于 DOM 操作,试想如果多个线程来对同一个 DOM 操作,一个线程添加 DOM 而另一个线程删除 DOM 那这结果到底是删除还是添加呢?是不是就乱了呢?那也就意味着对于 DOM 的操作只能是单线程,避免 DOM 渲染冲突。 ​

在浏览器环境中 UI 渲染线程和 JavaScript 执行引擎是互斥的,一方在执行时都会导致另一方被挂起。 ​

上面说了既然 JavaScript 是单线程的,那么同一时间只能处理一件事情,对于高并发大量请求不是会造成程序阻塞吗? ​

答案是 No,解决阻塞等待的方案就是异步,例如,程序发起一次网络请求或文件请求不必同步等待响应结果,真正处理这些任务由另外的线程实现,待有结果了再通知到 JavaScript 主线程,在 JavaScript 中正是通过单线程加事件循环实现的,同时也避免了多线程上下文切换,资源抢占问题,达到更好的高并发成就。 ​

另外,HTML5 提出了 Web Worker 标准,Node.js 提供了 worker_threads 模块,允许我们在服务中创建多个线程,但是这些都没改变 JavaScript 单线程的本质,这些创建线程属于子线程还是由主线程来管理。 ​

调用栈

栈是一种先进后出的数据结构,JavaScript 是一个单线程的编程语言,每次只能运行一段代码,有且只有一个调用栈。 ​

JavaScript 中所有的任务可以归为两种:同步任务与异步任务。 ​

我们先看第一种,同步任务在主线程上排队执行,形成一个由若干个帧组成的调用栈(Call Stack)。 ​

下例,当调用 hello() 函数时,第一个帧被创建压入栈中,该函数又调用了 intro() 函数,第二个帧被创建并压入栈中,位于 hello() 之上。此时 intro() 函数中没有在调用其它函数了,按照栈的后进先出的规则,intro() 函数开始执行直到完成第二个帧从栈中弹出,之后开始执行 hello() 函数,执行完毕之后,第一个帧从栈中弹出,栈也就被清空了。

function intro() {
	console.log('My name is codingMay!');
}
function hello() {
  intro();
	console.log('Hello');
}
hello();
复制代码

通过动图的方式展示下运行结果。

sync-call-stack.gif

在开发中,还有一个问题也是不可避免的,在某些场景下程序会抛出一些错误信息,也许是显示的错误定义,也许是意外的未知错误。 ​

我们对示例做下改造,让 intro() 抛出一个 Error 对象,在 Chrome 控制台运行之后,错误信息从 intro、Hello 再到匿名函数,把整个错误的调用栈都打印出来了。这是一个同步调用,上下文信息是有关联的,程序能够跟踪到下一行要执行的一些代码。 image.png 你可能还听过一个问题 “内存泄漏”,下面左侧就是一个例子,hello() 函数递归调用自身,代码没有设置边界,hello() -> hello() -> ... 程序一直这样运行下去,调用栈不断的增加数据,直到超过栈的最大空间限制,程序会报一个错误 VM356:4 Uncaught RangeError: Maximum call stack size exceededimage.png 思考一个问题 “上面的递归代码怎么改造才能不触发栈溢出?前提是还是递归调用。”

JavaScript 在执行时所有的数据会存放在内存里,像函数、函数变量、参数等这些已知数据占用空间的存在于内存区域的栈中,代码执行过程中创建的对象,存在于堆中,也是内存中的另外一块区域。

队列与回调函数

在 JavaScript 中当调用栈有东西还在执行时,我们的程序也不会空闲去执行其它的操作,试想,如果调用栈出现一些很耗时的任务,如果是用在客户端用户会看到页面被卡住了,如果是用在服务端会造成接口响应很慢,也就没有并发优势了,这是很糟糕的一件事,我们不能让 JavaScript 主线程阻塞。 ​

修改下上面的示例,在 inrto() 方法里加上 setTimeout 延迟执行,看下程序的执行是怎么样的?

function intro() {
	setTimeout(function timer() {
  	console.log('My name is codingMay!');
  }, 8000)
}
function hello() {
  intro();
	console.log('Hello');
}
hello(); 
复制代码

上述代码,intro() 函数内部执行了 setTimeout 定时器函数,这个是异步的,我们的 JavaScript 主线程不会在这里等待,会立即返回。setTimeout 第一个参数我们传入的 timer 这个是我们需要执行的代码,这里 timer 通常也是我们说的回调函数。 ​

注:Web Apis 这个是由宿主环境提供的 API,这里也有单独的线程来实现,例如定时器就是由宿主环境实现的。 image.png setTimeout 不是由 JavaScript 引擎实现的,这个是由 JavaScript 程序所运行的宿主环境提供的,理解这个概念也不难,在客户端我们的宿主环境就是浏览器,如果在服务端就是 Node.js。当计时器时间到了之后,宿主环境会将 timer 函数封装为一个事件放入 “队列”,队列是一个先进先出的数据结构。 ​

接下来执行队列里的任务就是 EventLoop 了~

EventLoop

EventLoop 从这个名字上也可以看出它是一个持续循环的过程,它会检查当前调用栈是否为空,只有在当前调用栈为空后进入下一个 Loop,如果任务队列有任务,取出执行,如果任务队列为空,它会同步地等待消息到达。 ​

按照如下类似方式来实现:

while (queue.waitForMessage()) {
  queue.processNextMessage(); // 同步地等待消息到达
}
复制代码

通过一个 Gif 完整的展示其运行效果,稍微有点灰,因为上传过程中被压缩了。 async-settimeout-eventloop-compression.gif

Reference

猜你喜欢

转载自juejin.im/post/7017379977179955237