16 网络模型:netpoll 与”一个连接一个 goroutine”

开篇提问

先看一段 Go 里再常见不过的 TCP 服务器代码:

ln, _ := net.Listen("tcp", ":8080")
for {
    conn, _ := ln.Accept()
    go handle(conn)   // 每个连接一个 goroutine
}
 
func handle(conn net.Conn) {
    defer conn.Close()
    // 读写...
    buf := make([]byte, 1024)
    for {
        n, err := conn.Read(buf)   // 阻塞读
        // ...
    }
}

这里有一个让你脊背发凉的问题,请你认真想:

如果来了 100 万个并发连接,这段代码会创建 100 万个 goroutine,每个都阻塞在 conn.Read() 上。那么,这些”阻塞在读”的 goroutine,是不是对应着 100 万个阻塞的操作系统线程?如果是,那 Go 的”百万并发”岂不又回到了”线程昂贵”的老路?

答案是:不是。 这些阻塞的 goroutine,不占操作系统线程。它们阻塞时,线程被释放去干别的了。

这中间的秘密,就是 netpoll(网络轮询器)。这一章,我们把这个 Go 网络模型里最精妙的部分彻底讲透。


子主题一:从”阻塞 I/O”到”I/O 多路复用”

先建立一个大背景:操作系统的网络 I/O 模型。

阻塞 I/O(BIO): 一个线程调用 read,如果没数据,线程就真的睡在系统调用里,直到有数据。Java 早期的 socket 就是这个模型——所以”一个连接一个线程”,连接一多,线程一多,线程切换和内存开销就把系统拖垮。这也是 C10K 问题(一万并发连接)的根源。

非阻塞 I/O(NIO): 把 socket 设成非阻塞,read 没数据就立刻返回”没数据”,线程不会卡死。但线程得不断轮询”有没有数据”,浪费 CPU。

I/O 多路复用(I/O multiplexing): 这是现代高性能服务器的标准答案。用一个系统调用,同时监听成千上万个 socket,哪个 socket 有事件(可读/可写/出错)就报告哪个。Linux 上是 epoll,macOS/BSD 上是 kqueue,Windows 上是 IOCP。

多路复用的核心价值:一个线程(或少数几个线程)就能管理成千上万个连接的事件,不需要”一个连接一个线程”。

Go 的 netpoll,就是把 epoll/kqueue/IOCP 封装起来,和 goroutine 调度器无缝对接的那一层。


子主题二:netpoll 是怎么工作的

关键流程如下:

  1. 一个 goroutine 调用 conn.Read()。
  2. 底层发现这个 socket 当前没有数据可读。
  3. 于是这个 goroutine 不进入阻塞的 OS 线程,而是被调度器摘下来、挂起,同时把这个 socket 的”可读事件”**注册到 netpoll(epoll)**上。
  4. 这个 goroutine 原来占用的线程(M)被释放,去跑别的 goroutine。
  5. 调度器里有一个(或几个)专门的”网络轮询线程”,在 epoll 上 epoll_wait,等待事件。
  6. 当数据到达,epoll 报告”这个 socket 可读了”,netpoll 就把之前挂起的那个 goroutine 唤醒,重新放回可运行队列。
  7. goroutine 重新获得线程,继续从 Read 返回,就像它只是”睡了一觉”,从头到尾都以为自己在”阻塞读”。

这是整个模型最精妙的地方:goroutine 视角里,它是”阻塞”的(代码写得像同步阻塞 I/O);但在运行时视角里,它根本没有占用线程,只是”挂起 + 等 epoll 通知”。

于是你同时得到了两样好东西:

  • 代码的简单性:conn.Read(buf) 一行,看起来就是阻塞读,不用回调、不用状态机。
  • 运行的高效性:百万连接只对应几十个线程 + 一个 epoll,而不是百万线程。

这就是”一个连接一个 goroutine”模式能成立的完整机制。它把”同步编程的简单”和”异步 I/O 的高效”统一了起来,是 Go 网络编程最核心的竞争力。


子主题三:syscall 阻塞 vs netpoll 阻塞——一个关键区别

这里必须澄清一个容易被混淆的点:不是所有”阻塞”都能像 netpoll 这样优雅地挂起。

网络 I/O 的阻塞:走 netpoll,goroutine 挂起、线程释放,成本极低。

系统调用(syscall)的阻塞:比如磁盘 I/O(在没有异步 I/O 的平台上)、某些慢 syscall。这种阻塞是线程级的——goroutine 卡在 syscall 里,线程也真的卡住了,没法释放去跑别的 goroutine。Go 处理这种情况的方式是:临时多起一个线程(M)来顶上,保证”阻塞的线程不占用 P”(P 被释放给新线程),从而其他 goroutine 还能跑。但代价是线程数量会临时增加。

这个区别,解释了为什么 Go 里”网络 I/O 多”不怕,“磁盘 I/O 多”要小心——前者是优雅挂起,后者要额外起线程。这也是为什么 Go 的磁盘 I/O 性能、以及 cgo 调用的性能,一直是需要特别关注的点。


子主题四:netpoll 带来的思考——“阻塞”的重新定义

netpoll 的存在,让你重新理解”阻塞”这个词。

在传统线程模型里,“阻塞”意味着”线程被占用、CPU 上下文切换、资源被锁死”。但在 Go 里,goroutine 的”阻塞”(网络 I/O、channel、锁、sleep)大多是”逻辑阻塞”——goroutine 挂起,但线程和 CPU 被释放去做别的事。

这正是 Go 并发模型最深刻的洞察:“阻塞”本身不是问题,问题是”阻塞时还霸占着线程”。 只要阻塞的 goroutine 能让出线程,阻塞就是廉价的、甚至是好事(它让调度器有机会去跑别的就绪任务)。

这个洞察,也解释了 Go 为什么能支撑”海量并发”:在海量并发的世界里,绝大多数 goroutine 在绝大多数时间都在”等待”(等 I/O、等锁、等数据)。Go 让”等待”几乎零成本(挂起即可,不占线程),于是”海量等待中的 goroutine”不再是负担,而是常态。 这和其他语言”海量等待 = 海量线程 = 灾难”的模型,形成了根本性的差异。


子主题五:一个连接一个 goroutine 的工程实践

基于这个模型,Go 网络编程有几个实践要点:

1. 一个连接一个 goroutine,读和写分开。 每个连接的读循环一个 goroutine,写循环一个 goroutine(或用一个 goroutine 同时读写,视协议而定)。这样每个 goroutine 的逻辑都是简单的顺序代码,没有回调地狱。

2. 注意 goroutine 泄漏。 一个连接断开,要保证对应的 goroutine 能退出。通常靠 defer conn.Close() + 读返回错误时退出 + context 取消(第十章)。“读循环卡住、连接断了 goroutine 还不退”是常见的泄漏源。

3. 合理设置超时。 conn.SetReadDeadline/SetWriteDeadline,或配合 context。没有超时的读,会永远挂着,又是泄漏源。

4. 连接的读写缓冲要合理。 小缓冲(如 1024)会导致频繁 syscall,大缓冲浪费内存。按协议特点选择,或用 bufio 包装。


子主题六:三种 I/O 模型全景对比——为什么”多路复用”成了标准答案

先抛一个场景,你自己设计一遍:

一个餐厅,100 张桌子。厨房出菜很慢。你打算怎么安排服务员?

方案 A(阻塞 I/O,BIO):一桌配一个服务员。客人点完菜,服务员就杵在出菜口,死等这桌的菜。100 桌 = 100 个站着发呆的服务员,而真正干活的只有厨房。

方案 B(非阻塞 I/O,NIO):还是一桌一个服务员,但不许傻等——每 30 秒跑到出菜口问一句”我的菜好了没?“,没好就回去再等 30 秒。人没少,还多了一堆来回跑的无效劳动;问得太勤累死,问得太慢客人饿死。

方案 C(I/O 多路复用):出菜口装一个叫号器。一个服务员盯着叫号器,哪桌菜好了叫号器就响,他才动一次。100 桌只需要 1~2 个服务员。

这三个方案,就是三种 I/O 模型。现在换成技术语言:

BIO:read(fd) 没数据时,线程睡眠在内核里,直到数据到达被唤醒。代价是线程本身:Linux 上一个线程默认 8MB 栈(虚拟内存,实际提交按页算,但 TLB、内核 task_struct、调度器的红黑树里都挂着它)。1 万个并发连接 = 1 万个线程,光调度开销和缓存污染就足以让机器趴下——这就是C10K 问题(1999 年由 Dan Kegel 提出)的根源。它不是”CPU 不够快”,是”模型选错了”。

NIO:fcntl(fd, O_NONBLOCK) 之后,read 没数据立刻返回 -1 并置 errno = EAGAIN。线程不睡了,但问题从”线程太多”变成了”你怎么知道什么时候该再试一次”。答案只有两个:轮询(烧 CPU)或者 sleep 一小会儿(增加延迟)。NIO 单独用是个半成品,它必须配一个”通知机制”才完整——那个通知机制就是多路复用。

I/O 多路复用:一个系统调用同时盯住成千上万个 fd,谁就绪就报谁。时间线大致是:select(1983,BSD)→ poll(1997)→ kqueue(FreeBSD 4.1,2000)→ epoll(Linux 2.5.44,2002)→ io_uring(Linux 5.1,2019)。

那 select 不好在哪?三宗罪,一条比一条致命:

  1. fd 数量硬上限:FD_SETSIZE = 1024,编译期写死。
  2. 每次调用都要拷贝 fd 集合:你关心 1 万个 fd,每次调用都要把整个位图从用户态复制到内核态,调用返回再拷回来。
  3. 返回后还要 O(n) 扫描:select 只告诉你”就绪的 fd 数量 ≥ 1”,不告诉你是谁。你得把 1 万个 fd 挨个 FD_ISSET 问一遍。

