[书籍翻译] 《JavaScript并发编程》第六章 实用的并发

本文是我翻译《JavaScript Concurrency》书籍的第六章 实用的并发,该书主要以Promises、Generator、Web workers等技术来说解JavaScript并发编程方面的实践。javascript

完整书籍翻译地址:https://github.com/yzsunlei/javascript_concurrency_translation 。因为能力有限,确定存在翻译不清楚甚至翻译错误的地方,欢迎朋友们提issue指出,感谢。html

在上一章中,咱们大体学习了Web workers的基本功能。咱们在浏览器中使用Web worker实现真正的并发,由于它们映射到实际的线程上,而这些线程又映射到独立的CPU上。本章,首次提供设计并行代码的一些实用方法。java

咱们首先简要介绍一下从函数式编程中能够借鉴的一些方法,以及它们如何能很好的适用于并发性问题。而后,咱们将决定应该经过并行计算仍是简单地在一个CPU上运行来解决并行有效性的问题。而后,咱们将深刻研究一些能够从并行运行的任务中受益的并发问题。咱们还将解决在使用workers线程时保持DOM响应的问题。node

函数式编程

函数显然是函数式编程的核心。其实,就是数据在咱们的应用程序中流转而已。实际上,数据及其它在程序中的流转可能与函数自己的实现一样重要,至少就应用程序设计而言。git

函数式编程和并发编程之间存在很强的亲和力。在本节中,咱们将看看为何是这样的,以及咱们如何应用函数式编程技术编写更强壮的并发代码。github

数据输入,数据输出

函数式编程相对其余编程范式是很强大的。这是一个解决一样问题的不一样方式。咱们使用一系列不一样的工具。例如,函数就是积木,咱们将利用它们来创建一个关于数据转换的抽象。命令式编程,从另外一方面来讲,使用构造,好比说类来构建抽象。与类和对象的根本区别是它们喜欢封装一些东西,而函数一般是数据流入,数据流出。web

例如,假设咱们有一个带有enabled属性的用户对象。咱们的想法是,enabled属性在某些给定时间会有一个值,也能够在某些给定时间改变。换句话说,用户改变状态。若是咱们将这个对象传递给咱们应用程序的不一样模块,那么状态也会随之传递。它被封装为一个属性。引用用户对象的这些组件中的任何一个均可以改变它,而后将其传递到其余地方,等等。下面的插图显示了一个函数在将用户传递给另外一个组件以前是如何改变其状态的:算法

image117.gif

在函数式编程中不是这样的。状态不是封装在对象内部,而后从组件传递到另外一个组件;不是由于这样作本质上是坏的,而是由于它只是解决问题的另外一种方式。状态封装是面向对象编程的目标,而函数式编程的关注的是从A点到B点并沿途转换数据。这里没有C点,一旦函数完成其工做就没有意义 - 它不关心数据的状态。这里是上图的函数替代方案:编程

image119.gif

咱们能够看到,函数方法使用更新后的属性值建立了一个新对象。该函数将数据做为输入并返回新数据做为输出。换句话说,它不会修改输入数据。这是一个简单的方法,但会有重要的结果,如不变性。后端

不变性

不可变数据是一个重要的函数式编程概念,很是适合并发编程。JavaScript是一种多范式语言。也就是说,它是函数式的,但也能够是命令式的。一些函数式编程语言严格遵循不变性 - 你根本没法改变对象的状态。这其实是很好的,它拥有选择什么时候保持数据不可变性以及什么时候不须要的灵活性。

在上一节的最后一张图中,展现了enable()函数实际返回一个具备与输入值不一样的属性值的全新对象。这样作是为了不改变输入值。虽然,这可能看起来很浪费 - 不断创建新对象,但实际上并不是如此。综合考虑当对象永远不会改变时咱们没必要写的标记代码。

例如,若是用户的enabled属性是可变的,则这意味着使用此对象的任何组件都须要不断检查enabled属性。如下是对此的见解:

image120.gif

只要组件想要向用户显示,就须要不断进行此检查。咱们实际上在使用函数方法时须要执行一样的检查。可是,函数式方法惟一有效的起点是建立路径。若是咱们系统中的其余内容能够更改enabled的属性,那么咱们须要担忧建立和修改路径。消除修改路径还消除了许多其余复杂性。这些被称为反作用。

反作用和并发性并很差。事实上,这是一个能够改变对象的方法,这使得并发变得困难。例如,假设咱们有两个线程想要访问咱们的用户对象。他们首先须要获取对它的访问权限,它可能已被锁定。如下是该方法的示图:

image122.gif

