在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
的定义如下,只列出了一些重要部分
注释中写明了缓冲区的设计方法,主要就是利用两个指针readerIndex
,writerIndex
分别记录着缓冲区中数据的起点和终点,写入数据的时候追加到writeIndex
后面,读出数据时从readerIndex
开始读。在readerIndex
前面预留了几个字节大小的空间,方便日后为数据追加头部信息。缓冲区在使用的过程中会动态调整readerIndex
和writerIndex
的位置,初始缓冲区为空,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), &