poll 只修了第一条(改用动态数组,无 1024 限制),后两条原封不动。真正的范式转变发生在 epoll:内核里常驻一棵红黑树(保存你”关注”的所有 fd)和一条就绪链表(保存”已就绪”的 fd)。你 epoll_ctl 注册一次,之后每次 epoll_wait 只从就绪链表里摘东西返回——复杂度从 O(你关注的 fd 数) 降到 O(就绪的 fd 数)。

这两个数字在真实系统里的差距是什么量级?10 万连接、同一时刻只有 50 个活跃:select 每次扫 10 万,epoll 返回 50 个。这就是”多路复用是标准答案”的全部理由——不是它让单次 I/O 变快了,而是它让”等待”这件事的成本和连接数解耦了。

但我要反问一个很多人答不上来的问题:

既然 epoll 这么好,那是不是所有网络程序都该用 epoll?

不是。你去看 Redis,它是单线程 + epoll 的典范;但你去看一个 8 条连接全速打满 100G 网卡的数据传输程序,它大概率用阻塞 I/O 加多线程,而且比 epoll 版本更快。为什么?

因为 epoll 的收益来自”大量连接 + 大部分时间空闲”。每次事件通知都要走一遍内核就绪链表、一次 epoll_wait 系统调用、一次用户态唤醒——如果每个 fd 每次查都必然就绪,那这套通知机制就是纯开销,直接阻塞读反而省事。

所以真正该问的问题是:你的流量形状是什么? 长连接推送、IM、API 网关、数据库连接池——这些是”海量连接 + 稀疏活跃”,epoll 的主场;批量数据传输、内网高速同步——“少量连接 + 持续活跃”,阻塞 I/O 也不丢人。精通的标志不是记住”epoll 更快”,而是知道它快在什么形状下。

还有一层:多路复用省下的是”线程”,不是”内存”和”复杂度”。10 万个连接在 epoll 里,每个仍然要占一个 fd、内核收发缓冲区(Linux 会按负载动态调整,通常几 KB 到几十 KB)、以及用户态的一个 goroutine 或任务对象。省的是每连接 8MB 的线程栈和调度开销,这笔账已经足够大,但它不是魔法。


子主题七:epoll 的两种触发模式,以及 Go 为什么选了”难用的那一个”

既然要用 epoll,下一个问题立刻砸过来:

同一个 fd 上有数据没读完,epoll 该不该反复通知我?

两种答案,就是两种触发模式:

水平触发(Level-Triggered,LT,epoll 默认):只要 fd 当前处于”可读”状态,epoll_wait 每次都会把它报出来。你这次只读了一半?没关系,下次调用还会提醒你。

优点:语义安全,不会漏数据,允许你”按需读一点”。缺点:如果没人把数据读完,它会反复唤醒,而每一次唤醒在 Go 里都对应一次 goroutine 的恢复与调度。

边缘触发(Edge-Triggered,ET,EPOLLET):只在”状态发生变化”的那一刻通知一次——比如从”无数据”变成”有数据”。通知完之后,哪怕缓冲区里还剩 1MB 没读,它也不会再吭声。

这意味着一条铁律:用 ET 时,fd 必须是非阻塞的,且每次被通知后必须循环读到 EAGAIN 为止。 否则剩下的数据会”饿死”——数据在那儿,但再也没有事件来叫你了。

用餐厅叫号器打比方:LT 是”只要你还没取走,我每分钟广播一次你的号”;ET 是”你的号只叫一次,你这次没把菜全端走,剩下的自己负责”。

Go 选了 ET。 具体地说,Linux 上的 netpoll 用非阻塞 fd 加上 EPOLLET(注册时关注的事件集合包含 EPOLLIN | EPOLLOUT | EPOLLRDHUP | EPOLLET)。为什么自找麻烦?

因为 Go 的执行模型是”一次通知 → 唤醒一个 goroutine → 它自己把活干完”。在这个模型里:

  • LT 的重复通知=重复的、无意义的唤醒。一个 goroutine 被唤醒、读了一部分、发现读不满、gopark 再次挂起、LT 又把它唤醒……这中间的每次 park/unpark 都是纯浪费。
  • ET 的”通知一次、读尽为止”恰好契合”一次唤醒把活干完”。既然 runtime 无论如何都要写一段循环读的代码,那就干脆选开销更小的模式。

代价是什么?代价是 runtime 里那段”被唤醒后读数据”的代码必须绝对正确——只要有一次没读到 EAGAIN 就退出,这个连接就永久饿死。所以 netpoll 的源码里,这块逻辑写得异常小心,注释比代码多。这也给了我们一个工程启示:ET 不是”性能优化技巧”,是”你要为它承担正确性责任”。如果你在自己写 epoll 代码,先用 LT,等你有明确的理由和足够的测试,再上 ET。

顺带两个容易被忽略的细节:

  • EPOLLRDHUP:对端关闭了写侧(发了 FIN),内核用这个事件告诉你”这边不会再有数据了”。Go 关注它,是为了及时感知半关闭,让阻塞在读上的 goroutine 立刻拿到 EOF,而不是等下一次读超时。这个细节直接决定了”对端挂断后你的服务多久能回收连接”。
  • EPOLLONESHOT:通知一次后就自动把这个 fd 从关注列表里摘掉,防止多线程同时处理同一个 fd。Go 不需要它——因为它的并发模型是”一个 fd 一个 goroutine 顺序读写”,天然不需要在多线程间协调 fd 的归属。你看,模型的选择会在这种角落里反复省事。

最后往前看一眼:io_uring(Linux 5.1+)是真正的分代跃迁——它用两个环形队列(提交队列 SQ、完成队列 CQ)让用户态和内核共享内存,批量提交请求、批量收割完成,把 epoll_ctl + read + epoll_wait 三次系统调用压成”填表 + 收表”,甚至可以做到完全无系统调用(SQPOLL 模式)。当前 Go 主流版本在网络 poller 上仍以 epoll 为主,但 io_uring 是值得你长期盯住的方向——因为它的语义和 IOCP 更像(都是”完成通知”),而 Go 的抽象层恰恰是压在”goroutine 何时能继续”上的,迁移成本比想象中小。


子主题八:epoll / kqueue / IOCP 的三种哲学,和 Go 的统一抽象

现在到了最容易讲错、也最值钱的一节。

很多人会把这三个东西并列成”Linux 的 epoll、macOS 的 kqueue、Windows 的 IOCP”,好像它们只是 API 长得不一样。请你先想一个问题:

epoll_wait 返回之后,数据在哪?GetQueuedCompletionStatus 返回之后,数据又在哪?

如果你答不上来,说明你还没真正理解它们的差别。答案是:

  • epoll_wait 返回时,数据还在内核的 socket 接收缓冲区里。它只是告诉你”3 号 fd 可以读了”,你得自己再发起一次 read,把数据从内核搬到你的内存。
  • GetQueuedCompletionStatus 返回时,数据已经在你之前提交的那块缓冲区里了。内核帮你把活干完了,才来通知你。

这就是 readiness(就绪通知) 与 completion(完成通知) 的分野,学术界也把它叫做 Reactor vs Proactor:

模型谁搬数据系统调用路径
epoll(Linux)Reactor / 就绪通知你epoll_ctl 注册关注 → epoll_wait 等就绪 → 你 read
kqueue(macOS/BSD)Reactor / 就绪通知你kevent 注册 + 等待(一个调用干两件事)→ 你 read
IOCP(Windows)Proactor / 完成通知内核你先提交 WSARecv(把缓冲区交给内核)→ GetQueuedCompletionStatus 取”完成包”

再补一句关于 kqueue 的:它比 epoll 更”通用”。kqueue 里 socket 只是众多事件源之一——你还可以在同一个 kqueue 上等文件变化(EVFILT_VNODE)、定时器(EVFILT_TIMER)、信号(EVFILT_SIGNAL)、子进程退出(EVFILT_PROC)。它更像一台”统一事件总线”,epoll 则专注网络(epoll 不能 poll 普通文件,因为普通文件永远”就绪”)。但作为网络轮询器,它的定位跟 epoll 是同一类:告诉你”可以读了”。

一句话记住:epoll/kqueue 说”你可以读了”,IOCP 说”我帮你读完了”。 所以严格意义上,epoll/kqueue 属于同步 I/O(真正搬数据的 read 那一步还是同步的),IOCP 属于真正的异步 I/O。

好,问题来了:

一个”就绪通知”,一个”完成通知”,语义差了十万八千里。Go 凭什么能用一套 netpoll 接口把它们统一掉?

答案在”抽象层压在哪一层”上。Go 的网络 poller 里有一个与平台无关的核心结构:runtime.pollDesc(一个 fd 的轮询描述)。它只管三件事:

  1. 等待队列:哪些 goroutine 正在等这个 fd 的读事件、哪些在等写事件(内部是 rg / wg 两个字段,存的是等待者 g 的指针,或”已就绪/已关闭”的标记值);
  2. 超时:读 deadline 和写 deadline(下一节详讲);
  3. 生命周期状态:fd 是否已被关闭、是否被另一个 goroutine 抢占等。

平台相关的部分(netpoll_epoll.go / netpoll_kqueue.go / netpoll_iocp.go)只负责一件事:把”这个平台上有事发生了”翻译成”pollDesc 上有 goroutine 该醒了”。

对 IOCP 这个”完成型”模型,Go 的做法是把完成事件折回收敛成就绪语义:IOCP 的完成包到达后,runtime 找到对应的 pollDesc,唤醒挂在上面的那个 g;g 恢复执行后,Read 发现数据已经在缓冲区里了,直接拷贝返回。