在这里,咱们能够看到第一个线程锁定用户对象,阻止其余线程访问它。第二个线程须要等到它解锁才能继续。这称为资源占用,它减弱了利用多核CPU的整个设计目的。若是线程等待访问某种资源,则它们并不真正的是在并行运行。不可变性能够解决资源占用问题,由于不须要锁定不会改变的资源。如下是使用两个线程的函数方法:

image123.gif

当对象不改变状态,任意数量的线程能够同时访问他们没有任何风险破坏对象的状态,因为乱序操做而且无需浪费宝贵的CPU时间等待的资源。

引用透明度和时间

将不可变数据做为输入的函数称为具备引用透明性的函数。这意味着给定相同的输入对象,不管调用多少次,该函数将始终返回相同的结果。这是一个有用的属性,由于它意味着从处理中删除时间因素。也就是说,惟一能够改变函数输出结果的因素是它的输入 - 而不是相对于其余函数调用的时间。

换句话说,引用透明函数不会产生反作用,由于它们使用不可变数据。所以,时间缺少是函数输出的一个因素,它们很是适合并发环境。让咱们来看一个不是引用透明的函数:

//仅当对象“enabled”时,返回给定对象的“name”属性。
//这意味着若是传递给它的用户永远不更新
//“enabled”属性,函数是引用透明的。
function getName(user) {
    if (user.enabled) {
        return user.name;
    }
}

//切换传入的“user.enabled”的属性值。
//像这样改变了对象状态的函数
//使引用透明度难以实现
function updateUser(user) {
    user.enabled = !user.enabled;
}

//咱们的用户对象 
var user = {
    name: 'ES6',
    enabled: false
};

console.log('name when disabled', '"${getName(user)}"');
//→name when disabled “undefined”

//改变用户状态。如今传递这个对象
//给函数意味着它们再也不存在
//引用透明,由于他们能够
//根据此更新生成不一样的输出。
updateUser(user);

console.log('name when enabled',`"${getName(user)}"`);
//→name when enabled "ES6"

该方式的getName()函数运行依赖于传递给它的用户对象的状态。若是用户对象是enabled,则返回name。不然,咱们没有返回。这意味着若是函数传入可变数据结构,则该函数不是引用透明的,在前面的示例中就是这种状况。enabled属性改变,函数的结果也会改变。让咱们修复这种状况,并使用如下代码使其具备引用透明性:

//“updateUser()”的引用透明版本,
//实际上它什么也没有更新。它创造了一个
//具备与传入的对象全部属性值相同的新对象,
//除了改变“enabled”属性值。
function updateUserRT(user) {
    return Object.assign({}, user, {
        enabled: !user.enabled
    });
}

//这种方法对“user”没有任何改变,
//代表使用“user”做为输入的任何函数,
//都保持引用透明。
var updatedUser = updateUserRT(user);

//咱们能够在任什么时候候调用referentially-transparent函数,
//并指望得到相同的结果。
//当这个对咱们的数据没有反作用时,
//并发性就变得更容易。
setTimeout(()=> {
    console.log('still enabled', `"${getName(user)}"`);
    //→still enabled "ES6"
}, 1000);

console.log('updated user', `"${getName(updatedUser)}"`);
//→updated user "undefined"

咱们能够看到,updateUserRT()函数实际上并无改变数据,它会建立一个包含更新的属性值的副本。这意味着咱们能够随时使用原始用户对象做为输入来调用updateUser()。

这种函数式编程技术能够帮助咱们编写并发代码,由于咱们执行操做的顺序不是一个影响因素。让异步操做有序执行很难。不可变数据带来引用透明性,这带来更强的并发语义。

咱们须要并行吗?

对于一些问题,并行性能够对咱们很是有用。建立workers并同步他们之间的通讯让执行任务不是免费的。例如,咱们可使用这个,经过精心设计的并行代码,很好的使用四个CPU内核。但事实证实,执行样板代码以促进这种并行性所花费的时间超过了在单个线程中简单处理数据所花费的。

在本节中,咱们将解决与验证咱们正在处理的数据以及肯定系统硬件功能相关的问题。对于并行执行根本没有意义的场景,咱们老是但愿有一个同步反馈。当咱们决定设计并行时,咱们的下一个工做就是弄清楚工做如何分配给worker。全部这些检查都在运行时执行。

数据有多大?

有时,并行并不值得。并行的方法是在更短的时间内计算更多。这样能够更快地获得咱们的结果,最终带来更迅速的用户体验。话虽如此,有些状况下咱们处理简单数据时使用多线程并非合理的。即便是一些大型数据集也可能没法从并行中受益。

肯定给定操做对于并行执行的适合程度的两个因素是数据的大小以及咱们对集合中的每一个项执行的操做的时间复杂度。换句话说,若是咱们有一个包含数千个对象的数组,可是对每一个对象执行的计算都很简单,那么就没有必要使用并行了。一样,咱们可能有一个只有不多对象的数组,但操做很复杂。一样,咱们可能没法将工做细分为较小的任务,而后将它们分发给worker线程。

