muduo网络库学习(六)缓冲区Buffer及TcpConnection的读写操作

来源:互联网 发布:全球变暖 知乎 编辑:程序博客网 时间:2024/06/07 17:10

tcp的通信过程中,内核其实为tcp维护着一个缓冲区

  • 当调用write/send时,会向内核缓冲区中写入数据,内核和tcp协议栈负责将缓冲区中的数据发送到指定<ip,port>的目标位置。
  • 当有数据到达内核的tcp缓冲区中,如果开启了对套接字可读事件的监听,那么内核会让套接字变为可读状态,从而从poll函数中返回,调用read/recv进行读操作。

但是,内核维护的tcp缓冲区通常都比较小

  • 如果调用write/send时,内核缓冲区已满,那么阻塞io将会阻塞在io函数上直到内核缓冲区有足够的空间容纳要写入的数据,非阻塞io将会返回错误,通常是EAGAIN/EWOULDBLOCK
  • 如果调用write/send时,内核缓冲区未满,但是不能容纳要写入的字节数,可用空间不足,那么只会写入能写入的那么多字节数,此时,仍然有一些数据没有发送,可是这些数据还非发送不可,就出现缓冲区已满的情况
  • 这就导致要不阻塞当前线程,要不无法正常写入数据,而如果采用判断返回值是否出错的方法,仍然是一直忙循环检测io写入状态,仍然是busy loop,仍然会阻塞当前线程

而且,io多路复用分水平触发和边缘触发两种,当内核tcp缓冲区中一直有数据时

  • 如果是水平触发,那么套接字会一直处于可读状态,io多路复用函数会一直认为这个套接字被激活,也就是说如果第一次触发后没有将tcp缓冲区中的数据全部读出,那么下次进行到poll函数时会立即返回,因为套接字一直是可读的。这会导致了busy loop问题
  • 如果是边缘触发,那么就只会触发一次,即使第一次触发没有将所有数据都读走,下次进行到poll也不会再触发套接字的可读状态,直到下次又有一批数据送至tcp缓冲区中,才会再次触发可读。所以有可能存在漏读数据的问题,万一不会再有数据到来呢,此时tcp缓冲区中仍然有数据,而应用程序却不知道

