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)
- 在主线程上,存在一个执行栈(Call Stack)
- 所有的同步任务都在主线程上执行
- 主线程外,存在一个
任务队列(Task Queue)
。当异步任务
执行条件满足时,就向任务队列添加一个新回调任务 - 只有当
执行栈
中无执行任务时(主线程空闲),系统才会读取任务队列中任务,并进行执行。否则任务队列中任务一直不会被执行
事件循环(Event Loop)
主线程在空闲时,从任务队列中读取任务以执行,这个过程是不断循环的,整个运行机制又称为事件循环(Event Loop)。
Reference
- JavaScript 运行机制详解:再谈Event Loop - http://www.ruanyifeng.com/blog/2014/10/event-loop.html
- 《Help, I’m stuck in an event-loop》 - https://vimeo.com/96425312
- Understanding Javascript Function Executions — Call Stack, Event Loop , Tasks & more - https://medium.com/@gaurav.pandvia/understanding-javascript-function-executions-tasks-event-loop-call-stack-more-part-1-5683dea1f5ec
- What the heck is the event loop anyway? | Philip Roberts | JSConf EU - https://www.youtube.com/watch?v=8aGhZQkoFbQ
- Understanding the JavaScript call stack - https://medium.freecodecamp.org/understanding-the-javascript-call-stack-861e41ae61d4
- 浏览器与Node的事件循环(Event Loop)有何区别? - https://blog.fundebug.com/2019/01/15/diffrences-of-browser-and-node-in-event-loop/
- 深入理解js事件循环机制(浏览器篇)- http://lynnelv.github.io/js-event-loop-browser