咱们执行的各个项的计算是静态因素。在设计时,咱们必需要有一个整体思路,该代码在CPU运行周期中是复杂的仍是简便的。这可能须要一些静态分析,一些快速的基准,是一目了然的仍是夹杂着一些诀窍和直觉。当咱们制订一个标准,来肯定一个给定的操做是否很是适合于并行执行,咱们须要结合计算自己与数据的大小。

让咱们看一个使用不一样性能特征来肯定给定函数是否应该使用并行的示例:

//此函数肯定操做是否应该使用并行。
//它须要两个参数 - 要处理的数据data
//和一个布尔标志expensiveTask,
//表示该任务对数据中的每一个项执行是否复杂
function isConcurrent(data, expensiveTask) {
    var size, 
        isSet = data instanceof Set,
        isMap = data instanceof Map;

    //根据data的类型,肯定计算出数据的大小
    if (Array.isArray(data)) {
        size = data.length
    } else if (isSet || isMap) {
        size = data.size;
    } else {
        size = Object.keys(data).length;
    }

    //肯定是否超过数据并行处理大小的门槛,
    //门槛取决于“expensiveTask”值。
    return size >= (expensiveTask ? 100: 1000);
}

var data = new Array(138);

console.log('array with expensive task', isConcurrent(data, true));
//→array with expensive task true

console.log('array with inexpensive task', isConcurrent(data, false));
//→array with expensive task false

data = new Set(new Array(100000).fill(null).map((x, i) => i));

console.log('huge set with inexpensive task', isConcurrent(data, false));
//→huge set with inexpensive task true

这个函数很方便,由于它是一个简单的前置检查让咱们执行 - 看须要并行仍是不须要并行。若是不须要是,那么咱们能够采起简单计算结果的方法并将其返回给调用者。若是它是须要的,那么咱们将进入下一阶段,弄清楚如何将操做细分为更小的任务。

该isParallel()函数考虑到的不只是数据的大小,还有数据项中的任何一项执行计算的成本。这让咱们能够微调应用程序的并发性。若是开销太大,咱们能够增长并行处理阈值。若是咱们对代码进行了一些更改,这些更改让之前简便的函数,变得复杂。咱们只须要更改expensiveTask标志。

当咱们的代码在主线程中运行时,它在worker线程中运行时会发生什么?这是否意味着咱们必须写下
两次任务代码:一次用于正常代码,一次用于咱们的workers?咱们显然想避免这种状况,因此咱们须要
保持咱们的任务代码模块化。它须要能在主线程和worker线程中均可用。

硬件并发功能

咱们将在并发应用程序中执行的另外一个高级检查是咱们正在运行的硬件的并发功能。这将告诉咱们要建立多少web workers。例如,经过在只有四个CPU核心的系统上建立32个web workers,咱们真的得不到什么好处的。在这个系统上,四个web workers会更合适。那么,咱们如何获得这个数字呢?

让咱们建立一个通用函数,来解决这个问题:

//返回理想的Web worker建立数量。
function getConcurrency(defaultLevel = 4) {

    //若是“navigator.hardwareConcurrency”属性存在,
    //咱们直接使用它。不然,咱们返回“defaultLevel”值,
    //这个值在实际的硬件并发级别上是一个合理的猜想值。
    return Number.isInteger(navigator.hardwareConcurrency) ? 
            navigator.hardwareConcurrency : 
            defaultLevel;
}

console.log('concurrency level', getConcurrency());
//→concurrency level 8

因为并不是全部浏览器都实现了navigator.hardwareConcurrency属性,所以咱们必须考虑到这一点。若是咱们不知道确切的硬件并发级别数,咱们必须作下猜想。在这里,咱们认为4是咱们可能遇到的最多见的CPU核心数。因为这是一个默认参数值,所以它做用于两点:调用者的特殊状况处理和简单的全局更改。

还有其余技术试图经过生成worker线程并对返回数据的速率进行采样来测量并发级别数。这是一种有趣的技术,
但因为涉及的开销和通常不肯定性,所以不适合生产级应用。换句话说,使用覆盖咱们大多数用户系统的静态值
就足够了。

建立任务和分配工做

一旦咱们肯定一个给定的操做应该并行执行,而且咱们知道要根据并发级别建立多少workers,就能够建立一些任务,并将它们分配给workers。从本质上讲,这意味着将输入数据切分为较小的块,并将这些数据传递给将咱们的任务应用于数据子集的worker。

