<?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 协程网络库】03 基于链式请求的零开销超时机制]]></title><description><![CDATA[<p dir="auto">这一步要实现什么，我也很纠结。最好的当然是直奔socket的封装，然后实现async accept，async read，async write这些典型的协程调用。</p>
<p dir="auto">但是上文中留下一些坑又需要尽快填一下，否则后续再改会导致更大的重构。我们需要在这一篇中解释一下为什么要在 <code>PollAwaiter</code> 中开放set_result，prepare，context这些看起来毫无卵用的接口。</p>
<p dir="auto">最主要的目的就是能实现一个timeout接口。</p>
<p dir="auto">在传统的 Reactor 模型（如 epoll）中，实现超时通常需要在用户态维护一个独立的数据结构（如最小堆或时间轮）来管理定时器，这往往伴随着额外的动态内存分配（分配定时器节点）以及后台线程的唤醒开销。</p>
<p dir="auto"><code>io_uring</code> 提供了更底层的解法：<strong>链式请求（Linked Requests）</strong>。</p>
<p dir="auto">通过将 I/O 操作与定时器在内核态进行绑定，我们可以将状态同步的复杂度完全下沉至内核，从而实现真正的零动态分配超时机制。</p>
<h3>1. 接口约束：single_shot_only_operation</h3>
<p dir="auto">在实现通用的超时包装器之前，我们需要界定“什么类型的操作允许被包装”。</p>
<p dir="auto">我们定义 <code>single_shot_only_operation</code>，要求目标类型不仅继承自 <code>Operation</code> 基类，还必须提供用于获取上下文、组装 SQE 以及处理结果的特定接口：</p>
<pre><code class="language-cpp">#include &lt;concepts&gt;
#include &lt;coroutine&gt;
#include &lt;expected&gt;
#include &lt;system_error&gt;

template&lt;typename T&gt;
concept single_shot_only_operation = requires (T&amp; op, ::io_uring_sqe* sqe)
{
    typename T::resume_type;

    requires std::is_lvalue_reference_v&lt;decltype(op.context())&gt;;
    op.context();
    op.prepare(sqe);
    op.set_result(0, 0);
    { op.await_resume() } -&gt; std::same_as&lt;std::expected&lt;typename T::resume_type, std::error_code&gt;&gt;;
} &amp;&amp; std::derived_from&lt;T, Operation&gt;;
</code></pre>
<p dir="auto">基于此约束，无论底层的协程等待体是 <code>Socket::async_read</code> 还是 <code>SignalSet::async_wait</code>，只要满足条件，编译器即可保证其能被安全地赋予超时语义。</p>
<h3>2. 核心机制：IOSQE_IO_LINK 与内核竞速</h3>
<p dir="auto"><code>io_uring</code> 的 SQE 链式调用是我们实现零开销超时的核心。当我们在一个 SQE 的标志位中设置 <code>IOSQE_IO_LINK</code> 时，内核会将其与紧随其后提交的下一个 SQE 绑定为一个原子链。</p>
<p dir="auto">配合专用的操作码 <code>IORING_OP_LINK_TIMEOUT</code>，内核会执行以下竞速逻辑：</p>
<ol>
<li>内核并行处理业务 I/O 请求，并同时启动定时器。</li>
<li>如果业务请求先完成，内核自动取消挂载的定时器。</li>
<li>如果定时器先到期，内核自动强行取消业务请求，并使其返回 <code>-ECANCELED</code>。</li>
</ol>
<p dir="auto">这种将同步状态机交由内核仲裁的设计，使得用户态代码无需介入复杂的取消流程。</p>
<h3>3. TimeoutAwaiter 的内存安全陷阱与实现</h3>
<p dir="auto">构建 <code>TimeoutAwaiter</code> 时，面临的最大工程挑战是<strong>协程帧的生命周期管理</strong>。</p>
<p dir="auto">由于我们向内核一次性提交了两个 SQE（业务 I/O 与 Timeout），内核在执行完毕后，<strong>必然会返回两个对应的 CQE</strong>。如果采用“先到先得”的简单逻辑，在第一个 CQE 到达时立即调用 <code>handle.resume()</code> 恢复协程，会导致一个隐蔽且致命的 Use-After-Free (UAF) 漏洞：</p>
<p dir="auto">当协程被恢复后，包含在该协程帧内的 <code>TimeoutAwaiter</code> 对象可能会随着当前作用域的结束而立即析构。此时，内核中仍有一个被取消的 CQE 正在返回途中。当 <code>IOContext</code> 收割这个滞后的 CQE 并尝试调用 <code>complete</code> 时，其 <code>user_data</code> 指针已指向被释放的内存，导致进程崩溃。</p>
<p dir="auto">因此，必须在 <code>complete</code> 中引入一个无锁的计数器屏障，确保两个 CQE 全部落地后，再将控制权交还给协程。</p>
<p dir="auto">完整的 <code>TimeoutAwaiter</code> 实现如下：</p>
<pre><code class="language-cpp">template&lt;single_shot_only_operation InnerOperation&gt;
class TimeoutAwaiter : public Operation {
public:
    using resume_type = typename InnerOperation::resume_type;

    template&lt;typename Duration&gt;
    TimeoutAwaiter(InnerOperation&amp;&amp; operation, Duration timeout) noexcept
      : inner_operation_{ std::forward&lt;InnerOperation&gt;(operation) }
    {
        using namespace std::chrono;

        timeout_.tv_sec = duration_cast&lt;seconds&gt;(timeout).count();
        timeout_.tv_nsec = duration_cast&lt;nanoseconds&gt;(timeout % 1s).count();
    }

    constexpr auto await_ready() const noexcept -&gt; bool { return false; }

    void await_suspend(std::coroutine_handle&lt;&gt; handle) noexcept
    {
        handle_ = handle;

        auto* io_sqe = context().sqe();
        auto* timeout_sqe = context().sqe();

        // 组装业务 I/O 并设置链式标志
        inner_operation_.prepare(io_sqe);
        io_sqe-&gt;flags |= IOSQE_IO_LINK;
        ::io_uring_sqe_set_data(io_sqe, this);

        // 紧跟超时探测请求
        ::io_uring_prep_link_timeout(timeout_sqe, &amp;timeout_, 0);
        ::io_uring_sqe_set_data(timeout_sqe, this);
    }

    auto await_resume() noexcept -&gt; std::expected&lt;resume_type, std::error_code&gt;
    {
        if (is_timed_out_)
            return unexpected_system_error(std::errc::timed_out);

        inner_operation_.set_result(result_, 0);
        return inner_operation_.await_resume();
    }

    void set_result(int result, std::uint32_t flags) noexcept
    {
        if (result == -ETIME)
            is_timed_out_ = true;
        else if (result != -ECANCELED)
            result_ = result;
    }

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

        if (--pending_cqes_ == 0) {
            auto handle = std::exchange(handle_, {});
            handle.resume();
        }
    }
    
    auto context() noexcept -&gt; decltype(std::declval&lt;InnerOperation&amp;&gt;().context())
    {
        return inner_operation_.context();
    }

private:
    InnerOperation inner_operation_;
    struct __kernel_timespec timeout_{};

    std::coroutine_handle&lt;&gt; handle_{ nullptr };
    
    int pending_cqes_{ 2 }; // 提交了 2 个 SQE，必然返回 2 个 CQE
    bool is_timed_out_{ false };
    int result_{ -ECANCELED };
};

template&lt;single_shot_only_operation Operation, typename Duration&gt;
auto timeout(Operation&amp;&amp; awaitable, Duration t) noexcept -&gt; TimeoutAwaiter&lt;std::decay_t&lt;Operation&gt;&gt;
{
    return TimeoutAwaiter&lt;std::decay_t&lt;Operation&gt;&gt;{ std::forward&lt;Operation&gt;(awaitable), t };
}
</code></pre>
<h4>架构收益：Core-Per-Thread 带来的无锁抽象</h4>
<p dir="auto">细心的读者可能会发现，在处理跨越不同异步回调的生命周期同步时，我们仅仅使用了一个普通的内建整型变量 <code>int pending_cqes_{ 2 };</code>，而没有求助于 <code>std::atomic&lt;int&gt;</code> 或任何形式的互斥锁。</p>
<p dir="auto">这正是我们选择 <strong>Core-Per-Thread（单线程独立上下文）</strong> 架构的直接收益。在该模型下，底层 <code>io_uring</code> 队列的投递、事件的收割（<code>IOContext::run</code>）、<code>complete</code> 回调的触发，以及协程的恢复，全都被严格限制在单一线程的顺序执行流中。这种确定的串行化特征从根本上消除了数据竞争。因此，我们可以毫无顾忌地使用裸整型进行状态流转，彻底免除了原子操作带来的缓存行同步与内存屏障开销，将“零开销抽象”贯彻到了每一个微小的细节中。</p>
<h3>4. 正交设计：避免 API 表面积爆炸</h3>
<p dir="auto">在上述实现中，<code>std::expected</code> 发挥了重要作用，我们将内核传回的 <code>-ETIME</code> 翻译为了标准的 <code>std::errc::timed_out</code>。但比类型安全更值得关注的，是这种基于泛型与组合语义带来的高层架构美学。</p>
<p dir="auto">在传统的网络库设计中，超时逻辑往往被直接硬编码进具体的 I/O 操作中。这意味着设计者不得不提供诸如 <code>async_read_with_timeout</code>、<code>async_write_with_timeout</code>、<code>async_connect_with_timeout</code> 等一系列冗余接口。假设系统存在 $N$ 种基础操作，未来又需要引入 $M$ 种类似于超时的修饰语义，API 的数量就会呈现 $N \times M$ 的指数级膨胀，最终导致<strong>表面积爆炸（API Surface Area Explosion）</strong>。</p>
<p dir="auto">而我们设计的 <code>TimeoutAwaiter</code> 与任何具体的业务操作是<strong>严格正交</strong>的。通过 C++20 的 Concept 约束，它充当了一个纯粹的通用修饰器，能够无缝叠加在任何满足规范的操作之上，将库的 API 复杂度完美控制在了 $N + M$。</p>
<p dir="auto">结合泛型的工厂函数，上层业务代码可以以极低侵入性的自然语序组合它们：</p>
<pre><code class="language-cpp">auto network_read_task(IOContext&amp; context, Socket&amp; socket) -&gt; Task&lt;void&gt;
{
    using namespace std::chrono_literals;
    
    // 组合语义：以正交的方式为 async_read 附加 5 秒的超时约束
    auto result = co_await timeout(socket.async_read(buffer), 5s);
    
    if (!result) {
        if (result.error() == std::errc::timed_out)
            spdlog::warn("Read operation timed out.");
        else
            spdlog::error("Read failed: {}", result.error().message());
            
        co_return;
    }
    
    spdlog::info("Successfully read {} bytes.", result.value());
}
</code></pre>
<h3>演示</h3>
<p dir="auto">由于现有实现比较简陋，我们只能复用下02章中async_wait来测试下timeout的语义是否正确。实际的代码里，是不太可能组合async_wait和timeout的。</p>
<pre><code class="language-c++">
auto shutdown_monitor(IOContext&amp; context) -&gt; Task&lt;void&gt;
{
    using namespace std::chrono_literals;

    SignalSet sets{ context, signals::interrupt, signals::terminate };

    // 唯一改动点，测试一下timeout的行为是否正确
    co_await timeout(sets.async_wait(), 5s);

    spdlog::info("Received shutdown signal, stopping IOContext...");
    context.stop();
}

auto demo(IOContext&amp; context) -&gt; Task&lt;void&gt;
{
    using namespace std::chrono_literals;
    spdlog::info("demo started");    

    // 模拟一些持续的异步工作，直到接收到退出信号
    while (true) 
        co_await sleep_for(context, 1s);

    spdlog::info("demo completed");
}

int main(int argc, char* argv[])
{
    IOContext context{};

    co_spawn(context, demo(context));
    co_spawn(context, shutdown_monitor(context));

    context.run();

    spdlog::info("IOContext stopped, exiting...");

    return EXIT_SUCCESS;
}
</code></pre>
<p dir="auto">执行结果</p>
<pre><code class="language-bash">[2026-04-20 02:28:23.356] [info] demo started
[2026-04-20 02:28:28.792] [info] Received shutdown signal, stopping IOContext...
[2026-04-20 02:28:28.792] [info] IOContext stopped, exiting...
</code></pre>
<p dir="auto">可以看到，5s之后，async_wait结束了等待，context.stop()触发，程序结束了。</p>
<p dir="auto">也可以按下Ctrl+C来触发SIGINT信号</p>
<pre><code class="language-bash">/home/doom/blog/build/demo/blog.timeout_v1
[2026-04-20 10:25:32.885] [info] demo started
^C[2026-04-20 10:25:33.891] [info] Received shutdown signal, stopping IOContext...
[2026-04-20 10:25:33.891] [info] IOContext stopped, exiting...
</code></pre>
<p dir="auto"><a href="https://github.com/Doomjustin/blog/blob/main/demo/timeout_v1.cpp" rel="nofollow ugc">完整代码</a></p>
]]></description><link>http://forum.d2learn.org/topic/191/基于-io_uring-的-c-20-协程网络库-03-基于链式请求的零开销超时机制</link><generator>RSS for Node</generator><lastBuildDate>Mon, 20 Apr 2026 13:21:07 GMT</lastBuildDate><atom:link href="http://forum.d2learn.org/topic/191.rss" rel="self" type="application/rss+xml"/><pubDate>Mon, 20 Apr 2026 01:27:01 GMT</pubDate><ttl>60</ttl></channel></rss>