等待
由 wait 引出,go runtime G-P-M 之间的一个交互。直到搞清楚操作系统层面的事件驱动
上面 👆 介绍了 go 服务中所有的 wait 操作。
核心:GMP 调度模型与gopark
所有的等待操作,最终几乎都会调用运行时的 gopark 函数。你可以把它理解为让当前goroutine(G)“停车”休眠。
- G (Goroutine): 用户态的轻量级线程,是执行任务的基本单位。
- M (Machine): 对应一个操作系统内核线程,真正负责在 CPU 上执行 G 的代码。
- P (Processor): 逻辑处理器,是 G 和 M 之间的桥梁,持有待运行的 G 队列。
当 G 等待时:gopark 会将当前 G 的状态从 _Grunning 改为 _Gwaiting,并将其从 M 上剥离,放入对应的等待队列(比如某个 channel 的发送队列)。然后,P 会从自己的本地队列中取出下一个 G,交给 M 继续执行。整个过程完全在用户态完成,不涉及昂贵的系统线程阻塞。
🛠️ 不同等待场景的底层实现
1. Channel 的发送/接收等待
- 底层结构:Channel 的底层是一个带锁的环形队列,并包含发送等待队列和接收等待队列。
- 等待过程:当 G 向一个满 channel 发送数据时,会调用
gopark,G 会被打包成sudog结构体放入该 channel 的发送等待队列。当有接收操作从 channel 取走数据后,运行时(runtime)会从发送等待队列中唤醒一个 G。 - 唤醒:被唤醒的 G 状态从
_Gwaiting改为_Grunnable,并被放回其原先的 P 的本地可运行队列,等待被调度执行。
2. 网络 I/O 等待 (netpoll)
这是 Go 高并发的关键。当 goroutine 进行网络读写(如net.Read)而数据未就绪时:
- 非阻塞模式:Go 将 socket 设置为非阻塞模式。
- 进入 netpoll:G 不会阻塞在系统调用上,而是调用
gopark,将 G 挂起。同时,这个 socket 的文件描述符(fd)会被注册到内核的多路复用器(如 epoll、kqueue、IOCP)中。 - 异步通知:调度器的一个特殊线程(或通过
sysmon)会定期或异步地检查(netpoll)这些 fd。当数据就绪时,内核通知 Go 运行时,运行时找到对应的 G,将其标记为可运行状态(goready),并放回 P 的队列。
3. time.Sleep 等待
- Go 运行时维护了一个最小四叉堆结构的定时器。
- 当调用
time.Sleep(d)时,会创建一个定时器并放入堆中,然后gopark当前 G。 - 一个独立的定时器处理线程(
timerproc) 会检查堆顶,在到期时唤醒对应的 G。
4. sync 包同步原语(Mutex, WaitGroup)
- 它们的等待也是基于运行时提供的
sema(信号量)机制。 - 当 G 请求锁失败时,会通过
sema进入休眠(gopark),并在锁释放时被唤醒。
5. select 语句
select的等待是上述机制的组合。编译器会将其优化为对一系列 channel 操作的非阻塞检查。- 如果所有 case 都未就绪,G 会依次将自己挂接到所有 channel 的等待队列上。当任一 case 就绪时,G 会被唤醒,并从其他队列中移除。
⚙️ 系统调用与阻塞
当 goroutine 执行一个真正的阻塞式系统调用(如文件 IO)时,情况稍有不同:
- P 与 M 解绑:系统调用会阻塞 M(内核线程)。Go 调度器感知到后,会将当前的P 从 M 上剥离。
- P 寻找新 M:解绑后的 P 会尝试找一个空闲的 M(或创建新 M)来执行队列中其他的 G。这保证了即使有 G 阻塞,其他 G 依然可以并发执行,有效利用了 CPU。
- 系统调用返回:当阻塞的系统调用完成后,阻塞的 G 会尝试找到一个 P 来继续执行。如果找不到,它会被放入全局队列,等待被其他 P 调度。
Go 能轻松支撑十万级并发连接,核心在于其 netpoll(网络轮询器)将阻塞的 socket I/O 完全转化为事件驱动模型,并与调度器深度集成,使得单个内核线程(M)能够高效管理数万个连接。它的设计哲学是 “绝不因等待网络 I/O 而阻塞内核线程”。
🔧 核心实现机制
1. 非阻塞 Socket 与多路复用器
这是所有高并发网络编程的基石。
-
设置非阻塞:Go 在创建监听或连接 socket 后,会立即调用
fcntl将其设置为 非阻塞模式。这意味着read/write等系统调用会立即返回,而不是“死等”。 -
统一抽象层:
netpoll在底层封装了不同操作系统的 I/O 多路复用机制:- Linux:
epoll - macOS/BSD:
kqueue - Windows:
IOCP
- Linux:
-
核心操作:当对一个 socket 进行读写但数据未就绪时,Go 不会让 goroutine 空等,而是执行上图中步骤 C 和 D:将当前 goroutine 挂起,并把 socket 的文件描述符(fd)及其关注的事件(读/写)注册到
epoll的监听列表中。
2. Goroutine 的挂起与就绪
这是 Go 相比其他语言(如使用回调的 Node.js)最优雅的地方。
- 挂起:当一个 goroutine 在 socket 上等待时,运行时调用
gopark,将其状态置为_Gwaiting,并记录下“这个 G 正在等哪个 fd”。 - 就绪与唤醒:当网络事件(如数据到达)发生后,
netpoll函数(会被定期或异步调用)查询epoll,获得就绪的 fd 列表。然后,它根据内部映射关系,找到正在等待这些 fd 的 goroutine,调用goready将它们的状态从_Gwaiting改为_Grunnable,并放回其关联的 P 的本地可运行队列。
3. 与调度器的深度集成
这是实现高性能的关键。netpoll 本身不执行任何用户代码,它只负责将网络 I/O 事件转换为可运行的 goroutine。有两种触发方式:
- 调度器驱动:在调度器的每一轮循环中,都可能快速检查一下
netpoll,看看是否有已经就绪的网络 I/O 事件对应的 G 可以立即执行。这保证了低延迟。 - 系统监控线程:
sysmon会定期调用netpoll,确保没有事件被遗漏,并处理一些长时间未处理的事件。
⚡ 如何支撑十万级并发?
1. 极低的内存开销
- 每个连接对应一个
netFD结构体和至少一个 goroutine。一个 goroutine 的初始栈仅 2KB,且能动态伸缩。十万个空闲连接的 goroutine 内存开销约 200MB,远小于传统的“线程池”模型(一个线程栈通常为 MB 级)。
2. 极少的上下文切换
- 所有等待和唤醒都发生在用户态,由 Go 调度器管理。只有真正需要执行时,goroutine 才会被调度到内核线程上运行。这避免了海量系统线程之间昂贵的上下文切换开销。
3. 基于事件的异步通知,基于阻塞的同步编程
- 开发者看到的是
conn.Read这种同步阻塞式的 API,易于理解和编写。 - 底层运行时通过
netpoll和epoll实现了完全的事件驱动和异步 I/O,结合 goroutine 调度,在保持开发体验简单的同时,达到了极高的性能。
4. 一个线程管理所有连接
- 理论上,一个
epoll实例可以管理数十万个 socket。这意味着 Go 程序可以用极少数的系统线程(M) 来轮询所有网络事件,然后将事件转化为 goroutine 的可执行状态。这是 C10K(万级并发)乃至 C1000K(百万级并发)问题的经典解决方案。
📖 内核触发核心概念详解
水平触发(LT)
- 通知逻辑:只要文件描述符处于就绪状态(例如,socket 接收缓冲区不为空),每次调用
epoll_wait都会返回该事件,反复·通知你。 - 行为类比:像一个水位传感器。只要水池里有水(状态就绪),它就持续亮灯(通知你)。你舀了一瓢水后,只要还有水,灯就一直亮着。
- 编程影响:编程模型更简单、更安全。因为即使你某次没有完全处理完数据(比如没读空缓冲区),下次调用
epoll_wait时它还会提醒你,数据不会丢失。 - 默认模式:
epoll的默认工作模式。
边缘触发(ET)
- 通知逻辑:只在文件描述符的就绪状态发生变化时通知一次。比如,从不可读到可读(空变非空)这个“边缘”时刻触发一次。之后,无论缓冲区是否还有数据,都不会再通知,除非再次发生新的状态变化(如又来新数据)。
- 行为类比:像一个边缘检测器。它只在水位从低到高(无到有)的瞬间发出一个脉冲信号。之后水位无论多高,它都不再发出信号,直到下次水位再次从低到高。
- 编程影响:
- 必须一次性处理完:当收到一个可读事件时,必须循环读取,直到
read返回EAGAIN或EWOULDBLOCK错误,确保将缓冲区清空。否则,剩余的数据将因无法再次收到通知而被“饿死”。 - 必须使用非阻塞 I/O:为了避免在最后一次读取时阻塞,ET 模式下的文件描述符必须设置为非阻塞模式。
- 性能潜力:减少了相同事件被重复通知的系统调用开销,理论上更高效,尤其是在高并发、活动连接密集的场景下。
- 必须一次性处理完:当收到一个可读事件时,必须循环读取,直到
| 特性 | 水平触发 (LT) | 边缘触发 (ET) |
|---|---|---|
| 通知频率 | 重复通知,直到状态改变 | 仅在状态变化时通知一次 |
| 编程复杂度 | 低,更简单安全 | 高,必须处理完所有数据 |
| I/O 模式 | 阻塞和非阻塞均可 | 必须使用非阻塞 I/O |
| 数据安全性 | 高,不易丢失事件 | 需小心,易“饿死”事件 |
| 性能潜力 | 足够应对绝大多数场景 | 在高压力、活动连接密集时可能更优 |
| Go 的选择 | ✅ 默认使用 | 未使用 |
操作系统通知 epoll 的机制
🔍 内核如何知道数据属于哪个 socket?
关键在于 TCP/IP 协议栈和 socket 的四元组映射。
-
数据包解析:当网卡收到一个数据包,它会通过 DMA 直接写入内核内存。内核的网络子系统开始处理:
- 解析以太网帧头,获取协议类型(如 IP)。
- 解析IP 头,获取源 IP、目标 IP 和协议(如 TCP)。
- 解析TCP 头,获取源端口和目标端口。
-
查找 socket:此时,内核得到了一个完整的连接四元组:
源IP:源端口 -> 目标IP:目标端口。在内核中,所有建立的 TCP 连接(对应socket结构体)都维护在一个全局的哈希表里。内核用这个四元组作为键,瞬间就能查找到唯一的、对应的内核socket结构。 -
数据入队:找到目标
socket后,数据被存入该socket内部的接收缓冲区。
🚨 epoll 如何被通知?核心:就绪队列
这是 epoll 高效的核心。epoll 在内核中不仅仅是一个“监听列表”,它由以下两部分关键数据结构组成:
- 兴趣列表:一个红黑树,高效存储着你通过
epoll_ctl添加的所有需要监听的 socket 文件描述符(fd)及其关注的事件(如EPOLLIN)。 - 就绪队列:一个双向链表。当 socket 的状态发生变化(如数据到达),内核协议栈在完成上述“数据入队”动作后,会立即检查这个 socket 是否在某个
epoll的兴趣列表中。
关键步骤在这里:
- 如果在,内核协议栈会回调
epoll模块的一个函数,将这个socket对应的结构体(epitem)插入到epoll的就绪队列中。 - 这个“回调-插入”操作是由内核协议栈发起的,完全是内核内部行为,不依赖于任何用户程序的轮询。这就是“事件驱动”的本质。
📞 用户程序如何知道?epoll_wait 的真相
当你的 Go 程序(实际上是运行时通过 netpoll)调用 epoll_wait 时,会发生以下情况:
- 检查就绪队列:
epoll_wait的系统调用会直接查看内核中那个epoll实例的就绪队列。 - 返回就绪事件:如果就绪队列不为空(说明有 socket 数据已到达),内核就把队列里的内容(就绪的 fd 及其事件)复制到用户空间提供的数组里,然后
epoll_wait立即返回这些事件的数量。 - 处理事件:你的程序遍历这些事件,对每个就绪的 fd 执行
read操作。由于数据早已在内核缓冲区,这个read只是将数据从内核复制到用户空间,不会阻塞。
为什么高效?
因为 epoll_wait 几乎不做查找工作。它返回时,内核已经帮你把所有准备好的 socket 整理好放在一个列表里了。程序拿到列表直接处理即可,时间复杂度是 O(1),与监听的 socket 总数无关。这与 select/poll 需要遍历所有被监听的 fd 来检查状态相比,有巨大的性能优势。
⚙️ 在 Go 中的体现
Go 的 netpoll 封装了 epoll_wait。运行时会在以下时机调用它:
- 调度时检查:在调度循环中,快速调用
netpoll看看是否有网络事件就绪的 goroutine 可以立即执行。 - 系统监控线程:
sysmon定期检查。
当 netpoll 通过 epoll_wait 拿到一批就绪的 socket 后,它会根据内部保存的映射关系,找到当初因读写这个 socket 而被挂起(gopark)的 goroutine,将其标记为可运行,重新放入调度队列。
💎 总结
- 精确归属:内核通过 TCP/IP 四元组 唯一确定数据包对应的 socket。
- 通知机制:数据到达后,内核协议栈主动将 socket 放入
epoll的就绪队列,这是真正的“事件通知”,而非轮询。 - 用户获取:
epoll_wait只是从就绪队列中取出结果,因此效率极高。 - Go 的整合:
netpoll利用此机制,将网络 I/O 事件高效地转换为 goroutine 的唤醒信号。
sysmon(系统监控器)是 Go 运行时中一个极其关键的后台独立监控线程。
调度时检查:相当于 p 在获取可运行的 G 时,如果找不到,就会检查一下 netpoll ,看是否有可用 G。