本文是我翻译《JavaScript Concurrency》书籍的第二章,该书主要以Promises、Generator、Web workers等技术来说解JavaScript并发编程方面的实践。
完整书籍翻译地址: https://github.com/yzsunlei/javascript_concurrency_translation 。因为能力有限,确定存在翻译不清楚甚至翻译错误的地方,欢迎朋友们提issue指出,感谢。
本书第一章咱们探讨了JavaScript并发的一些状况。通常来讲,在JavaScript应用程序中处理并发只是一件小事。有不少想编写并发JavaScript代码的,提出的一些解决办法并非很是规范的。有不少回调,而且用到的全部这些回调就足够让人发疯了。咱们还看了下咱们编写的并发JavaScript代码如何改变现有的组件。Web workers已经开始成熟,javascript语言的并发结构才刚刚引入。javascript
JavaScript语言和运行时环境已是定下来的。咱们须要在设计层面考虑并发,而不是在编写代码时。并发应该是默认的。这提及来很容易,但很难作到。在本书中,咱们将探讨JavaScript并发所提供的全部特性,以及咱们如何利用好它们做为设计工具的优点。可是,咱们在这样作以前,须要深刻了解JavaScript到底是怎样运行的。这些是设计并发应用程序的必要知识,由于咱们须要确切地知道在选择一种并发机制时会发生什么。前端
在本章中,咱们将从浏览器环境开始,看看代码运行所涉及的全部子系统 - 例如JavaScript解释器,任务队列和DOM自己。而后咱们将介绍一些代码示例,这些代码将揭示运行咱们的代码时真正发生的事情。最后咱们将经过讨论在这个模型中面临的挑战来结束本章。java
当咱们访问网页时,会在浏览器中为咱们建立整个环境。这个环境有几个子系统,咱们浏览的网页外观和行为都应该遵循万维网联盟(W3C)规范。任务是Web浏览器中的一个基本抽象。任何发生的事情要么是一个任务自己,要么是较大任务的一部分。node
若是您正在阅读任何W3C规范,他们使用术语“用户代理”而不是“Web浏览器”。在99.9%的状况下,咱们正在阅读的内容
符合主流的浏览器提供商。
在本节中,咱们将介绍这些环境的主要组件,以及任务队列和事件循环如何在这些组件之间进行通讯,以实现网页的总体外观和交互行为。git
这里先介绍一些术语,它们将在本章的各个部分进行讲解:程序员
• 执行环境:每当打开新网页时,都会建立一个容器。这是一个丰富复杂的环境,它拥有咱们的JavaScript代码将与之交互的一切。它也能够做为沙箱 - 咱们的JavaScript代码没法访问环境以外的东西。github
• JavaScript解释器:这是负责解析和执行JavaScript源代码的组件。浏览器的工做是使用全局变量来扩充解释器,例如window和XMLHttpRequest。编程
• 任务队列:只要发生一些事情,就会有任务排队。一个执行环境至少有一个这样的队列,但一般它有几个队列。json
• 事件循环:执行环境具备单一的事件循环,负责为全部任务队列提供服务。只有一个事件循环,由于只有一个线程。segmentfault
Web浏览器中建立的执行环境以下示图。任务队列是浏览器中发生的任何事情的入口。例如,一个任务能够用于经过将脚本传递给JavaScript解释器来执行脚本,而另外一个任务用于渲染DOM更新。如今咱们将深刻探究这个环境的组成部分。
也许Web浏览器执行环境中最具启发性的方面是咱们的JavaScript代码相对于执行它的解释器所占的部分要小。咱们的代码能够看做只是大机器中的一个齿轮。在这些环境中确定会发生不少事情,由于浏览器实现的平台有大量的用途。这不只仅包括在屏幕上渲染元素,或是使用样式属性加强这些元素。DOM自己相似于微平台,就像网络设施,文件访问,安全性等同样。全部这些部分对于网站运行的网络环境以及相关的应用程序都相当重要。
在并发环境中,咱们最感兴趣的是将全部这些组件组合在一块儿运行的机制。咱们的应用程序主要用JavaScript编写,解释器知道如何解析和运行它。可是,这最终如何转化为页面上的视觉变化?浏览器的网络组件如何知道发出HTTP请求,以及响应完成后如何调用JavaScript解释器?
这些不一样组件之间的协调限制了咱们在JavaScript中的使用并发。这些限制是必要的,由于没有它们,开发Web应用程序将变得至关复杂。
一旦执行环境准备好了,事件循环就是首先要启动运行的组件之一。它的工做是为环境中的一个或多个任务队列提供服务。浏览器提供商能够根据须要自由实现队列,但必须至少有一个队列。若是他们愿意的话,浏览器能够将每一个任务放在一个队列中,以同等的优先级执行每项任务。这样作的问题意味着若是队列被堆积,那么有些必须优先执行的任务(例如鼠标或键盘事件)就会出现问题了。
在实践中,有几个队列是有意义的,若是没有其余缘由,除了按优先级分隔任务。这一点很重要,由于只有一个控制线程 - 意味着只有一个CPU - 来处理这些队列。如下是经过不一样级别的优先级为几个队列提供服务的事件循环:
即便事件循环与执行环境一块儿启动,这并不意味着它老是要处理它的任务。若是老是要处理任务,那么实际应用程序永远不会有任何CPU空闲时间。事件循环将等待更多任务,优先级高的队列首先获得服务。例如,使用前面这张图中应用的队列,将始终首先为交互队列提供服务。即便事件循环正在处理渲染队列任务,若是交互式任务排队,事件循环将在处理渲染任务以前恢复处理此任务。
任务队列的概念对于理解Web浏览器的工做方式相当重要。浏览器这个术语其实是有误导性的。咱们在早期的一些网站中使用它们浏览静态网页。如今,大型复杂的应用程序在浏览器中运行 - 它实际上更像是一个Web平台。为它们提供服务的任务队列和事件循环多是处理这么多组件的最佳设计。
咱们在本章前面看到,从执行环境的角度来看,JavaScript解释器以及它解析和运行的代码实际上只是一个黑盒子。事实上,调用解释器自己就是一项任务,并且反映了JavaScript的运行直到完成的特性。许多任务涉及JavaScript解释器的调用,以下所示:
这些事件中的任何一个 - 用户单击元素,页面中加载的脚本或来自先前API调用的数据返回浏览器 - 建立调用JavaScript解释器的任务。它告诉解释器运行一段特定的代码,而且它将继续运行直到完成。这是JavaScript的运行直到完成的特性。接下来,咱们将深刻探究这些任务建立的执行上下文。
如今是时候看看JavaScript解释器自己 - 这是当事件发生而且代码须要运行时从其余浏览器组件接管的组件。在解释器中,咱们会找到一堆上下文,但总有一个活跃的JavaScript上下文。这与堆栈控制活动上下文的许多编程语言相似。
将活动上下文视为咱们JavaScript代码中正在发生的事件的快照。使用堆栈结构是由于活动上下文能够被随时更改成其余内容,例如调用函数时。发生这种状况时,会将新快照推送到堆栈,成为活动上下文。当它运行完成时,它会从堆栈中弹出,将下一个上下文保留为活动上下文。
在本节中,咱们将了解JavaScript解释器如何处理上下文切换,以及管理上下文堆栈的内部任务队列。
JavaScript解释器中的上下文堆栈不是静态结构 - 它在不断变化。在这个堆栈的整个生命周期中发生了两件重要的事情。首先,在堆栈的顶部,咱们有活动的上下文。这是解释器在其指令中移动时当前执行的代码。这里有张示图说明JavaScript执行上下文堆栈的概念,活动上下文始终位于顶部:
调用堆栈的另外一个重要事情是当活动上下文停用时为其记录状态。例如,假设在几条语句以后,func1()调用func2()。此时,在调用func2()以后,直接将上下文添加到该位置。而后,它被替换为新的活动上下文 - func2()。完成后,重复该过程,func1()再次成为活动上下文。
这种上下文切换发生在咱们的整个代码执行过程当中。例如,有一个全局上下文,它是咱们代码执行的入口,函数自己具备本身的上下文。最近JavaScript还有一些新增的语言特性,它们也有本身的上下文,如模块和生成器。接下来,咱们将看看负责建立新执行上下文的任务队列。
工做队列相似于咱们以前查看的任务队列。不一样之处在于工做队列特定于JavaScript解释器。也就是说,它们被封装在解释器中 - 浏览器不直接与这些队列交互。可是,当浏览器调用解释器时,例如,响应于加载的脚本或事件回调任务时,解释器将建立新的工做。
JavaScript解释器中的工做队列实际上比用于协调全部Web浏览器组件的任务队列简单得多。只有两个必要的队列。一个用于建立新的执行上下文堆栈(调用堆栈)。另外一个特定于promise解析回调函数。
咱们将在下一章深刻探讨promise解析回调的工做原理。
鉴于这些内部JavaScript工做队列的职责限制,有些人可能得出结论:它们是没必要要的 - 过分设计的行为。事实并不是如此,由于虽然今天在这些工做中发现的它们职责有限,可是工做队列设计让语言更容易地扩展和改进。特别是,在考虑将来语言版本中的新并发结构时,工做队列机制是颇有意义的。
到目前为止,在本章中,咱们已经了解了Web浏览器环境的全部内部组件,以及JavaScript解释器在此环境中的位置。全部这些与将并发原则应用于咱们的代码有什么关系?经过了解底层发生的事情,咱们能够更深刻地弄明白运行代码块时发生的状况。特别是,咱们知道相对于其余代码块发生了什么; 时间排序是一个相当重要的并发属性。
这就是说,让咱们实际写一些代码。在本节中,咱们将使用定时器将任务显式添加到任务队列。咱们还将了解JavaScript解释器什么时候何地跳转并开始执行咱们的代码。
所述的setTimeout()函数是在任何JavaScript代码定住。它用于在未来的某个时刻执行代码。JavaScript新手常常被setTimeout()函数弄迷糊,由于它是一个定时器。设定在将来的某个时间点,好比说3秒后,将调用回调函数。当咱们调用setTimeout()时,咱们将得到一个timer ID值,稍后可使用clearTimeout()清除它。如下是setTimeout()的基本用法:
//建立一个能够调用咱们函数的定时器好比300ms。 //咱们可使用console.time()和console.timeEnd()函数看到它实际须要多长时间。 // //这一般是301ms左右,根本不是用户能够注意到的, //但调度函数调用获得的准确性并不可靠。 var timer = setTimeout(() => { console.timeEnd('setTimeout'); }, 300); console.time('setTimeout');
这是JavaScript新手经常误解的部分;这个定时器只能尽可能保证时间准确性。咱们使用setTimeout()时惟一的保证是咱们的回调函数永远不会比咱们传递它的时间更早的被调用。所以,若是咱们说在300毫秒内调用此函数,它将永远不会在275毫秒内调用它。一旦300毫秒过去,新任务就会排队。若是在此任务以前没有任何排队等待,则回调会按时运行。即便有一些事情在它前面的队列,其实也不容易被察觉 - 它彷佛在正确的时间运行。
但正如咱们所见,JavaScript是单线程运行的。这意味着一旦JavaScript解释器启动,它就不会中止直到它完成; 即便有任务等待定时器事件回调。所以,即便咱们要求定时器在300毫秒时执行回调,它彻底有可能会在500毫秒时执行。咱们来看一个例子来看看为何它是可能的:
//注意,这个函数会消耗CPU ... function expensive(n = 25000) { var i = 0; while(++ i <n * n) {} return i; } //建立一个定时器,回调使用console.timeEnd()看看咱们等了多久。 //是不是真的等了咱们期待的300ms。 var timer = setTimeout(() => { console.timeEnd('setTimeout'); }, 300); console.time('setTimeout'); //这须要几秒钟的时间在CPU上完成。 //同时任务已排队等待运行咱们的回调函数, //但事件循环没法得到到那个任务队列,直到expensive()完成。 expensive();
setInterval()函数是setTimeout()函数的姐妹。正如其名,它接受一个回调函数,以固定的时间间隔进行调用执行。事实上,setInterval()函数采用了和setTimeout()彻底相同的参数。惟一的区别是在于它会不断的调用执行回调函数的功能,每隔X毫秒,直到该计时器被使用clearInterval()函数清除。
当咱们想要一遍又一遍地调用相同的函数时,这个函数很实用。例如,若是咱们轮询API接口,则setInterval()是一个很好的候选解决方案。可是,请记住,回调函数的调用是固定的。也就是说,一旦咱们用1000毫秒调用setInterval(),没有清除定时器就没有改变1000毫秒。对于间隔须要是动态的场景,使用setTimeout()能够更好的实现。回调函数中设定下一个间隔,容许间隔是动态的。例如,经过增长间隔时间来不断地轮询API。
在咱们上次查看的setTimeout()示例中,咱们看到了运行JavaScript代码如何破环事件循环。也就是说,它阻止事件循环使用咱们的回调函数来调用JavaScript解释器的任务。这容许咱们将代码执行推迟到未来的某个点,但没有准确的保证。让咱们看看当咱们使用setInterval()计划任务时会发生什么。还有一些后续运行的JavaScript代码块:
//一个跟踪咱们正在进行第几回执行的计数器。 var cnt = 0; //设置interval定时器。回调会记录调度回调函数的次数。 var timer = setInterval(() => { console.log('Interval', ++cnt); }, 3000); //阻塞CPU一段时间。当咱们再也不阻塞CPU时,调用第一个interval, //如预期的那样。而后第二个,若是预料到的话。依次类推 //所以,当咱们阻止回调任务时,咱们就是阻止执行下一个间隔的任务。 expensive(50000);
在上一节中,咱们了解了如何延时运行JavaScript代码。这是由其余JavaScript代码明确完成的。大多数状况下,咱们的代码会响应用户交互而直接运行。在本节中,咱们将介绍一些公共接口,不只由DOM事件使用的,还包括网络事件和Web worker事件等。咱们还将研究一种处理大量相似事件的技术 - 称为去抖。
事件对象接口被许多浏览器组件所使用,包括DOM元素。这是咱们如何分发事件到元素以及监听到的事件和执行一个回调函数做为响应。它其实是一个很是简单的交互,很容易被捕捉到。这是相当重要的,由于许多不一样类型的组件使用相同的接口进行事件管理。咱们将会经过这本书进一步看到。
上一节中使用的定时器的回调函数与执行EventTarget事件是相同的任务队列机制。若是事件被触发,一个使用对应的回调函数调用JavaScript解释器的任务将被加入任务队列。在这里使用setTimeout()所面临的限制一样会出现。下面是当长时间运行的JavaScript代码阻塞用户事件时的任务队列的示图:
除了将侦听器函数附加到对用户交互作出反应的事件目标上以外,咱们还能够手动触发这些事件,以下代码所示:
//通用事件回调,记录事件时间戳。 function onClick(e) { console.log('click', new Date(e.timeStamp)); } //咱们将要用做事件的元素目标对象。 var button = document.querySelector('button'); //设置咱们的 onClick 函数做为此目标上 click 事件的事件侦听器。 button.addEventListener('click', onClick); //除了用户点击按钮外,还有EventTarget接口让咱们手动调度事件 button.dispatchEvent(new Event('click'));
最好是尽量命名一下回调中使用的函数。这样,当咱们的代码出错时,跟踪查找问题就容易得多。使用匿名函数并非不能够,它只是在追踪问题时会耗费更多的时间。另外一方面,箭头函数更简洁,而且具备更大的绑定灵活性。选择使用它是明智的。
用户交互事件的一个挑战是在很短的时间内可能有不少这样的事件。例如,当用户在屏幕上移动鼠标时,会触发数百个事件。若是咱们有监听这些事件,任务队列将很快被填满,用户体验也就将会很糟糕。
即便咱们确实建立有高频事件(例如鼠标移动)的事件监听器,咱们也不必响应全部这些事件。例如,若是在1-2秒内发生了150次鼠标移动事件,咱们只关心最后一次移动 - 鼠标指针的最近位置。也就是说,使用咱们的事件回调代码调用JavaScript解释器的次数比须要的多149倍。
为了处理这种高频事件场景,咱们可使用一种称为去抖的技术。去抖函数意味着若是在给定时间范围内连续屡次调用它,则实际仅使用最后一个调用,并忽略全部先前的调用。让咱们来看看下面例子是如何实现的:
//跟踪“mousemove”事件的数量。 var events = 0; //debounce()将提供的 func 来限制调用它的频率。 function debounce(func, limit) { var timer; return function debounced(...args) { //移除全部现有的计时器 clearTimeout(timer); //在“limit”毫秒后调用函数 timer = setTimeout(() => { timer = null; func.apply(this, args); }, limit); }; } //记录有关鼠标事件的一些信息, 并记录事件总数。 function onMouseMove(e) { console.log(`X ${e.clientX} Y ${e.clientY}`); console.log('events', ++events); } //将输入的内容记录到文本输入中 function onInput(e) { console.log('input', e.target.value); } //使用debounced监听 mousemove 事件 //onMouseMove函数的版本。要是咱们 //没有使用debounce()包装此回调。 window.addEventListener('mousemove', debounce(onMouseMove, 300)); //使用去抖动版本监听 input 事件 //onInput()函数,以防止每次按键触发事件。 document.querySelector('input').addEventListener('input', debounce(onInput, 250));
使用去抖技术来避免给CPU带来不少不必的工做量。经过忽略149个事件,咱们保存了正确的值,不然大量执行CPU指令而且获得的不是正确值。咱们还节省了在这些事件处理程序中可能发生的各类类型的内存分配。
JavaScript的并发原则在“第一章,JavaScript并发简介?”结尾时已经讲过了,本书后面部分将经过代码示例来讲明它。
前端应用程序的另外一个重要部分是网络交互,获取数据,发出命令等。因为网络通讯本质上是异步进行的,所以咱们必须依赖事件 - EventTarget接口来确保准确性。
咱们首先看一下通用机制,它将咱们的回调函数与请求挂起并从后端获取响应数据。而后,咱们将看看如未尝试同步多个网络请求建立一个看似不太可能的并发场景。
为了与网络进行交互,咱们建立了一个XMLHttpRequest的实例。而后咱们告诉它咱们要作的请求类型 - GET或POST和请求接口。这些请求对象还实现了EventTarget接口,以便咱们能够监遵从网络返回的数据。如下是此代码的示例:
//回调成功的网络请求,解析JSON数据 function onLoad(e) { console.log('load', JSON.parse(this.responseText)); } //回调失败的网络请求,记录错误信息 function onError() { console.error('network', this.statusText || '未知错误'); } //回调已取消的网络请求,记录警告信息 function onAbort() { console.warn('request aborted ...'); } var request = new XMLHttpRequest(); //针对每种状况,使用 EventTarget 绑定不一样的事件监听 request.addEventListener('load', onLoad); request.addEventListener('error', onError); request.addEventListener('abort', onAbort); //发送 api.json接口 的 GET 请求。 request.open('get', 'api.json'); request.send();
咱们能够在这里看到网络请求有许多可能的状态。成功状态是服务器响应咱们须要的数据,而且咱们可以将其解析为JSON。错误状态是出现问题时,可能服务器没法访问。咱们在这里关注的最后的一个状态是请求被取消或停止。这意味着咱们再也不关心成功状态,由于咱们的应用程序中的某些内容在请求执行时发生了变化。例如,用户跳转到其它地方。
虽然以前的代码很容易使用和理解,但状况并不是老是如此。咱们如今看到的只是单个请求和一些回调。而咱们的应用程序不多由单个网络请求组成的。
在上一节中,咱们看到了与XMLHttpRequest实例的基本交互与发出网络请求的例子。当有多个请求时,挑战就来了。大多数状况下,咱们会发出多个网络请求,以便咱们获得渲染UI组件所需的全部数据。而来自后端的响应将在不一样时间返回,而且还可能彼此依赖。
无论怎样,咱们须要将这些异步网络请求的响应同步化。让咱们看看如何使用EventTaget回调函数来完成这项工做:
//获得响应时调用的函数,它还负责协调响应 function onLoad() { //当响应准备就绪时,咱们将解析的响应添加到 responses 数组 //以便后面的请求返回时咱们可使用其余的响应数据 responses.push(JSON.parse(this.responseText)); //是否出现了全部期待的响应? if(responses.length === 3) { //咱们如何按顺序作任何咱们须要的事情, //由于咱们须要全部数据来渲染UI组件 for(let response of responses) { console.log('hello', response.hello); } } } //建立咱们的API请求实例和 responses数组用于保存不一样步的响应结果 var req1 = new XMLHttpRequest(), req2 = new XMLHttpRequest(), req3 = new XMLHttpRequest(), responses = []; //发出咱们全部须要的网络请求 for(let req of [req1, req2, req3]) { req.addEventListener('load', onLoad); req.open('get', 'api.json'); req.send(); }
当有多个请求时,须要考虑不少额外的问题。因为它们都在不一样的时间返回,咱们须要将解析后的响应存储在一个数组中,随着每一个响应的返回,咱们须要检查是否有咱们指望的一切。这个简化的示例甚至没有考虑失败或取消的请求。正如此代码所表示的那样,同步的回调函数方法是有限的。在接下来的章节中,咱们将学习如何克服这一局限。
咱们在本章中讨论这个执行模型对JavaScript并发带来的挑战。有两个基本问题。第一个问题是任何运行的JavaScript代码都会阻止其余任何事情的发生。第二个问题是尝试使用回调函数完成异步操做,会致使回调地狱。
过去,JavaScript中缺少并行性并非真正的问题。没有人注意它,由于JavaScript仅被视为HTML页面的渐进加强工具。当前端开始承担更多责任时,这种状况发生了变化。目前,大多数应用程序逻辑处理实际上都放在前端。这容许后端组件专一于JavaScript没法解决的问题(从浏览器的角度来看,NodeJS彻底是咱们将在本书后面讨论的另外一个问题)。
例如,后端能够实现将API数据映射和转换为某种特殊的形式。这意味着前端JavaScript代码只须要查询此接口。问题是这个API接口是为某些特定的UI功能而建立的,而不是咱们数据模型的必须实现的。若是咱们能够在前端执行这些任务,咱们会将UI功能和所需的数据转换紧密结合在一块儿。这样能够减轻后端工做量,专一于复制和负载平衡等更重要的事情上。
咱们能够在前端执行这些类型的数据转换,但它们会严重破坏接口的可用性。这主要是因为全部模块须要相同的计算资源。换句话说,这个模型使咱们没法实现并发原则并利用多个资源。咱们将在Web workers的帮助下克服这个Web浏览器限制,这将在后面的章节中介绍。
经过回调进行同步很难实现,而且不能很好地扩展。回调地狱,这是在JavaScript编程中一个流行的术语。毋庸置疑,经过代码中的回调进行无休止的同步会产生问题。咱们常常须要建立某种状态跟踪机制,例如全局变量。当出现问题时,回调函数的嵌套在总体上遍历是很是耗时的。
通常来讲,同步多个异步操做的回调方法须要大量开销。也就是说,用于处理异步操做的代码存在不少重复的。同步并发原则是编写并发代码,而不是将主要目标嵌入同步处理逻辑的迷宫中。Promise经过减小回调函数的使用,帮助咱们在整个应用程序中一致地编写并发代码。
本章的重点是Web浏览器平台以及JavaScript在其中的地位。每当咱们浏览网页并与网页交互时,都会触发不少事件。这些做为任务处理,从队列中获取。其中一个任务是调用带有运行代码的JavaScript解释器。
当JavaScript解释器运行时,它包含执行上下文堆栈。函数,模块和全局脚本代码 - 这些都是JavaScript执行上下文的示例。解释器也有本身的内部工做队列; 一个用于建立新的执行上下文堆栈,另外一个用于调用promise解析回调函数。
咱们编写了一些使用setTimeout()函数手动建立任务的代码,并演示了长时间运行的JavaScript代码对于这些任务的影响。而后咱们查看了EventTarget接口,用于监听DOM事件和网络请求,以及咱们在本章中未讨论的其余内容,如Web workers和文件读取。
咱们贯穿本章的是JavaScript程序员在使用这个模型时所面临的一些挑战。特别是,很难遵照咱们的JavaScript并发原则。咱们不使用并行,并试图只使用同步,但回调倒是一个噩梦。
在下一章中,咱们将介绍一种使用promises进行同步的新思路。这将使咱们可以认真开始设计和构建并发JavaScript应用程序。
另外还有讲解两章nodeJs后端并发方面的,和一章项目实战方面的,这里就再也不贴了,有兴趣可转向https://github.com/yzsunlei/javascript_concurrency_translation查看。