<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[【 基于 io_uring 的 C++20 协程网络库】11 停止机制补完]]></title><description><![CDATA[<p dir="auto">在第 01 篇里，我留了两个 stop 相关的问题。</p>
<p dir="auto">第一个是：<code>stop()</code> 调了，但 <code>run()</code> 可能还卡在 <code>submit_and_wait</code> 里出不来。<br />
第二个是：就算 <code>run()</code> 退了，内核里可能还有已提交但未完成的操作。</p>
<p dir="auto">第一个问题在第 02 篇已经用 <code>eventfd</code> 解决了。这篇只聊第二个：怎么把停机过程做成可验证的<strong>先取消、再排干、最后退出</strong>。</p>
<h3>1. 强行退出的Pending状态与资源泄漏隐患</h3>
<p dir="auto">目前 <code>run()</code> 的退出条件为：</p>
<pre><code class="language-cpp">while (!should_stop_.load(std::memory_order_relaxed) &amp;&amp; outstanding_works_ &gt; 0)
</code></pre>
<p dir="auto">当 <code>should_stop_</code> 置为 true 时，循环会立刻终止。但这时协程可能还挂在 <code>read</code>、<code>accept</code>、<code>sleep_for</code> 上，对应 SQE 已交给内核，CQE 还没回来。</p>
<p dir="auto">如果事件循环直接退出，<code>io_uring</code> 被析构后，用户态后续完成路径就可能拿到历史的 <code>user_data</code> 并把它当作 <code>Operation*</code> 解引用。只要这个对象已经销毁，就会变成悬垂指针风险。靠<strong>析构顺序刚好没出事</strong>不是一个可靠方案。</p>
<h3>2. 确定的停机序列：取消与排干（Draining）</h3>
<p dir="auto">正确的回收顺序是：退出前先把挂起操作都收回来。</p>
<p dir="auto">所以 <code>stop()</code> 触发后，不应该立刻跳出循环，而是要先向内核发取消请求，再等所有操作（包括被取消的操作）把 CQE 回完，最后再退出。</p>
<p dir="auto"><code>io_uring</code> 提供了 <code>io_uring_prep_cancel</code> 接口。通过传入 <code>Operation*</code> 作为 <code>user_data</code>，内核能够定位并中断对应的操作。随后，内核会投递一个结果为 <code>-ECANCELED</code> 的 CQE，触发该操作的 <code>complete()</code> 回调，使得挂起的协程能够沿着正常的异常或错误处理路径恢复。</p>
<p dir="auto">这样做的好处是，不管停机发生在什么时候，协程都还能沿正常路径恢复，栈帧能按预期展开和销毁。前提是：我们得准确知道当前还有多少活跃操作。</p>
<h3>3. 方案取舍：全局取消的局限性与侵入式追踪</h3>
<p dir="auto">有一个更省代码的做法：提交一个特殊 Cancel SQE，带上 <code>IORING_ASYNC_CANCEL_ANY | IORING_ASYNC_CANCEL_ALL</code>，让内核尽可能全量取消。</p>
<p dir="auto">这条路看起来很短，但我最后没选，原因有三个。</p>
<p dir="auto">第一，状态不够确定。它是“尽力取消”，不是“给我精确活跃列表”。命令发出去后，用户态依然不知道还有多少 CQE 没回来。</p>
<p dir="auto">第二，语义太粗。像 <code>timeout</code> 组合子这种场景，一个 <code>co_await</code> 可能拆成多个底层请求（业务 I/O + timer）。全量取消能快速收敛，但会把上层状态机语义一起打平。</p>
<p dir="auto">第三，也是最关键的：我真正要解决的是“<code>run()</code> 什么时候能安全退出”。没有精确活跃计数，退出条件就没法验证。</p>
<p dir="auto">tips：</p>
<ol>
<li>这类 ANY/ALL 取消属于能力驱动特性，使用前要确认内核与 liburing 版本是否支持对应标志位语义。</li>
<li>取消结果本身也会通过 CQE 回来，可能出现“目标已完成/未命中”等结果码，这些都意味着它不适合作为唯一退出判据。</li>
<li>在我的实现里，它可以作为“加速收敛”的辅助工具，但不能替代用户态的活跃操作追踪。</li>
</ol>
<p dir="auto">所以我还是选了侵入式链表。链表不空，说明还有操作在等 CQE；链表清空，才允许退出。</p>
<p dir="auto">每个 awaiter 本来就继承自 <code>Operation</code>，把 <code>prev/next</code> 放进基类，插入删除都是 O(1)，而且不需要额外分配。</p>
<h3>4. 零开销追踪：侵入式链表实现</h3>
<p dir="auto">实现上我做得很直接：把侵入式指针放进 <code>Operation</code> 基类。</p>
<pre><code class="language-cpp">struct Operation {
    Operation* prev{ nullptr };
    Operation* next{ nullptr };
    bool is_canceling_{ false };

    virtual ~Operation() = default;
    virtual void complete(int res, std::uint32_t flags) noexcept = 0;
};
</code></pre>
<p dir="auto"><code>IOContext</code> 中维护链表指针及活跃操作计数：</p>
<pre><code class="language-cpp">Operation* head_{ nullptr };
Operation* tail_{ nullptr };
std::size_t tracking_operations_{ 0 };
</code></pre>
<p dir="auto"><code>track()</code> 和 <code>untrack()</code> 分别负责挂入、摘除，同时更新计数：</p>
<pre><code class="language-cpp">void IOContext::track(gsl::not_null&lt;Operation*&gt; operation) noexcept
{
    if (!head_) {
        head_ = tail_ = operation;
    }
    else {
        tail_-&gt;next = operation;
        operation-&gt;prev = tail_;
        tail_ = operation;
    }
    add_work();
}

void IOContext::untrack(gsl::not_null&lt;Operation*&gt; operation) noexcept
{
    // ... 链表节点摘除逻辑 ...
    operation-&gt;prev = operation-&gt;next = nullptr;
    drop_work();
}
</code></pre>
<h3>5. 生命周期绑定：在 Awaiter 中管理注册</h3>
<p dir="auto">以 <code>ReadSomeAwaiter</code> 为例，我这里遵循一个很朴素的约定：<code>await_suspend</code> 里注册，<code>complete</code> 里注销。</p>
<pre><code class="language-cpp">void ReadSomeAwaiter::await_suspend(std::coroutine_handle&lt;&gt; handle) noexcept
{
    handle_ = handle;

    auto* sqe = context_.sqe();
    prepare(sqe);
    ::io_uring_sqe_set_data(sqe, this);

    context().track(this);   // 提交 SQE 后显式声明生命周期开始
}

void ReadSomeAwaiter::complete(int result, std::uint32_t flags) noexcept
{
    context().untrack(this);  // CQE 返回后第一优先注销生命周期

    set_result(result, flags);
    if (handle_) {
        handle_.resume();
    }
}
</code></pre>
<p dir="auto">其他底层 awaiter 也都按这个模式来，这样挂起操作和链表节点始终是一对一关系。</p>
<p dir="auto">写路径里有个值得单独提一下的特例：<code>WriteAllAwaiter</code>。</p>
<p dir="auto">它和 <code>WriteSomeAwaiter</code> 不一样，不是一次 SQE 就结束，而是“分段发送直到 buffer 清空”的循环模型。也正因为这样，<code>track/untrack</code> 的时机要更小心：每一轮 <code>complete()</code> 先 <code>untrack(this)</code>，再决定是 <code>resume</code> 还是继续 <code>arm_write()</code>。</p>
<pre><code class="language-cpp">void WriteAllAwaiter::complete(int result, std::uint32_t flags) noexcept
{
    context_.untrack(this);
    set_result(result, flags);

    if (is_canceling_ || error_code_ != 0 || buffer_.empty()) {
        if (is_canceling_ &amp;&amp; error_code_ == 0)
            error_code_ = ECANCELED;
        resume(handle_, result, flags);
    }
    else {
        arm_write();
    }
}
</code></pre>
<p dir="auto">这里还有一个细节：取消路径里，如果当前轮没有带出错误码，会主动归一到 <code>ECANCELED</code>。这样上层就不会看到“已经取消但结果像成功”的歧义状态。</p>
<h3>6. 复杂状态机处理：超时组合子与 <code>sqe()</code> 接口收敛</h3>
<p dir="auto">普通 awaiter 基本是一对一：一个 <code>Operation</code> 对一个 SQE。到了 timeout 组合逻辑，就会出现多 CQE 的竞争。</p>
<p dir="auto">第一种是 <code>TimeoutAwaiter</code>。它用 <code>IOSQE_IO_LINK</code> 把业务操作和超时请求绑在一起。虽然内核会回两个 CQE，但它们共享同一个 <code>Operation</code> 节点，所以只需要追踪一次，等两个 CQE 都回来再摘除：</p>
<pre><code class="language-cpp">void await_suspend(std::coroutine_handle&lt;&gt; handle) noexcept
{
    // 发送两个 linked SQE，但在业务视角仅追踪一个逻辑单元
    context().track(this);
}

void complete(int result, std::uint32_t flags) noexcept override
{
    set_result(result, flags);

    if (--pending_cqes_ == 0) {
        context().untrack(this);  // 确保底层事件全部终结
        handle_.resume();
    }
}
</code></pre>
<p dir="auto">第二种是 <code>TimeoutCombinator</code>。这条路是解耦设计：业务操作和计时器作为两个独立 <code>Operation</code> 提交。</p>
<pre><code class="language-cpp">void await_suspend(std::coroutine_handle&lt;&gt; handle) noexcept
{
    auto* sqe = context().sqe();
    ::io_uring_prep_timeout(sqe, &amp;timeout_, 0, 0);
    ::io_uring_sqe_set_data(sqe, &amp;timer_);

    context().track(&amp;timer_);        // 追踪独立的计时器节点
    awaiter_.await_suspend(handle);  // 内部 Awaiter 自行管理追踪
}
</code></pre>
<p dir="auto">在竞速过程中，谁先完成，就调用 <code>context().cancel()</code> 去中断另一方。这样 timeout 组合子和全局停机路径就复用了同一套取消逻辑，也把旧版 <code>sqe(false)</code> 这种隐式开关顺带收掉了。</p>
<p dir="auto">这其实是一个我早就想动的点了：<code>sqe(bool tracking = true)</code>。</p>
<p dir="auto">这个参数最初是我为了赶进度加的。当时想法很直接：业务请求默认 <code>sqe(true)</code>，取消请求写 <code>sqe(false)</code>，先把功能跑通。</p>
<p dir="auto">但写完我就觉得不舒服。调用点里一个裸 <code>bool</code>，语义全靠人脑补，<code>false</code> 到底是“不计数”还是“这是取消请求”，每次都得回到定义处确认。这是一种典型的bad smell了。</p>
<p dir="auto">旧接口是这样的：</p>
<pre><code class="language-cpp">auto sqe(bool tracking = true) -&gt; ::io_uring_sqe*;

// 取消路径
auto* sqe = context().sqe(false);
::io_uring_prep_cancel(sqe, &amp;timer_, 0);
::io_uring_sqe_set_data(sqe, nullptr);
</code></pre>
<p dir="auto">这次停机重构正好给了我一个机会，把这个开关彻底拿掉：<code>sqe()</code> 只负责“拿一个 SQE”；计数交给 <code>track/untrack</code>；取消统一走 <code>cancel()</code>。</p>
<pre><code class="language-cpp">auto sqe() -&gt; ::io_uring_sqe*;

// 取消路径
context().cancel(&amp;timer_);
</code></pre>
<p dir="auto">拆完之后，接口职责就清楚了：</p>
<ol>
<li><code>sqe()</code> 只做资源分配。</li>
<li><code>track/untrack</code> 只做生命周期计数。</li>
<li><code>cancel()</code> 统一承载取消语义。</li>
</ol>
<p dir="auto">所以这次看起来是在修停机，其实也顺手把一处历史包袱清掉了。<code>sqe(bool)</code> 这个临时方案当时确实帮我快速推进了实现，但到了这个阶段，是时候干掉他了。</p>
<h3>7. 重构事件循环：排干后退出</h3>
<p dir="auto">事件循环的退出条件现在就一条：活跃计数归零。</p>
<pre><code class="language-cpp">void IOContext::run()
{
    while (tracking_operations_ &gt; 0) {
        if (should_stop_.load(std::memory_order_relaxed)) {
            auto* current = head_;
            while (current) {
                cancel(current);
                current = current-&gt;next;
            }
        }

        scheduler_.schedule();
    }
}
</code></pre>
<p dir="auto"><code>cancel()</code> 负责防重复，并投递取消指令：</p>
<pre><code class="language-cpp">void IOContext::cancel(gsl::not_null&lt;Operation*&gt; operation) noexcept
{
    if (operation-&gt;is_canceling_) return;  

    if (auto* sqe = scheduler_.sqe()) {
        ::io_uring_prep_cancel(sqe, operation, 0);
        ::io_uring_sqe_set_data(sqe, nullptr);  // 显式丢弃取消动作本身的 CQE
        operation-&gt;is_canceling_ = true;
    }
}
</code></pre>
<p dir="auto">到这里，<code>schedule()</code> 就不需要再返回“这一轮完成了多少”。计数和生命周期都在 <code>Operation</code> 自己的 <code>track/untrack</code> 路径里闭环。</p>
<h3>8. 完整的停机时序</h3>
<p dir="auto">停机流程如下：</p>
<pre><code class="language-text">[async::stop() 触发]
      |
      | 置 should_stop_ = true
      | eventfd 写入信号，中断 submit_and_wait 阻塞
      v
[run() 进入排干模式]
      |
      | 遍历侵入式链表
      | 调用 io_uring_prep_cancel 派发中断指令
      v
[内核态收割]
      |
      | 中止 I/O 轮询
      | 投递包含 -ECANCELED 的 CQE
      v
[schedule() 消费 CQE]
      |
      | 触发 op-&gt;complete(-ECANCELED, 0)
      | 节点自我 untrack()，递减 tracking_operations_
      | 协程带着 operation_canceled 错误流转并销毁
      v
[tracking_operations_ 归零]
      |
      | run() 循环安全终止
      v
[资源清理]
</code></pre>
<p dir="auto">通过这套机制，停机路径就变成了可验证流程：先唤醒、再取消、再排干、最后退出。在当前实现约束下，这能持续收敛悬垂指针风险，也让退出行为更可预期。</p>
]]></description><link>http://forum.d2learn.org/topic/200/基于-io_uring-的-c-20-协程网络库-11-停止机制补完</link><generator>RSS for Node</generator><lastBuildDate>Fri, 01 May 2026 05:56:41 GMT</lastBuildDate><atom:link href="http://forum.d2learn.org/topic/200.rss" rel="self" type="application/rss+xml"/><pubDate>Fri, 01 May 2026 03:05:04 GMT</pubDate><ttl>60</ttl></channel></rss>