跳转至内容
  • A place to talk about whatever you want

    22 主题
    168 帖子
    没有新主题
  • 7 主题
    20 帖子
    SPeakS

    @dustchens 链表结构损坏, 不闭环了 (如果问题解决可以把帖子状态设置为已解决

  • 开源软件 | 开源社区 | 开源理念 | 开源与商业 | 开源可持续发展 等相关话的交流讨论
    注: 这里的"开源"是泛化的共建共享概念, 范围包含 OSI的范围、自由软件、CC等相关内容

    57 主题
    246 帖子
    MoYingJiM

    补一个 0BSD,这个许可证很多时候也被放在与 Unlicense 和 WTFPL 相提并论的(都是公共领域)

  • 62 主题
    255 帖子
    dustchensD
    2 改进 vector,实现 Big5

    这部分内容我们依旧使用sunrisepeak大佬的代码演示,但在后半段,我们将会深入一些内容

    2.1 vector需要什么?

    经过第一章的描述,我们现在应该思考vector需要什么,由此我们才能得知vector需要实现什么

    整块空间 类比函数栈,它由空间适配器 Allocator提供,申请一整块内存 表现的像数组 vector需要一个size,标定数组的边界 额外的空间 vector是一个动态的数组,它标志着vector的空间可以调整大小,因此需要一个容量capacity 额外的空间,并不是再申请一块内存,而是在整块内存内部,需要区分哪些已经使用,哪些未使用且将来可以使用,一旦突破这个界限,就需要再申请一大块内存进行扩容

    经过以上总结,再回看第一章节的内容,可以明显发现一点,只有一个size,这个size既标定了已经使用的数量,又标定了vector能容纳的最大元素数量。这显然是不合理的,即便我们需要把这块空间内都初始化出对象,但依然缺少一个逻辑上能区分已使用量和未使用量的内容。

    2.2 添加容量

    代码方面我尽量复用之前的,大体讲解可以参照对应的视频讲解,后续讲解我只会说明一些我认为初学者容易不理解的地方

    template <typename T, typename Alloc = DefaultAllocator> class Vector { public: // 此处添加mCapacity_e的初始化 Vector() : mSize_e { 0 }, mCapacity_e { 0 }, mDataPtr_e { nullptr } { } Vector(int size) : mSize_e { 0 }, mCapacity_e { size } { // 1. 注意点1 mDataPtr_e = static_cast<T *>(Alloc::allocate(sizeof(T) * mCapacity_e)); // 2. 注意点2 for (int i = 0; i < mCapacity_e; i++, mSize_e++) { new (mDataPtr_e + i) T(); } } ~Vector() { if (mSize_e) { // 3. 注意点3 for (int i = 0; i < mSize_e; i++) { (mDataPtr_e + i)->~T(); } Alloc::deallocate(mDataPtr_e, mCapacity_e * sizeof(T)); } } private: int mSize_e; int mCapacity_e; T * mDataPtr_e; };

    可以看到,大框架基本不变,但是添加了容量后,依旧需要注意语句变化带来的含义

    申请空间以容量为标定 初始化一个元素mSize_e就需要增长一个 元素析构以mSize_e为边界 再次对比到函数空间,此时我们就可以理解为什么需要用到new (mDataPtr_e + i) T(); 这样的技巧 在函数中初始化对象,我们可以直接写下 T aaa,不用我们自己管理这个对象在函数空间内部的哪个地方 在vector中,没有这种机制,让对象挨个排列在内部空间中,因此,我们需要手动告诉程序,我们需要在 new(地址) 这个位置,初始化 T() 对象 。正是这个不同之处,需要我们使用到placement new,也就是定位。如果有自动的机制,我们甚至可以直接和在函数内部初始化对象一样,丝毫不用关心在函数空间的哪里有这个对象!

    如何销毁内容?

    类比到函数栈中,我们已知函数在退出时会自动调用对象的析构函数,因此我们希望vector也在它自身析构时,显式调用已构建对象的析构函数 之所以不需要类似placement new这样的操作,是因为在创建时,需要强制确定内存位置,但是销毁时,我们已经知道了哪些内容已经存在,可以显式直接调用。 capacity的作用

    capacity的加入,在目前还看不出区别,这是因为这几个构造函数都是直接让size和capacity相等,但是后续一旦需要再添加元素,产生扩容,那么就会体现出区别。
    引入capacity后,我们就需要在脑海中将整块内存划分为不同的区域

    已构建对象的内存 [data, size) 这部分内容上已经有了对象,可以看到析构也是以此为界限 未构建对象的内存 [size, capacity) 这部分没有任何内容,构建新内容必须使用placement new这样的技术 需要再次强调placement new技术,只有在裸内存上才能使用,也就是如果已经在一个地方用此技术初始化了一个对象,那么再次使用placement new在同一位置创建一个新内容是不允许的!除非先调用析构函数,将对象销毁,变为裸内存的状态才能继续使用。这部分对于新手来说直接理解依旧是非常抽象的,并且这个行为是未定义行为,也就是编译器不会给你强制警告!
    类比到函数中,那就是无法第二次调用对象的构造函数进行初始化!因为构造就已经开启了生命周期,再次使用会产生一些列问题,比如内存泄漏,多次析构等等。graph TD subgraph 正确方式 A[已有元素位置: 活对象 T] --> B[调用赋值运算符 *p = val] B --> C[对象值更新,无泄漏 ✅] end subgraph 错误方式 A --> D[❌ 直接 placement new 覆盖] D --> E[旧对象未析构,资源泄漏<br/>或二次析构风险(未定义行为)] end graph TD subgraph 正确方式 S[栈上对象 T x 已构造] --> T[使用赋值 x = val] T --> U[对象值更新 ✅] end subgraph 错误方式 S --> V[❌ 尝试第二次调用构造函数] V --> W[编译错误:不能直接调用构造函数<br/>或 显式析构后 placement new<br/>导致作用域结束时二次析构] end 接上一条,那么在某一位置已有对象的情况下,除了调用析构函数将对象销毁,变成裸内存,另外的方法就是调用赋值运算符,以更新内容,包括拷贝赋值和移动赋值。这也和我们在函数内使用 = 进行赋值和更新是同一个道理。 flowchart LR subgraph stack_frame[函数栈帧] V[vector对象\n data / size / capacity] end subgraph heap_buffer[堆上的连续存储] E1[元素0] E2[元素1] S1[空槽位] end V --> E1 V --> E2 V --> S1 data data+size data+capacity ↓ ↓ ↓ ┌─────────────────────────┬─────────────────────────┐ │ 已构造对象区 │ 未构造预留区 │ └─────────────────────────┴─────────────────────────┘ 区域 地址范围 对象状态 可执行操作 已构造对象区 [data, data+size) 存在活对象 • 改变内容:用拷贝/移动赋值<br>• 若必须整体替换:先析构,再 placement new(一般不推荐) 未构造预留区 [data+size, data+capacity) 原始内存(无对象) • 创建对象:必须通过 placement new 构造<br>• 无“修改”一说,因为没有对象可以修改

    这里也可以看出我们类比函数栈的好处,那就是不必拘泥于抽象的、高深的内容,不用一开始就去理解STL标准库的思想
    毕竟这是该领域顶级专家的成果,想要短时间内容入门是非常困难的。
    但是一旦我们将它和我们日常使用的内容相连,我们就可以通过共性去理解其原理,通过不同之处的探究,带着问题探究其深意

    2.3 全部构造函数

    此处不会详细讲解3/5/0法则,只需要知道如果我们显式定义了析构函数、拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符,其中的任意一项,就需要把这几项全部定义出来(或者删除掉)
    简而言之,如果有任意一项资源需要我们手动管理,这几个函数就必须认真对待。因为有手动管理,才需要定义这些内容,而这些内容一旦定义任意一项,移动相关的内容就不会由编译器自动生成,其余的哪怕生成,也是有问题的!

    1 构造函数的类型

    此处简单讲解一下不同类型构造函数的作用和区别,以及一些隐式的条件

    默认构造函数 不需要参数就能调用,用于 ClassT tClassT t{} 这样声明一个对象,没有参数
    同时这个函数还有一系列的规则,包括自动生成、被抑制生成等 普通构造函数 需要提供参数才能调用 用于 ClassT t(10) 这样的调用
    之所以区分,是因为默认构造函数有一系列特殊规则 复制构造函数 深拷贝 用于ClassT t_copy(other)这样的形式 对于有手动管理资源的类,不能使用编译器提供的复制构造,必须自己实现深拷贝,否则只会拷贝指针这样的门牌号,指向同一块内存,产生双重释放的问题 移动构造函数 窃取资源 用于ClassT t_copy(std::move(other))这样的形式 将资源从别的对象转移到自身,并且需要切断别的对象对于资源的所有权 注:不需要参数 不代表没有参数,因为还涉及到默认值等问题。且以上调用形式可能存在部分问题,需要后续研究。这里研究的重点还是这几个内容如何实现 注2:std::move 只是代表将对象转换为右值引用的形式,让我们能够调用移动构造函数,但并没有字面意义上的移动功能 1 默认构造函数

    在上文中,Vector() : mSize_e { 0 }, mCapacity_e { 0 }, mDataPtr_e { nullptr } { } 就是一个默认构造函数,但是这个函数被我们显式声明了,因为声明了一般的构造函数后,编译器是不会再生成默认构造函数的,除非我们自己再写出来

    那这样的函数还是默认构造函数吗?

    // 注意此处的参数 Vector(int size = 0) : mSize_e { 0 }, mCapacity_e { size } { mDataPtr_e = static_cast<T *>(Alloc::allocate(sizeof(T) * mCapacity_e)); for (int i = 0; i < mCapacity_e; i++, mSize_e++) { new (mDataPtr_e + i) T(); } } 这依然是一个默认构造函数 依旧是前文所讲内容,size有一个默认值,无需参数就能调用,符合规则 默认值 需要以后额外补充,大家也可以自行查找相关资料了解 同时这个默认构造函数也可以提供参数调用,但这个问题暂时无法深入 2 普通构造函数

    此处划分这两个构造函数的方式可能有问题,希望大家可以指出

    Vector(int size) : mSize_e { 0 }, mCapacity_e { size } 完整代码参照上文中出现的 这里的size决定了我们调用的时候必须给出参数才能匹配到该函数,比如用{} 初始的形式,Vector v1{10}; 10代表了初始的容量,以及将这10个空位都初始化上对应的对象 : mSize_e { 0 } 使用了C++11提供的初始化器,可以方便地将成员进行初始化,避免遗漏等问题。需要注意的是,初始化的顺序并不由我们写的顺序决定,不考虑继承等情况下,由成员在声明时的顺序决定 3 复制构造函数

    为什么需要深拷贝?

    指针 这是一切问题的根源,编译器生成的复制构造,只会去复制指针的值,这一段同样对应第一章补充内容,正如函数作用域不会管理指针背后的资源,默认生成的拷贝构造也不会去考虑远在天边的内存。

    内存复制 拷贝的主要意义是对资源的拷贝,简单指针复制是无效的,必须手动提供对资源内存的复制,我们希望得到的是另一块独立的内存资源,而不是当前资源的影子。

    由此,我们的目标也就很明确了,申请一块大小相同的内容,并在其上一一复制对象

    Vector(const Vector& other) : mSize_e {0}, mCapacity { other.mCapacity } { mDataPtr_e = static_cast<T *>(Alloc::allocate(sizeof(T) * mCapacity)); for (int i = 0; i < other.mSize_e; i++, mSize_e++) { new (mDataPtr_e + i) T(other.mDataPtr_e[i]); } } 接收参数 const Vector& 复制复制,首先就是要有另一个对象,因此采用引用的方式,同时不会修改也不允许修改另一个对象的内容,因此使用const修饰 size capacity 可以看出,这二者是有区别的,再次反映出了前文的内容。整体容量和已构造元素是要区分开的 placement new new (mDataPtr_e + i) T(other.mDataPtr_e[i]); 需要注意,我们依旧是在裸内存上进行构造,因此仍然需要使用定位new技术 4 移动构造函数

    快!

    Vector(Vector &&other) : mSize_e { other.mSize_e }, mCapacity { other.mCapacity }, mDataPtr_e {other.mDataPtr_e} noexcept { // reset other.mSize_e = 0; other.mCapacity = 0; other.mDataPtr_e = nullptr; }

    接收参数 Vector &&other 移动也是要有另一个对象,因此采用右值引用的方式,同时必须修改另一个对象的内容,所以无需const修饰

    浅拷贝 可以看到移动的本质是浅拷贝,但是之前在复制构造时说不能使用浅拷贝,但是此处又使用了浅拷贝,这是为什么?

    // reset other.mSize_e = 0; other.mCapacity = 0; other.mDataPtr_e = nullptr;

    原因在于,我们 “抛弃” 了拿来移动的那个对象,更准确地说,我们宣布另一个对象不再持有资源了。而之前的复制构造,需要两个对象都有资源,这才是关键区别

    快是最关键的好处,因为只复制几个指针和大小容量等内容,是非常迅速的,但是代价就是另一个vector对象失去了资源

    关于右值引用请大家自行查阅相关内容。其实就是为了区别调用的函数,调用移动必须要用这种方式。其他方式依旧可以完成这种内部资源转移,但是为了语义上的区分,这样做是最好的

    2.4 特殊运算符 =

    注意,此处的主体是运算符,而不是构造函数

    既然是运算符,区别于构造函数,那就意味着vector对象已经经历过调用构造函数的阶段,也就是初始化成功后,才能调用运算符 既然已经初始化过了,说明对象内部是有内容的 运算符可以出现自赋值的情况 比如 vec1 = vec1

    以上的注意点,构成了我们接下来两个特殊运算符的要点

    运算符类型 复制赋值运算符 类似赋值构造函数 移动赋值运算符 类似移动构造函数 调用方式都相同,使用 = 调用,但是同样,需要用不同的方式去匹配不同的运算符 重写operator=() 其中最重要的就是()内参数的选择 需要有返回值 1 复制赋值运算符

    接下来,我们需要根据代码,来分析上文提到的特殊要点

    // 1 Vector& operator=(const Vector &other) { // 2 this->~Vector(); mSize_e = other.mSize_e; mCapacity = other.mCapacity; mDataPtr_e = static_cast<T *>(Alloc::allocate(sizeof(T) * mCapacity)); for (int i = 0; i < mSize_e; i++) { mDataPtr_e[i] = other.mDataPtr_e[i]; } // 3 return *this; }

    可以看出,复制赋值运算符,基本沿袭了赋值构造的思路,但是多了释放自身内容和返回的步骤

    返回值是vector& 既然已经初始化过,有内容,就必须先释放掉vector对象本身的内容,因此调用了vector本身的析构函数 对this指针,解引用,获得vector对象,匹配上引用返回 this指针内容需要大家自行查阅

    以上的代码符合了需要释放自身旧内容、返回对象。但是依旧是有问题的,可以看出漏掉了最关键的自赋值的问题

    倘若出现 vec1 = vec1 这样的代码,那么调用之后,在this->~Vector(); 就把自身内容全部释放了,也就是说 vec1 现在变成了一个空对象,所持有的资源消失,这显然不是我们希望看到的

    如何做呢? 非常简单,也就是如果判断传进来的对象是自身,那就直接返回,否则就执行销毁自身内容并复制的步骤

    Vector& operator=(const Vector &other) { // 注意 if(this != &other) { this->~Vector(); mSize_e = other.mSize_e; mCapacity = other.mCapacity; mDataPtr_e = static_cast<T *>(Alloc::allocate(sizeof(T) * mCapacity)); for (int i = 0; i < mSize_e; i++) { mDataPtr_e[i] = other.mDataPtr_e[i]; } } return *this; } this != &other this是指向对象本身的指针,而对对象使用 &,取得了地址,也就相当于this,因此此处是地址的比较 之所以比较地址,而不是比较对象是否相等,是因为这样最简单,而且比较对象,是需要重写比较相关的运算符才可以进行的 2 移动赋值运算符 Vector& operator=(Vector &&other) noexcept { // 注意 if(this != &other) { this->~Vector(); mSize_e = other.mSize_e; mCapacity = other.mCapacity; mDataPtr_e = other.mDataPtr_e; other.mSize_e = 0; other.mCapacity = 0; other.mDataPtr_e = nullptr; } return *this; } 可以看出,移动赋值运算符也是类似的结构 2.5 总结

    这段将简单总结一下第二章的内容

    首先我们模仿了函数的效果,但是手动进行了内存管理,并且手动控制了在这块内存上对象的生命周期 解耦了已有元素数量(size)和容量(capacity),这要求我们在构造函数和析构函数以及特殊运算符中,申请空间需要以capacity为大小控制对象生命周期,需要以size为大小 构造函数和特殊运算符的规则,使得我们必须手动实现资源的管理,实现由编译器自动生成的内容无法实现的功能

    但是,显然这部分内容是非常浅显的,大部分的讲解都会提到这些内容,也不过是用几个新技术,在堆内存上模拟了一个数组的功能,并且我们大部分的接口还没有实现。如何再深入进去呢?如何理解标准库的一些思想呢?

    我们要学习vector,不仅是要学习代码怎么写,怎么实现,更要学习它的思想,需要在代码中抽丝剥茧,提炼出能体现某些原则的东西。

    关键!!!!

    可以注意到,以上的部分函数,添加了一个叫noexcept的东西
    它声明了,该函数不会抛出异常

    异常是我们串联起后续内容,拆解stl思想的一个重要切入点 没有异常,我们是难以理解为什么vector的代码如此复杂,复杂的同时还有一系列的原则,仿佛数据库一样 3 noexcept 引发的血案

    // TODO

    感谢 本文代码的核心框架来自 bilibili LH_Mouse大佬的视频。 同时也参考了 sunrisepeak 大佬的代码和视频: BV1K1421z7kt。且本文大部分讲解代码直接用的对应的教学文档代码。
  • 36 主题
    74 帖子
    DoomjustinD

    在前面的写路径优化里,我们已经用 writev 这类手段减少了系统调用的次数。接下来这一篇要解决另一个方向的开销:数据复制

    传统的 send 流程里,用户态的数据会先被复制进内核 socket buffer,再由驱动发给网卡。对于大块数据和高频发送,这一次复制本身就是明显的开销。

    Linux io_uring 的 IORING_OP_SEND_ZC 就是为了避免这一步。但要用对它,需要理解它的完成语义——这是大多数人第一次用时最容易出错的地方。

    1. 为什么需要零拷贝发送

    普通 send 的流程:

    用户 buffer ↓ (内核 memcpy) kernel socket buffer ↓ (DMA 或驱动) 网卡

    SEND_ZC 想跳过中间那一步复制,直接让驱动从用户态内存读取:

    用户 buffer ↓ (直接 DMA,无 memcpy) 网卡

    代价呢?驱动访问内存的时间变长了,所以在这期间,用户态不能释放或改写这块 buffer

    2. 两个 CQE:数据结果 vs 内存释放通知

    SEND_ZC 的完成模式跟普通 send 不一样。它会回两个 CQE:

    第一个 CQE(不带 IORING_CQE_F_NOTIF,带 IORING_CQE_F_MORE)

    内核告诉你这次 send 的结果:成功了多少字节或者失败原因 但驱动仍在使用你的 buffer 协程此时不会恢复

    第二个 CQE(带 IORING_CQE_F_NOTIF,不带 IORING_CQE_F_MORE)

    内核通知:我已经用完你的这块 buffer,可以释放了 协程在这一刻恢复执行 await_resume() 返回第一个 CQE 里已经存好的发送结果

    所以从协程的角度,返回 = notification 已到 = 内核已停止引用你的 buffer。buffer 完全可以释放或改写。

    3. 用 tag type 在 API 层标记零拷贝意图

    如果 async_send_some 直接接收普通 buffer 加一个 bool 标志,很容易在调用点看不出端倪:

    // 危险写法:一眼看不出 buffer 需要特殊处理 co_await socket.async_send_some(payload, true);

    更好的做法是引入一个 tag type,强制显式说明:

    struct ZeroCopyT { std::span<const std::byte> span; }; template<std::ranges::contiguous_range T> auto zero_copy(const T& range) -> ZeroCopyT { return { std::as_bytes(std::span{ range }) }; } // 调用时意图清晰 co_await socket.async_send_some(net::zero_copy(payload));

    这样做的好处:

    类型强制约束:传错类型编译就过不了。 调用点意图清晰:看到 zero_copy(...) 立刻知道这块 buffer 要特殊对待。 完成语义差异明确:零拷贝和普通发送的完成流程完全不同,标签清楚地区分了两条路。 4. Awaiter 的状态机:按 CQE flags 区分两阶段

    对应 SendZCAwaiter 的核心就是这个 complete() 方法,按照 flags 区分 CQE 的含义:

    void SendZCAwaiter::complete(int result, std::uint32_t flags) noexcept { // 不带 NOTIF:这是数据发送结果 CQE,保存结果 if (!(flags & IORING_CQE_F_NOTIF)) set_result(result, flags); // 不带 MORE:标志最后一个 CQE,结束整个操作 if (!(flags & IORING_CQE_F_MORE)) { context().untrack(this); if (handle_) handle_.resume(); } }

    这里的关键点:

    第一个 CQE(不带 NOTIF,带 MORE):存下发送字节数或错误码,不恢复协程 第二个 CQE(带 NOTIF,不带 MORE):跳过结果保存(已有了),恢复协程

    时序是这样的:

    User coroutine SendZCAwaiter io_uring kernel | | | | co_await async_send_some | | | (net::zero_copy(buf)) | | |-------------------------> | | | await_suspend() | | - prepare SQE | | - track(this) | | | submit & wait | | |--------------------------> | | 🔄 (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_) | | | <-- proceed | |

    两个 CQE 都到了,buffer 安全可释放,协程才真正恢复。

    5. 实战边界条件

    虽然协程返回时 notification 已到,但从工程实践角度有几点值得注意:

    Buffer 生命周期的本质约束
    本质上只需要保证 buffer 在协程返回前保持有效。由于协程返回 = notification 已到,这个条件在实际代码中很容易满足。对于栈上的 std::string、std::vector 或其他作用域内存,这不成问题。只有当 buffer 是通过 new 分配且在其他线程被 delete 时,才会违反这个约束——但这种情况通常表明应用层本身的内存管理有问题。

    内存被占用的时间,比想象中还要长(TCP ACK 陷阱)
    第 1 节提到"驱动访问内存的时间变长了",乍一看像是微秒级的 DMA 操作。但在真实场景中,这个"变长"往往不是驱动的事儿,而是毫秒到秒级的网络 RTT。很多网卡驱动和协议栈实现中,内核必须等到对端返回 TCP ACK 确认包,确认数据不需要重传了,才会吐出带 NOTIF 的第二个 CQE。这意味着 SEND_ZC 的 notification 延迟直接和恶劣的物理网络环境强绑定:丢包、重传、网络拥塞都会拉长等待时间。在一个跨洲际链路上发送,notification 可能要等好几秒,期间内存始终被内核占用。这对内存规划和资源隔离的影响不可忽视。

    大文件的"内存锁定"爆炸(RLIMIT_MEMLOCK)
    虽然大文件(MB 级)发送时零拷贝有明显优势,但绝对不能一次性把几个 GB 的大文件全都梭哈给 SEND_ZC。原因是:内核在等待 notification 期间,会把这块物理内存 Pin 住(锁定,防止被 Swap)。如果瞬间提交过大内存,极易触发操作系统的 RLIMIT_MEMLOCK 限制导致直接报错,或者把物理内存撑爆。工业界的正确做法是分片(Chunking):用一个 while 循环,每次 co_await socket.async_send_some(net::zero_copy(chunk)) 发送几 MB,等这几 MB 的 notification 回来(协程唤醒)后,再发下一块。这样既能享受零拷贝的收益,又能保持内存占用在可控范围内。

    错误也要等 notification
    即使第一个 CQE 返回错误,completion 流程也要走完(等 notification)。不要假设出错时内核会跳过 notification。

    示例调用(小消息场景):

    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 自动销毁,此时已安全

    大文件分片示例:

    std::ifstream file("large_file.bin", std::ios::binary); const size_t chunk_size = 1024 * 1024; // 1 MB std::vector<char> 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 回来,内存可重用于下一块 } 6. 小结

    SEND_ZC 的核心是一个两阶段完成模型

    第一个 CQE:发送是否成功、发了多少字节 第二个 CQE:内核通知"我用完你的 buffer 了"

    协程在第二个 CQE 到来时才恢复,这样调用方既得到了发送结果,也确切知道何时可以释放 buffer。

    在 API 层用 ZeroCopyT tag type 强制显式选择零拷贝,在 awaiter 层按 CQE flags 正确分发两阶段完成,这两层结合才是鲁棒的设计——既不会因为侥幸巧合而偶然正确,也能清晰表达意图。

  • 一个技术知识分享、学习、交流的社区

    16 主题
    54 帖子
    sunrisepeakS

    @dustchens 已支持

  • Got a question? Ask away!

    4 主题
    14 帖子
    SPeakS

    备注一下使数学公式的使用语法

    单行公式语法 - $ 你的公式 $

    $ log_2^n $

    $ log_2^n $

    多行公式语法 - $$ 你的公式 $$

    $$ log_2^n => log_2^9 = 3 , n = 9 $$

    $$
    log_2^n =>
    log_2^9 = 3, n = 9
    $$

公告栏 | Bulletin Board

欢迎加入d2learn社区 - 社区指南
Welcome to the d2learn Community - Community Guide

一个以 [知识、技术、代码、项目、想法、开源] 相关话题为主导的社区
A community focused on topics related to [knowledge, technology, code, projects, ideas, and open source].


在线用户