RingBuffer:面向网络编程的环形缓冲区实现

RingBuffer:面向网络编程的环形缓冲区实现

一、RingBuffer 是什么

RingBuffer 也叫环形缓冲区,本质上是一段固定大小的连续内存

它的特点是,写到末尾后可以从头继续写,逻辑上看起来像一个环

在网络编程中,RingBuffer 常用于保存 socket 收到但还没处理完的数据,或者保存即将发送但还没写完的数据

比如一次 read 只读到半个 HTTP 请求,就可以先放进 RingBuffer,等下一次数据到来后再继续解析

二、RingBuffer 的核心思想

1 使用 head 和 tail 表示数据范围

这个实现中使用 m_headm_tail 表示缓冲区中数据的起点和终点

m_head 指向可读数据的开始位置

m_tail 指向可写数据的结束位置

当前已经使用的空间是 m_tail - m_head

剩余可写空间是 m_size - (m_tail - m_head)

2 逻辑下标不断增长,物理下标取模

m_headm_tail 不需要每次回绕,而是一直递增

真正访问数组时,再通过取模映射到真实下标

普通写法是 index % m_size

这个实现中为了提高效率,把缓冲区大小调整为 2 的幂,然后使用 index & (m_size - 1) 代替取模

比如 m_tail & (m_size - 1) 就是当前真实写入位置

3 为什么要调整为 2 的幂

因为只有当 m_size 是 2 的幂时,index & (m_size - 1) 才等价于 index % m_size

例如大小为 8 时,m_size - 1 是 7,二进制是 111

此时 index & 7 可以快速得到 0 ~ 7 的循环下标

三、普通读写接口

1 写入数据 add

add 的作用是把用户传入的数据写入 RingBuffer

如果剩余空间不够,直接返回 -1

如果写入位置到数组末尾不够放,就分成两段写

cpp 复制代码
int RingBuffer::add(const char* data, int len) {
    int remain = m_size - (m_tail - m_head);
    if(len > remain) return -1;

    int tail_pos = m_tail & (m_size - 1);
    int first = std::min(len, m_size - tail_pos);

    memcpy(m_buffer + tail_pos, data, first);
    memcpy(m_buffer, data + first, len - first);

    m_tail += len;
    return len;
}

这里的关键是分段写入

第一段从 tail_pos 写到数组末尾

第二段从数组开头继续写

2 读取数据 read

read 的作用是从 RingBuffer 中读取指定长度的数据到用户缓冲区

如果可读数据不够,也返回 -1

cpp 复制代码
int RingBuffer::read(char* dst, int len) {
    int used = m_tail - m_head;
    if(used < len) return -1;

    int head_pos = m_head & (m_size - 1);
    int first = std::min(len, m_size - head_pos);

    memcpy(dst, m_buffer + head_pos, first);
    memcpy(dst + first, m_buffer, len - first);

    m_head += len;
    return len;
}

普通的 addread 更适合业务层手动读写数据

但是在 socket 编程中,还可以进一步优化

四、面向 socket 的读写接口

这个 RingBuffer 的一个特点是,专门提供了 read_fdwrite_fdrecv_fdsend_fd

它们不是简单地先读到临时数组,再拷贝到 RingBuffer

而是直接把 RingBuffer 的空闲区域交给系统调用

这样可以减少一次中间拷贝

五、使用 readv / writev 处理环形空间

1 为什么需要 iovec

环形缓冲区的可写空间或可读数据,可能被数组末尾切成两段

比如当前 tail_pos 在数组靠后的位置,剩余空间可能是:

第一段:tail_pos ~ m_size - 1

第二段:0 ~ 某个位置

readvwritev 支持一次传入多个内存片段,所以刚好适合 RingBuffer

2 writable_iov:为 readv 准备写入空间

writable_iov 用来获取当前 RingBuffer 中可以写入的空间

它最多返回两段 iovec

cpp 复制代码
int RingBuffer::writable_iov(struct iovec iov[2], int to_write) {
    int remain = m_size - (m_tail - m_head);
    remain = std::min(remain, to_write);

    if(remain == 0) return 0;

    int tail_pos = m_tail & (m_size - 1);
    int first = std::min(m_size - tail_pos, remain);

    iov[0].iov_base = m_buffer + tail_pos;
    iov[0].iov_len = first;

    int second = remain - first;
    if(second > 0) {
        iov[1].iov_base = m_buffer;
        iov[1].iov_len = second;
        return 2;
    }

    return 1;
}

