16 Mar 2015

JavaScript Event Loop 解析

本文将介绍

随着JavaScript作为浏览器的脚本语言愈发普及, 对于其事件驱动交互模型有一个基本的理解, 以及它与其他语言(例如: Ruby, Python, 和 Java)在请求-响应模型上的区别是令我们受益良多的. 在这篇文章里, 我会解释一些 JavaScript 并发模型的核心概念, 包括: event loop, 消息队列, 希望可以借此提升大家对于这门语言(你或许已经开始用它来编码, 但却没有全面的理解它)的理解.


本文目标人群

本文定位为前端开发人员, 并且是正在(或将要)在客户端或服务端使用 JavaScript 的开发人员. 如果你已经对于 event loop 很精通了, 那么这篇文章的大多数内容你都可能会很熟悉. 对于那些不是很精通的人, 我希望可以提供给你们一个基本的理解, 以便于你们能更好的分析日常接触的代码.


非阻塞式 I/O

JavaScript内, 几乎所有的I/O操作都是非阻塞的. 这包含了: HTTP 请求, 数据库操作以及硬盘读写; 单线程执行请求运行时环境执行某个操作, 提供一个回调函数, 然后继续去做其他的事情. 当操作完成时, 一个消息通过之前提供的回调函数进入队列. 在未来的某个时间点, 这个消息会被移出队列并且回调函数也被执行.

对于已经习惯于使用 user inerfaces (像 “mousedown” 和 “click” 这样的事件可以在任何时间被触发) 的开发人员来说, 交互模型或许已经是很熟悉的了, 它不同于同步的方式, 同步的请求-响应模型常见于服务端应用中.

让我们比较2段代码, 代码向 www.google.com 发送 HTTP 请求并将响应输出到控制台. 首先, Ruby, 使用了Faraday库:

response = Faraday.get 'http://www.google.com'
puts response
puts 'Done!'

执行流程简要归纳为如下步骤:

  1. 执行 get 方法, 执行线程等待响应
  2. 接收来自 Google 的响应, 返回给调用者并存储在一个变量内
  3. 变量的值(当前情况下, 就是响应)输出到控制台
  4. “Done!” 输出到控制台

接下来, 我们利用Node.js与Request库进行相同操作:

request('http://www.google.com', function(error, response, body) {
  console.log(body);
});

console.log('Done!');

看起来只有细微的不同, 但是有着完全不同的行为:

  1. 执行request函数, 将一个匿名函数作为回调函数传递给request, 当将来响应可用时, 执行该回调方法.
  2. “Done!” 立即输出到控制台
  3. 将来某时, 响应返回后, 执行回调函数, 输出响应体到控制台

Event Loop

将调用者从响应中解耦出来, 可以令 JavaScript 运行时环境在等待异步处理完成时去做一些其他的工作. 但是在内存中这些回调函数是存活的 - 它们的执行顺序是什么? 什么触发它们的调用?

JavaScript 运行时环境包含了一个消息队列, 其内部存储了一个待处理消息的列表, 这些消息与回调函数相关联. 这些排队的消息用于响应外部事件(比如: 鼠标点击事件, 接收到一个HTTP请求的响应), 且该外部事件必须提供了一个回调函数. 举个例子: 用户要点击一个按钮却没有提供回调函数 - 没有消息会入队列.

在一个循环内, 轮询这个队列, 并且执行轮询到的消息对应的回调函数.

回调函数的调用在调用栈内起到了初始化帧的作用, 由于 JavaScript 是单线程, 更深层的轮询和处理会被停止, 等待栈上所有调用返回. 随后的(同步)函数调用会添加新的帧到栈上(例如: 函数 init 用 changeColor).

function init() {
  var link = document.getElementById("foo");

  link.addEventListener("click", function changeColor() {
    this.style.color = "burlywood";
  });
}

init();

在这个例子里, 当用户点击 “foo” 元素时候, 一个消息(还有回调, changeColor)被加入队列并且事件 “onclick” 被触发. 当消息出队列时, 它的回调函数 changeColor 会被调用. 当 changeColor 返回(或者抛出错误)时, even loop 才会继续进行下去. 只要函数 changeColor 存在, 被指定为 “foo” 元素的 onclick 的回调函数, 接下来在这个元素上的点击就会引起更多的消息(并且与回调函数 changeColor 关联起来)被加入队列.


附加信息的排队