在前一章中,咱们看到了第一个获取输入数据并将其转化为任务的示例。一旦工做被拆分,咱们就会产生一个新worker,并在任务完成时终止它。像这样建立和终止线程根据咱们正在构建的应用程序类型,这可能不是理想的方法。例如,若是咱们偶尔运行一个能够从并行处理中受益的复杂操做,那么按需生成workers多是有意义的。可是,若是咱们频繁的并行处理,那么在应用程序启动时生成线程可能更有意义,并重用它们来处理不少类型的任务。如下是有多少操做能够为不一样任务共享同一组worker的说明:

image130.gif

这种配置容许操做发送消息到已在运行的worker线程,并获得返回结果。当咱们正在处理他们的时候,这里没有与生成新worker和清理它们相关的开销。目前仍然是问题的和解。咱们将操做拆分为较小的任务,每一个任务都返回本身的结果。然而,该操做被指望返回一个单一的结果。因此当咱们将工做分红更小的任务,咱们还须要一种方法将任务结果合并到一个总体中。

让咱们编写一个通用函数来处理将工做分红任务并将结果整合在一块儿以进行协调的样板方法。当咱们在用它的时候,咱们也让这个函数肯定操做是否应该并行化,或者它是应该在主线程中同步运行。首先,让咱们看一下咱们要针对每一个数据块并行运行的任务自己,由于它是切片的:

//根据提供的参数返回总和的简单函数。
function sum(...numbers) {
    return numbers.reduce((result, item) => result + item);
}

此任务保持咱们的worker代码以及在主线程中运行的应用程序的其余部分分开。缘由是咱们要在如下两个环境中使用此函数:主线程和worker线程。如今,咱们将建立一个能够导入此函数的worker,并将其与在消息中传递给worker的任何数据一块儿使用:

//加载被这个worker执行的通用任务
importScripts('task.js');

if (chunk.length) {
    addEventListener('message', (e) => {

        //若是咱们收到“sum”任务的消息,
        //而后咱们调用咱们的“sum()”任务,
        //并发送带有操做ID的结果。
        if(e.data.task === 'sum') {
            postMessage({
                id: e.data.id,
                value: sum(...e.data.chunk)
            });
        }
    });
}

在本章的前面,咱们实现了两个工具函数。所述isConcurrent()函数肯定运行的操做是否做为一组较小的并行任务。另外一个函数getConcurrency()肯定咱们应该运行的并发级别数。咱们将在这里使用这两个函数,并将介绍两个新的工具函数。事实上,这些是将在后面帮助使用咱们的生成器。咱们来看看这个:

//今生成器建立一系列的workers来匹配系统的并发级别。
//而后,做为调用者遍历生成器,即下一个worker是
//yield的,直到最后结束。而后咱们再从新开始。
//这就像一个循环上用于选择workers来发送消息。
function* genWorkers() {
    var concurrency = getConcurrency();
    var workers = new Array(concurrency);
    var index = 0;

    //建立workers,将每一个存储在“workers”数组中。
    for (let i = 0; i < concurrency; i++) {
        workers[i] = new Worker('worker.js');

        //当咱们从worker那里获得一个结果时,
        //咱们经过ID将它放在适当的响应中 
        workers[i].addEventListener('message', (e) => {
            var result = results[e.data.id];
            
            result.values.push(e.data.value);

            //若是咱们收到了预期数量的响应,
            //咱们能够调用该操做回调,
            //将响应做为参数传递。
            //咱们也能够删除响应,
            //由于咱们如今是在处理它。
            if (result.values.length === result.size) {
                result.done(...result.values);
                delete results[e.data.id];
            }
        });
    }

    //只要他们须要,就继续生成workers。
    while (true) {
        yield workers[index] ? 
        workers[index++] : 
        workers[index = 0];
    }
}

//建立全局“worker”生成器。
var workers = genWorkers();

//这将生成惟一ID。咱们须要它们
//将Web worker执行的任务映射到
//更大的建立它们的操做上。
function* genID() {
    var id = 0;
    while (true) {
        yield id++;
    }
}

//建立全局“id”生成器。
var id = genID();

伴随着这两个生成器的位置 - workers和id - 咱们如今就已经能够实现咱们的parallel()高阶函数。咱们的想法是将一个函数做为输入以及一些其余参数,这些参数容许咱们调整并行的行为并返回一个能够在整个应用程序中正常调用的新函数。咱们如今来看看这个函数:

//构建一个在调用时运行给定任务的函数
//在worker中将数据拆分红块。
function parallel(expensive, taskName, taskFunc, doneFunc) {

    //返回的函数将数据做为参数处理,
    //以及块大小,具备默认值。
    return function(data, size = 250) {

        //若是数据不够大,函数也并不复杂,
        //那么只需在主线程中运行便可。
        if (!isConcurrent(data, expensive)) {
            if (typeof taskFunc === 'function') {
                return taskFunc(data);
            } else {
                throw new Error('missing task function');
            }
        } else {
            //此调用的惟一标识符。
            //用于协调worker结果时。
            var operationID = id.next().value;

            //当咱们将它切成块时,
            //用于跟踪数据的位置。
            var index = 0;
            var chunk;

            //全局“results”对象获得一个包含有关此操做的数据对象。
            //“size”属性表示咱们期待的返回结果数量。
            //“done”属性是全部结果被传递给的回调函数。
            //而且“values”存着来自workers的结果。
            result[operationID] = {
                size: 0,
                done: doneFunc,
                values: []
            };

            while (true) {
                //获取下一个worker。
                let worker = workers.next().value;
                
                //从输入数据中切出一个块。
                chunk = data.slice(index, index + size);
                index += size;

                //若是要处理一个块,咱们能够增长预期结果的大小,
                //并发布一个给worker的消息。
                //若是没有块的话,咱们就完成了。
                if (chunk.length) {
                    results[operationID].size++;
                    
                    worker.postMessage({
                        id: operationID,
                        task: taskName,
                        chunk: chunk
                    });
                } else {
                    break;
                }
            }
        }
    };
}

//建立一个要处理的数组,使用整数填充。
var array = new Array(2000).fill(null).map((v, i) => i);

//建立一个“sumConcurrent()”函数,
//在调用时,将处理worker中的输入数据。
var sumConcurrent = parallel(true, 'sum', sum,
    function(...results) {
        console.log('results', results.reduce((r, v) => r + v));
    });

sumConcurrent(array);

如今咱们可使用parallel()函数来构建在整个应用程序中调用的并发函数。例如,当咱们必须计算大量输入的总和时,就可使用sumConcurrent()函数。惟一不一样的是输入数据。

这里一个明显的限制是咱们只有一个回调函数,咱们能够在并行化函数完成时指定。
并且,这里有不少标记要作 - 用ID来协调任务与他们的操做有些痛苦; 这感受好像咱们正在实现promise。
这是由于这基本上就是咱们在这里所作的。下一章将详细介绍如何将promise与worker相结合,以免混乱的抽象,
例如咱们刚刚实现的抽象。

候选的问题

在上一节中,你学习了如何建立一个通用函数,该函数将在运行中决定如何使用worker划分和实施,或者在主线程中简单地调用函数是否更有利。既然咱们已经有了通用的并行机制,咱们能够解决哪些问题?在本节中,咱们将介绍从稳固的并发体系结构中受益的最典型的并发方案。

使人尴尬的并行

如何将较大的任务分解为较小的任务时,很明显就是个使人尴尬的并行问题。这些较小的任务不依赖于彼此,这使得开始执行输入并生成输出而不依赖于其余workers状态的任务变得更加容易。这又回到了函数式编程,以及引用透明性和没有反作用的方法。

这些类型的问题是咱们想要经过并发解决的 - 至少首先,在咱们的应用首次实施时是困难的。就并发问题而言,这些都是悬而未决的结果,它们应该很容易解决而不会冒提供功能能力的风险。

咱们在上一节中实现的最后一个示例是一个使人尴尬的并行问题,咱们只须要每一个子任务来添加输入值并返回它们。当集合很大且非结构化时,全局搜索是另外一个例子,咱们不多花费工做来分红较小的任务并将它们合并出结果。搜索大文本输入是一个相似的例子。mapping和reducing是另外一个须要工做相对较少的并行例子。

搜索集合

一些集合排过序。能够有效地搜索这些集合,由于二进制搜索算法可以简单地基于数据被排序的前提来避免大部分的数据查找。然而,有时咱们使用的是非结构化或未排序的集合。在有些状况下,时间复杂度多是O(n),由于须要检查集合中的每一项,不能作出任何假设。

大量文本是非结构化集合的一个典型的例子。若是咱们要在这个文本中搜索一个子字符串,那么就没有办法避免根据咱们已经查找过的内容搜索文本的一部分 - 须要覆盖整个搜索空间。咱们还须要计算大量文本中子字符串出现次数。这是一个使人尴尬的并行问题。让咱们编写一些代码来计算字符串输入中子字符串出现次数。咱们将复用在上一节中建立的并行工具函数,特别是parallel()函数。这是咱们将要使用的任务:

//统计在“collection”中“item”出现的次数
function count(collection, item) {
    var index = 0,
        occurrences = 0;
        
    while (true) {

        //找到第一个索引。
        index = collection.indexOf(item, index);

        //若是咱们找到了,就增长计数,
        //而后增长下一个的起始索引。
        //若是找不到,就退出循环。
        if (index > -1) {
            occurrences += 1;
            index += 1;
        } else {
            break;
        }
    }

    //返回找到的次数。
    return occurrences;
}

