RingBuffer:面向网络编程的环形缓冲区实现
一、RingBuffer 是什么
RingBuffer 也叫环形缓冲区,本质上是一段固定大小的连续内存
它的特点是,写到末尾后可以从头继续写,逻辑上看起来像一个环
在网络编程中,RingBuffer 常用于保存 socket 收到但还没处理完的数据,或者保存即将发送但还没写完的数据
比如一次 read 只读到半个 HTTP 请求,就可以先放进 RingBuffer,等下一次数据到来后再继续解析
二、RingBuffer 的核心思想
1 使用 head 和 tail 表示数据范围
这个实现中使用 m_head 和 m_tail 表示缓冲区中数据的起点和终点
m_head 指向可读数据的开始位置
m_tail 指向可写数据的结束位置
当前已经使用的空间是 m_tail - m_head
剩余可写空间是 m_size - (m_tail - m_head)
2 逻辑下标不断增长,物理下标取模
m_head 和 m_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;
}
普通的 add 和 read 更适合业务层手动读写数据
但是在 socket 编程中,还可以进一步优化
四、面向 socket 的读写接口
这个 RingBuffer 的一个特点是,专门提供了 read_fd、write_fd、recv_fd、send_fd
它们不是简单地先读到临时数组,再拷贝到 RingBuffer
而是直接把 RingBuffer 的空闲区域交给系统调用
这样可以减少一次中间拷贝
五、使用 readv / writev 处理环形空间
1 为什么需要 iovec
环形缓冲区的可写空间或可读数据,可能被数组末尾切成两段
比如当前 tail_pos 在数组靠后的位置,剩余空间可能是:
第一段:tail_pos ~ m_size - 1
第二段:0 ~ 某个位置
readv 和 writev 支持一次传入多个内存片段,所以刚好适合 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,如果返回 -1 且 errno 是 EAGAIN 或 EWOULDBLOCK,说明当前没有数据可读,不应该移动 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_fd 和 read_fd 思路类似
区别是 recv_fd 使用 recvmsg
它可以传入 flags
比如可以支持 MSG_DONTWAIT、MSG_PEEK 等选项
2 send_fd
send_fd 和 write_fd 思路类似
区别是 send_fd 使用 sendmsg
它也可以传入 flags
比如可以使用 MSG_NOSIGNAL,避免向已关闭连接发送数据时触发 SIGPIPE