Go 里“一个连接一个 goroutine”的写法能撑起几万个连接,关键在网络轮询器:阻塞的网络 IO 并不真的阻塞一个线程。这篇用线程数和 goroutine 状态验证:连接多了线程不增长,而绕开轮询器的阻塞系统调用则一个占一个线程。
100 条 TCP 连接,服务端每条一个 goroutine 阻塞在 Read 上:NumGoroutine = 102;线程创建数 5 → 5 goroutine 状态:[IO wait]×101 [running]×1 lsof 里属于本进程的 KQUEUE 描述符:4u KQUEUE 2000 条 TCP 连接,服务端每条一个 goroutine 阻塞在 Read 上:NumGoroutine = 2002;线程创建数 5 → 12 goroutine 状态:[IO wait]×2001 [running]×1 lsof 里属于本进程的 KQUEUE 描述符:4u KQUEUE
服务端每个连接一个 goroutine,在 Read 里“阻塞”时实际是把这个描述符登记到轮询器(macOS 上是 kqueue,Linux 上是 epoll),然后 goroutine 挂起,线程去跑别的 goroutine;数据到达时轮询器唤醒它。goroutine 的状态显示为 [IO wait],而不是占着线程的 [syscall]。进程里只有一个 kqueue 描述符。线程从 5 涨到 12 是调度与别的原因,我没有逐个解释这 7 个线程。
200 个 goroutine 各自从 os.Pipe 的读端 Read(阻塞):线程创建数 5 → 6;状态 [IO wait]×200 [running]×1 200 个 goroutine 各自对阻塞的 pipe 描述符直接 syscall.Read:线程创建数 5 → 203;状态 [running]×1 [syscall]×200
对照实验里,把 pipe 描述符直接交给 syscall.Read(阻塞模式),每个阻塞的调用占住一个线程(线程数 +198);而 os.Pipe() 返回的 os.File 显然走了轮询器(状态是 [IO wait],我没有读 os 包里把描述符设为非阻塞并注册的代码),同样的 200 个阻塞读只多一个线程。结论:想让大量阻塞 IO 便宜,要用 net 与 os.File 这类封装,而不是自己 syscall。普通磁盘文件不能被轮询,读写它们仍然是阻塞系统调用(本文没有试)。
SetReadDeadline(100ms) 之后 Read:101 ms 后返回,错误:read tcp 127.0.0.1:64775->127.0.0.1:64774: i/o timeout;是超时错误?true
SetReadDeadline 把超时时刻交给运行时的定时器;到点后阻塞的 Read 返回 i/o timeout,错误实现了 net.Error 且 Timeout() 为真。实测 100 ms 的超时 101 ms 后返回。