-
1 基础理论铺垫
1.1 io 多路复用
在正式开始,我们有必要作个预热,提前理解一下所谓io多路复用
的概念.
拆解多路复用一词,所谓多路
,指的是存在多个待服务
目标,而复用
,指的是重复利用一个单元来为上述的多个目标提供服务
.
聊到io多路复用时,我比较希望举一个经营餐厅的例子——一个餐馆在运营过程中,考虑到人力成本,
一个服务员
往往需要同时为多名不同的顾客
提供服务,这个服务过程本质上就属于多路复用
.
下面我们就以这个餐厅的例子作为辅助,来一起从零到一地推演一遍 io 多路复用技术的形成思路.
1)单点阻塞 io 模型
在 linux
系统中,一切皆为文件,即一切事物都可以抽象化为一个文件句柄 file descriptor
,后续简称 fd
.
比如服务端希望接收来自客户端的连接,其中一个实现方式就是让线程 thread
以阻塞模式
对 socket fd
发起 accept
系统调用,这样当有连接到达时,thread 即可获取结果;当没有连接就绪事件时,thread 则会因 accept 操作而陷入阻塞态.
这样阻塞模式的好处就在于,thread 可以在所依赖事件未就绪
时,通过阻塞
的模式让渡出 cpu 执行权,在后续条件就绪
时再被唤醒
,这样就能做到忙闲有度,提高 cpu 的利用率.
这样表述完,大家未必能直接感受到该方式存在的局限,我们将其翻译成餐厅的例子——这就好比是餐厅为每名顾客提供一位专属服务员进行
一对一服务
的(单点),专属服务员只关注特定顾客的指令,在此之前完全处于沉默待命
状态(阻塞态),对其他客人的传唤也是充耳不闻.
而上述方式存在的不足之处就在于人力成本
. 我们一名服务员只能为一名顾客提供服务,做不到复用
,显得有点儿浪费
. 于是接下来演进的方向所需要围绕的目标就是——降本增效
.
2)多点轮询 + 非阻塞io 模型
要复用,就得做到让一个 thread
能同时监听多个 fd
,只要任意其一有就绪事件到达,就能被 thread 接收处理. 在此前提下,accept 的阻塞调用模式就需要被摒弃,否则一旦某个 fd 连接未就绪时,thread 就会立刻被 block 住,而无法兼顾到其他 fd 的情况.
于是我们可以令 thread 采用非阻塞
轮询的方式,一一对每个 fd 执行非阻塞模式
下的 accept
指令:此时倘若有就绪的连接,就能立即获得并做处理;若没有就绪事件,accept 也会立刻返回错误结果(EAGAIN)
,thread 可以选择忽略跳过,并立即开始下一次轮询行为.
上述方式倒是实现复用
了,但其背后存在什么问题呢?
同样用餐厅的例子加以说明. 餐厅规定一个服务员需要同时为多名指定的顾客提供服务,但这名服务员需要辗转腾挪各餐桌之间,
轮流不间断
地对每名客人进行主动问询
,即便得到回复基本都是否定的,但他也一刻都不允许停歇. 这样的操作模式下,即使客人不嫌烦,这个服务员自己也会被这种高强度的无效互动
行为给折腾到筋疲力尽
.
相信这样解释完,大家也能看出问题所在. 在这种模式下,thread 不间断地在对每个 fd 发起非阻塞系统调用
,倘若各 fd 都没有就绪事件,那么 thread 就只会一直持续着无意义的空转行为
,这无疑是一种对 cpu 资源的浪费.
3)io 多路复用
到了这里,大家可能就会问了,餐厅能否人性化一些,虽然我们希望让服务生与顾客之间建立一对多的服务关系,但是服务生可以基于顾客的主动招呼再采取响应,而在客人没有明确诉求
时,服务生可以小憩一会儿
,一方面养足体力,另一方面也避免对客人产生打扰.
是的,这个解决方案听起来似乎是顺理成章的,然而放到计算机领域可能就并非如此了. 用户态 thread 是一名
视听能力不好
的服务生,他无法同时精确接收到多名顾客的主动传唤,只能通过一一向顾客问询的方式(系统调用)来获取信息,这就是用户态视角
的局限性.
于是为了解决上述问题,io 多路复用技术
就应运而生了. 它能在单个指令层面支持让用户态 thread 同时对多个 fd 发起监听,调用模式还可以根据使用需要调整为非阻塞、阻塞或超时模式
.
在 linux 系统中,io 多路复用技术包括 select、poll、epoll
. 在随后的章节中我们将重点针对 epoll 展开介绍,并进一步揭示 golang io 模型底层对 epoll 的应用及改造.
1.2 epoll 核心知识
epoll
全称 EventPoll
,顾名思义,是一种以事件回调机制
实现的 io 多路复用技术.
epoll 是一个指令组,其中包含三个指令:
-
• epoll_create;
-
• epoll_ctl;
-
• epoll_wait.
以上述三个指令作为主线,我们通过流程串联的方式来揭示 epoll 底层实现原理.
1)epoll_create
extern int epoll_create (int __size) __THROW;
通过 epoll_create 可以开辟一片内核空间
用于承载 epoll 事件表
,在表中可以注册一系列关心的 fd
、相应的监听事件类型
以及回调时需要携带的数据
.
epoll 事件表是基于红黑树
实现的 key-value 有序表
,其中 key 是 fd,value 是监听事件类型以及使用方自定义拓展数据.
针对 epoll 事件表的数据结构选型,可能部分同学会在心中存有疑惑——为什么不基于
哈希表
而选择了红黑树
这种有序表结构呢?针对该问题,我在此仅提供一些个人观点:
•
内存连续性
:哈希表底层基于桶数组 + 链表实现时,桶数组部分在存储上需要为连续空间;而红黑树节点之间通过链表指针关联,可以是非连续空间,在空间分配上比较灵活•
操作性能
:虽然哈希表的时间复杂度是 O(1),但是常数系数很高;而红黑树虽为 O(logN),但在 N 不大的情况下(fd数量相对收敛),O(logN) 相对于O(1)差距并不大,此时哈希表的高常数系数反而会导致性能瓶颈
2)epoll_ctl
epoll_ctl 指令用于对 epoll 事件表内的 fd 执行变更操作
,进一可分为:
-
• EPOLL_CTL_ADD:
增加
fd 并注册监听事件类型 -
• EPOLL_CTL_MOD:
修改
fd 监听事件类型 -
• EPOLL_CTL_DEL:
删除
fd
extern int epoll_ctl (int __epfd, int __op, int __fd,
struct epoll_event *__event) __THROW;
由于 epoll 事件表是红黑树结构,所以上述操作时间复杂度都是 O(logN) 级别
3)epoll_wait
执行 epoll_wait 操作时,会传入一个固定容量的就绪事件列表
,当注册监听的 io 事件就绪
时,内核中会基于事件回调机制
将其添加到就绪事件列表
中并进行返回.
值得一提的是epoll_wait 操作还能够支持非阻塞模式、阻塞模式以及超时模式的多种调用方式
.
extern int epoll_wait (int __epfd, struct epoll_event *__events,
int __maxevents, int __timeout);
我们回头总结一下epoll
中存在的优势,这里主要与 select
指令进行对比(本文中没有对 select 展开介绍,这部分需要大家自行了解):
-
•
fd数量灵活
:epoll 事件表中 fd 数量上限灵活,由使用方在调用 epoll_create 操作时自行指定(而 select 可支持的fd 数量固定,灵活度不足) -
•
减少内核拷贝
:epoll_create 指令开辟内核空间后,epoll_ctl 注册到事件表中的 fd 能够多次 epoll_wait 操作复用,不需要重复执行将 fd 从用户态拷贝到内核态的操作(select 操作是一次性的,每起一轮操作都需要重新指定 fd 并将其拷贝到内核中) -
•
返回结果明确
:epoll_wait 直接将明确的就绪事件填充到使用方传入的就绪事件列表中,节省了使用方的检索成本(select 只返回就绪事件数量而不明确告知具体是哪些 fd 就绪,使用方还存在一次额外的检索判断成本)
凡事都需要辩证看待,在不同的条件与语境下,优劣势的地位可能会发生转换. 以 epoll 而言,其主要适用在监听
fd 基数较大且活跃度不高
的场景,这样epoll 事件表
的空间复用以及epoll_wait操作的精准返回才能体现出其优势
;反之,如果 fd数量不大且比较活跃时,反而适合 select 这样的简单指令,此时 epoll核心优势体现不充分,其底层红黑树这样的复杂结构实现反而徒增累赘.
2 go netpoll 原理
2.1 整体架构设计
在 linux 系统下,golang 底层依赖 epoll 作为核心基建来实现其 io 模型,但在此基础上,golang还设计了一套因地制宜的适配方案,通常被称作 golang netpoll
框架.
下面我们从流程拆解的方式,来对 netpoll 框架展开介绍:
-
•
poll_init
:底层调用epoll_create
指令,完成epoll 事件表的初始化(golang 进程中,通过 sync.Once保证 poll init 流程只会执行一次. ) -
•
poll_open
:首先构造与 fd 对应的 pollDesc实例,其中含有事件状态标识器 rg/wg,用于标识事件状态以及存储因poll_wait 而阻塞的 goutine(简称 g) 实例;接下来通过epoll_ctl(ADD)
操作,将 fd(key) 与 pollDesc(value) 注册到 epoll事件表中 -
•
poll_close
:执行epoll_ctl(DEL)
操作,将 pollDesc 对应 fd 从 epoll 事件表中移除 -
•
poll_wait
:当 g 依赖的某io 事件未就绪时,会通过gopark
操作,将 g 置为阻塞态,并将 g 实例存放在 pollDesc 的事件状态标识器 rg/wg 中 -
•
net_poll
:gmp 调度流程会轮询驱动 netpoll 流程,通常以非阻塞模式发起epoll_wait
指令,取出所有就绪的 pollDesc,通过事件标识器取得此前因 gopark 操作而陷入阻塞态的 g,返回给上游用于唤醒和调度(事实上,在 gc(垃圾回收 garbage collection) 和 sysmon 流程中也存在触发 netpoll 流程的入口,但属于支线内容,放在 3.8 小节中展开)
2.2 net server 流程设计
以启动 net server 的流程为例,来观察其底层与 netpoll 流程的依赖关系:
-
•
net.listen
:启动 server 前,通过 net.Listen 方法创建端口监听器 listener. 具体包括如下几个核心步骤:-
•
创建 socket
:通过 syscall socket 创建 socket fd,并执行 bind、listen 操作,完成 fd 与端口的绑定以及开启监听 -
•
执行 poll init 流程
:通过 epoll create 操作创建 epoll 事件表 -
•
执行 poll open流程
:将端口对应的 socket fd 通过 epoll ctl(ADD)操作注册到 epoll 事件表中,监听连接就绪事件
-
-
•
listener.Accept
:创建好 listener 后,通过 listener.Accept 接收到来的连接:-
•
轮询 + 非阻塞 accept
:轮询对 socket fd 调用非阻塞模式下的 accept 操作,获取到来的连接 -
•
执行 poll wait 流程
:如若连接未就绪,通过 gopark 操作将当前 g 阻塞,并挂载在 socket fd 对应 pollDesc 的读事件状态标识器 rg 中 -
•
执行 poll open 流程
:如若连接已到达,将 conn fd 通过 epoll ctl(ADD)操作注册到 epoll 事件表中,监听其读写就绪事件
-
-
•
conn.Read/Write
:通过 conn.Read/Write 方法实现数据的接收与传输-
•
轮询 + 非阻塞 read/write
:轮询以非阻塞模式对 conn fd 执行 read/write 操作,完成数据接收与传输 -
•
执行 poll wait 流程
:如果 conn fd 的读写条件未就绪,通过 gopark 操作将当前 goroutine 阻塞,并挂载在 conn fd 对应 pollDesc 的读/写事件标识器 rg/wg 中
-
-
•
conn.Close
:当连接已处理完毕时,