第一章: 异步:现在与稍后 - 并发

优质
小牛编辑
125浏览
2023-12-01

并发

让我们想象一个网站,它显示一个随着用户向下滚动而逐步加载的状态更新列表(就像社交网络的新消息)。要使这样的特性正确工作,(至少)需要两个分离的“进程” 同时 执行(在同一个时间跨度内,但没必要是同一个时间点)。

注意: 我们在这里使用带引号的“进程”,因为它们不是计算机科学意义上的真正的操作系统级别的进程。它们是虚拟进程,或者说任务,表示一组逻辑上关联,串行顺序的操作。我们将简单地使用“进程”而非“任务”,因为在术语层面它与我们讨论的概念的定义相匹配。

第一个“进程”将响应当用户向下滚动页面时触发的onscroll事件(发起取得新内容的Ajax请求)。第二个“进程”将接收返回的Ajax应答(将内容绘制在页面上)。

显然,如果用户向下滚动的足够快,你也许会看到在第一个应答返回并处理期间,有两个或更多的onscroll事件被触发,因此你将使onscroll事件和Ajax应答事件迅速触发,互相穿插在一起。

并发是当两个或多个“进程”在同一时间段内同时执行,无论构成它们的各个操作是否 并行地(在同一时刻不同的处理器或内核)发生。你可以认为并发是“进程”级别的(或任务级别)的并行机制,而不是操作级别的并行机制(分割进程的线程)。

注意: 并发还引入了这些“进程”间彼此互动的概念。我们稍后会讨论它。

在一个给定的时间跨度内(用户可以滚动的那几秒),让我们将每个独立的“进程”作为一系列事件/操作描绘出来:

“线程”1 (onscroll事件):

  1. onscroll, request 1
  2. onscroll, request 2
  3. onscroll, request 3
  4. onscroll, request 4
  5. onscroll, request 5
  6. onscroll, request 6
  7. onscroll, request 7

“线程”2 (Ajax应答事件):

  1. response 1
  2. response 2
  3. response 3
  4. response 4
  5. response 5
  6. response 6
  7. response 7

一个onscroll事件与一个Ajax应答事件很有可能在同一个 时刻 都准备好被处理了。比如我们在一个时间线上描绘一下这些事件的话:

  1. onscroll, request 1
  2. onscroll, request 2 response 1
  3. onscroll, request 3 response 2
  4. response 3
  5. onscroll, request 4
  6. onscroll, request 5
  7. onscroll, request 6 response 4
  8. onscroll, request 7
  9. response 6
  10. response 5
  11. response 7

但是,回到本章前面的事件轮询概念,JS一次只能处理一个事件,所以不是onscroll, request 2首先发生就是response 1首先发生,但是他们不可能完全在同一时刻发生。就像学校食堂的孩子们一样,不管他们在门口挤成什么样,他们最后都不得不排成一个队来打饭!

让我们来描绘一下所有这些事件在事件轮询队列上穿插的情况:

事件轮询队列:

  1. onscroll, request 1 <--- 进程1开始
  2. onscroll, request 2
  3. response 1 <--- 进程2开始
  4. onscroll, request 3
  5. response 2
  6. response 3
  7. onscroll, request 4
  8. onscroll, request 5
  9. onscroll, request 6
  10. response 4
  11. onscroll, request 7 <--- 进程1结束
  12. response 6
  13. response 5
  14. response 7 <--- 进程2结束

“进程1”和“进程2”并发地运行(任务级别的并行),但是它们的个别事件在事件轮询队列上顺序地运行。

顺便说一句,注意到response 6response 5没有按照预想的顺序应答吗?

单线程事件轮询是并发的一种表达(当然还有其他的表达,我们稍后讨论)。

非互动

在同一个程序中两个或更多的“进程”在穿插它们的步骤/事件时,如果它们的任务之间没有联系,那么他们就没必要互动。如果它们不互动,不确定性就是完全可以接受的。

举个例子:

  1. var res = {};
  2. function foo(results) {
  3. res.foo = results;
  4. }
  5. function bar(results) {
  6. res.bar = results;
  7. }
  8. // ajax(..) 是某个包中任意的Ajax函数
  9. ajax( "http://some.url.1", foo );
  10. ajax( "http://some.url.2", bar );

foo()bar()是两个并发的“进程”,而且它们被触发的顺序是不确定的。但对我们的程序的结构来讲它们的触发顺序无关紧要,因为它们的行为相互独立所以不需要互动。

这不是一个“竞合状态”Bug,因为这段代码总能够正确工作,与顺序无关。

互动

更常见的是,通过作用域和/或DOM,并发的“进程”将有必要间接地互动。当这样的互动将要发生时,你需要协调这些互动行为来防止前面讲述的“竞合状态”。

