浏览器把自己关进了沙箱:Spectre、跨源隔离与那 95 微秒
- 作者:Bougie
- 创建于:2026-10-04
前阵子调一个 WebAssembly 多线程的东西,一上来就撞死了:
ReferenceError: SharedArrayBuffer is not defined
同一个文件摆在 localhost 上就正常,扔到生产环境就报这个。查了一圈才发现,差别不在代码,而在两个响应头。
顺着这两个头查下去,我把整个交易看懂了。先看一个最直观的证据——在这样一个几乎空白的页面上:
let prev = performance.now()
const values = new Set()
const deltas = new Set()
for (let i = 0; i < 200000; i++) {
const n = performance.now()
values.add(n)
const d = n - prev
if (d > 0) deltas.add(d)
prev = n
}
二十万次调用,我拿到了这样两个结果:
没加响应头 {"distinctValues":203, "smallestDeltasMs":[0.0999999, 0.1000000, 0.1999999, ...]}
加了两个响应头 {"distinctValues":3960, "smallestDeltasMs":[0.0049999, 0.0050001, 0.0099999, ...]}
203 个不同的值,3960 个不同的值。中间差的,就是这篇文章要讲的东西。

下面所有数字都是我在 Chrome for Testing 149.0.7827.55(Linux x86_64,navigator.hardwareConcurrency 是 12)上真跑出来的,脚本可以照着复现。
# 一、它防的到底是谁
故事要从 2018 年 1 月说起。Spectre 和 Meltdown 被公开之后,整个行业发现一件很难堪的事:CPU 的推测执行会执行本不该执行的分支,而且即使 CPU 事后把这个结果丢掉了,它在缓存里留下的痕迹不会被丢掉。
于是只要同时满足两个条件,攻击就成立:
- 我能在同一台机器上跑自己的代码(在浏览器里,一个
<script>就够了) - 我有一把足够精细的秒表,能把缓存里"这个位置被调用过"这件事读出来
条件 1 浏览器管不了。同源策略从来只承诺"不让你读数据",从来没承诺过"不让你和我并排坐进同一块内存"。
所以能动的只有条件 2,而浏览器里恰好有两样现成的东西:performance.now() 负责提供时间,SharedArrayBuffer 提供一块能被反复冲刷的共享内存。两个凑在一起,就是一套标准的缓存侧信道测量工具。
浏览器厂商的第一反应很粗暴:2018 年,Chrome 和 Firefox 直接把 SharedArrayBuffer 从页面里摘掉了(后来在站点隔离的保护下短暂放回过一段时间)。到 2021 年,Chrome 92 起换了个思路,改成一笔交易:
想用这些危险的高精度能力?可以。但你得先把整个页面关进一个笼子——笼子里不许有任何没明确"自报家门"的跨源资源,也不许和跨源的窗口有任何关系。
这个笼子叫跨源隔离(Cross-Origin Isolation),两道锁分别叫 COOP 和 COEP。
# 二、实验台:两个 origin,六组响应头
规则不在代码里,全在响应头里,所以实验台得我自己搭。两个 origin:
- 主站
http://localhost:8200(扮演我的网页) - 第三方
http://localhost:8201(扮演 CDN、字体站、广告、嵌入的三方页面)
localhost 虽然是明文 HTTP,但它是 trustworthy origin,同样算 secure context,所以不用配证书就能验证。主站这边按路径给不同的响应头:
const MAIN_HEADERS = {
'/plain': {},
'/coop': { 'Cross-Origin-Opener-Policy': 'same-origin' },
'/coep': { 'Cross-Origin-Embedder-Policy': 'require-corp' },
'/both': {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp'
},
'/cred': {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'credentialless'
},
'/coop-popups': {
'Cross-Origin-Opener-Policy': 'same-origin-allow-popups',
'Cross-Origin-Embedder-Policy': 'require-corp'
}
}
六个页面挨个打开,量四样东西:
| 响应头 | crossOriginIsolated | SharedArrayBuffer | performance.now 最小步长 | measureUserAgentSpecificMemory |
|---|---|---|---|---|
| 什么都不加 | false | undefined | 100 µs | 不存在 |
只有 COOP: same-origin | false | undefined | 100 µs | 不存在 |
只有 COEP: require-corp | false | undefined | 100 µs | 不存在 |
COOP + COEP require-corp | true | function | 5 µs | 可用 |
COOP + COEP credentialless | true | function | 5 µs | 可用 |
same-origin-allow-popups + COEP | false | undefined | 100 µs | 不存在 |
几个必须记住的结论:
- 两个头缺一不可。 只加一个,能力一个都解锁不了。
COOP: same-origin-allow-popups不算数(最后一行是实测值)。这条指令本意是"我和我打开的弹窗之间的关系别切断",但它换不来隔离。做 OAuth 弹窗登录、支付回调窗口的人会卡在这里——因为same-origin会让它们的window.opener变成null(实测:普通页面打开同源弹窗后w.opener !== null,COOP: same-origin下变成null,same-origin-allow-popups下又是好的)。- 除了这两个头,还得是 secure context(HTTPS 或 localhost),并且没有被
Permissions-Policy里的cross-origin-isolated指令禁掉,三样齐了crossOriginIsolated才是true。别猜,运行时读window.crossOriginIsolated就知道了。
浏览器支持这边:COOP/COEP 是 Chrome 83、Firefox 79、Safari 15.2 才有的;credentialless 是 Chrome 96 才加的,到今天也只有 Chromium 系支持。
# 三、拿回来的第一样东西:一把细 20 倍的尺子
开头那两个数字,说的就是这件事。
未隔离时 Chrome 把 performance.now() 的粒度钳到 100 微秒,并且每个值都落在 100µs 的整数格上——所以二十万次调用只吐出 203 个不同的值,大约每 1000 次调用才往前挪一格。隔离之后粒度变成 5 微秒,同样二十万次调用吐出 3960 个值,分辨率提高了 20 倍。

