进程、线程、js单线程
一. 区分进程和线程
很多新手是区分不清线程和进程的,没有关系。这很正常。先看看下面这个形象的比喻:
进程是一个工厂,工厂有它的独立资源-工厂之间相互独立-线程是工厂中的工人,多个工人协作完成任务-工厂内有一个或多个工人-工人之间共享空间
如果是 windows 电脑中,可以打开任务管理器,可以看到有一个后台进程列表。对,那里就是查看进程的地方,而且可以看到每个进程的内存资源信息以及 cpu 占有率。
所以,应该更容易理解了:进程是 cpu 资源分配的最小单位(系统会给它分配内存)
最后,再用较为官方的术语描述一遍:
-
进程是 cpu 资源分配的最小单位(是能拥有资源和独立运行的最小单位)
-
线程是 cpu 调度的最小单位(线程是建立在进程的基础上的一次程序运行单位,一个进程中可以有多个线程)
提示:
-
不同进程之间也可以通信,不过代价较大
-
现在,一般通用的叫法:单线程与多线程,都是指在一个进程内的单和多。(所以核心还是得属于一个进程才行)
二. 浏览器是多进程的
理解了进程与线程了区别后,接下来对浏览器进行一定程度上的认识:(先看下简化理解)
-
浏览器是多进程的
-
浏览器之所以能够运行,是因为系统给它的进程分配了资源(cpu、内存)
-
简单点理解,每打开一个 Tab 页,就相当于创建了一个独立的浏览器进程。
关于以上几点的验证,请再第一张图:
图中打开了 Chrome 浏览器的多个标签页,然后可以在 Chrome 的任务管理器中看到有多个进程(分别是每一个 Tab 页面有一个独立的进程,以及一个主进程)。
感兴趣的可以自行尝试下,如果再多打开一个 Tab 页,进程正常会 +1 以上(不过,某些版本的 ie 却是单进程的)
注意: 在这里浏览器应该也有自己的优化机制,有时候打开多个 tab 页后,可以在 Chrome 任务管理器中看到,有些进程被合并了(所以每一个 Tab 标签对应一个进程并不一定是绝对的)
三、为什么 JavaScript 是单线程 ?
JavaScript 语言的一大特点就是单线程,也就是说,同一个时间只能做一件事。那么,为什么JavaScript 不能有多个线程呢 ?这样能提高效率啊。
JavaScript 的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript 的主要用途是与用户互动,以及操作 DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript 同时有两个线程,一个线程在某个 DOM 节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?
所以,为了避免复杂性,从一诞生,JavaScript 就是单线程,这已经成了这门语言的核心特征,将来也不会改变。
为了利用多核 CPU 的计算能力,HTML5 提出 Web Worker 标准,允许 JavaScript 脚本创建多个线程,但是子线程完全受主线程控制,且不得操作 DOM。所以,这个新标准并没有改变 JavaScript 单线程的本质。
四. JavaScript是单线程,怎样执行异步的代码 ?
单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。
js 引擎执行异步代码而不用等待,是因有为有 消息队列和事件循环。
- 消息队列:消息队列是一个先进先出的队列,它里面存放着各种消息。
- 事件循环:事件循环是指主线程重复从消息队列中取消息、执行的过程。
实际上,主线程只会做一件事情,就是从消息队列里面取消息、执行消息,再取消息、再执行。当消息队列为空时,就会等待直到消息队列变成非空。而且主线程只有在将当前的消息执行完成后,才会去取下一个消息。这种机制就叫做事件循环机制,取一个消息并执行的过程叫做一次循环。
事件循环用代码表示大概是这样的:
while(true) {
var message = queue.get();
execute(message);
}
那么,消息队列中放的消息具体是什么东西?消息的具体结构当然跟具体的实现有关,但是为了简单起见,我们可以认为:
消息就是注册异步任务时添加的回调函数。
再次以异步 AJAX 为例,假设存在如下的代码:
$.ajax('http://segmentfault.com', function(resp) {
console.log('我是响应:', resp);
});
// 其他代码
...
...
...
主线程在发起 AJAX 请求后,会继续执行其他代码。AJAX 线程负责请求 segmentfault.com,拿到响应后,它会把响应封装成一个 JavaScript 对象,然后构造一条消息:
// 消息队列中的消息就长这个样子
var message = function () {
callbackFn(response);
}
其中的 callbackFn 就是前面代码中得到成功响应时的回调函数。
主线程在执行完当前循环中的所有代码后,就会到消息队列取出这条消息(也就是 message 函数),并执行它。到此为止,就完成了工作线程对主线程的通知,回调函数也就得到了执行。如果一开始主线程就没有提供回调函数,AJAX 线程在收到 HTTP 响应后,也就没必要通知主线程,从而也没必要往消息队列放消息。
用图表示这个过程就是:
从上文中我们也可以得到这样一个明显的结论,就是:
异步过程的回调函数,一定不在当前这一轮事件循环中执行。
事件循环进阶:macrotask 与 microtask
一张图展示 JavaScript 中的事件循环:
一次事件循环:先运行 macroTask 队列中的一个,然后运行 microTask 队列中的所有任务。接着开始下一次循环(只是针对 macroTask 和 microTask,一次完整的事件循环会比这个复杂的多)。
JS 中分为两种任务类型:macrotask 和 microtask,在 ECMAScript 中,microtask 称为 jobs,macrotask 可称为 task。
它们的定义?区别?简单点可以按如下理解:
macrotask(又称之为宏任务),可以理解是每次执行栈执行的代码就是一个宏任务(包括每次从事件队列中获取一个事件回调并放到执行栈中执行)
每一个 task 会从头到尾将这个任务执行完毕,不会执行其它
浏览器为了能够使得 JS 内部 task 与 DOM 任务能够有序的执行,会在一个 task 执行结束后,在下一个 task 执行开始前,对页面进行重新渲染
(task -> 渲染 -> task ->...)
microtask(又称为微任务),可以理解是在当前 task 执行结束后立即执行的任务
也就是说,在当前 task 任务后,下一个 task 之前,在渲染之前
所以它的响应速度相比 setTimeout(setTimeout是task)会更快,因为无需等渲染
也就是说,在某一个 macrotask 执行完后,就会将在它执行期间产生的所有 microtask 都执行完毕(在渲染前)
分别很么样的场景会形成 macrotask 和 microtask 呢 ?
macroTask: 主代码块, setTimeout, setInterval, setImmediate, requestAnimationFrame, I/O, UI rendering(可以看到,事件队列中的每一个事件都是一个 macrotask)
microTask: process.nextTick, Promise, Object.observe, MutationObserver
补充:在 node 环境下,process.nextTick 的优先级高于 Promise,也就是可以简单理解为:在宏任务结束后会先执行微任务队列中的 nextTickQueue 部分,然后才会执行微任务中的 Promise 部分。
另外,setImmediate 则是规定:在下一次 Event Loop(宏任务)时触发(所以它是属于优先级较高的宏任务),(Node.js 文档中称,setImmediate 指定的回调函数,总是排在 setTimeout 前面),所以 setImmediate 如果嵌套的话,是需要经过多个 Loop 才能完成的,而不会像 process.nextTick 一样没完没了。
实践:上代码
我们以 setTimeout、process.nextTick、promise 为例直观感受下两种任务队列的运行方式。
console.log('main1');
process.nextTick(function() {
console.log('process.nextTick1');
});
setTimeout(function() {
console.log('setTimeout');
process.nextTick(function() {
console.log('process.nextTick2');
});
}, 0);
new Promise(function(resolve, reject) {
console.log('promise');
resolve();
}).then(function() {
console.log('promise then');
});
console.log('main2');
别着急看答案,先以上面的理论自己想想,运行结果会是啥?
最终结果是这样的:
main1
promise
main2
process.nextTick1
promise then
setTimeout
process.nextTick2
process.nextTick 和 promise then在 setTimeout 前面输出,已经证明了macroTask 和 microTask 的执行顺序。但是有一点必须要指出的是。上面的图容易给人一个错觉,就是主进程的代码执行之后,会先调用 macroTask,再调用 microTask,这样在第一个循环里一定是 macroTask 在前,microTask在后。
但是最终的实践证明:在第一个循环里,process.nextTick1 和 promise then 这两个 microTask 是在 setTimeout 这个 macroTask 里之前输出的,这是为什么呢 ?
因为主进程的代码也属于 macroTask(这一点我比较疑惑的是主进程都是一些同步代码,而 macroTask 和 microTask 包含的都是一些异步任务,为啥主进程的代码会被划分为 macroTask,不过从实践来看确实是这样,而且也有理论支撑:【翻译】Promises/A+ 规范)。
主进程这个 macroTask(也就是 main1、promise 和 main2 )执行完了,自然会去执行 process.nextTick1 和 promise then 这两个 microTask。这是第一个循环。之后的 setTimeout 和process.nextTick2 属于第二个循环。
别看上面那段代码好像特别绕,把原理弄清楚了,都一样 ~
requestAnimationFrame、Object.observe(已废弃) 和 MutationObserver 这三个任务的运行机制大家可以从上面看到,不同的只是具体用法不同。重点说下 UI rendering。在 HTML 规范:event-loop-processing-model 里叙述了一次事件循环的处理过程,在处理了 macroTask 和 microTask 之后,会进行一次 Update the rendering,其中细节比较多,总的来说会进行一次 UI 的重新渲染。
事件循环机制进一步补充
这里就直接引用一张图片来协助理解:(参考自 Philip Roberts 的演讲《Help, I’m stuck in an event-loop》)
上图大致描述就是:
- 主线程运行时会产生执行栈,栈中的代码调用某些 api 时,它们会在事件队列中添加各种事件(当满足触发条件后,如 ajax 请求完毕)
- 而栈中的代码执行完毕,就会读取事件队列中的事件,去执行那些回调
- 如此循环
- 注意,总是要等待栈中的代码执行完毕后才会去读取事件队列中的事件
任务调度
大部分操作系统的任务调度是采用时间片轮转的抢占式调度方式,也就是说一个任务执行一小段时间后强制暂停去执行下一个任务,每个
任务轮流执行。任务执行的一小段时间叫做时间片,任务正在执行时的状态叫运行状态,任务执行一段时间后强制暂停去执行下一个任务,被
暂停的任务就处于就绪状态,等待下一个属于它的时间片的到来。这样每个任务都能得到执行,由于CPU的执行效率非常高,时间片
非常短,在各个任务之间快速地切换,给人的感觉就是多个任务在“同时进行”,这也就是我们所说的并发