返回介绍

第一部分 类型和语法

第二部分 异步和性能

1.4 并发

发布于 2023-05-24 16:38:21 字数 8428 浏览 0 评论 0 收藏 0

现在让我们来设想一个展示状态更新列表(比如社交网络新闻种子)的网站,其随着用户向下滚动列表而逐渐加载更多内容。要正确地实现这一特性,需要(至少)两个独立的“进程”同时运行(也就是说,是在同一段时间内,并不需要在同一时刻)。

这里的“进程”之所以打上引号,是因为这并不是计算机科学意义上的真正操作系统级进程。这是虚拟进程,或者任务,表示一个逻辑上相关的运算序列。之所以使用“进程”而不是“任务”,是因为从概念上来讲,“进程”的定义更符合这里我们使用的意义。

第一个“进程”在用户向下滚动页面触发 onscroll 事件时响应这些事件(发起 Ajax 请求要求新的内容)。第二个“进程”接收 Ajax 响应(把内容展示到页面)。

显然,如果用户滚动页面足够快的话,在等待第一个响应返回并处理的时候可能会看到两个或更多 onscroll 事件被触发,因此将得到快速触发彼此交替的 onscroll 事件和 Ajax 响应事件。

两个或多个“进程”同时执行就出现了并发,不管组成它们的单个运算是否并行 执行(在独立的处理器或处理器核心上同时运行)。可以把并发看作“进程”级(或者任务级)的并行,与运算级的并行(不同处理器上的线程)相对。

并发也引出了这些“进程”之间可能的彼此交互的概念。我们会在后面介绍。

在给定的时间窗口内(用户滚动页面的几秒钟内),我们看看把各个独立的“进程”表示为一系列事件 / 运算是什么样的:

“进程”1(onscroll 事件):

onscroll, 请求1
onscroll, 请求2
onscroll, 请求3
onscroll, 请求4
onscroll, 请求5
onscroll, 请求6
onscroll, 请求7

“进程”2(Ajax 响应事件):

响应1
响应2
响应3
响应4
响应5
响应6
响应7

很可能某个 onscroll 事件和某个 Ajax 响应事件恰好同时可以处理。举例来说,假设这些事件的时间线是这样的:

onscroll, 请求1
onscroll, 请求2           响应1
onscroll, 请求3           响应2
响应3
onscroll, 请求4
onscroll, 请求5
onscroll, 请求6           响应4
onscroll, 请求7

响应6
响应5
响应7

但是,本章前面介绍过事件循环的概念,JavaScript 一次只能处理一个事件,所以要么是 onscroll ,请求 2 先发生,要么是响应 1 先发生,但是不会严格地同时发生。这就像学校食堂的孩子们,不管在门外多么拥挤,最终他们都得站成一队才能拿到自己的午饭!

下面列出了事件循环队列中所有这些交替的事件:

onscroll, 请求1     <--- 进程1启动
onscroll, 请求2
响应1         <--- 进程2启动
onscroll, 请求3
响应2
响应3
onscroll, 请求4
onscroll, 请求5
onscroll, 请求6
响应4
onscroll, 请求7     <--- 进程1结束
响应6
响应5
响应7         <--- 进程2结束

“进程”1 和“进程”2 并发运行(任务级并行),但是它们的各个事件是在事件循环队列中依次运行的。

另外,注意到响应 6 和响应 5 的返回是乱序的了吗?

单线程事件循环是并发的一种形式(当然还有其他形式,后面会介绍)。

1.4.1 非交互

两个或多个“进程”在同一个程序内并发地交替运行它们的步骤 / 事件时,如果这些任务彼此不相关,就不一定需要交互。如果进程间没有相互影响的话,不确定性是完全可以接受的

举例来说:

var res = {};

function foo(results) {
  res.foo = results;
}

function bar(results) {
  res.bar = results;
}

// ajax(..)是某个库提供的某个Ajax函数
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

