|
| 1 | +--- |
| 2 | +id: adv-coding-core-rw-semantics |
| 3 | +title: IO 完成语义与 Port 状态机 |
| 4 | +sidebar_position: 1 |
| 5 | +--- |
| 6 | + |
| 7 | +# IO 完成语义与 Port 状态机 |
| 8 | + |
| 9 | +基础 API 见 [IO 读写抽象](/docs/basic_coding/core/core-rw) 和 [Operation 操作模型](/docs/basic_coding/core/core-op)。下文直接讨论这套 I/O 完成模型为什么这样组织。 |
| 10 | + |
| 11 | +`LibXR` 当前这套 I/O 完成模型可以看成三层:`Operation` 描述这次操作完成后该怎样反馈,`ReadPort / WritePort` 负责队列、busy 状态和完成交接,具体驱动只负责把硬件推进到“接受请求”或“完成请求”这两个边界。真正复杂的状态机留在 `Port`,而不是绑在 `Operation` 上;这不是偶然,而是设计本意。 |
| 12 | + |
| 13 | +`Operation` 故意被做成体积很小、可平凡拷贝的对象,只携带完成方式本身:`CALLBACK`、`BLOCK`、`POLLING` 或 `NONE`,外加对应的小型数据载体。这样做的目的,就是避免把复杂生命周期、waiter 归属、timeout 后交接之类的状态机塞进 `Operation` 本体。否则它一旦变成重对象,不仅复制和传递成本会上升,驱动层和端口层的边界也会开始混乱。 |
| 14 | + |
| 15 | +对 `BLOCK` 来说,这一点尤其重要。`Operation::UpdateStatus()` 在 `BLOCK` 模式下只负责 `sem->PostFromCallback(in_isr)`,并不会把最终 `ErrorCode` 本身塞进信号量语义里。最终结果仍由端口侧的 `block_result_` 交接。换句话说,信号量在这里表达的是“可以醒来检查归属了”,而不是“这次操作必然已经合法完成了”。这正是为了把最危险的问题挡住:一个已经 timeout 返回的 waiter,后面又被迟到完成再次 `post`,于是同一个 semaphore 出现多次唤醒,旧 token 被新调用误认成当前完成。`BLOCK timeout` 首先要防的,就是这种“post 了已经超时返回的信号量”的情况。 |
| 16 | + |
| 17 | +## 1. `ReadPort` 的状态机 |
| 18 | + |
| 19 | +`ReadPort` 当前的核心状态有: |
| 20 | + |
| 21 | +| 状态 | 含义 | |
| 22 | +| ---- | ---- | |
| 23 | +| `IDLE` | 没有挂起读,也没有待交接完成 | |
| 24 | +| `PENDING` | 请求已交给底层推进,等待队列侧完成 | |
| 25 | +| `BLOCK_CLAIMED` | 当前 `BLOCK` 唤醒已经归这次 waiter 所有 | |
| 26 | +| `BLOCK_DETACHED` | timeout 或 reset 已把 waiter 分离,完成侧必须静默 | |
| 27 | +| `EVENT` | 数据先到、waiter 还没挂起;下一次调用必须先重查队列 | |
| 28 | + |
| 29 | +这里最容易被误解的是 `EVENT`。 |
| 30 | + |
| 31 | +它不是“读完成”,而是: |
| 32 | + |
| 33 | +- 数据先进入了软件队列 |
| 34 | +- 但当时还没有可认领的挂起读请求 |
| 35 | + |
| 36 | +所以后续调用方必须重新检查一次 `queue_data_`,而不是盲目重新下发底层读。 |
| 37 | + |
| 38 | +--- |
| 39 | + |
| 40 | +## 2. `WritePort` 的状态机 |
| 41 | + |
| 42 | +`WritePort` 的状态机和 `ReadPort` 不同,因为写路径多了“提交队列所有权”这件事。 |
| 43 | + |
| 44 | +核心状态有: |
| 45 | + |
| 46 | +| 状态 | 含义 | |
| 47 | +| ---- | ---- | |
| 48 | +| `IDLE` | 没有活动提交者,也没有挂起中的 `BLOCK` waiter | |
| 49 | +| `LOCKED` | 当前提交路径占有队列修改权 | |
| 50 | +| `BLOCK_WAITING` | 一个 `BLOCK` waiter 已经挂起,但完成还没 claim | |
| 51 | +| `BLOCK_CLAIMED` | 最终唤醒已经归当前 waiter 所有 | |
| 52 | +| `BLOCK_DETACHED` | timeout/reset 已把 waiter 分离,完成侧不能再 post | |
| 53 | + |
| 54 | +这里的关键点是: |
| 55 | + |
| 56 | +- 多线程并发写安全,并不是“无锁队列自己解决了一切” |
| 57 | +- 真正的外层安全边界是 `busy_` 这道原子门 |
| 58 | + |
| 59 | +也就是说,多个线程不会同时直接冲进驱动 `WriteFun()` 抢底层硬件;只有拿到 `LOCKED` 的那一条路径,才拥有这次提交的队列修改权和 kickoff 权。 |
| 60 | + |
| 61 | +--- |
| 62 | + |
| 63 | +## 3. 端口眼里的“成功”是什么意思 |
| 64 | + |
| 65 | +端口层里最重要的一条约定是: |
| 66 | + |
| 67 | +- 驱动返回 `PENDING`:表示“底层已接受,完成以后再交接” |
| 68 | +- 驱动返回非 `PENDING`:表示“这次调用已经终结” |
| 69 | + |
| 70 | +这就是当前契约里“non-`PENDING` is terminal”的含义。 |
| 71 | + |
| 72 | +它直接带来两个后果: |
| 73 | + |
| 74 | +1. 如果驱动声称自己没进入 `PENDING`,端口不会再替它维护后续完成语义 |
| 75 | +2. 如果驱动在返回非 `PENDING` 后,底层其实还在后台继续跑,就会出现语义错位 |
| 76 | + |
| 77 | +因此驱动设计里必须非常明确:什么时刻算“已经交给硬件”,什么时刻又只是暂时 busy、还没真正接单。`PENDING` 和 non-`PENDING` 的边界一旦划错,就会出现很难看的语义错位:端口以为这次调用已经终结,但底层还在后台继续推进,结果 `BUSY / TIMEOUT / late completion` 全部缠在一起。很多看上去像队列 bug 的问题,本质上都是这里没有钉牢。 |
| 78 | + |
| 79 | +## 4. `BLOCK` timeout 不是取消 |
| 80 | + |
| 81 | +`ReadOperation(sem, timeout)` / `WriteOperation(sem, timeout)` 的 |
| 82 | +`timeout` 都是: |
| 83 | + |
| 84 | +- 相对等待时长 |
| 85 | +- 传给 `Semaphore::Wait(timeout)` 的参数 |
| 86 | + |
| 87 | +它不是绝对 deadline,也不自动意味着底层取消。 |
| 88 | + |
| 89 | +因此 `BLOCK timeout` 的真实含义是:它只限制这次同步等待窗口,并不保证已经被底层接受的操作会被撤销。timeout 之后要做的关键工作,不是“把硬件立刻停掉”,而是把完成归属处理对。只要底层已经启动,迟到完成就仍然可能发生;如果这时候还允许它继续 `post` 旧的 semaphore,就会出现重复唤醒。`BLOCK_DETACHED` 一类状态存在的意义,就是告诉完成路径:这次 waiter 已经不属于原调用者了,后续只能静默收尾,不能再把唤醒投给它。 |
| 90 | + |
| 91 | +## 5. `Reset()` 为什么也走 detach 模型 |
| 92 | + |
| 93 | +`Reset()` 当前不是简单粗暴地把状态直接清成 `IDLE`。 |
| 94 | + |
| 95 | +对 `BLOCK` 路径来说,它和 timeout 的处理模型是一致的: |
| 96 | + |
| 97 | +1. 先把当前 waiter 分离 |
| 98 | +2. 让迟到完成保持静默 |
| 99 | +3. 等旧交接彻底排空后再重新开放端口 |
| 100 | + |
| 101 | +这样做的目的,是避免下面这类竞态: |
| 102 | + |
| 103 | +- 调用者已经 timeout / reset 返回 |
| 104 | +- 底层旧完成稍后到来 |
| 105 | +- 老完成又把新的调用错误唤醒 |
| 106 | + |
| 107 | +--- |
| 108 | + |
| 109 | +所以 `Reset()` 和 timeout 在这里其实是同一类问题:都要先分离当前 waiter,再等旧交接彻底排空,而不是假装世界已经恢复干净。 |
| 110 | + |
| 111 | +## 6. `AsyncBlockWait` 的位置 |
| 112 | + |
| 113 | +`AsyncBlockWait` 不是给 `ReadPort / WritePort` 自己用的状态机替代品,它更像是: |
| 114 | + |
| 115 | +- 给具体驱动内部“同步外观 + 异步硬件”这类路径准备的共享 waiter handoff |
| 116 | + |
| 117 | +它的状态非常直接: |
| 118 | + |
| 119 | +| 状态 | 含义 | |
| 120 | +| ---- | ---- | |
| 121 | +| `IDLE` | 当前没有活跃等待者 | |
| 122 | +| `PENDING` | waiter 已挂起,等待完成 | |
| 123 | +| `CLAIMED` | 完成已经认领当前 waiter | |
| 124 | +| `DETACHED` | timeout 已分离 waiter,完成只能静默回收 | |
| 125 | + |
| 126 | +适合它的场景通常是: |
| 127 | + |
| 128 | +- 驱动本身没有完整走 `ReadPort / WritePort` |
| 129 | +- 但仍然需要“同步等待一个异步完成” |
| 130 | + |
| 131 | +例如某些 `SPI / I2C` 驱动里的 `BLOCK` 事务。 |
| 132 | + |
| 133 | +--- |
| 134 | + |
| 135 | +## 7. 读这套状态机时的总规则 |
| 136 | + |
| 137 | +可以把 `Port` 看成“完成所有权转移器”。 |
| 138 | + |
| 139 | +它真正管理的不是: |
| 140 | + |
| 141 | +- 数据从哪里来 |
| 142 | +- 数据发到哪里去 |
| 143 | + |
| 144 | +而是: |
| 145 | + |
| 146 | +- 现在这次完成到底属于谁 |
| 147 | +- timeout 之后谁还可以说话 |
| 148 | +- 迟到完成应该唤醒谁,还是必须静默 |
| 149 | + |
| 150 | +如果按这个视角去读 `libxr_rw.*`,很多状态名就会顺很多。 |
0 commit comments