Go 并发编程——网络轮询器

基于操作系统提供的 I/O 多路复用机制,如 Linux 的 epoll,将异步、非阻塞的网络 I/O 操作,封装成开发者熟悉的同步阻塞模型。

核心思想是:当 goroutine 执行网络I/O时,若操作未就绪,该 goroutine 会被挂起;一旦就绪,网络轮询器就会唤醒让其执行。增强了程序的并发处理能力。

1 设计原理

I/O多路复用

  • select 可同时监听最多 1024 个文件描述符的可读或可写状态
  • poll 和 select 类似,使用链表存储文件描述符,摆脱了 1024 的数量限制

多路复用函数会阻塞的监听一组文件描述符,当文件描述符的状态转变为可读或可写时,select 会返回就绪事件的个数,程序可以在输入的文件描述符中查找就绪的,然后执行对应操作。

select 的限制

  • 监听能力有限
  • 内存拷贝开销大,需要维护一个较大的数据结构存储文件描述符,该结构需要拷贝到内核中。
  • 时间复杂度 O(n),返回就绪事件个数后,需要遍历所有的文件描述符。

不同操作系统有自己的 I/O 多路复用函数,例如:epoll、kqueue,Go 都实现了对应的网络轮询器。如果目标平台是 Linux,就会根据文件中的 // +build linux 编译指令选择 netpoll_epoll.go

2 数据结构

pollDesc 是 Go 运行时用于管理一个文件描述符及其相关 IO 事件、等待的 goroutine 和 超时信息的内部结构体。

  • 当一个 goroutine 等待读事件时,其指针会被放入 rg 字段。
  • 当事件就绪时,pollDesc 的状态会更新,并唤醒 rgwg 中等待的 goroutine。

3 多路复用

网络轮询器实际上是对 I/O 多路复用技术的封装。

实现原理:

  • 网络轮询器的初始化
  • 向网络轮询器加入待监控的任务
  • 如何从网络轮询器获取触发的事件

初始化

netpollinit(),会调用 epoll_create 系统调用,创建一个 epoll 池

轮询事件

  1. 将对应的文件描述符和对应的 pollDesc 注册到 epoll 池中。
  2. 尝试进行非阻塞的读写操作。
  3. 若数据未就绪,会将goroutine指针存入 pollDesc 的 rg / wg 字段,goroutine 置于 _Gwaiting 状态,让出线程的控制权

M 去执行其他的 G

事件循环

netpoll 函数

  1. epoll 的 epollwait 系统调用,等待其中注册的 fd 发生I/O事件
  2. epollwait 返回时,会得到一组已就绪的事件列表
  3. 对于每一个就绪的事件,通过 epoll_event 中携带的数据找到对应的 pollDesc 结构体。
  4. 将 pollDesc 上的 goroutine 加入到一个返回列表中
  5. 最终返回一个可运行的 goroutine 列表给调度器

调度器获得这个列表后,会将其中处于 waiting 态的 gorotuine 置为 runnable,放入全局或本地队列。

4 小结

Go 语言的网络轮询器 (netpoller) 是一个精妙的设计,它通过以下几个层面,在系统底层和开发者接口之间构建了一座桥梁:

  1. 统一抽象:通过 pollDesc 结构体和 netpoll_*.go 多模块文件,为不同平台的 I/O 多路复用机制提供了统一的抽象。
  2. 协同调度:将 I/O 事件的就绪状态与 Goroutine 的调度生命周期紧密结合,实现了“阻塞”的 Goroutine 而不阻塞操作系统线程。
  3. 同步范式:将复杂的、基于回调的异步 I/O 操作,封装成了开发者熟悉的同步阻塞编程模型,极大地降低了编写高并发网络服务的门槛

5 扩展阅读:在内核视角下

5.1 核心数据结构

  • event poll:epoll_create 时生成的对象,包含两个重要成员
    • 红黑树:用于存储所有被监控的fd,插入、删除、修改均为 O(logN)
    • 就绪列表 Ready List:用于存储所有事件已经就绪的 fd 列表
  • epitem(epoll item):当 epoll_ctl 添加 fd 时,会创建一个 epitem 节点添加到红黑树上,记录 fd 的状态、关注的事件类型
  • 回调机制(回调函数):每个 epitem 都会与一个回调函数绑定,当 fd 对应的设备数据就绪时,内核会主动调用这个回调函数。

5.2 为什么 epoll 比 select/poll 更优越?(核心优势对比)

优势一:监听数量无上限

  • select:受限于 FD_SETSIZE(通常为 1024),修改需要重新编译内核。
  • poll:使用链表管理,虽无硬编码上限,但仍需遍历。
  • epoll上限为系统最大文件句柄数(如 /proc/sys/fs/file-max),理论上支持百万级并发。

优势二:高效的事件获取(时间复杂度 O(1) vs O(n))

这是性能分水岭:

  • select/poll:每次调用时,需要将用户态的 FD 集合全部拷贝到内核态,且内核需要遍历整个集合(O(n)),检查每个 FD 的状态。如果连接数(n)为 100 万,而只有 1 个活跃连接,依然要遍历 100 万次。
  • epollepoll_wait 直接返回活跃连接列表。它只处理活跃的 FD,效率与活跃连接数(m)成正比,即 O(m)。在海量空闲连接下,性能优势呈指数级放大。
优势三:零拷贝与内存映射(减少数据拷贝)
  • select/poll:每次 select 调用都会将整个 FD 集合从用户态拷贝到内核态,返回时再拷贝回去,频繁的上下文切换和拷贝开销极大。
  • epollepoll_ctl 只在添加/删除时拷贝一次 FD。同时,epoll 使用 mmap(内存映射)机制,在内核和用户空间共享一块内存,返回就绪事件时无需拷贝,直接映射读取。
优势四:边缘触发(ET)模式
  • select/poll 仅支持水平触发(LT),即只要数据没读完,每次调用都会通知你,容易导致频繁的系统调用。
  • epoll 引入了边缘触发(ET) 模式。它只告诉应用程序 “从无到有” 的那一瞬间。一旦通知,应用层必须一次将数据全部读取完毕,否则下次不再通知。这极大地减少了 epoll_wait 的调用次数,配合非阻塞 I/O 可以达到极高的吞吐量(Go 的 netpoller 默认采用 ET 模式)。
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