这里是两个由于隐含的顺序而互动的并发“进程”的例子,它 有时会出错

  1. var res = [];
  2. function response(data) {
  3. res.push( data );
  4. }
  5. // ajax(..) 是某个包中任意的Ajax函数
  6. ajax( "http://some.url.1", response );
  7. ajax( "http://some.url.2", response );

并发的“进程”是那两个将要处理Ajax应答的response()调用。它们谁都有可能先发生。

假定我们期望的行为是res[0]拥有"http://some.url.1"调用的结果,而res[1]拥有"http://some.url.2"调用的结果。有时候结果确实是这样,而有时候则相反,要看哪一个调用首先完成。很有可能,这种不确定性是一个“竞合状态”Bug。

注意: 在这些情况下要极其警惕你可能做出的主观臆测。比如这样的情况就没什么不寻常:一个开发者观察到"http://some.url.2"的应答“总是”比"http://some.url.1"要慢得多,也许有赖于它们所做的任务(比如,一个执行数据库任务而另一个只是取得静态文件),所以观察到的顺序看起来总是所期望的。就算两个请求都发到同一个服务器,而且它故意以确定的顺序应答,也不能 真正 保证应答回到浏览器的顺序。

所以,为了解决这样的竞合状态,你可以协调互动的顺序:

  1. var res = [];
  2. function response(data) {
  3. if (data.url == "http://some.url.1") {
  4. res[0] = data;
  5. }
  6. else if (data.url == "http://some.url.2") {
  7. res[1] = data;
  8. }
  9. }
  10. // ajax(..) 是某个包中任意的Ajax函数
  11. ajax( "http://some.url.1", response );
  12. ajax( "http://some.url.2", response );

无论哪个Ajax应答首先返回,我们都考察它的data.url(当然,假设这样的数据会从服务器返回)来找到应答数据应当在res数组中占有的位置。res[0]将总是持有"http://some.url.1"的结果,而res[1]将总是持有"http://some.url.2"的结果。通过简单的协调,我们消除了“竞合状态”的不确定性。

这个场景的同样道理可以适用于这样的情况:多个并发的函数调用通过共享的DOM互动,比如一个在更新<div>的内容而另一个在更新<div>的样式或属性(比如一旦DOM元素拥有内容就使它变得可见)。你可能不想在DOM元素拥有内容之前显示它,所以协调工作就必须保证正确顺序的互动。

没有协调的互动,有些并发的场景 总是出错(不仅仅是 有时)。考虑下面的代码:

  1. var a, b;
  2. function foo(x) {
  3. a = x * 2;
  4. baz();
  5. }
  6. function bar(y) {
  7. b = y * 2;
  8. baz();
  9. }
  10. function baz() {
  11. console.log(a + b);
  12. }
  13. // ajax(..) 是某个包中任意的Ajax函数
  14. ajax( "http://some.url.1", foo );
  15. ajax( "http://some.url.2", bar );

在这个例子中,不管foo()bar()谁先触发,总是会使baz()运行的太早了(ab之一还是空的时候),但是第二个baz()调用将可以工作,因为ab将都是可用的。

有许多不同的方法可以解决这个状态。这是简单的一种:

  1. var a, b;
  2. function foo(x) {
  3. a = x * 2;
  4. if (a && b) {
  5. baz();
  6. }
  7. }
  8. function bar(y) {
  9. b = y * 2;
  10. if (a && b) {
  11. baz();
  12. }
  13. }
  14. function baz() {
  15. console.log( a + b );
  16. }
  17. // ajax(..) 是某个包中任意的Ajax函数
  18. ajax( "http://some.url.1", foo );
  19. ajax( "http://some.url.2", bar );

baz()调用周围的if (a && b)条件通常称为“大门”,因为我们不能确定ab到来的顺序,但在打开大门(调用baz())之前我们等待它们全部到达。

另一种你可能会遇到的并发互动状态有时称为“竞争”,但更准确地说应该叫“门闩”。它的行为特点是“先到者胜”。在这里不确定性是可以接受的,因为你明确指出“竞争”的终点线上只有一个胜利者。

考虑这段有问题的代码:

  1. var a;
  2. function foo(x) {
  3. a = x * 2;
  4. baz();
  5. }
  6. function bar(x) {
  7. a = x / 2;
  8. baz();
  9. }
  10. function baz() {
  11. console.log( a );
  12. }
  13. // ajax(..) 是某个包中任意的Ajax函数
  14. ajax( "http://some.url.1", foo );
  15. ajax( "http://some.url.2", bar );

不管哪一个函数最后触发(foo()bar()),它不仅会覆盖前一个函数对a的赋值,还会重复调用baz()(不太可能是期望的)。

