[muduo网络库]——muduo库Buffer类

  接着之前我们的[muduo网络库]——muduo库Socket类,Acceptor类,Connector类,下是muduo的简化类图,接下来看muduo库中的Buffer类,Buffer是TcpConnection的成员
  所以这里先对TcpConnection有一个大概了解,TcpConnection是整个网络库的核心, 封装一次TCP连接,以及控制该TCP连接的方法(连接建立,关闭和销毁),以及该连接发生个各种事件(读、写、错误、连接)对应的处理函数,以及这个Tcp连接的服务端和客户端的套接字地址信息,但是注意它不能发起连接
在这里插入图片描述

0 前提

以下内容来源《Linux多线程服务端编程-使用muduo C++网络库》陈硕

0.1 Unix/Linux上五种IO模型

 Unix/Linux上五种IO模型:阻塞(blocking),非阻塞(non-blocking),IO复用(IO multiplexing),信号驱动(sign-driven),异步(asynchronous)。muduo库采用non-blocking+IO multiplexing+one loop per thread模式

event loop是non-blocking网络编程的核心, 在现实生活中, non-blocking几乎总是和IO multiplexing一起使用, 原因有两点:

  • 没有人真的会用轮询(busy-pooling) 来检查某个non-blocking IO 操作是否完成, 这样太浪费CPU cycles。
  • IO multiplexing一般不能和blocking IO用在一起, 因为blocking IO中read()/write()/accept()/ connect()都有可能阻塞当前线程, 这样线程就没办法处理其他socket上的IO事件了

non-blocking模式

  • 非阻塞 read:如果 read 操作没有数据可读,非阻塞模式下会立即返回 -1,并设置 errno 为 EAGAIN 或 EWOULDBLOCK,而不是阻塞等待数据到来。
  • 非阻塞 write:如果写缓冲区满了,非阻塞模式下 write 会立即返回 -1,并设置 errno 为 EAGAIN 或 EWOULDBLOCK,而不是等待缓冲区腾出空间。

0.2 为什么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。
  TcpConnection 必须要有output buffer :Tcp是面向传输的可靠的传输层通信协议,考虑一个常见的场景:程序想通过TCP连接发送100KB的数据,但是write()调用中,操作系统只接受了80KB,这时你肯定不想在原地等待, 因为不知道会等多久(取决于对方什么时候接受数据,然后滑动TCP窗口)。 程序应该尽快交出控制权, 返回event loop。 在这种情况下, 剩余的20kB数据怎么办?
  对于应用程序而言, 它只管生成数据, 它不应该关心到底数据是一次性发送还是分成几次发送, 这些应该由网络库来操心, 程序只要调用TcpConnection::send()就行了, 网络库会负责到底。 网络库应该接管这剩余的20kB数据, 把它保存在该TCP connectionoutput buffer里, 然后注册POLLOUT事件, 一旦socket变得可写就立刻发送数据。 当然, 这第二次write()也不一定能完全写入20kB, 如果还有剩余, 网络库应该继续关注POLLOUT事件; 如果写完了20kB, 网络库应该停止关注POLLOUT, 以免造成busy loop。
  如果程序又写入了50kB, 而这时候output buffer里还有待发送的20kB数据, 那么网络库不应该直接调用write(), 而应该把这50kB数据append在那20kB数据之后, 等socket变得可写的时候再一并写入。
  如果output buffer里还有待发送的数据, 而程序又想关闭连接(对程序而言, 调用TcpConnection::send()之后他就认为数据迟早会发出去) , 那么这时候网络库不能立刻关闭连接, 而要等数据发送完毕。
  综上, 要让程序在write操作上不阻塞, 网络库必须要给每个TCPconnection配置output buffer。
  TcpConnection必须要有input buffer: TCP是一个无边界的字节流协议, 接收方必须要处理“收到的数据尚不构成一条完整的消息”和“一次收到两条消息的数据”等情况。 一个常见的场景是, 发送方send()了两条1kB的消息(共2kB) , 接收方收到数据的情况可能是:

  • 一次性收到2kB数据;
  • 分两次收到, 第一次600B, 第二次1400B;
  • 分两次收到, 第一次1400B, 第二次600B;
  • 分两次收到, 第一次1kB, 第二次1kB;
  • 分三次收到, 第一次600B, 第二次800B, 第三次600B;
  • 其他任何可能。 一般而言, 长度为n字节的消息分块到达的可能性有2n-1种。

  网络库在处理“socket可读”事件的时候, 必须一次性把socket里的数据读完(从操作系统buffer搬到应用层buffer) , 否则会反复触发POLLIN事件, 造成busy-loop。 那么网络库必然要应对“数据不完整”的情况, 收到的数据先放到input buffer里, 等构成一条完整的消息再通知程序的业务逻辑。 这是编解码器codec的职责。
  所以, 在TCP网络编程中, 网络库必须要给每个TCP connection配置input buffer。
  muduo EventLoop采用的是epoll level trigger, 而不是edge trigger。 一是为了与传统的poll兼容, 因为在文件描述符数目较少,活动文件描述符比例较高时, epoll不见得比poll更高效, 必要时可以在进程启动时切换Poller。 二是level trigger编程更容易, 以往select/poll的经验都可以继续用, 不可能发生漏掉事件的bug。 三是读写的时候不必等候出现EAGAIN, 可以节省系统调用次数, 降低延迟。
  所有muduo中的IO都是带缓冲的IO(buffered IO)。

