【译】异步:现在与将来(并发篇)

原文: https://github.com/getify/You-Dont-Know-JS/blob/master/async%20%26%20performance/ch1.md#concurrency
译者:熊贤仁

让我们想象有个网站,展示状态更新的列表(类似社交网络信息流),当用户下滑列表时,渐进式地加载数据。为了让这一特性正确工作,(至少)两个独立的 “进程” 需要同时执行(也就是说,在同一段窗口时间下,而不必是同一时刻)。

注意: 我们使用了给 “进程” 加了引号,因为它们不是计算机科学意义上的真正的操作系统级别的进程。它们是虚拟进程,或者说任务,用来表示一系列逻辑相关的连续的操作。我们简单地使用了 “进程” 而不是 “任务”,因为这个术语更加匹配我们正在探讨的定义。

当用户向下滚动页面后,第一个 “进程” 会响应 onscroll 事件(发起 Ajax 请求去获取新内容)。第二个 “进程” 会接收 Ajax 返回的响应(去渲染内容到页面上)。

显然,如果用户向下滚动页面足够快的话,你可能在页面接收到第一个响应并处理时,看到不止一个 onscroll 事件被触发。因此你将得到快速触发并交错执行的onscroll 事件和 Ajax 响应事件。

并发是在同一段时间内两个或多个 “进程” 同时运行,不管它们的独立构成的运算是否 “并行” (多个独立的处理器或核心同一时刻)执行。你可以把并发看作是 “进程” 级(或者任务级)的并行化,这与运算级的并行化(多个独立处理器线程)是截然相反的。

注意: 并发也引入了这些相互作用的 “进程”间的一个可选的概念。我们将随后介绍它。

对于给定的一段窗口时间(用户滚动页面的几秒内),我们把每个独立的 “进程” 看作是一系列事件或运算:

“进程” 1(onscroll 事件):

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

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

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

onscroll 事件和 Ajax 响应事件很可能在同一时刻准备执行。举个例子,让我们设想这些事件在一个时间线上:

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

但是,回到我们之前章节讲过的事件循环的概念,JS 同一时刻只能处理一个事件,onscroll 事件也不例外。要么请求 2 先执行,要么响应 1 先执行,但它们字面上不能同时执行。就像学校食堂的孩子们,不管门外挤进了多少人,他们也必须排队取餐。

我们设想所有这些事件在事件循环队列中交替执行。
事件循环队列:

onscroll, request 1 <--- Process 1 starts
onscroll, request 2
response 1 <--- Process 2 starts
onscroll, request 3
response 2
response 3
onscroll, request 4
onscroll, request 5
onscroll, request 6
response 4
onscroll, request 7 <--- Process 1 finishes
response 6
response 5
response 7 <--- Process 2 finishes

“进程 1” 和 “进程 2” 并发运行(任务级并发),而他们独立的事件在事件循环队列中按序运行。

顺便说一下,注意到响应 6 和响应 5 那令人意外的顺序了吗?

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

非交互

当两个或多个 “进程” 在同一个程序内并发地交替执行它们的步骤或事件,如果彼此任务不相关,它们不必有交互。如果它们没有交互,程序的不确定性将相当低。

比如:

var res = {};

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

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

// ajax(..) is some arbitrary Ajax function given by a library
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

foo()bar() 是两个并发 “进程”,它们哪个先执行是不确定的。但它们的执行顺序对这段程序来说都无关紧要的,因为它们是独立执行的,故不需要交互。

这不是一个 “竞态条件” bug,不管执行顺序如何,因为这段代码总是正常工作。

交互

更常见的是,并发 “进程” 直接通过作用域和 DOM 进行必要的交互。如前面所述,你需要协调这些交互,从而防止 “竞态条件”。

这里有个简单的例子,有两个并发 “进程”,它们由于隐含的排列顺序而产生交互,这个顺序有时会被破坏

var res = [];

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

// ajax(..) is some arbitrary Ajax function given by a library
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(..) is some arbitrary Ajax function given by a library
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" 的结果。通过简单的调整,我们排除了 “竞态条件” 不确定性。

如果调用了多个并发函数,这些函数通过共享内存产生交互,上面场景下的论证也可以得到应用。比如其中一个更新一个 <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(..) is some arbitrary Ajax function given by a library
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

在这个例子中,不管是 foo() 还是 bar() 先执行,总是会造成 baz() 过早执行(ab 都还是 unfined),但 baz() 的第二次调用会正常工作,因为 ab 都已被初始化了。

有多种方法可以处理此类情况。这里有一个简单的办法:

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(..) is some arbitrary Ajax function given by a library
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