关键洞察来了:Go 的抽象层统一在”goroutine 什么时候能继续跑”这个语义上,而不是统一在”事件模型”上。 因为 Go 的 I/O 原语是阻塞式的,运行时真正要的从来不是”事件”,而是”这个 goroutine 能不能往下走了”。无论是”可以读了”还是”读完了”,在 Go 看来都只是”可以继续了”的一种来源。IOCP 的完成包和 epoll 的就绪通知,被压成了同一个信号。

请你在这里停一停,体会一下这个设计的漂亮之处:它没有试图统一三个平台的 API(那是做不到的),而是找到了一个比三者都低一层的、三者都能提供的东西——“唤醒”。

那代价呢?反问一句:

统一抽象是免费的吗?

当然不是。代价是 Go 拿不到各平台的”超额收益”:

  • IOCP 天生支持”同时提交多个异步读”、“固定缓冲区零分配”,Go 基本只用到它最基础的能力;
  • Linux 上 Go 也没有把 io_uring 的批量提交、SQPOLL 无系统调用等能力用起来;
  • kqueue 能同时等子进程和文件变化,Go 的 netpoll 完全没碰这些。

抽象的价值是”到处都能跑、心智统一”,代价是”每个平台都不是最优”。这是一切跨平台运行时的宿命,Go 也不例外。 你作为使用者该知道的结论是:如果你的服务只在 Linux 上跑、并且想榨干最后一滴性能,那么”绕过标准库直接用 io_uring”是一个真实的选项——但你得自己承担”和 goroutine 调度器不兼容”的后果(比如你的 I/O 不再能被调度器优雅挂起)。这是一笔要用”业务收益”去称重量的交易,绝大多数人算完会发现不划算。


子主题九:netpoll 与调度器的精确交互——一个 goroutine 的完整”睡—醒”轨迹

前几节讲清了”是什么”,这一节讲”精确发生了什么”。请你跟着这 7 步走一遍,走完你就敢说精通了。

第 0 步:快路径(fast path)。 conn.Read(buf) 进到 internal/poll.FD.Read,runtime 会先直接试一次非阻塞 read。

如果缓冲区里已经有数据 —— 直接返回,零挂起、零 netpoll 参与。

这一步非常重要,因为它解释了一件反直觉的事:一个负载很低、数据总是即时到达的 Go 服务,几乎从不使用 netpoll。 只有”读不到”的时候才会走后面的慢路径。所以别一上来就说”我的服务 QPS 高所以 netpoll 很忙”——netpoll 忙不忙,取决于有多少请求恰好撞上了空缓冲区,而不是 QPS 本身。

第 1 步:拿不到数据,进入慢路径。 read 返回 EAGAIN。runtime 调用 poll_runtime_pollWait(runtime 内部对应 pollDesc.wait),最终走到 netpollblock(pd, mode, waitio)。

第 2 步:先挂队列,再 park——顺序不能反。 netpollblock 做两件事,而且顺序极其讲究:

  1. 把当前 g 的指针写进 pollDesc 的等待字段(读侧 rg / 写侧 wg);
  2. 然后才调用 gopark,把 g 从 _Grunning 改成 _Gwaiting,M(线程)被释放,P 转头去本地队列取下一个 g 来跑。

为什么顺序是”先挂队列再 park”?反过来想一遍:如果先 gopark、再挂队列,那么在”park 完成”和”挂进队列”之间的那一小段窗口里,网卡数据到了、epoll 报了事件、runtime 去找等待者——发现队列是空的,这个唤醒信号就丢了。g 挂起后再也没人唤醒它,这个连接永久卡死。

这就是并发编程里经典的 lost wakeup 问题。现实中的类比:你必须先把自己的名字登记在候诊名单上,再去睡觉;要是先睡了再登记,护士叫到你的时候名单上根本没你。

第 3 步:fd 注册到 epoll——一次注册,长期有效。 在首次使用时,pollDesc.init 会调用 netpollopen,把这个 fd 注册进 epoll 实例,关注的事件是 EPOLLIN | EPOLLOUT | EPOLLRDHUP | EPOLLET。

注意这里有个巨大的性能含义:注册是一次性的。 之后这个 fd 上的每一次读写、每一次挂起唤醒,都不再需要 epoll_ctl。这正是 epoll 相对 select 的另一半优势——select 每次调用都要重新传一遍全部 fd 集合,epoll 只需一次。

第 4 步:谁在等事件?这里有个常见误解。

很多人以为”Go 里有几个专门的 netpoll 线程在 epoll_wait”。这个说法不准确。 真实情况是 Go 有多条取事件的路径,而且是”谁有空谁来 poll”:

  • sysmon(监控线程):runtime 常驻的后台监控线程,在它的循环里以非阻塞方式调用 netpoll(0),拿到就绪的 g 列表就注入运行队列。它的定位是兜底——防止所有 P 都在忙、没人去 poll 导致事件饿死。
  • 调度循环本身:findRunnable 在本地队列、全局队列、其他 P 那里都找不到活干时,会调用 netpoll(delay),根据情况阻塞或非阻塞地等一会儿事件;拿到 g 列表后用 injectglist 塞回 P 的队列。这是主路径。
  • GC 结束后:startTheWorldWithSema 也会 poll 一次,避免 STW 期间到达的事件被晾在那里。

“谁有空谁来 poll”这个设计自动实现了 poll 线程的伸缩:忙的时候几乎没人 poll(事件本来就会立刻被主路径拿到),闲的时候每个 P 都能 poll。不需要你配置”几个 poller 线程”,这就是答案——Go 里这个问题的答案是”0 个固定线程”。

顺带一个狠问题:如果所有 P 都在跑 CPU 密集的代码,谁来 poll? 靠 sysmon 兜底。而且 Go 1.14 之后有了异步抢占(基于信号的抢占),长时间运行的 g 会被强制让出 P,所以事件不会永久饿死。但延迟会实实在在地变差——所以在 Go 里,一个死循环占着 P 的 g 确实会拖慢同一台机器上所有网络请求的响应。这跟 Node 的”CPU 密集卡住整个事件循环”是同构的问题,只是 Go 有抢占兜底,程度轻得多。

第 5 步:数据到达。 网卡 → DMA 写入内核内存 → 硬中断/软中断 → 内核协议栈(IP/TCP 层处理)→ 数据进 socket 接收缓冲区 → epoll 的就绪链表里挂上这个 fd → epoll_wait 返回这个事件。

第 6 步:事件翻译成”唤醒”。 runtime 遍历返回的事件,对每个事件找到对应的 pollDesc,调用 netpollready → netpollunblock,把等待的 g 从 pollDesc 的等待字段里摘出来,放进一个 g 链表(gList)里。此时 g 的状态从 _Gwaiting 变回 _Grunnable。

第 7 步:批量注入运行队列。 这个 g 链表通过 injectglist 一次性塞进 P 的 runnext 槽、本地运行队列或全局队列(塞不下的会被推到全局队列,并按需唤醒更多 M/P)。g 之后被调度到某个 M 上,从 gopark 之后那条指令继续,Read 再试一次,这次拿到数据,返回。

整个过程中,goroutine 视角是”我阻塞了一会儿”;运行时视角是”我被挂起、线程被拿走、事件来了我又被放回队列”。反问一句:这中间发生了几次线程上下文切换?

零次。 只有 goroutine 栈的保存和恢复(几个寄存器的存/取),没有内核态的线程切换、没有 TLB 刷新、没有调度器的重排。这就是”百万连接”真正的成本模型——它便宜在”没有线程切换”,而不只是”省内存”。

(提示:本节提到的内部函数名如 netpollblock、netpollready、netpollunblock、injectglist、netpoll 等在 Go 各版本中存在且职责稳定,但签名和调用点会随版本微调。读源码时以你本地 go/src/runtime/netpoll.go 和 netpoll_epoll.go 为准,不要背函数名,要背流程。)


子主题十:读写超时的底层实现——deadline 是怎么”接进”netpoll 的

先问一个让无数线上事故显形的问题:

如果对端建立连接后一个字节都不发,你的读 goroutine 会怎样?

答案是:它会一直挂着,直到天荒地老。 netpoll 只在”有事件”时唤醒它,没有事件就没有唤醒。连接泄漏、fd 耗尽、内存缓慢上涨——这就是那条事故曲线的起点。

唯一的解法是 deadline。

API 层:SetDeadline(t)(同时设读写)、SetReadDeadline(t)、SetWriteDeadline(t)。注意参数类型是 time.Time,是绝对时刻,不是 duration。传零值 time.Time{} 表示”取消 deadline”。

底层发生了什么:deadline 的设置最终走到 poll_runtime_pollSetDeadline,它在 pollDesc 上维护两个 runtime timer(读的 rtimer、写的 wtimer)。runtime timer 是 Go 1.14 之后重写过的定时器实现——挂在每个 P 上的四叉堆(老版本是全局最小堆 + 64 个桶),伸缩性好了很多。

关键在于这个定时器的回调做什么:它不是帮你去读数据,而是:

  1. 把 pollDesc 上等待的那个 g 通过 netpollunblock 唤醒;
  2. 但标记它的唤醒原因是”超时”,而不是”有 I/O 事件”。

g 醒来之后,先判断自己是被谁叫醒的 —— 是被 I/O 事件叫醒的,还是被定时器叫醒的。后者就返回一个包装好的 *net.OpError,底层错误是 os.ErrDeadlineExceeded,它的 Timeout() 方法返回 true(errors.Is(err, os.ErrDeadlineExceeded) 可以稳定判定)。

这套机制的美妙之处:deadline 被”翻译”成了一种和 I/O 事件平级的唤醒源。netpoll 不需要为超时写一套单独的等待逻辑,它只需要在挂起 g 的同时挂一个 timer,然后不管谁先到,谁先到谁负责唤醒。挂起的 g 只有一个出口,唤醒的理由有两个。