所以,我们可以用一个简单的门闩来协调互动,仅让第一个过去:

  1. var a;
  2. function foo(x) {
  3. if (a == undefined) {
  4. a = x * 2;
  5. baz();
  6. }
  7. }
  8. function bar(x) {
  9. if (a == undefined) {
  10. a = x / 2;
  11. baz();
  12. }
  13. }
  14. function baz() {
  15. console.log( a );
  16. }
  17. // ajax(..) 是某个包中任意的Ajax函数
  18. ajax( "http://some.url.1", foo );
  19. ajax( "http://some.url.2", bar );

if (a == undefined)条件仅会让foo()bar()中的第一个通过,而第二个(以及后续所有的)调用将会被忽略。第二名什么也得不到!

注意: 在所有这些场景中,为了简化说明的目的我们都用了全局变量,这里我们没有任何理由需要这么做。只要我们讨论中的函数可以访问变量(通过作用域),它们就可以正常工作。依赖于词法作用域变量(参见本丛书的 作用域与闭包 ),和这些例子中实质上的全局变量,是这种并发协调形式的一个明显的缺点。在以后的几章中,我们会看到其他的在这方面干净得多的协调方法。

协作

另一种并发协调的表达称为“协作并发”,它并不那么看重在作用域中通过共享值互动(虽然这依然是允许的!)。它的目标是将一个长时间运行的“进程”打断为许多步骤或批处理,以至于其他的并发“进程”有机会将它们的操作穿插进事件轮询队列。

举个例子,考虑一个Ajax应答处理器,它需要遍历一个很长的结果列表来将值变形。我们将使用Array#map(..)来让代码短一些:

  1. var res = [];
  2. // `response(..)`从Ajax调用收到一个结果数组
  3. function response(data) {
  4. // 连接到既存的`res`数组上
  5. res = res.concat(
  6. // 制造一个新的变形过的数组,所有的`data`值都翻倍
  7. data.map( function(val){
  8. return val * 2;
  9. } )
  10. );
  11. }
  12. // ajax(..) 是某个包中任意的Ajax函数
  13. ajax( "http://some.url.1", response );
  14. ajax( "http://some.url.2", response );

如果"http://some.url.1"首先返回它的结果,整个结果列表将会一次性映射进res。如果只有几千或更少的结果记录,一般来说不是什么大事。但假如有1千万个记录,那么就可能会花一段时间运行(在强大的笔记本电脑上花几秒钟,在移动设备上花的时间长得多,等等)。

当这样的“处理”运行时,页面上没有任何事情可以发生,包括不能有另一个response(..)调用,不能有UI更新,甚至不能有用户事件比如滚动,打字,按钮点击等。非常痛苦。

所以,为了制造协作性更强、更友好而且不独占事件轮询队列的并发系统,你可以在一个异步批处理中处理这些结果,在批处理的每一步都“让出”事件轮询来让其他等待的事件发生。

这是一个非常简单的方法:

  1. var res = [];
  2. // `response(..)`从Ajax调用收到一个结果数组
  3. function response(data) {
  4. // 我们一次只处理1000件
  5. var chunk = data.splice( 0, 1000 );
  6. // 连接到既存的`res`数组上
  7. res = res.concat(
  8. // 制造一个新的变形过的数组,所有的`data`值都翻倍
  9. chunk.map( function(val){
  10. return val * 2;
  11. } )
  12. );
  13. // 还有东西要处理吗?
  14. if (data.length > 0) {
  15. // 异步规划下一个批处理
  16. setTimeout( function(){
  17. response( data );
  18. }, 0 );
  19. }
  20. }
  21. // ajax(..) 是某个包中任意的Ajax函数
  22. ajax( "http://some.url.1", response );
  23. ajax( "http://some.url.2", response );

我们以每次最大1000件作为一个块儿处理数据。这样,我们保证每个“进程”都是短时间运行的,即便这意味着会有许多后续的“进程”,在事件轮询队列上的穿插将会给我们一个响应性(性能)强得多的网站/应用程序。

当然,我们没有对任何这些“进程”的顺序进行互动协调,所以在res中的结果的顺序是不可预知的。如果要求顺序,你需要使用我们之前讨论的互动技术,或者在本书后续章节中介绍的其他技术。

我们使用setTimeout(..0)(黑科技)来异步排程,基本上它的意思是“将这个函数贴在事件轮询队列的末尾”。

注意: 从技术上讲,setTimeout(..0)没有直接将一条记录插入事件轮询队列。计时器将会在下一个运行机会将事件插入。比如,两个连续的setTimeout(..0)调用不会严格保证以调用的顺序被处理,所以我们可能看到各种时间偏移的情况,使这样的事件的顺序是不可预知的。在Node.js中,一个相似的方式是process.nextTick(..)。不管那将会有多方便(而且通常性能更好),(还)没有一个直接的方法可以横跨所有环境来保证异步事件顺序。我们会在下一节详细讨论这个话题。