这 95 微秒的差价到底意味着什么?单个缓存命中与否的时间在几十到上百纳秒的量级——所以 5 微秒的尺子依然量不出"一次访问",但这不重要:侧信道攻击从来不是只测一次,它测几千几万次然后做统计。100µs 的尺子把信号抹平了,5µs 的尺子抹不平。
这也是为什么这个能力不能随便给:它是被明文绑定在一个"我先把陌生人全请出去"的承诺上的。
# 四、第二样东西:真正的共享内存
先确认它是真的共享,不是"看起来像":
const sab = new SharedArrayBuffer(16)
const view = new Int32Array(sab)
worker.postMessage({ buf: sab, slot: 0, n: 1000 }) // worker 里做 1000 次 Atomics.add
await done
console.log(view[0]) // 1000 <-- 主线程没有收到任何消息,值自己变了
view[0] 直接是 1000。worker 没有 postMessage 回来,主线程也没有 .then()——同一块物理内存,改完就看见了。
顺手踩到一个老坑:
Atomics.wait(view, 0, 0, 1)
// TypeError: Atomics.wait cannot be called in this context
Atomics.wait 在主线程永远是禁止的(它会把线程卡死),什么条件下都一样。Atomics.waitAsync 则两种环境里都能用。
# 到底能有多快
四百万次 Atomics.add(x, slot, 1),换四种排布:
| 排布 | 耗时 | 每秒操作数 |
|---|---|---|
| 1 个 worker,全做完 | 28.0 ms | 1.43 亿 |
| 4 个 worker,各自写相距 1KB 的槽位 | 13.9 ms | 2.89 亿 |
4 个 worker,写相邻的槽位(slot = i) | 59.6 ms | 0.67 亿 |
| 4 个 worker,抢同一个槽位 | 93.6 ms | 0.43 亿 |
第二行和第三行的差距值得单独说:四个 worker 写的是不同的变量,但因为它们挨得太近,落在同一条 cache line 上,CPU 的缓存一致性协议会把这四次写入当成互相打架。 这就是伪共享(false sharing)——4 个人干活比 1 个人干活还慢一倍。
真实代码里这意味着:用 SharedArrayBuffer 做计数器聚合的时候,别让每个 worker 的计数器在内存里肩并肩站着,给它们 padding 到 64 字节以上。

