ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Web Worker 与 Transferable:大文件上传零拷贝实践

Web Worker 与 Transferable:大文件上传零拷贝实践 看到标题里这三个词凑在一起——Worker、结构化克隆、Transferable——我第一反应就是又有人在处理大文件上传、音视频处理这类吃内存的活了。这个问题我前前后后踩了三年坑第一年把大数组放主线程算哈希页面直接卡成白屏第二年学会用 Worker 了但 postMessage 传来传去内存翻倍移动端直接被系统杀掉第三年才算是把 Transferable 的边界条件摸清楚知道什么时候该用、什么时候不该硬用。这篇文章准备把这轮完整账目摊开讲。如果你正在计划用 Worker 处理大文件上传、图像帧数据或者单纯想搞清楚 postMessage 底层到底复制了什么这篇值得读完再动手。1. 别把 Worker 当函数调用常驻线程需要先算好账1.1 主线程为什么会卡JavaScript 主线程和渲染线程是绑在一起的你在大循环里算一秒钟浏览器就没法响应点击和绘制表现就是页面卡了。文件分片算哈希、大图压缩、音视频转码这些任务数据动不动几十 MB 甚至上百 MB放在主线程跑体验基本属于灾难级别。Worker 能解决这个问题因为它有独立的线程和独立的 JS 执行环境。主线程派一个任务过去Worker 吭哧吭哧算算完把结果丢回来主线程只负责刷新进度条用户依然可以正常滚动页面。1.2 一个常驻 Worker 的内存与生命周期成本但是 Worker 不是免费的。每次 new Worker 都要创建一个新的 V8 隔离实例底层要分配线程栈、堆内存、全局对象环境开销并不小。在移动端一个空 Worker 的基线内存可能就要十几 MB 甚至更多创建过程还可能伴随一次完整的引擎初始化。所以实践上大家默认倾向常驻页面加载时创建一个 Worker后续不断向它发任务而不是每来一个任务就 new 一个再 terminate 一个。这样线程启动成本只付一次后面全是复用。常驻的代价是这个 Worker 会一直占着一个系统线程即使它空闲。你在 macOS 的 Activity Monitor 或者 Instruments 里看到多出来的 GThread往往就是这些后台 Worker 线程。只要你没主动 terminate它就一直挂在那里。这个状态并不等于内存泄漏但确实是一笔固定开销。1.3 常见误区提交一个任务就 new 一个 Worker我见过不少项目在每次点击上传时都临时 new 一个 Worker传完立刻 terminate。数据量小的时候看不出来一旦文件大起来Worker 的创建成本反而比计算本身还高而且频繁创建销毁还会增加 GC 压力。更合理的方式是把 Worker 设计成有状态的服务主线程发一条 init 消息把文件句柄和配置传过去Worker 内部维护一个执行状态跑完当前任务后保持空闲等待下一条指令。这样不仅能省创建开销还能让你在 Worker 里缓存一些复用性资源比如分片池、哈希计算的中间结果。2. postMessage 的默认动作结构化克隆算法到底干了什么2.1 结构化克隆和 JSON.stringify 的差别主线程和 Worker 之间没有共享的内存空间所以 postMessage 传数据不能直接传引用。浏览器需要把数据转换成一种可以跨线程传输的形式这个转换过程由结构化克隆算法structured clone algorithm完成。很多人以为 postMessage 传对象就是深拷贝还有人以为它内部会走 JSON都不准确。JSON.stringify 根本没法处理 Date、Map、Set、RegExp、ArrayBuffer 这些类型而结构化克隆都能处理而且它保留了自引用关系。比如一个对象a.self aJSON 序列化会直接爆栈结构化克隆可以正常传递过去接收端拿到的对象obj.self obj依然成立。2.2 能克隆的类型和不能克隆的类型结构化克隆对 JavaScript 内建类型的支持相当广。普通对象、数组、Map、Set、Date、RegExp、Error、Blob、File、TypedArray、DataView、ArrayBuffer、ImageBitmap 这些都能传。但是函数、Symbol、DOM 节点这类带执行环境或宿主引用的东西没法克隆强行传过去会抛 DataCloneError。这里有个常见的坑很多人想把一个带方法的对象传给 Worker比如worker.postMessage({ handler: () {} })结果直接报错。Worker 之间通信的数据必须保持纯数据状态任何方法都要拆出来在接收端重新挂上去。2.3 一份数据在内存里被复制了几次这是最要命的地方。假设你在主线程构造了一个 200MB 的 ArrayBuffer直接 postMessage 给 Worker。结构克隆算法要先把主线程这边 200MB 的二进制数据完整复制一份生成传输副本然后 Worker 那边再把副本反序列化成自己的 ArrayBuffer。整个过程中你的原始 buffer 占 200MB传输副本占 200MBWorker 收到的 buffer 又占 200MB。内存峰值直接飙到 600MB 级别。这里面的每字节 memcpy 都是实打实的时间消耗。200MB 的数据拷一遍可能就要几十毫秒到上百毫秒如果来回传几次卡顿就明显了。所以对大块二进制数据来说结构化克隆的代价不是理论问题是切切实实的性能和内存问题。3. Transferable 的零拷贝把所有权交接清楚3.1 零拷贝的真实含义Transferable 翻译过来是可转移对象它的本质是内存块所有权的转移而不是数据的复制。你用worker.postMessage(buffer, [buffer])把 ArrayBuffer 传过去浏览器不会复制这 200MB 的数据而是直接把底层内存块的所有权从主线程交到 Worker 手里。这个过程在底层只是更新了对象引用和内部句柄成本接近常量级跟你传 1MB 还是传 200MB 没有太大关系。所以大家叫它零拷贝。严格来说操作系统层面的零拷贝比如 sendfile) 和这里的零拷贝不是一个概念但精神一致数据不经过用户态到内核态的重复搬运。3.2 哪些类型可以转移不是所有数据都能转移。目前常见的可转移类型包括ArrayBuffer、MessagePort、ImageBitmap、OffscreenCanvas、WebAssembly.Module 和 WebAssembly.Memory新的浏览器还支持 ReadableStream、WritableStream、TransformStream 以及部分音视频帧对象。需要注意TypedArray 虽然是对 ArrayBuffer 的视图但它本身不是 Transferable。你要转移一段 Uint8Array 的数据得先把它底层的 buffer 取出来转移接收方再重新包一层视图。很多人第一次用的时候会直接写postMessage(uint8Array, [uint8Array])结果发现没起作用就是因为转移列表里的对象必须是 ArrayBuffer 本身。3.3 被转移后的 ArrayBuffer 发生了什么ArrayBuffer 被转移后发送端这边的对象并不会立刻从内存里消失而是被 detach 了。detach 之后这个 buffer 的 byteLength 变成 0任何读取或写入操作都会抛错。你不能再碰它因为它已经成了接收方的私有财产。我用一个生活化的类比结构化克隆就像你把一本书的内容手抄一份给对方自己手里还有原书Transferable 就像你把书直接推到对方桌上你手里不能再拿这本书所有后续操作都在对方手里完成。所有权转移让内存零拷贝成为可能代价是发送方的引用彻底失效。3.4 Transferable 的真实代价和典型误用作为从业者我总结过 Transferable 的五个非表面代价第一发送端对象在转移后立即失效这个约束会传染给你代码里的所有路径。如果你有个异步操作在 transfer 之后又去读这个 buffer会拿到一个空对象还可能抛异常排查起来非常隐蔽。第二Transferable 只能用于一小撮类型复杂对象不能直接转移。你没法把一个自定义类的实例 transfer 过去只能手动把它的数据拆到 ArrayBuffer 里接收方再按约定重建。这个拆装过程本身就是心智负担。第三你不该把 Transferable 用来做广播。假设你想把同一份数据同时发给两个 WorkerTransferable 只能转移一次第一次转移后原 buffer 已经失效第二个 Worker 根本拿不到数据。这种情况用结构化克隆反而是对的。第四来回转移容易把事情搞复杂。比如 A 把 buffer 转给 BB 处理完又转回 A每次所有权都在变任何一处误用就会产生运行时错误。这种接力棒模型需要很严格的代码纪律否则不如直接考虑共享内存方案。第五小数据传输时 Transferable 没有优势。传一个 1KB 的对象结构化克隆可能只要 0.1msTransferable 的性能提升根本感知不到反而增加了代码复杂度。投入到这个方向前先算清楚数据量。4. 常驻 Worker Transferable大文件上传的实际方案4.1 大文件场景为什么需要 Worker前两年我被分配一个文件上传模块文件动辄几个 GB需要做分片、算 MD5、断点续传、并发控制。这些活全放主线程用户一选文件整个页面直接失去响应进度条都不带动的。放进 Worker 之后主线程只负责收集文件句柄、接收进度消息、渲染界面。真正的分片、读取、哈希、上传全在后台线程执行。配合常驻 Worker多次上传任务还能复用同一个线程不会反复创建销毁。4.2 结构化克隆和 Transferable 的对比体验我在自己的开发机上简单跑过对比一个 64MB 的 ArrayBuffer用结构化克隆发给 Worker再让 Worker 回传一次整体耗时在 200 到 500 毫秒之间内存峰值明显拉高改成用 Transferable 双向传递耗时基本是微秒到毫秒级内存峰值几乎不增加。这个对比说明数据量越大Transferable 的收益越夸张。但反过来传一个 4KB 的配置对象时两种方式的差异在性能面板上几乎看不到。所以我的原则很简单大块二进制数据才用 Transferable普通消息老老实实走结构化克隆。4.3 一个可以直接抄的分片上传骨架我整理了一份简化但能跑通的骨架。主线程负责选文件和创建常驻 WorkerWorker 负责分片、哈希和上传。主线程侧的关键代码const uploadWorker new Worker( new URL(./upload-worker.js, import.meta.url), { type: module, name: file-upload-worker } ); fileInput.addEventListener(change, async (event) { const file event.target.files[0]; if (!file) return; uploadWorker.postMessage({ type: init, file, chunkSize: 2 * 1024 * 1024, uploadUrl: /api/upload/chunk }); }); uploadWorker.onmessage (event) { const { type, index, total } event.data; if (type progress) { renderProgress(index, total); } else if (type done) { notifyUploadFinished(); } };注意这里把 File 对象直接通过结构化克隆传给了 WorkerFile 本身是不可变对象浏览器底层通常会复用数据句柄而不是把整个文件数据复制一遍所以这个操作代价不大。Worker 侧的关键代码let file null; let chunkSize 0; let uploadUrl ; self.onmessage async (event) { const { type } event.data; if (type init) { file event.data.file; chunkSize event.data.chunkSize; uploadUrl event.data.uploadUrl; await runUpload(); } }; async function runUpload() { const totalChunks Math.ceil(file.size / chunkSize); for (let i 0; i totalChunks; i) { const blob file.slice(i * chunkSize, (i 1) * chunkSize); const formData new FormData(); formData.append(chunk, blob); formData.append(index, String(i)); formData.append(total, String(totalChunks)); await uploadChunkWithProgress(formData, i, totalChunks); } self.postMessage({ type: done }); } function uploadChunkWithProgress(formData, index, total) { return new Promise((resolve, reject) { const xhr new XMLHttpRequest(); xhr.open(POST, uploadUrl); xhr.timeout 30000; xhr.upload.onprogress (event) { if (event.lengthComputable) { self.postMessage({ type: chunk-progress, loaded: event.loaded, total: event.total }); } }; xhr.onload () { if (xhr.status 200 xhr.status 300) { self.postMessage({ type: progress, index, total }); resolve(); } else { reject(new Error(Upload failed: ${xhr.status})); } }; xhr.onerror () reject(new Error(Network error)); xhr.send(formData); }); }这个版本主要用 Blob 分片直接上传没有出现 ArrayBuffer所以不需要 Transferable。如果某个环节你需要把读取出来的 ArrayBuffer 传回主线程或者传递给另一个 Worker就要用到转移语法const buffer await blob.arrayBuffer(); worker.postMessage({ type: chunk-buffer, index, buffer }, [buffer]);转移之后原本的 ArrayBuffer 在主线程侧已经失效不要继续访问。5. 常见报错和排查实录5.1 误把 Service Worker 的问题当成 Worker 问题经常有人跑到社区问我页面里没写任何 Worker为什么控制台报Error loading webview: Error: Could not register Service Worker: InvalidStateError这个真不是业务代码的问题。Service Worker 和 Web Worker 虽然都叫 Worker但完全是两回事。Service Worker 是浏览器底层的网络代理生命周期由事件驱动经常出现在 PWA、Electron WebView 这类环境里。这个报错大概率是你用的某个框架或编辑器插件在底层尝试注册 Service Worker而当前环境不允许或者缓存状态坏了。处理办法通常是重启应用、清理用户缓存目录、升级 Electron 版本。如果在普通网页里出现先检查是否引入了含 SW 的第三方脚本别把排查方向带偏到自己的 Web Worker 代码上。5.2 DOMWindow 的 targetOrigin 报错还有个高频报错长这样Failed to execute postMessage on DOMWindow: The target origin provided (https://example.com) does not match the recipient windows origin (https://internal.example.com)。注意看报错对象是 DOMWindow不是 Worker。这是 window.postMessage 的 targetOrigin 参数问题属于跨窗口通信跟 Web Worker 的 postMessage 不是同一个 API。Worker.postMessage 不需要 targetOrigin只有 window.postMessage 需要。修法很简单把 targetOrigin 改成接收窗口的真实 origin或者在开发阶段用 * 通配消息体里再做校验但生产环境还是强烈建议写精确 origin避免数据被第三方窗口接收。5.3 DataCloneError不支持的克隆类型DataCloneError常见于往 Worker 里传递函数、Symbol、DOM 元素、AudioNode 这类带宿主引用的对象。报错信息通常很明确什么类型无法克隆。解决办法是把数据拆成纯可克隆结构比如把一组回调函数改成消息类型字符串Worker 在自己环境里维护一个分发表。还有一个更隐蔽的坑某些特殊对象表面上可克隆但转移列表里写错类型也会出问题。比如你在转移列表里放了一个普通对象而不是 ArrayBuffer浏览器会直接抛错。检查思路是确认 transfer 列表中的每一项都是 Transferable 类型且不是 TypedArray 的包装视图。5.4 转移后访问已失效的 BufferArrayBuffer 被 transfer 之后原对象还在变量里但内部已经 detach。此时访问它某些环境会静默返回空数据某些环境直接抛 TypeError。最坑的是异步代码你把 buffer 转给 Worker结果后面的 async 回调又去读同一块 buffer因为变量名还挂在作用域里很容易忘记它已经失效。我的排查经验是在代码里凡是调用过 postMessage 的 transfer 列表的变量立刻用一个明确的变量名前缀标识已转移比如transferredBuffer并且马上加注释。不要在转移后继续持有引用。如果业务逻辑真的需要保留副本那就说明这个场景不适合用 Transferable应该改成结构化克隆。5.5 macOS 空闲线程不必恐慌如果你是做 Electron 或者短视频处理应用在 macOS 上查看线程信息时可能会发现一个 GThread 长期空闲即使页面没有任何任务。这个大概率就是你常驻创建的那个 Web Worker 后台线程也可能来自浏览器底层框架的线程池。这本身不是泄漏但如果你对内存占用敏感可以在 Worker 的 onmessage 里加一个空闲计时器比如 5 分钟没有任何任务就主动调用 close 或者让主线程 terminate下次需要时再创建。6. 我的选择原则把这些经历沉淀下来我个人在实际操作中形成了几条很明确的原则。大二进制数据、需要频繁跨线程送的场景优先上 Transferable但一定要把所有权转移的边界写清楚转移后原引用坚决不再碰。数据量小、结构复杂的消息直接结构化克隆不要为了秀性能硬拆 ArrayBuffer得不偿失。需要多个线程真正并行访问同一份数据那就考虑 SharedArrayBuffer但它要开 COOP/COEP 安全头而且并发控制得靠 Atomics 加锁复杂度是完全不同级别没有刚需别碰。常驻 Worker 也不是越多越好。一个业务场景一个 Worker 就够了移动端尤其要克制。线程创建本身是昂贵的但长期占着一个空闲线程同样有成本任务完成后记得把 Worker 内部持有的大对象显式置空比如file null让内存尽早释放。最后再分享一个平时容易忽略的小技巧如果任务本身不需要在主线程做就把 FileReader、arrayBuffer 这类读取操作也放到 Worker 里执行让主线程只接收进度消息。这样主线程连大文件的解码压力都没有页面流畅度会有一个质的提升。
返回列表