好,现在讲三个必须刻进肌肉记忆的坑:

坑一:deadline 是”绝对时刻”,一次设置长期有效。 这句话的推论是:你必须在每次读之前续期,而不是在循环外设一次。

// 错:只有第一次读有超时,之后所有读都无限期
conn.SetReadDeadline(time.Now().Add(30 * time.Second))
for {
    n, err := conn.Read(buf)
    // ...
}
 
// 对:每次读之前重新设定
for {
    conn.SetReadDeadline(time.Now().Add(30 * time.Second))
    n, err := conn.Read(buf)
    // ...
}

反问一下:那”每 30 秒必须有数据到达”和”整个请求 30 秒内必须完成”,是同一个 deadline 吗?不是。 前者是空闲超时(idle timeout,要每次续期),后者是整体超时(absolute deadline,设一次不动)。混淆这两者是长连接服务最常见的超时 bug,没有之一。

坑二:读超时之后,连接的状态是不可信的。 这一点极其重要,而且经常被人忽略。读超时意味着”在截止时间前没凑够你想要的字节”——但缓冲区里可能已经读进来了一部分数据,TCP 字节流的位置已经往前走了。

TCP 是字节流,没有消息边界。你读过一半的包,不能假装没读过再重新读一次。所以工程上的惯例是:读超时就关闭连接,而不是”超时了再试一次”。这一点和”失败重试”组合起来时特别危险——你以为你在重试一次读,实际上你在从一个错位的流位置继续解析,解出来的是一堆语义正确的垃圾。

坑三:写超时比读超时更容易被忘,而且它防的是攻击。 如果客户端只发请求不读响应,你的响应会不断往内核发送缓冲区里堆,堆满之后 Write 就挂住了。没有写超时的服务,在 Slowloris(慢速攻击:开一堆连接,每次只发一个字节、拖着不收响应)面前是裸奔的——几十条连接就能把你的服务器拖死。

http.Server 就是靠这套机制实现它的四个超时的:

  • ReadHeaderTimeout:读请求头的超时。务必设置,这是防慢速攻击的第一道门。
  • ReadTimeout:读整个请求(含 body)的超时。
  • WriteTimeout:写响应的超时。
  • IdleTimeout:Keep-Alive 状态下等下一个请求的超时(HTTP/2 语义不同)。

它们的本质就是在不同阶段对底层 conn 调用 SetDeadline 的组合。理解了 deadline 的实现,你就能精确推理出”我设的这个超时到底覆盖哪一段”——而不是靠猜。

最后一个反问,也是最容易答错的:

既然我们有 context.WithTimeout,为什么还需要 SetDeadline?context 取消不能让阻塞的 Read 返回吗?

不能,至少不能”直接”让它返回。context 的取消只是一个通知,它不会打断已经陷入内核的等待。对一个正挂在 epoll 上的 goroutine 来说,真正能让它从 Read 返回的路径只有三条:

  1. 数据到了(netpoll 正常唤醒);
  2. deadline 到期(runtime timer 唤醒);
  3. 另一条 goroutine 把这个 conn Close 了(连接被关闭,阻塞的 Read 立刻返回 use of closed network connection 错误)。

net/http 里请求 ctx 被取消时,服务端干的就是第 3 条——关掉底层连接。所以 context 和 deadline 不是替代关系,是协作关系:context 负责”在业务逻辑层面传播取消意图”,deadline 负责”在 I/O 层面保证一定能醒过来”。第十章的 context 和这一节是互补的两半,缺哪一半都会漏。


子主题十一:连接生命周期管理——半关闭、优雅关闭,与”对面跑了”的侦查

请你先回答一个看似简单的问题:

conn.Close(),是”礼貌地挂电话”,还是”直接摔电话”?

多数人会脱口而出”挂电话”,但真实答案取决于一个你大概从没设过的选项:SO_LINGER。

默认行为(“礼貌挂断”):Close() 发送 FIN,走正常的四次挥手,主动关闭方进入 TIME_WAIT 状态。内核会先把发送缓冲区里剩余的数据发完,再收尾。

SetLinger(0) + Close()(“直接摔电话”):内核立即发送 RST,丢弃发送缓冲区里残留的数据,跳过 TIME_WAIT。对端收到的错误是 connection reset by peer(ECONNRESET)。

什么时候该摔?两种场景:

  • 你作为服务端大量主动关闭连接,导致 TIME_WAIT 堆积(TIME_WAIT 停留在主动关闭方,会占 fd、内存和端口;持续时间通常是 2×MSL,一两分钟);
  • 对端是个僵死/恶意客户端,你不想为它多等一秒。

代价必须讲清楚:摔电话会丢数据。 发送缓冲区里还没发出去的字节会被直接抛弃,对端可能永远收不到你最后那一段响应。所以 —— 别把它当默认优化。我见过太多”为了消除 TIME_WAIT 加一行 SetLinger(0)“的改动,最后变成”线上偶发响应体被截断”的悬案。

半关闭:CloseWrite(),这个 API 你迟早会需要。

TCPConn.CloseWrite() 发一个 FIN,语义是:“我这边写完了,但我还想听你说完。” 之后你仍然可以继续 Read 对端发来的数据。

这是什么?这是用 TCP 的流结束信号当”消息结束符”。很多协议的结束判定就靠它:

  • HTTP/1.0 里响应没有 Content-Length 时,服务端就是靠关闭连接来表示”body 结束了”,客户端读到 io.EOF 才知道收完了;
  • 流式上传、某些 RPC 的请求结束判定;
  • 一些老式协议(SMTP 的 DATA 段、早期的一些私有协议)也依赖它。

最干净的双工收尾流程就是:A 发完数据 → A CloseWrite() → B 读到 io.EOF → B 发完响应 → B Close()。

注意:确实也有 CloseRead(),但它几乎不用——关闭读侧之后你再读会得到错误,而不是 EOF,语义很别扭,实践中基本只在”我要彻底放弃这个连接”的边缘场景出现。

现在到重点:怎么知道”对面跑了”?

这是网络编程里信息量最大的一块,因为不同的死法,你看到的症状完全不同:

对端怎么”死”的你的 Read 看到你的 Write 看到
正常 Close()(发 FIN)n=0, err=io.EOF第一次写可能成功,之后对端回 RST,再写报 EPIPE
进程崩溃 / 被 RSTECONNRESET(connection reset by peer)同样 ECONNRESET / EPIPE
断电、拔网线、路由黑洞什么都没有,一直挂着一直挂着

前两行都是”有信号”的死亡,你能察觉。第三行才是噩梦 —— 半开连接(half-open connection):TCP 连接只存在于两端的内核状态里,中间链路断了,两端谁都不知道。你的连接会一直”以为自己还活着”,Read 永远阻塞,fd 永远不释放。

这是 TCP 的固有缺陷,协议层面没有任何办法自动发现。唯一的兜底是”主动探测”:

  • TCP keepalive:conn.(*net.TCPConn).SetKeepAlive(true) + SetKeepAlivePeriod(d)。系统默认是 2 小时,对服务端来说等于没有,必须改成几十秒到几分钟。它工作在 TCP 层,探测包对用户态透明,优点是零业务侵入。
  • 应用层心跳:协议里定义 ping/pong 帧,空闲一段时间后主动发一个。优点是能顺便检测”对端进程活着但业务卡死”(TCP keepalive 只能证明内核还活着,证明不了应用层还在处理)——这两者不是一回事,长连接服务通常需要两个都配。

反问:心跳间隔该设多少? 没有标准答案,但设计时要明确两个约束:检测延迟(你要在多久内发现故障)和额外开销(心跳流量 = 连接数 / 间隔)。业界常见做法是”空闲 30s 发一次,连续 3 次无响应判定失效”,再叠一层 TCP keepalive 做兜底。别抄数字,抄推导方式。

最后一个实践要点:net.Conn 的并发安全边界。

文档的说法是”多个 goroutine 可以同时调用 Conn 的方法”,但这不等于”你可以随便并发读写”:

  • 多个 goroutine 同时 Read:安全(不会 crash),但数据会被瓜分——一条消息可能被两个 goroutine 各读一半,这是逻辑错误,不是崩溃。
  • 多个 goroutine 同时 Write:安全(不会 crash),但字节会交错,写出一个语义混乱的流。
  • Close() 与 Read/Write 并发:安全,而且这是有意为之——Close 会让阻塞在读写上的 goroutine 立刻返回错误。这就是上一节说的”用 Close 打断阻塞读写”手法的依据,也是很多连接管理库(以及 net/http 的 ctx 取消)的底牌。

所以正确的并发模式是那句老话:一条连接,一个读 goroutine + 一个写 goroutine;写侧如果有很多生产者,用一个 channel 汇聚,由写 goroutine 独占 Write。


子主题十二:http.Server 的并发模型——把”一个连接一个 goroutine”再剥一层

现在问一个我面试时必问、答错率超过一半的问题:

http.Server 是”每个请求一个 goroutine”,还是”每个连接一个 goroutine”?

正确答案是:每个连接一个 goroutine,而且同一个连接上的多个请求是串行处理的。

看源码结构就清楚了:Serve 里是一个 for { rw, err := l.Accept() ... } 循环,每 accept 一个连接就 go c.serve(connCtx)。在这个 c.serve 里,是一个串行的读请求—调 handler—写响应的循环,直到 Keep-Alive 超时或对端关闭。