# 但它不是万能药:postMessage 其实没那么慢
顺便量了一下老办法。一个 worker 和主线程一来一回,做十万次:
roundTrips: 100000, totalMs: 667.8, usPerRoundTrip: 6.678
单次往返 6.678 微秒,也就是每秒大约 15 万次。回头看上面那张表:4 个 worker 伪共享那一行花了 59.6 毫秒——同样的时间里,postMessage 大概能做 8900 次往返。
所以这笔账真正的分水岭不是"快不快",而是"传多少、传几次"。 一次来回 6.7µs 对绝大多数应用来说完全够用;只有当你的数据量大到结构化克隆的成本成为瓶颈、或者调用次数多到百万级的时候,共享内存才真正开始划算。
# 五、第三样东西:看清自己吃了多少内存
隔离解锁的第三个 API 是 performance.measureUserAgentSpecificMemory()。在一个空白页面上调用:
{
"bytes": 432889,
"breakdown": [
{ "attribution": [], "bytes": 174404, "types": ["Shared"] },
{ "attribution": [], "bytes": 0, "types": [] },
{ "attribution": [], "bytes": 24064, "types": ["DOM"] },
{
"attribution": [
{ "scope": "Window", "url": "http://localhost:8200/both" }
],
"bytes": 234421,
"types": ["JavaScript"]
}
]
}
一个几乎什么都没干的页面,43 万字节。其中 JS 堆 23 万,DOM 2.4 万,还有 17 万是浏览器自己那份共享基础设施。attribution 为空的那几条,是归因不到你这个 JS realm 头上的浏览器内部开销。
这个数字每次跑都不一样(JIT 和 GC 状态不同),别拿绝对值当事实。有用的是它的结构:这是唯一一个能在生产环境里、按你自己世界的边界,把内存拆开给前端看的 API。想知道 SPA 跑一晚上涨了多少、某个路由是不是泄漏,以前只能猜,现在能测。
代价:目前只有 Chromium 系支持,而且必须隔离。
# 六、账单来了:第三方资源
前面三样是收货,从这里开始是付款。
我在第三方 origin 上放了五种资源,然后挨个模式去加载它们:
| 主站响应头 | 普通图片(无 CORP) | 带 CORP: cross-origin | 有 CORS 但不写 crossorigin | 有 CORS 且写了 crossorigin | 跨源 iframe |
|---|---|---|---|---|---|
| 无 / 只有 COOP | ✅ | ✅ | ✅ | ✅ | ✅ |
COEP: require-corp(含 COOP 组合) | ❌ | ✅ | ❌ | ✅ | ❌ |
COEP: credentialless | ✅ | ✅ | ✅ | ✅ | ❌ |
被打回去的时候,控制台是这样一句:
Failed to load resource: net::ERR_BLOCKED_BY_RESPONSE.NotSameOriginAfterDefaultedToSameOriginByCoep
后半句是重点:COEP 给没有 CORP 头的资源,默认填了一个 same-origin。 也就是说,你没显式表态,浏览器就替你选了最严的那个。
于是教训很具体:
- 图片、字体、脚本:要么对方响应带上
Cross-Origin-Resource-Policy: cross-origin,要么你走 CORS 并且在标签上老老实实写crossorigin="anonymous"(第三列证明:只有 CORS 头、不写属性,一样跪——这一条最容易在字体上翻车) - iframe:单独一节,见下
<link rel=preload>、<script>、fetch():全部适用,没有例外- 自己的 Service Worker 也绕不过去:我注册了一个监听
/proxyimg的 worker,它内部fetch('http://localhost:8201/nocorp.png', { mode: 'no-cors' })后再把响应吐出去。普通页面OK 1px;同一份代码在COOP + COEP: require-corp的页面上,结果是BLOCKED。别指望用它当代理偷偷绕开这笔帐。

