本章节我们来解析Buffer类(应用层缓冲区)的设计以及TcpConnection接收和发送数据。
我们首先来回顾一下muduo的IO模型:one loop per thread + IO multiplexing
event loop是non-blocking网络编程的核心,而non-blocking几乎总是和IO multiplexing一起使用:
(1)没有人真的会使用轮询来检查某个non-blocking IO操作是否以及完成,浪费CPU
(2)IO复用一般不能和blocking IO用在一起,因为blocking IO中read/write/accept/connect都有可能阻塞当前线程,这样线程就没法处理其他socket上的事件
non-blocking编程有许多难点:
(1)如果主动关闭连接,如何保证对方已经收到全部数据?
(2)如果应用层有缓冲,如何保证先发送完缓冲区的数据,再断开连接?
(3)该用边沿触发还是条件触发,如果是条件触发,我们什么时候关注POLLOUT事件?会不会造成busy-loop?如果是边沿触发,如何防止漏读造成的饥饿?
(4)在非阻塞网络编程中,为什么要使用应用层发送缓冲区?为什么要使用应用层接收缓冲区?
(5)在非阻塞网络编程中,如何设计并使用缓冲区?一方面,我们希望减少系统调用,一次读的数据越多越划算,那么似乎需要准备一个大的缓冲区。另一方面,我们希望减少内存占用。如果有10K个连接,每个连接一建立就分配50KB的读写缓冲区,将占用1G内存,而大多数这些缓冲区的使用率很低,这个问题如何解决?
(6)如何使用发送缓冲区,万一接收方处理缓慢,数据会不会一直堆积在发送方,造成内存暴涨?如何做应用层的流量控制?
本文围绕以上问题,对muduo进行分析。
non-blocking的核心思想是避免阻塞在IO系统调用上,这样可以最大限度的复用thread-of-control,让一个线程服务于多个socket连接,因此IO线程只能阻塞在IO multiplexing上。在这种情况下,我们来分析一下难点4:
(1)TcpConnection必须要有发送缓冲区。程序想通过TCP连接发送100KB的数据,但是在write调用中,系统只接受了80KB,你肯定不想原地等待(阻塞),因为你不知道会等多久。这种情况下,剩下的20KB怎么办?对于应用程序来说,它只管生成数据,它不应该关心数据到底是一次性发送还是分多次发送。所以网络库应该接管剩余的20KB数据,保存在发送缓冲区,然后注册POLLOUT事件,一旦socket变得可写,就立刻发送数据,当然,如果写不完,则继续这一过程。如果写完了,则停止关注POLLOUT,因为muduo是条件触发,会造成busy-loop。如果程序又写入了50KB,而这时候发送缓冲区里还有待发送的20KB数据,那么网络库不应该直接调用write,而应该把这50KB的数据append在那20KB后面。如果发送缓冲区里有待发送的数据,而程序又想主动关闭连接(对于程序而言,调用send函数后,就认为数据肯定会发送出去),那么这时候不能直接关闭连接,需要等待数据发送完毕。
(ps:解释一下为什么muduo采用条件触发,难点3,一是为了与传统的poll兼容,因为在文件描述符较少,活动文件描述符比例较高的时候,epoll不见得比poll更高效。二是条件触发编程更容易,不可能发生漏掉事件的bug。三是读写的时候不必等候出现EAGAIN,可以节省系统调用的次数)
(2)TcpConnection必须要有输入缓冲区。TCP是一个无边界的字节流协议,接收方必须要处理“收到的数据尚不构成一条完整的消息”和“一次收到两条消息的数据”等情况。网络库在处理socket可读事件的时候,必须一次性把socket里的数据读完(即从操作系统buffer搬到应用层buffer),否则会反复触发POLLIN事件,造成busy-loop。那么网络库必然要应对数据不完整的情况,等构成一条完整的消息再通知程序的业务逻辑。
下面我们来看一下buffer类的设计,对外表现为一块连续的内存,size可以自动增长,因此采用vector来管理,有三个数据成员:
std::vector buffer_;
size_t readerIndex_;
size_t writerIndex_;
readable = writerIndex_ - readerIndex. writable = size() - writerIndex.另外还有一个prependable = readerIndex.并且数据类型为int,而不是迭代器(or 指针),是为了防止vector扩充导致迭代器失效。关于buffer类的内存管理比较平常,这里不做赘述,主要介绍一个核心函数readFd(int)。
ssize_t Buffer::readFd(int fd, int* savedErrno)
{
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;
const ssize_t n = readv(fd, vec, 2);
if (n < 0) {
*savedErrno = errno;
} else if (static_cast(n) <= writable) {
writerIndex_ += n;
} else {
writerIndex_ = buffer_.size();
append(extrabuf, n - writable);
}
return n;
}
这个函数有几点值得一提,一是使用了向量IO:scatter/gather IO,即分散读和聚集写,并且一部分缓冲区使用buffer,另一部分缓冲区来自stack,这样缓冲区足够大,通常一次readv系统调用即可取完全部数据。二是在stack上准备了65536字节的extrabuf,然后利用readv来读取数据,这么做利用的stack上的空间,避免每个连接的初始buffer过大而造成内存浪费,也避免了反复调用read的系统开销(因为缓冲区足够大)。由于采用条件触发,不会反复调用read直到返回EAGAIN,
难点5。
当第一块缓冲区(buffer)足够,则直接使用即可,否则,将extrabuf缓冲区里的数据append到buffer里,由于buffer内部是采用vector来实现,可以实现自动扩充。
关于数据的发送,则是在TcpConnection中进行设计:
//发送数据
void TcpConnection::sendInLoop(const std::string& message)
{
loop_->assertInLoopThread();
ssize_t nwrote = 0;
// if no thing in output queue, try writing directly
if (!channel_->isWriting() && outputBuffer_.readableBytes() == 0) {
nwrote = ::write(channel_->fd(), message.data(), message.size());
if (nwrote >= 0) {
if (static_cast(nwrote) < message.size()) {
std::cout << "I am going to write more data\n";
}else
{
std::cout<<"i have writed all the data in one times\n";
}
} else {
nwrote = 0;
if (errno != EWOULDBLOCK) {
std::cout << "TcpConnection::sendInLoop\n";
abort();
}
}
}
assert(nwrote >= 0);
if (static_cast(nwrote) < message.size()) {
std::cout<<"TcpConnection::sendInLoop() enableWrite()\n";
outputBuffer_.append(message.data()+nwrote, message.size()-nwrote);
if (!channel_->isWriting()) {
channel_->enableWriting();
}
}
}
sendInLoop会先尝试直接发送数据,如果一次发送完毕就不会启用WriteCallback,如果只发送了部分数据,则把剩余的数据放入outputBuffer_,并开始关注writable事件
void TcpConnection::handleWrite()
{
loop_->assertInLoopThread();
if (channel_->isWriting()) {
ssize_t n = ::write(channel_->fd(),
outputBuffer_.peek(),
outputBuffer_.readableBytes());
if (n > 0) {
outputBuffer_.retrieve(n);
if (outputBuffer_.readableBytes() == 0) {
channel_->disableWriting();
if (state_ == kDisconnecting) {
shutdownInLoop();
}
} else {
std::cout << "I am going to write more data\n";
}
} else {
std::cout << "TcpConnection::handleWrite\n";
abort();
}
} else {
std::cout << "Connection is down, no more writing\n";
}
}
当socket变得可写时,channel会调用handleWrite继续发送剩下的数据。一旦发送完毕,立刻停止关注POLLOUT事件,另外如果正在执行关闭事件,则调用shutdownInLoop,继续执行关闭过程。
void TcpConnection::shutdownInLoop()
{
loop_->assertInLoopThread();
if (!channel_->isWriting())
{
// we are not writing
socket_->shutdownWrite();
}
}
因为在调用sendInLoop函数的时候,如果有剩余数据未发送,则会将相关channel的写状态置为true,所以如果这时候执行shutdownInLoop,则不会真正关闭,而是将TcpConnection的状态置为kDisconnecting,这样在handleWrite函数中,如果发送缓冲区中的数据以及发送完毕,则会调用shutdownInLoop,继续执行关闭。对应于难点1和2。
关于难点6,muduo采用高水位回调和低水位回调来解决:WriteCompleteCallback和HighWaterMarkCallback。当缓冲区被清空的时候,调用WriteCompleteCallback,当缓冲区达到一定限度的时候,调用HighWaterMarkCallback停止读取。
最后,我们来分析一下muduo的关于TCP长连接中粘包分包问题的解决方法。
前面我们在讨论muduo buffer类的数据结构的时候,有一个prependable,预留了8个字节的空间,使得在头部添加信息的时候,不至于移动整个vector,因此,在打包的时候,可以将长度信息(4个字节)放入prependable中,再移动readerIndex,在分包的时候,将头部4个字节的内容,转换为int*型,进行解引用操作,然后通过networkToHost32转换为主机字节序,便是长度。如果已经读取的数据长度小于len+kHeaderLen,则不予处理,直到构成一条完整的数据的时候,才进行处理,调用messageCallback.