包裹着 baz() 调用的 if (a && b) 条件语句通常被称为一个 “门”,因为不确定 ab 的到达顺序,我们要在开门之前(调用 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(..) is some arbitrary Ajax function given by a library
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

无论哪个(foo() 或者 bar())先执行,不仅 a 的赋值会被另一个覆盖,而且还会重复调用 baz() (很可能并不希望这样)。

所以,我们可以通过一个简单的门闩来调整交互过程,以只让第一个通过:

var a;

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

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

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

// ajax(..) is some arbitrary Ajax function given by a library
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

if (a == undefined) 条件只会让 foo()bar() 中的第一个通过,第二个调用(事实上是任何后续调用的)的会被忽略。第二名并不光荣!

注意:在以上所有场景中,我们都使用了全局变量来做简单的示范,但对于我们的论证来说,这样做并不是必要的。只要问题中的函数可以访问变量(通过作用域),它们都会正常工作。依赖于词法作用域变量(见本书的作用域和闭包部分),以上例子中的全局变量,事实上对于那些并发调整来说是一个明显的负面因素。随着后面章节的展开,我们会看到更多种干净得多的调整方式。

协作

协调并发的另一个形式被称为 “协作式并发”。在这里,我们要关注的点不再是通过共享作用域内的值来进行交互(尽管这显然也是可以的)。通过使用一个长期运行的 “进程” ,并把 “进程” 分成多个步骤或批次,并发 “进程” 可以在事件循环队列中交叉执行它们的各种操作。

举个例子,考虑一个 Ajax 响应处理器,它需要遍历一长串结果列表来转换值。我们会使用 Array.map(..) 来简化代码:

var res = [];

// `response(..)` receives array of results from the Ajax call
function response(data) {
    // add onto existing `res` array
    res = res.concat(
        // make a new transformed array with all `data` values doubled
        data.map( function(val){
            return val * 2;
        } )
    );
}

// ajax(..) is some arbitrary Ajax function given by a library
ajax( "http://some.url.1", response );
ajax( "http://some.url.2", response );

如果 "http://some.url.1" 先得到返回结果,整个列表立即会被 map 进 res。如果只有几千或者更少的数据,通常问题不大。但如果说有个一千万条数据,这需要运行好一会儿(性能不错的笔记本上要几秒种,移动设备上会久得多,等待)。

当这样一个 “进程” 在运行,页面中的其他任务就可以去一边呆着了,包括没有其他的response(...) 函数调用,没有任何 UI 更新,甚至像滚动、输入、按钮点击这样的用户事件也没法工作了。这相当痛苦。

所以,为了实现一个协作更好的并发系统,它更友好,而且不会霸占事件循环队列,你可以异步地批处理这些结果,每次 “yielding” 后返回事件循环,让其他等待事件执行。

这里有个很简单的方法:

var res = [];

// `response(..)` receives array of results from the Ajax call
function response(data) {
    // let's just do 1000 at a time
    var chunk = data.splice( 0, 1000 );

    // add onto existing `res` array
    res = res.concat(
        // make a new transformed array with all `chunk` values doubled
        chunk.map( function(val){
            return val * 2;
        } )
    );

    // anything left to process?
    if (data.length > 0) {
        // async schedule next batch
        setTimeout( function(){
            response( data );
        }, 0 );
    }
}

// ajax(..) is some arbitrary Ajax function given by a library
ajax( "http://some.url.1", response );
ajax( "http://some.url.2", response );

我们把数据集处理成一些最大容量为 1000 的块。这样做可以保证 “进程” 运行时间比较短,即使这样也意味着会有更多后续的 “进程”,因为事件循环队列的交叉执行会提升网站/APP 的响应性能。

当然,我们没有针对这些 “进程” 的执行顺序做任何的交互调整,因此 res 中结果的顺序将是不确定的。如果要求保证顺序,你需要使用像我们前面讨论过的交互技巧,或者本书后面章节要介绍的技术。

为了实现异步调度,我们使用了 setTimeout(..0)(hack),基本上,这里的意思是 “把这个函数插入到事件循环队列的末尾”。

注意: 严格来说,setTimeout(..0) 并没有直接往事件循环队列插入处理函数。定时器将在有机会的情况下插入事件。比如说,两个相继的 setTimeout(..0) 调用并不能严格保证按照调用顺序处理,所以可以各种不同的情况都可能发生,比如定时器漂移,这时这些事件是不可预测的。Node.js 中,一个相似的方法是 process.nextTick(..)。尽管这很方便(通常性能也更好),但还没有直接的方法(至少现在还没有)可以跨所有环境来保证异步事件顺序。我们会在下一节深入讨论这个话题。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,921评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,635评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,393评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,836评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,833评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,685评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,043评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,694评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,671评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,670评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,779评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,424评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,027评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,984评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,214评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,108评论 2 351
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,517评论 2 343

推荐阅读更多精彩内容