Go 的 pollDesc 不直接绑定回调函数,而是通过 goroutine 指针(rg/wg)隐式关联;epoll 就绪后由 runtime 唤醒对应 goroutine,而非调用函数指针;muduo 等库显式注册回调属用户态事件驱动,而 Go 为实现同步语义由运行时自动调度。

Go netpoll 中 pollDesc 如何绑定回调函数
Go 的 pollDesc 本身不直接存储用户回调函数,它只负责把 fd 和运行时调度挂钩。真正承载业务逻辑的回调(比如 net.Conn.Read 阻塞后该唤醒谁),是靠 goroutine 的栈上下文 + runtime.g 指针隐式关联的。
当你调用 conn.Read(),底层会走到 internal/poll.(*FD).Read(),最终触发 runtime.netpollblock() —— 此时当前 goroutine 被挂起,它的 g 结构体指针被写入 pollDesc 的 rg/wg 字段(读/写等待队列),同时该 pollDesc 已通过 runtime.poll_runtime_pollOpen() 注册进 epoll 实例。
-
pollDesc.rg存的是等待读就绪的 goroutine 指针;wg同理 - epoll_wait 返回就绪 fd 后,
runtime.netpoll()扫描所有就绪pollDesc,根据事件类型(EPOLLIN/EPOLLOUT)唤醒对应rg或wg - 唤醒动作不是调用某个函数指针,而是将 goroutine 标记为可运行并交还给调度器
为什么 Go 不像 muduo 那样暴露 Channel.setReadCallback
muduo 的 Channel::setReadCallback() 是显式注册 C++ 函数对象,属于用户态事件驱动模型;而 Go 的设计目标是让网络操作“看起来同步”,所以回调由 runtime 自动注入,开发者无需、也不能手动设置。
这种差异源于编程模型根本不同:
- muduo:用户控制 EventLoop 线程,回调在用户线程内执行,
readCallback_是用户传入的std::function - Go:用户调用阻塞式 IO,runtime 在 epoll 就绪后自动唤醒对应 goroutine,回调逻辑就是被挂起的那行
Read()后续代码 - 强行在 Go 层模拟
setReadCallback会导致 goroutine 泄漏或竞态 —— 因为无法安全接管 runtime 对pollDesc的生命周期管理
第三方库(如 gnet)如何绕过 runtime 实现显式回调
像 gnet 这类高性能网络库,选择绕开 net.Conn 抽象,直接操作 fd 并自己维护 epoll 实例(netpoll.Poller),从而获得类似 muduo 的显式回调能力。
关键点在于:
- 它用
syscall.EpollCreate1(0)创建独立 epoll fd,不依赖 runtime 的netpoll - 每个连接封装为
conn结构体,内部保存onData、onClosed等回调函数指针 - EventLoop 线程调用
epoll_wait()后,遍历就绪 fd,查到对应conn,再调用其onData() - 此时回调发生在用户定义的 eventloop 线程中,而非 runtime 调度的任意 P 上
代价是失去 Go 原生 net 包的超时控制、TLS 支持和 GC 友好性,必须自己处理 fd 关闭、buffer 管理和错误传播。
容易忽略的陷阱:pollDesc 生命周期与 fd 复用
很多人误以为只要 fd 相同,pollDesc 就复用 —— 实际上 pollDesc 是和 *FD 绑定的,而 *FD 在 Close() 后会被 runtime.poll_runtime_pollClose() 显式注销。
但若你用 syscall.Dup() 复制 fd,新 fd 不会自动关联已有 pollDesc;反之,若未正确调用 Close(),旧 pollDesc 可能残留,导致 epoll_ctl(EPOLL_CTL_DEL) 失败或唤醒已销毁的 goroutine。
真正安全的 fd 复用方式只有两种:
- 用
net.FileConn()导出文件描述符后,立即关闭原net.Listener或net.Conn - 在自建 Poller 场景下,确保每个 fd 仅注册一次,且
remove操作与close严格配对
runtime 的 pollDesc 不提供 public API 来手动管理,任何试图绕过 net 包直接操作它的行为,都会在 GC 或调度变更时失效。

















