3. 目录协议核心概念:目录条目、状态与所有权

好,我们进入正题。这一章要讲的,是 CXL.cache 协议里最基础、也最容易搞混的三个概念:目录条目、状态和所有权。我个人做协议验证的时候,发现很多工程师在这三个东西上栽跟头。

说白了,目录协议就是个“记账本”。谁拿了哪块数据,拿了什么权限,这个本子记得清清楚楚。咱们今天就看看这个本子长什么样,怎么记,以及记错了会出什么乱子。

核心要点:目录条目是缓存一致性管理的最小单元,状态描述访问权限,所有权决定数据迁移的发起方。

3.1 目录条目:协议的心脏

一个目录条目,就是一笔账。它记录了某个缓存行在系统中的归属和状态。我习惯把它理解成一个“结构体”,里面塞了几个关键字段。

典型的目录条目包含以下信息:

  • 物理地址标签:标识这条数据是哪块内存的(我见过有人叫它 Tag)。
  • 状态编码:数据当前处于什么状态(I/S/E/M,后面会细说)。
  • 所有者列表:哪些 Cache 持有这条数据的副本(唯一所有者还是多个共享者)。
  • 脏位:数据有没有被修改过(和 M 状态紧密相关,我犯过的最蠢错误就是脏位没同步)。

举个例子,目录像下面这样:

地址 Tag 状态 所有者 ID 脏位
0xA000 E Cache-3 0
0xB000 S Cache-1, Cache-2 0
0xC000 M Cache-4 1

看到没?同一块内存,它在不同地址上的状态可以完全不同。目录就是靠这张表,维持着系统的全局一致性。

一个小技巧:调试时直接把目录条目 dump 出来看。我遇到的大部分死锁问题,最后都能从目录条目的状态矛盾里找到线索。

3.2 状态:缓存行的通行证

CXL.cache 的状态其实脱胎于 MESI 协议,但稍微简化了一点。一共就三种状态:I(无效)、S(共享)、E/M(唯一/修改)。

你可能会问:“E 和 M 不是分开的吗?” 是的,但在 CXL.cache 的目录协议里,E 和 M 在目录视角下通常被合并处理。为什么呢?

因为目录只关心“谁能写”。E 和 M 的拥有者都享有独占权限,区别只在于 M 的数据是最新的(脏的),而 E 的数据和内存一致。目录强制把 E/M 看成同一个授权类别——“唯一所有者”。

状态转换的规则其实很直白:

  1. I → S:读未命中,向目录请求共享副本。目录同意,并加入共享列表。
  2. I → E/M:读未命中但请求独占,或者写未命中。目录撤销所有其他副本后,授予唯一所有权。
  3. S → I:其他 Cache 要独占写入,目录会发 Invalidaton 给所有共享者。
  4. S → E/M:共享者想写,需要发送 Upgrade 请求。目录先收回复本(如果别人也有 S),再升级。
  5. E/M → I:唯一所有者被外部撤销(或者自己写回)。
  6. E/M → S:唯一所有者被要求降级为共享(比如另一个请求只读)。

嗯,这里面最难搞的就是“S → E/M”这个路径。曾经有一个项目,一个共享者升级时忘了发 Upgrade,直接发写请求,结果目录以为它要读,直接给了数据——数据就乱了。

避坑指南:状态转换的触发条件必须和目录的授权严格同步。目录说你没资格写,你就不能写。我曾经在验证环境里故意制造了一次“违反状态写数据”的场景——不出意料,数据一致性直接崩溃。

3.3 所有权:谁说了算

我理解的“所有权”,就是“数据迁移的发起权”。在 CXL.cache 里,所有权分两种:

  • 数据所有权:我手里有最新的数据。对 M 状态的 Cache 来说,它拥有数据写回权和数据提供权。
  • 访问所有权:我可以在不被驱逐的情况下访问数据。对 S 状态的 Cache 来说,它只拥有访问权,没有提供权。

为什么要区分这个?因为所有权决定了谁需要响应目录的“回写”或者“侦听”请求。

举个例子:

假设目录有一条数据在 Cache-A 里是 E 状态。这时 Cache-B 要读。目录会选择:

  1. 方式一:让 Cache-A 提供数据,同时降级为 S。目录更新条目为“共享列表包含 A 和 B”。
  2. 方式二:让 Cache-A 写回并失效,目录再发给 Cache-B 一份新数据。目录条目状态变成“I + B 有 S”。

方式一保留了 Cache-A 的所有权(降级),方式二实际上将所有权从 A 转移到 B。我个人在设计快速转发路径时,更喜欢方式一,因为它减少了内存访问延时。

所有权还有一个关键点:目录必须唯一知道谁有所有权。这就是为什么目录条目是安全基石。

3.4 三者关系:一张图看懂

看文字可能有点抽象。我画了一张图,把目录条目、状态、所有权的关系串起来:

目录协议核心概念关系图 目录条目 地址Tag / 状态 / 所有者 / 脏位 状态 I / S / E/M 所有权 数据所有权 / 访问所有权 I 状态 → 无所有权 无效副本 S 状态 → 访问所有权 可读,不可写,不可提供 E/M 状态 → 数据所有权 可读可写,可提供数据 三者关系总结 目录条目记录状态 → 状态决定所有权的类型 → 所有权决定数据交互规则 错误的状态会导致错误的所有权推测,进而引发一致性问题

嗯,图上很清楚:目录条目像个“总控”,它依靠状态字段来推断所有权细节。状态一旦异常,整个所有权链条都会崩掉。

3.5 实战视角:一个典型交互

假设有一个场景。Cache-A 想写一个地址,它先问目录:

  1. Cache-A 发 WrInv 请求。
  2. 目录查条目:地址 0xC000 当前所有者是 Cache-B(E 状态)。
  3. 目录发 SnpInv 给 Cache-B,要求失效并写回(如果需要)。
  4. Cache-B 收到后,写回数据到内存,并回复 Acknowledge。
  5. 目录收到确认,把条目更新为:地址 0xC000 → M 状态 → 所有者 Cache-A → 脏位 1。
  6. 目录发 GntD(授权数据)给 Cache-A,同时附带写回的数据(如果数据是由目录从内存取,或者由 Cache-B 推送)。

这里面每一个步骤的状态变化都强依赖于目录条目的正确性。我当年排查过一个 bug:

  • 目录在收到 Cache-B 的失效确认之前,就先把条目改成了新所有者。
  • 结果 Cache-A 以为写完了,立刻又发下一个写请求。
  • 目录根据错误的条目状态,认为没有冲突,直接授权——数据覆盖发生。

你看,所有权的转移必须是一个“原子操作”,否则就出大事。

经验之谈:我自己写验证组件时,专门对着目录条目做了一个“状态机镜像”——如果目录条目和组件的状态镜像对不上,立刻报 Error。这个方法帮我抓住了好几个死锁。

3.6 容易混淆的几个点

最后列一下我见过的高频踩坑点:

  • “目录条目中的 E 和 M 是同一回事吗?” 在目录角度看,是的——都是唯一所有者。但 M 需要数据写回 E 不需要。
  • “所有权的转移要不要等数据写回?” 一定要。我试过不等写回直接转移所有权,结果就是读了脏数据。
  • “S 状态的 Cache 能不能主动发起数据提供?” 不可以。S 状态只有访问权,没有数据提供权。提供数据必须由目录授权。
  • “脏位能省略吗?” 不能!脏位决定内存数据是否有效。省略脏位,目录就不知道是否需要写回。

嗯,这一章就这些。理解目录条目、状态与所有权,后面的状态转换图才能看懂。别急,我们一步一个脚印。