muduo_net代码剖析之Buffer类的设计

一、备用知识

1、为什么TcpConnection必须要有output buffer

muduo_net代码剖析之Buffer类的设计_第1张图片
考虑一个常见场景:
    程序想通过TCP连接向对方发送100K字节的数据,但是write()调用中,操作系统只接收了80K字节(受TCP advertised window的控制,细节见TCPv1),你肯定不想在原地等待,因为不知道会等多久(取决于对方什么时候能够接收数据,然后滑动TCP窗口)。程序应该尽快交出控制权,返回eventloop。在这种情况下,剩余的20K字节数据怎么处理?
    对于应用程序而言,它只管生成数据,它不应该关心到底数据是一次性发送还是分成几次发送,这些应该由网络库来操心,程序只要调用TcpConnection::send()就行了,网络库会负责到底。那么,网络库是怎么做的呢?(请看①②③④⑤的描述)
    ① 还有20K字节的数据没有发送,此时,网络库会接管这剩余的20K字节数据,把它保存在该Tcp connection的output buffer中,然后注册POLLOUT事件,一旦socket变得可写,就立即将20K数据的一部分写入socket。
    ② 当然,这第二次write()也不一定能将20K字节全部写入到socket,如果还有剩余,网络库应该继续关注POLLOUT事件;
    ③ 当20K字节数据全部写完后,网络库将停止关注POLLOUT事件,以免造成busy loop。(Muduo EventLoop采用的就是epoll level trigger,LT模式当缓冲区可读可写时,将一直触发读写事件,这么做的具体原因,我以后再说。)
    ④ 如果程序又写入了50K字节,而这时候output buffer里还有待发送的20K数据,那么网络库不应该直接调用write(),而应该把这50K数据append在那20K数据之后,等socket变得可写的时候,再一并写入。
    ⑤ 如果output buffer里还有待发送的数据,而程序又想关闭连接(对于程序而言,调用TcpConnection::send()之后,就认为数据迟早会发送出去),那么这时候网络库不能立即关闭连接,而要等待数据发送完毕后,才去断开连接(参考“为什么muduo的shutdown()没有直接关闭TCP连接”)。

综上,要让程序在write操作上不阻塞,网络库必须要给每个tcp connection配置output buffer。

2、为什么TcpConnection必须要有input buffer

muduo_net代码剖析之Buffer类的设计_第2张图片
    TCP是一个无边界的字节流协议,接收方必须要处理“收到的数据尚不构成一条完整的消息”和“一次收到两条消息的数据”等等粘包情况。

    网络库在处理“socket可读”事件的时候,必须一次性把socket里的数据读完(即将数据从操作系统buffer搬到应用层buffer),否则会反复触发POLLIN事件,造成busy-loop。(再说一遍:Muduo EventLoop采用的就是epoll level trigger,这么做的具体原因,我以后再说。)

    那么网络库必然要应对“数据不完整”的情况,收到的数据先放到input buffer里,等构成一条完整的消息再通知程序的业务逻辑。这通常是code的职责,间第2.3节“Boost.Asio聊天服务器”一文中“TCP分包”的论述和代码。

所以,在tcp网络编程中,网络库必须要给每一个tcp connection配置input buffer。

    当input buffer中有数据时,就会通知上层应用程序,onMessage(Buffer*)回调,根据上层应用层协议判定是否是一个完整的包,即:如果不是一条完整的消息,就不会取走数据,也不会进行相应的处理;如果是一条完整的消息,将取走消息并进行相应的处理。

epoll使用level trigger(LT模式)的原因

① 与poll兼容
② LT模式不会发生漏掉事件的BUG,但POLLOUT事件不能一开始就关注,否则会出现busy loop,而应该在write无法完全写入内核缓冲区的时候才关注,将未写入内核缓冲区的数据添加到应用层output buffer,直到应用层output buffer写完,写完后停止关注POLLOUT事件
③ 读写的时候不必等候EAGIN,可以节约系统调用的次数,降低延迟。(注:如果用ET模式,读的时候读到EAGAIN,写的时候直到output buffer写完或者EAGAIN)


二、应用层缓冲区Buffer

1、Buffer的要求

muduo buffer的设计考虑了常见的网络编程需求,我试图在易用性和性能之间找一个平衡点,目前这个平衡点更偏向于易用性。

muduo buffer的设计要点:
(1) 对外表现为一块连续的内存(char* ,len),以方便客户代码的编写
(2) 其size()可以自动增长,以适应不同大小的消息。他不是一个fixed size array(即char buf[8192])
(3) 内部以vector of char 来保存数据,并提供相应的访问函数

Buffer其实像一个queue,从末尾写入数据,从头部读出数据。即内部维护了下标,分别是:读入位置、写入位置。

