十、工作器——学习奉献和共享的工作器

在过去的几章中,我们重点讨论了 Node.js,以及如何使用与前端相同的语言编写后端应用。 我们已经看到了各种创建服务器、卸载任务和流媒体的方法。 在本部分中,我们将重点关注浏览器的卸载任务方面。

最后,正如我们在 Node.js 中看到的,我们需要将一些计算密集型任务从主线程中卸载到一个单独的线程或进程中,以确保我们的应用保持响应。 虽然服务器不响应的影响可能是相当刺耳的,但我们的用户界面不工作的影响是完全令人讨厌的大多数用户。 因此,我们有了 Worker API。

在本章中,我们将特别关注两种类型的工作者,奉献型和共享型。 总的来说,我们将做以下工作:

  • 学习通过 worker API 将繁重的处理任务卸给 worker 线程。
  • 学习如何通过postMessageBroadcastChannelapi 与工作器交谈。
  • 讨论ArrayBufferTransferrable属性,这样我们就可以在工作人员和主线程之间快速移动数据。
  • 看看SharedWorker和 Atomics API,看看我们如何在应用的多个选项卡之间共享数据。
  • 利用前面几节的知识,看看共享缓存的部分实现。

技术要求

完成本章需要完成的项目如下:

把工作交给一个敬业的工作器

Workers 让我们能够将长时间运行、计算密集型的任务转移到后台。 我们可以将该任务卸载到后台线程,而不必确保我们的事件循环中没有充满某种类型的繁重任务。

在其他语言/环境中,这可能看起来像以下(这只是伪代码,并没有真正绑定到任何语言):

