在恶补ECMAScript新规范的时候,遇到了几个Web Workers的例子,才知道有Web API已经有了这个新功能。
这里感叹一下,只是几年没跟进Web API,它就已经演化到我想像不到的程度了,刚参加工作那些年哪里敢想JS可以有线程池、蓝牙、震动、甚至游戏手柄的API支持?而现在这些东西全都有了……加上WebGL、WebRTC、WebVR等新Web API,感觉都可以直接将Web平台视作一个游戏机平台了哦……
老牛亦解韶光贵,不待扬鞭自奋蹄 —— 臧克家《老黄牛》
动机及原理
刚开始我以为Web Workers就是一个可以在浏览器运行的JS的异步调度器API,然而事实上我的理解有极大的问题。如果它只是一个异步调度器,那么实际上即便没有它的时候我们也可以使用Promise/async/await
来达到这个目的,甚至再不济也可以使用setTimeout/setInterval
来实现异步调度。
事实上它并不是一个简单的异步调度器。
假设一下场景。例如在阅读本博客的某篇欧拉计划文章时,运行了求解脚本,发现运行得很慢,它会卡住浏览器的渲染主线程,这样便无法继续在这页面获取任何界面响应了。我作为博主寻思这样的用户体验太糟糕了,希望解决这个问题,那么我可以有什么技术选项呢?我可以把求解脚本封装在async/setTimeout
代码块中从而使得脚本异步执行、不卡死图形界面吗?例如这样:
'use strict';
function problem_xxx() {
let sum = 0n;
for (let i = 0n; i < 100000000n; ++i)
sum += i;
return sum;
}
(async () => {
alert(problem_xxx());
})().finally(() => {
setTimeout(() => {
alert(problem_xxx());
}, 1);
});
答案显然是不能。这是为什么呢?
这是因为,JS的异步调度器实际上是从界面渲染主线程中进行调度的。也就是说,当点击上面的运行按钮后,界面渲染主线程的大循环会在同一个线程中产生一个点击事件,然后执行上述代码,然后当遇到async/setTimeout
代码块时,它会标记这段代码为异步代码,当整块代码执行的过程中某个“闲暇”的阶段,才开始执行这段异步代码,整个过程,实际上都是在界面渲染主线程的大循环中进行的,也就是对于主线程来说,这段异步代码一点也异步。在JS的语境下,异步实际上是相对的。
那好了,如果异步也不是异步的,这咋整?
答案就是Web Workers。它就像Java
并发编程API中的Executors
,是实打实的线程池。(并发不是相对的,是一个绝对概念,能并发执行的代码当然也可以视为异步代码。)
W3C为Web API引入Web Workers当然就是为了给浏览器环境带来线程池,但我觉得这个API应该被纳入ECMAScript的规范,因为这便相当于在其他JS环境中如果要使用线程池便要引入其它库。
就正如篇首大图所描述,Web Workers的执行环境是独立于界面渲染主线程的,而且依然可以使用各种Web API来为界面渲染主线程服务。(虽然有一些限制。)
Worker脚本
Worker脚本本质上就是一个普通的独立JS文件(无法使用<script />
标签内嵌在HTML中),不过它的上下文正如上文所说的那样,不是界面渲染主线程,也就是说它的全局对象根本就不是、也无法访问window
,取而代之的是W3C提供了一个self
对象来作为上下文全局对象。
即在这种脚本中:window === undefined && globalThis !== window && self !== undefined && globalThis === self
。由于是另外的线程,所有与界面渲染有关的API都无法使用,包括且不限于alert
、document.get...
。
Worker脚本可以使用self.onmessage
回调获取其它环境发给它的消息、使用self.postMessage()
方法来通知生成这个Worker实例的上下文、且Worker脚本也可以创建新的Worker实例,只不过Worker脚本必须遵守同源规则。
Worker脚本不能使用import/export
模块系统,但可以使用self.importScripts()
脚本导入其它脚本。
下面是一个用来测试Web Workers执行环境的Worker脚本:
'use strict';
const workerJsUrl = window.URL.createObjectURL(new Blob([`
/* 'use strict'; */ // Worker脚本只可以在严格模式下执行,没必要声明这个
onmessage = (event) => {
switch (event.data) {
case 'test':
let noWindow = false;
try {
console.log(window);
} catch {
noWindow = true;
}
let noAlert = false;
try {
console.log(alert);
} catch {
noAlert = true;
}
postMessage({
'window === undefined': noWindow,
'alert === undefined': noAlert,
'globalThis !== window': noWindow && globalThis !== undefined,
'self !== undefined': self !== undefined,
'globalThis === self': globalThis === self,
'self.onmessage === onmessage': self.onmessage === onmessage,
'self.postMessage === postMessage': self.postMessage === postMessage,
'self.importScripts === importScripts': self.importScripts === importScripts
});
break;
}
};
`], { type: 'application/javascript' }));
const worker = new Worker(workerJsUrl);
worker.onmessage = (event) => {
alert(JSON.stringify(event.data, null, 4));
};
worker.postMessage('test');
前文卡界面的脚本可以将它如下改写为Worker脚本:
'use strict';
const problemJsUrl = window.URL.createObjectURL(new Blob([`
// 被Worker脚本引用的脚本也被视为Worker脚本的一部分,所以也是默认就是严格模式,不用额外声明
function problem_xxx() {
self.postMessage('正在执行problem_xxx,请稍候……'); // 所以Worker能调用的这里也能调用
let sum = 0n;
for (let i = 0n; i < 100000000n; ++i)
sum += i;
return sum;
}
`], { type: 'application/javascript' }));
const workerJsUrl = window.URL.createObjectURL(new Blob([`
importScripts('${problemJsUrl}');
onmessage = (event) => {
switch (event.data) {
case 'problem_xxx':
postMessage(problem_xxx());
break;
}
};
`], { type: 'application/javascript' }));
const worker = new Worker(workerJsUrl);
worker.onmessage = ({ data: msg, ...rest }) => {
alert(msg);
};
worker.postMessage('problem_xxx');
可以看到,这样执行就不会卡界面了。
专用Worker
直接使用Worker
类创建的都是专用Worker实例。所谓“专用”就是每次引用Worker脚本的时候都被视为创建一个新的DedicatedWorkerGlobalScope上下文/作用域,别的Worker实例即便使用同样的脚本都不会被视为同一个实例,互相是隔离的。这是常用的场景。
前面已经展示过专用Worker的onmessage
回调的用法,它会把消息的副本放置在事件参数的data
属性中,这个回调的return
返回值是不会被视作对消息的应答的,一定要使用postMessage()
方法才可以答复,例子就不赘述了。
它还有两个回调,分别时onerror
和onmessageerror
,它们的差异是:
- 当Worker脚本任何一处位置抛出未被捕获的异常都会触发
onerror
回调; - 当Worker脚本的
onmessage
回调抛出未被捕获的异常就会触发onmessageerror
回调。
'use strict';
const workerJsUrl = window.URL.createObjectURL(new Blob([`
onmessage = (event) => {
throw 222;
};
throw 111;
`], { type: 'application/javascript' }));
const worker = Object.assign(new Worker(workerJsUrl), {
onerror(event) {
alert('onerror: ' + event.message + '\nline number: ' + event.lineno + '\nfile: ' + event.filename);
},
onmessageerror(event) {
alert('onmessageerror: ' + event.message + '\nline number: ' + event.lineno + '\nfile: ' + event.filename);
}
});
worker.postMessage('xxx'); // 不能没有参数
专用Worker还有停止Worker脚本执行的方法,在Worker脚本内可以使用self.close()
主动停止,也可以在Worker脚本外使用terminate()
强制停止:
'use strict';
const workerJsUrl = window.URL.createObjectURL(new Blob([`
let doNotClose = false;
onmessage = ({ data }) => {
switch (data) {
case 'doNotClose':
doNotClose = true;
postMessage('3秒内收到消息');
break;
}
};
let sec = 0;
const h = setInterval(() => {
sec++;
if (sec >= 3 && !doNotClose) {
clearInterval(h);
postMessage('等待3秒也没收到消息,close()');
close();
} else if (sec == 5 && Math.random() > 0.25)
postMessage('doNotTerminate');
}, 1000);
`], { type: 'application/javascript' }));
let doNotTerminate = false;
const worker = new Worker(workerJsUrl);
worker.onmessage = ({ data }) => {
switch (data) {
case 'doNotTerminate':
doNotTerminate = true;
break;
}
alert(data);
};
setTimeout(() => {
worker.postMessage('doNotClose');
}, parseInt(Math.random() * 6000));
let sec = 0;
const h = setInterval(() => {
sec++;
if (sec >= 6 && !doNotTerminate) {
clearInterval(h);
alert('等待6秒也没收到消息,terminate()');
worker.terminate();
}
}, 1000);
共享SharedWorker允许多个外部脚本引用同一个Worker脚本实例,这使得浏览器多页签、多个iframe
甚至多个Worker脚本实例之间进行通信成为可能。假象一下有一个共享Worker脚本用于长耗时计算,使用SharedWorker的话便可以在其他线程/进程获取到这个脚本的运算过程和结果:
'use strict';
const sharedWorkerJsUrl = window.URL.createObjectURL(new Blob([`
let sec = 0;
const h = setInterval(() => {
sec++;
if (h >= 60)
clearInterval(h);
}, 1000);
// 共享SharedWorker要使用onconnect回调来获得它的客户端口
onconnect = (event) => {
// 每个连接上共享SharedWorker的客户端都被抽象为MessagePort
for (const port of event.ports) {
// MessagePort的回调与专用Worker脚本的类似
port.onmessage = (event) => {
// 通知客户端时都必须通过端口来操作
port.postMessage(sec);
};
// 没有调用start()的端口的消息将无法传入共享SharedWorker,
// 这个方法也可以在共享SharedWorker外调用
port.start();
}
};
`], { type: 'application/javascript' }));
const sharedWorker1 = new SharedWorker(sharedWorkerJsUrl),
sharedWorker2 = new SharedWorker(sharedWorkerJsUrl),
msgPort1 = sharedWorker1.port, // port属性就是这个客户端指向共享SharedWorker的MessagePort
msgPort2 = sharedWorker2.port;
// 当加载共享SharedWorker脚本出现未捕获异常时会进入这个回调
sharedWorker1.onerror = (event) => {};
// MessagePort的回调与专用Worker脚本的类似
msgPort1.onmessage = ({ data: sec }) => {
alert('MsgPort1: ' + sec);
msgPort1.close();
};
// 还有一个onmessageerror回调
// 当这个port的onmessage出现未捕获异常时会进入这个回调
msgPort1.onmessageerror = (event) => {};
setTimeout(() => {
msgPort1.postMessage('');
}, parseInt(Math.random() * 6000));
msgPort2.onmessage = ({ data: sec }) => {
alert('MsgPort2: ' + sec);
msgPort2.close();
};
setTimeout(() => {
msgPort2.postMessage('');
}, parseInt(Math.random() * 6000));
可以看到,共享SharedWorker的全局对象SharedWorkerGlobalScope的回调与专用Worker的是不一样的,所以专用Worker脚本不可以直接当作共享SharedWorker脚本来使用。且两端通讯时必须通过端口对象MessagePort来进行。最后,无法停止共享SharedWorker脚本的执行(没有terminate()
函数),只能够在端口对象上调用close()
来断开客户端与脚本实例之间的链接。
Worker脚本之间通讯
脚本与外部环境通过postMessage()
可以进行双向通信,但是如果脚本之间没有父子关系,它们之间可以如何通信呢?
这种办法在《ES2017新特性》一文的最末尾有介绍,连上Atomics
一起使用之后还可以实现一些“等待-通知”的功能,例子就不赘述了。
将SharedArrayBuffer
实例作为postMessage()
的参数时,对方会收到这个实例的指针的副本,这个行为是与其它参数不一样的,其它参数都是传值的副本。
消息通道MessageChannel
每个消息通道都由两个MessagePort
组成,但它们有很有趣的特性:
- 经过
port1
发送出去的消息会在port2
的回调中出现; - 相反,经过
port2
发送出去的消息会在port1
的回调中出现。
'use strict';
const worker1JsUrl = window.URL.createObjectURL(new Blob([`
onmessage = (event) => {
const [port2] = event.ports;
port2.postMessage('Worker1: ' + event.data);
};
`], { type: 'application/javascript' })),
worker2JsUrl = window.URL.createObjectURL(new Blob([`
onmessage = (event) => {
const [port1] = event.ports;
port1.onmessage = ({ data }) => {
postMessage('Worker2: ' + data + ', ' + event.data);
};
};
`], { type: 'application/javascript' }));
const msgCh = new MessageChannel(),
port1 = msgCh.port1,
port2 = msgCh.port2;
const worker1 = new Worker(worker1JsUrl),
worker2= new Worker(worker2JsUrl);
worker1.postMessage('Hi!', [port2]);
worker2.postMessage('Aloha!', [port1]);
worker2.onmessage = ({ data }) => {
alert(data);
};
消息通道不单可以用在Web Workers,也可以用在iframe
之间通信,因为每个window
对象都可以使用onmessage
获取消息通道传过来的消息、使用postMessage(data, [port1, port2, ...])
发送消息。
广播通道BroadcastChannel
广播通道与MessagePort
功能也很类似,但它具有一个全局有效的名字,其它上下文可以通过名字获得这个广播通道的单例。
'use strict';
const worker1JsUrl = window.URL.createObjectURL(new Blob([`
const bc = new BroadcastChannel('test-bc');
onmessage = ({ data }) => {
bc.postMessage('Worker1: ' + data);
};
bc.onmessage = ({ data }) => {
postMessage('Broadcasting1: ' + data);
};
`], { type: 'application/javascript' })),
worker2JsUrl = window.URL.createObjectURL(new Blob([`
const bc = new BroadcastChannel('test-bc');
onmessage = ({ data }) => {
bc.postMessage('Worker2: ' + data);
};
bc.onmessage = ({ data }) => {
postMessage('Broadcasting2: ' + data);
};
`], { type: 'application/javascript' }));
const worker1 = new Worker(worker1JsUrl),
worker2= new Worker(worker2JsUrl);
worker1.onmessage = ({ data }) => {
alert(data);
};
worker2.onmessage = ({ data }) => {
alert(data);
};
let sec = 0;
const h = setInterval(() => {
sec += 2;
if (sec >= 10) {
clearInterval(h);
worker1.terminate();
worker2.terminate();
} else {
worker1.postMessage('Hi!');
worker2.postMessage('Aloha!');
}
}, 2000);
显然这个广播通道不会将同一上下文的消息再次发送到这个上下文的通道实例的onmessage
回调,不用担心造成广播风暴。不过,不同上下文之间的消息传递还是有可能会造成广播风暴的,这种情况要小心使用。
由 bruce 于 2019-12-10 17:03 更新