Linux多线程服务端编程学习(三):非阻塞网络编程中应用层Buffer的必需性

本文的内容参照了陈硕先生的muduo网络库,本篇文章源码的地址为:https://github.com/freshman94/NetLib

原因

问题一:在非阻塞网络编程中,为什么要使用应用层发送缓冲区?

假设应用程序需要发送40KB的数据,但操作系统的内核发送缓冲区只有25KB的剩余空间,那么剩下的15KB数据怎么办?如果等待OS缓冲区可用,会阻塞当前线程,因为不知道对方什么时候收到并读取数据。因此网络库应该把15KB数据缓存起来,放到这个TCP链接的应用层缓冲区中,等socket变得可写时立刻发送数据,这样发送操作不会阻塞。若应用程序随后又要发送50KB的数据,而此时发送缓冲区尚有未发送的数据,那网络库应将这50KB的数据追加到发送缓冲区的末尾,而不能立即write,因为这样可能会打乱数据的顺序。

问题二:在非阻塞网络编程中,为什么要使用应用层接收缓冲区?

TCP是一个无边界的字节流协议,接收方必须要处理“收到的数据尚不构成一条完整的消息”和“一次收到两条消息的数据”等情况。因此这些数据应该先暂存在某个地方,以保证收到了一条完整的消息。

同时,muduo网络库使用的是水平触发方式,因为水平触发方式的编程更容易,并且不会有漏读的问题。那么这样的话,网络库在处理socket可读事件的时候,必须一次性把socket里的数据读完,否则会反复触发EPOLLIN事件。

Buffer的设计

+-------------------+------------------+------------------+
| prependable bytes |  readable bytes  |  writable bytes  |
|                   |     (CONTENT)    |                  |
+-------------------+------------------+------------------+
|                   |                  |                  |
 0      <=      readerIndex   <=   writerIndex    <=     size 

缓冲区的结构如上图所示,其底层使用vector来实现,可以自动增长。

其中prependable域可以用于存消息的长度,这可以用于解决TCP的粘包问题;readable域表示接收缓冲区,writable域表示发送缓冲区。

			prependable = readIndex
			readable = writeIndex - readIndex
			writable = size() - writeIndex

初始时readIndex = writeIndex = prependSize

考虑几种情况:

  • 数据都读取完了。此时应该将readIndex和writeIndex复位,以备新一轮使用。
  • 空间不足时。这时有两种情况:
    • 如果prepend区和writable区的总空间足够写入数据,则可先将readable区的数据腾挪到前面,以腾出空间
    • 如果prepend区和writable区的总空间也不足够,则需要为缓冲区resize空间,以实现自动增长。Buffer的空间增长之后,不会再回缩。

如何更合理地设计缓冲区?

一方面,我们希望减少系统调用,一次读的数据越多越划算,那么似乎应该准备一个大的缓冲区。但另一方面我们又希望减少内存占用。如果有上千万个并发连接,这样读写缓冲区占用的内存空间是巨大的。muduo的解决方法是用readv结合栈上空间。

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() + writeIndex_;
	vec[0].iov_len = writable;
	vec[1].iov_base = extrabuf;
	vec[1].iov_len = sizeof extrabuf;
	
	//缓冲区的可写空间大于extrabuf,则不使用extrabuf
	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(n) <= writable)
		writeIndex_ += n;
	else{
		writeIndex_ = buffer_.size();
		append(extrabuf, n - writable);
	}
	return n;
}

通过在栈上准备一个65536字节的extrabuf。如果数据不多,数据都写到Buffer中。如果数据长度超过Buffer的writable域的大小,就会将数据读到extrabuf中,然后程序再把extrabuf中的数据append到Buffer中。

这样既避免了初始Buffer过大时造成的内存浪费,也避免了反复调用read造成的系统开销。

Buffer的线程安全性

Buffer并没有设计为线程安全的,这是因为muduo网络库会保证数据的接收和发送动作都会在Buffer对应的IO线程中进行,代码中会用EventLoop::assertLoopThread()保证以上承诺。我会在下一篇文章中介绍这个函数。
如果数据的发送就发生在当前线程,则会直接在当前线程中操作output Buffer。如果数据的发送发生在别的线程,则会调用EventLoop::runInLoop()来保证数据的发送在缓冲区所属于的IO线程中进行。下一篇文章会进行解释这是如何实现的。

你可能感兴趣的:(多线程网络编程)