文章目录
一、muduo的IO模型
event loop 是 non-blocking 网络编程的核心,在现实生活中,non-blocking 几乎总是和 IO multiplexing 一起使用,原因有两点:
- 没有人真的会编写循环轮询 (busy-pooling) 来检查某个 non-blocking IO 操作是否完成,这样太浪费 CPU资源了
- IO-multiplex 一般不能和 blocking IO 用在一起,因为 blocking IO read()/write()/accept()/connect() 都有可能阻塞当前线程,这样线程就没办法处理其他 socket上的 IO 事件了
二、为什么non-blocking网络编程中应用层buffer是必须的
non-blocking IO的核心思想是避免阻塞在read()或write()或者其他IO系统调用上,这样可以最大限度地复用thread-of-control,让一个线程能服务于多个socket连接,IO线程只能阻塞在IO multiplexing函数上,如select/poll/epoll_wait
这样一来,应用层的缓冲是必须的,每个TCP socket都要有stateful的input buffer和output buffer
1. output buffer的必要性
考虑一个场景:程序想通过TCP连接发送100KB的数据,但是在write()调用中,操作系统只接受了80KB(受TCP advertised window的控制),此时你肯定不想原地等待,因为不知道会等多久(取决于对方什么时候接收数据,然后滑动TCP窗口)。程序不应该阻塞,而应该返回event loop。在这种情况下,剩余的20KB数据怎么办?
对于应用程序而言,它只管生成数据,它不关心到底数据是一次性发送还是分成几次发送,这些应该由网络库来操心,程序只要调用TcpConnection::send()就行了,具体如何发送,由网络库负责。网络库应该接管这剩余的20kB数据,把它保存在该TcpConnection的output buffer里,然后注册POLLOUT事件,一旦socket变得可写就立刻调用write系统调用发送数据。当然,这第二次write()也不一定能完全写人20kB,如果还有剩余,网络库应该继续关注POLLOUT事件;如果写完了20kB,网络库应该停止关注POLLOUT,以免造成busy loop。
如果应用程序此时又写入了50kB数据,而这时候output buffer里还有待发送的20kB数据,那么网络库不应该直接调用write(),而应该把这50kB数据append在那20kB数据之后,等socket变得可写的时候再一并写人。
如果outputbuffer里还有待发送的数据,而程序又想关闭连接(对应用程序而言,调用TcpConnection::send()之后他就认为数据迟早会发出去),那么这时候网络库不能立刻关闭连接,而要等数据发送完毕。
2. input buffer的必要性
TCP是一个无边界的字节流协议,接收方必须要处理 “收到的数据尚不构成一条完整的消息” 和 “一次收到两条消息的数据” 等情况。一个常见的场景是,发送方send()了两条1kB的消息(共2kB),接收方收到数据的情况可能是:
- 一次性收到2kB数据;
- 分两次收到,第一次600B, 第二次1400B;
- 分两次收到,第一次1400B, 第二次600B;
- 分两次收到,第一次1kB,第二次1kB;
- 分三次收到,第一次600B, 第二次800B,第三次600B;
- 其他任何可能。一般而言,长度为n字节的消息分块到达的可能性有 2 n − 1 2^{n-1} 2n−1种
网络库在处理 “socket 可读” 事件的时候,在LT模式下,必须一次性把socket对应的内核TCP读缓冲区里的数据读完(从操作系统buffer搬到应用层buffer),否则会反复触发POLLIN事件,造成busy-loop。那么网络库必然要应对“数据不完整”的情况,收到的数据先放到input buffer里,等构成一条完整的消息,再通知程序的业务逻辑
三、muduo库如何处理粘包问题
粘包问题的最本质原因在与接收对等方无法分辨消息与消息之间的边界在哪。我们通过使用某种方案给出边界,例如:
- 发送定长包。如果每个消息的大小都是一样的,那么在接收对等方只要累计接收数据,直到数据等于一个定长的数值就将它作为一个消息。
- 包尾加上\r\n标记。FTP协议正是这么做的。但问题在于如果数据正文中也含有\r\n,则会误判为消息的边界。
- 包头加上包体长度。包头是定长的4个字节,说明了包体的长度。接收对等方先接收包体长度,依据包体长度来接收包体。
- 使用更加复杂的应用层协议。
muduo库采用的是第三种方案,包头存放包体长度
网络层的muduo库只负责收发数据,粘包解决的属于业务层,代码由muduo的用户手动编写,使用muduo收发数据双方需要约定好,收发的数据的前8字节表示字符流的长度,这样处理粘包问题
四、大量并发连接的场景下,如何避免缓冲区占用大量的内存空间
在非阻塞网络编程中,如何设计并使用缓冲区?
在LT模式下,数据没读完,我们需要频繁调用read,一方面我们希望减少系统调用,一次读的数据越多越划算,那么似乎应该准备一个大的缓冲区;另一方面希望减少内存占用。
如果有10000个并发连接,每个连接一建立就分配各 50kB的读写缓冲区的话,将占用1GB内存,而大多数时候这些缓冲区的使用率很低,muduo用readv(2)结合栈上空间巧妙地解决了这个问题。
具体做法是,在栈上准备一个65536字节的extrabuf,然后利用readv来读取数据,iovec有两块空间,第一块指向muduo Buffer中的writable字节,另一块指向栈上的extrabuf。这样如果读入的数据不多,那么全部都读到堆区的Buffer中去了;如果长度超过Buffer的writable字节数,就会读到栈上的extrabuf里,然后程序再把extrabuf 里的数据 append()到 Buffer 中
struct iovec {
void *iov_base; /* Starting address */
size_t iov_len; /* Number of bytes to transfer */
};
// 从TCP缓冲区搬数据到Buffer对象buffer_
ssize_t Buffer::readFd(int fd, int* saveErrno){
char extrabuff[65536] = {0}; // 64K栈空间,会随着函数栈帧回退,内存自动回收
struct iovec vec[2];
const size_t writable = writableBytes(); // Buffer底层剩余的可写空间
vec[0].iov_base = begin() + writerIndex_; // 第一块缓冲区
vec[0].iov_len = writable; // iov_base缓冲区可写的大小
vec[1].iov_base = extrabuff; // 第二块缓冲区
vec[1].iov_len = sizeof(extrabuff);
// 如果Buffer有65536字节的空闲空间,就不使用栈上的缓冲区,如果不够65536字节,就使用栈上的缓冲区,即readv一次最多读取65536字节数据
const int iovcnt = (writable < sizeof(extrabuff)) ? 2 : 1;
const ssize_t n = ::readv(fd, vec, iovcnt);
if(n < 0){
*saveErrno = errno;
}else if(n <= writable){
// 读取的数据n小于Buffer底层的可写空间,readv会直接把数据存放在begin() + writerIndex_
writerIndex_ += n;
}else{
// Buffer底层的可写空间不够存放n字节数据,extrabuff有部分数据(n - writable)
// 从缓冲区末尾再开始写
writerIndex_ = buffer_.size();
// 从extrabuff里读取 n - writable 字节的数据存入Buffer底层的缓冲区,会扩容
append(extrabuff, n - writable);
}
return n;
}
这么做利用了临时栈上空间避免每个连接的初始Buffer过大造成的内存浪费,也避免反复调用read()的系统开销(由于缓冲区足够大,通常一次readv()系统调用就能读完全部数据)。由于muduo的事件触发采用LT,因此这个函数并不会反复调用read()直到其返回EAGAIN,从而可以降低消息处理的延迟