foo() 和 bar() 是两个并发执行的“进程”,按照什么顺序执行是不确定的。但是,我们构建程序的方式使得无论按哪种顺序执行都无所谓,因为它们是独立运行的,不会相互影响。

这并不是竞态条件 bug,因为不管顺序如何,代码总会正常工作。

1.4.2 交互

更常见的情况是,并发的“进程”需要相互交流,通过作用域或 DOM 间接交互。正如前面介绍的,如果出现这样的交互,就需要对它们的交互进行协调以避免竞态的出现。

下面是一个简单的例子,两个并发的“进程”通过隐含的顺序相互影响,这个顺序有时会被破坏:

var res = [];

function response(data) {
  res.push( data );
}

// ajax(..)是某个库中提供的某个Ajax函数
ajax( "http://some.url.1", response );
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" 的响应,这可能是由它们所执行任务的性质决定的(比如,一个执行数据库任务,而另一个只是获取静态文件),所以观察到的顺序总是符合预期。即使两个请求都发送到同一个服务器,也总会按照固定的顺序响应,但对于响应返回浏览器的顺序,也没有人可以真正保证。

所以,可以协调交互顺序来处理这样的竞态条件:

var res = [];

function response(data) {
  if (data.url == "http://some.url.1") {
    res[0] = data;
  }
  else if (data.url == "http://some.url.2") {
    res[1] = data;
  }
}

// ajax(..)是某个库中提供的某个Ajax函数
ajax( "http://some.url.1", response );
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 元素在拿到内容之前显示出来,所以这种协调必须要保证正确的交互顺序。

有些并发场景如果不做协调,就总是(并非偶尔)会出错。考虑:

var a, b;

function foo(x) {
  a = x * 2;
  baz();
}

function bar(y) {
  b = y * 2;
  baz();
}

function baz() {
  console.log(a + b);
}

// ajax(..)是某个库中的某个Ajax函数
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

在这个例子中,无论 foo() 和 bar() 哪一个先被触发,总会使 baz() 过早运行(a 或者 b 仍处于未定义状态);但对 baz() 的第二次调用就没有问题,因为这时候 a 和 b 都已经可用了。

要解决这个问题有多种方法。这里给出了一种简单方法:

var a, b;

function foo(x) {
  a = x * 2;
  if (a && b) {
    baz();
  }
}

function bar(y) {
  b = y * 2;
  if (a && b) {
    baz();
  }
}

function baz() {
  console.log( a + b );
}

// ajax(..)是某个库中的某个Ajax函数
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

包裹 baz() 调用的条件判断 if (a && b) 传统上称为 (gate),我们虽然不能确定 a 和 b 到达的顺序,但是会等到它们两个都准备好再进一步打开门(调用 baz() )。

另一种可能遇到的并发交互条件有时称为竞态 (race),但是更精确的叫法是门闩 (latch)。它的特性可以描述为“只有第一名取胜”。在这里,不确定性是可以接受的,因为它明确指出了这一点是可以接受的:需要“竞争”到终点,且只有唯一的胜利者。

请思考下面这段有问题的代码:

var a;

function foo(x) {
  a = x * 2;
  baz();
}

function bar(x) {
  a = x / 2;
  baz();
}

function baz() {
  console.log( a );
}

// ajax(..)是某个库中的某个Ajax函数
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

不管哪一个(foo() 或 bar() )后被触发,都不仅会覆盖另外一个给 a 赋的值,也会重复调用 baz() (很可能并不是想要的结果)。

所以,可以通过一个简单的门闩协调这个交互过程,只让第一个通过:

var a;

function foo(x) {
  if (!a) {
    a = x * 2;
    baz();
  }
}

function bar(x) {
  if (!a) {
    a = x / 2;
    baz();
  }
}

function baz() {
  console.log( a );
}

// ajax(..)是某个库中的某个Ajax函数
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

条件判断 if (!a) 使得只有 foo() 和 bar() 中的第一个可以通过,第二个(实际上是任何后续的)调用会被忽略。也就是说,第二名没有任何意义!