如今让咱们建立一个文本块供咱们搜索,并使用并行函数来搜索它:

//咱们须要查找的非结构化文本。
var string =`Lorem ipsum dolor sit amet,mei zril aperiam sanctus id,duo wisi aeque 
molestiae ex。Utinam pertinacia ne nam,eu sed cibo senserit。Te eius timeam docendi quo,
vel aeque prompta philosophia id,necut nibh accusamus vituperata。Id fuisset qualisque
cotidieque sed,eu verterem recusabo eam,te agam legimus interpretaris nam。EOS 
graeco vivendo et,at vis simul primis`;

//使用咱们的“parallel()”工具函数构造一个新函数 - “stringCount()”。
//经过迭代worker计数结果来实现记录字符串的数量。
var stringCount = parallel(true, 'count', count,
    function(...results) {
        console.log('string', results.reduce((r, v) => r + v));
    });

//开始子字符串计数操做。
stringCount(string, 20, 'en');

在这里,咱们将输入字符串拆分为20个字符块,而且搜索输入值en。最后找到3个结果。让咱们看看是否可以使用这项任务,随着咱们并行worker工具和统计出现的次数在一个数组中。

//建立一个介于1和5之间的10,000个整数的数组。
var array = new Array(10000).fill(null).map(() => {
    return Math.floor(Math.random() * (5 - 1)) + 1;
});

//建立一个使用“count”任务的并行函数,
//计算在数组中出现的次数。
var arrayCount = parallel(true, 'count', count, function(...results) {
    console.log('array', results.reduce((r, v) => r + v));
});

//咱们查找数字2 - 可能会有不少。
arrayCount(array, 1000, 2);

因为咱们使用随机整数生成这个10,000个元素的数组,所以每次运行时输出都会有所不一样。可是,咱们的并行worker工具的优势是咱们可以以更大的块调用arrayCount()。

您可能已经注意到咱们正在过滤输入,而不是在其中找到特定项。这是一个使人尴尬的并行
问题的例子,而不是使用并发解决的问题。咱们以前的过滤代码中的worker节点不须要彼此通讯。
若是咱们有几个worker节点都寻找某一个项,咱们将不可避免地面临提早终止的状况。

但要处理提早终止,咱们须要worker以某种方式相互通讯。这不必定是坏事,只是更多的共享状态和更多的
并发复杂性。这样的结果在并发编程中变得相关 - 咱们是否能够在其余地方进行优化以免某些并发性挑战呢?

Mapping和Reducing

JavaScript中的Array原生语法已经有了map()方法。咱们如今知道,有两个关键因素会影响给定输入数据集运行给定操做的可伸缩性和性能。它是数据的大小乘以应用于此数据中每一个项上的任务复杂度。若是咱们将大量数据放到一个数组,而后使用复杂的代码处理每一个数组项,这些约束可能会致使咱们的应用程序出现问题。

让咱们看看用于过去几个代码示例的方法是否能够帮助咱们将一个数组映射到另外一个数组,而没必要担忧在单个CPU上运行的原生Array.map()方法 - 一个潜在的瓶颈。咱们还将解决迭代大数据集合的问题。这与mapping相似,只有咱们使用Array.reduce()方法。如下是任务函数:

//一个“plucks”给定的基本映射
//从数组中每一个项的“prop”。
function pluck(array, prop) {
    return array.map((x) => x[prop]);
}

//返回迭代数组项总和的结果。
function sum(array) {
    return array.reduce((r, v) => r + v);
}

如今咱们有了能够从任何地方调用的泛型函数 - 主线程或worker线程。咱们不会再次查看worker代码,由于它使用与此以前的示例相同的模式。它肯定要调用的任务,并格式化处理发送回主线程的响应。让咱们继续使用parallel()工具函数来建立一个并发map函数和一个并发reduce函数:

//建立一个包含75,000个对象的数组。
var array = new Array(75000).fill(null).map((v, i) => {
    return {
        id: i,
        enabled: true
    };
});

//建立一个并发版本的“sum()”函数
var sumConcurrent = parallel(true, 'sum', sum,
    function(...results) {
        console.log('total', sum(results));
    });

//建立一个并发版本的“pluck()”函数。
//当并行任务完成时,将结果传递给“sumConcurrent()”。
var pluckConcurrent = parallel(true, 'pluck', pluck,
    function(...results) {
        sumConcurrent([].concat(...results));
    });

//启动并发pluck操做。
pluckConcurrent(array, 1000, 'id');

在这里,咱们建立了75个任务分发给workers(75000/1000)。根据咱们的并发级别数,这意味着咱们将同时从数组项中提取多个属性值。reduce任务以相同方式工做; 咱们并发的计算映射的集合。咱们仍然须要在sumConcurrent()回调进行求和,但它不多。

