解析 Web Worker

901 阅读10分钟

本专栏内容在未来较长一段时间内不会涉及基础,本文内容需要建立在本主题有一定了解基础之上。

最近一次更新:2023-06-07

提高应用可靠性和用户体验,是开发中最重要的一环之一。

随着时代的发展,Web 从简单样式的静态图文形式转变为了复杂的动态应用。JavaScript 仍然存在的障碍实际上是语言本身。 JavaScript 是单线程环境,这意味着多个脚本不能同时运行。然而从最初到现在保持一致的是:每一个页面应用都只使用一个单独的进程来处理当前页面的运行(当然这并不绝对,Google、Alibaba等大型互联网公司通常会使用多个进程和服务器来处理它们的服务)。

单一的处理进程导致了工作负荷变的繁重,随着应用的复杂性的增加,主线程(进程:一主多辅)成为了性能上限制上的重要瓶颈。加上对于用户因为设备性能影响,应用的呈现效果几乎不可预测,且这种现象会因为用户的增加以及设备的多样化不断递增。

开发人员通过使用 setTimeout() 、 setInterval() 、 XMLHttpRequest 和事件处理程序等技术来模拟“并发”。可以看到这些事件存在的统一的特性:异步运行,但是非阻塞并不一定就意味着并发。异步事件在当前执行的脚本产生后处理。(关于异步可以看本文搜录专栏:什么是 Js ? 事件环篇)

WebWorker

Web Worker 将线程引入了 JavaScript,为 Web 内容在后台线程中运行脚本提供了一种简单的方法.将繁重的工作卸载到工作线程(辅助线程),避免阻塞 UI。 某种程度上,它帮我们解决了上面的负面影响。

Web Workers 在独立的线程中工作,因此,需要单独的执行代码文件。 workers 和主线程间的数据传递通过这样的消息机制进行 —— 双方都使用 postMessage() 方法发送各自的消息,使用 onmessage 事件处理函数来响应消息(消息被包含在message事件的 data 属性中)。这个过程中数据并不是被共享而是被复制。

创建工作线程实例:

// mian 

const worker = new Worker('worker_thread.js')

如果指定的工作线程文件存在,浏览器将生成一个新的线程且以异步的方式工作。整个线程被加载完毕之后,工作线程便开始工作,但如果该线程路径无法解析或返回 404,那么它会通过静默方式失败。

通过 postMessage 发送消息:

// worker_thread.js

worker.postMessage(/** */);

我们说到线程之间的通信数据是复制而不是共享的,因此每次传递时都会创建一个副本。数据在传递给工作线程的过程中被序列化,在另一端被反序列化。

我们来通过自包含的方式创建工作线程,来尝试传递 JSON 数据:

<button onclick="start()">do worker</button>
<button onclick="stop()">stop worker</button>
<output id="result"></output>

<script>
    if (window.Worker) {
        // 工作线程脚本
        const str = `
            self.addEventListener('message', function(e) {
                var data = e.data;
                switch (data.cmd) {
                case 'start':
                    self.postMessage('Worker Stopped: ' + data.msg);
                    break;
                case 'stop':
                    self.postMessage('Worker Stopped: ' + data.msg );
                    self.close(); 
                    // 停止一个 worker: 从主页面调用 worker.terminate () ,或者在 worker 本身内部调用 self. close ()。
                    break;
            }, false);
       `;
        const blob = new Blob([str]),
            el = document.getElementById('result');
        // 加载 Worker 脚本
        this.worker = new Worker(window.URL.createObjectURL(blob));

        function start() {
            worker.postMessage({ 'cmd': 'start', 'msg': 'do worker ...' });
        }

        function stop() {
            worker.postMessage({ 'cmd': 'stop', 'msg': 'stop worker ...' });
        }

        worker.addEventListener('message', function (e) {
            el.textContent = e.data
        }, false);

        // 错误事件
        worker.onerror = function (e) {
            el.textContent = "Error occured!";
        };
    } else {
        el.textContent = "Not supprot Web Worker!";
    }

</script>

我们在结果上可以拿到正确的数据。这好像并不能证明数据通信就是经历了 序列化和反序列化 这一过程(我们会在下面的 structuredClone 部分提到 )。

关于序列化:其实引擎在处理数据时,对于 JSON字符串 的处理要比 Js 代码要有大量的优势:JSON.parse 使应用构建更快捷(但是尽管在某些方面会有优势,序列化依旧会阻塞发送领域,而反序列化会阻塞接收领域。)

上面代码中,我们通过 window.URL.createObjectURL() 方法创建一个 URL 字符串,用于引用存储在 DOM File 或 Blob 对象中的数据。Blob URL 是唯一的,在应用程序的生存期内持续存在(直到卸载 document )。

