我是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 | for (;;) { |
在同步 I/O 模型中,例如在 Go 程序(或使用线程执行阻塞 I/O 的 C/C++ 程序)中,没有办法在read之前获取缓冲区。相反,您为读取调用提供缓冲区,您的 goroutine(或线程)阻塞直到数据准备好,然后恢复执行:
1 | nbytes, err := conn.Read(buf) |
这意味着您必须为每个连接分配一个缓冲区:缓冲区被提供给读取调用本身,这意味着必须有一个缓冲区与每个可能发生读取的连接相关联。与 C 不同,在 Go 中没有办法知道连接是否可读,除非实际尝试从中读取数据。这意味着至少具有大量空闲连接的 Go 代理将搅动大量虚拟内存空间,并且随着时间的推移也可能导致大量 RSS 内存占用。
这并不是 Go 的高级网络方法降低效率的唯一原因。例如,Go 缺少一种方法来执行矢量化网络 I/O。如果您对如何解决这些问题有想法,请通过电子邮件或 Twitter 告诉我,我会更新这篇文章。