细说JavaScript单线程的一些事

标签: JavaScript 单线程javascript


首发地址:码农网《细说JavaScript单线程的一些事》html

最近被同窗问道 JavaScript 单线程的一些事,我竟回答不上。好吧,感受本身的 JavaScript 白学了。下面是我这几天整理的一些关于 JavaScript 单线程的一些事。html5

首先,说下为何 JavaScript 是单线程?

总所周知,JavaScript 是以单线程的方式运行的。说到线程就天然联想到进程。那它们有什么联系呢?java

进程和线程都是操做系统的概念。进程是应用程序的执行实例,每个进程都是由私有的虚拟地址空间、代码、数据和其它系统资源所组成;进程在运行过程当中可以申请建立和使用系统资源(如独立的内存区域等),这些资源也会随着进程的终止而被销毁。而线程则是进程内的一个独立执行单元,在不一样的线程之间是能够共享进程资源的,因此在多线程的状况下,须要特别注意对临界资源的访问控制。在系统建立进程以后就开始启动执行进程的主线程,而进程的生命周期和这个主线程的生命周期一致,主线程的退出也就意味着进程的终止和销毁。主线程是由系统进程所建立的,同时用户也能够自主建立其它线程,这一系列的线程都会并发地运行于同一个进程中。

显然,在多线程操做下能够实现应用的并行处理,从而以更高的 CPU 利用率提升整个应用程序的性能和吞吐量。特别是如今不少语言都支持多核并行处理技术,然而 JavaScript 却以单线程执行,为何呢?git

其实这与它的用途有关。做为浏览器脚本语言,JavaScript 的主要用途是与用户互动,以及操做 DOM。若以多线程的方式操做这些 DOM,则可能出现操做的冲突。假设有两个线程同时操做一个 DOM 元素,线程 1 要求浏览器删除 DOM,而线程 2 却要求修改 DOM 样式,这时浏览器就没法决定采用哪一个线程的操做。固然,咱们能够为浏览器引入“锁”的机制来解决这些冲突,但这会大大提升复杂性,因此 JavaScript 从诞生开始就选择了单线程执行。github

另外,由于 JavaScript 是单线程的,在某一时刻内只能执行特定的一个任务,而且会阻塞其它任务执行。那么对于相似 I/O 等耗时的任务,就不必等待他们执行完后才继续后面的操做。在这些任务完成前,JavaScript 彻底能够往下执行其余操做,当这些耗时的任务完成后则以回调的方式执行相应处理。这些就是 JavaScript 与生俱来的特性:异步与回调。web

固然对于不可避免的耗时操做(如:繁重的运算,多重循环),HTML5 提出了Web Worker,它会在当前 JavaScript 的执行主线程中利用 Worker 类新开辟一个额外的线程来加载和运行特定的 JavaScript 文件,这个新的线程和 JavaScript 的主线程之间并不会互相影响和阻塞执行,并且在 Web Worker 中提供了这个新线程和 JavaScript 主线程之间数据交换的接口:postMessage 和 onMessage 事件。但在 HTML5 Web Worker 中是不能操做 DOM 的,任何须要操做 DOM 的任务都须要委托给 JavaScript 主线程来执行,因此虽然引入 HTML5 Web Worker,但仍然没有改线 JavaScript 单线程的本质。浏览器

并发模式与 Event Loop

JavaScript 有个基于“Event Loop”并发的模型。
啊,并发?不是说 JavaScript 是单线程吗? 没错,的确是单线程,可是并发与并行是有区别的。
前者是逻辑上的同时发生,然后者是物理上的同时发生。因此,单核处理器也能实现并发。安全

并发与并行

并发与并行 多线程

并行你们都好理解,而所谓“并发”是指两个或两个以上的事件在同一时间间隔中发生。如上图的第一个表,因为计算机系统只有一个 CPU,故 ABC 三个程序从“微观”上是交替使用 CPU,但交替时间很短,用户察觉不到,造成了“宏观”意义上的并发操做。

Runtime 概念

下面的内容解释一个理论上的模型。现代 JavaScript 引擎已着重实现和优化了如下所描述的几个概念。

Stack、Heap、Queue

Stack(栈)

这里放着 JavaScript 正在执行的任务。每一个任务被称为帧(stack of frames)。

function f(b) {
  var a = 12;
  return a + b + 35;
}

function g(x) {
  var m = 4;
  return f(m * x);
}

g(21);