Thread::runAsync((data) -> {
   for(d : data) { //do some computation }
});

虽然这在这些环境中工作得很好,但我们必须开始考虑诸如死锁、僵尸线程、写后读等问题。 所有这些都很难理解,通常是遇到的最困难的错误。 JavaScript 并没有给我们使用类似前面内容的能力,而是给了我们工作者,这给了我们另一个工作环境,在那里我们不会面临同样的问题。

For those that are interested, a book on operating systems or Unix programming can help shed light on the preceding issues. These topics are out of the scope of this book, but they are quite interesting and there are even languages that are trying to address these issues by building the workarounds into the languages. Some examples of these are Go (https://golang.org/), which uses a technique of message passing, and Rust (https://www.rust-lang.org/), which utilizes the concept of borrow checking and such to minimize these issues.

以一个正在后台完成的工作的例子开始,我们将生成一个Worker,并让它计算超过 100 万个数字的总和。 要做到这一点:

  1. 我们添加以下script部分到我们的 HTML 文件:
<script type="text/javascript">
    const worker = new Worker('worker.js');
    console.log('this is on the main thread');
</script>
  1. 我们为我们的Worker创建一个 JavaScript 文件,并添加以下内容:
let num = 0;
for(let i = 0; i < 1000000; i++) {
    num += i;
}

如果我们启动 Chrome,我们应该看到打印出两条消息——一条是说它在主线程上运行,另一条是值 499999500000。 我们还应该看到,一个是由 HTML 文件记录的,另一个是由 worker 记录的。 我们刚刚生成了一个 worker,并让它为我们做一些工作!

Remember that if we want to run JavaScript files from our filesystem and not a server, we will need to close out of all instances of Chrome and then relaunch it from the command line using chrome.exe –-allow-file-access-from-files. This will give us access to launch our external JavaScript files from the filesystem and not need a server.

让我们继续做一些用户可能想做的更复杂的事情。 一个有趣的数学问题是求一个数的质因数分解。 这意味着,当给定一个数字时,我们将试图找到组成这个数字的所有质数(只能被 1 和它本身整除的数)。 一个例子就是质因数分解 12,也就是 2 2 3。

This problem leads to the interesting field of cryptography and how public/private keys work. The basic understanding is that, given two relatively large prime numbers, multiplying them is easy, but finding those two numbers from that product of them is infeasible due to time constraints. 

回到手头的任务,我们要做的是在用户在输入框中输入一个数字后生成一个worker。 我们将计算这个数字并将其记录到控制台。 所以让我们开始:

  1. 我们添加一个输入到我们的 HTML 文件,并改变代码,以生成一个worker的变化事件的输入框:
<input id="in" type="number" />
<script type="text/javascript">
document.querySelector("#in").addEventListener('change', (ev) => {
    const worker = new Worker('worker.js', {name : 
     ev.target.value});
});
</script>
  1. 接下来,我们将在worker中获取我们的名字并使用它作为输入。 从那里,我们将运行在https://www.geeksforgeeks.org/print-all-prime-factors-of-a-given-number/上找到的质因数分解算法,但转换为 JavaScript。 一旦我们完成,我们将关闭worker:
let numForPrimes = parseInt(self.name);
const primes = [];
console.log('we are looking for the prime factorization of: ', numForPrimes);
while( numForPrimes % 2 === 0 ) {
    primes.push(2);
    numForPrimes /= 2;
}
for(let i = 3; i <= Math.sqrt(numForPrimes); i+=2) {
    while( numForPrimes % i === 0 ) {
        primes.push(i);
        numForPrimes /= i;
    }
}
if( numForPrimes > 2 ) {
    primes.push(numForPrimes);
}
console.log('prime factorization is: ', primes.join(" "));
self.close();

如果我们现在在浏览器中运行这个应用,我们将看到在每次输入之后,我们都会在控制台中得到控制台日志消息。 注意,数字 1 没有因子。 这有一个数学上的原因,但请注意,数字 1 没有质因数分解。

我们可以在大量输入的情况下运行这个程序,但如果我们输入一个相对较大的数字,比如123,456,789,它仍然会在后台计算它,就像我们在主线程上做事情一样。 现在,我们正在通过 worker 的名字将数据传递给 worker。 必须有一种方法在工作线程和主线程之间传递数据。 这就是postMessageBroadcastChannelapi 发挥作用的地方!

在应用中移动数据

正如我们在 Node.js 的worker_thread模块中看到的,有一种方法可以与我们的工作人员进行通信。 这是通过postMessage系统。 如果我们看一下方法签名,就会发现它需要的消息可以是任何 JavaScript 对象,甚至是那些具有循环引用的对象。 我们还看到另一个参数叫做转移。 我们将深入讨论这个问题,但正如它的名字所暗示的那样,它允许我们实际传输数据,而不是将数据复制给 worker。 这是一种更快的数据传输机制,但在使用它时,我们将在后面讨论一些注意事项。

让我们以我们一直在构建和响应前端发送的消息为例:

  1. 我们将交换出创建一个新的worker,每次发生更改事件,并立即创建一个。 然后,在一个更改事件上,我们将数据通过postMessage:发送到worker
const dedicated_worker = new Worker('worker.js', {name : 'heavy lifter'});
document.querySelector("#in").addEventListener('change', (ev) => {
    dedicated_worker.postMessage(parseInt(ev.target.value));
});
  1. 如果我们现在尝试这个例子,我们将不会从主线程收到任何东西。 我们必须响应工作器的全局描述符self上的onmessage事件。 让我们继续,并添加我们的处理程序,并删除self.close()方法,因为我们想保持这个周围:
function calculatePrimes(val) {
    let numForPrimes = val;
    const primes = [];
    while( numForPrimes % 2 === 0 ) {
        primes.push(2);
        numForPrimes /= 2;
    }
    for(let i = 3; i <= Math.sqrt(numForPrimes); i+=2) {
        while( numForPrimes % i === 0 ) {
            primes.push(i);
            numForPrimes /= i;
        }
    }
    if( numForPrimes > 2 ) {
        primes.push(numForPrimes);
    }
    return primes;
}
self.onmessage = function(ev) {
    console.log('our primes are: ', calculatePrimes(ev.data).join(' '));
}

正如我们从这个例子中看到的,我们已经将质数的计算移到了一个单独的函数中,当我们得到一条消息时,我们获取数据并将其传递给calculatePrimes方法。 现在,我们正在使用消息传递系统。 让我们继续向示例添加另一个特性。 而不是打印到主机上,让我们根据用户的输入提供一些反馈:

  1. 我们将在输入的下面添加一个段落标签来保存我们的答案:
<p>The primes for the number is: <span id="answer"></span></p>
<script type="text/javascript">
    const answer = document.querySelector('#answer');
    // previous code here
</script>
  1. 现在,我们将添加workeronmessage处理程序,就像我们在worker中所做的那样,以侦听来自worker的事件。 当我们得到一些数据时,我们将用返回的值填充答案:
dedicated_worker.onmessage = function(ev) {
    answer.innerText = ev.data;
}
  1. 最后,我们将改变我们的worker代码,使用postMessage方法发送数据,将质数发送回主线程:
self.onmessage = function(ev) {
    postMessage(calculatePrimes(ev.data).join(' '));
}

这也显示了我们不需要添加self块来调用全局作用域上的方法。 就像 window 是主线程的全局作用域一样,self是工作线程的全局作用域。

通过这个例子,我们已经探索了postMessage方法,并看到了如何在一个 worker 之间向派生它的线程发送数据,但是如果我们有多个想要通信的选项卡呢? 如果我们有多个想要向其发送消息的工作人员呢?

处理这个问题的一种方法是跟踪所有的 worker,并通过它们进行循环,将数据像下面这样发送出去:

const workers = [];
for(let i = 0; i < 5; i++) {
    const worker = new Worker('test.js', {name : `worker${i}`});
    workers.push(worker);
}
document.querySelector("#in").addEventListener('change', (ev) => {
    for(let i = 0; i < workers.length; i++) {
        workers[i].postMessage(ev.target.value);
    }
});

test.js文件中,我们只记录消息的控制台日志,并说明我们正在引用的工作人员的名字。 这很容易失控,因为我们需要跟踪哪些工作器还活着,哪些工作器已经被删除了。 另一种处理方法是在通道上广播数据。 幸运的是,我们有一个 API,叫做BroadcastChannelAPI。

MDN 网站上的文档状态(https://developer.mozilla.org/en-US/docs/Web/API/Broadcast_Channel_API),我们需要做的就是创建一个BroadcastChannel对象通过一个参数到它的构造函数,通道的名称。 谁先调用它,谁就创建了这个频道,然后任何人都可以监听它。 发送和接收数据就像我们的postMessageonmessage示例一样简单。 下面是我们之前的测试接口代码,不需要跟踪所有的工作人员,只需要广播出数据:

const channel = new BroadcastChannel('workers');
document.querySelector("#in").addEventListener('change', (ev) => {
    channel.postMessage(ev.target.value);
});

然后,在我们的workers中,所有我们需要做的是监听BroadcastChannel而不是监听我们自己的消息处理器:

const channel = new BroadcastChannel('workers');
channel.onmessage = function(ev) {
    console.log(ev.data, 'was received by', name);
}

现在,我们简化了在具有相同主机的多个工作人员甚至多个选项卡之间发送和接收消息的过程。 这个系统的伟大之处在于,我们可以让一些员工根据某些标准监听一个频道,而其他人则监听另一个频道。 这样我们就有了一个全局通道,可以发送任何一个用户都可以响应的命令。 让我们对我们的初始程序做一个简单的调整。 我们将有四个工作人员,而不是将数据发送给一个专门的工作人员; 其中两个处理偶数,另外两个处理奇数:

  1. 我们更新了主代码,让四名工作器下水。 我们将根据数字是否为偶数来命名:
for(let i = 0; i < 4; i++) {
    const worker = new Worker('worker.js', 
        {name : `worker ${i % 2 === 0 ? 'even' : 'odd'}`}
    );
}
  1. 我们改变输入发生的事情,将偶数发送到偶数通道,将奇数发送到奇数通道:
document.querySelector("#in").addEventListener('change', (ev) => {
    const value = parseInt(ev.target.value);
    if( value % 2 === 0 ) {
        even_channel.postMessage(value);
    } else {
        odd_channel.postMessage(value);
    }
});
  1. 我们创建了三个通道:一个用于偶数,一个用于奇数,一个用于全局发送给所有员工:
const even_channel = new BroadcastChannel('even');
const odd_channel = new BroadcastChannel('odd');
const global = new BroadcastChannel('global');
  1. 我们添加了一个新的按钮来杀死所有的工作器,并将其连接到全球频道上播放:
<button id="quit">Stop Workers</button>
<script type="text/javascript">
document.querySelector('#quit').addEventListener('click', (ev) => {
     global.postMessage('quit');
});
</script>
  1. 我们改变我们的 worker 来处理基于其名称的消息:
const mainChannelName = name.includes("odd") ? "odd" : "even";
const mainChannel = new BroadcastChannel(mainChannelName);
  1. 当我们在其中一个渠道上收到消息时,我们的反应就像以前一样:
mainChannel.onmessage = function(ev) {
    if( typeof ev.data === 'number' )
        this.postMessage(calculatePrimes(ev.data));
}
  1. 如果我们在全局通道上收到消息,我们检查它是否是quit消息。 如果是,那么杀死工作器:
const globalChannel = new BroadcastChannel('global');
globalChannel.onmessage = function(ev) {
    if( ev.data === 'quit' ) {
        close();
    }
}
  1. 现在,回到主线程,我们将监听偶数和奇数通道的数据。 当有数据时,我们几乎像以前一样处理它:
even_channel.onmessage = function(ev) {
    if( typeof ev.data === 'object' ) {
        answer.innerText = ev.data.join(' ');
    }
}
odd_channel.onmessage= function(ev) {
    if( typeof ev.data === 'object' ) {
        answer.innerText = ev.data.join(' ');
    }
}

需要注意的一件事是我们的 worker 和主线程如何处理来自奇偶通道的数据。 因为我们是在广播,所以我们需要确保这是我们想要的数据。 在 worker 的情况下,我们只需要数字,而在主线程的情况下,我们只需要数组。

The BroadcastChannel API only works with the same origin. This means that we cannot communicate between two different sites, only with pages under the domain.

虽然这是一个过于复杂的BroadcastChannel机制的例子,但它应该展示了我们如何能够轻松地将工作人员与它们的父级解耦,并使它们易于发送数据,而无需循环遍历它们。 现在,我们将返回到postMessage方法并查看transferrable属性以及它对于发送和接收数据的意义。

在浏览器中发送二进制数据

虽然消息传递是发送数据的一种很好的方式,但在跨通道发送非常大的对象时,存在一些问题。 例如,假设我们有一个专门的 worker,它代表我们发出请求,并从缓存中向 worker 添加一些数据。 它可能有数千条记录。 虽然 worker 已经占用了相当多的内存,但当我们使用postMessage时,我们将看到两件事:

  • 移动物体所需的时间会很长
  • 我们的记忆会急剧增加

原因在于浏览器用来发送数据的结构化克隆算法。 本质上,它将序列化和反序列化我们的对象,而不是仅仅通过通道移动数据,本质上创建它的多个副本。 最重要的是,我们不知道垃圾收集器什么时候运行,因为我们知道它是非确定性的。

我们可以在浏览器中看到复制过程。 如果我们创造一个叫做largeObject.js的工作器并移动一个巨大的有效载荷,我们可以利用Date.now()方法测量它所花费的时间。 在此基础上,我们可以利用开发者工具中的记录系统,正如我们在第 1 章Web 上的高性能工具中所了解到的,来分析我们使用的内存数量。 让我们设置这个测试用例:

  1. 创建一个新的 worker 并将其分配为一个大对象。 在本例中,我们将使用一个包含 100000 个元素的数组来存储对象:
const dataToSend = new Array(100000);
const baseObj = {prop1 : 1, prop2 : 'one'};
for(let i = 0; i < dataToSend.length; i++) {
    dataToSend[i] = Object.assign({}, baseObj);
    dataToSend[i].prop1 = i;
    dataToSend[i].prop2 = `Data for ${i}`;
}
console.log('send at', Date.now());
postMessage(dataToSend);
  1. 现在,我们向 HTML 文件中添加一些代码,以启动这个 worker 并侦听消息。 我们将标记消息到达时,然后我们将分析代码,以查看内存的增加:
const largeWorker = new Worker('largeObject.js');
largeWorker.onmessage = function(ev) {
    console.log('the time is', Date.now());
    const obj = ev.data;
}

如果我们现在将其加载到浏览器并分析代码,我们应该会看到类似于下面的结果。 该消息花费的时间在 800ms 到 1.7 s 之间,堆大小在 80mb 到 100mb 之间。虽然这种情况绝对超出了大多数人的范围,但它显示了这种类型的消息传递的一些问题。

解决这个问题的方法是使用postMessage方法的可转移部分。 这允许我们通过通道发送一个二进制数据类型,而不是复制它,通道实际上只是传输对象。 这意味着发送方不再有访问权限,但接收方有。 考虑这个问题的一种方法是,发送方将数据放在一个保存位置,并告诉接收方它所在的位置。 此时,发送方不能再访问它。 接收器接收所有的数据,并注意到它有一个查找数据的位置。 它到达这个位置并抓取它,从而实现数据传输机制。

让我们继续编写一个简单的示例。 让我们用一串数据填充我们的 heavy worker,在这个例子中,是一个从 1 到 1,000,000 的数字列表:

  1. 我们创建一个包含 1,000,000 个元素的Int32Array。 然后把 1 到 1,000,000 的所有数相加:
const viewOfData = new Int32Array(1000000);
for(let i = 1; i <= viewOfData.length; i++) {
    viewOfData[i-1] = i;
}
  1. 然后,我们将利用postMessage的可转移部分发送该数据。 注意,我们必须得到潜在的ArrayBuffer。 我们将很快讨论这个问题:
postMessage(viewOfData, [viewOfData.buffer]);
  1. 我们将在主线程上接收数据并写出该数据的长度:
const obj = ev.data;
console.log('data length', obj.byteLength);

我们会注意到传输这一大块数据所花费的时间几乎是不引人注意的。 这是因为前面的理论,它只是装箱数据,并把它放在一边为接收。

需要为类型化数组和ArrayBuffers留出一个边。 ArrayBuffers可以被认为是 Node.js 中的缓冲区。 它们是存储数据的最低形式,直接保存某些数据的字节。 但是,要真正利用它们,我们需要把放在之上。 这意味着我们需要赋予这句话的意义。 在我们的例子中,我们说它存储有符号的 32 位整数。 我们可以在ArrayBuffer上放置各种视图,就像我们在 Node.js 中以不同方式解释缓冲区一样。 最好的思考方式是,ArrayBuffer是我们真的不想使用的底层系统,而视图是赋予底层数据意义的系统。

考虑到这一点,如果我们在工作端检查Int32Array的字节长度,我们将看到它是零。 我们不能再访问这些数据了,就像我们说的。 在进入SharedWorkersSharedArrayBuffers之前,为了进一步利用这一特性,我们将修改我们的分解程序,利用这一可转移属性来发送因子:

  1. 我们将使用几乎完全相同的逻辑,除了发送的数组,我们将发送Int32Array:
if( typeof ev.data === 'number' ) {
    const result = calculatePrimes(ev.data);
    const send = new Int32Array(result);
    this.postMessage(result, [result.buffer]);
}
  1. 现在我们将更新接收结束代码来处理正在发送的ArrayBuffers,而不仅仅是一个数组:
if( typeof ev.data === 'object' ) {
    const data = new Int32Array(ev.data);
    answer.innerText = data.join(' ');                  
}

如果我们测试这段代码,我们会看到它的工作原理是一样的,但我们不再跨数据复制,我们只是将它交给主线程,从而使消息传递更快,占用更少的内存。

主要思想是,如果我们只是发送结果或者我们需要尽可能快的速度,我们应该尝试利用可转移系统来发送数据。 如果我们必须在 worker 中使用发送后的数据,或者没有一种简单的方法来发送数据(我们没有序列化技术),我们可以使用正常的postMessage系统。

Just because we can use the transferrable system to reduce memory footprint, it could cause times to increase based on the amount of data transformation we need to apply. If we already have binary data, this is great, but if we have JSON data that needs to be moved, it may be better to just transfer it in that form instead of having to go through many intermediary transformations.

带着这些想法,让我们来看看SharedWorker系统和SharedArrayBuffer系统。 这两个系统,特别是SharedArrayBuffer,在过去都导致了一些问题(我们将在下一节讨论这个问题),但如果我们仔细使用它们,我们将能够利用它们作为良好的消息传递和数据共享机制的能力。

共享数据和员工

虽然大多数时候我们希望在应用的 worker 和 tabs 之间保持界限,但有时我们只希望在每个实例之间共享数据甚至 worker。 在这种情况下,我们可以使用两个系统,SharedWorkerSharedArrayBuffer

SharedWorker就是它听起来的样子,当一个人旋转起来,就像BroadcastChannel,其他人做同样的调用来创建SharedWorker,它将只是连接到已经创建的实例。 让我们这样做:

  1. 我们将为SharedWorkerJavaScript 代码创建一个新文件。 在这里,放一些通用的计算函数,如加减:
const add = function(a, b) {
    return a + b;
}
const mult = function(a, b) {
    return a * b;
}
const divide = function(a, b) {
    return a / b;
}
const remainder = function(a, b) {
    return a % b;
}
  1. 在我们当前的工作器代码中,启动SharedWorker:
const shared = new SharedWorker('shared.js');
shared.port.onmessage = function(ev) {
    console.log('message', ev);
}

我们已经看到了一个问题。 我们的系统声明没有找到SharedWorker。 为了利用SharedWorker,我们必须在窗口启动它。 现在,我们要把开始代码移到主页。

  1. 将开始代码移动到主页,然后将端口传递给一个工作人员:
const shared = new SharedWorker('shared.js');
shared.port.start();
for(let i = 0; i < 4; i++) {
    const worker = new Worker('worker.js', 
        {name : `worker ${i % 2 === 0 ? 'even' : 'odd'}`}
    );
    worker.postMessage(shared.port, [shared.port]);
}

我们现在遇到了另一个问题。 由于我们想要将端口传递给 worker,并且在主窗口中不能访问它,所以我们使用了 transferable system。 但是,由于我们当时只有一个 reference,一旦我们发送给一个 worker,就不能再发送了。 相反,让我们启动一个工作器并关闭我们的BroadcastChannel系统。

  1. 注释掉我们的BroadcastChannels和所有循环代码。 让我们在这个窗口中只启动一个 worker:
const shared = new SharedWorker('shared.js');
shared.port.start();
const worker = new Worker('worker.js');
document.querySelector("#in").addEventListener('change', (ev) => {
    const value = parseInt(ev.target.value);
    worker.postMessage(value);
});
document.querySelector('#quit').addEventListener('click', (ev) => {
    worker.postMesasge('quit');
});
  1. 有了这些变化,我们将不得不简化我们敬业的员工。 我们只会像之前一样在我们的消息通道上响应事件:
let sharedPort = null;
onmessage = function(ev) {
    const data = ev.data;
    if( typeof data === 'string' ) {
        return close();
    }
    if( typeof data === 'number' ) {
        const result = calculatePrimes(data);
        const send = new Int32Array(result);
        return postMessage(send, [send.buffer]);
    }
    // handle the port
    sharedPort = data;
}
  1. 现在我们在一个工作器中有了SharedWorker端口,但是这一切为我们解决了什么问题呢? 现在,我们可以同时打开多个选项卡,并向每个选项卡获取数据。 为了看到这一点,让我们将一个处理器连接到sharedPort:
sharedPort.onmessage = function(ev) {
    console.log('data', ev.data);
}
  1. 最后,一旦连接发生,我们可以更新SharedWorker来响应,如下所示:
onconnect = function(e) {
    let port = e.ports[0];
    console.log('port', port);
    port.onmessage = function(e) {
        port.postMessage('you sent data');
    }
    port.postMessage('you connected');
}

通过这个,我们将看到一个信息回到我们的工作器。 我们现在有了我们的SharedWorker和运行,并直接与我们的DedicatedWorker沟通! 但是,仍然有一个问题:为什么我们没有看到来自我们的SharedWorker的日志? 嗯,我们的SharedWorker生活在一个与DedicatedWorker和主线不同的环境中。 要访问我们的SharedWorker,我们可以去 URLchrome://inspect/#workers,然后定位它。 现在,我们没有给它起任何名字,所以应该叫它untitled,但是当我们点击它下面的inspect选项时,我们现在有了一个 worker 的调试上下文。

我们已经将SharedWorker连接到 DOM 上下文,并且将每个DedicatedWorker连接到那个SharedWorker,但是我们需要能够向每个DedicatedWorker发送消息。 让我们继续并添加以下代码:

  1. 首先,我们需要跟踪所有通过SharedWorker连接到我们的工作人员。 添加以下代码到我们的onconnect监听器的底部:
ports.push(port);
  1. 现在,我们将添加一些 HTML 到我们的文档,这样我们就可以发送addmultiplydividesubtract请求以及两个新的数字输入:
<input id="in1" type="number" />
<input id="in2" type="number" />
<button id="add">Add</button>
<button id="subtract">Subtract</button>
<button id="multiply">Multiply</button>
<button id="divide">Divide</button>
  1. 接下来,我们将这些信息通过DedicatedWorker传递给SharedWorker:
if( typeof data === 'string' ) {
    if( data === 'quit' ) {
        close();
    } else {
        sharedPort.postMessage(data);
    }
}
  1. 最后,我们的SharedWorker将运行相应的操作并将其传递回DedicatedWorkerDedicatedWorker将把数据记录到控制台:
port.onmessage = function(e) {
    const _d = e.data.split(' ');
    const in1 = parseInt(_d[1]);
    const in2 = parseInt(_d[2]);
    switch(_d[0]) {
        case 'add': {
            port.postMessage(add(in1, in2));
            break;
        }
        // other operations removed since they are the same thing
    }
}

有了所有这些,我们现在可以打开应用的多个标签,它们都共享相同的前面的数学系统! 对于这种类型的应用来说,这有点过分了,但当我们需要在应用中执行跨多个窗口或选项卡的复杂操作时,它可能会很有用。 这可能是一些利用 GPU 的东西,我们只想做一次。 让我们继续,以SharedArrayBuffer的概述结束本节。 然而,需要记住的是,SharedWorker是由所有制表符持有的单个线程,而DedicatedWorker是每个制表符/窗口的线程。 虽然共享一个 worker 对于前面解释的一些任务是有益的,但如果多个选项卡同时使用它,它也会减慢其他任务的速度。

SharedArrayBuffer允许我们所有的实例共享相同的内存块。 正如一个可转移对象可以有不同的所有者基于传递内存给另一个工作器,一个SharedArrayBuffer允许不同的上下文共享同一块。 这允许更新跨所有实例传播,并且对某些类型的数据几乎具有即时更新,但它也有许多与之相关的缺陷。

这是我们在其他语言中最接近的SharedMemory。 为了正确地利用SharedArrayBuffer,我们需要利用 Atomics API。 同样,不直接深入 Atomics API 背后的细节,它确保操作以正确的顺序发生,并且确保它们在更新期间不需要任何人重写它们。

Again, we are starting to get into details where it can be hard to fully understand what is happening. One good way to think of the Atomics API is a system where many people are sharing a piece of paper. They all take turns writing on it and reading what others wrote down.

However, one of the downfalls is that they are only allowed to write a single character at a time. Because of this, someone else may write something in their location while they are still trying to finish writing their word, or someone may read their incomplete phrase. We need a mechanism for people to be able to write the entire word that they want, or read the entire section, before someone starts writing. This is the job of the Atomics API.

SharedArrayBuffer遭受相关问题的浏览器不支持它(目前,只有 Chrome 支持它没有国旗),问题,我们可能需要使用原子 API(SharedWorker不能寄给主线程或专门的工作器由于安全问题)。

为了建立一个基本的SharedArrayBuffer操作示例,我们将在主线程和工作线程之间共享一个缓冲区。 当我们向 worker 发送请求时,我们将更新该 worker 内部的数字 1。 更新这个数字应该对主线程可见,因为他们共享缓冲区:

  1. 创建一个简单的 worker,并使用onmessage处理程序检查它是否收到了一个数字。 如果是,我们将增加SharedArrayBuffer中的数据。 否则,数据是来自主线程的SharedArrayBuffer:
let sharedPort = null;
let buf = null;
onmessage = function(ev) {
    const data = ev.data;
    if( typeof data === 'number' ) {
        Atomics.add(buf, 0, 1);
    } else {
        buf = new Int32Array(ev.data);
    }
}
  1. 接下来,在我们的主线程中,我们将添加一个新按钮,显示Increment。 当它被点击时,它会发送一条消息给专用的 worker 来增加当前的数字:
// HTML
<button id="increment">Increment</button>
<p id="num"></p>

// JavaScript
document.querySelector('#increment').addEventListener('click', () => {
    worker.postMessage(1);
});
  1. 现在,当 worker 更新其一侧的缓冲区时,我们将不断检查SharedArrayBuffer是否有更新。 我们总是把数字放在前面代码片段中显示的 number 段落元素中:
setInterval(() => {
    document.querySelector('#num').innerText = shared;
}, 100);
  1. 最后,为了启动这一切,我们将在主线程上创建一个SharedArrayBuffer,并在启动后将其发送给 worker:
let shared = new SharedArrayBuffer(4);
const worker = new Worker('worker_to_shared.js');
worker.postMessage(shared);
shared = new Int32Array(shared);

这样,我们可以看到我们的值现在是递增的,即使我们没有从工作线程向主线程发送任何数据! 这就是共享内存的力量。 如前所述,我们相当有限的原子 API,因为我们不能使用waitnotify系统在主线程上,我们不能使用SharedArrayBuffer``SharedWorker里面,但是它只能用于系统读取数据。

在这些情况下,我们可以更新SharedArrayBuffer,然后发送消息给主线程,我们更新了它,或者它可能已经是一个使用SharedArrayBuffers的 Web API,比如 WebGL 渲染上下文。 虽然前面的示例不是很有用,但它确实展示了如果在SharedWorker中生成和使用SharedArrayBuffer的能力再次可用,我们将来可能如何使用共享系统。 接下来,我们将重点构建一个所有工作人员都可以共享的单一缓存。

构建一个简单的共享缓存

与我们的一切,我们要关注一个用例在报告系统相当普遍,大多数类型的操作 GUIs-a 大块的数据需要其他数据添加到它(有人称之为装饰数据和其他人称之为归因)。 例如,我们有一个客户列表的买卖订单。

该数据可以通过以下方式返回:

{
    customerId : "<guid>",
    buy : 1000000,
    sell : 1000000
}

有了这些数据,我们可能想要添加一些与客户 ID 相关联的上下文。 我们可以从两方面着手:

  • 首先,我们可以在数据库中执行连接操作,为用户添加所需的信息。
  • 其次,我们将在这里演示的是,当我们得到基本查询时,在前端添加这些数据。 这意味着当我们的应用启动时,我们会获取所有这些属性数据并将其存储在一些后台缓存中。 接下来,当我们发出请求时,我们也将向缓存发出相应数据的请求。

对于我们实现第二个选择,我们将实现我们之前学到的两项技术,SharedWorkerpostMessage接口:

  1. 我们创建了一个基础级别的 HTML 文件,其中每一行数据都有一个模板。 我们不会去创建一个 web 组件的深潜水我们在第三章,原始土地——看现代 web,但是我们将使用它来创建我们的表行需求:
<body>
    <template id="row">
        <tr>
            <td class="name"></td>
            <td class="zip"></td>
            <td class="phone"></td>
            <td class="email"></td>
            <td class="buy"></td>
            <td class="sell"></td>
        </tr>
    </template>
   <table id="buysellorders">
   <thead>
       <tr>
           <th>Customer Name</th>
           <th>Zipcode</th>
           <th>Phone Number</th>
           <th>Email</th>
           <th>Buy Order Amount</th>
           <th>Sell Order Amount</th>
       </tr>
   </thead>
   <tbody>
   </tbody>
   </table>
</body>
  1. 我们设置了一些指向模板和表的指针,以便进行快速插入。 在此之上,我们可以为将要创建的SharedWorker创建一个占位符:
const tableBody = document.querySelector('#buysellorders > tbody');
const rowTemplate = document.querySelector('#row');
const worker = new SharedWorker('<fill in>', {name : 'cache'});
  1. 通过这个基本设置,我们可以创建我们的SharedWorker并给它一些基本级别的数据。 为此,我们将使用网站https://www.mockaroo.com/。 这将允许我们创建一堆随机数据而不需要自己思考。 我们可以将数据更改为我们想要的任何内容,但在本例中,我们将使用以下选项:

  2. id:行号

  3. full_name:全名
  4. email:邮箱地址
  5. phone:电话
  6. zipcode:数字序列:######

  7. 填好这些选项后,我们可以将格式更改为 JSON 并通过单击 Download Data 保存。 这样,我们可以建立我们的SharedWorker。 与其他的SharedWorker类似,我们将使用onconnect处理程序并为传入的端口添加onmessage处理程序:

onconnect = function(e) {
    let port = e.ports[0];
    port.onmessage = function(e) {
        // do something
    }
}
  1. 接下来,我们启动 HTML 文件中的SharedWorker:
const worker = new SharedWorker('cache_shared.js', 'cache');
  1. 现在,当我们的SharedWorker启动时,我们将使用importScripts加载文件。 这让我们可以用script标签加载外部 JavaScript 文件,就像我们在 HTML 中做的那样。 为此,我们需要修改 JSON 文件,将对象指向一个变量,并将其重命名为一个 JavaScript 文件:
let cache = [{"id":1,"full_name":"Binky Bibey","email":"bbibey0@furl.net","phone":"370-576-9587","zipcode":"640069"}, //rest of the data];

// SharedWorker.js
importScripts('./mock_customer_data.js');
  1. 现在我们已经引入了数据缓存,我们将响应从端口发送的消息。 我们只期望数字数组。 这些将对应于与用户关联的 ID。 现在,我们将循环遍历字典中的所有项,看看是否有它们。 如果这样做,我们将把它们添加到一个数组中,并对其进行响应:
const handleReq = function(arr) {
    const res = new Array(arr.length)
    for(let i = 0; i < arr.length; i++) {
        const num = arr[i];
        for(let j = 0; j < cache.length; j++) {
            if( num === cache[j].id ) {
                res[i] = cache[j];
               break;
            }
        }
    }
    return res;
}
onconnect = function(e) {
    let port = e.ports[0];
    port.onmessage = function(e) {
        const request = e.data;
        if( Array.isArray(request) ) {
            const response = handleReq(request);
            port.postMessage(response);
        }
    }
}
  1. 这样,我们就需要在 HTML 文件中添加相应的代码。 我们将添加一个按钮,将发送 100 个随机 id 到我们的SharedWorker。 当我们发出请求并获得与数据关联的 id 时,这将进行模拟。 模拟函数是这样的:
// developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/
// Global_Objects/Math/random

const getRandomIntInclusive = function(min, max) {
    return Math.floor(Math.random() * (max - min + 1)) + min;
}
const simulateRequest = function() {
    const MAX_BUY_SELL = 1000000;
    const MIN_BUY_SELL = -1000000;
    const ids = [];
    const createdIds = [];
    for(let i = 0; i < 100; i++) {
        const id = getRandomIntInclusive(1, 1000);
        if(!createdIds.includes(id)) {
            const obj = {
                id,
                buy : getRandomIntInclusive(MIN_BUY_SELL,  
                 MAX_BUY_SELL),
                sell : getRandomIntInclusive(MIN_BUY_SELL, 
                 MAX_BUY_SELL)
            };
            ids.push(obj);
        }
    }
    return ids;
}
  1. 通过前面的模拟,我们现在可以添加请求的输入,然后将其发送到我们的SharedWorker:
requestButton.addEventListener('click', (ev) => {
    const res = simulateRequest();
    worker.port.postMessage(res);
});
  1. 现在,我们正在发布错误的数据到我们的SharedWorker。 我们只想发布 id,但如何将我们的请求与来自我们的SharedWorker的响应绑定? 我们需要稍微修改我们的requestresponse方法的结构。 我们现在将绑定一个 ID 到我们的消息,这样我们就可以让SharedWorker将其发送回我们。 这样,我们就可以在请求的前端有一个映射,以及与之关联的 id。 进行以下更改:
// HTML file
const requestMap = new Map();
let reqCounter = 0;
requestButton.addEventListener('click', (ev) => {
    const res = simulateRequest();
    const reqId = reqCounter;
    reqCounter += 1;
    worker.port.postMessage({
        id : reqId,
        data : res
    });
});

// Shared worker
port.onmessage = function(e) {
    const request = e.data;
    if( request.id &&
        Array.isArray(request.data) ) {
        const response = handleReq(request.data);
        port.postMessage({
            id : request.id,
            data : response
        });
    }
}
  1. 有了这些更改,我们仍然需要确保只将 id 传递给SharedWorker。 我们可以在发送请求之前从请求中提取这些信息:
requestButton.addEventListener('click', (ev) => {
    const res = simulateRequest();
    const reqId = reqCounter;
    reqCounter += 1;
    requestMap.set(reqId, res);
    const attribute = [];
    for(let i = 0; i < res.length; i++) {
        attribute.push(res[i].id);
    }
    worker.port.postMessage({
        id : reqId,
        data : attribute
    });
});
  1. 现在我们需要处理 HTML 文件中返回给我们的数据。 首先,我们将一个onmessage处理程序附加到端口:
worker.port.onmessage = function(ev) {
    console.log('data', ev.data);
}
  1. 最后,我们从映射中获取关联的买入/卖出订单,并用返回的缓存数据填充它。 一旦我们完成了这些,我们只需要克隆我们的行模板并填写相应的字段:
worker.port.onmessage = function(ev) {
    const data = ev.data;
    const baseData = requestMap.get(data.id);
    requestMap.delete(data.id);
    const attribution = data.data;
    tableBody.innerHTML = '';
    for(let i = 0; i < baseData.length; i++) {
        const _d = baseData[i];
        for(let j = 0; j < attribution.length; j++) {
            if( _d.id === attribution[j].id ) {
                const final = {..._d, ...attribution[j]};
                const newRow = rowTemplate.content.cloneNode(true);
                newRow.querySelector('.name').innerText =  
                 final.full_name;
                newRow.querySelector('.zip').innerText = 
                 final.zipcode;
                newRow.querySelector('.phone').innerText = 
                 final.phone;
                newRow.querySelector('.email').innerText = 
                 final.email;
                newRow.querySelector('.buy').innerText = 
                 final.buy;
                newRow.querySelector('.sell').innerText = 
                 final.sell;
                tableBody.appendChild(newRow);
            }
        }
    }
}

通过前面的示例,我们已经创建了一个共享缓存,具有相同域的任何页面都可以使用它。 虽然有某些优化(我们可以将数据存储为映射,并将 ID 作为键),但我们仍然要比等待数据库连接快一些(特别是在带宽有限的地方)。

总结

这一章的重点是将任务从主线程转移到其他工作线程。 我们已经看到了只有一个页面有专门的工作者。 然后,我们了解了如何在多个工作人员之间广播消息,而不必通过各自的端口循环。

然后,我们看到了如何利用SharedWorker在同一个域上共享一个工作者,也看到了如何利用SharedArrayBuffer共享一个数据源。 最后,我们实际地看看如何创建任何人都可以访问的共享缓存。

在下一章中,我们将利用ServiceWorker进一步阐述缓存和处理请求的概念。