如果要创建许多 Blob URL,需要释放不需要的引用。通过将 Blob URL 传递给 window.URL.revokeObjectURL() 释放 URL:

window.URL.revokeObjectURL(blobURL);

Chrome 中,可以通过  chrome://blob-internals/ 查看所有创建的 blob URL。

同样的为了代码的健壮性,我们需要处理 Web Worker 中抛出的所有错误。在工作线程执行时发生错误,会触发 ErrorEvent ,会返回一些信息用于错误位置: filename - 导致错误脚本名称, lineno - 发生错误的行号,以及 message - 对错误信息的描述。简单看:

function onError(e) {  
 return [  
    'ERROR: Line ', e.lineno, ' in ', e.filename, ': ', e.message  
    ].join('');  
}

worker.addEventListener('error', onError, false)

在一开始我们谈到了通过 worker 减轻服务压力,现在不妨通过一个场景来对比当使用 worker 时带来的收益;

首先,我们把工作中的任务模块抽象成一个循环:

function fn(){
    let i = 0 
    while(I < 1000000000){
        i++
    }
    return i
}
fn()
image.png image.png

可以看到在这里最直观的感受就是这个大循环所造成的 long task,往往我们在项目中需要优化的也正是这些耗时的任务;

现在我们再来通过 worker 来看它是否可以帮助我们一定程度上解决这个问题。

 if (window.Worker) {
      function doWorker(work, num) {

        let blob = new Blob([work])
        return new Promise((resolve, reject) => {
          this.worker = new Worker(window.URL.createObjectURL(blob))

          worker.postMessage(num)
          worker.addEventListener('message', function (ev) {
            resolve(ev.data)
          })
          worker.onerror = reject

        })
      }



      function fn() {
        doWorker(str, 100000000).then(res => {
          console.log("fn:", res);
        })
      }

      let str = `self.addEventListener('message', function (ev) {
          let i = 0,
            num = ev.data

          while (i < num) {
            i++
          }
          postMessage(i)
        })`

      fn()

    }

image.png

image.png

如此,我们把任务通过 worker 进行了压力分担,也验证了 worker 的定位。

ImportScripts()

可以通过 ImportScripts() 函数方法将外部脚本文件或库加载到工作线程中。简单看下语法:

importScripts('script1.js');  
importScripts('script2.js');

// 可以将多个外部脚本同时引入
importScripts('script1.js', 'script2.js');

