万字解析 golang netpoll 底层原理

  • 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:当连接已处理完毕时,

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值