这个事实有三个直接推论,每一个都能救命:

  1. 一个连接上的请求是排队的。 客户端用了 Keep-Alive 并发发 10 个请求(pipelining),服务端会一个一个处理,按序返回。所以”HTTP/1.1 + 长连接”并不能提升单连接的并发度——真正的并发来自多条连接(浏览器通常每域名 6~8 条)。
  2. Handler 里阻塞会阻塞整条连接——但不会阻塞别的连接(别的连接有自己的 goroutine)。这和 Netty 的”阻塞 EventLoop 会卡住几千条连接”形成鲜明对比。这就是”每连接一个 goroutine”相对”每 EventLoop 一个线程”的一个巨大优势:故障域被隔离到了单条连接。
  3. “每个请求一个 goroutine”这个说法真正成立的地方是 HTTP/2。 h2 里:一个连接一个 goroutine 负责帧的读写和流控,每打开一个 stream 就再起一个 goroutine 跑 http.Handler。所以 h2 能在一条 TCP 上并发跑很多请求,而你的 handler 代码一个字都不用改——Go 自动把”每请求一 goroutine”从 TCP 连接层搬到了 stream 层。这就是抽象层选对位置带来的红利。

Keep-Alive 与连接复用(客户端侧,这里是重灾区)。

http.Transport 维护一个空闲连接池,几个关键参数默认值的坑,一个比一个实在:

  • MaxIdleConnsPerHost 默认是 2。 意味着你瞬间并发 100 个请求到同一个主机,处理完只有 2 条连接会进池子被复用,其余 98 条用完就关闭、进入 TIME_WAIT。高并发下客户端端口会被迅速耗尽(cannot assign requested address)。调大它,几乎是所有高并发 Go 客户端的第一步。
  • MaxIdleConns:全局空闲连接上限(默认 100)。
  • MaxConnsPerHost:对单个主机的总连接数上限(默认 0,即不限)。想做限流/熔断时用它。
  • IdleConnTimeout:空闲连接保留时长(默认 90 秒)。

还有个经典到可以当梗的坑:必须读完并关闭 response body,连接才会归还到空闲池。

resp, err := client.Do(req)
if err != nil { return err }
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)  // 关键:读干

只 Close() 不读完,连接会被直接丢弃而不是复用——效果等价于”每请求一条新连接”,连接池形同虚设。这条规则在”只要状态码不看 body”的代码里特别容易漏。

服务端的超时与优雅关闭。

超时参数上一节讲过(ReadHeaderTimeout / ReadTimeout / WriteTimeout / IdleTimeout / MaxHeaderBytes)。这里重点说优雅关闭:

  • srv.Shutdown(ctx):先关 listener(新连接进不来了),然后立即关闭所有空闲连接,并等待活跃请求处理完;如果 ctx 到期还没处理完,就强制关闭。
  • srv.Close():立刻关闭 listener 和所有连接,正在处理的请求会直接断掉。

生产环境的标准收尾顺序是:从服务发现/负载均衡摘除自己 → Shutdown → 等待退出(中间还要考虑”等待时间要大于 LB 的健康检查间隔 + 摘除传播时间”)。

但请你务必记住 Shutdown 的两条边界:

  1. 它管不了被 Hijack 的连接和已升级的 WebSocket 连接。 这些连接已经脱离了 http.Server 的生命周期管理(Hijack 之后 conn 归你),你得自己维护它们的注册表并主动关闭。这是”Go 服务 Shutdown 卡住不退出”最常见的原因。
  2. 它只保证”我这边的礼貌”。 反问一句:为什么很多团队配好了 Shutdown,发布时还是会有 502?

因为 Shutdown 只负责”等我把手上的活干完”,它管不了三件事:LB 的健康检查还没把你摘掉(还在往你这儿发新流量)、内核 accept 队列里还排着没被 Accept 的连接、客户端的老连接还在往这个 IP 发请求。优雅关闭是单向的礼貌,不是全网协同的保证。 所以必须三层一起做:摘流量(等 LB 生效)+ Shutdown(等请求处理完)+ 客户端重试(兜底)。少任何一层,发布都会有毛刺。


子主题十三:网络性能调优——缓冲、零拷贝、连接池

你的服务压测上不去的时候,第一反应是什么?加机器?加 GOMAXPROCS?

先把下面三件事做完再决定。我按”投入产出比”排序。

一、缓冲:把系统调用的次数降下来

每次 Read/Write 都是一次系统调用:用户态→内核态的特权切换、参数校验、可能的调度,少说几百纳秒,多则微秒级。用 1KB 缓冲读 1MB 数据 = 1024 次系统调用;用 32KB 缓冲 = 32 次。 省下来的是实实在在的 CPU。

  • bufio.Reader:一次性填满内部缓冲,之后的读基本是纯内存拷贝。热路径上优先用 ReadSlice / ReadByte / Peek(零分配或极少分配);ReadString('\n') 写按行协议非常舒服——但要警惕单行无限长把内存撑爆,正确做法是用 bufio.Scanner 并设 Buffer() 上限,或者 ReadSlice 后自己判断长度。
  • bufio.Writer:别忘了 Flush()。 “服务端逻辑全对但客户端收不到响应”这个经典故障,八成是 Flush 漏了。经验法则:写之前算一次”我这一轮要写多少”,别每写一小段就 Flush(那就等于没缓冲)。
  • 反模式:每条小消息直接 conn.Write()。正确姿势是写侧单 goroutine + bufio.Writer + 定量或定时 Flush(比如攒到 8KB 或距上次 Flush 超过 1ms,取先到者)。
  • 顺带一个默认值:Go 的 TCP 连接默认开启 TCP_NODELAY(禁用 Nagle)。别去关它。 关掉之后小包会等着攒批或等 ACK,在延迟 ACK 的相互作用下,单次交互延迟可能从亚毫秒涨到 40ms 量级——这是个能让你查一周的现象级坑。

二、零拷贝:让内核帮你搬

核心思想:数据从内核缓冲区到用户态、再从用户态回到内核缓冲区,这两趟搬运在无谓地烧内存带宽和 CPU。能省就省。

  • sendfile(2):文件内容从页缓存直接送到 socket,完全不经过用户态。Go 里怎么写?你什么都不用写 —— io.Copy(tcpConn, file) 时,*net.TCPConn 实现了 io.ReaderFrom,内部会自动走 sendfile(条件满足时)。静态文件服务、镜像分发、视频点播靠它省下一次完整拷贝 + 一次上下文切换。
  • splice(2)(Linux):在两个 fd 之间直接搬数据,连页缓存都不用绕。Go 里 io.Copy 从 *net.TCPConn 到 *net.TCPConn 时会尝试用它——代理、网关、sidecar 这类”转发”场景是它的主场。
  • net.Buffers([][]byte)走 writev(2):一次系统调用写多个分散的缓冲区。适合”响应头 + 若干段响应体”这种拼装,省掉一次用户态的内存合并。

反问:零拷贝永远更快吗?不。

  • 省的是”拷贝”,多的是”内核路径的复杂度”。几十 KB 以下的小数据,sendfile 未必划算;
  • 一旦你要对数据做变换,就必须回到用户态。 压缩、加密、协议转换……统统不行。TLS 就是最典型的反例:数据必须进用户态加密,sendfile 完全用不上 —— 这也是 HTTPS 比 HTTP 贵的部分原因(Linux 有 kTLS 可以把加密下放到内核,但 Go 标准库不走这条路)。
  • 所以记住这句话:零拷贝的适用窗口是”大而不需要修改的数据”。

三、连接池:别把时间浪费在握手上

先算一笔账:TCP 三次握手 = 1 个 RTT;TLS 1.3 握手 = 1 个 RTT(会话复用可以 0-RTT)。跨机房一个 RTT 大概 1~30ms 不等。

你辛辛苦苦优化业务逻辑省下的 200 微秒,抵不上一次跨机房握手的零头。 这就是连接池为什么永远是第一优先级。

  • 出向:复用 http.Client / Transport(绝对不要每次请求 new 一个 Client,那是把连接池直接扔了);调大 MaxIdleConnsPerHost;数据库方面 database/sql 自带连接池,配好 SetMaxOpenConns / SetMaxIdleConns / SetConnMaxLifetime 就行。
  • 入向:你能控制的主要是”别让连接白站着” —— 设 IdleTimeout、限制单连接的请求数(很多服务器有 MaxRequestsPerConn 之类的配置,Go 的 http.Server 没有内建,但可以自己数)、对慢客户端设 ReadHeaderTimeout。
  • 池子的大小怎么定? 反问一句:连接池的本质是什么?

本质是用”空闲时占着一点资源”,换”需要时省一次握手”。所以池子不是越大越好——池子太大,服务端会同时挂着大量半死不活的连接(每个都要占 fd、内核收发缓冲区、用户态的对象,一条空闲 TCP 连接内核侧大约几 KB 到几十 KB),压测时的表现是”还能连上,但响应越来越慢”。

定量的答案来自 Little’s Law(利特尔法则):

并发数 = QPS × 平均响应时间

这条公式在容量规划里比任何”经验值”都好用。QPS 2000、平均 RT 50ms → 需要的并发是 100,池子给 100~150 就够;给 2000 纯属给自己找麻烦。把池子大小绑在”并发数”上,而不是绑在”QPS”上。

四、别忘了那个最土的限制:fd 上限

一个连接一个 fd。ulimit -n 如果是默认的 1024,你连 1000 个连接都撑不住,前面所有的优化都是白搭。上线前必查:

  • 进程级:ulimit -n(systemd 里是 LimitNOFILE=,Docker 里是 --ulimit nofile=);
  • 系统级:/proc/sys/fs/file-max 和 fs.nr_open。

这个检查只需要 30 秒,但它能在压测前夜救你一命。


子主题十四:业界对照深挖——Netty、Node.js、tokio 各自把”等待”藏在了哪里

最后一个大问题,也是最能看出你有没有真正理解的一问:

同样是 epoll,为什么 Go 的代码和 Netty 的代码长得完全不像?为什么 Node 要 async/await,Rust 要 .await,而 Go 什么都不用写?