上述代码调用 g 时,建立栈的第一帧,该帧包含了 g 的参数和局部变量。当 g 调用 f 时,第二帧就会被建立,而且置于第一帧之上,固然,该帧也包含了 f 的参数和局部变量。当 f 返回时,其对应的帧就会出栈。同理,当 g 返回时,栈就为空了(栈的特定就是后进先出 Last-in first-out (LIFO))。

Heap(堆)

一个用来表示内存中一大片非结构化区域的名字,对象都被分配在这。

Queue(队列)

一个 JavaScript runtime 包含了一个任务队列,该队列是由一系列待处理的任务组成。而每一个任务都有相对应的函数。当栈为空时,就会从任务队列中取出一个任务,并处理之。该处理会调用与该任务相关联的一系列函数(所以会建立一个初始栈帧)。当该任务处理完毕后,栈就会再次为空。(Queue的特色是先进先出 First-in First-out (FIFO))。

为了方便描述与理解,做出如下约定:

  • Stack 栈为主线程
  • Queue 队列为任务队列(等待调度到主线程执行)

OK,上述知识点帮助咱们理清了一个 JavaScript runtime 的相关概念,这有助于接下来的分析。

Event Loop

之因此被称为 Event loop,是由于它以如下相似方式实现:

while(queue.waitForMessage()) {
  queue.processNextMessage();
}

正如上述所说,“任务队列”是一个事件的队列,若是 I/O 设备完成任务或用户触发事件(该事件指定了回调函数),那么相关事件处理函数就会进入“任务队列”,当主线程空闲时,就会调度“任务队列”里第一个待处理任务(FIFO)。固然,对于定时器,当到达其指定时间时,才会把相应任务插到“任务队列”尾部。

“执行至完成”

每当某个任务执行完后,其它任务才会被执行。也就是说,当一个函数运行时,它不能被取代且会在其它代码运行前先完成。
固然,这也是 Event Loop 的一个缺点:当一个任务完成时间过长,那么应用就不能及时处理用户的交互(如点击事件),甚至致使该应用奔溃。一个比较好解决方案是:将任务完成时间缩短,或者尽量将一个任务分红多个任务执行。

毫不阻塞

JavaScript 与其它语言不一样,其 Event Loop 的一个特性是永不阻塞。I/O 操做一般是经过事件和回调函数处理。因此,当应用等待 indexedDB 或 XHR 异步请求返回时,其仍能处理其它操做(如用户输入)。

例外是存在的,如 alert 或者同步 XHR,但避免它们被认为是最佳实践。注意的是,例外的例外也是存在的(但一般是实现错误而非其它缘由)。

定时器

定时器的一些概念

上面也提到,在到达指定时间时,定时器就会将相应回调函数插入“任务队列”尾部。这就是“定时器(timer)”功能。

定时器 包括 setTimeout 与 setInterval 两个方法。它们的第二个参数是指定其回调函数推迟每隔多少毫秒数后执行。

对于第二个参数有如下须要注意的地方:

  • 当第二个参数缺省时,默认为 0;
  • 当指定的值小于 4 毫秒,则增长到 4ms(4ms 是 HTML5 标准指定的,对于 2010 年及以前的浏览器则是 10ms);

若是你理解上述知识,那么如下代码就应该对你没什么问题了:

console.log(1);
setTimeout(function() {
  console.log(2);
},10);
console.log(3);
// 输出:1 3 2

深刻了解定时器

零延迟 setTimeout(func, 0)

零延迟并非意味着回调函数马上执行。它取决于主线程当前是否空闲与“任务队列”里其前面正在等待的任务。

看看如下代码:

(function () {

  console.log('this is the start');

  setTimeout(function cb() {
    console.log('this is a msg from callback');
  });

  console.log('this is just a message');

  setTimeout(function cb1() {
    console.log('this is a msg from callback1');
  }, 0);

  console.log('this is the end');

})();

// 输出以下:
this is the start
this is just a message
this is the end
undefined // 当即调用函数的返回值
this is a msg from callback
this is a msg from callback1
setTimeout(func, 0) 的做用
  • 让浏览器渲染当前的元素更改(浏览器将 UI render 和 JavaScript 的执行是放在一个线程中,线程阻塞会致使界面没法更新渲染)
  • 从新评估“scriptis running too long”警告
  • 改变执行顺序

再看看如下代码:

<button id='do'> Do long calc!</button>
<div id='status'></div>
<div id='result'></div>


