【JavaScript】JavaScript 单线程与异步

Posted by 西维蜀黍 on 2018-11-11, Last Modified on 2021-09-21

JavaScript 单线程设计

JavaScript 的一个很重要的特性就是单线程,即同一个时间点只能够做一件事情。我们不禁会问,为什么不把 JavaScript 设计成多线程呢?

按照最朴素的想法来理解,多线程可以充分使用多核CPU的计算资源,这样程序的执行效率不是应该更高吗?

然而,**这就是 JavaScript 设计的巧妙之处。**作为运行在浏览器宿主上的脚本语言,JavaScript 主要用于高效的处理与用户的交互,具体来说是根据用户的特定操作触发相应的业务逻辑、操作DOM。

基于这个特点,如果将 JavaScript 设计成可支持多线程,则需要引入复杂的多线程同步机制,这违背了 JavaScript 尽可能简单的核心特征。

若不引入线程同步机制,一个线程在某个DOM元素上添加内容,而另一个线程在这个元素上删除内容,这时候浏览器应该以哪个线程的操作为准呢?

关于浏览器的多线程与JavaScript的单线程

JavaScript的单线程

因为JavaScript运行在浏览器中,这里的单线程其实是指:每个 window 有且只有一个 JavaScript 线程

这意味着,若一个浏览器打开了多个window,则会存在多个JavaScript 线程,但是这些多个JavaScript 线程是完全没有交集而独立运行的。

以 Chrome 浏览器中为例,当你打开一个 Tab 页时,其实就是创建了一个进程,一个进程中可以有多个线程。

浏览器不是单线程的

因为JavaScript运行在浏览器中,每个window只有一个JavaScript 线程。

但是浏览器却不是单线程的,而是多线程的,具体可能包括以下线程(不同浏览器的实现各有不同):

  • GUI界面渲染线程
    • 主要负责页面的渲染,解析 HTML、CSS,构建 DOM 树,布局和绘制等。
    • 当界面需要重绘或者由于某种操作引发回流时,将执行该线程。
    • 该线程与 JavaScript 引擎线程互斥,当执行 JavaScript 引擎线程时,GUI 渲染会被挂起,当任务队列空闲时,JavaScript 引擎才会去执行 GUI 渲染。
  • JavaScript引擎线程
    • 该线程当然是主要负责处理 JavaScript 脚本,执行代码。
    • 也是主要负责执行准备好待执行的回调函数事件,比如,定时器计数结束,或者异步请求成功并正确返回时,对应的回调函数被放入任务队列,等待 JavaScript 引擎线程的最终执行。
    • 提的一提的是,该线程与 GUI 渲染线程互斥。因此,当 JavaScript 引擎线程执行 JavaScript 脚本时间过长,将导致页面渲染的阻塞。
  • 定时器计数线程
    • 主线程依次执行代码时,遇到定时器,会将定时器交给该线程处理,当计数完毕后,定时器计数线程会将计数完毕后的事件加入到任务队列的尾部,等待 JavaScript 引擎线程执行。
  • 浏览器事件触发线程
    • 负责响应各种事件(比如 setTimeout 定时器计数结束, ajax 等异步请求成功并触发回调函数,或者用户触发点击事件时),并将对应的回调函数依次加入到任务队列的队尾,等待 JavaScript 引擎线程的执行。
  • 异步HTTP请求线程
    • 负责执行异步请求一类的函数的线程,如: Promise,axios,ajax 等。
    • 主线程依次执行代码时,遇到异步请求,会将函数交给该线程处理,当监听到状态码变更,如果有回调函数,事件触发线程会将回调函数加入到任务队列的尾部,等待 JavaScript 引擎线程执行。

比如,在通过XMLHttpRequest发送Request时,浏览器会新开一个线程(以处理网络请求)。

当网络请求的状态变化时,且之前已设置了回调函数。这个回调函数任务就被添加到等待JavaScript线程执行的消息队列中。

当请求结束后,该线程可能就会被销毁。

任务队列(Task Queue/Callback Queue )

单线程意味着,只有当前一个任务(一个任务可以理解为一个函数或一个函数组)执行完成,才会执行后一个任务。如果前一个任务执行的时间很长,那后一个待执行任务就不得不一直等着了。

如果是因为前一个任务执行过程中的计算量比较大,导致后一个任务被阻塞了,那还勉强可以理解。

但是,大部分情况往往都是因为任务在执行I/O操作(比如磁盘读写、网络传输),导致当前任务和后面的任务被阻塞了(而被阻塞时,CPU是闲着的)。

因此,JavaScript 的设计者意识到,当进行类似I/O操作这样耗时的操作时,采取挂起当前等待任务的策略,而先去执行后面的任务。当这些等待任务已经得到了结果(比如,对于磁盘操作来说,已经将数据从硬盘读取到了内核缓存区),再在合适的时机执行他们(什么叫“合适的时机”接下来会解释)。

任务队列(Task Queue),也可以成为回调队列(Callback Queue),本质上是一个用于管理回调函数调用的FIFO(First In, First Out,先进先出)的队列(Queue)。

任务类型

于是,在JavaScript中,所有的任务就分为了以下两种:

同步任务(synchronous task)

在主线程上排队且依次被执行的任务。这意味着这种任务可能会阻塞其他任务的执行(当这个任务的执行时间很长时)。

异步任务(asynchronous task)

异步任务通常暗指其包括一个回调函数(Callback)

当异步任务执行后,不会等待到其对应的回调操作可以被执行,而是立即开始执行**主线程执行栈(Call Stack)**中的下一个任务。

当主线程执行栈中不存在任务时,才依次执行任务队列(Task Queue)中的异步回调操作。

特点

  • 异步任务一定包含一个回调函数(Callback),当执行条件满足时,对应的回调函数任务将被添加到**任务队列(Task Queue)**中(若任务队列中没有其他任务,则这个回调函数任务会被立即执行)。
    • 比如,发送Ajax请求,当Response已经收到且状态正常时(这里发生了一个事件),对应的回调函数就会立即被放入任务队列(但不一定会被立即执行)
    • 又比如,通过setInterval间隔触发一个任务,当时间到达时(执行条件满足),将这个任务添加到任务队列中
  • 只有当这个任务所在的任务队列中没有其他任务了,且主线程执行栈中也没有任务时(这就是上文所谓的合适的时机),这个异步任务对应的回调函数才会进入主线程被执行。

类型

  • 调用setInterval、setTimeout触发的任务。当到达指定时间时,该任务就会被放入任务队列
  • DOM 元素的 Event Listeners触发的任务。比如在一个Button上绑定了一个onclick事件,当用户click时,指定的任务就会被添加到任务队列
  • 发送一个Ajax请求,当Response已经收到且状态正常(stateCode = 200)时(这里发生了一个事件),才将回调函数添加到任务队列

事件循环(Event Loop)

执行栈(Call Stack)

  1. 在主线程上,存在一个执行栈(Call Stack)
  2. 所有的同步任务都在主线程上执行
  3. 主线程外,存在一个任务队列(Task Queue)。当异步任务执行条件满足时,就向任务队列添加一个新回调任务
  4. 只有当执行栈中无执行任务时(主线程空闲),系统才会读取任务队列中任务,并进行执行。否则任务队列中任务一直不会被执行

事件循环(Event Loop)

主线程在空闲时,从任务队列中读取任务以执行,这个过程是不断循环的,整个运行机制又称为事件循环(Event Loop)。

Reference