需要注意的是使用该方法,仅在提供绝对 URL 时才有效。因为 worker 会对 blob URL 以 blob: 前缀进行解析,而应用是以不同的(大概是 https:// )方案运行的。所以会因为跨域问题导致失败。

在内联的工作线程中使用 importScripts() 的一种方法是在主脚本中 “注入” url,将其传递给内联工作线程并手动构造绝对 URL。确保从同一源导入外部脚本:

...  
<script id="worker" type="javascript:;">  
self.onmessage = function(e) {  
    const data = e.data;  

    if (data.url) {  
        var url = data.url.href;  
        var index = url.indexOf('index.html');  
        if (index != -1)   
            url = url.substring(0, index);  

        importScripts(url + 'engine.js');  
    }  
...  
};  
</script>  
<script>  
    var worker = new Worker(window.URL.createObjectURL(/** blob */));  
    worker.postMessage({url: document.location});  
</script>

postMessage

我们说到,主辅线程之间通过 postMessage() 来沟通消息,在实现上:postMessage 依靠 structuredClone (结构化克隆) 将消息从一个 JavaScript 工作上下文复制到另一个工作上下文。

structuredClone

structuredClone 算法的核心:

  1. Run StructuredSerialize() on the message.
  2. Queue a task in the receiving realm, that will execute the following steps:
    1. Run StructuredDeserialize() on the serialized message
    2. Create a MessageEvent and dispatch a MessageEvent with the deserialized message on the receiving port

这其中,StructuredSerialize() 和 StructuredDeserialize() 不是真实的函数,它们还没有通过JavaScript 公开。那这两个函数实际上是做什么的?我们可以将 StructuredSerialize() 和 StructuredDeserialize() 分别视为 JSON.stringify() 和 JSON.parse() 的优化版(但是两组方法之间是没有任何关系的),它们可以处理循环数据结构、内置类型,如:Map、Set、ArrayBuffer 等。

Chrome(Safari)对 StructuredDeserialize 会延迟执行,直到我们去访问 messageEvent 上的 data 属性。Firefox 在这个调度事件的选择上会在调度之前进行反序列化。

大部分的浏览器实现了结构化克隆算法 caniuse 2023.5

该方法将给定的值进行深拷贝(注意:不同于 lodash 的 deepClone)。

结构化克隆的类型受限,具体查看:structuredClone

Tip:如果输入的某一部分不在类型范围之内,则会抛出异常:

let o = {
    msg: 'test.',
    a: [1[2, [3, [4, [5, [6]]]]]],
    o: {
        x: {
            m: "mssaage"
        }
    },
    fn(){} // 不支持的类型
}

let co = structuredClone(o)
console.log("o", o);
console.log("co", co);
image.png

postMessage

回到 postMessage,我们说 postMessage() 在消息传递上是一个复制的过程,内部依赖了 structuredClone() 进行序列化反序列化的相关处理,在传递数据的同时的过程中会生成副本(这个复制的过程依旧可能会需要数百毫秒的时间)。这个时间上的消耗其实就是我们在上面所说到的:序列化和反序列化会在传输和接收时的阻塞时间

因此,如果要传递一个 50MB 的大文件(例如),则在工作线程和主线程之间获取该文件会产生明显的开销,这也就有了 postMessage 慢的一个说法。

上文提到,不同浏览器在对于序列化的时机是不同的,且在消息在序列化和反序列化上存在阻塞,这一定程度上产生了时间上的消耗。而对象的复杂性是影响序列化和反序列化对象所需时间的重要因素。

序列化和反序列化过程都必须以某种方式遍历整个对象,这不可避免(经验上来说:我们可以根据 JSON 对象的大小来判断除数该对象所需要的时间,对象的 JSON 字符串化大致和它的传输时间成正比。)

在上文我们提到过 JSON.parse 的优势,是的,JSON.parse 和 JSON.stringify 速度非常快,且 JSON 作为 JavaScript 的一个子集,在解析器需要处理的情况较少。我们在JSON.parse 使应用构建更快捷,也提到了对于大对象数据有时可以包装到 JSON.parse 中,以此来减少 Js 的解析时间。但是我们并不能通过 JSON 来加速 postMessage。

image.png

好像大差不差,其实在 100kb 的大小范围内并保持在 100 ms 的响应式预算,postMessage 并不会有什么影响。

处理结构化克隆对性能上的影响的另一方式是不去使用它🤣。可以通过可转移类型(又或许采用WebAssembly?)。

Transferable Objects

转移意味着不复制。

postMessage 支持了把原始值中的可转移对象转移到新对象。 可转移对象(Transferable Objects)与原始对象分离并附加到新对象;被转移的对象将不可以在原始对象中访问被访问。

可传输对象支持跨代理传输,数据会从一个上下文传输到另一个上下文。传输实际上是重新创建对象,同时共享对原数据的引用,然后分离要传输的对象。但并非所有对象都是可转移对象,并且作为可转移对象的方方面面在传输时都必须保留。

上面说过与按引用传递不同,传输是一种不可逆的操作。一旦对象被转移,就不能再次转移或使用。例如,将 ArrayBuffer 从主应用传输到 Worker 时,原始 ArrayBuffer 将被清除,不再可用。它的内容转移到工作线程上下文。

为了处理可转移对象,postMessage 支持以下这种操作方式(可序列化数据):

// 案例中第一个参数是 `ArrayBuffer` 消息,第二个参数是应传输的项目列表。
// 在可转移列表中指定 `arrayBuffer` 。

worker.postMessage(arrayBuffer, [transferableList]);

// 
window.postMessage(arrayBuffer, targetOrigin, [transferableList]);

Tip: 类似 Int32Array 和 Uint8Array 等类型化数组是可序列化的,可是不能转移。 但是,它们底层缓冲区是一个 ArrayBuffer,是一个可转移对象。

所以:我们可以在数据参数中发送 uIntArray.buffer,不是 uInt8Array

worker.postMessage(uInt8Array, [uInt8Array.buffer]);

由于它们的多线程行为,Web worker 只能访问 JavaScript 功能的一个子集:

  • navigator | location(read-only) | XMLHttpRequest | setTimeout()/setInterval() - clearTimeout()/clearInterval | App Cache(感兴趣的可以看这个:Application Cache)| 使用 importScripts() 导入 | ...

对以下数据对象无权访问,其实我们对于它们很熟悉了:

DOM | window | document | parent

End

在文章的开篇,我们就谈到了 Js 作为单线程,在运行上存在的一些弊端。主线程除了运行 Web 应用程序的 JavaScript 之外还有其他职责,这也是为什么我们需要尽可能的避免在主线程上长时间阻塞 JavaScript 代码。

但是如果我们将一部分 Js 代码移动到一个专门用于执行 Js 的工作线程中,是否可以呢?在这样的场景下,我们不必担心不同的工作环境造成的应用性能差距影响,仍然可以保证能够响应用户交互事件。

WebWorker 是在 Js 对线程的处理。为减轻应用进程的工作压力,我们通过 webWorker 来进行分担。当然我们可以其他方式:我们定期向代码添加一些 “断点” ,让浏览器有机会停止某些任务,当浏览器完成当前工作,再继续执行断点任务。