如果代码中被调用的函数是异步的(像 setTimeout), 被提供的回调函数会作为一个不同的队列消息的一部分被最后调用, 新消息位于某个未来 event loop 的标记上. 例如:

function f() {
  console.log("foo");
  setTimeout(g, 0);
  console.log("baz");
  h();
}

function g() {
  console.log("bar");
}

function h() {
  console.log("blix");
}

f();

由于 setTimeout 的非阻塞的特性, 它的回调函数的触发至少在0毫秒之后并且不会作为当前消息的一部分被处理. 在这个例子中, setTimeout 被调用了, 回调函数为 g, 时间间隔为0毫秒. 当指指定的事件到了(在这个例子, 几乎是立即), 一个单独的消息会被加入到队列中并且消息中包含 g 作为它的回调函数. 结果控制台会是这样: “foo”, “baz”, “blix”, 接下来, 在下一个 event loop 的标记上: “bar”. 如果在同一个调用帧内, 有两个setTimeout的调用 - 第二个参数传递相同的值 - 它们的回调函数会按调用顺序入队列.


Web Workers

使用 Web Workers 可以将一个昂贵的操作卸载到一个单独的执行线程上, 释放主线程去处理一些其他的事情. worker 包含一个单独的消息队列, event loop 和与初始化它本身实例的原始线程独立的内存空间. worker 与主线程间的通信通过消息传递完成, 这与传统的事件代码(我们已经给出过的例子)很是相似.

首先, worker:

// our worker, which does some CPU-intensive operation
var reportResult = function(e) {
  pi = SomeLib.computePiToSpecifiedDecimals(e.data);
  postMessage(pi);
};

onmessage = reportResult;

然后,HTML 中 script 标签内的的主代码块:

// our main code, in a <script>-tag in our HTML page
var piWorker = new Worker("pi_calculator.js");
var logResult = function(e) {
  console.log("PI: " + e.data);
};

piWorker.addEventListener("message", logResult, false);
piWorker.postMessage(100000);

在这个例子中, 主线程产生一个 worker 并注册它的”消息”事件的回调函数 logResult. 在 worker 内, reportResult 函数被注册到它自身的”消息”上. 当 worker 线程接收到来自主线程的消息时, worker 将一个消息存入队列, 并对应到回调函数 reportResult 上. 当出队列时, 一个消息被传回到主线程上, 主线程上会有一个新消息被存入队列(随着回调函数 logResult). 通过这种方式, 开发人员可以将一些CPU密集型操作委托给一个单独的线程, 从而释放主线程去继续处理消息和事件.


关于闭包的注解

JavaScript 对闭包的支持允许你可以注册这样的回调函数, 当它被执行时, 保持了到创建这个函数的上下文环境的访问权限, 尽管回调函数的执行过程创建了一个全新的调用栈. 特别的是, 回调函数作为另一个消息队列(而不是创建它们的消息队列)的一部分被调用. 参考这个例子:

function changeHeaderDeferred() {
  var header = document.getElementById("header");

  setTimeout(function changeHeader() {
    header.style.color = "red";

    return false;
  }, 100);

  return false;
}

changeHeaderDeferred();

在这个例子里, changeHeaderDeferred 函数被执行, 包含变量 header. 接下来 setTimeout 被调用, 会引发一个消息在大约100毫秒后被添加到消息队列里, 最后 changeHeaderDeferred 函数返回 false, 第一条消息的处理结束 - 但是 header 变量仍然通过一个闭包被引用, 因此不会被垃圾收集. 当第二条消息被处理时(changeHeader 哈数), 它保留了到外部函数作用域声明的 header 变量的访问权限. 只要第二条消息(changeHeader 函数)被处理完, header变量就可以被垃圾收了.


Takeaways

JavaScript 的事件驱动交互模型不同于很多程序员习以为常的请求-响应模型 - 但是正如你所见, 它并不是什么复杂的事情. 利用一个简单的消息队列和 event loop, JavaScript使得一个开发人员通过一系列异步触发的回调函数去构建他们的系统, 当等待外部事件发生时, 可以释放运行时环境去处理并发操作. 然后, 这仅仅是其中一种处理并发的方式. 在本文的第二部分, 我将会对JavaScript与MRI Ruby, EventMachine (Ruby), and Java (多线程)的并发模型进行比较.


补充阅读


注:本文为译文,原文出处The JavaScript Event Loop: Explained


Tags:
0 comments