因为这四个 runtime 回答的是同一个问题:“一个 I/O 操作没完成时,这段逻辑执行到哪儿了、它的上下文存在哪里、谁负责把它接回去?”

答案各不相同,而这个答案决定了这门语言网络代码的全部长相。

1. Netty(Java)——Reactor 模式的工业化实现

结构:BossGroup(acceptor,只管 accept)+ WorkerGroup(N 个 EventLoop,每个 EventLoop = 一个线程 + 一个 Selector + 一个任务队列)。一个 Channel 一旦注册到某个 EventLoop,就终身绑定在这条线程上。

“终身绑定”是 Netty 的精髓所在:同一个 Channel 的所有事件都在一条线程上顺序执行,于是 —— 不需要锁。你在 ChannelHandler 里维护的状态,只要没被共享给别的 EventLoop,就是天然线程安全的。

发现了吗? 这和 Go 的”一条连接一个 goroutine 顺序读写”、Node 的”单线程事件循环”是同一个思想的三种表达:用”串行化”替代”加锁”。这个思想贯穿了整个高性能网络编程史。

代价也同源:EventLoop 绝不能被阻塞。 在 handler 里做一次同步数据库查询,这条 EventLoop 上挂着的几千条连接全部卡住。所以 Netty 的标准姿势是:把阻塞工作丢给独立的业务线程池,完成后用 ChannelPromise / 回调把结果送回 EventLoop。

请你在这里停一下,看清楚一件事:回调地狱不是语法问题,是架构约束的产物。 因为”你不能阻塞”,所以”后半段逻辑”必须被切成一个可传递的对象(回调),等结果回来再被调用。是”不能阻塞”逼出了”回调”,不是 Java 语法不好。

责任链:ChannelPipeline 里串着一串 ChannelInboundHandler / ChannelOutboundHandler(拆包粘包、编解码、SSL、压缩、业务),标准的责任链模式。想一下 Go 里对应的东西是什么?—— 是 net.Conn 的层层包装:你写一个类型内嵌 net.Conn、只重写 Read 做解密,就得到了一个”解密层”。同样是责任链,Netty 用框架约定,Go 用接口组合。

Java 21 的虚拟线程(Loom),本质是承认了同步写法是对的:JVM 在阻塞调用处挂起虚拟线程、释放载体线程、I/O 完成后再恢复 —— 这就是把 Go 的 netpoll 思路搬进 JVM。

但两者的处境天差地别:Go 从第一天起就只有一套可挂起的 I/O 栈,没有历史包袱;JVM 有二十多年积累的同步阻塞 API(JDBC、synchronized 的 pinning、大量 native 库),每一处都得单独改造才能”可挂起”,改造量是恐怖的。这就是”从零设计”相对于”向后兼容”的巨大优势,也是 Go 当年敢押注这个模型的底气。

2. Node.js ——单线程事件循环 + libuv

结构:一个主线程跑事件循环,libuv 在背后用 epoll/kqueue/IOCP 收事件。关键在于:文件 I/O、DNS 解析、压缩、加密这些”epoll 管不了”的东西,交给 libuv 的线程池(默认 4 个线程),完成后把回调丢回主线程。

所以 Node 的”异步”其实是两类:真异步(网络,靠 epoll)和假异步(文件,靠线程池伪装)。这个区分很重要 —— 你以为你在用 epoll,实际上你的文件读写在消耗 4 条线程。

优势:单线程 = 无锁 = 心智负担极低,而 JS 从语言层面就是单线程模型,契合得近乎完美。

硬伤:一个 CPU 密集任务会把整个进程的所有连接一起冻住。 JSON.parse 一个巨大的对象、一次正则回溯、一次同步加密 —— 全场陪葬。历史上 Node 没有抢占(现在有了有限的机制但语义上仍是协作式),唯一的解法是拆任务(setImmediate 分片)、开子进程、或者用 worker_threads。“Node 适合 I/O 密集、不适合 CPU 密集”这句话的准确含义就在这里。

async/await 的真相,值得单独说:它是 Promise + 状态机(生成器)的语法糖。它让异步代码看起来是同步的,但函数仍然在 await 处被切成两半——一次 await 就是状态机的一次 yield/恢复。栈在 await 处被拆散了(所以 Node 的异步错误栈经常难看、丢失中间帧)。

对比 Go:goroutine 是真栈。 挂起时整个调用栈原封不动保留(栈就在那块内存里,一个字都不用搬),恢复后从下一条指令继续。所以 Go 的 panic 栈、pprof 采样栈、错误栈,永远是一棵完整真实的调用树。

这是”看起来一样,本质完全不同”的经典案例。 下次有人跟你说”Node 的 async/await 和 Go 的 goroutine 差不多”,你就把这段讲给他听。

3. Rust tokio ——协作式调度 + 显式异步

结构:mio(跨平台的 epoll/kqueue/IOCP 封装,只提供”就绪通知”这一层)+ tokio 的多线程 work-stealing 调度器(每个 worker 一条本地队列,空闲时去偷别人的任务)+ 基于 Waker 的唤醒机制。

唤醒机制值得你逐字对比 Go:

  • tokio 里没有”挂起 goroutine”这种运行时魔法。 取而代之的是:Future::poll 被调用,如果没就绪就返回 Poll::Pending,同时把当前任务的 Waker 注册到 reactor;事件到达时 reactor 调用 wake(),任务被放回队列,调度器再 poll 一次。
  • Go 是”g 挂起 → 事件到了唤醒 g → 从下一条指令继续”;tokio 是”任务返回 Pending → 事件到了重新 poll 任务 → 从头再走一遍状态机”。

一个是从断点继续,一个是从头重放。 这个差别解释了 Rust 异步的全部约束:Future 必须是可重入的、所有跨 await 的状态必须被显式保存(编译器帮你生成状态机,帮你保存局部变量)——因为它没有栈,只有一个结构体。

代价有三个,每一个都真实:

  1. 函数着色(function coloring):async fn 只能被 async fn 或 runtime 调用,异步会传染。Rust 社区那篇著名文章就叫 What Color Is Your Function?
  2. 混用同步库会阻塞整个 worker —— 和 Netty 的问题同构,只是载体从 EventLoop 变成了 worker 线程。
  3. 脑力成本:Pin、生命周期、Send/Sync 约束、Boxed future 的分配……每一个都是编译器在跟你要一个明确的承诺。

收益同样真实:没有运行时栈、没有 GC、Future 是零成本抽象(编译期生成状态机,没有堆分配、没有调度器强制介入),所以单连接的内存占用和延迟抖动做到了极致。这就是为什么代理(linkerd、Cloudflare 的部分组件)、网关、嵌入式这类”每一微秒都要计较”的场景,Rust/tokio 的份额在涨。

4. 收束:把四个模型并成一句话

回到开头那问:“执行到哪儿了”这件事,谁在记账?

  • Go:运行时记账 —— 栈在那儿,一个字都不用你操心。
  • Node:语言 + 闭包记账 —— Promise 状态机和闭包帮你存着”后面要干什么”。
  • tokio:编译器 + 你自己记账 —— 编译器生成状态机,但你得显式 .await,还得为函数着色买单。
  • Netty:你自己记账 —— 手写回调、手写状态机、自己把上下文塞进 ChannelHandler 的字段里。

于是 Go 的取舍可以一句话说清:用”一个运行时 + 每个 goroutine 最小 2KB 的可增长栈”这点开销,换回”你什么都不用管”。

这也是整套《精通 Go 设计》想传达的核心判断:Go 卖的从来不是极致性能,是”默认正确 + 够用的性能”。 在 99% 的业务服务里,这个交换血赚 —— 因为你省下的不是机器,是工程师的脑子和线上事故的夜晚。

而”精通”的标志,是你能说清楚这个交换的边界在哪里:什么时候该接受这份默认(绝大多数时候),什么时候必须绕过它(极少数的极致性能场景),以及绕过它的代价是什么。知道自己在买什么,才叫精通。


业界对照

Java NIO / Netty: Java 的 Netty 用 NIO + Reactor 模式 + 线程池实现高并发,性能很强,但代码是异步回调 + 事件驱动,心智负担重(callback hell)。Java 21 的虚拟线程(Loom)开始支持”同步代码 + 挂起式阻塞”,向 Go 的模型靠拢——这是对 Go 模型的”迟到但正式的认可”。

Node.js: 单线程事件循环 + 非阻塞 I/O + 回调/Promise/async-await。单线程避免了锁,但 CPU 密集任务会阻塞整个事件循环,必须小心。Node 的 async-await 已经让”异步代码写得像同步”,但和 Go 的”真同步 + 挂起”仍有本质区别(Node 是语法糖,Go 是运行时栈切换)。

Rust tokio: async/await + 运行时,性能极强(零成本抽象),但 async 函数是”颜色函数”(async fn 只能被 await,会”传染”),且编译期心智负担重。tokio 的模型和 Go 类似(epoll + 任务调度),但显式得多。

Erlang: 每个连接一个进程(类似 goroutine),进程间消息传递,天然适合高并发网络服务。Erlang 的进程模型比 Go 更彻底(独立堆、独立 GC),但生态和性能特性不同。


版本演进小结

  • 早期:netpoll 就已存在(Go 从诞生就押注”goroutine + netpoll”这个模型)。
  • 持续:netpoll 的 epoll/kqueue/IOCP 封装持续优化;runtime 的 netpoll 和调度器的配合持续打磨。
  • 相关演进:http.Server 的连接管理、超时、优雅关闭不断完善;Go 1.13+ 的 http.NewRequestWithContext 让请求级取消贯穿网络栈。

主线:Go 网络模型的核心,是”用 netpoll 把阻塞的 goroutine 从线程上摘下来挂起、有事件再唤醒”,从而让”同步阻塞的编程模型”和”异步多路复用的高性能”兼得。这个模型从 Go 诞生起就是它的灵魂,后续的演进都是在夯实和打磨它。


