Javascript深入浅出之单线程

在 CSS3 出来以前,我们通过 setTimeoutsetInterval 来实现动画,我们熟悉并且热衷于它们的使用,但它背后的原理却未深究。把握核心,深究问题的根本,永远是我们的核心,今天我们了解一下定时器背后的男人——单线程。

1
2
3
4
5
setTimeout(function() {
console.log('0');
}, 0);
console.log('1');

出乎意料,上面这段代码,会先输出 1,然后输出 0。(等我们掌握了单线程,也就迎刃而解了)

JS的单线程

一个浏览器进程,拥有很多线程,比如 GUI 渲染线程、事件触发线程、HTTP 请求线程,以及 js 引擎线程。 js 单线程就是指 js 的线程只有一条,也就是说,同一时间,只有某个任务能够执行,这段代码可能是初始化执行的代码,可能是某个元素的点击事件,也可能是 ajax 请求的回调函数。

javascript 操作几乎都是通过页面展示出来的,大多会用到 DOM 操作,设想一下,如果我有两个操作涉及到了同一个元素,而他们又是冲突的,浏览器需要如何处理?当然,我们也可以像其他语言一样,为 javascript 设计诸如 互斥锁 或者 信号量 的机制来实现多线程的协调处理,但是这会给定位轻量级脚本语言的 javascript 带来繁杂计算量的成本。

js 线程执行的时候,会阻塞其他线程的执行。比如下面这段代码,我们会发现,浏览器不会在每一个循环里去重新渲染页面,它会等待这段 js 执行完毕,再去执行 GUI 渲染线程,当然,浏览器会进行一定的优化,所以最终我们看到的是,浏览器直接显示出 1000000,而不是 0 - 1000000 极速闪过。

1
2
3
4
5
6
7
var
span = document.getElementsByTagName('span')[0],
i;
for (i = 0; i <= 1000000; i++) {
span.innerHTML = i;
}

上面我提到了,一个浏览器有一个进程,但是我们可爱的 Chrome 自有其可爱之处,它为了防止——因为一个页面的 js 的不好的执行影响浏览器的所有页面,所以它为每个页面开了一个进程。当然,这里我只对 Chrome 和 Firefox 进行了测试,ie 我是一直都知道的,它是整个浏览器一个进程。整个浏览器一个进程是什么意思?所有页面共用一个 js 线程,也就是,A 页面如果有一段耗时很长的代码正在执行, B 页面触发一个元素的点击事件并不会立即执行,需要等待 A 页面的 js 执行完毕。

Event Loop

单线程意味着同一时间只能执行一个任务,是不是说 Javascript 是同步的?那么为什么还会有 XHR 异步请求?如果有好几个事件等着被执行,该如何安排?

js 线程上的代码是顺序执行的,是同步的。而非同步的,诸如响应事件, ajax 请求,定时器事件,它们都被放置到一个队列里面,我们称之为任务队列。

前面我们讲过,函数被调用的时候,会创建上下文,形成上下文栈,当函数返回后,其上下文会退出栈。如果栈空了,就会从任务队列中,读取排在最前面的事件并执行。

下面我们就要主要讲一讲任务队列的由来了。

身处执行栈中的代码会调用外部的各种 API ,我们拿 XMLHttpRequest 来说。JS 的执行线程发起异步请求,浏览器会开一条新的 HTTP 请求线程来执行请求,这时候 JS 的这一步任务已完成,会继续执行下面的其他任务。然后在之后的某一刻, js 的事件触发线程监听到刚才的 HTTP 请求已完成,它就会把回调函数放入任务队列中的尾部等待执行。所以,js 的单线程 和 异步 更多地应该是属于浏览器的行为。

任务队列就是由这些异步事件或者异步请求的回调函数组成的,JS 线程最后执行的异步任务,其实就是这些回调函数。

任务队列是一个先进先出(FIFO)的队列,排在前面的事件,会最先被读取执行。然后再创建执行上下文,推入栈中,依此循环往复。

借助下面的图片来看一下,仿照的 《Help, I’m stuck in an event-loop》 中的图片。

Event_loop.png失踪了

定时器

setTimeoutsetInterval 是通过定时器线程进行处理的,它会在指定时间之后将回调函数放入任务队列中。我们来看一下下面这张图(来自于《JavaScript忍者秘籍》)。

single_thread_time.png失踪了

由于在 0-10ms 期间触发了 click 事件,所以事件处理函数被放置在了任务队列,并且处于任务队列最前端,但是由于当前代码还未执行完毕,所以不能立即读取执行 click 事件回调函数。10ms 的时候 setTimeoutsetInterval 的回调函数被放置到任务队列,紧随 click 事件。

到了 18ms 的时候,js 线程的代码执行完毕,先执行任务队列最前端的 click 事件回调函数,在其执行期间,20ms 处,setInterval 第二次想要插入队列尾部,但是由于拥有相同回调函数的 setInterval 实例已经存在了,所以这次的将被废弃,不能继续插入到队列中。

在 27ms 处, click 事件结束,相应地开始执行 setTimeout 的回调函数,在其执行期间,30ms 处,setInterval 第三次想要插入队列尾部,同理,不能继续插入到队列中。

在 34ms 处, setTimeout 事件结束,相应地开始执行 setInterval 的回调函数,在其执行期间,40ms 处,setInterval 第四次想要插入队列尾部,由于这时候队列中已经没有 setInterval 的实例了,所以成功插入到队列中。

在 42ms 处, 第一个 setInterval 事件结束,相应地开始执行第二个 setInterval 的回调函数…

零延迟 setTimeout

延迟时间为 0 的 setTimeout 不一定就是立即执行,这需要看当前栈是否为空,以及任务队列中在其前面是否有正在排队的任务。

我们上面所看的这段代码,就是指当前代码执行完后(等待当前栈为空),立即执行回调函数(假设任务队列中在其之前没有正在排队的任务)。

1
2
3
4
5
setTimeout(function() {
console.log('0');
}, 0);
console.log('1');

分解长耗时任务

利用上面我们刚刚学习的 零延迟setTimeout 可以给我们带来一个实际的开发福利,当我们进行一个需要耗时特别久的任务时,我们应当考虑一下,用户是否能够愿意等待这么久。所以,我么需要进行一些必要的分解。

1
2
3
4
5
6
7
var container = document.getElementById('container');
for (var i = 0; i < 200000; i++) {
var span = document.createElement('span');
span.appendChild(document.createTextNode(i));
container.appendChild(span);
}

这段代码创建了 200000 个DOM节点,对于浏览器来说是个挺大的负担,浏览器也需要一段时间来计算并渲染,也极有可能影响用户的交互操作。

所以我们需要做的就是把它分成几个步骤,好让后面的代码能够继续执行,也能减轻浏览器的压力。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var
container = document.getElementById('container'),
count = 200000,
devide = 5,
chunkSize = count/devide,
iteration = 0;
setTimeout(function generate() {
for (var i = 0; i < chunkSize; i++) {
var base = chunkSize * iteration;
var span = document.createElement('span');
span.appendChild(document.createTextNode((i + base) + ',' + iteration));
container.appendChild(span);
}
iteration++;
if (iteration < devide) {
setTimeout(generate, 0);
}
}, 0);

  • 浏览器是多线程的,js 是单线程的。
  • 异步任务的回调函数都会被添加到任务队列中,当且仅当执行栈为空时,才会读取任务队列的第一个任务。
  • 单线程带来的是某些时候事件响应不按照我们预定的时间节点执行。
  • 对于耗时过久的任务,我们需要将它分解成多步进行,具体分为几步,根据实际情况即可。

热评文章