Go 语言中的零拷贝优化html
相信那些曾经使用 Go 写过 proxy server 的同窗应该对 io.Copy()/io.CopyN()/io.CopyBuffer()/io.ReaderFrom
等接口和方法不陌生,它们是使用 Go 操做各种 I/O 进行数据传输常常须要使用到的 API,其中基于 TCP 协议的 socket 在使用上述接口和方法进行数据传输时利用到了 Linux 的零拷贝技术 sendfile
和 splice
。linux
我前段时间为 Go 语言内部的 Linux splice
零拷贝技术作了一点优化:为 splice
系统调用实现了一个 pipe pool,复用管道,减小频繁建立和销毁 pipe buffers 所带来的系统开销,理论上来讲可以大幅提高 Go 的 io
标准库中基于 splice
零拷贝实现的 API 的性能。所以,我想从这个优化工做出发,分享一些我我的对多线程编程中的一些不成熟的优化思路。git
因本人才疏学浅,故行文之间恐有纰漏,望诸君海涵,不吝赐教,若能予以斧正,则感激涕零。github
纵观 Linux 的零拷贝技术,相较于mmap
、sendfile
和 MSG_ZEROCOPY
等其余技术,splice
从使用成本、性能和适用范围等维度综合来看更适合在程序中做为一种通用的零拷贝方式。golang
splice()
系统调用函数定义以下:算法
#include <fcntl.h> #include <unistd.h> int pipe(int pipefd[2]); int pipe2(int pipefd[2], int flags); ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);
fd_in 和 fd_out 也是分别表明了输入端和输出端的文件描述符,这两个文件描述符必须有一个是指向管道设备的,这算是一个不太友好的限制。编程
off_in 和 off_out 则分别是 fd_in 和 fd_out 的偏移量指针,指示内核从哪里读取和写入数据,len 则指示了这次调用但愿传输的字节数,最后的 flags 是系统调用的标记选项位掩码,用来设置系统调用的行为属性的,由如下 0 个或者多个值经过『或』操做组合而成:数组
splice()
尝试仅仅是移动内存页面而不是复制,设置了这个值不表明就必定不会复制内存页面,复制仍是移动取决于内核可否从管道中移动内存页面,或者管道中的内存页面是不是完整的;这个标记的初始实现有不少 bug,因此从 Linux 2.6.21 版本开始就已经无效了,但仍是保留了下来,由于在将来的版本里可能会从新被实现。splice()
不要阻塞 I/O,也就是使得 splice()
调用成为一个非阻塞调用,能够用来实现异步数据传输,不过须要注意的是,数据传输的两个文件描述符也最好是预先经过 O_NONBLOCK 标记成非阻塞 I/O,否则 splice()
调用仍是有可能被阻塞。splice()
系统调用将会有更多的数据传输过来,这个标记对于输出端是 socket 的场景很是有用。splice()
是基于 Linux 的管道缓冲区 (pipe buffer) 机制实现的,因此 splice()
的两个入参文件描述符才要求必须有一个是管道设备,一个典型的 splice()
用法是:缓存
int pfd[2]; pipe(pfd); ssize_t bytes = splice(file_fd, NULL, pfd[1], NULL, 4096, SPLICE_F_MOVE); assert(bytes != -1); bytes = splice(pfd[0], NULL, socket_fd, NULL, bytes, SPLICE_F_MOVE | SPLICE_F_MORE); assert(bytes != -1);
数据传输过程图:bash
使用 splice()
完成一次磁盘文件到网卡的读写过程以下:
pipe()
,从用户态陷入内核态,建立匿名单向管道,pipe()
返回,上下文从内核态切换回用户态;splice()
,从用户态陷入内核态;splice()
返回,上下文从内核态回到用户态;splice()
,从用户态陷入内核态;splice()
返回,上下文从内核态切换回用户态。上面是 splice
的基本工做流程和原理,简单来讲就是在数据传输过程当中传递内存页指针而非实际数据来实现零拷贝,若是有意了解其更底层的实现原理请移步:《Linux I/O 原理和 Zero-copy 技术全面揭秘》。
从上面对 splice
的介绍可知,经过它实现数据零拷贝须要利用到一个媒介 -- pipe
管道(2005 年由 Linus 引入),大概是由于在 Linux 的 IPC 机制中对 pipe
的应用已经比较成熟,因而借助了 pipe 来实现 splice
,虽然 Linux Kernel 团队曾在 splice
诞生之初便说过在将来能够移除掉 pipe 这个限制,但十几年过去了也依然没有付诸实施,所以 splice
至今仍是和 pipe
死死绑定在一块儿。
那么问题就来了,若是仅仅是使用 splice
进行单次的大批量数据传输,则建立和销毁 pipe
开销几乎能够忽略不计,可是若是是须要频繁地使用 splice
来进行数据传输,好比须要处理大量网络 sockets 的数据转发的场景,则 pipe
的建立和销毁的频次也会随之水涨船高,每调用一次 splice
都建立一对 pipe
管道描述符,并在随后销毁掉,对一个网络系统来讲是一个巨大的消耗。
对于这问题的解决方案,天然而然就会想到 -- 『复用』,好比大名鼎鼎的 HAProxy。
HAProxy 是一个使用 C 语言编写的自由及开放源代码软件,其提供高可用性、负载均衡,以及基于 TCP 和 HTTP 的应用程序代理。它很是适用于那些有着极高网络流量的 Web 站点。GitHub、Bitbucket、Stack Overflow、Reddit、Tumblr、Twitter 和 Tuenti 在内的知名网站,及亚马逊网络服务系统都在使用 HAProxy。
由于须要作流量转发,可想而知,HAProxy 不可避免地要高频地使用 splice
,所以对 splice
带来的建立和销毁 pipe buffers 的开销没法忍受,从而须要实现一个 pipe pool,复用 pipe buffers 减小系统调用消耗,下面咱们来详细剖析一下 HAProxy 的 pipe pool 的设计思路。
首先咱们来本身思考一下,一个最简单的 pipe pool 应该如何实现,最直接且简单的实现无疑就是:一个单链表+一个互斥锁。链表和数组是用来实现 pool 的最简单的数据结构,数组由于数据在内存分配上的连续性,可以更好地利用 CPU 高速缓存加速访问,可是首先,对于运行在某个 CPU 上的线程来讲,一次只须要取一个 pipe buffer 使用,因此高速缓存在这里的做用并不十分明显;其次,数组不只是连续并且是固定大小的内存区,须要预先分配好固定大小的内存,并且还要动态伸缩这个内存区,期间须要对数据进行搬迁等操做,增长额外的管理成本。链表则是更加适合的选择,由于做为 pool 来讲其中全部的资源都是等价的,并不须要随机访问去获取其中某个特定的资源,并且链表自然是动态伸缩的,随取随弃。
锁一般使用 mutex,在 Linux 上的早期实现是一种彻底基于内核态的 sleep-waiting 也就是休眠等待的锁,kernel 维护一个对全部进程/线程均可见的共享资源对象 mutex,多线程/进程的加锁解锁其实就是对这个对象的竞争。若是如今有 AB 两个进程/线程,A 首先进入 kernel space 检查 mutex,看看有没有别的进程/线程正在占用它,抢占 mutex 成功以后则直接进入临界区,B 尝试进入临界区的时候,检测到 mutex 已被占用,就由运行态切换成睡眠态,等待该共享对象释放,A 出临界区的时候,须要再次进入 kernel space 查看有没有别的进程/线程在等待进入临界区,而后 kernel 会唤醒等待的进程/线程并在合适的时间把 CPU 切换给该进程/线程运行。因为最初的 mutex 是一种彻底内核态的互斥量实现,在并发量大的状况下会产生大量的系统调用和上下文切换的开销,在 Linux kernel 2.6.x 以后都是使用 futex (Fast Userspace Mutexes) 实现,也便是一种用户态和内核态混用的实现,经过在用户态共享一段内存,并利用原子操做读取和修改信号量,在没有竞争的时候只需检查这个用户态的信号量而无需陷入内核,信号量存储在进程内的私有内存则是线程锁,存储在经过 mmap
或者 shmat
建立的共享内存中则是进程锁。
即使是基于 futex 的互斥锁,若是是一个全局的锁,这种最简单的 pool + mutex 实如今竞争激烈的场景下会有可预见的性能瓶颈,所以须要进一步的优化,优化手段无非两个:下降锁的粒度或者减小抢(全局)锁的频次。由于 pipe pool 中的资源原本就是全局共享的,也就是没法对锁的粒度进行降级,所以只能是尽可能减小多线程抢锁的频次,而这种优化经常使用方案就是在全局资源池以外引入本地资源池,对多线程访问资源的操做进行错开。
至于锁自己的优化,因为 mutex 是一种休眠等待锁,即使是基于 futex 优化以后在锁竞争时依然须要涉及内核态开销,此时能够考虑使用自旋锁(Spin Lock),也便是用户态的锁,共享资源对象存在用户进程的内存中,避免在锁竞争的时候陷入到内核态等待,自旋锁比较适合临界区极小的场景,而 pipe pool 的临界区里只是对链表的增删操做,很是匹配。
HAProxy 实现的 pipe pool 就是依据上述的思路进行设计的,将单一的全局资源池拆分红全局资源池+本地资源池。
全局资源池利用单链表和自旋锁实现,本地资源池则是基于线程私有存储(Thread Local Storage, TLS)实现,TLS
是一种线程的私有的变量,它的主要做用是在多线程编程中避免锁竞争的开销。TLS
由编译器提供支持,咱们知道编译 C 程序获得的 obj
或者连接获得的 exe
,其中的 .text
段保存代码文本,.data
段保存已初始化的全局变量和已初始化的静态变量,.bss
段则保存未初始化的全局变量和未初始化的局部静态变量。
而 TLS
私有变量则会存入 TLS
帧,也就是 .tdata
和 .tboss
段,与.data
和 .bss
不一样的是,运行时程序不会直接访问这些段,而是在程序启动后,动态连接器会对这两个段进行动态初始化 (若是有声明 TLS
的话),以后这两个段不会再改变,而是做为 TLS
的初始镜像保存起来。每次启动一个新线程的时候都会将 TLS
块做为线程堆栈的一部分进行分配并将初始的 TLS
镜像拷贝过来,也就是说最终每一个线程启动时 TLS
块中的内容都是同样的。
HAProxy 的 pipe pool 实现原理:
thread_local
修饰的一个单链表,节点是 pipe buffer 的两个管道描述符,那么每一个须要使用 pipe buffer 的线程都会初始化一个基于 TLS
的单链表,用以存储 pipe buffers;每一个线程去取 pipe 的时候会先从本身的 TLS
中去尝试获取,获取不到则加锁进入全局 pipe pool 去找;使用 pipe buffer 事后将其放回:先尝试放回 TLS
,根据必定的策略计算当前 TLS
的本地 pipe pool 链表中的节点是否已通过多,是的话则放到全局的 pipe pool 中去,不然直接放回本地 pipe pool。
HAProxy 的 pipe pool 实现虽然只有短短的 100 多行代码,可是其中蕴含的设计思想却包含了许多很是经典的多线程优化思路,值得细读。
受到 HAProxy 的 pipe pool 的启发,我尝试为 Golang 的 io
标准库里底层的 splice
实现了一个 pipe pool,不过熟悉 Go 的同窗应该知道 Go 有一个 GMP 并发调度器,提供了强大并发调度能力的同时也屏蔽了操做系统层级的线程,因此 Go 没有提供相似 TLS
的机制,却是有一些开源的第三方库提供了相似的功能,好比 gls,虽然实现很精巧,但毕竟不是官方标准库并且会直接操做底层堆栈,因此其实也并不推荐在线上使用。
一开始,由于 Go 缺少 TLS
机制,因此我提交的初版 go pipe pool 就是一个很简陋的单链表+全局互斥锁的实现,由于这个方案在进程的生命周期中并不会去释放资源池里的 pipe buffers(实际上 HAProxy 的 pipe pool 也会有这个问题),也就是说那些未被释放的 pipe buffers 将一直存在于用户进程的生命周期中,直到进程结束以后才由 kernel 进行释放,这明显不是一个使人信服的解决方案,结果不出意料地被 Go team 的核心大佬 Ian (委婉地)否决了,因而我立刻又想了两个新的方案:
sync.Pool
标准库来实现 pipe pool,并利用 runtime.SetFinalizer
来解决按期释放 pipe buffers 的问题。第一个方案须要引入额外的 goroutine,而且该 goroutine 也为这个设计增长了不肯定的因素,而第二个方案则更加优雅,首先由于基于 sync.Pool
实现,其底层也能够说是基于 TLS
的思想,其次利用了 Go 的 runtime 来解决定时释放 pipe buffers 的问题,实现上更加的优雅,因此很快,我和其余的 Go reviewers 就达成一致决定采用第二个方案。
sync.Pool
是 Go 语言提供的临时对象缓存池,通常用来复用资源对象,减轻 GC 压力,合理使用它能对程序的性能有显著的提高。不少顶级的 Go 开源库都会重度使用 sync.Pool
来提高性能,好比 Go 领域最流行的第三方 HTTP 框架 fasthttp 就在源码中大量地使用了 sync.Pool
,而且收获了比 Go 标准 HTTP 库高出近 10 倍的性能提高(固然不只仅靠这一个优化点,还有不少其余的),fasthttp 的做者 Aliaksandr Valialkin 做为 Go 领域的大神(Go contributor,给 Go 贡献过不少代码,也优化过 sync.Pool
),在 fasthttp 的 best practices 中极力推荐使用 sync.Pool
,因此 Go 的 pipe pool 使用 sync.Pool
来实现也算是水到渠成。
sync.Pool
底层原理简单来讲就是:私有变量+共享双向链表。
Google 了一张图来展现 sync.Pool
的底层实现:
sync.Pool
尝试获取缓存的对象时,须要先把当前的 goroutine 锁死在 P 上,防止操做期间忽然被调度走,而后先尝试去取本地私有变量 private
,若是没有则去 shared
双向链表的表头取,该链表能够被其余 P 消费(或者说"偷"),若是当前 P 上的 shared
是空则去"偷"其余 P 上的 shared
双向链表的表尾,最后解除锁定,若是仍是没有取到缓存的对象,则直接调用 New
建立一个返回。private
为空,则直接将对象存入,不然就存入 shared
双向链表的表头,最后解除锁定。shared
双向链表的每一个节点都是一个环形队列,主要是为了高效复用内存,共享双向链表在 Go 1.13 以前使用互斥锁 sync.Mutex
保护,Go 1.13 以后改用 atomic CAS 实现无锁并发,原子操做无锁并发适用于那些临界区极小的场景,性能会被互斥锁好不少,正好很贴合 sync.Pool
的场景,由于存取临时对象的操做是很是快速的,若是使用 mutex,则在竞争时须要挂起那些抢锁失败的 goroutines 到 wait queue,等后续解锁以后唤醒并放入 run queue,等待调度执行,还不如直接忙轮询等待,反正很快就能抢占到临界区。
sync.Pool
的设计也具备部分的 TLS
思想,因此从某种意义上来讲它是就 Go 语言的 TLS
机制。
sync.Pool
基于 victim cache 会保证缓存在其中的资源对象最多不超过两个 GC 周期就会被回收掉。
所以我使用了 sync.Pool
来实现 Go 的 pipe pool,把 pipe 的管道文件描述符对存储在其中,并发之时进行复用,并且会按期自动回收,可是还有一个问题,当 sync.Pool
中的对象被回收的时候,只是回收了管道的文件描述符对,也就是两个整型的 fd 数,并无在操做系统层面关闭掉 pipe 管道。
所以,还须要有一个方法来关闭 pipe 管道,这时候能够利用 runtime.SetFinalizer
来实现。这个方法其实就是对一个即将放入 sync.Pool
的资源对象设置一个回调函数,当 Go 的三色标记 GC 算法检测到 sync.Pool
中的对象已经变成白色(unreachable,也就是垃圾)并准备回收时,若是该白色对象已经绑定了一个关联的回调函数,则 GC 会先解绑该回调函数并启动一个独立的 goroutine 去执行该回调函数,由于回调函数使用该对象做为函数入参,也就是会引用到该对象,那么就会致使该对象从新变成一个 reachable 的对象,因此在本轮 GC 中不会被回收,从而使得这个对象的生命得以延续一个 GC 周期。
在每个 pipe buffer 放回 pipe pool 以前经过 runtime.SetFinalizer
指定一个回调函数,在函数中使用系统调用关闭管道,则能够利用 Go 的 GC 机制按期真正回收掉 pipe buffers,从而实现了一个优雅的 pipe pool in Go,相关的 commits 以下:
为 Go 的 splice
引入 pipe pool 以后,对性能的提高效果以下:
goos: linux goarch: amd64 pkg: internal/poll cpu: AMD EPYC 7K62 48-Core Processor name old time/op new time/op delta SplicePipe-8 1.36µs ± 1% 0.02µs ± 0% -98.57% (p=0.001 n=7+7) SplicePipeParallel-8 747ns ± 4% 4ns ± 0% -99.41% (p=0.001 n=7+7) name old alloc/op new alloc/op delta SplicePipe-8 24.0B ± 0% 0.0B -100.00% (p=0.001 n=7+7) SplicePipeParallel-8 24.0B ± 0% 0.0B -100.00% (p=0.001 n=7+7) name old allocs/op new allocs/op delta SplicePipe-8 1.00 ± 0% 0.00 -100.00% (p=0.001 n=7+7) SplicePipeParallel-8 1.00 ± 0% 0.00 -100.00% (p=0.001 n=7+7)
基于 pipe pool 复用和直接建立&销毁 pipe buffers 相比,耗时降低在 99% 以上,内存使用则是降低了 100%。
固然,这个 benchmark 只是一个纯粹的存取操做,并未加入具体的业务逻辑,因此是一个很是理想化的压测,不能彻底表明生产环境,可是 pipe pool 的引入对使用 Go 的 io
标准库并基于 splice
进行高频的零拷贝操做的性能一定会有数量级的提高。
这个特性最快应该会在今年下半年的 Go 1.17 版本发布,到时就能够享受到 pipe pool 带来的性能提高了。
经过给 Go 语言实现一个 pipe pool,期间涉及了多种并发、同步的优化思路,咱们再来概括总结一下。