TcpConnection会有两个Buffer成员, Buffer inputBuffer_ 、Buffer outputBuffer_;

  • input buffer:TcpConnection会从socket读取数据,然后写入input buffer(其实这一步是用Buffer::readFd()完成的);客户代码从input buffer读取数据
  • output buffer:客户端代码会把数据写入output buffer(其实这一步是用TcpConnection::send()完成的);TcpConnection从output buffer读取数据并写入到socket。

其实,input、output是针对于客户端代码而言,客户代码从input读,往output写。TcpConnection的读写正好相反。

2、Buffer类的成员

Buffer的作用就是暂时存储数据。当向Buffer写入数据后,Buffer可写入空间writeable减小,可读空间readable增大;取走数据后变化相反。
muduo中的Buffer为封装了的vector。vector为一块连续空间,且其本身具有自动增长的性质,它的迭代器为原始指针,使用起来较为方便。

①Buffer类只有3个成员变量,分别是buffer_、readerIndex_、writerIndex_

std::vector<char> buffer_; //vector用来替代固定大小的数字
size_t readerIndex_; //读位置
size_t writerIndex_; //写位置

kCRLF是一个柔性数组,存放“\r\n”,可以用来搜索缓冲区数据中的”\r\n“

②其中,readerIndex_、writerIndex_将整个buffer_分成了3个区域,分别是prependable、writable、readable ,Buffer的结构如下图:
muduo_net代码剖析之Buffer类的设计_第3张图片
readeIndex_是可读部分的首位置,writerIndex_是可写部分的首位置。两个中间就是实际可读(readable)的元素。前部预留的Prepend部分可以用来向头部添加内容而不需要内存挪动的消耗。下面三个函数能获得这3个区域的大小:

size_t readableBytes() const //可读空间,里面放有缓存的数据
{ return writerIndex_ - readerIndex_; }
size_t writableBytes() const //可写空间大小
{ return buffer_.size() - writerIndex_; }
size_t prependableBytes() const //预留空间大小
{ return readerIndex_; }

3、与3个区域相关的函数

① 构造函数

  1. kCheapPrepend、kInitialSize分别表示prependable、writable的大小
  2. 初始时,将readIndex、writeIndex设为kCheapPrepend(8)位置
    muduo_net代码剖析之Buffer类的设计_第4张图片
static const size_t kCheapPrepend = 8; //prependable的大小
static const size_t kInitialSize = 1024; //writable的大小

explicit Buffer(size_t initialSize = kInitialSize)
  : buffer_(kCheapPrepend + initialSize), // 8+1024
    readerIndex_(kCheapPrepend), //8
    writerIndex_(kCheapPrepend)  //8
{
  assert(readableBytes() == 0);
  assert(writableBytes() == initialSize);
  assert(prependableBytes() == kCheapPrepend);
}

② 获取readIndex、writerIndex位置

//图中的readIndex位置
const char* peek() const 
{ return begin() + readerIndex_; }

//图中writerIndex位置
char* beginWrite()
{ return begin() + writerIndex_; }
const char* beginWrite() const
{ return begin() + writerIndex_; }

4、Buffer的操作:读、写

  1. 基本的read-write操作
    ①当有足够的空间读写时,每次read走、write入数据后,都要将readIndex、writeIndex指针下标后移
    ②当将数据全部read空时,readIndex、writeIndex指针下标设置为初始位置,即kCheapPrepend(8)
    muduo_net代码剖析之Buffer类的设计_第5张图片

  2. 内部腾挪
    Buffer没有封装为一个环,所以当Buffer使用一段时间后,其真正的可写入空间不一定是size() - writeIndex,因为readIndex和prependable之间可能还有空间。因此当可写如空间不够时,有时可以通过把CONTENT向“前移”来达到目的。

void shrink(size_t reserve)
{
  Buffer other; //生成临时对象other(大小为readableBytes()+reserve),交换之后,临时对象析构掉
  
//两种情况
  //1.空间不够resize空间
  //2.空间足够内部腾挪
  other.ensureWritableBytes(readableBytes()+reserve); 

  //将当前空间中readable中的数据全部添加到other空间中,然后再交换swap
  other.append(toStringPiece()); 
  swap(other);
}

muduo_net代码剖析之Buffer类的设计_第6张图片
3. 扩增可写空间writeable
当writeable不能够容纳足够多的数据时,要进行动态的缓冲区扩充,即:①重新申请一块更大的缓冲区 ②旧空间上的数据拷贝到新缓冲区上 ③释放旧空间
muduo_net代码剖析之Buffer类的设计_第7张图片

4. 一个小小的创新:prepend空间
muduo_net代码剖析之Buffer类的设计_第8张图片

4、读、写Buffer

[1] 从readable区域中,读走数据

//读取len个字节的数据
void retrieve(size_t len)
{
  assert(len <= readableBytes());
  if (len < readableBytes())//if 要读字节数 < 可读字节数
  {
    readerIndex_ += len; //只是readerIndex_后移len
  }
  else //if 要读字节数 > 可读字节数,即将数据全部读走
  {
    retrieveAll(); //readerIndex_、writerIndex_位置重置为初始位置(8)
  }
}
void retrieveAll() 
{
  readerIndex_ = kCheapPrepend;
  writerIndex_ = kCheapPrepend;
}
//取出[readerIndex_,end]之间的数据,end为手动指定的
void retrieveUntil(const char* end);