本章思考题

【思考题】

  1. “一个连接一个 goroutine”为什么不会变成”百万连接百万线程”?netpoll 在中间做了什么?

  2. 网络 I/O 的阻塞和 syscall(如磁盘 I/O)的阻塞,在 Go 里的处理有什么本质区别?

  3. 为什么说 Go 重新定义了”阻塞”?“阻塞本身不是问题,问题是阻塞时还霸占线程”这句话怎么理解?

  4. 一个连接断开后,它的读 goroutine 为什么可能泄漏?怎么防止?

  5. 三种 I/O 模型(BIO / NIO / 多路复用)各自的代价是什么?select 相对 epoll 到底差在哪三点?为什么”多路复用不是在所有场景下都更快”?

  6. epoll 的水平触发(LT)和边缘触发(ET)有什么本质区别?Go 的 netpoll 选了哪一个,为什么?用 ET 时必须遵守的铁律是什么?

  7. epoll / kqueue / IOCP 三者的核心差异是什么?“就绪通知”和”完成通知”分别指什么?Go 凭什么能用一套 netpoll 把它们统一起来,代价又是什么?

  8. 一个 goroutine 阻塞在 conn.Read 上时,从”读不到数据”到”被唤醒继续执行”,运行时内部依次发生了什么?为什么必须”先把 g 挂进 pollDesc 的等待队列,再 gopark”?

  9. Go 里”谁在跑 epoll_wait”?是几个固定的 netpoll 线程吗?如果所有 P 都在跑 CPU 密集任务,事件会不会饿死?

  10. SetReadDeadline 的底层是怎么实现的?为什么 deadline 是”绝对时刻”而不是”时长”?长连接读循环里 deadline 应该设在哪?读超时之后为什么通常应该关闭连接而不是重试?

  11. 有了 context.WithTimeout,为什么还需要 SetDeadline?context 取消能不能直接让一个阻塞中的 conn.Read 返回?真正能让它返回的路径有哪几条?

  12. CloseWrite()(半关闭)解决的是什么问题?对端”正常关闭""进程崩溃(RST)""断电/拔网线”三种死法,分别会让你的 Read/Write 看到什么?半开连接为什么危险,怎么兜底?

  13. http.Server 到底是”每请求一个 goroutine”还是”每连接一个 goroutine”?HTTP/2 下这个答案为什么会变?客户端侧 MaxIdleConnsPerHost 默认值是多少,不调会踩什么坑?为什么必须读完并关闭 response body?

  14. 零拷贝(sendfile / splice / writev)在什么条件下才划算?为什么 TLS 场景下基本用不上 sendfile?连接池大小应该按”QPS”还是”并发数”来定,依据是什么?

  15. 同为 epoll,为什么 Go 和 Netty 的代码长相完全不同?“执行到哪儿了”这件事,在 Go、Node.js、tokio、Netty 里分别由谁记账?为什么说 Go 卖的不是极致性能而是”默认正确”?

