【译】Go与非阻塞IO

我是Golang在并发方式的粉丝:使用 goroutines 编写代码比使用 C 或 C++ 等语言编写传统的非阻塞网络服务器要容易得多。然而,在处理高度并发的网络代理时,我意识到,即 Go 并发模型会使编写执行大量并发 I/O 并高效使用内存的程序变得更加困难。

有问题的程序是类似于 HAProxy 或 Envoy 的网络代理。通常,代理有大量连接的客户端,但这些客户端中的大多数实际上是空闲的,没有未完成的网络请求。每个客户端连接都有一个读缓冲区和一个写缓冲区。因此,这样一个程序的简单内存使用至少是:connections * (readbuf_sz + writebuf_sz)

在这种性质的 C 或 C++ 程序中可以使用一个技巧来减少内存使用。假设通常有 5% 的客户端连接实际处于活动状态,而其他 95% 的客户端连接处于空闲状态,没有挂起的读取或写入。在这种情况下,您可以创建一个缓冲对象池 。当连接实际处于活动状态时,它们会获取缓冲区以用于从池中读取/写入 ,而当连接空闲时,它们会将缓冲区释放回池中。这将分配的缓冲区数量减少到大约活动连接实际需要的缓冲区数量。在这种情况下,使用这种技术将减少 20 倍的内存,与原始方法相比,只分配了 5% 的缓冲区。

这种技术之所以有效,是因为非阻塞读写 在 C 中的工作方式。在 C 中,您使用 select(2)epoll_wait(2) 之类的系统调用来获取文件描述符已准备好读取/写入的通知,然后您自己显式调用 read(2)write(2) 在该文件描述符上。这使您有机会在调用 select/epoll 之后,但在进行读取调用之前获取缓冲区。下面是网络代理的事件循环核心部分的简化版本,展示了这种技术:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
for (;;) {
// wait for some file descriptors to be read to read
int nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);

for (int n = 0; n < nfds; n++) {
// acquire a buffer
void *buf = acquire_buffer(pool);

// read the file descriptor
int clientfd = events[n].data.fd;
ssize_t nbytes = read(clientfd, buf, BUFSIZE);
if (nbytes < 0) {
// error case
handle_error(fd, clientfd);
} else {
// process the request
process_read(clientfd, buf, nbytes);
}

// return the buffer to the pool
release_buffer(pool, buf);
}
}

在同步 I/O 模型中,例如在 Go 程序(或使用线程执行阻塞 I/O 的 C/C++ 程序)中,没有办法在read之前获取缓冲区。相反,您为读取调用提供缓冲区,您的 goroutine(或线程)阻塞直到数据准备好,然后恢复执行:

1
nbytes, err := conn.Read(buf)

这意味着您必须为每个连接分配一个缓冲区:缓冲区被提供给读取调用本身,这意味着必须有一个缓冲区与每个可能发生读取的连接相关联。与 C 不同,在 Go 中没有办法知道连接是否可读,除非实际尝试从中读取数据。这意味着至少具有大量空闲连接的 Go 代理将搅动大量虚拟内存空间,并且随着时间的推移也可能导致大量 RSS 内存占用。
这并不是 Go 的高级网络方法降低效率的唯一原因。例如,Go 缺少一种方法来执行矢量化网络 I/O。如果您对如何解决这些问题有想法,请通过电子邮件或 Twitter 告诉我,我会更新这篇文章。