共计 9081 个字符,预计需要花费 23 分钟才能阅读完成。
博客原文
Go 语言中的零拷贝优化
导言
置信那些已经应用 Go 写过 proxy server 的同学应该对 io.Copy()/io.CopyN()/io.CopyBuffer()/io.ReaderFrom
等接口和办法不生疏,它们是应用 Go 操作各类 I/O 进行数据传输常常须要应用到的 API,其中基于 TCP 协定的 socket 在应用上述接口和办法进行数据传输时利用到了 Linux 的零拷贝技术 sendfile
和 splice
。
我前段时间为 Go 语言外部的 Linux splice
零拷贝技术做了一点优化:为 splice
零碎调用实现了一个 pipe pool,复用管道,缩小频繁创立和销毁 pipe buffers 所带来的零碎开销,实践上来说可能大幅晋升 Go 的 io
规范库中基于 splice
零拷贝实现的 API 的性能。因而,我想从这个优化工作登程,分享一些我集体对多线程编程中的一些不成熟的优化思路。
因自己满腹经纶,故行文之间恐有纰漏,望诸君海涵,不吝赐教,若能予以斧正,则感激不尽。
splice
纵观 Linux 的零拷贝技术,相较于 mmap
、sendfile
和 MSG_ZEROCOPY
等其余技术,splice
从应用老本、性能和适用范围等维度综合来看更适宜在程序中作为一种通用的零拷贝形式。
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_F_MOVE:批示
splice()
尝试仅仅是挪动内存页面而不是复制,设置了这个值不代表就肯定不会复制内存页面,复制还是挪动取决于内核是否从管道中挪动内存页面,或者管道中的内存页面是否是残缺的;这个标记的初始实现有很多 bug,所以从 Linux 2.6.21 版本开始就曾经有效了,但还是保留了下来,因为在将来的版本里可能会从新被实现。 - SPLICE_F_NONBLOCK:批示
splice()
不要阻塞 I/O,也就是使得splice()
调用成为一个非阻塞调用,能够用来实现异步数据传输,不过须要留神的是,数据传输的两个文件描述符也最好是事后通过 O_NONBLOCK 标记成非阻塞 I/O,不然splice()
调用还是有可能被阻塞。 - SPLICE_F_MORE:告诉内核下一个
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);
数据传输过程图:
应用 splice()
实现一次磁盘文件到网卡的读写过程如下:
- 用户过程调用
pipe()
,从用户态陷入内核态,创立匿名单向管道,pipe()
返回,上下文从内核态切换回用户态; - 用户过程调用
splice()
,从用户态陷入内核态; - DMA 控制器将数据从硬盘拷贝到内核缓冲区,从管道的写入端 ” 拷贝 ” 进管道,
splice()
返回,上下文从内核态回到用户态; - 用户过程再次调用
splice()
,从用户态陷入内核态; - 内核把数据从管道的读取端 ” 拷贝 ” 到套接字缓冲区,DMA 控制器将数据从套接字缓冲区拷贝到网卡;
splice()
返回,上下文从内核态切换回用户态。
下面是 splice
的根本工作流程和原理,简略来说就是在数据传输过程中传递内存页指针而非理论数据来实现零拷贝,如果无意理解其更底层的实现原理请移步:《Linux I/O 原理和 Zero-copy 技术全面揭秘》。
pipe pool for splice
pipe pool in HAProxy
从上面对 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 pool,应用自旋锁爱护。
每个线程去取 pipe 的时候会先从本人的 TLS
中去尝试获取,获取不到则加锁进入全局 pipe pool 去找;应用 pipe buffer 过后将其放回:先尝试放回 TLS
,依据肯定的策略计算以后 TLS
的本地 pipe pool 链表中的节点是否曾经过多,是的话则放到全局的 pipe pool 中去,否则间接放回本地 pipe pool。
HAProxy 的 pipe pool 实现尽管只有短短的 100 多行代码,然而其中蕴含的设计思维却蕴含了许多十分经典的多线程优化思路,值得细读。
pipe pool in Go
受到 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 (婉转地)否决了,于是我马上又想了两个新的计划:
- 基于这个现有的计划加上一个独立的 goroutine 定时去扫描 pipe pool,敞开并开释 pipe buffers;
- 基于
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
的底层实现:
- 获取对象时:当某个 P 上的 goroutine 从
sync.Pool
尝试获取缓存的对象时,须要先把以后的 goroutine 锁死在 P 上,避免操作期间忽然被调度走,而后先尝试去取本地公有变量private
,如果没有则去shared
双向链表的表头取,该链表能够被其余 P 生产(或者说 ” 偷 ”),如果以后 P 上的shared
是空则去 ” 偷 ” 其余 P 上的shared
双向链表的表尾,最初解除锁定,如果还是没有取到缓存的对象,则间接调用New
创立一个返回。 - 放回对象时:先把以后的 goroutine 锁死在 P 上,如果本地的
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 如下:
- internal/poll: implement a pipe pool for splice() call
- internal/poll: fix the intermittent build failures with pipe pool
- internal/poll: ensure that newPoolPipe doesn’t return a nil pointer
- internal/poll: cast off the last reference of SplicePipe in test
为 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,期间波及了多种并发、同步的优化思路,咱们再来演绎总结一下。
- 资源复用,晋升并发编程性能最无效的伎俩肯定是资源复用,也是最空谷传声的优化伎俩。
- 数据结构的选取,数组反对 O(1) 随机拜访并且能更好地利用 CPU cache,然而这些劣势在 pool 的场景下并不显著,因为 pool 中的资源具备等价性和单个存取 (非批量) 操作,数组须要事后调配固定内存并且伸缩的时候会有额定的内存管理负担,链表随取随弃,人造反对动静伸缩。
- 全局锁的优化,两种思路,一种是依据资源的个性尝试对锁的粒度进行降级,一种是通过引入本地缓存,尝试错开多线程对资源的拜访,缩小竞争全局锁的频次;还有就是依据理论场景适当地抉择用户态锁。
- 利用语言的 runtime,像 Go、Java 这类自带一个宏大的 GC 的编程语言,在性能上个别不是 C/C++/Rust 这种无 GC 语言的对手,然而凡事有利有弊,自带 runtime 的语言也具备独特的劣势,比方 HAProxy 的 pipe pool 是 C 实现的,在过程的生命周期中创立的 pipe buffers 会始终存在占用资源(除非被动敞开,然而很难精确把控机会),而 Go 实现的 pipe pool 则能够利用自身的 runtime 进行定期的清理工作,进一步缩小资源占用。
参考 & 延长
- sync.Pool
- pipe pool in HAProxy
- internal/poll: implement a pipe pool for splice() call
- internal/poll: fix the intermittent build failures with pipe pool
- internal/poll: ensure that newPoolPipe doesn’t return a nil pointer
- internal/poll: cast off the last reference of SplicePipe in test
- Use Thread-local Storage to Reduce Synchronization
- ELF Handling For Thread-Local Storage