出于简化演示的目的,在所有这些场景中,我们一直都使用了全局变量,但这对于此处的论证完全不是必需的。只要相关的函数(通过作用域)能够访问到这些变量,就会按照预期工作。依赖于词法作用域变量(参见本系列的《你不知道的 JavaScript(上卷)》的“作用域和闭包”部分),实际上前面例子中那样的全局变量,对于这些类别的并发协调是一个明显的负面因素。随着后面几章内容的展开,我们会看到还有其他种类的更清晰的协调方式。

1.4.3 协作

还有一种并发合作方式,称为并发协作 (cooperative concurrency)。这里的重点不再是通过共享作用域中的值进行交互(尽管显然这也是允许的!)。这里的目标是取到一个长期运行的“进程”,并将其分割成多个步骤或多批任务,使得其他并发“进程”有机会将自己的运算插入到事件循环队列中交替运行。

举例来说,考虑一个需要遍历很长的结果列表进行值转换的 Ajax 响应处理函数。我们会使用 Array#map(..) 让代码更简洁:

var res = [];

// response(..)从Ajax调用中取得结果数组
function response(data) {
  // 添加到已有的res数组
  res = res.concat(
    // 创建一个新的变换数组把所有data值加倍
    data.map( function(val){
      return val * 2;
    } )
  );
}

// ajax(..)是某个库中提供的某个Ajax函数
ajax( "http://some.url.1", response );
ajax( "http://some.url.2", response );

如果 "http://some.url.1" 首先取得结果,那么整个列表会立刻映射到 res 中。如果记录有几千条或更少,这不算什么。但是如果有像 1000 万条记录的话,就可能需要运行相当一段时间了(在高性能笔记本上需要几秒钟,在移动设备上需要更长时间,等等)。

这样的“进程”运行时,页面上的其他代码都不能运行,包括不能有其他的 response(..) 调用或 UI 刷新,甚至是像滚动、输入、按钮点击这样的用户事件。这是相当痛苦的。

所以,要创建一个协作性更强更友好且不会霸占事件循环队列的并发系统,你可以异步地批处理这些结果。每次处理之后返回事件循环,让其他等待事件有机会运行。

这里给出一种非常简单的方法:

var res = [];

// response(..)从Ajax调用中取得结果数组
function response(data) {
  // 一次处理1000个
  var chunk = data.splice( 0, 1000 );

  // 添加到已有的res组
  res = res.concat(
    // 创建一个新的数组把chunk中所有值加倍
    chunk.map( function(val){
      return val * 2;
    } )
  );

  // 还有剩下的需要处理吗?
  if (data.length > 0) {
    // 异步调度下一次批处理
    setTimeout( function(){
      response( data );
    }, 0 );
  }
}

// ajax(..)是某个库中提供的某个Ajax函数
ajax( "http://some.url.1", response );
ajax( "http://some.url.2", response );

我们把数据集合放在最多包含 1000 条项目的块中。这样,我们就确保了“进程”运行时间会很短,即使这意味着需要更多的后续“进程”,因为事件循环队列的交替运行会提高站点 /App 的响应(性能)。

当然,我们并没有协调这些“进程”的顺序,所以结果的顺序是不可预测的。如果需要排序的话,就要使用和前面提到类似的交互技术,或者本书后面章节将要介绍的技术。

这里使用 setTimeout(..0) (hack)进行异步调度,基本上它的意思就是“把这个函数插入到当前事件循环队列的结尾处”。

严格说来,setTimeout(..0) 并不直接把项目插入到事件循环队列。定时器会在有机会的时候插入事件。举例来说,两个连续的 setTimeout(..0) 调用不能保证会严格按照调用顺序处理,所以各种情况都有可能出现,比如定时 器漂移,在这种情况下,这些事件的顺序就不可预测。在 Node.js 中,类似的方法是 process.nextTick(..) 。尽管它们使用方便(通常性能也更高),但并没有(至少到目前为止)直接的方法可以适应所有环境来确保异步事件的顺序。下一小节我们会深入讨论这个话题。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文