所以,设计应用层自己的缓冲区是很有必要的,也就是由应用程序来管理缓冲区问题

  • 应用层缓冲区通常很大,也可以初始很小,但可以通过动态调整改变大小(vector
  • 应用层缓冲区需要有读/写两个(缓冲区类只有一个,既可被用作读缓冲区,也可被用作写缓冲区)
  • 当用户想要调用write/send写入数据给对端,如果数据可以全部写入,那么写入就好了。如果写入了部分数据或者根本一点数据都写不进去,此时表明内核缓冲区已满,为了不阻塞当前线程,应用层写缓冲区会接管这些数据,等到内核缓冲区可以写入的时候自动帮用户写入。
  • 当有数据到达内核缓冲区,应用层的读缓冲区会自动将这些数据读到自己那里,当用户调用read/recv想要读取数据时,应用层读缓冲区将已经从内核缓冲区取出的数据返回给用户,实际上就是用户从应用层读缓冲区读取数据
  • 应用层缓冲区对用户而言是隐藏的,用户可能根本不知道有应用层缓冲区的存在,只需读/取数据,而且也不会阻塞当前线程

缓冲区Buffer的设计

muduo应用层缓冲区的设计采用std::vector数据结构,一方面内存是连续的方便管理,另一方面,vector自带的增长模式足以应对动态调整大小的任务
缓冲区Buffer的定义如下,只列出了一些重要部分

注释中写明了缓冲区的设计方法,主要就是利用两个指针readerIndexwriterIndex分别记录着缓冲区中数据的起点和终点,写入数据的时候追加到writeIndex后面,读出数据时从readerIndex开始读。在readerIndex前面预留了几个字节大小的空间,方便日后为数据追加头部信息。缓冲区在使用的过程中会动态调整readerIndexwriterIndex的位置,初始缓冲区为空,readerIndex == writerIndex
缓冲区默认大小为1KB,头部预留空间为8 bytes,如果使用过程中发现缓冲区大小不够,会增加缓冲区大小,方法见readFd函数

/// A buffer class modeled after org.jboss.netty.buffer.ChannelBuffer////// @code/// +-------------------+------------------+------------------+/// | prependable bytes |  readable bytes  |  writable bytes  |/// |                   |     (CONTENT)    |                  |/// +-------------------+------------------+------------------+/// |                   |                  |                  |/// 0      <=      readerIndex   <=   writerIndex    <=     size/// @endcode/* *         *   缓冲区的设计方法,muduo采用vector连续内存作为缓冲区,libevent则是分块内存 *      1.相比之下,采用vector连续内存更容易管理,同时利用std::vector自带的内存 *        增长方式,可以减少扩充的次数(capacity和size一般不同) *      2.记录缓冲区数据起始位置和结束位置,写入时写到已有数据的后面,读出时从 *        数据起始位置读出 *      3.起始/结束位置如上图的readerIndex/writeIndex,其中readerIndex为缓冲区 *        数据的起始索引下标,writeIndex为结束位置下标。采用下标而不是迭代器的 *        原因是删除(erase)数据时迭代器可能失效 *      4.开头部分(readerIndex以前)是预留空间,通常只有几个字节的大小,可以用来 *        写入数据的长度,解决粘包问题 *      5.读出和写入数据时会动态调整readerIndex/writeIndex,如果没有数据,二者 *        相等 */class Buffer : public muduo::copyable{ public:  static const size_t kCheapPrepend = 8;  static const size_t kInitialSize = 1024;  explicit Buffer(size_t initialSize = kInitialSize)    : buffer_(kCheapPrepend + initialSize),      readerIndex_(kCheapPrepend),      writerIndex_(kCheapPrepend)  {    assert(readableBytes() == 0);    assert(writableBytes() == initialSize);    assert(prependableBytes() == kCheapPrepend);  }  /* 可读的数据就是起始位置和结束位置中间的部分 */  size_t readableBytes() const  { return writerIndex_ - readerIndex_; }  size_t writableBytes() const  { return buffer_.size() - writerIndex_; }  size_t prependableBytes() const  { return readerIndex_; }  /* 返回数据起始位置 */  const char* peek() const  { return begin() + readerIndex_; }  /// Read data directly into buffer.  ///  /// It may implement with readv(2)  /// @return result of read(2), @c errno is saved  /* 从套接字(内核tcp缓冲区)中读取数据放到读缓冲区中 */  ssize_t readFd(int fd, int* savedErrno); private:  char* begin()  { return &*buffer_.begin(); }  const char* begin() const  { return &*buffer_.begin(); } private:  /* 缓冲区 */  std::vector<char> buffer_;  /* 数据起始点 */  size_t readerIndex_;  /* 数据结束点 */  size_t writerIndex_;  /* \r\n */  static const char kCRLF[];};

TcpConnection的读操作

Poller检测到套接字的Channel处于可读状态时,会调用Channel的回调函数,回调函数中根据不同激活原因调用不同的函数,这些函数都由TcpConnection在创建Channel之初提供,当可读时,调用TcpConnection的可读函数handleRead,而在这个函数中,读缓冲区就会从内核的tcp缓冲区读取数据
注意这个是TcpConnection的函数

/* * 1.TcpConnection构造时,创建一个监听服务器/客户端连接的fd的Channel,设置各种回调函数 * 2.TcpServer设置各种回调函数(可读等),然后调用connectEstablished,将Channel添加到Poller中 * 3.EventLoop继续监听事件,调用Poller * 4.poll返回,处理激活的Channel,调用Channel的handleEvent * 5.hanleEvent根据激活事件的类型(可读/可写/挂起/错误)调用不同的处理函数 * 6.若可读,调用hanleRead,TcpConnection中的读缓冲区将内核tcp缓冲区的数据全部读出 * 7.调用用户提供的当可读时执行的回调函数,用户可直接从应用层缓冲区读数据 */void TcpConnection::handleRead(Timestamp receiveTime){  loop_->assertInLoopThread();  int savedErrno = 0;  /* 读缓冲区从内核tcp中读取数据 */  ssize_t n = inputBuffer_.readFd(channel_->fd(), &savedErrno);  if (n > 0)  {    /* 如果成功读取数据,调用用户提供的可读时回调函数 */    messageCallback_(shared_from_this(), &inputBuffer_, receiveTime);  }  else if (n == 0)  {    /* 如果返回0,说明对端已经close连接,处理close事件,关闭tcp连接 */    handleClose();  }  else  {    /* 出错 */    errno = savedErrno;    LOG_SYSERR << "TcpConnection::handleRead";    handleError();  }}

TcpConnectionhandleRead函数中,读缓冲区读取数据,调用readFd函数,readFd函数是将数据从内核tcp缓冲区中读出,存放到自己的读缓冲区中,也是缓冲区最重要的函数,其中用到了readv(分散读)/writev(集中写)系统调用解决缓冲区大小不足的问题

/* * 从tcp缓冲区(sockfd)中读取数据,存放到应用层缓冲区中 *   两种情况 *      1.应用层缓冲区足以容纳所有数据 *        直接读取到buffer_中 *      2.应用层缓冲区不够 *        开辟一段栈空间(128k)大小,使用分散读(readv)系统调用读取数据 *        然后为buffer_开辟更大的空间,存放读到栈区的那部分数据 *         *   为什么不在Buffer构造时就开辟足够大的缓冲区 *      1.每个tcp连接都有输入/输出缓冲区,如果连接过多则内存消耗会很大 *      2.防止客户端与服务器端数据交互比较少,造成缓冲区的浪费 *      3.当缓冲区大小不足时,利用vector内存增长的优势,扩充缓冲区 *   *   为什么不在读数据之前判断一下应用层缓冲区是否可以容纳内核缓冲区的全部数据 *      1.采用这种方式就会调用一次recv,传入MSG_PEEK,即recv(sockfd,, extrabuf, sizeof(extrabuf), MSG_PEEK) *        可根据返回值判断缓冲区还有多少数据没有接收,然后再调用一次recv从内核冲读取数据 *      2.但是这样会执行两次系统调用,得不偿失,尽量使用一次系统调用就将所有数据读出,这就需要一个很大的空间 *       *   struct iovec *      1.iov_base,存放数据的缓冲区起始位置,写时往这个位置写入iov_len个字节,读时从这个位置读出iov_len个字节 *      2.iov_len,要读入多少数据从内核缓冲区/要写入多少数据到内核缓冲区 *   *   readv(int fd, const struct iovec *iov, int iovcnt);分散读 *   writev(int fd, const struct iovec *iov, int iovcnt);集中写 */ssize_t Buffer::readFd(int fd, int* savedErrno){  // saved an ioctl()/FIONREAD call to tell how much to read  /* 开辟的栈空间,128k */  char extrabuf[65536];  /* readv用到的数据结构,定义如上 */  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.  /* 如果应用层读缓冲区足够大(大于128k,初始时才1k -.-),就不需要往栈区写数据了 */  const int iovcnt = (writable < sizeof extrabuf) ? 2 : 1;  /* 分散读,返回读取的字节数 */  const ssize_t n = sockets::readv(fd, vec, iovcnt);  if (n < 0)  {    *savedErrno = errno;  }  /*    * 读取的字节数比较少,读缓冲区足以容纳   * 因为读缓冲区是readv的第一块内存,所以率先向这块内存写数据   */  else if (implicit_cast<size_t>(n) <= writable)  {    writerIndex_ += n;  }  else  {    /*      * 将栈空间的数据追加到缓冲区末尾      * 因为读缓冲区已经写满了,所以writerIndex指针就指向缓冲区的末尾     */    writerIndex_ = buffer_.size();    append(extrabuf, n - writable);  }  // if (n == writable + sizeof extrabuf)  // {  //   goto line_30;  // }  return n;}

如果读缓冲区大小不够,其他数据就会写入到栈空间,接下来需要将栈空间的数据追加到缓冲区的末尾,使用append函数

  void append(const char* /*restrict*/ data, size_t len)  {    /* 确保有足够的空间容纳len大小的数据 */    ensureWritableBytes(len);    /* 将数据copy到writerIndex后面,beginWrite返回的就是writerIndex位置的地址(writerIndex是下标) */    std::copy(data, data+len, beginWrite());    /* 写完数据,更新writerIndex */    hasWritten(len);  }

函数首先调用ensureWritableBytes函数确保读缓冲区有足够的空间,如果没有,就需要调用resize函数重新设置空间大小(std::vector的内存增长就体现在这里,因为capacitysize通常不同,所以如果resize设置的大小没有超过capacity,那么空间仍然足够,不会重新开辟内存,将数据拷贝到新内存上)

  void ensureWritableBytes(size_t len)  {    /* 返回剩余可用空间大小,如果不足len,开辟新空间(调用resize) */    if (writableBytes() < len)    {      makeSpace(len);    }    assert(writableBytes() >= len);  }

如果空间不够,就需要调整空间大小

  void makeSpace(size_t len)  {    /*      * 在多次从缓冲区读数据后,readerIndex会后移很多,导致预留空间变大     * 在增大空间之前,先判断调整预留空间的大小后能否容纳要求的数据     * 如果可以,则将预留空间缩小为8字节(默认的预留空间大小)     * 如果不可以,那么就只能增加空间     */    if (writableBytes() + prependableBytes() < len + kCheapPrepend)    {      // FIXME: move readable data      /* writerIndex代表当前缓冲区已使用的大小,调整只需调整到恰好满足len大小即可 */      buffer_.resize(writerIndex_+len);    }    else    {      /* 通过缩小预留空间大小可以容纳len个数据,就缩小预留空间 */      // move readable data to the front, make space inside buffer      assert(kCheapPrepend < readerIndex_);      /* 返回缓冲区数据个数,writerIndex - readerIndex */      size_t readable = readableBytes();      /* 将所有数据前移 */      std::copy(begin()+readerIndex_,                begin()+writerIndex_,                begin()+kCheapPrepend);      /* 更新两个指针(下标) */      readerIndex_ = kCheapPrepend;      writerIndex_ = readerIndex_ + readable;      assert(readable == readableBytes());    }  }

此时应用层读缓冲区从内核中读取数据完成,在用户可读的回调函数中(在readFd函数执行完调用),用户可以调用Buffer的接口从缓冲区中读取数据,程序示例如下
这里写图片描述
这是用户提供给TcpServer的可读时的回调函数,又由TcpServer提供给TcpConnection,当TcpConnection的读缓冲区执行完readFd返回后,会执行用户的回调函数,图片程序来自muduo的测试用例。

可以看到

  • buf->readableBytes()返回缓冲区中可读字节数
  • conn->name()返回TcpConnection的名字(由TcpServer设置)
  • receiveTimepoll函数返回的时间,一直作为参数传到ChannelTcpConnectiononMessage
  • buf->retrieveAsString()读取缓冲区所有数据
  /* 从缓冲区中读取所有数据 */  string retrieveAllAsString()  {    return retrieveAsString(readableBytes());  }  /* 从缓冲区中读取len个字节的数据 */  string retrieveAsString(size_t len)  {    assert(len <= readableBytes());    /* peek返回数据的起点 */    /* 调用string(const char* s, size_type n);构造函数,初始化为从地址s开始的n个字节 */    string result(peek(), len);    /* 调整缓冲区,即改变readerIndex的位置,后移len */    retrieve(len);    return result;  }

这两个函数从读缓冲区中读取数据,一个是全读,一个是读取指定字节个数的数据,读完之后,缓冲区需要调整readerIndex位置以指向新的数据起点

  /* 调整readerIndex,后移len */  void retrieve(size_t len)  {    assert(len <= readableBytes());    /*      * 如果调整后仍然有数据,就将readerIndex增加len     * 如果已经将数据全部读完(len >= readableBytes),那么就初始化readerIndex/writerIndex位置     */    if (len < readableBytes())    {      readerIndex_ += len;    }    else    {      retrieveAll();    }  }

如果数据全部被用户读出,就重新调整readerIndex/writerIndex位置

  /* 初始化readerIndex/writerIndex位置,通常在用户将数据全部读出之后执行 */  void retrieveAll()  {    readerIndex_ = kCheapPrepend;    writerIndex_ = kCheapPrepend;  }

TcpConnection的写操作

发送数据使用的是写缓冲区,当内核tcp缓冲区空间不足时,会把数据写到写缓冲区,由写缓冲区在合适的时机写入内核tcp缓冲区,合适的时机指内核tcp缓冲区有多余空间时。
但是怎样才能直到内核tcp缓冲区有多余的空间呢,通过监听可写事件即可。
但是如果内核tcp缓冲区一直不满,那么就一直可写,就会一直触发poll,导致busy loop,所以muduo只有在需要的时候才会检测内核tcp缓冲区的可写事件,即只有当tcp缓冲区已满,但是写缓冲区中有数据等待写入tcp缓冲区时才会监听。

不同于读取数据的是,发送数据使用的是TcpConnection提供的接口,而不是直接向Buffer中写。

/* 几个重载的send函数,用于用户想要发送数据到对端 */void TcpConnection::send(const void* data, int len){  send(StringPiece(static_cast<const char*>(data), len));}void TcpConnection::send(const StringPiece& message){  if (state_ == kConnected)  {    /*      * 如果当前线程和TcpConnection所属线程相同,直接在当前线程发送     * 否则,需要使用std::bind绑定函数和对象,并添加到自己所在线程的事件循环中     */    if (loop_->isInLoopThread())    {      sendInLoop(message);    }    else    {      /* 可以直接在bind中绑定函数 ? */      void (TcpConnection::*fp)(const StringPiece& message) = &TcpConnection::sendInLoop;      loop_->runInLoop(          std::bind(fp,                    this,     // FIXME                    message.as_string()));                    //std::forward<string>(message)));    }  }}

send函数调用sendInLoop函数,保证在TcpConnection所属线程发送数据

  • 发送时会先判断写缓冲区是否已经有数据存在,如果有,就不能直接向tcp缓冲区写了,因为数据要有顺序的发送,所以需要追加到写缓冲区中
  • 如果写缓冲区中没有数据,就可以尝试向tcp缓冲区写数据,如果全部写入,当然很happy,但是如果只写入一部分或者一点也没写进去(tcp缓冲区已满),就需要添加到写缓冲区中,同时开启对tcp缓冲区(其实就是用于通信的套接字)的可写事件的监听,等待tcp缓冲区可写
void TcpConnection::sendInLoop(const StringPiece& message){  sendInLoop(message.data(), message.size());}/*  * 写入数据 * 1.如果Channel没有监听可写事件且输出缓冲区为空,说明之前没有出现内核缓冲区满的情况,直接写进内核 * 2.如果写入内核出错,且出错信息(errno)是EWOULDBLOCK,说明内核缓冲区满,将剩余部分添加到应用层输出缓冲区 * 3.如果之前输出缓冲区为空,那么就没有监听内核缓冲区(fd)可写事件,开始监听 */void TcpConnection::sendInLoop(const void* data, size_t len){  loop_->assertInLoopThread();  /* 写入tcp缓冲区的字节数 */  ssize_t nwrote = 0;  /* 没有写入tcp缓冲区的字节数 */  size_t remaining = len;  /* 调用write时是否出错 */  bool faultError = false;  /* 当前TcpConnection状态,TcpConnection有四种状态,kDisconnected表示已经断开连接,不能再写了,直接返回 */  if (state_ == kDisconnected)  {    LOG_WARN << "disconnected, give up writing";    return;  }  // if no thing in output queue, try writing directly  /* 如果输出缓冲区有数据,就不能尝试发送数据了,否则数据会乱,应该直接写到缓冲区中 */  if (!channel_->isWriting() && outputBuffer_.readableBytes() == 0)  {    /* 读取函数 */    nwrote = sockets::write(channel_->fd(), data, len);    if (nwrote >= 0)    {      /* 写入了一些数据 */      remaining = len - nwrote;      /*        * 完全写入tcp缓冲区,且用户有提供写数据的回调函数,等待执行完后调用       * 因为当前TcpConnection和EventLoop所在同一个线程,       * 而且此时EventLoop通常处在正在处理激活Channel的过程中(当前函数有可能也是在这个过程)       * 所以等待这个函数执行完再调用回调函数       */      if (remaining == 0 && writeCompleteCallback_)      {        loop_->queueInLoop(std::bind(writeCompleteCallback_, shared_from_this()));      }    }    else // nwrote < 0    {      /* 一点也没写进去       * 如果错误为EWOULDBLOCK,表明tcp缓冲区已满       */      nwrote = 0;      if (errno != EWOULDBLOCK)      {        /* EPIPE表示客户端已经关闭了连接,服务器仍然尝试写入,就会出现EPIPE */        LOG_SYSERR << "TcpConnection::sendInLoop";        if (errno == EPIPE || errno == ECONNRESET) // FIXME: any others?        {          faultError = true;        }      }    }  }  assert(remaining <= len);  /* 没出错,且仍有一些数据没有写到tcp缓冲区中,那么就添加到写缓冲区中 */  if (!faultError && remaining > 0)  {    /* 获取写缓冲区数据总量 */    size_t oldLen = outputBuffer_.readableBytes();    /* 到达高水位,调用回调函数,这个函数没有设置? */    if (oldLen + remaining >= highWaterMark_        && oldLen < highWaterMark_        && highWaterMarkCallback_)    {      loop_->queueInLoop(std::bind(highWaterMarkCallback_, shared_from_this(), oldLen + remaining));    }    /* 把没有写完的数据追加到输出缓冲区中,然后开启对可写事件的监听(如果之前没开的话) */    outputBuffer_.append(static_cast<const char*>(data)+nwrote, remaining);    if (!channel_->isWriting())    {      channel_->enableWriting();    }  }}

如果tcp缓冲区不足以全部容纳数据,就会开启对可写事件的监听,当tcp缓冲区可写,就调用Channel的回调函数,这个回调函数也是在TcpConnection构造函数中传给Channel

  channel_->setWriteCallback(      std::bind(&TcpConnection::handleWrite, this));
/* 当tcp缓冲区可写时调用 */void TcpConnection::handleWrite(){  loop_->assertInLoopThread();  if (channel_->isWriting())  {    /* 尝试写入写缓冲区的所有数据,返回实际写入的字节数(tcp缓冲区很有可能仍然不能容纳所有数据) */    ssize_t n = sockets::write(channel_->fd(),                               outputBuffer_.peek(),                               outputBuffer_.readableBytes());    if (n > 0)    {      /* 调整写缓冲区的readerIndex */      outputBuffer_.retrieve(n);      if (outputBuffer_.readableBytes() == 0)      {        /* 全部写到tcp缓冲区中,关闭对可写事件的监听 */        channel_->disableWriting();        /* 如果有写入完成时的回调函数(用户提供,则等待函数结束后调用 */        if (writeCompleteCallback_)        {          loop_->queueInLoop(std::bind(writeCompleteCallback_, shared_from_this()));        }        /*          * 如果连接正在关闭(通常关闭读端),那么关闭写端,但是是在已经写完的前提下         * 如果还有数据没有写完,不能关闭,要在写完再关          */        if (state_ == kDisconnecting)        {          shutdownInLoop();        }      }    }    else    {      LOG_SYSERR << "TcpConnection::handleWrite";      // if (state_ == kDisconnecting)      // {      //   shutdownInLoop();      // }    }  }  else  {    LOG_TRACE << "Connection fd = " << channel_->fd()              << " is down, no more writing";  }}

这里的细节问题就是如果想要关闭连接,那么通常是先关闭读端,等到将写缓冲区所有数据都写到tcp缓冲区后,再关闭写端,否则这些数据就不能发送给对端了
muduo没有提供close函数,关闭是分两步进行的(使用shutdown而不适用close),这样更容易控制
handleWrite函数中调用的shutdownInLoop函数如下,用于关闭写端

void TcpConnection::shutdownInLoop(){  loop_->assertInLoopThread();  if (!channel_->isWriting())  {    // we are not writing    socket_->shutdownWrite();  }}

至此发送数据的操作完成,所以数据都在tcp缓冲区中等待着或正在运往对端(客户端)

阅读全文
0 0
原创粉丝点击