$('#do').on('click', function() {
  
  // 此处会触发 redraw 事件,但会放到队列里执行,直到 long() 执行完。
  $('#status').text('calculating....');

  // 没设定定时器,用户将没法看到 “calculating...”
  // 这是由于“calculation”的 redraw 事件会紧接在
  // “calculating...”的 redraw 事件后执行
  long(); // 执行长时间任务,形成阻塞

  // 设定了定时器,用户就如期看到“calculating...”
  // 大约 50ms 后,将耗时长的 long 回调函数插入“任务队列”末尾,
  // 根据先进先出原则,其将在 redraw 以后被调度到主线程执行
  //setTimeout(long,50);

});

function long() {
  var result = 0;
  for (var i = 0; i<1000; i++){
    for (var j = 0; j<1000; j++){
      for (var k = 0; k<1000; k++){
        result = result + i+j+k;
      }
    } 
  }
  // 在本案例中,该语句必须放到这里,这将使它与回调函数的行为相似
  $('#status').text('calculation done');
}
正版与翻版 setInterval 的区别

你们均可能知道经过 setTimeout 能够模仿 setInterval 的效果,下面咱们看看如下代码的区别:

// 利用 setTimeout 模仿 setInterval
setTimeout(function() {
  /* 执行一些操做. */
  setTimeout(arguments.callee, 1000);
}, 1000);

setInterval(function() {
  /* 执行一些操做 */
}, 1000);

可能你认为这没什么区别。的确,当回调函数里的操做耗时很短时,并不能看出它们有什么区别。
其实:上面案例中的 setTimeout 老是会在其回调函数执行后延迟 1000ms(或者更多,但不可能少)再次执行回调函数,从而实现 setInterval 的效果,而 setInterval 老是 1000ms 执行一次,而无论它的回调函数执行多久。

因此,若是 setInterval 的回调函数执行时间比你指定的间隔时间相等或者更长,那么其回调函数会连在一块儿执行。

你能够试试运行如下代码:

var counter = 0;
  var initTime = new Date().getTime();
  var timer = setInterval(function() {
    if(counter===2) {
      clearInterval(timer);
    }
    if(counter === 0) {
      for(var i = 0; i < 1990000000; i++) {
        ;
      }
    }

    console.log("第"+counter+"次:" + (new Date().getTime() - initTime) + " ms");

    counter++;
},1000);

我电脑 Chrome 浏览器的输入以下:

第0次:2007 ms
第1次:2013 ms
第2次:3008 ms

从上面的执行结果可看出,第一次和第二次执行间隔很短(不足 1000ms)。

浏览器

浏览器不是单线程的

上面说了这么多关于 JavaScript 是单线程的,下面说说其宿主环境——浏览器。
浏览器的内核是多线程的,它们在内核制控下相互配合以保持同步,一个浏览器至少实现三个常驻线程:

  1. JavaScript 引擎线程 JavaScript 引擎是基于事件驱动单线程执行的,JavaScript 引擎一直等待着任务队列中任务的到来,而后加以处理。
  2. GUI 渲染线程 GUI 渲染线程负责渲染浏览器界面,当界面须要重绘(Repaint)或因为某种操做引起回流(reflow)时,该线程就会执行。但须要注意 GUI 渲染线程与 JavaScript 引擎是互斥的,当 JavaScript 引擎执行时 GUI 线程会被挂起,GUI 更新会被保存在一个队列中等到 JavaScript 引擎空闲时当即被执行。
  3. 浏览器事件触发线程事件触发线程,当一个事件被触发时该线程会把事件添加到“任务队列”的队尾,等待 JavaScript 引擎的处理。这些事件可来自 JavaScript 引擎当前执行的代码块如 setTimeOut、也可来自浏览器内核的其余线程如鼠标点击、AJAX 异步请求等,但因为 JavaScript 是单线程执行的,全部这些事件都得排队等待 JavaScript 引擎处理。

在 Chrome 浏览器中,为了防止因一个标签页奔溃而影响整个浏览器,其每一个标签页都是一个进程(Renderer Process)。固然,对于同一域名下的标签页是可以相互通信的,具体可看 浏览器跨标签通信。在 Chrome 设计中存在不少的进程,并利用进程间通信来完成它们之间的同步,所以这也是 Chrome 快速的法宝之一。对于 Ajax 的请求也须要特殊线程来执行,当须要发送一个 Ajax 请求时,浏览器会开辟一个新的线程来执行 HTTP 的请求,它并不会阻塞 JavaScript 线程的执行,当 HTTP 请求状态变动时,相应事件会被做为回调放入到“任务队列”中等待被执行。

看看如下代码:

document.onclick = function() {
  console.log("click");
}

