<?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 协程网络库】09 读路径优化：recv_multishot与ReceiveStream]]></title><description><![CDATA[<p dir="auto">在上一篇里，我们解决了"怎么把数据发出去"。这一篇转到读路径：把 <code>recv_multishot</code> 接进来，减少高频读取时的重复提交。</p>
<p dir="auto">如果沿用 <code>async_read_some</code>，每次读取都要先准备一块可写 buffer，再提交一次 <code>recv</code>，等 CQE 回来后再决定要不要继续下一次。这个模型本身没错，但放到 Echo、代理、网关这类长连接里，会很快变成重复劳动：准备 buffer、提交 SQE、等待 CQE、再补上下一次读取。</p>
<p dir="auto"><code>recv_multishot</code> 能直接解决这件事：一次提交，对应后续多次完成。</p>
<p dir="auto">在开始之前，先确认一下当前库的状态。前几篇一路写下来，<code>IOContext</code> 一直是个比较扁平的类：持有一个 <code>io_uring</code> ring 句柄，提供 SQE 获取和事件循环驱动，加上 work 计数和 stop 信号：</p>
<pre><code class="language-cpp">class IOContext {
    ::io_uring ring_;
    int event_fd_;
    std::size_t outstanding_works_{ 0 };
    std::atomic&lt;bool&gt; should_stop_{ false };

    void run();
    auto sqe() -&gt; ::io_uring_sqe*;
    void wakeup();
    // CQE 分发 ...
};
</code></pre>
<p dir="auto">socket 层的每个异步操作，把自己包装成 <code>Operation</code> 提交进 ring，CQE 回来时由 <code>IOContext</code> 分发回去。至于 buffer，一直是由调用方临时传入，操作完成后调用方自己处理生命周期。</p>
<p dir="auto">这个结构目前是干净的，但 <code>recv_multishot</code> 要求的东西比这多一层。</p>
<hr />
<h3>1. 直接加进去会碰到什么</h3>
<p dir="auto"><code>recv_multishot</code> 需要 buffer ring：内核不再接受调用方临时传入的单块 buffer，而是要求预先注册一组固定大小的槽位。每次有数据到达，内核从 ring 里借一个槽位写入，CQE 里带上这次借出的槽位编号，调用方读完数据后再把槽位还回去。</p>
<p dir="auto">最直接的想法是往 <code>IOContext</code> 里加几个字段：</p>
<pre><code class="language-cpp">class IOContext {
    ::io_uring ring_;
    int event_fd_;
    std::size_t outstanding_works_{ 0 };
    std::atomic&lt;bool&gt; should_stop_{ false };

    // 新加的 buffer ring 管理
    std::unordered_map&lt;unsigned, BufferRing&gt; buffer_rings_;
    unsigned next_bgid_{ 0 };
    std::optional&lt;unsigned&gt; default_bgid_;
};
</code></pre>
<p dir="auto">功能上能跑，但麻烦随之而来。</p>
<p dir="auto">第一，<code>IOContext</code> 现在要同时处理两类完全不同的事情：一类是"这一次 submit_and_wait 怎么跑、CQE 怎么分发"，另一类是"有哪些 buffer 组被注册在内核里、槽位怎么借出和归还"。这两件事没有天然的耦合关系，混在一起只会让两侧的逻辑都难以单独修改。</p>
<p dir="auto">第二，更直接的问题出在 CQE 分发上。之前"一个 CQE 对应一次操作完成"的判断，在 multishot 里不再成立——事件循环必须识别 <code>IORING_CQE_F_MORE</code>，在这个 flag 还存在时不能把这笔 work 从计数里扣掉。这不是细节调整，而是分发逻辑本身语义的变化。如果这块逻辑和 buffer ring 管理搅在同一段代码里，两边会互相干扰。</p>
<p dir="auto">第三，socket 层如果要用 buffer ring，就必须拿到 <code>bgid</code> 和 <code>bid</code>，然后到处传。调用方写业务代码时不该感知这些细节，但没有合适的封装层，这些细节就只能往上漏。</p>
<p dir="auto">所以在写 multishot awaiter 之前，要先把 <code>IOContext</code> 的职责拆开：</p>
<ol>
<li>和 ring 驱动、唤醒、CQE 分发有关的逻辑，收进独立的 <code>Scheduler</code>。</li>
<li>buffer ring 的建立、槽位借出和归还，收进独立的 <code>BufferRingGroup</code>。</li>
<li><code>IOContext</code> 自己只保留对外协调接口和 work tracking。</li>
<li>socket 层只暴露消费接口，不让业务层直接碰 buffer slot。</li>
</ol>
<p dir="auto">这不是为了"代码好看"，而是为了把复杂度放回正确位置。不做这一步，后面无论接收流、buffer 池复用还是取消清理，都会越来越难理顺。</p>
<hr />
<h3>2. 重构后的 <code>IOContext</code></h3>
<p dir="auto">拆分后的 <code>IOContext</code> 把两类职责分别交给两个内部类：</p>
<pre><code class="language-cpp">class IOContext {
private:
    class Scheduler {
        // ring 初始化、SQE 获取、submit_and_wait、wakeup
    };

    class BufferRingGroup {
        // setup buffer ring、release buffer slot、default bgid
    };

    Scheduler scheduler_;
    BufferRingGroup buffers_;
    std::size_t outstanding_works_{ 0 };
    std::atomic&lt;bool&gt; should_stop_{ false };
};
</code></pre>
<p dir="auto"><code>Scheduler</code> 和 <code>BufferRingGroup</code> 的职责本来就不是一回事。</p>
<p dir="auto"><code>Scheduler</code> 关注的是"这一次事件循环怎么跑"：</p>
<ol>
<li>初始化和销毁 <code>io_uring</code> ring。</li>
<li>提供 SQE。</li>
<li><code>submit_and_wait</code> 后遍历 CQE，分发给对应 <code>Operation</code>。</li>
<li>通过 <code>eventfd</code> 做跨线程 stop 唤醒。</li>
</ol>
<p dir="auto">尤其是第 3 点，在引入 multishot 之后已经不能再按"一个 CQE 对应一个完整操作"来理解了。<code>Scheduler::schedule()</code> 现在必须识别 <code>IORING_CQE_F_MORE</code>，只有在 multishot 真正结束时才能把这笔 work 从计数里扣掉。这意味着 <code>recv_multishot</code> 不只是给 socket 层加了个新能力，它也顺手改写了事件循环对"完成"这件事的理解。</p>
<p dir="auto">而 <code>BufferRingGroup</code> 关心的是另一件事："有哪些 buffer 组被长期注册在内核里，以及它们怎么被重复借出和归还"。这部分如果混进 <code>Scheduler</code>，后者就会一边跑 CQE 批调度，一边操心 buffer 池资源生命周期，边界很快就会糊掉。</p>
<p dir="auto">接口本身也能看出这种分层：</p>
<pre><code class="language-cpp">auto setup_buffer_ring(unsigned entries, unsigned size) -&gt; unsigned
{
    return buffers_.setup(scheduler_.ring(), entries, size);
}

void release_buffer_ring(unsigned bgid, unsigned bid)
{
    buffers_.release(bgid, bid);
}
</code></pre>
<p dir="auto"><code>IOContext</code> 在这里做的事情很克制：buffer ring 的建立确实需要底层 ring 句柄，但建立完成之后，后续使用者不必再感知这些细节。</p>
<p dir="auto">有了这层结构，<code>ReceiveStream</code> 才真正有了落脚点。否则它一边要操心 multishot 请求，一边还得自己解决 buffer group 的分配和归还，那就不是 socket 接口该承担的事情。</p>
<p dir="auto">它在 <code>StreamSocket</code> 上的入口很轻：</p>
<pre><code class="language-cpp">auto receive_stream() -&gt; ReceiveStream&lt;Context&gt;
{
    auto default_bgid = this-&gt;context().default_buffer();
    if (!default_bgid)
        throw std::runtime_error{ "No default buffer ring available for receive stream" };

    return ReceiveStream&lt;Context&gt;{ this-&gt;context(), this-&gt;native_handle(), *default_bgid };
}
</code></pre>
<p dir="auto"><code>ReceiveStream</code> 不是独立工作的，它默认依赖 <code>IOContext</code> 里已经准备好的 buffer ring。在当前实现里，第一次 <code>setup_buffer_ring()</code> 创建出来的组会自动成为默认组，所以 demo 里只做一次初始化就够了。</p>
<hr />
<h3>3. 重构落地：把 buffer ring 收进 <code>IOContext</code></h3>
<p dir="auto">buffer ring 进入 <code>IOContext</code> 之后，下一步才轮到它自己的实现。</p>
<p dir="auto"><code>BufferRingGroup::setup()</code> 做了三件事：分配一整块连续内存、向内核注册一个 buf ring、把每个 slot 预先填进 ring。</p>
<pre><code class="language-cpp">auto IOContext::BufferRingGroup::setup(::io_uring* ring, unsigned entries, unsigned size) -&gt; unsigned
{
    auto bgid = next_bgid_++;
    auto&amp; buffer_ring = group_[bgid];

    buffer_ring.size = size;
    buffer_ring.entries = entries;
    buffer_ring.mask = ::io_uring_buf_ring_mask(entries);

    const auto alloc_size = static_cast&lt;std::size_t&gt;(entries * size);
    buffer_ring.base_address = memory_resource_-&gt;allocate(alloc_size, ALIGNMENT);

    int res = 0;
    buffer_ring.buffer = ::io_uring_setup_buf_ring(ring, entries, bgid, 0, &amp;res);

    auto* base = static_cast&lt;std::byte*&gt;(buffer_ring.base_address);
    for (unsigned i = 0; i &lt; entries; ++i)
        ::io_uring_buf_ring_add(buffer_ring.buffer, base + i * size, size, i, buffer_ring.mask, i);

    ::io_uring_buf_ring_advance(buffer_ring.buffer, entries);
    buffer_ring.tail = entries;

    if (!default_buffer_bgid_)
        default_buffer_bgid_ = bgid;

    return bgid;
}
</code></pre>
<p dir="auto">这部分有三个会直接影响行为的选择。</p>
<p dir="auto">第一，所有 buffer slot 放在一整块连续内存里，而不是单独 <code>new</code> 一堆小块。这么做不是图省事，而是因为 buf ring 的典型使用方式本来就是"固定大小、重复借用、按槽位编号回收"。连续布局让 <code>bid -&gt; 地址</code> 的映射退化成简单的指针偏移，后面 CQE 回来时只要 <code>base + bid * size</code> 就能找回数据。</p>
<p dir="auto">第二，<code>bgid</code> 的管理被封装在 <code>BufferRingGroup</code> 内部。调用方只拿到一个逻辑编号，不直接接触底层注册细节；默认组也在这里顺手建立起来，方便 socket 层提供一个无参的 <code>receive_stream()</code>。</p>
<p dir="auto">第三，归还路径同样应该放在这里，而不是散在各处调用 <code>io_uring_buf_ring_add</code>。因为释放 slot 本质上是 buffer ring 自己的状态变更，和具体是哪条连接、哪次读操作用过这个 slot 没关系。</p>
<p dir="auto">归还逻辑如下：</p>
<pre><code class="language-cpp">void IOContext::BufferRingGroup::release(unsigned bgid, unsigned bid)
{
    auto&amp; buffer_ring = group_[bgid];
    auto* base = static_cast&lt;std::byte*&gt;(buffer_ring.base_address);
    const int offset = buffer_ring.tail &amp; buffer_ring.mask;

    ::io_uring_buf_ring_add(
        buffer_ring.buffer,
        base + bid * buffer_ring.size,
        buffer_ring.size,
        bid,
        buffer_ring.mask,
        offset
    );

    ::io_uring_buf_ring_advance(buffer_ring.buffer, 1);
    ++buffer_ring.tail;
}
</code></pre>
<p dir="auto">一个 slot 被借走时，<code>bid</code> 会随 CQE 一起回来；一个 slot 被归还时，<code>BufferRingGroup</code> 只需要根据同样的 <code>bid</code> 把它重新挂回 ring。这样 buffer 的借出和回收就闭上了环，之后 <code>PooledBuffer</code> 才有可能用 RAII 去包装它。</p>
<hr />
<h3>4. 地基理顺后，再把 multishot 接上</h3>
<p dir="auto"><code>ReceiveStream</code> 真正的提交动作在 <code>arm_operation()</code> 里：</p>
<pre><code class="language-cpp">void arm_operation()
{
    if (!operation_)
        operation_ = new MutishotReceiveOperation{ this, context_, bgid_ };

    auto* sqe = context_-&gt;sqe();
    ::io_uring_prep_recv_multishot(sqe, fd_, nullptr, 0, 0);
    sqe-&gt;flags |= IOSQE_BUFFER_SELECT;
    sqe-&gt;buf_group = bgid_;
    ::io_uring_sqe_set_data(sqe, static_cast&lt;Operation*&gt;(operation_));
    operation_armed_ = true;
}
</code></pre>
<p dir="auto">这个提交动作有两个关键信息。</p>
<p dir="auto">第一，<code>io_uring_prep_recv_multishot</code> 只提交一次，但不是只收一次。只要内核认为这个 multishot 请求还有效，后面每次有数据到达，它都可以继续产出新的 CQE。</p>
<p dir="auto">第二，<code>IOSQE_BUFFER_SELECT</code> 告诉内核：接收缓冲区不由这次 SQE 显式给出，而是从 <code>buf_group</code> 对应的 buffer ring 里挑一个空闲槽位来写。用户态这次不传 <code>void* buf</code>，而是预先把一组固定大小的 buffer 注册给内核，后面由内核按需借用。</p>
<p dir="auto">CQE 回来时，<code>res</code> 是本次收到的字节数，<code>flags</code> 里则可能带上两个额外信息：</p>
<ol>
<li><code>IORING_CQE_F_BUFFER</code>：说明这次结果对应某个 buffer ring 里的槽位。</li>
<li><code>IORING_CQE_F_MORE</code>：说明这个 multishot 请求还活着，后面还会继续收。</li>
</ol>
<p dir="auto">这两个 flag 基本就是 <code>ReceiveStream</code> 最关心的信息：一个告诉它"数据落在哪块 buffer 上"，一个告诉它"这条流还要不要继续等下去"。</p>
<hr />
<h3>5. 最后才是用户侧 API：为什么返回 <code>PooledBuffer</code></h3>
<p dir="auto">如果只是为了把数据交给调用方，返回 <code>std::span&lt;std::byte&gt;</code> 看起来已经够了。但对 <code>ReceiveStream</code> 来说，这远远不够，因为 span 只是一段视图，不携带任何归还语义。</p>
<p dir="auto">内核从 buffer ring 里借出的槽位，最终必须还回去，否则 ring 只会越用越少，直到耗尽。这个归还动作不能指望业务层记得手工调用，所以这里必须做成 RAII。</p>
<p dir="auto"><code>PooledBuffer</code> 的职责正是这个：</p>
<pre><code class="language-cpp">template&lt;typename Context&gt;
class PooledBuffer {
public:
    PooledBuffer(Context&amp; context, unsigned bgid, unsigned bid, std::span&lt;std::byte&gt; buffer)
      : context_{ &amp;context }, bgid_{ bgid }, bid_{ bid }, buffer_{ buffer }
    {}

    ~PooledBuffer()
    {
        release();
    }

    auto data() const noexcept -&gt; std::span&lt;std::byte&gt;
    {
        return buffer_;
    }

private:
    void release()
    {
        if (valid())
            context_-&gt;release_buffer_ring(bgid_, bid_);
    }
};
</code></pre>
<p dir="auto">调用方拿到的是一块"带归还能力的 buffer 句柄"。它可以读这段数据，也可以把这段数据直接交给后续写操作；一旦这个对象离开作用域，对应槽位就自动回到 buffer ring，可供下一次接收复用。</p>
<p dir="auto">这也是 <code>ReceiveStream</code> 最重要的体验差异：上层拿到的是一块已经装好、生命周期清晰、离开作用域后能自动回收的结果，而不是一段裸内存视图。</p>
<hr />
<h3>6. <code>next()</code> 为什么需要 ready queue</h3>
<p dir="auto"><code>ReceiveStream</code> 对外只暴露一个动作：</p>
<pre><code class="language-cpp">auto next() -&gt; NextAwaiter
{
    return NextAwaiter{ *this };
}
</code></pre>
<p dir="auto"><code>next()</code> 能不能用得顺，关键在 <code>NextAwaiter</code> 和 <code>ready_results_</code> 的配合。</p>
<pre><code class="language-cpp">class NextAwaiter {
public:
    auto await_ready() const noexcept -&gt; bool
    {
        return !stream_.ready_results_.empty();
    }

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

        if (!stream_.operation_armed_)
            stream_.arm_operation();
    }

    auto await_resume() -&gt; std::expected&lt;PooledBuffer&lt;Context&gt;, std::error_code&gt;
    {
        if (stream_.ready_results_.empty())
            return unexpected_system_error(std::errc::operation_canceled);

        auto result = std::move(stream_.ready_results_.front());
        stream_.ready_results_.pop_front();
        return result;
    }
};
</code></pre>
<p dir="auto">这里的 <code>ready_results_</code> 不是装饰品，而是必须存在的一层缓冲。</p>
<p dir="auto"><code>recv_multishot</code> 有一个天然特征：协程还没来得及再次 <code>co_await next()</code>，它就可能已经连续产出了多个 CQE。如果没有队列，后到的结果只能覆盖先到的结果，或者逼着 <code>handle_cqe()</code> 当场恢复协程并同步消化所有数据，这两种都不对。</p>
<p dir="auto">把这段行为画成时序会直观很多：</p>
<pre><code class="language-text">[Kernel multishot]          [ReceiveStream]                 [User Coroutine]
    |                           |                                |
    |-- CQE #1 ----------------&gt;| push ready_results_            |
    |-- CQE #2 ----------------&gt;| push ready_results_            |
    |-- CQE #3 ----------------&gt;| push ready_results_            |
    |                           |                                |
    |                           |&lt;----------- co_await next() ---|
    |                           | pop #1 and resume              |
    |                           |&lt;----------- co_await next() ---|
    |                           | pop #2 and resume              |
</code></pre>
<p dir="auto">也就是说，<code>ready_results_</code> 不是优化项，而是 <code>recv_multishot</code> 能以“生产者-消费者节奏解耦”方式暴露给上层 API 的必要条件。</p>
<p dir="auto">用 <code>std::deque&lt;result_type&gt;</code> 把结果先存起来，问题就顺了：</p>
<ol>
<li>CQE 到达时，先转成 <code>PooledBuffer</code> 或 error，推进队列。</li>
<li>如果此刻真的有协程挂在 <code>next()</code> 上，就恢复它。</li>
<li>如果协程暂时还没来取，结果就老实待在队列里，等下一次 <code>await_ready()</code> 直接命中。</li>
</ol>
<p dir="auto">这样一来，<code>ReceiveStream</code> 就有了一点异步生成器的感觉：底层持续产出，上层按自己的节奏消费，中间靠一个轻量队列解耦。</p>
<hr />
<h3>7. CQE 到达后如何变成可消费结果</h3>
<p dir="auto"><code>handle_cqe()</code> 做的事情，说到底就是把内核语义翻译成库语义。</p>
<pre><code class="language-cpp">void handle_cqe(int result, std::uint32_t flags) noexcept
{
    std::optional&lt;PooledBuffer&lt;Context&gt;&gt; pooled_buffer;

    if (flags &amp; IORING_CQE_F_BUFFER) {
        const auto bid = static_cast&lt;std::uint16_t&gt;(flags &gt;&gt; IORING_CQE_BUFFER_SHIFT);
        auto&amp; buffer_ring = context_-&gt;buffer_ring(bgid_);

        auto* base = static_cast&lt;std::byte*&gt;(buffer_ring.base_address);
        std::span&lt;std::byte&gt; data{ base + bid * buffer_ring.size, static_cast&lt;std::size_t&gt;(result) };
        pooled_buffer.emplace(*context_, bgid_, bid, data);
    }

    if (result == -ECANCELED) {
        ready_results_.emplace_back(unexpected_system_error(std::errc::operation_canceled));
    }
    else if (result &gt;= 0) {
        if (pooled_buffer)
            ready_results_.emplace_back(std::move(*pooled_buffer));
        else
            ready_results_.emplace_back(PooledBuffer&lt;Context&gt;{});
    }
    else {
        ready_results_.emplace_back(unexpected_system_error(-result));
    }

    if (handle_) {
        auto handle = std::exchange(handle_, nullptr);
        handle.resume();
    }
}
</code></pre>
<p dir="auto">这一步分三层：</p>
<p dir="auto">第一层，从 <code>flags</code> 里提取 <code>bid</code>，再结合 <code>bgid_</code> 找回 buffer ring，算出这次数据实际落在了哪一段内存上。</p>
<p dir="auto">第二层，把这段内存包装成 <code>PooledBuffer</code>。从这一刻开始，buffer 的归还责任被显式绑定到了对象生命周期上。</p>
<p dir="auto">第三层，把内核返回码整理成 <code>std::expected</code>：正常数据走 value，取消和错误走 error。这样上层的消费方式就统一了。</p>
<p dir="auto">还有一个细节：<code>result &gt;= 0</code> 但没有 <code>IORING_CQE_F_BUFFER</code> 时，代码会塞一个默认构造的 <code>PooledBuffer</code>。这给上层留出了处理空结果的空间，比如把空 buffer 视为连接关闭。这个约定在 demo 里也直接用到了。</p>
<hr />
<h3>8. 真正难的是收尾</h3>
<p dir="auto"><code>ReceiveStream</code> 最容易写错的，不是提交 multishot，而是收尾。</p>
<p dir="auto">问题在于：当 <code>ReceiveStream</code> 析构时，内核里那笔 multishot 请求不一定已经结束。你当然可以提交一个 cancel SQE，但 cancel 也是异步的，在 cancel 的 CQE 真正回来之前，原来的 multishot CQE 仍然可能再到一次。这个窗口期一旦处理不好，要么 use-after-free，要么 buffer 泄漏。</p>
<p dir="auto">解决办法是：析构时不直接删 operation，而是先 <code>detach()</code>。</p>
<pre><code class="language-cpp">void destroy() noexcept
{
    if (operation_) {
        operation_-&gt;detach();

        auto* sqe = context_-&gt;sqe(false);
        ::io_uring_prep_cancel(sqe, static_cast&lt;Operation*&gt;(operation_), 0);
        ::io_uring_sqe_set_data(sqe, nullptr);

        operation_ = nullptr;
    }

    if (context_)
        context_ = nullptr;
}
</code></pre>
<p dir="auto"><code>detach()</code> 的效果是把 <code>operation</code> 和 <code>ReceiveStream</code> 本体断开。之后如果晚到的 CQE 还落到这笔 operation 上，<code>complete()</code> 会走另一条路径：不再访问已经析构的 stream，而是仅仅把 buffer 槽位补回 buffer ring。</p>
<pre><code class="language-cpp">void complete(int res, unsigned flags) override
{
    const bool has_more = (flags &amp; IORING_CQE_F_MORE) != 0;

    if (stream_) {
        if (!has_more) {
            stream_-&gt;operation_armed_ = false;
            stream_-&gt;operation_ = nullptr;
        }

        stream_-&gt;handle_cqe(res, flags);
    }
    else if (flags &amp; IORING_CQE_F_BUFFER) {
        // stream 已不存在，只负责把槽位补回 buffer ring
        // ...
    }

    if (!has_more)
        delete this;
}
</code></pre>
<p dir="auto">这样一来，<code>ReceiveStream</code> 的析构和 multishot 请求的最终死亡就不再需要严格同步，晚到的 CQE 也不会污染用户态状态，只会做最必要的资源回收。</p>
<p dir="auto">另外，move 构造和 move 赋值里也有一个对应的修正动作：如果 operation 还活着，就把 <code>operation_-&gt;stream_</code> 改指向新的对象。这保证了 <code>ReceiveStream</code> 被移动后，后续 CQE 仍然能回到正确实例上。</p>
<hr />
<h3>9. 实际用法</h3>
<p dir="auto">在 demo 里，Echo Server 的 session 代码已经很干净了：</p>
<pre><code class="language-cpp">auto session(ip::tcp::socket&lt;IOContext&gt; client) -&gt; Task&lt;&gt;
{
    auto stream = client.receive_stream();

    while (true) {
        auto read_result = co_await stream.next();
        if (!read_result) {
            spdlog::warn("Failed to read from client {}: {}",
                         client.native_handle(),
                         read_result.error().message());
            co_return;
        }

        auto received = read_result-&gt;data();
        if (received.empty()) {
            spdlog::info("Client {} disconnected", client.native_handle());
            co_return;
        }

        auto write_result = co_await timeout(async_write(client, received), 1ms);
        if (!write_result)
            co_return;
    }
}
</code></pre>
<p dir="auto">这段代码最直观的变化是：读路径里已经看不到"准备 buffer -&gt; 提交 recv -&gt; 处理 buffer 生命周期"这些样板动作了。业务协程眼里只剩一件事：下一块数据什么时候到。</p>
<p dir="auto">这正是 <code>ReceiveStream</code> 的价值。它不是单纯给 <code>recv_multishot</code> 套了个 awaiter，而是顺手把 buffer ring、生命周期、结果排队和析构期收尾这些麻烦一起包掉了。上层得到的是一个可以连续 <code>next()</code> 的接收流，底层保留的则是 io_uring 多次投递的吞吐优势。</p>
]]></description><link>http://forum.d2learn.org/topic/198/基于-io_uring-的-c-20-协程网络库-09-读路径优化-recv_multishot与receivestream</link><generator>RSS for Node</generator><lastBuildDate>Fri, 01 May 2026 04:20:47 GMT</lastBuildDate><atom:link href="http://forum.d2learn.org/topic/198.rss" rel="self" type="application/rss+xml"/><pubDate>Thu, 30 Apr 2026 09:29:32 GMT</pubDate><ttl>60</ttl></channel></rss>