muduo网络库源码解析 六

来源:互联网 发布:java通过url调用接口 编辑:程序博客网 时间:2024/06/04 20:06

本章节我们来解析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<char> 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<size_t>(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<size_t>(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<size_t>(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事件
,以后会在handleWrite里发送剩余数据。如果当前outputBuffer_已经有待发送的数据,就不能先尝试发送了。

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.



原创粉丝点击