muduo net库学习笔记6——缓冲区Buffer及TcpConnection的读写操作

感谢并转载自https://blog.csdn.net/sinat_35261315/article/details/78357586
必须得多看几遍==
在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缓冲区中仍然有数据,而应用程序却不知道

ps:在这里插入图片描述

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

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

缓冲区Buffer的设计

muduo应用层缓冲区的设计采用std::vector数据结构,一方面内存是连续的方便管理,另一方面,vector自带的增长模式足以应对动态调整大小的任务

Buffer.h的注释中写明了缓冲区的设计方法:
在这里插入图片描述
注释中写明了缓冲区的设计方法,主要就是利用两个指针readerIndexwriterIndex分别记录着缓冲区中数据的起点和终点,写入数据的时候追加到writeIndex后面,读出数据时从readerIndex开始读。在readerIndex前面预留了几个字节大小的空间,方便日后为数据追加头部信息。缓冲区在使用的过程中会动态调整readerIndexwriterIndex的位置,初始缓冲区为空,readerIndex == writerIndex
注意:readerIndexwriterIndex都是下标,而不是迭代器,因为删除使用的erase,迭代器可能失效

缓冲区默认大小为1KB,头部预留空间为8 bytes,如果使用过程中发现缓冲区大小不够,会增加缓冲区大小,方法见readFd函数, 在Buffer.cc


TcpConnection的读操作TcpConnection::handleRead

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

这个函数是TcpConnection的函数

再再再来顺一遍

  1. TcpConnection构造的时候,创建一个监听服务器/客户端连接的fd的Channel,设置各种回调函数√
  2. TcpServer设置各种回调函数, 然后调用connectEstablished,将Channel添加到Poller中√
  3. EventLoop继续监听事件,调用Poller
  4. poll返回,处理激活的Channel,调用ChannelhandleEvent
  5. handleEvent根据激活事件的类型(可读/可写/挂起/错误)调用不同的处理函数√
  6. 若可读,调用handReadTcpConnection中的读缓冲区将内核tcp缓冲区的数据全部读出
  7. 调用用户提供的可读时执行的回调函数√
void TcpConnection::handleRead(Timestamp receiveTime)
{
  loop_->assertInLoopThread();
  int savedErrno = 0;
  ssize_t n = inputBuffer_.readFd(channel_->fd(), &savedErrno);//读缓冲区从内核tcp中读取数据
  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(集中写)系统调用解决缓冲区大小不足的问题

⭐⭐⭐note:

从tcp缓冲区(sockfd)中读取数据,存放到应用层缓冲区中的两种情况:
1.应用层缓冲区足以容纳所有数据
直接读取到buffer_中
2.应用层缓冲区不够
开辟一段栈空间(128k)大小,使用分散读(readv)系统调用读取数据
然后为buffer_开辟更大的空间,存放读到栈区的那部分数据

⭐⭐⭐Q1:为什么不再Vuffer构造时就开辟足够大的缓冲区

1,每个tcp连接都有输入/输出缓冲区, 如果连接过大则内存消耗会很大
2,防止客户端与服务器数据交互比较少,造成缓冲区的浪费
3,当缓冲区大小不足时,利用vector内存增长的优势,扩展缓冲区

⭐⭐⭐Q2:为什么不在都数据之前判断一下应用层缓冲区是否可以容纳内核缓冲区的全部数据

1,采用这种方式就会调用一次recv,传入MSG_PEEK,即recv(sockfd, extrabuf, sizeof(extrabuf), MSG_PEEK), 根据返回值判断缓冲区还有多少数据没有接收,然后再调用一次recv从内核冲读取数据
2,但是这样会执行两次系统调用,得不偿失,尽量使用一次系统调用就将所有数据读出,这就需要一个很大的空间

⭐⭐⭐Q3:struct iovec

1, iov_base,存放数据的缓冲区起始位置,写时往这个位置写入iov_len个字节,读时从这个位置读出iov_len个字节
2,iov_len,从内核缓冲区要读入多少数据/要写入多少数据到内核缓冲区

readFd函数👇


ssize_t Buffer::readFd(int fd, int* savedErrno)
{
  // saved an ioctl()/FIONREAD call to tell how much to read
  char extrabuf[65536];//开辟的栈空间128k
  struct iovec vec[2];//readv用到的数据结构,定义见👆
  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;
  //如果应用层读缓冲区足够大(大于128k,初始时才1k -.-),就不需要往栈区写数据了 
  
  const ssize_t n = sockets::readv(fd, vec, iovcnt);//分散读,返回读取的字节数 
  if (n < 0)
  {
    *savedErrno = errno;
  }
  else if (implicit_cast<size_t>(n) <= writable)
  {//取的字节数比较少,读缓冲区足以容纳,因为读缓冲区是readv的第一块内存,所以率先向这块内存写数据
    writerIndex_ += n;
  }
  else
  {//将栈空间的数据追加到缓冲区末尾,
  //因为读缓冲区已经写满了,所以writerIndex指针就指向缓冲区的末尾
    writerIndex_ = buffer_.size();
    append(extrabuf, n - writable);//用append了
  }
  // 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);
  }

Buffer中vector内存增长的体现

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

如果空间不够,在这里插入图片描述

就需要调整空间大小makespace

  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的接口从缓冲区中读取数据


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));

void TcpConnection::handleWrite()
{
  loop_->assertInLoopThread();
  if (channel_->isWriting())
  {
    ssize_t n = sockets::write(channel_->fd(),
                               outputBuffer_.peek(),
                               outputBuffer_.readableBytes());//往缓冲区写数据,返回实际写入的字节数
                               //tcp缓冲区又可能不能容纳所有数据
    if (n > 0)
    {//要调整写缓冲区的readerIndex
      outputBuffer_.retrieve(n);
      if (outputBuffer_.readableBytes() == 0)
      {
        channel_->disableWriting();/* 全部写到tcp缓冲区中,关闭对可写事件的监听 */
        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缓冲区中等待着或正在运往对端(客户端)

感谢https://blog.csdn.net/sinat_35261315/article/details/78357586

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值