网络缓冲区 · 通过读写偏移量维护数据区间的高效“零拷贝” Buffer 设计

文章目录

前言

一、引言

二、实现

三、细节补充

Q:如何理解零拷贝?

[Q: 为什么将功能分得这么细?](#Q: 为什么将功能分得这么细?)

Q:什么是状态机?

[Q: 底层为什么使用vector<char> ,而不使用string?](#Q: 底层为什么使用vector<char> ,而不使用string?)

[Q:为什么把 Write 和 MoveWriteOffset 拆开?](#Q:为什么把 Write 和 MoveWriteOffset 拆开?)

Clear

Q:此网络缓冲区与muduo库中的区别

[Q:关于muduo 的 prepend、readv 和 zero-copy 优化](#Q:关于muduo 的 prepend、readv 和 zero-copy 优化)

总结


前言

路漫漫其修远兮,吾将上下而求索;


一、引言

TCP 协议本身拥有发送缓冲区和接收缓冲区(内核态,用于解决"可靠传输 + 流量控制"), 在应用层再实现一个网络缓冲区Buffer 为了解决粘报与半包问题。在高并发网络服务器中(eg.muduo),socket 的读写往往是不完整的。一次 read 可能读取不到完整业务数据,也可能读取多个请求。因此在 Reactor 模型中,须设计一个高效、可复用的网络缓冲区来管理数据的接收与发送。

**Buffer 的设计目标:**支持高效读写、减少内存拷贝、避免频繁扩容、接口与业务解耦

整体内存模型设计:

复制代码
| 已读 | 可读 | 可写 | 
       ↑r    ↑w
  • _reader_idx 指向可读的位置

  • _writer_idx 指向可写的位置

  • vector<char> 保存数据,自适应写空间保障机制

注:自适应写空间保障机制,实现一个接口( EnsureWriteSpace**),** 其逻辑为:判断尾部空间是否足够,尾部空间足够,直接返回;如果尾部空间不够,看头部空间+尾部空间是否能满足len;都不够的话就扩容;简单来说就是:尽量不扩容,实在不行才扩容;

二、实现

复制代码
#pragma once
#include<iostream>
#include<vector>
#include<string>
#include<unistd.h>
#include<cstdint>
#include<assert.h>
#include<algorithm>
#include<string.h>

#define BUFFER_DEFAULT_SIZE 1024

//向buffer 中写入数据、读取数据的功能
class Buffer
{
public:
    Buffer():_reader_idx(0),_writer_idx(0),_buffer(BUFFER_DEFAULT_SIZE)
    {}
    char* Begin(){return &(*_buffer.begin());}
    char* WritePostion(){return _writer_idx + Begin();}
    char* ReadPosition(){return _reader_idx + Begin();}
    uint64_t TailIdleSize(){return _buffer.size()-_writer_idx;}
    uint64_t HeadIdleSize(){ return _reader_idx;}
    uint64_t ReadAbleSize(){ return _writer_idx - _reader_idx;}
    void MoveReadOffset(uint64_t len){
        if(len == 0) return;
        assert(len <= ReadAbleSize());
        _reader_idx += len;
    }
    void MoveWriteOffset(uint64_t len){
        if(len == 0) return;
        assert(len <= TailIdleSize());
        _writer_idx += len;
    }
    //确保写入数据的时候空间足够
    void EnsureWriteSpace(uint64_t len){
        //判断尾部空间是否足够,尾部空间足够,直接返回;如果尾部空间不够,看头部空间+尾部空间是否能满足len;都不够的话就扩容
        if(len <= TailIdleSize()) return;
        else if( len <= TailIdleSize() + HeadIdleSize())
        {
            //挪动数据
            uint64_t rez = ReadAbleSize();
            std::copy(ReadPosition() , ReadPosition() + rez, Begin());
            _reader_idx = 0;
            _writer_idx = rez;
        }
        else{
            //扩容
            _buffer.resize(_writer_idx+len);
        }
    }
    //写 - 写入void*数据、写并移动指针、写入string、写并移动指针、写入Buffer、写并移动指针
    void Write(const void* data , uint64_t len)
    {
        if(len == 0) return;
        EnsureWriteSpace(len);
        const char* d = (const char*)data;
        std::copy(d , d+len , WritePostion());
    }
    void WriteAndPush(const void* data , uint64_t len)
    {
        Write(data ,len);
        MoveWriteOffset(len);
    }
    void WriteString(const std::string& data)
    {
        Write(data.c_str() , data.size());
    }
    void WriteStringAndPush(const std::string& data)
    {
        WriteString(data);
        MoveWriteOffset(data.size());
    }
    void WriteBuffer(Buffer& data)
    {
        Write(data.ReadPosition(), data.ReadAbleSize());
    }
    void WriteBufferAndPush(Buffer& data)
    {
        WriteBuffer(data);
        MoveWriteOffset(data.ReadAbleSize());
    }
    //读 - 读void* 数据、读并移动指针、读出string、读并移动指针、读取一行的数据、读并移动指针
    void Read(void* buf , uint64_t len)
    {
        if(len <= 0) return;
        assert(len <= ReadAbleSize());
        std::copy(ReadPosition() , ReadPosition() + len, (char*)buf);
    }
    void ReadAndPop(void* buf , uint64_t len)
    {
        Read(buf , len);
        MoveReadOffset(len);
    }
    std::string ReadAsString(uint64_t len)
    {
        // if(len <=0 ) return "";
        assert(len <= ReadAbleSize());
        std::string str;
        str.resize(len);
        Read(&str[0] , len);
        return str;
    }
    std::string ReadAsStringAndPop(uint64_t len)
    {
        std::string str = ReadAsString(len);
        MoveReadOffset(len);
        return str;
    }
    //找到分隔符
    char* FindCRLF()
    {
        char* res = (char*)memchr(ReadPosition(),'\n' ,ReadAbleSize());
        return res;
    }
    std::string GetLine()
    {
        char * pos = FindCRLF();
        if(pos == NULL)
        {
            return " ";
        }
        return ReadAsString(pos-ReadPosition()+1);
    }
    std::string GetLineAndPop()
    {
        std::string str = GetLine();
        MoveReadOffset(str.size());
        return str;
    }
    //清空缓冲区
    void Clear()
    {
        _reader_idx = 0;
        _writer_idx = 0;
    }
private:
    std::vector<char> _buffer;
    uint64_t _reader_idx;
    uint64_t _writer_idx;
};

三、细节补充

Q:如何理解零拷贝?

工程里说的 zero-copy ≠ 完全没有拷贝,通常指的是:避免不必要的用户态内存 memcpy

zero-copy 这个"思想"主要由上层使用方式来完成,Buffer 只负责"不给你添乱",并提供实现 zero-copy 的可能性。

Q: 为什么将功能分得这么细?

细粒度接口可以把「数据操作」与「状态推进」解耦,从而提升正确性、灵活性和可维护性(更好实现协议解析部分的功能,网络 IO 的不确定性决定了接口必须解耦)

Buffer **不是vector<char>**的封装,也不是string 的替代品,而是一个:

维护读写状态的有限状态机

核心状态只有两个:_reader_idx、writer_idx

一切行为,都是围绕这两个状态是否推进、何时推进展开的;

Q:什么是状态机?

状态机 = 一个对象的行为,不取决于"你调用了什么函数",而取决于"它当前处于什么状态"

Buffer 的行为依赖"当前状态"

我们来看同一个函数,在不同状态下,行为完全不同:

例子:EnsureWriteSpace(len)

复制代码
if (len <= TailIdleSize()) {
    // 什么都不做
}
else if (len <= TailIdleSize() + HeadIdleSize()) {
    // 整理数据
}
else {
    // 扩容
}

从上述代码中不难发现:**调用的是同一个函数,但做的事完全取决于当前状态 ,**这就是状态机的核心特征。

可以这么说,Buffer 管理的是「数据 + 状态」;

Q: 底层为什么使用vector<char> ,而使用不string?

  • **网络中数据以二进制流的形式传输,**使用vector<char> 符合"字节流"直觉,且可以精准控制,而string的接口与使用习惯,围绕"文本"设计的,容易被误用为 C 字符串,在混用 c_str() 或 C 接口时,遇到'\0' 会引入隐性 bug;
  • string 中存在标准库未定义的SSO (Small String Optimization 小字符串优化),基于这点string 的扩容是隐性的,对于使用者来说不好控制,易出错;而vector<char> 的扩容会走我们实现的接口 EnsureWriteSpace, 显性扩容,即便是扩容后指针失效,上层因为知道这点那么就可以自行解决,不容易出错;

Q:为什么把 Write 和 MoveWriteOffset 拆开?

  • 在网络协议解析中,写入数据和提交数据不是同一件事。可以先写入数据,再判断是否构成完整协议包,只有确认无误后才推进写指针。这样可以避免半包情况下破坏 Buffer 状态。

Clear

将两个指针置空就行了,不用释放空间;

Q:此网络缓冲区与muduo库中的区别

设计思想是一致的,都是三段式内存模型。muduo 额外支持 prepend、readv 和 zero-copy 优化,本文的网络缓冲区更偏向基础版,结构更清晰,方便理解和扩展

Q:关于muduo 的 prepend、readv 和 zero-copy 优化

muduo 的 Buffer 不只是"能用",而是围绕高性能网络 IO 做了三类关键优化:prepend、readv 以及 zero-copy(零拷贝) ,分别解决协议封装、系统调用效率和数据拷贝成本问题。

prepend: 为"协议头"而生的前置空间设计(例如:Tcp协议的body 往往是先生成的,header 需要在最后才能确定(例如长度字段)); 带来的好处:不移动已有数据, O(1) 添加协议头

readv:减少系统调用 + 避免盲目扩容 ; 使用 read() 从 socket 读取数据时,不知道内核会返回多少字节,Buffer 尾部空间可能不够;传统方案中,要么提前扩容(浪费内存),要么读到临时缓冲区再拷贝;readv 一次系统调用将数据优先写入 Buffer,溢出数据暂存在栈空间,再统一 append;本质上是把"是否扩容"的决策推迟到 read 之后,用最小成本适配不确定的 IO 数据量。

zero-copy: 减少不必要的数据拷贝(避免用户态之间的 memcpy,不追求硬件级 DMA zero-copy)


总结

Buffer 的设计是对网络 IO 本质的尊重,看到这里就去动手写写吧~

相关推荐
炮院李教员7 小时前
Ubuntu 24.04 安装common-extensions
linux·运维·ubuntu
qs70167 小时前
c直接调用FFmpeg命令无法执行问题
c语言·开发语言·ffmpeg
zoujiahui_20187 小时前
python中模型加速训练accelerate包的用法
开发语言·python
码界奇点7 小时前
基于Golang的分布式综合资产管理系统设计与实现
开发语言·分布式·golang·毕业设计·go语言·源代码管理
满天星83035777 小时前
【Linux】信号(下)
android·linux·运维·服务器·开发语言·性能优化
专注于大数据技术栈7 小时前
java学习--String
java·开发语言·学习
拾贰_C7 小时前
【Ubuntu】怎么查询Nvidia显卡信息
linux·运维·ubuntu
Chrikk7 小时前
基于 RAII 的分布式通信资源管理:NCCL 库的 C++ 封装
开发语言·c++·分布式
A24207349307 小时前
js常用事件
开发语言·前端·javascript