0.3 设计思想

先来谈谈Buffer类整体的设计思想。因为这个编程思想,今后在各个需要缓冲区的项目编程中都可以用到。
muduo的Buffer的定义如下,其内部是 一个 std::vector,且还存在两个size_t类型的readerIndex_writerIndex_标识来表示读写的位置。结构图如下:
在这里插入图片描述

两个index把vector的内容分为三块: prependable、 readable、writable, 各块的大小见式7-1。 灰色部分是Buffer的有效载荷(payload) ,各块大小关系:

  • prependable = readIndex
  • readable = writeIndex - readIndex
  • writable = buffer.size() - writeIndex

一 重要成员变量

  • static const size_t kCheapPrepend = 8://缓冲区prependable的初始大小
  • static const size_t kInitialSize = 1024://writable的初始大小,readable的初始大小为0
  • std::vector<char> buffer_:缓冲区
  • size_t readerIndex_:读索引
  • size_t writerIndex_:写索引
    在这里插入图片描述

二 重要成员函数

  • 通过构造函数,是两个标志位都指向kCheapPrepend
  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_; }
  • peek()函数,求出缓冲区可读数据的起始位置
  const char* peek() const { return begin() + readerIndex_; }
  • retrieve(len)函数,取出len的长度数据,即缩短len长度,若超出总长度,直接重新初始化
 void retrieve(size_t len)
  {
    assert(len <= readableBytes());
    if (len < readableBytes())
    {
      readerIndex_ += len;
    }
    else
    {
      retrieveAll();
    }
  }
    void retrieveAll()
  {
    readerIndex_ = kCheapPrepend;
    writerIndex_ = kCheapPrepend;
  }
  • retrieveAllAsString(),将Buffer中的数据转为string类型的数据返回
 string retrieveAllAsString()
  {
    return retrieveAsString(readableBytes());
  }
    string retrieveAsString(size_t len)
  {
    assert(len <= readableBytes());
    string result(peek(), len);
    retrieve(len);
    return result;
  }
  • append()将data数据添加到Buffer中,并且更新writerIndex_ 指向
 void append(const char* /*restrict*/ data, size_t len)
  {
    ensureWritableBytes(len);
    std::copy(data, data+len, beginWrite());
    hasWritten(len);
  }

- ensureWritableBytes(),判断缓冲区还有多少能写的空间,以及扩容

  void ensureWritableBytes(size_t len)
  {
    if (writableBytes() < len)
    {
      makeSpace(len);
    }
    assert(writableBytes() >= len);
  }