这样 readv 就可以把 socket 中的数据直接读到 RingBuffer 中

3 readable_iov:为 writev 准备可读数据

readable_iov 用来获取当前 RingBuffer 中可以发送的数据

它同样最多返回两段 iovec

cpp 复制代码
int RingBuffer::readable_iov(struct iovec iov[2], int to_read) {
    int used = m_tail - m_head;
    used = std::min(used, to_read);

    if(used == 0) return 0;

    int head_pos = m_head & (m_size - 1);
    int first = std::min(m_size - head_pos, used);

    iov[0].iov_base = m_buffer + head_pos;
    iov[0].iov_len = first;

    int second = used - first;
    if(second > 0) {
        iov[1].iov_base = m_buffer;
        iov[1].iov_len = second;
        return 2;
    }

    return 1;
}

这样 writev 就可以直接把 RingBuffer 中的数据写入 socket

六、read_fd 和 write_fd 的实现

1 read_fd:从 fd 读入 RingBuffer

read_fd 内部先调用 writable_iov 获取可写空间

然后通过 readv 把数据直接读入 RingBuffer

cpp 复制代码
int RingBuffer::read_fd(int fd, int to_read) {
    struct iovec iov[2];
    int iov_cnt = writable_iov(iov, to_read);

    if(iov_cnt == 0) {
        errno = ENOSPC;
        return -1;
    }

    ssize_t n = ::readv(fd, iov, iov_cnt);
    if(n > 0) {
        m_tail += static_cast<int>(n);
    }

    return static_cast<int>(n);
}

对于非阻塞 socket,如果返回 -1errnoEAGAINEWOULDBLOCK,说明当前没有数据可读,不应该移动 m_tail

2 write_fd:从 RingBuffer 写入 fd

write_fd 内部先调用 readable_iov 获取可读数据

然后通过 writev 写入 socket

cpp 复制代码
int RingBuffer::write_fd(int fd, int to_write) {
    struct iovec iov[2];
    int iov_cnt = readable_iov(iov, to_write);

    if(iov_cnt == 0) {
        return 0;
    }

    ssize_t n = ::writev(fd, iov, iov_cnt);
    if(n > 0) {
        m_head += static_cast<int>(n);
    }

    return static_cast<int>(n);
}

对于非阻塞 socket,一次 writev 不一定能把所有数据都写完

所以只需要根据实际写入的字节数移动 m_head

剩余数据继续留在 RingBuffer 中,等待下一次 EPOLLOUT 再发送

七、recv_fd 和 send_fd

1 recv_fd

recv_fdread_fd 思路类似

区别是 recv_fd 使用 recvmsg

它可以传入 flags

比如可以支持 MSG_DONTWAITMSG_PEEK 等选项

2 send_fd

send_fdwrite_fd 思路类似

区别是 send_fd 使用 sendmsg

它也可以传入 flags

比如可以使用 MSG_NOSIGNAL,避免向已关闭连接发送数据时触发 SIGPIPE

相关推荐
XWalnut3 小时前
Redis从入门到精通
数据库·redis·缓存
LZZ and MYY4 小时前
RTS 在windows和Linux之间ShareMem
linux·运维·服务器
爱学习的徐徐4 小时前
Linux 基础IO
linux·服务器
郝学胜-神的一滴4 小时前
中级OpenGL教程 008:精准控制高光光斑大小与强度
c++·unity·godot·three.js·图形学·opengl·unreal
蛋蛋的学习记录4 小时前
C#窗体应用中使用EasyModbusCore通讯
服务器·c#·tcp
zt1985q4 小时前
本地部署源代码管理解决方案 Bitbucket Data Center 并实现外部访问
运维·服务器·数据库·网络协议·postgresql·源代码管理
xiaofeichaichai4 小时前
Service Worker、PWA 与 Web Worker — 离线缓存与主线程算力分离
前端·缓存
牢姐与蒯4 小时前
c++数据结构之c++11(一)
数据结构·c++
禹凕4 小时前
Linux基础——环境
linux·运维·服务器·ubuntu
Lin_Aries_04214 小时前
ETPNav 复现指南:从环境搭建到连续环境视觉语言导航全流程
笔记·具身智能·datawhale