[书籍翻译] 《JavaScript并发编程》 第二章 JavaScript运行模型
本文是我翻译《javascript concurrency》书籍的第二章 javascript运行模型,该书主要以promises、generator、web workers等技术来讲解javascript并发编程方面的实践。
完整书籍翻译地址: 。由于能力有限,肯定存在翻译不清楚甚至翻译错误的地方,欢迎朋友们提issue指出,感谢。
本书第一章我们探讨了javascript并发的一些情况。一般来说,在javascript应用程序中处理并发只是一件小事。有很多想编写并发javascript代码的,提出的一些解决办法并不是非常规范的。有很多回调,并且用到的所有这些回调就足够让人发疯了。我们还看了下我们编写的并发javascript代码如何改变现有的组件。web workers已经开始成熟,javascript语言的并发结构才刚刚引入。
javascript语言和运行时环境已经是定下来的。我们需要在设计层面考虑并发,而不是在编写代码时。并发应该是默认的。这说起来很容易,但很难做到。在本书中,我们将探讨javascript并发所提供的所有特性,以及我们如何利用好它们作为设计工具的优势。但是,我们在这样做之前,需要深入了解javascript究竟是怎样运行的。这些是设计并发应用程序的必要知识,因为我们需要确切地知道在选择一种并发机制时会发生什么。
在本章中,我们将从浏览器环境开始,看看代码运行所涉及的所有子系统 - 例如javascript解释器,任务队列和dom本身。然后我们将介绍一些代码示例,这些代码将揭示运行我们的代码时真正发生的事情。最后我们将通过讨论在这个模型中面临的挑战来结束本章。
一切都是任务
当我们访问网页时,会在浏览器中为我们创建整个环境。这个环境有几个子系统,我们浏览的网页外观和行为都应该遵循万维网联盟(w3c)规范。任务是web浏览器中的一个基本抽象。任何发生的事情要么是一个任务本身,要么是较大任务的一部分。
如果您正在阅读任何w3c规范,他们使用术语“用户代理”而不是“web浏览器”。在99.9%的情况下,我们正在阅读的内容
符合主流的浏览器提供商。
在本节中,我们将介绍这些环境的主要组件,以及任务队列和事件循环如何在这些组件之间进行通信,以实现网页的整体外观和交互行为。
大体的介绍
这里先介绍一些术语,它们将在本章的各个部分进行讲解:
• 执行环境:每当打开新网页时,都会创建一个容器。这是一个丰富复杂的环境,它拥有我们的javascript代码将与之交互的一切。它也可以作为沙箱 - 我们的javascript代码无法访问环境之外的东西。
• javascript解释器:这是负责解析和执行javascript源代码的组件。浏览器的工作是使用全局变量来扩充解释器,例如window和xmlhttprequest。
• 任务队列:只要发生一些事情,就会有任务排队。一个执行环境至少有一个这样的队列,但通常它有几个队列。
• 事件循环:执行环境具有单一的事件循环,负责为所有任务队列提供服务。只有一个事件循环,因为只有一个线程。
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()
所述的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()
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);
响应dom事件
在上一节中,我们了解了如何延时运行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应用程序。
推荐阅读
-
[书籍翻译] 《JavaScript并发编程》第五章 使用Web Workers
-
[书籍翻译] 《JavaScript并发编程》第六章 实用的并发
-
[书籍翻译] 《JavaScript并发编程》第七章 抽取并发逻辑
-
[书籍翻译] 《JavaScript并发编程》第三章 使用Promises实现同步
-
[书籍翻译] 《JavaScript并发编程》第一章 JavaScript并发简介
-
[书籍翻译] 《JavaScript并发编程》 第二章 JavaScript运行模型
-
[书籍翻译] 《JavaScript并发编程》第五章 使用Web Workers
-
[书籍翻译] 《JavaScript并发编程》第五章 使用Web Workers
-
[书籍翻译] 《JavaScript并发编程》第三章 使用Promises实现同步
-
[书籍翻译] 《JavaScript并发编程》 第二章 JavaScript运行模型