<?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 协程网络库】08 写路径优化：Scatter-Gather与writev]]></title><description><![CDATA[<p dir="auto">在构建了全异步 Echo Server 之后，我们的网络库已经能够处理一来一回的字节流通信。但工程实践中，一个真实的应用往往不会只发送一块连续内存。</p>
<p dir="auto">考虑一个 HTTP/1.1 响应：响应头（<code>std::string</code>）和响应体（<code>std::vector&lt;char&gt;</code>）通常分布在两块不相关的内存区域。最朴素的写法是连续调用两次 <code>async_write_some</code>，但这意味着两次 <code>io_uring</code> 提交、两次协程挂起恢复——这在高并发场景下代价不菲。</p>
<p dir="auto">更糟糕的是，两次独立的写操作并非原子的。在 Nagle 算法被关闭的情况下（<code>TCP_NODELAY</code>），内核可能将头部和体部拆成两个 TCP 报文分别发送，给对端解析器制造不必要的复杂度。</p>
<p dir="auto">本篇将探讨如何通过 <strong>Scatter/Gather I/O</strong> 机制，在单次系统调用中完成对多块内存的原子性聚合写操作。</p>
<hr />
<h3>1. 问题根源：内存布局与系统调用边界</h3>
<p dir="auto">标准的 <code>write(2)</code> 系统调用接受的是一块连续的 <code>(void* buf, size_t count)</code>。要发送分散在多处的数据，传统上有两种方案：</p>
<ol>
<li><strong>拼接再发送</strong>：将所有数据 <code>memcpy</code> 到一块连续缓冲区，再调用一次 <code>write</code>。代价是额外的内存分配与拷贝，延迟增加，CPU 缓存命中率下降。</li>
<li><strong>多次调用</strong>：依次对每块数据各调用一次 <code>write</code>。代价是多次用户态 <img src="http://forum.d2learn.org/assets/plugins/nodebb-plugin-emoji/emoji/android/2194.png?v=q3jg1528nj0" class="not-responsive emoji emoji-android emoji--left_right_arrow" style="height:23px;width:auto;vertical-align:middle" title=":left_right_arrow:" alt="↔" /> 内核态的上下文切换，以及不可避免的时序问题。</li>
</ol>
<p dir="auto">POSIX 标准的答案是 <code>writev(2)</code>：</p>
<pre><code class="language-c">ssize_t writev(int fd, const struct iovec *iov, int iovcnt);
</code></pre>
<p dir="auto">调用方构造一个 <code>iovec</code> 数组，每个元素描述一块内存区域，内核在内部完成聚合，对外表现为<strong>单次、原子的写操作</strong>。这种模式被称为 <strong>Gather Write</strong>（聚合写），与之对应的 <code>readv</code> 称为 <strong>Scatter Read</strong>（分散读），统称 <strong>Scatter/Gather I/O</strong>。</p>
<pre><code class="language-c">struct iovec {
    void  *iov_base;  // 缓冲区起始地址
    size_t iov_len;   // 缓冲区长度
};
</code></pre>
<hr />
<h3>2. Concept 先行：定义 Buffer 序列的语言契约</h3>
<p dir="auto">C++ 标准库没有"一组只读 buffer 的 range"这个抽象，我们用两个 Concept 来定义它：</p>
<pre><code class="language-cpp">template&lt;typename T&gt;
concept const_buffer = requires(const T&amp; t)
{
    { buffer(t) } -&gt; std::same_as&lt;std::span&lt;const std::byte&gt;&gt;;
};

template &lt;typename T&gt;
concept sequence_buffer = std::ranges::range&lt;T&gt; 
                       &amp;&amp; const_buffer&lt;std::ranges::range_reference_t&lt;T&gt;&gt;;
</code></pre>
<p dir="auto"><code>std::string</code>、<code>std::vector&lt;char&gt;</code>、<code>std::string_view</code> 等连续存储类型本身就满足 <code>const_buffer</code>。因此 <code>std::vector&lt;std::string&gt;</code> 或 <code>std::array&lt;std::string_view, N&gt;</code> 这类 range 可以直接传给 <code>async_write_some</code>，不需要用户手动做任何转换。</p>
<hr />
<h3>3. 异步实现：<code>WriteSequenceAwaiter</code> 的内存安全设计</h3>
<p dir="auto">异步化 <code>writev</code> 有一个绕不开的内存安全问题：<code>iov</code> 数组的生命周期。</p>
<pre><code class="language-cpp">template&lt;sequence_buffer Buffer&gt;
class WriteSequenceAwaiter: public Operation {
public:
    WriteSequenceAwaiter(context_type&amp; context, int socket, const Buffer&amp; buffers)
      : context_(context), socket_(socket)
    {
        iov_.reserve(std::ranges::size(buffers));

        for (const auto&amp; chunk : buffers) {
            auto data = buffer(chunk);
            iov_.push_back(::iovec{
                .iov_base = const_cast&lt;std::byte*&gt;(data.data()),
                .iov_len  = data.size()
            });
        }
    }

    void prepare(::io_uring_sqe* sqe) noexcept override
    {
        ::io_uring_prep_writev(sqe, socket_, iov_.data(), iov_.size(), 0);
    }

    // ...
private:
    std::vector&lt;::iovec&gt; iov_;
    // ...
};
</code></pre>
<p dir="auto">SQE 提交后，内核在某个未知的时间点才真正执行 I/O，等待期间 <code>iov</code> 数组必须保持有效。<code>WriteSequenceAwaiter</code> 的做法是<strong>在构造函数中把 <code>iovec</code> 元数据全部拷贝进 <code>iov_</code> 成员向量</strong>。</p>
<p dir="auto">这里有一个关键的区分：</p>
<ul>
<li><strong><code>iovec</code> 元数据（指针 + 长度）</strong>：占用空间极小（每个 16 字节），在构造时拷贝，由 Awaiter 对象自身持有。</li>
<li><strong>实际的 payload 字节</strong>：不拷贝，<code>iov_base</code> 直接指向调用方提供的原始内存。</li>
</ul>
<p dir="auto">由于 Awaiter 对象驻留在协程帧中，而协程帧的生命周期与 <code>co_await</code> 表达式完全绑定——<code>co_await</code> 在整个异步操作期间协程不会销毁帧，因此：</p>
<ol>
<li><code>iov_</code> 向量本身由协程帧持有，直到 <code>complete()</code> 恢复后才释放——内核读取 <code>iov</code> 数组时内存有效。</li>
<li>payload 字节由调用方保证在 <code>co_await</code> 期间有效，<code>iov_base</code> 指针始终合法。</li>
</ol>
<p dir="auto"><code>iov_</code> 元数据由 Awaiter 自己持有，payload 字节一字节不动——零拷贝，正确性靠协程帧的生命周期保证。</p>
<hr />
<h3>4. 暴露给用户的 API：<code>async_write_some</code> 的重载决议</h3>
<p dir="auto"><code>WriteSequenceAwaiter</code> 最终通过 <code>StreamSocket</code> 上的重载函数暴露给用户：</p>
<pre><code class="language-cpp">// 重载一：单块 buffer
auto async_write_some(std::span&lt;const std::byte&gt; buffer) noexcept 
    -&gt; async::WriteSomeAwaiter;

// 重载二：buffer 序列
template&lt;sequence_buffer Buffer&gt;
auto async_write_some(const Buffer&amp; buffer) noexcept 
    -&gt; async::WriteSequenceAwaiter&lt;Buffer&gt;;
</code></pre>
<p dir="auto">两个重载共享同一个函数名，编译器依据参数类型自动选择正确的路径：</p>
<ul>
<li>传入 <code>std::span&lt;const std::byte&gt;</code>（或满足隐式转换的单一连续 range）→ 走 <code>WriteSomeAwaiter</code>，底层是 <code>io_uring_prep_write</code></li>
<li>传入满足 <code>sequence_buffer</code> 的 range（如 <code>std::vector&lt;std::string_view&gt;</code>）→ 走 <code>WriteSequenceAwaiter</code>，底层是 <code>io_uring_prep_writev</code></li>
</ul>
<hr />
<h3>5. 实战：发送分散的 HTTP 响应</h3>
<p dir="auto">以发送一个 HTTP/1.1 响应为例，头部和体部分别存储在两块独立内存中：</p>
<pre><code class="language-cpp">auto send_response(net::ip::tcp::socket&amp; conn) -&gt; async::Task&lt;&gt;
{
    std::string header = 
        "HTTP/1.1 200 OK\r\n"
        "Content-Type: text/plain\r\n"
        "Content-Length: 13\r\n"
        "\r\n";

    std::string body = "Hello, World!";

    // std::string 直接满足 const_buffer，std::array&lt;std::string, 2&gt; 满足 sequence_buffer
    std::array parts = { header, body };

    // 一次 co_await → 一次 io_uring 提交 → 一次内核 writev
    auto result = co_await conn.async_write_some(parts);
    if (!result) {
        spdlog::warn("send failed: {}", result.error().message());
        co_return;
    }

    spdlog::info("sent {} bytes", *result);
}
</code></pre>
<p dir="auto">与朴素的两次 <code>async_write_some</code> 调用相比，这里只有<strong>一次协程挂起恢复</strong>，且头部与体部保证在同一次 <code>writev</code> 中原子发送——在 <code>TCP_NODELAY</code> 的场景下尤为重要。</p>
<hr />
<h3>6. "写完为止"与"读完为止"：WriteAll 与 ReadAll</h3>
<p dir="auto"><code>async_write_some</code> 和 <code>async_read_some</code> 只保证<strong>单次内核调用</strong>——它们返回的是"这次实际传输了多少字节"，而非"请求的字节是否全部完成"。在流式协议下，这种部分传输（partial I/O）是完全合法的内核行为。</p>
<p dir="auto">"写完为止"在网络编程中足够常见，值得在库层面直接封装。<code>WriteAllAwaiter</code> 和 <code>ReadAllAwaiter</code> 将"重试直到完成"的逻辑封装在 Awaiter 内部，业务层无需感知部分传输的存在：</p>
<h4>6.1 WriteAllAwaiter</h4>
<pre><code class="language-cpp">class WriteAllAwaiter: public CancelableOperation {
    // ...
    void complete(int result, std::uint32_t flags) noexcept override
    {
        set_result(result, flags);

        if (error_code_ != 0 || buffer_.empty())
            resume(handle_, result, flags);   // 完成或出错，恢复协程
        else
            arm_write();                       // 还有剩余，重新提交
    }

    void arm_write() noexcept
    {
        auto* sqe = context_.sqe();
        ::io_uring_prep_send(sqe, socket_, buffer_.data(), buffer_.size(), 0);
        ::io_uring_sqe_set_data(sqe, this);
    }

    void set_result(int result, std::uint32_t flags) noexcept
    {
        if (result &gt; 0) {
            bytes_written_ += static_cast&lt;std::size_t&gt;(result);
            buffer_ = buffer_.subspan(result);   // 滑动视图，指向剩余部分
        }
        else if (result == 0) {
            error_code_ = ECONNABORTED;          // 对端关闭连接
        }
        else {
            error_code_ = -result;
        }
    }
};
</code></pre>
<p dir="auto"><code>buffer_</code> 是 <code>std::span&lt;const std::byte&gt;</code>，只是视图，不持有数据。每次部分写入后，<code>set_result</code> 把视图头部前移；<code>complete()</code> 随即检查：还有剩余就调 <code>arm_write()</code> 重新提交 SQE，写完了或出错了才调 <code>resume()</code> 恢复协程。</p>
<p dir="auto">这里有个值得留意的细节：重试不能用循环实现。<code>complete()</code> 是内核 CQE 的回调路径，不能在里面等待下一次 I/O 完成——唯一的方式是重新提交一个 SQE，让内核完成后再次回调。这也是 <code>arm_write()</code> 会在 <code>complete()</code> 里再次出现的原因。协程从头到尾只挂起一次，中间所有的重试都在回调链里发生，外面看不到任何细节。</p>
<p dir="auto"><code>ReadAllAwaiter</code> 与之对称，换成 <code>io_uring_prep_recv</code> 和可写的 <code>std::span&lt;std::byte&gt;</code>。<code>result == 0</code> 时返回 <code>ECONNABORTED</code>——对端已关闭，已读字节不足期望长度，继续等下去不会再有新数据。</p>
<h4>6.2 取消机制：为什么不能用 link_timeout？</h4>
<p dir="auto">在第三篇博客中，我们为一次性的 I/O 操作（<code>async_read_some</code>、<code>async_write_some</code>）实现了超时机制，其底层是 io_uring 的 <strong><code>IOSQE_IO_LINK</code> + <code>io_uring_prep_link_timeout</code></strong> 方案：将一个 timeout SQE 通过 <code>IOSQE_IO_LINK</code> 链接到 I/O SQE 后面，内核自动保证"哪个先完成，另一个被取消"。</p>
<p dir="auto">但这一方案对 <code>WriteAllAwaiter</code> / <code>ReadAllAwaiter</code> 完全失效，原因在于它们是<strong>多次提交</strong>的操作——每次部分写入后，<code>complete()</code> 回调里会再次调用 <code>arm_write()</code> 提交新的 SQE。而 <code>IOSQE_IO_LINK</code> 只能链接<strong>相邻的两个</strong> SQE，第一次提交时的链接在第一个 CQE 到达后就已经消耗完毕，无法覆盖后续的重试 SQE。</p>
<p dir="auto">为此，这里引入了一套针对多次提交操作的取消机制，核心是 <code>CancelableOperation</code> 里的一个 <code>parent</code> 指针：</p>
<pre><code class="language-cpp">struct CancelableOperation : public Operation {
    CancelableOperation* parent{ nullptr };

    void resume(std::coroutine_handle&lt;&gt; handle, int result, std::uint32_t flags) noexcept
    {
        if (parent)
            parent-&gt;complete(result, flags);   // 路由给包装层
        else if (handle)
            std::exchange(handle, nullptr).resume();
    }
};
</code></pre>
<p dir="auto"><code>CancelableOperation</code> 在 <code>Operation</code> 基础上新增了一个 <code>parent</code> 指针。当操作被一个超时组合器包裹时，<code>parent</code> 被设置为该组合器；否则为 <code>nullptr</code>，行为与普通 <code>Operation</code> 相同。</p>
<p dir="auto"><code>WriteAllAwaiter</code> 继承自 <code>CancelableOperation</code>，并在 <code>complete()</code> 中通过 <code>resume()</code> 而非直接调用 <code>handle_.resume()</code>。关键在于 <code>resume()</code> 只在<strong>整个操作最终完成或出错</strong>时才被调用——中间每次部分写入完成后，<code>complete()</code> 直接调用 <code>arm_write()</code> 重新提交，不经过 parent。只有当 <code>buffer_.empty()</code>（写完）或 <code>error_code_ != 0</code>（出错/被取消）时，才通过 <code>resume()</code> 将结果路由出去。这样 parent 只需处理一次最终事件，而不是每次重试。</p>
<p dir="auto">这段逻辑如果只看文字会比较绕，可以把它压成一个事件时序：</p>
<pre><code class="language-text">[User Coroutine]          [TimeoutCombinator]             [io_uring]
    |                         |                            |
    |-- co_await timeout() --&gt;|                            |
    |                         |-- submit timer SQE -------&gt;|
    |                         |-- submit write_all SQE ---&gt;|
    |                         |                            |
    |                         |&lt;-- CQE(write partial) ---- |  (继续 arm_write)
    |                         |&lt;-- CQE(write done) ---- ---|  (或 error)
    |                         |-- cancel(timer) ----------&gt;|
    |                         |&lt;-- CQE(timer canceled) ----|
    |&lt;------------------------|  resume once               |
</code></pre>
<p dir="auto">另一条分支是 timer 先到期：<code>TimeoutCombinator</code> 会反向 cancel 当前飞行中的 write/read SQE，同样等两边 CQE 都收干净后再恢复协程。核心目标只有一个：<strong>恢复一次，但把飞行中的请求收尾做完整</strong>。</p>
<p dir="auto">有了这个基础，<code>TimeoutCombinator</code> 就可以实现真正的独立定时器了：</p>
<pre><code class="language-cpp">template&lt;cancelable_operation Awaiter&gt;
class TimeoutCombinator: public CancelableOperation {
    void await_suspend(std::coroutine_handle&lt;&gt; handle) noexcept
    {
        handle_ = handle;

        // 独立提交一个定时器 SQE
        auto* sqe = context().sqe();
        ::io_uring_prep_timeout(sqe, &amp;timeout_, 0, 0);
        ::io_uring_sqe_set_data(sqe, &amp;timer_);

        // 再提交内层操作（内层操作的 parent 已在构造时设为 this）
        awaiter_.await_suspend(handle);
    }

    void complete(int result, std::uint32_t flags) noexcept override
    {
        // 内层操作（某次重试）先完成了——取消定时器
        if (state_ == State::Pending) {
            state_ = State::AwaiterCompleted;
            auto* sqe = context().sqe(false);
            ::io_uring_prep_cancel(sqe, &amp;timer_, 0);
        }
        if (--pending_cqes_ == 0)
            std::exchange(handle_, {}).resume();
    }

    void on_timer_completed(int result) noexcept
    {
        // 定时器先到——取消内层操作当前正在飞行的 SQE
        if (state_ == State::Pending) {
            state_ = State::TimerCompleted;
            auto* sqe = context().sqe(false);
            ::io_uring_prep_cancel(sqe, &amp;awaiter_, 0);
        }
        if (--pending_cqes_ == 0)
            std::exchange(handle_, {}).resume();
    }
};
</code></pre>
<p dir="auto">与 <code>TimeoutAwaiter</code> 的 <code>IOSQE_IO_LINK</code> 不同，<code>TimeoutCombinator</code> 将定时器 SQE 和内层操作 SQE <strong>独立提交</strong>，两者在 io_uring 中是平等的并发请求：</p>
<ul>
<li><strong>内层操作先完成</strong>：通过 <code>parent-&gt;complete()</code> 进入 <code>TimeoutCombinator::complete()</code>，发出 <code>io_uring_prep_cancel</code> 取消定时器，等待定时器的 CQE 到达后恢复协程。</li>
<li><strong>定时器先到期</strong>：<code>Timer::complete()</code> 调用 <code>on_timer_completed()</code>，发出 <code>io_uring_prep_cancel</code> 取消当前正在飞行的 <code>awaiter_</code> SQE，等待其 CQE 到达后以 <code>timed_out</code> 恢复协程。</li>
</ul>
<p dir="auto">两种情况都要求 <strong><code>pending_cqes_</code>（初值为 2）减到零</strong>才恢复协程——这保证了无论竞争结果如何，所有飞行中的 SQE 的 CQE 最终都被消耗掉，不会残留在完成队列中干扰后续操作。</p>
<p dir="auto">从调用方视角来看，两套机制的接口完全相同：</p>
<pre><code class="language-cpp">// async_read_some：单次提交 → 走 TimeoutAwaiter（link_timeout）
auto r1 = co_await timeout(socket.async_read_some(buf), 5s);

// async_read_all：多次提交 → 走 TimeoutCombinator（独立定时器 + cancel）
auto r2 = co_await timeout(socket.async_read_all(buf), 5s);
</code></pre>
<p dir="auto"><code>timeout()</code> 函数通过两个重载，依据 <code>single_shot_only_operation</code> 和 <code>cancelable_operation</code> 两个 Concept 在编译期自动分发，调用方无需关心底层选择了哪套机制。</p>
<hr />
<h4>6.3 为什么不需要序列版 WriteAll</h4>
<p dir="auto">看到这里，你可能会问：我们有 <code>WriteSequenceAwaiter</code>（序列版 <code>write_some</code>），是否也需要一个序列版 <code>write_all</code>？</p>
<p dir="auto">答案是<strong>不需要</strong>。</p>
<p dir="auto"><code>writev</code> 的关键语义是<strong>原子性</strong>：内核保证整个 <code>iovec</code> 数组作为一个整体提交给协议栈。对于流式套接字（<code>SOCK_STREAM</code>），内核要么接受全部数据进入发送缓冲区，要么在缓冲区不足时只接受一部分。</p>
<p dir="auto">但这里有一个根本性的约束：<strong><code>writev</code> 的部分写入发生后，你无法简单地"重试剩余部分"</strong>。每次 <code>writev</code> 写入 N 字节后，你需要遍历 <code>iovec</code> 数组，跳过已完整写入的 chunk，并修剪部分写入那个 chunk 的 <code>iov_base</code>/<code>iov_len</code>，然后以剩余的 <code>iovec</code> 子集重新提交：</p>
<pre><code class="language-cpp">// 如果要实现序列版 write_all，必须处理这种修剪逻辑
void advance(std::size_t n) {
    while (n &gt; 0 &amp;&amp; !iov_.empty()) {
        if (n &gt;= iov_.front().iov_len) {
            n -= iov_.front().iov_len;
            iov_.erase(iov_.begin());          // O(n) erase，或改用 index
        } else {
            auto* base = static_cast&lt;char*&gt;(iov_.front().iov_base);
            iov_.front().iov_base = base + n;
            iov_.front().iov_len -= n;
            n = 0;
        }
    }
}
</code></pre>
<p dir="auto">这并非不可实现，但代价是显著的复杂度提升，而实际收益却微乎其微——实践中发送缓冲区充足时 <code>writev</code> 极少发生部分写入。</p>
<p dir="auto">更重要的是，<code>writev</code> 的使用场景本身就决定了调用方通常不关心"是否全部写完"：当你用 <code>writev</code> 拼装一个 HTTP 响应时，你的目标是<strong>原子地将头部和体部交给内核</strong>，至于内核何时真正通过 TCP 发出去，不是这一层要操心的。这与 <code>write_all</code> 的语义（"确保用户层的所有字节都离开应用缓冲区"）本质上是两个不同的问题。</p>
<p dir="auto">因此，我们的设计决策是：</p>
<table class="table table-bordered table-striped">
<thead>
<tr>
<th>场景</th>
<th>API 选择</th>
</tr>
</thead>
<tbody>
<tr>
<td>一次性发送多块分散内存</td>
<td><code>async_write_some(sequence_buffer)</code> → <code>writev</code></td>
</tr>
<tr>
<td>确保单块内存完整写入</td>
<td><code>async_write_all(span)</code> → 循环 <code>send</code></td>
</tr>
<tr>
<td>既要分散又要保证全部写完</td>
<td>希望没有这种需求</td>
</tr>
</tbody>
</table>
]]></description><link>http://forum.d2learn.org/topic/197/基于-io_uring-的-c-20-协程网络库-08-写路径优化-scatter-gather与writev</link><generator>RSS for Node</generator><lastBuildDate>Fri, 01 May 2026 04:26:11 GMT</lastBuildDate><atom:link href="http://forum.d2learn.org/topic/197.rss" rel="self" type="application/rss+xml"/><pubDate>Thu, 30 Apr 2026 08:30:52 GMT</pubDate><ttl>60</ttl></channel></rss>