for(var i = 0; i< 100000000; i++);

解释一下代码:首先向 document 注册了一个 click 事件,而后就执行了一段耗时的 for 循环,在这段 for 循环结束前,你能够尝试点击页面。当耗时操做结束后,console 控制台就会输出以前点击事件的“click”语句。这证实了点击事件(也包括其它各类事件)是由额外单独的线程触发的,事件触发后就会将回调函数放进了“任务队列”的末尾,等待着 JavaScript 主线程的执行。

总结

  • JavaScript 是单线程的,同一时刻只能执行特定的任务,而浏览器是多线程的。
  • 异步任务(各类浏览器事件、定时器等)都是先添加到“任务队列”(定时器则到达其指定参数时)。当 Stack 栈(JavaScript 主线程)为空时,就会读取 Queue 队列(任务队列)的第一个任务(队首),而后执行。

JavaScript 为了不复杂性,而实现单线程执行。而现在 JavaScript 却变得愈来愈不简单了,固然这也是 JavaScript 迷人的地方。

后续更新(回复网友的问题)

  1. 关于"setTimeout(func, 0)的做用"一节中,redraw事件发生后,事件处理函数被插入任务队列,等待当前栈中long函数执行完毕再执行。此时经过setTimeout(long,0)便可将long函数插到任务队列中redraw事件处理函数的后面。事实上Chrome中也确实是这么处理的(个人版本号是55.0.2883.87 m),但是最新的火狐和Edge都至少要将延时设置为15ms以上,请问这是为何?

答:恩,这的确取决于浏览器的内部实现。

昨晚,我看了Chrome(chromium)的定时器源码实现:

一些变量的定义:

static const int maxIntervalForUserGestureForwarding = 1000; // One second matches Gecko.
static const int maxTimerNestingLevel = 5;
static const double oneMillisecond = 0.001;
// Chromium uses a minimum timer interval of 4ms. We'd like to go
// lower; however, there are poorly coded websites out there which do
// create CPU-spinning loops.  Using 4ms prevents the CPU from
// spinning too busily and provides a balance between CPU spinning and
// the smallest possible interval timer.
static const double minimumInterval = 0.004;

定时器的部分实现:

DOMTimer::DOMTimer(ExecutionContext* context, ScheduledAction* action, int interval, bool singleShot, int timeoutID)
    : SuspendableTimer(context)
    , m_timeoutID(timeoutID)
    , m_nestingLevel(context->timers()->timerNestingLevel() + 1)
    , m_action(action)
{
    ASSERT(timeoutID > 0);
    if (shouldForwardUserGesture(interval, m_nestingLevel))
        m_userGestureToken = UserGestureIndicator::currentToken();

    InspectorInstrumentation::asyncTaskScheduled(context, singleShot ? "setTimeout" : "setInterval", this, !singleShot);

    double intervalMilliseconds = std::max(oneMillisecond, interval * oneMillisecond);
    if (intervalMilliseconds < minimumInterval && m_nestingLevel >= maxTimerNestingLevel)
        intervalMilliseconds = minimumInterval;
    if (singleShot)
        startOneShot(intervalMilliseconds, BLINK_FROM_HERE);
    else
        startRepeating(intervalMilliseconds, BLINK_FROM_HERE);
}

从上述代码可看出:Chrome 实现的定时器的最小时间间隔是 1ms。只有知足 intervalMilliseconds < minimumInterval && m_nestingLevel >= maxTimerNestingLevel 该条件时,定时器的最小时间间隔才是 4ms

所以,各浏览器是往响应更快的方向发展的。


对于你提问的“在Edge和火狐上,redraw事件和setTimeout执行顺序问题”,也一样取决于浏览器的内部实现。

我在我电脑的Edge和火狐浏览器上进行测试,当时间间隔较小时(如 0~10ms),redraw和setTimeout的执行顺序是不固定的。

所以,这须要你通过足够多的测试,获得一个相对安全的时间值,以确保执行顺序的正确性。

参考资料:

  1. JavaScript 运行机制详解:再谈Event Loop
  2. JavaScript单线程和浏览器事件循环简述
  3. Javascript是单线程的深刻分析
  4. Concurrency model and Event Loop
  5. 也谈setTimeout
  6. 单线程的Javascript

若这篇文章让您获益,欢迎您在 Github 给个 Star


本文连接:http://www.codeceo.com/articl...本文做者:码农网 – 刘健超[ 原创做品,转载必须在正文中标注并保留原文连接和做者等信息。]

相关文章
相关标签/搜索