<?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 协程网络库】12 零拷贝发送SEND_ZC]]></title><description><![CDATA[<p dir="auto">在前面的写路径优化里，我们已经用 <code>writev</code> 这类手段减少了系统调用的次数。接下来这一篇要解决另一个方向的开销：<strong>数据复制</strong>。</p>
<p dir="auto">传统的 <code>send</code> 流程里，用户态的数据会先被复制进内核 socket buffer，再由驱动发给网卡。对于大块数据和高频发送，这一次复制本身就是明显的开销。</p>
<p dir="auto">Linux <code>io_uring</code> 的 <code>IORING_OP_SEND_ZC</code> 就是为了避免这一步。但要用对它，需要理解它的完成语义——这是大多数人第一次用时最容易出错的地方。</p>
<hr />
<h3>1. 为什么需要零拷贝发送</h3>
<p dir="auto">普通 <code>send</code> 的流程：</p>
<pre><code>用户 buffer
  ↓ (内核 memcpy)
kernel socket buffer  
  ↓ (DMA 或驱动)
网卡
</code></pre>
<p dir="auto"><code>SEND_ZC</code> 想跳过中间那一步复制，直接让驱动从用户态内存读取：</p>
<pre><code>用户 buffer
  ↓ (直接 DMA，无 memcpy)
网卡
</code></pre>
<p dir="auto">代价呢？驱动访问内存的时间变长了，所以在这期间，<strong>用户态不能释放或改写这块 buffer</strong>。</p>
<hr />
<h3>2. 两个 CQE：数据结果 vs 内存释放通知</h3>
<p dir="auto"><code>SEND_ZC</code> 的完成模式跟普通 <code>send</code> 不一样。它会回两个 CQE：</p>
<p dir="auto"><strong>第一个 CQE</strong>（不带 <code>IORING_CQE_F_NOTIF</code>，带 <code>IORING_CQE_F_MORE</code>）</p>
<ul>
<li>内核告诉你这次 <code>send</code> 的结果：成功了多少字节或者失败原因</li>
<li>但驱动仍在使用你的 buffer</li>
<li>协程此时<strong>不会</strong>恢复</li>
</ul>
<p dir="auto"><strong>第二个 CQE</strong>（带 <code>IORING_CQE_F_NOTIF</code>，不带 <code>IORING_CQE_F_MORE</code>）</p>
<ul>
<li>内核通知：我已经用完你的这块 buffer，可以释放了</li>
<li>协程<strong>在这一刻</strong>恢复执行</li>
<li><code>await_resume()</code> 返回第一个 CQE 里已经存好的发送结果</li>
</ul>
<p dir="auto">所以从协程的角度，返回 = notification 已到 = 内核已停止引用你的 buffer。buffer 完全可以释放或改写。</p>
<hr />
<h3>3. 用 tag type 在 API 层标记零拷贝意图</h3>
<p dir="auto">如果 <code>async_send_some</code> 直接接收普通 buffer 加一个 bool 标志，很容易在调用点看不出端倪：</p>
<pre><code class="language-cpp">// 危险写法：一眼看不出 buffer 需要特殊处理
co_await socket.async_send_some(payload, true);
</code></pre>
<p dir="auto">更好的做法是引入一个 tag type，强制显式说明：</p>
<pre><code class="language-cpp">struct ZeroCopyT {
    std::span&lt;const std::byte&gt; span;
};

template&lt;std::ranges::contiguous_range T&gt;
auto zero_copy(const T&amp; range) -&gt; ZeroCopyT
{
    return { std::as_bytes(std::span{ range }) };
}

// 调用时意图清晰
co_await socket.async_send_some(net::zero_copy(payload));
</code></pre>
<p dir="auto">这样做的好处：</p>
<ol>
<li><strong>类型强制约束</strong>：传错类型编译就过不了。</li>
<li><strong>调用点意图清晰</strong>：看到 <code>zero_copy(...)</code> 立刻知道这块 buffer 要特殊对待。</li>
<li><strong>完成语义差异明确</strong>：零拷贝和普通发送的完成流程完全不同，标签清楚地区分了两条路。</li>
</ol>
<hr />
<h3>4. Awaiter 的状态机：按 CQE flags 区分两阶段</h3>
<p dir="auto">对应 <code>SendZCAwaiter</code> 的核心就是这个 <code>complete()</code> 方法，按照 flags 区分 CQE 的含义：</p>
<pre><code class="language-cpp">void SendZCAwaiter::complete(int result, std::uint32_t flags) noexcept
{
    // 不带 NOTIF：这是数据发送结果 CQE，保存结果
    if (!(flags &amp; IORING_CQE_F_NOTIF))
        set_result(result, flags);

    // 不带 MORE：标志最后一个 CQE，结束整个操作
    if (!(flags &amp; IORING_CQE_F_MORE)) {
        context().untrack(this);
        if (handle_)
            handle_.resume();
    }
}
</code></pre>
<p dir="auto">这里的关键点：</p>
<ul>
<li>第一个 CQE（不带 NOTIF，带 MORE）：存下发送字节数或错误码，不恢复协程</li>
<li>第二个 CQE（带 NOTIF，不带 MORE）：跳过结果保存（已有了），恢复协程</li>
</ul>
<p dir="auto">时序是这样的：</p>
<pre><code>User coroutine              SendZCAwaiter              io_uring kernel
     |                            |                            |
     | co_await async_send_some   |                            |
     | (net::zero_copy(buf))      |                            |
     |-------------------------&gt;  |                            |
     |                   await_suspend()                       |
     |                    - prepare SQE                        |
     |                    - track(this)                        |
     |                            | submit &amp; wait              |
     |                            |--------------------------&gt; |
     |  🔄 (suspended)            |                            |
     |                            |                   1st CQE: send result
     |                            | complete(...)              |
     |                 !(NOTIF)✓  | set_result(bytes sent)     |
     |                 (MORE)✓    | (don't resume yet)         |
     |                            |                            |
     |                            |            2nd CQE: notification
     |                            | complete(...)              |
     |                 !(NOTIF)✗  | (skip set_result)          |
     |                 (MORE)✗    | untrack(this)              |
     |                            | handle_.resume()           |
     | 🔄 (awoken)                |                            |
     | auto result =              |                            |
     | await_resume()             |                            |
     | (return byte_sent_)        |                            |
     | &lt;-- proceed                |                            |
</code></pre>
<p dir="auto">两个 CQE 都到了，buffer 安全可释放，协程才真正恢复。</p>
<hr />
<h3>5. 实战边界条件</h3>
<p dir="auto">虽然协程返回时 notification 已到，但从工程实践角度有几点值得注意：</p>
<ol>
<li>
<p dir="auto"><strong>Buffer 生命周期的本质约束</strong><br />
本质上只需要保证 buffer 在协程返回前保持有效。由于协程返回 = notification 已到，这个条件在实际代码中很容易满足。对于栈上的 <code>std::string</code>、<code>std::vector</code> 或其他作用域内存，这不成问题。只有当 buffer 是通过 <code>new</code> 分配且在其他线程被 <code>delete</code> 时，才会违反这个约束——但这种情况通常表明应用层本身的内存管理有问题。</p>
</li>
<li>
<p dir="auto"><strong>内存被占用的时间，比想象中还要长（TCP ACK 陷阱）</strong><br />
第 1 节提到"驱动访问内存的时间变长了"，乍一看像是微秒级的 DMA 操作。但在真实场景中，这个"变长"往往不是驱动的事儿，而是<strong>毫秒到秒级的网络 RTT</strong>。很多网卡驱动和协议栈实现中，内核必须等到对端返回 TCP ACK 确认包，确认数据不需要重传了，才会吐出带 <code>NOTIF</code> 的第二个 CQE。这意味着 <code>SEND_ZC</code> 的 notification 延迟直接和恶劣的物理网络环境强绑定：丢包、重传、网络拥塞都会拉长等待时间。在一个跨洲际链路上发送，notification 可能要等好几秒，期间内存始终被内核占用。这对内存规划和资源隔离的影响不可忽视。</p>
</li>
<li>
<p dir="auto"><strong>大文件的"内存锁定"爆炸（RLIMIT_MEMLOCK）</strong><br />
虽然大文件（MB 级）发送时零拷贝有明显优势，但绝对<strong>不能一次性把几个 GB 的大文件全都梭哈给 <code>SEND_ZC</code></strong>。原因是：内核在等待 notification 期间，会把这块物理内存 Pin 住（锁定，防止被 Swap）。如果瞬间提交过大内存，极易触发操作系统的 <code>RLIMIT_MEMLOCK</code> 限制导致直接报错，或者把物理内存撑爆。工业界的正确做法是<strong>分片（Chunking）</strong>：用一个 <code>while</code> 循环，每次 <code>co_await socket.async_send_some(net::zero_copy(chunk))</code> 发送几 MB，等这几 MB 的 notification 回来（协程唤醒）后，再发下一块。这样既能享受零拷贝的收益，又能保持内存占用在可控范围内。</p>
</li>
<li>
<p dir="auto"><strong>错误也要等 notification</strong><br />
即使第一个 CQE 返回错误，completion 流程也要走完（等 notification）。不要假设出错时内核会跳过 notification。</p>
</li>
</ol>
<p dir="auto">示例调用（小消息场景）：</p>
<pre><code class="language-cpp">std::string payload = "Zero-copy message from io_uring SEND_ZC\n";
auto result = co_await socket.async_send_some(net::zero_copy(payload));
if (!result) {
    log::error("send_zc failed: {}", result.error());
    co_return;
}
// 作用域结束时 payload 自动销毁，此时已安全
</code></pre>
<p dir="auto">大文件分片示例：</p>
<pre><code class="language-cpp">std::ifstream file("large_file.bin", std::ios::binary);
const size_t chunk_size = 1024 * 1024;  // 1 MB
std::vector&lt;char&gt; buffer(chunk_size);

while (file.read(buffer.data(), chunk_size)) {
    size_t bytes_read = file.gcount();
    auto result = co_await socket.async_send_some(
        net::zero_copy(std::span(buffer.data(), bytes_read))
    );
    if (!result) {
        log::error("send chunk failed: {}", result.error());
        break;
    }
    // notification 回来，内存可重用于下一块
}
</code></pre>
<hr />
<h3>6. 小结</h3>
<p dir="auto"><code>SEND_ZC</code> 的核心是一个<strong>两阶段完成模型</strong>：</p>
<ol>
<li>第一个 CQE：发送是否成功、发了多少字节</li>
<li>第二个 CQE：内核通知"我用完你的 buffer 了"</li>
</ol>
<p dir="auto">协程在第二个 CQE 到来时才恢复，这样调用方既得到了发送结果，也确切知道何时可以释放 buffer。</p>
<p dir="auto">在 API 层用 <code>ZeroCopyT</code> tag type 强制显式选择零拷贝，在 awaiter 层按 CQE flags 正确分发两阶段完成，这两层结合才是鲁棒的设计——既不会因为侥幸巧合而偶然正确，也能清晰表达意图。</p>
]]></description><link>http://forum.d2learn.org/topic/201/基于-io_uring-的-c-20-协程网络库-12-零拷贝发送send_zc</link><generator>RSS for Node</generator><lastBuildDate>Sat, 02 May 2026 20:04:30 GMT</lastBuildDate><atom:link href="http://forum.d2learn.org/topic/201.rss" rel="self" type="application/rss+xml"/><pubDate>Sat, 02 May 2026 16:20:40 GMT</pubDate><ttl>60</ttl></channel></rss>