二进制与共享内存:从字节解释到跨线程同步
文本、图像、压缩包最终都可以表示成字节。JavaScript 的普通数组便于组织值,但二进制处理还需要指定每个数占多少字节、怎样解释符号与字节序。ArrayBuffer 保存原始存储区域,视图决定如何读写它。
同一块存储,不同观察方式
const buffer = new ArrayBuffer(4);
const bytes = new Uint8Array(buffer);
const view = new DataView(buffer);
view.setUint16(0, 0x1234, false);
console.log([...bytes].join(',')); // 18,52,0,0
console.log(view.getUint16(0, false)); // 4660
console.log(view.getUint16(0, true)); // 13330DataView 的偏移单位是字节。最后参数 false 表示大端:高位字节在前;true 表示小端。默认是大端,但协议代码最好明确写出。字节本身没有“这是 4660”的标签,解释方式一变,值就变了。
TypedArray 适合一串同类型元素,例如 Uint8Array、Int16Array、Float32Array;索引按元素计数,byteLength 按字节计数。多字节 TypedArray 使用平台字节序,且构造视图的 byteOffset 需要满足元素大小对齐要求。DataView 可在任意合法字节偏移读取不同类型,适合混合字段的文件头与网络协议。
解析子视图时,别把底层整块 buffer 当成当前消息
const storage = new Uint8Array([99, 99, 0x01, 0x02, 88]);
const message = storage.subarray(2, 4);
const wrong = new DataView(message.buffer);
const right = new DataView(message.buffer, message.byteOffset, message.byteLength);
console.log(wrong.getUint16(0, false)); // 25443:读到了 99、99
console.log(right.getUint16(0, false)); // 258:读到了 1、2message.buffer 仍指向完整 storage 的存储,并没有裁成两字节。视图的三件信息要一起传递:buffer 指向存储、byteOffset 指明当前范围起点、byteLength 指明范围长度。遗漏偏移会读错位置,遗漏长度还会让解析器误以为后面的字节也属于消息。
用 DataView 读取协议头以前还应验证最小长度。若协议定义第 0 字节是版本、接下来的 2 字节是大端负载长度,则至少需要 3 字节,再检查剩余字节是否足够。语言的越界异常只能告诉你访问非法,不能代替“包还没收完整”或“协议长度无效”的业务判断。
数值转换、复制和共享视图
const data = new Uint8Array([260, -1]);
const shared = data.subarray(0, 1);
const copied = data.slice(0, 1);
shared[0] = 9;
console.log([...data].join(',')); // 9,255
console.log(copied[0]); // 4Uint8Array 写入按无符号 8 位整数转换,260 变成 4,-1 变成 255;Uint8ClampedArray 则夹到范围内并按其舍入规则处理,适合颜色通道。Float32Array 会降低到 32 位浮点精度;BigInt64Array/BigUint64Array 使用 BigInt,不能随便混入 Number。
subarray 共享底层存储,slice 复制元素。new Uint16Array(otherTypedArray) 复制并转换元素值;new Uint16Array(otherTypedArray.buffer) 则共享并重新解释字节,这两个构造参数外观相近,语义差很远。set 可以批量复制元素;TypedArray 没有普通数组的 push、splice 扩容模式。访问越界索引一般读到 undefined、写入不生效;DataView 越界读写会抛 RangeError。
字符数不是字节数
const encoded = new TextEncoder().encode('中A');
console.log('中A'.length, encoded.length); // 2 4
console.log(new TextDecoder().decode(encoded)); // 中A字符串 length 计 UTF-16 码元,TextEncoder 输出 UTF-8 字节。不能用字符串 length 作为 UTF-8 网络负载的字节长度。TextDecoder 默认可能用替代字符处理非法输入;需要严格拒绝时使用 { fatal: true }。
Blob 表示不可变原始数据,File 在 Blob 之上增加文件名等信息;它们不是 TypedArray 视图。blob.arrayBuffer() 可以异步取得字节,读取大文件则考虑 stream,避免一次性占用全部内存。
转移与调整大小
普通 ArrayBuffer 可以通过结构化克隆的 transfer 列表转移。转移后发送端原 buffer 脱离,byteLength 变为 0,旧视图不能继续作为原数据使用。它表达所有权交接,不是两端共享同一可写内存。
const original = new Uint8Array([7, 8]);
const moved = structuredClone(original, { transfer: [original.buffer] });
console.log(original.byteLength, moved[0]); // 0 7旧文章常说 ArrayBuffer 永远固定长度。现代语言支持通过 { maxByteLength } 创建可调整大小的 buffer,再调用 resize;普通未带此选项的 buffer 仍不可调整。省略固定 length 的 TypedArray 视图可跟随大小变化,固定长度视图在缩小时可能越界。使用前检查环境支持,不能把新能力假设到所有旧浏览器;转移方法同理。
resize 改变的不只是一个 length 数字
if (typeof ArrayBuffer.prototype.resize === 'function') {
const buffer = new ArrayBuffer(4, { maxByteLength: 8 });
const tracking = new Uint8Array(buffer);
const fixed = new Uint8Array(buffer, 0, 4);
tracking[0] = 9;
buffer.resize(2);
console.log(tracking.length, fixed.length); // 2 0
buffer.resize(6);
console.log(tracking.length, fixed.length); // 6 4
console.log(tracking[0], tracking[3]); // 9 0
}tracking 没指定固定元素数量,所以随 buffer 范围改变;fixed 明确要求 4 个字节,缩到 2 时整个视图越界,length 表现为 0,重新扩大到足够范围后又恢复。被缩掉的存储不会保证原值保留,新增字节初始化为 0。
转移则是另一回事:发送端 buffer 已脱离,不能靠把其他对象扩容让它恢复。共享、复制、重解释、调整大小、转移是不同操作,判断代码时先识别它改变的是存储内容、视图范围还是所有权。
共享内存需要真正的同步协议
SharedArrayBuffer 允许多个 agent 访问同一块存储,不放进 transfer 列表。浏览器通常要求安全上下文和跨源隔离,常见配置涉及 COOP/COEP 响应头;应检查 crossOriginIsolated,并在独立 Worker 环境中组织并行计算。不是在普通网页控制台构造一个对象就完成了多线程配置。
const sharedBuffer = new SharedArrayBuffer(4);
const count = new Int32Array(sharedBuffer);
console.log(Atomics.add(count, 0, 1)); // 0:返回修改前的值
console.log(Atomics.load(count, 0)); // 1此片段在支持共享内存的环境展示原子操作,本身没有启动线程。真实多 Worker 场景中,普通 count[0]++ 是读、计算、写三步,两个线程可能覆盖结果;Atomics.add 将对应更新作为原子操作。Atomics 还提供 load、store、exchange、compareExchange 与等待/通知,用于构建明确协议。单个字段原子更新并不意味着多个字段组成的业务事务原子化。
Atomics.wait 只适用于 SharedArrayBuffer 上的 Int32Array/BigInt64Array,并会阻塞;浏览器主线程不能用它。Worker 中等待也必须重新检查条件,notify 唤醒不等于业务条件已满足。支持时可考虑 waitAsync,仍需处理返回对象中同步完成与异步等待两条路径。没有充分的并行共享需求时,消息传递通常更容易证明正确。
compareExchange 把“检查后再写”合成一步
const state = new Int32Array(new SharedArrayBuffer(4));
const first = Atomics.compareExchange(state, 0, 0, 1);
const second = Atomics.compareExchange(state, 0, 0, 1);
console.log(first, second, Atomics.load(state, 0)); // 0 1 1这里把 0 约定为“未领取”、1 为“已领取”。compareExchange 仅在当前值等于预期值 0 时写入 1,但无论成功与否都返回旧值:第一次返回 0,表示抢到了;第二次返回 1,说明别人已领取。普通 if (state[0] === 0) state[0] = 1 中间可能被另一线程插入,两个线程都以为自己成功,因此不是等价替代。
这只是单位置状态转移,尚不是完整锁。持有者出错时谁释放、等待者何时重试、是否允许重入与超时,都需要协议补充。忙循环反复检查会消耗 CPU;wait/notify 或异步消息可以让等待更合理。notify 只负责唤醒,醒来仍需重新检查共享状态,因为“有人叫醒我”不代表“我要的条件成立”。
在两个 Node Worker 中验证共享计数
以下是 Node 专属的独立 .mjs 文件,使用内置 node:worker_threads,不适用于浏览器直接粘贴。两个 Worker 各执行 1000 次原子增加,主线程等它们退出后读取总数:
import { Worker } from 'node:worker_threads';
const shared = new SharedArrayBuffer(4);
function countInWorker() {
return new Promise((resolve, reject) => {
const worker = new Worker(`
const { workerData } = require('node:worker_threads');
const counter = new Int32Array(workerData);
for (let n = 0; n < 1000; n++) Atomics.add(counter, 0, 1);
`, { eval: true, workerData: shared });
worker.once('error', reject);
worker.once('exit', code => {
if (code === 0) resolve();
else reject(new Error('Worker 退出码 ' + code));
});
});
}
await Promise.all([countInWorker(), countInWorker()]);
console.log(Atomics.load(new Int32Array(shared), 0)); // 2000workerData 传递 SharedArrayBuffer,使两个线程看见同一存储;它不是把每个计数器复制一份,最后再求和。示例等待 exit,确保 Worker 都已退出而不只是某个中间消息到达。将 Atomics.add 改成普通自增可能偶然仍得到 2000,尤其工作很短时;一次没重现竞态不构成安全证据。原子操作的保证来自语义,实验用于检查我们的代码确实按这套语义组织。
浏览器 Worker 需要单独的脚本入口或模块入口,并满足前述安全上下文与隔离条件。这里借 Node 消除服务端响应头配置干扰来观察共享更新,不意味着两种宿主的启动方式完全相同。
练习
同样的两个字节,为什么大端和小端得到不同数值?
修改 subarray 为什么影响原数组,slice 却不受影响?将 subarray 传给解析器时,为什么还要保留 byteOffset 和 byteLength?
为什么两个 Worker 的
count[0]++不可靠,而单独一次 Atomics.add 可以避免这类丢失更新?实现 parsePacket(bytes):输入 Uint8Array,第 0 字节必须为版本 1,接下来的两字节为大端负载长度;本题要求恰好一个完整包,多余字节也拒绝。返回负载副本。验证带前后杂字节的 subarray、截断包、错误版本和声明长度不符;修改原存储后,返回结果不应变化。