如果你既拿不到第三方资源的 CORP 头,也拿不到 CORS 头,只剩一条路:把它代理到你自己的同源下——注意是服务端代理,不是 Service Worker(SW 那条路刚才已经被实测堵死了)。多一跳,换来不用说服任何人。
# 七、iframe 是最贵的那一刀
第六节最后那列看起来只是"也被拦了",但 iframe 的通行条件和图片根本不是一回事。我试了四种响应头组合(主站都是 COOP: same-origin + COEP: require-corp):
| iframe 文档的响应头 | 结果 |
|---|---|
| 什么都不加 | ❌ 被拦 |
CORP: cross-origin | ❌ 被拦 |
CORP + COOP: same-origin | ❌ 被拦 |
CORP + COEP: require-corp | ✅ 通过 |
什么都不加,但写 <iframe credentialless> | ✅ 通过 |
第一条最反直觉:给 iframe 加 CORP 头没用。 人家要的是"你自己的 COEP",不是"你允不允许被嵌"。这就是当年 YouTube / Twitter / 各种嵌入式 consumer 组件一开 COEP 就全部消失的原因——那些 origin 你根本联系不上,谈何加 COEP。
Chromium 后来为此专门做了一扇逃生门:anonymous iframe(第四行的 <iframe credentialless> 实测可用,哪怕第三方一个响应头都没加)。它给这个 iframe 一个独立的、没有任何凭证的临时环境(相当于用一个全新的、从未登录过的浏览器打开它),所以浏览器肯让它进笼子。
代价也很清楚:里面的页面拿不到 cookie、storage,登录态全丢。嵌个 YouTube 视频可以,嵌个需要登录的后台 iframe 不行。而且这属性 Chromium 系独有。
# 八、credentialless:要不要凭证,你自己选
require-corp 太硬,于是有了第二种 COEP:credentialless。
它的逻辑简单粗暴:既然没有 CORP 的资源这么麻烦,那我请求的时候干脆不带凭证,这样它拿不到任何跟用户身份有关的东西,也就没什么可泄漏的了——于是 CORP 检查可以放行。
服务端日志是这么写的(我给两个页面种了同一个 cookie):
CDN GET /nocorp.png cookie=probe=abc123 <-- 普通页面发起的请求
CDN GET /nocorp.png cookie=NONE <-- credentialless 页面发起的请求
一行 cookie=NONE,说完了全部:credentialless 会把跨源 no-cors 请求上的 cookie 全部摘掉。
翻译成人话:
- CDN 上的图片、字体、静态资源:无所谓,它们本来就不要凭证 →
credentialless是净赚,你一个第三方联系人都不用说服 - 需要登录态的 API、带认证的 CDN 私有文件:直接失效,且失败得很安静
- Firefox / Safari 不认这个值 → 你还是得同时备一套
require-corp,按 UA 分支下发两个头
# 九、真要上线,怎么开
顺序很重要,别一上来就全量。
1. 先只报告,不执行。 两个 -Report-Only 版本先铺上去,配套的端点用 Reporting-Endpoints 定义:
Reporting-Endpoints: coop="https://example.com/report", coep="https://example.com/report"
Cross-Origin-Opener-Policy-Report-Only: same-origin; report-to="coop"
Cross-Origin-Embedder-Policy-Report-Only: require-corp; report-to="coep"
这种方式下资源该加载照样加载,只是违规行为会发报告。静默观察一周,看看哪些资源会挂——尤其是那些"只在某个地区的 CDN 上才缺 CORP"的资源。
2. 旧的浏览器 / 不支持的浏览器要有退路。 运行时判断比 UA 嗅探可靠:
if (self.crossOriginIsolated) {
startSharedMemoryVersion()
} else {
startPostMessageVersion() // 6.7µs 一次往返,其实够用
}
3. 加上生产环境的两个头。 别忘了 always,它负责让 4xx/5xx 响应也带上:
add_header Cross-Origin-Opener-Policy same-origin always;
add_header Cross-Origin-Embedder-Policy require-corp always;
如果这个域名前面还挂着 CDN,记住一件事:CDN 缓存响应头的规则要一并改,否则你会遇到"源站有头、边缘节点没头"的鬼故事——页面在部分节点上是隔离的,在另一部分节点上不是,crossOriginIsolated 时真时假。
4. 把靠 opener 传值的窗口通讯改掉。 COOP: same-origin 会让 window.opener === null(实测:普通页面和 same-origin-allow-popups 下 opener 还在,same-origin 下就没了)。OAuth 弹窗、支付回调这些老写法会静默失效,改成 postMessage 或者服务端回跳。
5. 最后再确认一次: window.crossOriginIsolated === true。这是唯一的真相,别信"头应该加上了"。
# 十、这笔交易划不划算
一句话总结我这一天的实验:
跨源隔离不是性能优化,是一笔交换。你交出的是"随便加载别人资源"的自由,换回的是"共享内存 + 5 微秒的时钟 + 看自己吃多少内存"这三个能力。
该开的:
- WebAssembly 多线程:ffmpeg.wasm、多线程版的 SQLite / DuckDB、图像视频编解码、物理模拟——它们的线程之间必须共享线性内存,postMessage 的克隆成本会让多线程失去意义
- 需要把计时精度做到微秒级的自监控 / A/B 计时 / 渲染管线测量
- 长会话应用(编辑器、设计工具、监控大盘)想在生产环境真的盯住自己的内存曲线
不该开的:
- 内容站、营销页、广告变现页面:第六节那张表每一行 ❌ 都可能是真金白银,而你可能连第三方服务商的电话都打不通
- 只有一两个 worker、数据量不大的场景:记住那个 6.678 微秒。如果你的瓶颈不在这里,别为了"现代化"去付这笔管理费
对一个完全由自己说了算的工具型应用来说,这笔交易划算得很——毕竟要说服的人只有你自己;对一个往上贴了 20 个第三方 script 的站点来说,它可能就是"打开两个响应头,网站废一半"的开始。
至于我开头撞到的那个 ReferenceError:加上两个响应头之后它消失了,我那台 Linux 机器上的 12 个核第一次在同一个页面里真·共享起了同一块内存。这个交换我付得起,因为我那个页面从头到尾只加载自己的东西。