ArrayBuffer 发给 Worker 后变成零长度:结构化克隆与转移所有权
- 发布时间:2026-10-23 22:12:21
- 本文热度:浏览 2 赞 0 评论 0
- 文章标签: JavaScript WebWorker ArrayBuffer
- 全文共1字,阅读约需1分钟
把图片字节发给Worker以后,主线程里原来的TypedArray突然长度变成零,未必是Worker改坏了数据。如果发送时把底层ArrayBuffer放进transfer列表,它已经从发送方脱离;这是所有权转移的正常结果。
普通结构化克隆让两端各自保留可用数据,转移则让可转移资源只归接收方使用。选择后者以前,应确认调用方以后不再依赖原来的视图,否则一次看似无害的消息发送就会让后续处理失去输入。
下面做一个完整浏览器实验:先复制字节验证互不影响,再把原缓冲区发进Worker修改,最后转移回来。页面显示两端脱离后的长度,不用性能数字推断所有权。
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8">
<title>ArrayBuffer 转移实验</title>
<button id="run" type="button">复制与转移</button>
<pre id="result"></pre>
<script>
async function transferRoundTrip() {
const source = new Uint8Array([1, 2, 3, 4]);
const cloned = structuredClone(source.buffer);
new Uint8Array(cloned)[0] = 99;
if (source[0] !== 1) throw new Error('copy changed original');
const code = `
onmessage = event => {
const { buffer } = event.data;
const bytes = new Uint8Array(buffer);
for (let index = 0; index < bytes.length; index++) bytes[index] += 1;
const before = buffer.byteLength;
postMessage({ kind: 'result', buffer }, [buffer]);
postMessage({ kind: 'after', before, remaining: buffer.byteLength });
};
`;
const url = URL.createObjectURL(new Blob([code], { type: 'text/javascript' }));
const worker = new Worker(url);
let timer;
const lines = ['plain clone: original starts with 1, copy starts with 99'];
try {
const answer = new Promise((resolve, reject) => {
let returned = null, workerState = null;
timer = setTimeout(() => reject(new Error('worker did not finish')), 3000);
worker.onerror = event => reject(new Error(event.message));
worker.onmessage = event => {
if (event.data.kind === 'result') returned = event.data.buffer;
if (event.data.kind === 'after') workerState = event.data;
if (returned !== null && workerState !== null) {
resolve({ returned, workerState });
}
};
});
worker.postMessage({ buffer: source.buffer }, [source.buffer]);
if (source.byteLength !== 0 || source.buffer.byteLength !== 0) {
throw new Error('sender buffer was not detached');
}
lines.push('main sender after transfer: byteLength=0');
const { returned, workerState } = await answer;
const values = [...new Uint8Array(returned)];
if (values.join(',') !== '2,3,4,5' || workerState.remaining !== 0) {
throw new Error('unexpected round-trip result');
}
if (returned === source.buffer) throw new Error('old buffer revived');
lines.push(`returned bytes: ${values.join(',')}`);
lines.push(`worker sender after return: byteLength=${workerState.remaining}`);
lines.push(`original view is still detached: byteLength=${source.byteLength}`);
return lines.join('\n');
} finally {
clearTimeout(timer);
worker.terminate();
URL.revokeObjectURL(url);
}
}
const button = document.querySelector('#run');
button.onclick = async () => {
button.disabled = true;
try {
document.querySelector('#result').textContent = await transferRoundTrip();
} catch (error) {
document.querySelector('#result').textContent = `failed: ${error.message}`;
} finally {
button.disabled = false;
}
};
</script>
</html>
保存为 buffer-transfer.html,在支持structuredClone、Worker和可转移ArrayBuffer的现代浏览器打开。正常结果里,主线程发送后长度为0,返回数据为2、3、4、5,Worker发回后长度也变成0。最初那份source视图不会因为资源回来就自动恢复。
转移的是底层资源,不是让两端继续共用同一对象
Uint8Array 是视图,transfer列表里放的是它的ArrayBuffer。原缓冲区一旦脱离,引用同一缓冲区的其他视图也会受到影响。业务若先建了多个切片视图交给不同模块,不能只通知其中一个调用者“我把这一份发走了”。
TypedArray的subarray通常仍引用同一底层缓冲区。只想转移一小段时,直接把subarray.buffer发走,转移的可能是整块共享底层数据,而不是那几个可见元素。需要独立小段所有权,就先创建明确的新缓冲区,并计算它是否值得复制。
接收端得到可用的资源对象,返回时再建立新的本地视图。不要把原来的view缓存到全局,等消息回来以后继续读取它;应让返回值成为新一阶段的输入。示例用对象身份检查提醒读者,原对象没有被“复活”。
这些是可观察语义,不等于对所有运行时承诺底层始终零拷贝。实现可以有自己的内存管理与传输策略,应用选择transfer的直接依据应该是所有权合同与实际数据流,再用真实环境评估性能。
脱离后的零长度,不要当成通用判定公式
示例最初创建的是四字节缓冲区,所以发送以后变成零长度,能与已知初始状态对照。一个原本就合法创建为零长度的ArrayBuffer,同样报告byteLength为零,却不等于已经脱离。若工具接收任意外部缓冲区,不能只凭这个数值就给它贴上detached标签。
对当前已脱离缓冲区重新构建TypedArray会失败,而合法的零长度缓冲区仍能建立空视图。不同运行时还可能提供专门状态API,使用时要核对目标版本;教程没有为了这项检测再引入较新的浏览器要求。
结构化克隆还会保留单次对象图内部的重复引用关系:同一个缓冲区在输入对象里被a、b两个字段引用,克隆后两个字段仍可以引用同一个新缓冲区。这不意味着新对象与原对象共享资源,而是副本内部仍然保持原来的关联。把“深复制”理解成每经过一条路径就必定新建一份对象,也会误判数据结构。
因此接口最好直接说明谁拥有哪块缓冲区,以及接收者能否修改、能否继续转交。一个模块只拿到只读视图约定,另一个模块却把其底层资源转移走,单靠局部函数注释很难维持一致行为。所有权选择应出现在消息或函数的边界,而不是藏在某个内部优化分支里。
普通克隆也不是任意对象的完整复制
结构化克隆支持一组规定类型,可以处理许多普通数据和循环引用,但函数、DOM节点等并不能照搬进消息。把行为对象塞进载荷,会遇到DataCloneError,而不是在Worker端得到同样的方法。
对象原型、访问器和属性描述符也不能被当成普通业务类的完整序列化合同。跨消息边界更适合传递明确的数据,再在接收端建立需要的行为;不要让类实例能否被克隆成为接口的隐含前提。
SharedArrayBuffer则属于共享内存模型,不是这里这种一边脱离、一边接手的转移。它还有跨源隔离与同步要求,不能为保留原视图而把ArrayBuffer随手换成SharedArrayBuffer,忽略并发读写协议。
失败时,原来的所有权未必还能取回
发送成功以后Worker报错,主线程的原缓冲区已经脱离。重试不能继续拿这份旧视图发送,需要事先决定是否保留可重建输入、是否保存源文件,或是否接受这一阶段失败后丢弃数据。
示例为Worker响应设置了等待预算,最终无论成功还是失败都会terminate并释放Blob URL。终止Worker不是让转移资源自动回到主线程的协议,它只是结束这次执行者寿命。重要数据不能只保留在一份可能被终止的临时工作者里。
这份页面只允许单次在途操作,因此没有处理多请求ID匹配。组件同时提交多块数据时,每条消息都应携带稳定编号;迟到结果和取消后的返回也要能识别,不能靠“下一条消息就是当前请求”配对。
运行一次以后再点按钮,每次都会创建新输入与新Worker。修改为重复使用同一个source,再观察第二次发送失败,能直观发现把转移当普通复制的错误。应修的是数据拥有者与重建位置,而不是把脱离后的零长度当作随机浏览器故障。