【参考答案】

  1. 因为阻塞在网络读上的 goroutine 会被 netpoll 从线程上摘下来挂起,并注册到 epoll 上,不占 OS 线程;线程被释放去跑别的 goroutine;epoll 报告有数据时,netpoll 再唤醒它。所以百万连接对应的是”百万个挂在 epoll 上的 goroutine + 几十个线程”,而不是百万线程。阻塞在 goroutine 视角是”阻塞”,在运行时视角是”挂起等待”。

  2. 网络 I/O 阻塞走 netpoll:goroutine 挂起、线程释放,成本极低,是”优雅挂起”。syscall 阻塞是线程级的:goroutine 卡在 syscall 里,线程也真的卡住;Go 的处理是临时多起线程顶上,释放 P 给新线程,保证其他 goroutine 能跑,但线程数会临时增加。所以网络 I/O 多不怕,磁盘 I/O 和 cgo 调用多要额外关注。

  3. 传统线程模型里,阻塞 = 线程被占用 + 上下文切换 + 资源锁死,是灾难。Go 里,goroutine 的”阻塞”(网络 I/O、channel、锁、sleep)大多是”逻辑阻塞”——goroutine 挂起,线程和 CPU 被释放去做别的事。所以阻塞本身不可怕,可怕的是”阻塞时还霸占线程”。Go 让”等待”几乎零成本,海量等待中的 goroutine 成为常态而非负担。

  4. 因为读 goroutine 阻塞在 conn.Read 上,如果连接断开、或对端异常、或读没有超时,goroutine 可能永远卡在 Read 上不退出。防止方法:defer conn.Close()(断开时关闭连接、读会返回错误);读返回错误(含 EOF)就退出循环;设置读超时(SetReadDeadline)或 context 取消(第十章);保证每个阻塞的读都有”出口”。

  5. BIO:一个线程死等一个连接,1 万连接 = 1 万线程,栈内存(默认 8MB)+ 调度开销 + 缓存污染直接拖垮系统,这是 C10K 的根源。NIO:非阻塞 read 立刻返回 EAGAIN,线程不睡,但不知道何时该重试,只能轮询(烧 CPU)或 sleep(加延迟),不配通知机制就是半成品。多路复用:一个调用盯住成千上万 fd,谁就绪报谁,等待成本与连接数解耦。 select 的三宗罪:①FD_SETSIZE=1024 硬上限;②每次调用都要把整个 fd 集合在用户态/内核态之间来回拷贝;③返回后必须用 FD_ISSET 做 O(n) 扫描才知道是谁就绪(poll 只修了①)。epoll 在内核常驻红黑树(关注的 fd)+ 就绪链表(就绪的 fd),epoll_ctl 一次注册长期有效,epoll_wait 只返回就绪的那几个,复杂度从 O(关注数) 降到 O(就绪数)。 不是所有场景都更快:epoll 的收益来自”大量连接 + 大部分时间空闲”。若只有少量连接且持续活跃(如 8 条连接打满 100G 网卡),每次通知都要走就绪链表 + 一次 epoll_wait + 一次用户态唤醒,纯属额外开销,阻塞 I/O 反而更快。精通的标志是知道它快在什么”流量形状”下,而不是记住”epoll 更快”。

  6. LT(默认):只要 fd 处于可读状态,epoll_wait 每次都报告它,允许一次只读一部分,语义安全,但会造成重复唤醒。ET(EPOLLET):只在状态变化时通知一次,剩下的数据不会再提醒。 Go 选了 ET(注册事件为 EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET)。理由:Go 的模型是”一次通知 → 唤醒一个 goroutine → 把活干完”,LT 的重复通知意味着反复的 park/unpark,是纯浪费;ET 的”通知一次、读尽为止”与模型天然契合。 铁律:用 ET 时 fd 必须非阻塞,且每次被唤醒后必须循环读到 EAGAIN 为止,否则剩余数据会永久饿死(数据在缓冲区里,但再也不会有事件来叫你)。工程建议:自己写 epoll 先用 LT,有明确理由和充分测试再上 ET。补充:Go 关注 EPOLLRDHUP 是为了及时感知对端半关闭;Go 不需要 EPOLLONESHOT,因为”一 fd 一 goroutine 顺序读写”本来就不需要在多线程间协调 fd 归属。

  7. 差异是就绪通知 vs 完成通知(Reactor vs Proactor):epoll_wait 返回时数据还在内核接收缓冲区,你要自己再 read 一次;kqueue(macOS/BSD)同样是就绪语义,但更通用,能在同一个 kqueue 上等文件变化、定时器、信号、子进程退出(EVFILT_*),且注册与等待都用 kevent 一个调用;IOCP(Windows)是完成语义 —— 你先提交 WSARecv 并把缓冲区交给内核,内核搬完数据后投递完成包,你 GetQueuedCompletionStatus 取到的是”我已经帮你读完了 N 字节”。所以严格说 epoll/kqueue 属于同步 I/O,IOCP 是真正的异步 I/O。 Go 的统一方式:抽象层不压在”事件模型”上,而压在”goroutine 何时能继续跑”上。平台无关的核心是 runtime.pollDesc,只管三件事:读写等待队列(rg/wg)、读写 deadline、生命周期状态。平台相关文件(netpoll_epoll.go / netpoll_kqueue.go / netpoll_iocp.go)只负责把”这个平台有事发生”翻译成”pollDesc 上有 g 该醒了”;对 IOCP,是把完成包折回成就绪语义,唤醒那个 g 后数据已在缓冲区里。完成包和就绪通知被压成了同一个信号。 代价:拿不到各平台的超额收益 —— IOCP 的多路提交、零分配固定缓冲区,Linux 上 io_uring 的批量提交与无系统调用,kqueue 的非网络事件源,Go 都没用。抽象的价值是能跑、心智统一,代价是每个平台都不是最优,这是一切跨平台运行时的宿命。

  8. ①Read 先试一次非阻塞读,有数据直接返回(快路径,零挂起);②返回 EAGAIN,走 poll_runtime_pollWait → netpollblock;③把当前 g 写进 pollDesc 的等待字段,然后才 gopark:g 从 _Grunning 变 _Gwaiting,M 被释放,P 去跑别的 g;④首次使用时 netpollopen 把这个 fd 一次性注册进 epoll(之后不再需要 epoll_ctl);⑤数据到达 → 内核协议栈 → epoll 就绪链表 → epoll_wait 返回;⑥netpollready/netpollunblock 把 g 从 pollDesc 摘出,状态回 _Grunnable,放进 g 链表;⑦injectglist 把链表批量塞回 P 的 runnext/本地队列/全局队列,g 恢复执行后再读一次,拿到数据返回。 顺序不能反:若先 gopark 再挂队列,则”park 完成”到”挂进队列”之间的窗口里事件到达时,runtime 找不到等待者,唤醒信号丢失,g 永久挂死 —— 经典的 lost wakeup 问题。类比:必须先登记到候诊名单,再去睡觉。 整个过程零次线程上下文切换(只有 goroutine 栈的保存恢复),这才是百万连接真正的成本模型。(内部函数名各版本略有出入,背流程、不要背名字。)

  9. 不是固定线程。Go 是”谁有空谁来 poll”,有三条路径:①sysmon 监控线程在循环里以 netpoll(0) 非阻塞兜底,防止所有 P 都忙时没人 poll;②调度循环 findRunnable 找不到活干时调用 netpoll(delay),拿到 g 后 injectglist 塞回队列(主路径);③startTheWorldWithSema(GC 结束后)也会 poll 一次。这个设计自动实现了 poller 的伸缩,“该配几个 poller 线程”这个问题的答案是 0 个固定线程。 会不会饿死:靠 sysmon 兜底 + Go 1.14 之后的异步抢占,长时间运行的 g 会被强制让出 P,所以不会永久饿死;但延迟会实实在在变差,所以 Go 里也要避免让一个 g 死循环占着 P。

  10. 底层:SetReadDeadline 最终走到 poll_runtime_pollSetDeadline,在 pollDesc 上挂一个 runtime timer(读 rtimer / 写 wtimer,Go 1.14 后是挂在 P 上的四叉堆)。定时器的回调不是去读数据,而是用 netpollunblock 唤醒等待的 g 并标记”唤醒原因是超时”;g 醒来后判断来源,是超时就返回包装成 *net.OpError 的 os.ErrDeadlineExceeded(Timeout() 为 true)。deadline 被翻译成了与 I/O 事件平级的唤醒源。 “绝对时刻”意味着一次设置长期有效,所以长连接读循环必须在每次读之前续期(SetReadDeadline(time.Now().Add(30*time.Second))),而不是循环外设一次 —— 否则只有第一次读有超时。同时要区分”空闲超时”(每 30 秒必须有数据,需每次续期)和”整体超时”(绝对时刻,设一次不动),混淆二者是长连接服务最常见的超时 bug。 读超时后应关闭连接:因为读超时只表示”截止前没凑够字节”,缓冲区里可能已读进一部分,TCP 字节流的位置已经动了。此时”重试一次读”会从错位的位置继续解析,得到一堆语义正确但内容错误的垃圾。写超时同样重要:对端只发不收会让发送缓冲区堆满、Write 挂死,没有写超时的服务在 Slowloris 慢速攻击面前是裸奔的。

  11. 不能(至少不能直接)。context 的取消只是一个通知,无法打断已经陷入内核/epoll 的等待。真正能让阻塞中的 conn.Read 返回的路径只有三条:①数据到达,netpoll 正常唤醒;②deadline 到期,runtime timer 唤醒;③另一条 goroutine 把这个 conn Close() 了,Read 立刻返回 “use of closed network connection”。 net/http 中请求 ctx 被取消时,服务端干的就是第③条 —— 关掉底层连接。所以二者是协作关系而非替代关系:context 负责在业务层传播”取消意图”,deadline 负责在 I/O 层保证”一定能醒过来”。第十章与本节是互补的两半,缺哪一半都会漏。

  12. CloseWrite() 发 FIN,语义是”我写完了,但我还想听你说完”,之后仍可继续 Read。它解决的是用 TCP 流结束信号当”消息结束符”:HTTP/1.0 没有 Content-Length 时靠关闭连接表示 body 结束(客户端读到 io.EOF 才知道收完);流式上传、部分 RPC、SMTP DATA 段同理。最干净的收尾:A 发完 → CloseWrite() → B 读到 EOF → B 响应 → Close()。(CloseRead() 基本不用,读会得到错误而非 EOF。) 三种死法的症状:①正常 Close()(FIN)→ Read 返回 n=0, err=io.EOF;Write 第一次可能成功,对端回 RST 后再写报 EPIPE(broken pipe)。②进程崩溃/被 RST → ECONNRESET(connection reset by peer)。③断电/拔网线/路由黑洞 → 什么都没有,Read 和 Write 都一直挂着。 第③种即半开连接:TCP 状态只存在于两端内核,链路断了双方都不知情,fd 永不释放,这是 TCP 的固有缺陷,协议层无法自动发现。兜底只能靠主动探测:TCP keepalive(SetKeepAlive(true) + SetKeepAlivePeriod,系统默认 2 小时等于没有,要改成几十秒到几分钟)和应用层心跳(ping/pong)。两者不重复 —— keepalive 只能证明对端内核活着,证明不了应用还在处理,长连接服务通常两个都配。间隔按”可接受检测延迟”和”心跳流量 = 连接数/间隔”推导,别抄数字。

  13. 是每连接一个 goroutine:Serve 里 accept 到一个连接就 go c.serve(),在这个 goroutine 内串行地”读请求 → 调 handler → 写响应”,直到 Keep-Alive 超时或对端关闭。推论:①同一连接上的请求是排队的(HTTP/1.1 pipelining 也是按序响应,真正的并发来自多条连接);②handler 阻塞只阻塞这一条连接,故障域被隔离到单连接(对比 Netty 阻塞 EventLoop 会卡住几千条连接);③**“每请求一个 goroutine”真正成立的是 HTTP/2** —— 一连接一 goroutine 管帧与流控,每个 stream 再起一个 goroutine 跑 handler,而 handler 代码一字不改,Go 自动把并发粒度从 TCP 连接层搬到了 stream 层。 客户端:MaxIdleConnsPerHost 默认 2。瞬间并发 100 个请求到同一主机时只有 2 条连接被复用,其余 98 条用完即关并进入 TIME_WAIT,端口迅速耗尽(cannot assign requested address)—— 高并发 Go 客户端第一步就该调大它。另外必须读完并关闭 response body(defer resp.Body.Close() + io.Copy(io.Discard, resp.Body)),只 Close 不读完,连接会被丢弃而非归还池中,连接池形同虚设。 服务端优雅关闭:Shutdown(ctx) 关 listener、立即关空闲连接、等活跃请求处理完(ctx 到期强关);Close() 立刻全断。注意 Shutdown 管不了被 Hijack 的连接和已升级的 WebSocket(conn 已脱离 http.Server,需自己维护注册表)—— 这是”Shutdown 卡住不退出”最常见的原因;而且它只是单向礼貌,发布 502 还得靠”LB 摘流量 + Shutdown + 客户端重试”三层一起做。

  14. 零拷贝的适用窗口是**“大而不需要修改的数据”。sendfile(文件页缓存直送 socket,不经用户态):Go 里 io.Copy(tcpConn, *os.File) 会自动走这条路,因为 *net.TCPConn 实现了 io.ReaderFrom;几十 KB 以下的小数据未必划算(省的是拷贝,多的是内核路径复杂度)。splice(两个 fd 间直接搬):io.Copy 从 TCPConn 到 TCPConn 时会尝试,适合代理/网关/sidecar。net.Buffers([][]byte)走 writev:一次调用写多个分散缓冲,适合”响应头 + 分段响应体”。 TLS 场景下基本用不上:数据必须进用户态加密才能发出去,sendfile 这条路被截断(Linux kTLS 可把加密下放到内核,但 Go 标准库不走),这也是 HTTPS 比 HTTP 贵的部分原因。 连接池大小应按并发数**定,而不是 QPS。依据 Little’s Law:并发数 = QPS × 平均响应时间(QPS 2000、RT 50ms → 需要并发 100,池子给 100~150 即可,给 2000 纯属给服务端堆大量半死不活的连接)。池子本质是”用空闲时占资源换需要时省握手”(TCP 握手 1 RTT、TLS 1.3 握手 1 RTT,跨机房十几到几十毫秒,抵得上业务层所有微优化的总和)。另外别把 TCP_NODELAY 关掉(Go 默认开启),否则小包与延迟 ACK 相互作用,延迟可能从亚毫秒涨到 40ms 量级;也别忘了检查 ulimit -n(systemd LimitNOFILE、Docker --ulimit nofile)和 fs.file-max。

  15. 因为四个 runtime 对同一个问题给出了不同答案:“I/O 没完成时,这段逻辑执行到哪儿了、上下文存在哪、谁负责接回去” —— 这个答案决定了网络代码的长相。 Go:运行时记账(真栈,挂起时调用栈原封不动,从下一条指令继续,panic/pprof 栈永远完整)。Node:语言 + 闭包记账(Promise 状态机,一次 await 就是一次 yield,栈在 await 处被拆散,异步错误栈经常丢帧)。tokio:编译器 + 你自己记账(无栈,Future::poll 返回 Pending 时把 Waker 注册进 reactor,事件到了从头重新 poll;Future 必须可重入、状态必须显式保存,代价是函数着色传染 async fn、混用同步库会阻塞 worker、还要应对 Pin/生命周期/Send 约束,收益是零成本抽象、无 GC、延迟抖动最小)。Netty:你自己记账(Channel 终身绑定 EventLoop 因而无需加锁,但 EventLoop 绝不能被阻塞,阻塞工作必须丢给业务线程池再用回调送回 —— 回调地狱不是语法问题,是”不能阻塞”这个架构约束的产物)。Java 21 虚拟线程正是把 Go 的 netpoll 思路搬进 JVM,但要改造二十多年积累的同步阻塞 API,包袱远比 Go 沉重。 Go 的取舍一句话:用”一个运行时 + 每 goroutine 最小 2KB 可增长栈”这点开销,换回”你什么都不用管”。 所以 Go 卖的从来不是极致性能,而是**“默认正确 + 够用的性能”** —— 省下的不是机器,是工程师的脑子和线上事故的夜晚。精通的标志是能说清这个交换的边界:绝大多数时候接受默认,极少数极致性能场景绕过它,且清楚绕过的代价。