注:本文为阅读 muduo 网络库源码 Buffer 部分的体悟

本文中 Buffer 一词均指代 muduo 网络库的 class Buffer。

为非阻塞网络库设计一个合理的缓冲区机制是很重要的一环1。muduo 网络库源码中的 Buffer 部分中,我觉得最能体现精华的就是关于设计缓冲区大小这一部分,其核心就是下方所贴出的ssize_t Buffer::readFd(int fd, int *savedErrno)函数,其作用是读取fd指示的对端发来的数据,并保存于内部的vector<char> buf_变量中。下面就是对这一函数体现的设计思路的解析。

如何为每个 TCP 连接分配合理大小的缓冲区呢?如果太大,那么当 TCP 连接数增多之后,内存必然负担过大;而如果过小,那么会增加系统调用(::read(), ::write())的次数,时间开销陡增。作者给出的方案很巧妙,即使用分散/聚集 IO,也称为向量 IO。

初始时,Buffer 的内置缓冲vector<char> buf_的初始大小其实很小,在源码中仅设置为 1K 字节。但在进行::readv()调用之前,会先设置一个栈内变量extrabuf,其大小为 64K 字节。此时相当于有两个缓冲段,再执行::readv()系统调用,调用完毕后会有两种情况:

  • 数据量少 - 此时数据全部读入到buf_中;
  • 数据量多 - 此时额外的数据会读入到extrabuf之中,随后再把这些数据添加到buf_的尾部即可

注意,如果是第 2 种情况,那么buf_的 size 也会变大,突出一个动态变化。不过函数也设计了机制使得buf_的 size 存在一个上限,在源码中的值为 2*64K-1 字节。

上述流程完毕之后,栈内变量extrabuf也就自动被销毁了,不会对内存造成太多负担。当然,其实也会存在对端数据太多,以至于一次::readv()无法全部读取的情况,但这已经是很罕见的情况了,大多数情况下,64K 字节已经足够使用。

注意,这样的方案使得每个线程的extrabuf开销是固定的,完全不受单个线程内 TCP 连接数的影响。这其实很简单,因为Buffer::readFd()以线程为单位进行调用,函数调用完毕后,栈内空间自动被释放。这样就很好的解决了“缓冲区大小设置”这个问题了。

实际上,最简单的::read()::write()在 Linux 内核中也是作为向量 IO 进行实现,只不过它们的缓冲段个数为 1 罢了2

下面是 Buffer 的数据读入函数ssize_t Buffer::readFd(int fd, int *savedErrno)的源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
ssize_t Buffer::readFd(int fd, int* savedErrno)
{
// saved an ioctl()/FIONREAD call to tell how much to read
char extrabuf[65536];
struct iovec vec[2];
const size_t writable = writableBytes();
vec[0].iov_base = begin()+writerIndex_;
vec[0].iov_len = writable;
vec[1].iov_base = extrabuf;
vec[1].iov_len = sizeof extrabuf;
// when there is enough space in this buffer, don't read into extrabuf.
// when extrabuf is used, we read 128k-1 bytes at most.
const int iovcnt = (writable < sizeof extrabuf) ? 2 : 1;
const ssize_t n = sockets::readv(fd, vec, iovcnt);
if (n < 0)
{
*savedErrno = errno;
}
else if (implicit_cast<size_t>(n) <= writable)
{
writerIndex_ += n;
}
else
{
writerIndex_ = buffer_.size();
append(extrabuf, n - writable);
}
// if (n == writable + sizeof extrabuf)
// {
// goto line_30;
// }
return n;
}

一些其他知识点总结

  1. Nagle 算法
    Nagle 算法一句话来说就是为了在一定程度上克服小包问题。因为 IP 和 TCP 协议都会存在数据包包头,而如果只发送一两个字节的数据信息,则无效信息太多。解决的思路是先将要发送的数据缓存起来,等缓存到一定程度的时候,再把它发送出去。但这会导致一个新的问题,即write-write-read的延迟问题3。为解决这个问题,TCP 会开放一个TCP_NODEALY的接口,以禁掉 Nagle 算法。

  2. TCP keep alive
    这个接口存在的理由是为了让服务端能够去探测那些长时间未发送数据的 TCP 连接是否还处于有效状态。简而言之就是为了剔除无效资源。不过如果设置不当还是会有一些负面效果的,比如说如果超时时间设置的过短,那么就有可能会踢掉本来是正常状态的 TCP 连接4

Reference

[1] Linux 多线程服务端编程 p.205,陈硕

[2] Linux 系统编程第二版,Robert Love.

[3] TCP 中的 NO_DELAY

[4] 理解 TCP 长连接(Keepalive)