执行并发迭代任务时咱们须要谨慎。Mapping是简单的,由于咱们建立的是一个原始数组的大小和排序
方面的克隆。这是不一样的值。Reducing多是依赖于该结果做为它目前的立场。不一样的是,由于每一个数组
项经过迭代函数,它的结果,由于它被建立,能够改变的最终结果输出。
并发使得这个变得困难,但在此以前的例子,该问题是尴尬的并行 - 不是全部的迭代工做都是。

保持DOM响应

到本章这里,重点已经被数据中心化了 - 经过使用web worker来对获取输入和转换进行分割和控制。这不是worker线程的惟一用途; 咱们也可使用它们来保持DOM对用户的响应。

在本节中,咱们将介绍一个在Linux内核开发中使用的概念,将事件分红多个阶段以得到最佳性能。而后,咱们将解决DOM与咱们的worker之间进行通讯的挑战,反之亦然。

Bottom halves

Linux内核具备top-halves和bottom-halves的概念。这个想法被硬件中断请求机制使用。问题是硬件中断一直在发生,而这是内核的工做,以确保它们都是及时捕获和处理的。为了有效地作到这一点,内核将处理硬件中断的任务分为两半 - top-halves和bottom-halves。

top-halves的工做是响应外部触发,例如鼠标点击或击键。可是,top-halves受到严格限制,这是故意的。处理硬件中断请求的top-halves只能安排实际工做 - 全部其余系统组件的调用 - 之后再进行。后面的工做是在bottom-halves完成的。这种方法的反作用是中断在低级别迅速处理,在优先级事件方面容许更大的灵活性。

什么内核开发工做必须用到JavaScript和并发?好了,它变成了咱们能够借用这些方法,而且咱们的“bottom-half”的工做委托给一个worker。咱们的事件处理代码响应DOM事件实际上什么也不作,除了传递消息给worker。这确保了在主线程中只作它绝对须要作而没有任何额外的处理。这意味着,若是Web worker返回的结果要展现,它能够立刻这么作。请记住,在主线程包括渲染引擎,它阻止咱们运行的代码,反之亦然。这是处理外部触发的top-halves和bottom-halves的示图:

image138.gif

JavaScript是运行即完成的,咱们如今已经很清楚了。这意味着在top-halves花费的时间越少,就越须要经过更新屏幕来响应用户。与此同时,JavaScript也在咱们的bottom-halves运行的Web worker中运行完成。这意味着一样的限制适用于此; 若是咱们的worker获得在短期内发送给它的100条消息,他们将以先入先出(FIFO)的顺序进行处理。

不一样之处在于,因为此代码未在主线程中运行,所以UI组件在用户与其交互时仍会响应。对于高要求的产品来讲,这是一个相当重要的因素,值得花时间研究top-halves和bottom-halves。咱们如今只须要弄清楚实现。

转换DOM操做

若是咱们将Web worker视为应用程序的bottom-halves,那么咱们须要一种操做DOM的方法,同时在top-halves花费尽量少的时间。也就是说,由worker决定在DOM树中须要更改什么,而后通知主线程。接着,主线程必须作的就是在发布的消息和所需的DOM API调用之间进行转换。在接收这些消息和将控制权移交给DOM之间没有数据操做; 毫秒在主线程中是宝贵的。

让咱们看看这是多么容易实现。咱们将从worker实现开始,该实如今想要更新UI中的内容时将DOM操做消息发送到主线程:

//保持跟踪咱们渲染的列表项数量。
var counter = 0;

//主线程发送消息通知全部必要的DOM操做数据内容。
function appendChild(settings) {
    postMessage(settings);

    //咱们已经渲染了全部项,咱们已经完成了。
    if (counter === 3) {
        return;
    }

    //调度下一个“appendChild()”消息。
    setTimeout(() => {
        appendChild({
            action: 'appendChild',
            node: 'ul',
            type: 'li',
            content: `Item ${++counter}`
        });
    }, 1000);
}

//调度第一个“appendChild()”消息。
//这包括简单渲染到主线程中的DOM所需的数据。
setTimeout(() => {
    appendChild({
        action: 'appendChild',
        node: 'ul',
        type: 'li',
        content: `Item ${++counter}`
    });
}, 1000);

这项工做将三条消息发回主线程。他们使用setTimeout()进行定时,所以咱们能够指望的看到每秒渲染一个新的列表项,直到显示全部三个。如今,让咱们看一下主线程代码如何使用这些消息:

//启动worker(bottom-halves)。
var worker = new Worker('worker.js');

