设计目的
- 当调用write/send时,会向缓冲区中写入数据,内核和tcp协议栈负责将缓冲区中的数据发送到指定地址的目标位置。
- 当有数据到达内核的tcp缓冲区中,如果开启了对套接字可读事件的监听,那么内核会让套接字变为可读状态,从而从poll函数中返回,调用read/recv进行读操作。
- 如果调用write/send时,内核缓冲区已满,那么阻塞在IO函数上直到内核缓冲区有足够的空间容纳要写入的数据,非阻塞IO将会返回错误,通常是EAGAIN/EWOULDBLOCK。
- 如果调用write/send时,内核缓冲区未满,但是不能容纳要写入的字节数,可用空间不足,那么雪茹能写入的那么多字节数,此时,仍然有一些数据没有发送,可是这些数据还非发送不可,就出现缓冲区已满的情况。
- 这就导致要不阻塞当前线程,要不无法正常写入数据,而如果采用判断返回值是否出错的办法,仍然是一直忙循环检测IO写入状态,仍然会阻塞当前线程。
- 当内核缓冲区中一直有数据时,如果是水平触发,那么套接字会一直处于可读状态,这会导致忙循环问题。
- 当内核缓冲区一直有数据时,如果是边缘触发,那么就只会触发一次,如果数据没有读完,可能会造成数据漏读。
设计思路
- 应用层缓冲区通常很大,也可以很小,所以可通过动态调整来改变其大小,使用vector。
- 应用层缓冲区需要有读/写两个(缓冲区类只有一个,既可被当做读缓冲区,也可被用作写缓冲区)
- 当用户想要调用write/send写入数据给对端,如果数据可以全部写入,那就写入,如果只写入了部分数据或者写入不了,此时表明内核缓冲区已满,为了不阻塞当前线程,应用层缓冲区会接管这些数据,等到内核缓冲区可以写入的时候自动帮用户写入。
- 当有数据到达内核缓冲区,应用层的读缓冲区会自动将这些数据读到自己那里,当用户调用read/recv想要读取数据时,应用层读缓冲区将已经从内核缓冲区取出的数据返回给用户,实际上就是用户从应用层读缓冲区读取数据。
- 应用层缓冲区对用户而言是隐藏的,用户不需要知道有应用层缓冲区的存在,只需要读/写数据,而且也不会阻塞当前线程。
详细设计
缓冲区的设计采用vector数据结构,方便动态调整缓冲区大小。设计方法主要就是利用两个指针readerIndex_,writerIndex_分别记录缓冲区中数据的起点和终点,写入数据时就追加到writeIndex_后面,读取数据时就从readerIndex_开始读。缓冲区在使用的过程中会动态调整readerIndex_和writerIndex_的位置,初始缓冲区为空,即readerIndex == writerIndex。缓冲区默认大小为1KB,如果使用过程中发现缓冲区大小不够,会动态增加缓冲区大小。
核心代码
1.读缓冲区
ssize_t Buffer::readFd(int fd, int* savedErrno)
{
// 开辟额外栈空间,保证一次读到足够多的数据
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);
//如果应用层缓冲区足够大(大于128k)就不需要往栈区写数据了
const int iovcnt = (writable < sizeof(extrabuf))? 2 : 1;
//分散读,返回读取的字节数
const ssize_t n = ::readv(fd, vec, 2);
if(n < 0) {
printf("[Buffer:readFd]fd = %d readv : %s\n", fd, strerror(errno));
*savedErrno = errno;
}
//读取的字节数比较少,读缓冲区足以容纳,读缓冲区是readv的第一块内存,所以率先向这块内存写数据
else if(static_cast<size_t>(n) <= writable)
writerIndex_ += n;
else {
//将栈空间的数据追加到缓冲区末尾,因为读缓冲区已经写满了,所以writeIndex_指针就指向缓冲区末尾
writerIndex_ = buffer_.size();
append(extrabuf, n - writable);
}
return n;
}
2.写缓冲区
ssize_t Buffer::writeFd(int fd, int* savedErrno)
{
//剩余可读字节数
size_t nLeft = readableBytes();
//可读的第一个位置
char* bufPtr = __begin() + readerIndex_;
ssize_t n;
//写缓冲区
if((n = ::write(fd, bufPtr, nLeft)) <= 0) {
if(n < 0 && n == EINTR) //中断,返回0
return 0;
else { //失败,返回-1
printf("[Buffer:writeFd]fd = %d write : %s\n", fd, strerror(errno));
*savedErrno = errno;
return -1;
}
} else { //成功,移动readerIndex_指针,并返回字节数
readerIndex_ += n;
return n;
}
}
问题
1.为什么不在Buffer构造时就开辟足够大的缓冲区?
- 每个tcp连接都有输入/输出缓冲区,如果连接过多则内存消耗会很大。
- 防止客户端与服务端数据交互比较少,造成缓冲区的浪费。
- 当缓冲区大小不足时,利用vector内存增长的优势,扩充缓冲区。
2.为什么不在读数据之前判断一下应用层缓冲区是否可以容纳内核缓冲区的全部数据?
- 采用这种方式就会调用一次recv,传入MSG_PEEK,即只获取缓冲区有多少数据没有没接收,但不取数据,然后再调用一次recv从内核缓冲区读取数据。
- 此过程执行了两次系统调用,得不偿失,尽量使用一次系统调用就将所有数据读出,这就需要一个很大的空间。
3.应用层缓冲区不够时,怎么办?
- 开辟一段栈空间(128k),使用分散读(readv)系统调用读取数据,然后为buffer_开辟更大的空间,存放读到栈区的那部分数据。