13、WebRTC与WebWorker:将编解码与处理逻辑移入Worker、主线程卡顿的隔离、SharedArrayBuffer的使用
做WebRTC开发久了,你一定会遇到这个问题:视频通话一开,页面就卡得不行。用户滑动页面、点击按钮,全都没反应。说白了,主线程被视频处理给拖死了。
我最早做WebRTC项目时,就踩过这个坑。当时一个720p的视频流,加上美颜滤镜,主线程直接掉到十几帧。用户反馈说「你们这个视频通话,连聊天框都打不开」。嗯,从那以后,我就把编解码和处理逻辑全扔进Worker了。
为什么主线程会卡顿?
WebRTC的实时通信,本质上是一个持续的数据流水线。每一帧视频数据进来,都要经过:
- 解封装(Demuxing)
- 解码(Decoding)
- 前处理(美颜、滤镜、缩放)
- 渲染(Canvas/Video标签)
这些操作如果全在主线程做,那主线程就变成了一个24小时无休的搬运工。你想想看,用户还要点击按钮、输入文字、滚动页面,主线程哪有空管这些?
核心问题:JavaScript是单线程的。主线程既要处理UI交互,又要处理视频帧,必然导致卡顿。
WebWorker:把重活扔到后台
WebWorker的出现,就是为了解决这个问题。它允许你在后台线程运行脚本,不阻塞主线程。
我个人习惯把视频处理分成三层:
- 采集层:主线程负责getUserMedia获取流
- 处理层:Worker负责编解码、滤镜、缩放
- 渲染层:主线程只负责把处理好的帧画到Canvas上
这样分工后,主线程的负担大大减轻。用户操作界面时,视频处理在后台默默进行,互不干扰。
实战:把编解码移入Worker
我们来看一个具体的例子。假设你要在视频通话中做美颜处理,传统做法是在主线程里逐帧处理:
// ❌ 主线程处理 - 卡顿的根源
function processFrame(frame) {
// 美颜滤镜
const processed = applyBeautyFilter(frame);
// 缩放
const resized = resizeFrame(processed);
// 绘制到Canvas
canvas.drawImage(resized, 0, 0);
}
改成Worker后,流程变成这样:
// ✅ Worker处理 - 主线程不卡
// main.js
const worker = new Worker('video-worker.js');
navigator.mediaDevices.getUserMedia({ video: true })
.then(stream => {
const track = stream.getVideoTracks()[0];
const processor = new MediaStreamTrackProcessor(track);
const reader = processor.readable.getReader();
function readFrame() {
reader.read().then(({ done, value }) => {
if (done) return;
// 把帧传给Worker处理
worker.postMessage(value);
readFrame();
});
}
readFrame();
});
// video-worker.js
self.onmessage = function(e) {
const frame = e.data;
// 在Worker里做美颜、缩放
const processed = applyBeautyFilter(frame);
const resized = resizeFrame(processed);
// 把处理好的帧传回主线程
self.postMessage(resized);
};
小技巧:使用MediaStreamTrackProcessor可以避免频繁的Canvas绘制,直接操作原始视频帧,性能更好。
SharedArrayBuffer:零拷贝的数据共享
Worker之间传数据,默认是拷贝的。你想想看,一帧1080p的视频数据,几MB大小,每次postMessage都要拷贝一次,这开销可不小。
SharedArrayBuffer就是来解决这个问题的。它允许主线程和Worker共享同一块内存,数据不用拷贝,直接读写。
我曾经在一个项目中,用SharedArrayBuffer把视频帧的传输延迟从5ms降到了0.1ms。效果非常明显。
// 主线程
const buffer = new SharedArrayBuffer(1920 * 1080 * 4); // RGBA格式
const sharedArray = new Uint8Array(buffer);
// 把共享内存传给Worker
worker.postMessage(buffer);
// Worker
let sharedBuffer;
self.onmessage = function(e) {
sharedBuffer = e.data;
const sharedArray = new Uint8Array(sharedBuffer);
// 直接读写共享内存,无需拷贝
sharedArray[0] = 255; // 修改第一个像素
};
注意:SharedArrayBuffer需要设置跨域隔离(Cross-Origin Isolation),否则浏览器会禁用。需要在HTTP响应头中设置:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
主线程卡顿的隔离策略
把处理逻辑移入Worker后,主线程的卡顿问题基本解决了。但还有一些细节需要注意:
| 场景 | 策略 | 说明 |
|---|---|---|
| 视频解码 | 移入Worker | 使用WebCodecs API在Worker中解码 |
| 音频处理 | 移入Worker | AudioWorklet比普通Worker更合适 |
| 渲染 | 留在主线程 | Canvas操作必须在主线程 |
| UI交互 | 留在主线程 | 用户操作必须及时响应 |
我个人建议,把耗时超过16ms(一帧的时间)的操作,全部移入Worker。这样主线程就能保持60fps的流畅度。
知识体系总览
下面这张图,概括了本章的核心逻辑:
避坑指南
我曾经在一个项目中,把所有的视频处理都扔进了Worker,结果发现内存占用飙升。排查了半天,原来是Worker里创建了太多临时对象,没有及时释放。
这里有几个我踩过的坑,分享给你:
- Worker数量不是越多越好:每个Worker都有独立的内存空间,开太多反而浪费。一般1-2个Worker就够了。
- SharedArrayBuffer的边界问题:多线程同时读写同一块内存,要注意同步。可以用Atomics API来保证原子操作。
- Worker的启动开销:Worker启动需要几十毫秒,不要在每次视频通话时都新建Worker。建议提前创建好,复用。
我的习惯:在页面初始化时,就创建好Worker池。视频通话开始时,直接往Worker里扔数据。这样能省掉启动时间。
好了,关于WebRTC与WebWorker的配合,核心就是这些。把重活扔到后台,主线程只做轻量操作,卡顿问题自然就解决了。
公众号:蓝海资料掘金营,微信deep3321