worker.addEventListener('message', (e) => {

    //若是咱们收到“appendChild”动做的消息,
    //而后咱们建立新元素并将其附加到
    //适当的父级 - 在消息数据中找到全部这些信息。
    //这个处理程序绝对是除了与DOM交互以外什么都没有
    if (e.data.action ==='appendChild') {
        let child = document.createElement(e.data.type);
        child.textContent = e.data.content;
    };

    document.querySelector(e.data.node).appendChild(child);
});

正如咱们所看到的,咱们有不多机会给top-halves(主线程)带来瓶颈,致使用户交互卡住。这很简单 - 这里执行的惟一代码是DOM操做代码。这大大增长了快速完成的可能性,容许屏幕为用户明显更新。

另外一个方向是什么,将外部事件放入系统而不干扰主线程?咱们接下来会看看这个。

转换DOM事件

一旦触发了DOM事件,咱们就但愿将控制权移交给咱们的Web worker。经过这种方式,主线程能够继续运行,好像没有其余事情发生 - 你们都很高兴。不幸的是,还有一点。例如,咱们不能简单地监听每一个元素上的每个事件,将每一个元素转发给worker,若是它不断响应事件,那么它将破坏不在主线程中运行代码的目的。

相反,咱们只想监听worker关心的DOM事件。这与咱们实现任何其余Web应用程序的方式没有什么不一样;咱们的组件会监听他们关心的事件。要使用workers实现这一点,咱们须要一种机制来告诉主线程在特定元素上设置DOM事件监听器。而后,worker能够简单地监听传入的DOM事件并作出相应的响应。咱们先来看一下worker的实现:

//当“input”元素触发“input”事件时,
//告诉主线程咱们想要收到通知。
postMessage({
    action: 'addEventListener',
    selector: 'input',
    event: 'input'
});

//当“button”元素触发“click”事件时,
//告诉主线程咱们想要收到通知。
postMessage({
    action: 'addEventListener',
    selector: 'button',
    event: 'click'
});

//一个DOM事件被触发了。
addEventListener('message', (e) => {
    var data = e.data;

    //根据具体状况以不一样方式记录
    //事件是由触发的。
    if(data.selector === 'input') {
        console.log('worker', `typed "${data.value}"`);
    } else if (data.selector === 'button') {
        console.log('worker', 'clicked');
    }
});

该worker要求有权访问DOM的主线程设置两个事件侦听器。而后,它为DOM事件设置本身的事件侦听器,最终进入worker。让咱们看看负责设置处理程序和向worker转发事件的DOM代码:

//启动worker...
var worker = new Worker('worker.js');

//当咱们收到消息时,这意味着worker想要
//监听DOM事件,因此咱们必须设置代理。
worker.addEventListener('message', (msg) => {
    var data = msg.data;
    if (data.action === 'addEventListener') {

        //找到worker正在寻找的节点。
        var nodes = document.querySelectorAll(data.selector);

        //为给定的“event”添加一个新的事件处理程序
        //咱们刚刚找到的每一个节点。当那个事件发生时触发,
        //咱们只是发回一条消息返回到包含相关事件数据的worker。
        for (let node of nodes) {
            node.addEventListener(data.event, (e) => {
                worker.postMessage({
                    selector: data.selector,
                    value: e.target.value
                });
            })
        };
    }
});

为简洁起见,只有几个事件属性被发送回worker。因为Web worker消息中的序列化限制,咱们没法发送事件
对象。实际上,可使用相同的模式,但咱们可能会为此添加更多事件属性,例如clientX和clientY。

小结

前一章向咱们介绍了Web workers,重点介绍了这些组件的强大功能。本章改变了方向,重点关注并发的“why”方面。咱们经过查看函数式编程的某些方面以及它们如何适合JavaScript中的并发编程来解决问题。

咱们研究了肯定跨worker同时执行给定操做的可行性所涉及的因素。有时,拆分大型任务并将其做为较小的任务分发给worker须要花费大量开销。咱们实现了一些通用工具函数,帮助咱们实现并发函数,封装一些相关的并发样板代码。

并不是全部问题都很是适合并发解决方案。最好的方法是自上而下地工做,找出使人尴尬的并行问题,由于它们是悬而未决的成果。而后,咱们将此原则应用于许多map-reduce问题。

咱们简要介绍了top-halves和bottom-halves的概念。这是一种策略,可使主线程持续清除待处理的JavaScript代码,以保持用户界面的响应。咱们在忙于思考关于咱们最有可能遇到的并发问题的类型,以及解决它们的最佳方法,咱们的代码复杂性上升了一个档次。下一章是关于将三个并发原则集合在一块儿的方式,它将并发性放在首位,而不会牺牲代码的可读性。

最后补充下书籍章节目录

另外还有讲解两章nodeJs后端并发方面的,和一章项目实战方面的,这里就再也不贴了,有兴趣可转向https://github.com/yzsunlei/javascript_concurrency_translation查看。

相关文章
相关标签/搜索