- 如何正确扩容,这就是Buffer的精髓了

void makeSpace(size_t len)
  {
    if (writableBytes() + prependableBytes() < len + kCheapPrepend)
    {
      // FIXME: move readable data
      buffer_.resize(writerIndex_+len);
    }
    else
    {
      // move readable data to the front, make space inside buffer
      assert(kCheapPrepend < readerIndex_);
      size_t readable = readableBytes();//保存一下没有读取的数据
      std::copy(begin()+readerIndex_,//源开始位置
                begin()+writerIndex_,//源结束位置
                begin()+kCheapPrepend);//目标开始位置
      readerIndex_ = kCheapPrepend;
      writerIndex_ = readerIndex_ + readable;
      assert(readable == readableBytes());
    }
  }

扩容巧妙思想在于,因为两个指针的不断移动,导致指向可读数据的指针一直后移,预留区越来越大,如果一味的扩容,会导致前面预留区越来越大,这样造成了浪费

  • 利用writableBytes() + prependableBytes()判断整个Buffer剩余的可写入的空间,如果这个空间小于要写入的以及预留的8字节位置的总和,那么直接扩容
  • 如果大于说明目前剩余的位置还足够存放要写入的数据,那么通过vector的数据拷贝,把Buffer里面的数据挪一挪,这时候readerIndex_就指向了初始位置,writerIndex_的位置就是目前可写入的首地址,这样在进行写入,就不需要一味的扩容。

借用我在地铁站里吃闸机的图片
在这里插入图片描述

  • 客户端发来数据,readFd从该TCP接受缓冲区中将数据读取来并放入Buffer中
ssize_t Buffer::readFd(int fd, int *savedErrno)
{
  // saved an ioctl()/FIONREAD call to tell how much to read
  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;
  // 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;
  const ssize_t n = sockets::readv(fd, vec, iovcnt);
  if (n < 0)
  {
    *savedErrno = errno;
  }
  else if (implicit_cast<size_t>(n) <= writable)
  {
    writerIndex_ += n;
  }
  else
  {
    writerIndex_ = buffer_.size();
    append(extrabuf, n - writable);
  }
  // if (n == writable + sizeof extrabuf)
  // {
  //   goto line_30;
  // }
  return n;
}

在读取数据的时候,我们并不知道数据的最终大小是多少,所以采用以下方法:

  1. 首先定义一个64K栈缓存extrabuf临时存储,利用栈的好处是可以自动释放,并计算出目前剩余可写的空间大小;
  2. 利用结构体iovec指定两块缓冲区,一块是目前剩余的可写的Buffer,一块是临时的缓冲区,指定了起始位以及缓冲区的大小;
  3. const int iovcnt = (writable < sizeof extrabuf) ? 2 : 1;如果writable < sizeof extrabuf就选2块内存readv,否则一块;
  4. 读数据const ssize_t n = sockets::readv(fd, vec, iovcnt);
  5. 若读取的数据超过现有内部buffer_的eritable的空间大小时,启用备用的extrabuf 64kb的空间,并将这些数据添加到内部buffer_的末尾

iovec结构体定义

iovec 结构体用于定义分散-聚集 I/O 操作中的数据块。它通常与 readv、writev 等系统调用配合使用,以便在单次 I/O 操作中读写多个非连续的内存区域。

struct iovec
  {
    void *iov_base;	/* 指向数据缓冲区的指针  */
    size_t iov_len;	/* 缓冲区的长度  */
  };

对于每一个传输的元素,指针成员iovec指向缓冲区,这个缓冲区存放的时readv所接受的数据或者是writev将要发送的数据,成员ivo_len在各种情况下分别确定了接受的最长长度以及实际写入的长度,对于readv(),假如传入3个缓冲区每个缓冲区大小为16,当前文件偏移量为20,那么内核会把当前文件的偏移量[20,36)存入iovec[0],[36,52)存入iovec[1],[52,68)存入iovec[2]。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值