本篇我们将讨论 Go 语言底层的网络编程原理,本篇将揭示 Go 语言是如何做到同步的代码,异步的执行。
特此声明,本篇是笔者基于 Go 1.25.3 版本源码、并与 Google Gemini 3Pro 共创所作,非常庆幸在当今 AI 时代下获取知识已是如此便利,且也为学习者从第一性原理理解所学知识大大降低了门槛。不过本篇的篇章安排和叙述逻辑,均由笔者把控和审阅,欢迎放心阅读。
在开启本章之前,你最好对下列知识有一点的了解:
1. 宏观概述
要从根本上理解 Go 的网络编程模型,我们需要剥离掉语法糖,回到计算机体系结构和操作系统原理的 第一性原理:如何高效地处理 CPU 计算与 I/O 等待之间的速度差异?
Go 的网络模型之所以强大,是因为它在一个极其优雅的抽象层(Goroutine)下,完美隐藏了复杂的异步 I/O 细节。
接下来让我们尝试由表及里,从编程模型到内核实现,分三个层级来剖析。
2.1 第一层:编程模型 —— 同步的代码,异步的执行
在 Go 1.25 中,网络编程的依然遵循着 Go 诞生之初的哲学:Goroutine-per-connection。开发者编写的是标准的 同步阻塞式(Synchronous Blocking) 代码。
1 | // 开发者视角:逻辑是线性的 |
如果按照 C 语言或早期 Java
的传统线程模型,上述代码意味着每个连接需要一个 OS
线程。但线程太重了(栈内存约 1MB - 8MB,上下文切换成本高)。但是在 Go
语言中,当你调用 c.Read 时,当前的 Goroutine
确实"暂停"了,但底层的操作系统线程(M)并没有阻塞,而是去干别的活了。这样开发者拥有了编写简单线性逻辑的权利,同时享受了非阻塞
I/O 的高性能。
2.2 第二层:系统调用层 —— 非阻塞 I/O 的伪装
为了实现上述同步阻塞的假象,Go 在底层实际上使用的是 非阻塞 I/O(Non-blocking I/O)。
在 Go 1.25 的 net 包内部,当你创建一个 socket 时,Go
Runtime 会通过系统调用(如 Linux 下的 socket +
fcntl)显式地将该文件描述符(File Descriptor, FD)设置为
Non-blocking 模式。
当你调用 conn.Read() 时,Go 底层实际执行了以下逻辑:
- 直接尝试读取: 直接对 FD 发起
read系统调用。 - EAGAIN 错误: 绝大多数时候,内核缓冲区是空的。因为
FD 是非阻塞的,操作系统不会让线程睡眠,而是立刻返回一个
EAGAIN(或EWOULDBLOCK)错误,表示现在没数据,别堵在这。 - 捕获错误并挂起: Go
的网络库捕获到这个错误,意识到"现在读不到数据"。于是,它不会让代码报错,而是通过
Runtime 调度器将当前的 Goroutine 状态置为
Gwaiting(等待中),并将该 Goroutine 移出 CPU 执行队列。
2.3 第三层:Runtime 核心 —— Netpoller 与 GMP 的联动
这是 Go 网络模型的心脏。Go 引入了一个名为 Netpoller 的组件,它是 Go Runtime 与操作系统 I/O 多路复用机制(I/O Multiplexing)之间的桥梁。
Netpoller 并不是一个一直运行的独立线程,而是 Runtime 中的一组函数逻辑。它封装了不同操作系统的多路复用技术:
- Linux:
epoll - macOS/FreeBSD:
kqueue - Windows:
IOCP
在 Linux 的 epoll 中,包含 3 个核心函数:
- 新建多路复用器:
epoll_create() - 插入监听事件:
epoll_ctl() - 查询发生了什么事件:
epoll_wait()
Go 的 Netpoller 提供了对各个平台多路复用器的抽象和适配:
netpollinit->epoll_createnetpollopen->epoll_ctlnetpoll->epoll_wait
让我们回到刚才 conn.Read() 返回 EAGAIN
的时刻:
- 注册(Register): 当前运行的 Goroutine (G)
在被挂起前,会将自己的 FD 和期望的事件(如可读)注册到 Netpoller
中。本质上是调用了
epoll_ctl将 FD 加入监听列表。 - 让出(Park): G 停止运行,M(系统线程)现在空闲了。M 会根据 GMP 调度模型,从 P(处理器)的本地队列中抓取下一个可运行的 G 去执行。
- 监控(Poll): 什么时候唤醒原来的 G?
- 被动触发: 当系统监控线程
sysmon运行,或者调度器发现没有 G 可运行时,会调用runtime.netpoll。 - 底层机制:
runtime.netpoll内部调用epoll_wait,询问操作系统我关注的那些 FD 有哪些数据到了。
- 被动触发: 当系统监控线程
- 唤醒(Ready): 操作系统返回就绪的 FD
列表。Netpoller 根据 FD 找到当初阻塞在上面的 Goroutine,将其状态改为
Grunnable(可运行),并将其注入到当前 P 的本地队列或全局队列中。 - 执行: 在下一轮调度中,原来的 G 被 M 拿到,继续执行
conn.Read()后面的代码。
2.4 小节
如果用文字总结这套机制的精髓,可以概括为:用户态的阻塞,内核态的非阻塞;线性的逻辑,事件驱动的内核。
完整的数据流向图解:
- User:
conn.Read(buf) - Go Runtime (Poll):
syscall.Read(fd)-> 返回EAGAIN - Go Scheduler:
- 调用
netpollOpen(注册 epoll) - 调用
gopark(挂起当前 G,状态 -> Gwaiting) - 线程 M 切换去执行其他 G
- 调用
- --- 时间流逝,网络包到达网卡 ---
- OS Kernel: 中断处理,数据拷贝到内核缓冲区,FD 变为 Readable。
- Go Runtime (Monitor/Schedule):
sysmon或 调度器执行netpoll(epoll_wait)- 发现 FD 就绪
- 调用
goready(找到对应的 G,状态 -> Grunnable)
- Go Scheduler: G 被放入队列,最终被 M 执行。
- User:
conn.Read从挂起处恢复,再次执行syscall.Read,成功读取数据。
sequenceDiagram
autonumber
participant G as User Goroutine (G)
participant NP as Netpoller (Internal)
participant Sched as Go Scheduler (M/P)
participant OS as OS Kernel (epoll/IO)
Note over G, Sched: 阶段一:发起读请求 (User Space)
G->>NP: 1. conn.Read(buf)
activate G
activate NP
NP->>OS: 2. syscall.Read(fd) (非阻塞)
OS-->>NP: 3. 返回 EAGAIN (无数据)
Note right of NP: 判定需要挂起
NP->>OS: 4. netpollOpen / epoll_ctl
(注册 FD 到 epoll 实例)
NP->>Sched: 5. gopark (请求挂起 G)
deactivate NP
deactivate G
activate Sched
Note over G: 状态: Grunning -> Gwaiting
Note over Sched: 6. 线程 M 解绑当前 G
M 切换去执行其他 G
deactivate Sched
Note over G, OS: 阶段二:异步等待 (Kernel Space)
G-x G: (Goroutine 暂停,不消耗 CPU)
Note over OS: ... 时间流逝 ...
Note over OS: 7. 网络包到达 -> 中断处理
数据拷入内核缓冲区 -> FD Readable
Note over G, OS: 阶段三:唤醒与执行 (Runtime Monitor)
loop Sysmon 或 调度器检查
Sched->>OS: 8. netpoll (epoll_wait)
OS-->>Sched: 9. 返回就绪 FD 列表
end
activate Sched
Sched->>Sched: 根据 FD 找到对应的 G
Sched->>Sched: 10. goready(G)
Note over G: 状态: Gwaiting -> Grunnable
Sched-->>G: 11. G 被放入本地/全局队列
最终被 M 捕获并执行
deactivate Sched
activate G
Note over G: 从 gopark 处恢复代码执行
G->>NP: 12. 再次调用 internal read
activate NP
NP->>OS: 13. syscall.Read(fd)
OS-->>NP: 14. 返回实际数据 (Data)
NP-->>G: 15. 返回 n, err
deactivate NP
deactivate G
2. 源码剖析
在对 Go 的网络编程模型有了一定的宏观了解后,本篇我们将深入底层源码来剖析 Go Runtime 是如何实现上面这些能力的。
2.1 Go 的系统调用的封装
在 Go1.16 左右的版本(笔者之前研究的是 Go.16
版本,对其他版本可能不太熟悉),Go 对
epoll_create、epoll_ctl
等系统调用,每个都有单独的汇编实现,如下:
1 | // int32 runtime·epollcreate(int32 size); |
但是当最近笔者在阅读 Go 1.25 版本的源码时,发现 Go 已经统一了系统调用的入口了,如 linux amd64 平台上,Go 将系统调用统一封装在 internal/runtime/syscall/asm_linux_amd64.s,代码如下:
1 | // func Syscall6(num, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2, errno uintptr) |
我们不用太纠结它的具体实现,通过注释,我们可以知道这段汇编对应的就是
Go 里面的 Syscall6,具体位于 runtime/syscall/syscall_linux.go#L17:
1 | // Syscall6 calls system call number 'num' with arguments a1-6. |
它的具体运用在 syscall/syscall_linux.go#L95:
1 | //go:uintptrkeepalive |
这个时候 epollo_create、epollo_wait 和
epollo_ctl 就很好实现了:
1 | func EpollCreate1(flags int32) (fd int32, errno uintptr) { |
2.2 Go 对 Epoll 的抽象 - network poller
本文仅介绍针对 Linux AMD64 的实现。
Go NetWork Poll 是对各个平台多路复用器的抽象和适配:
netpollinit->epoll_createnetpollopen->epoll_ctlnetpoll->epoll_wait
2.1.1 netpollinit -> epoll_create
系统指令:internal/runtime/syscall/defs_linux_amd64.go
1 | SYS_EPOLL_CREATE1 = 291 |
Go 中的声明:EpolloCreate1
1 | func EpollCreate1(flags int32) (fd int32, errno uintptr) { |
_EPOLL_CLOEXEC:创建的 epfd 会设置FD_CLOEXEC,它是一个 fd 的标识说明,用来设置文件的 close-on-exec 状态的。当 close-on-exec 状态为 0 时,调用 exec 时,fd 不会被关闭;非零状态时则会被关闭,这样做可以防止 fd 泄露给执行 exec 后的进程。
针对 Linux 的实现:runtime/netpoll_epoll.go
- 创建 epoll 实例:创建 Linux 的 I/O 多路复用器,用于同时监控成千上万个网络连接。
- 创建 eventfd:创建一个特殊的文件描述符,用于唤醒阻塞线程。
- 将 eventfd 注册到 epoll:这样既能等网络事件,也能被主动唤醒。
1 | // 新建多路复用器,这个函数在 Go 程序启动时被调用一次,用于初始化 Linux 平台的网络轮询器(netpoller) |
2.1.2 netpollopen -> epoll_ctl
系统指令:internal/runtime/syscall/defs_linux_amd64.go
1 | SYS_EPOLL_CTL = 233 |
Go 中的声明:EpolloCtl
1 | func EpollCtl(epfd, op, fd int32, event *EpollEvent) (errno uintptr) { |
epfd:epoll_create 函数返回的文件描述符,用于标识内核中的 epoll 实例op:对 fd 文件描述符的操作类型:EPOLL_CTL_ADD:向 interest list 添加一个需要监视的描述符EPOLL_CTL_DEL:向 interest list 删除一个描述符EPOLL_CTL_MOD:修改 interst list 中的一个描述符
fd:需要被操作的文件描述符event:一个指向名为 epoll_event 的结构的指针,它存储了我们实际要监视的 fd 的事件EPOLLIN:表示对应的文件描述符可以读。EPOLLOUT:表示对应的文件描述符可以写。EPOLLERR:表示对应的文件描述符发生错误。EPOLLHUP:表示对应的文件描述符被挂断。EPOLLRDHUP:表示对端关闭连接或半关闭写端。EPOLLET: 将 epoll 设为边缘触发(Edge Triggered)模式,相对于水平触发(Level Triggered)来说的。
针对 Linux 的实现:runtime/netpoll_epoll.go
- 传入一个 socket 的 fd,和 pollDesc 指针,pollDesc 是 Go 中对 socket 的抽象。pollDesc 中记录了 socket 的详细信息,以及哪个协程休眠在等待此 socket;
- 将 socket 的可读、可写、断开事件注册到 epoll 中;
- 将 epoll 设置为 ET 模式。
1 | // 将 fd 的四个事件 syscall.EPOLLIN | syscall.EPOLLOUT | syscall.EPOLLRDHUP | syscall.EPOLLET 注册到 epfd 上 |
2.1.3 netpoll -> epoll_wait
系统指令:internal/runtime/syscall/defs_linux_amd64.go
1 | SYS_EPOLL_PWAIT = 281 |
Go 中的声明:EpolloWait
1 | func EpollWait(epfd int32, events []EpollEvent, maxev, waitms int32) (n int32, errno uintptr) { |
epfd:epoll_create 函数返回的文件描述符,用于标识内核中的 epoll 实例。ev已经分配好的 epoll_event 结构体数组,epoll 会把发生的事件存入 events 中。maxev:告诉内核最多返回的事件数量有多大,必须大于 0。waitms:超时时间,-1 表示 epoll 将无限制等待下去。
针对 Linux 的实现:runtime/netpoll_epoll.go
- 根据 delay 确定要轮询多久;
- 创建一个长度为 128 的事件列表;
- 调用系统底层的 epollwait,查询有多少事件发生了;
- 新建一个协程列表;
- 遍历事件列表;
- 获取 go 中对 fd 的抽象结构体的值 pd;
- 将 pd 中的 g 取出来加入到 toRun 列表中;
- 返回可执行的 goroutine 列表。
1 | // 注意返回的是一个可执行的 Goroutine 列表 |
netpollready() 表示 pd 底层的 fd 已经可以进行 I/O
操作了:
1 | func netpollready(toRun *gList, pd *pollDesc, mode int32) int32 { |
2.1.4 谁在调用 netpoll()?
2.1.4.1 垃圾回收循环
runtime/proc.go 中的 startTheWorldWithSema()
会调用 netpoll()
1 | func startTheWorldWithSema(emitTraceEvent bool) int64 { |
runtime/mgc.go 中的 gcStart()
会调用 startTheWorldWithSema(),而 gcStart()
又会被我们的 g0 协程一直循环执行。
1 | // gcStart starts the GC. |
而且 g0 协程在循环 gc 的时候,顺带执行了
netpoll() 来检查是否有事件发生。
2.1.4.2 协程调度
在 深入浅出 Go
语言的 GPM 模型(Go1.21) 中,我们提到了 Go 协程调度最核心的函数
schedule():
1 | func schedule() { |
协程调度的时候会去执行 findRunnable() 寻找可以运行的
Goroutine,这里面也会调用 netpoll()
检查是否有网络事件发生。
1 | func findRunnable() (gp *g, inheritTime, tryWakeP bool) { |
2.2 network poll 对 socket 的抽象 —— pollDesc
Go 的 netpoller 需要进一步对 socket 进行抽象,是为了解决 2 个核心问题:
- 状态同步问题:如何让 Go 调度器(用户态)和操作系统内核(内核态)共享同一个 socket 的状态(是读还是写?是谁在等?)。
- 生命周期错位问题:操作系统内核的通知是异步的,可能在 Go 已经关闭或复用了文件描述符(FD)之后,内核才发来一个旧的就绪通知。这会导致严重的内存腐坏或逻辑错误。
为此,Go 定义了两个数据结构:pollDesc 和
pollCache。我们将 pollDesc
看作"桥梁",将 pollCache
看作"安全区"。
2.2.1 pollDesc
pollDesc 是 Go 运行时为每个网络文件描述符(socket)创建的轮询描述符对象,用于管理该 fd 的异步 I/O 状态。
1 | // Network poller descriptor. |
核心字段解析:
fd:这是最原始的操作系统文件描述符(例如 Linux 上的int类型的 5, 6 等)。它是连接到epoll/kqueue的物理句柄。rg(Read Group) /wg(Write Group):这是最重要的字段。 它们实现了无锁(Lock-free)的状态流转。它们不仅仅存储 Goroutine 的指针(*g),还是一个多状态的原子变量:0 (pdNil): 没有任何 Goroutine 在等待。1 (pdReady): I/O 已经就绪(网卡有数据了),不需要等待,直接读。2 (pdWait): 正在准备挂起,作为中间状态。> 2 (G Pointer): 存储了正在阻塞等待的 Goroutine 的内存地址。
当
epoll_wait返回就绪事件时,Netpoller 会通过rg或wg里的地址找到那个 G,然后调用goready(G)唤醒它。超时管理(
rt、wt、rd、wd):管理读写操作的 deadline。Go 的SetReadDeadline和SetWriteDeadline就是在这里实现的。每个网络连接自带两个定时器。如果超时触发,定时器回调会强制将rg或wg状态置为错误,并唤醒 G。G 醒来后发现是超时导致的唤醒,于是返回timeout error。防止过时通知(
fdseq、rseq、wseq):通过序列号防止在fd复用后收到旧的就绪通知。link:指向下一个空闲的pollDesc,后面会详细分析。
2.2.1 pollCache
网络程序中会频繁地打开和关闭连接,每个连接都需要一个
pollDesc。如果每次都分配新对象并最终让 GC
回收,会带来巨大的性能开销。pollCache
通过对象池模式复用
pollDesc,大幅提升性能。用一句话概述就是:pollCache
是一个专门用于分配 pollDesc 的链表式缓存池。
1 | type pollCache struct { |
相信不少读者都会注意到注释中的这句话:
1 | // PollDesc objects must be type-stable, |
为什么呢?想象下面这样一个流程:
- 你打开了一个 Socket,FD 为 10。
- Go 将 FD 10 注册给
epoll,由于内核并没有给我们回调函数,epoll内部通常存储的是pollDesc的内存地址作为user_data。 - 你关闭了连接。Go 回收了 FD 10,也释放了
pollDesc的内存。 - 危险时刻:假设这块内存立刻被 Go 的 GC 分配给了一个
string或者是其他对象。 - 延迟通知:此时,内核里积压的一个关于 FD 10
的"可读"事件突然触发了(或者是一个极端的竞态条件)。
epoll返回了那个旧的pollDesc内存地址,告诉 Runtime 这里"可读"。 - 崩溃:Runtime 以为这还是个
pollDesc,试图去修改它的rg字段。但这块内存现在存的是一个字符串!结果:内存腐坏(Memory Corruption),程序直接崩溃且极难调试。
解决方案:Type-Stable Memory(类型稳定内存)
pollCache
保证了通过它分配出去的内存块,即使被释放回收了,也永远只能作为
pollDesc 存在,绝不会被 GC 挪作他用。
sys.NotInHeap: 标记这个结构体不在普通的 GC 堆上管理,而是手动管理的(pollDesc的第一个字段)。- 链表管理:
lock: 保护链表。first: 指向链表头部的空闲pollDesc。- 分配: 从
first取一个。如果链表空,向 OS 申请一大块内存(4KB),切分成多个pollDesc串到链表上。 - 释放: 并不是真的
free掉内存,而是把它放回first链表头,留给下一个连接复用。
这就保证了:即使内核发来一个过期的通知,Runtime
访问的那个内存地址依然是一个合法的 pollDesc
结构体(虽然它可能不再关联任何活跃连接),最多就是读到一个无效状态,而不会导致内存越界或类型错误。
2.2.1.1 分配 alloc
1 | func (c *pollCache) alloc() *pollDesc { |
2.2.1.2 回收 free
1 | func (c *pollCache) free(pd *pollDesc) { |
即便内存类型安全了,我们还面临逻辑上的 ABA 问题:
- Goroutine A 使用 FD 10 (
pollDesc地址 0x123)。 - A 关闭连接,释放 FD 10,释放
pollDesc(0x123 返回缓存池)。 - Goroutine B 建立新连接,刚好系统又分配了 FD 10,且
pollCache又把 0x123 分配给了 B。 - 此时,内核发来了 A 时代的 FD 10 的就绪事件。
- Runtime 拿着 0x123,以为是 B 的数据来了,错误地唤醒了 B(或者处理了错误的数据)。
fdseq 的作用: 每次
pollDesc 被复用(从缓存池拿出来)时,fdseq
都会自增。
- 当注册
epoll时,Go 会把当前的fdseq记录在某个地方(或者在检查时比对)。 - 当事件回来时,Runtime
会检查:
Event.seq == pollDesc.seq? - 如果不相等,说明是个过期事件,直接忽略,不进行唤醒操作。
2.2.3 总结
pollDesc(State): 使用atomic.Uintptr存储 Goroutine 指针,实现了用户态 G 与内核态 I/O 事件的高效无锁传递。pollCache(Memory): 使用类型稳定内存(Type-Stable Memory),从物理内存布局的层面消灭了异步 I/O 可能导致的内存腐坏风险。fdseq(Logic): 使用版本号机制,解决了资源复用带来的逻辑混淆(ABA 问题)。
这就是为什么 Go 的网络库在高并发、高动态(大量连接建立和断开)场景下,依然稳如磐石的底层原因。
2.3 network poller 工作细节
2.3.1 初始化 poll_runtime_pollServerInit
通过原子操作 & 双重检查来执行一次
netpollinit(),创建一个 epoll。
1 | //go:linkname poll_runtime_pollServerInit internal/poll.runtime_pollServerInit |
补充:go:linkname
补充:go:linkname
The //go:linkname directive instructs the compiler to use“importpath.name” as the object file symbol name for the variable orfunction declared as “localname” in the source code. Because thisdirective can subvert the type system and package modularity, it is onlyenabled in files that have imported “unsafe”.
//go:linkname的目的是告诉编译器使用importpath.name来对本来不可导出的(localname)函数或者变量实现导出功能。由于这种方法是破坏了Go语言的模块化规则的,所以必须在导入了"unsafe"包的情况下使用。
即:
由于 Go语法规则限制,小写字母开头的函数或者变量是本模块私有的,不可被包外的代码访问;但是如果必须要能被外部模块访问到,又要限制为私有方法呢?只能在编译器上做手脚,通过一个特殊的标记 来实现这种功能。
具体到上面的例子:
1 | //go:linkname poll_runtime_pollServerInit internal/poll.runtime_pollServerInit |
- 表示调用
internal/poll.runtime_pollServerInit相当于调用当前的poll_runtime_pollServerInit。
2.3.2 新增监听 poll_runtime_pollOpen
- 在 pollcache 链表中分配一个 pollDesc,用来描述要新增将它的 socket;
- 初始化 pollDesc,主要是将 rg、wg 置为 0;
- 调用 netpollopen,将底层 socket 及其读、写和断开事件注册到 epoll 上;
1 | //go:linkname poll_runtime_pollOpen internal/poll.runtime_pollOpen |
2.3.3 判断是否就绪 poll_runtime_pollWait
- 协程要对 socket 进行 read 或者 write 的时候,底层就会调用 poll_runtime_pollWait;
- 该方法循环调用 netpollblock(),直到 netpollblock() 返回 true,表明 rg 或 wg 已经置为 pdReady 了,可以进行读或者写了。
- netpollblock():
- 根据 mode,取出 rg 或者 wg,命名为 gpp;
- 如果 gpp 是 pdReady,直接返回 true,否则,置为 pdWait,返回 false。
1 | func (pd *pollDesc) wait(mode int, isFile bool) error { |
1 | func runtime_pollWait(ctx uintptr, mode int) int |
1 | //go:linkname poll_runtime_pollWait internal/poll.runtime_pollWait |
1 | // returns true if IO is ready, or false if timed out or closed |
1 | func netpollblockcommit(gp *g, gpp unsafe.Pointer) bool { |
2.3.4 调度协程去读写 socket
socket 已经可以读写:
runtime 循环调用 netpoll() 方法;
前面分析过了,是 g0 协程在 gc 的时候顺便调用了 netpoll。
发现 socket 可读写时,给对应的 rg 或 wg 置为 pdReady(1);
协程调用 poll_runtime_pollWait() 判断 socket 是否就绪;判断 rg 或者 wg 已经置为 pdReady(1),那就返回 0;
runtime 就知道 socket 可以操作了。
socket 暂时不可读写:
- runtime 循环调用 netpoll() 方法;
- netpoll 中没有监听到任何事件,执行不到 netpollready,没有对 pd 做任何改变;
- 协程调用 poll_runtime_pollWait() 判断
socket 是否就绪:
- 判断 rg 或 wg 还是 pdNil(0),就将 rg 或者 wg 置为 pdWait(2);
- 调用 gopark 将协程进行休眠等待;
- 然后再进入 netpollblockcommit 将 rg 或者 wg 置为 G pointer;
- 假如 runtime 后面再循环调用 netpoll() 方法;
- 发现 socket 可读写时,进入 netpollready 再检查对应的 rg 或者 wg;
- netpollready 再进入 netpollunblock,它会检查 rg 或者 wg;
- 若为 G pointer,那么就将 rg 或者 wg 置为 pdReady,然后返回协程地址给 runtime;
- runtime 就会去调度对应协程进行 socket 的读写操作。
读写后都会再将 rg 或者 wg 置为 nil
2.3.5 总结

Go 的网络操作底层为 阻塞模型(协程调度) + 多路复用(系统底层),具体情况为:
- BIO:go
协程从网络读取数据,读取失败并且返回
syscall.EAGAIN时,依次调用waitRead->runtime_pollWait->poll_runtime_pollWait->netpollblock->gopark将当前协程挂起。 - NIO:runtime 的 g0 协程在 gc 的时候会顺便调用
netpoll()检查 socket 事件是否发生,当 socket 可操作的时候,重新唤醒对应协程,进行调度。
具体细节为:
- runtime
- runtime 会一直循环去检查 socket 的可读写状态 ——
netpoll() - 然后再看是否有协程在等待对应的 socket:——
netpollready()- 没有,那就单纯记录 pollDesc;
- 有那就唤醒协程,将 g 加入 toRun 列表,进行调度 ——
netpollunblock()
- runtime 会一直循环去检查 socket 的可读写状态 ——
- goroutine
- 表明想要操作 socket ——
poll_runtime_pollWait(pd,mode) - 循环检查自己关心的 socket 是否可操作 ——
netpollblock()- 可以操作,goroutine 就会对 socket 进行读或写操作了;
- 不可操作:
- 就将自己休眠 ——
gopark(); - 将 rg 或 wg 置为自己的地址 ——
netpollblockcommit()
- 就将自己休眠 ——
- 表明想要操作 socket ——
2.4 net 包
net 包是 go 原生的网络包;
net 包实现了 TCP、UDP、HTTP 等网络操作;
使用
net.Listen()可以得到LISTEN状态的 socket —— listener;使用
listener.Accept()可以得到ESTABLISHED状态的 socket —— conn;conn.Read() / Writer()可以进行读写 socket 的操作;network poll 作为上述功能的底层支撑;
本文仅介绍 TCP 相关的部分。
2.4.1 net.netFD
netFD 是 Go 中 net 包对 socket
之类的网络文件描述符的抽象。
1 | // Network file descriptor. |

2.4.2 net.Listen() Listenter
1 | func Listen(network, address string) (Listener, error) { |
- 新建 socket,并执行 bind 操作;
- 新建一个 netFD,它是 net 包对 socket 的详情描述;
- 返回一个 TCPListener 对象,底层是调用了 runtime_pollOpen 方法,将 TCPListener 的 FD 信息加入监听。TCPListener 对象本质是一个 LISTEN 状态的 socket。

2.4.3 listener.Accept()
1 | // Accept implements the Accept method in the [Listener] interface; it |
- 调用 tcpListener 的 accept,本质上就是调用处于 LISTEN 状态的 socket 的 accept 方法,看看有无新的连接;
- 如果失败,休眠等待新的连接,底层调用了 runtime_pollWait;
- 如果有新的连接,那就包装成一个新的 socket,最后返回为一个 TCPConn 变量,底层是调用了 runtime_pollOpen 方法,将 TCPConn 的 FD 信息加入监听。TCPConn 对象本质是一个 ESTABLISHED 状态的 socket。

2.4.4 conn.Read() / conn.Write()
这两个方法原理差不多,下面以 Read() 为例。
1 | // Read implements the Conn Read method. |
1 | // Read implements io.Reader. |
- 底层直接调用 socket 原生读写方法(syscall.Read、syscall.Write);
- 成功则直接返回;
- 如果失败,休眠等待可读 / 可写事件的发生;
- 被唤醒后重新调用系统 socket 进行读写;

2.4.5 net.DialTCP()
Dial() 方法支持 TCP、UDP、IP、unix、unixgram 和
unixpacket 网络通讯方式,它是一个统共的方法,通过传入
network
字段来区分不同的网络类型,所以它前面很多的操作,都是在判断当前是什么网络类型。本文主要讲
TCP 的实现底层,故直接进入 DialTCP()
即可,其他的网络类型,也是大同小异的。
1 | func DialTCP(network string, laddr, raddr *TCPAddr) (*TCPConn, error) { |
1 | func (sd *sysDialer) dialTCP(ctx context.Context, laddr, raddr *TCPAddr) (*TCPConn, error) { |
1 | func (sd *sysDialer) doDialTCP(ctx context.Context, laddr, raddr *TCPAddr) (*TCPConn, error) { |
- 创建一个系统的网络连接工具 sysDialer;
- dial 进行 TCP 连接,连接不上那就是 connect refused;
- 连接上的话,创建一个新的 socket,并最后返回为一个 TCPConn 变量,底层是调用了 runtime_pollOpen 方法,将 TCPConn 的 FD 信息加入监听。TCPConn 对象本质是一个 ESTABLISHED 状态的 socket。

3. 总结