//返回值:void
void retrieveInt64();
void retrieveInt32();
void retrieveInt16();
void retrieveInt8();
//返回值:intXX_t,即读到的值
int64_t readInt64();
int32_t readInt32();
int16_t readInt16();
int8_t readInt8();

//取出readable中len字节的数据,并转换成string类型,再返回
string retrieveAsString(size_t len)//取出readable中所有的数据,并转换成string类型,再返回
string retrieveAllAsString(); 

//取出readable中所有的数据,并转换成StringPiece类型,再返回
StringPiece toStringPiece() const

[2] 将数据写入到writable区域中
append():把数据追加到writeable区域中,如果空间不够,会进行makespace

void append(const char* data, size_t len)
{
  ensureWritableBytes(len); //确保有可写空间,空间不够,就自动分配
  std::copy(data, data+len, beginWrite());
  hasWritten(len);
}
void ensureWritableBytes(size_t len) 
{
  if (writableBytes() < len) //如果可写空间不够
  {
    makeSpace(len); //resize或移动数据,使Buffer能容下len大数据
  }
  assert(writableBytes() >= len);
}
void makeSpace(size_t len) //resize或移动数据,使Buffer能容下len大数据
 {
   //确保空间是真的不够,而不是挪动就可以腾出空间
   if (writableBytes() + prependableBytes() < len + kCheapPrepend)
   {
     // FIXME: move readable data
     buffer_.resize(writerIndex_+len);
   }
   else
   {
     //内部腾挪就足够append,那么就内部腾挪一下。
     // move readable data to the front, make space inside buffer
     assert(kCheapPrepend < readerIndex_);
     size_t readable = readableBytes();
     std::copy(begin()+readerIndex_,    //原来的可读部分全部copy到Prepend位置,相当于向前挪动,为writeable留出空间
               begin()+writerIndex_,
               begin()+kCheapPrepend);
     readerIndex_ = kCheapPrepend;   //更新下标
     writerIndex_ = readerIndex_ + readable;
     assert(readable == readableBytes());
   }
 }
void append(const StringPiece& str)
{
  append(str.data(), str.size());
}
void append(const void* data, size_t len)
{
  append(static_cast<const char*>(data), len);
}

void appendInt64(int64_t x)
{
  int64_t be64 = sockets::hostToNetwork64(x);
  append(&be64, sizeof be64);
}
void appendInt32(int32_t x);
void appendInt16(int16_t x);
void appendInt8(int8_t x);

[3] 向从Prepend区域中,放入数据的函数API:

void prependInt64(int64_t x); //放入x,x大小为8字节
void prependInt32(int32_t x);
void prependInt16(int16_t x);
void prependInt8(int8_t x);
void prepend(const void* data, size_t len);

5、核心函数 readFd

实现功能:从socket中读取数据,存放到Buffer中的writeable中
实现细节:因为不知道一次性可以读多少,因此先在栈上开辟了65536字节的空间extrabuf,使用readv读至Buffer_中。①如果Buffer中wriable足够存放从fd读到的数据,则读取完毕;②否则剩余数据先读到extrabuf中然后以append的方式追加入Buffer_。

ssize_t Buffer::readFd(int fd, int* savedErrno)
{
  //saved an ioctl()/FIONREAD call to tell how much to read
  //节省一次ioctl系统调用(获取当前有多少可读数据)
  //为什么这么说?因为我们准备了足够大的extrabuf,那么就不需要使用ioctl取查看fd有多少可读字节数了
  char extrabuf[65536];
 
  //使用iovec分配两个连续的缓冲区
  struct iovec vec[2];
  const size_t writable = writableBytes();
//第一块缓冲区,指向可写空间begin()+writerIndex_
  vec[0].iov_base = begin()+writerIndex_;
  vec[0].iov_len = writable;
//第二块缓冲区,指向栈上空间extrabuf
  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;  //writeable一般小于65536

  //返回值n:表示readv读到的字节数
  const ssize_t n = sockets::readv(fd, vec, iovcnt);  //iovcnt=2
  if (n < 0)
  {
    *savedErrno = errno;
  }
  else if (implicit_cast<size_t>(n) <= writable)//writable足够容纳
  {
    writerIndex_ += n;   //writerIndex_后移n
  }
  else //writable不足够容纳 ==> 数据被接受到了第二块缓冲区extrabuf,将其append至buffer
  {
    writerIndex_ = buffer_.size();//更新writerIndex到buffer.size()位置
    append(extrabuf, n - writable);//将extrabuf中的数据,再追加到buffer中
  }
  return n;
}

代码中struct iovec vec[2]的图示:
muduo_net代码剖析之Buffer类的设计_第9张图片

你可能感兴趣的:(Muduo库源码剖析)