刚开始写 Go 网络服务的时候,我一直有个疑问:为什么大家都敢“一连接一个 goroutine”?
如果按传统线程模型理解,一个连接一个执行单元,几万个连接不就把机器拖死了吗?尤其是大量连接都卡在 Read 上,难道不是几万个线程一起挂着?
后来才明白,Go 这里最关键的点是:goroutine 可以阻塞,但阻塞网络 I/O 时,Go 尽量不让操作系统线程也跟着一起阻塞。
这背后就是 runtime 的 netpoller。
一个简单的 TCP server 大概长这样:
func ServeTCP(ctx context.Context, addr string) error { lc := net.ListenConfig{} ln, err := lc.Listen(ctx, "tcp", addr) if err != nil { return err } defer ln.Close() serveCtx, stop := context.WithCancel(ctx) defer stop() var wg sync.WaitGroup gofunc() { <-serveCtx.Done() _ = ln.Close() }() for { conn, err := ln.Accept() if err != nil { stop() wg.Wait() if ctx.Err() != nil { return ctx.Err() } return err } wg.Add(1) gofunc() { defer wg.Done() handleConn(serveCtx, conn) }() }}func handleConn(ctx context.Context, conn net.Conn) { done := make(chan struct{}) gofunc() { select { case <-ctx.Done(): _ = conn.Close() case <-done: } }() defer conn.Close() defer close(done) buf := make([]byte, 4096) for { if err := conn.SetReadDeadline(time.Now().Add(30 * time.Second)); err != nil { return } n, err := conn.Read(buf) if err != nil { return } if _, err := conn.Write(buf[:n]); err != nil { return } if ctx.Err() != nil { return } }}这个写法有几个关键点:
• Accept到一个连接,就起一个 goroutine 处理• ctx取消时关闭 listener,让阻塞的Accept退出• 用 WaitGroup等已经接进来的连接 goroutine 结束• 连接处理里监听 ctx,取消时关闭连接,让阻塞的Read退出• 每次读之前设置 deadline,避免连接一直不发数据 • 错误正常返回,不用 panic
表面上看,conn.Read(buf) 是阻塞的。如果客户端一直不发数据,这个 goroutine 就会停在那里。
问题来了:它停在那里时,占着一个 OS thread 吗?
正常网络连接下,不会一直占着。
阻塞 goroutine,不等于阻塞线程
Go 的网络连接底层会被设置成非阻塞模式。你在代码里写的是阻塞式 API:
n, err := conn.Read(buf)但运行时发现这个 fd 暂时不可读时,不是让当前线程傻等,而是把当前 goroutine 挂起来,把 fd 交给 netpoller 监听。
大概是这个流程:
goroutine 调用 conn.Read │ ▼底层 fd 暂时没有数据 │ ▼goroutine 挂起,记录它在等这个 fd 可读 │ ▼M 线程继续去跑别的 goroutine │ ▼netpoller 收到 fd 可读事件 │ ▼把等待的 goroutine 放回可运行队列所以 Go 可以让很多 goroutine 同时“阻塞在网络读写上”,而不是让很多 OS thread 同时卡住。
这也是为什么 Go 里一连接一个 goroutine 不是离谱写法。你写起来像同步阻塞代码,运行时在下面做了事件驱动。
netpoller 到底是什么
netpoller 可以理解成 Go runtime 内置的网络事件轮询器。
不同系统底层实现不一样:
• Linux 上主要靠 epoll • macOS、BSD 上主要靠 kqueue • Windows 上是另一套机制
不要把 netpoller 简化成“Go 就是 epoll”。更准确的说法是:Go runtime 提供了一层统一的网络轮询抽象,各个平台用自己的 I/O 多路复用机制实现。
runtime 源码里 runtime/netpoll.go 就把这层抽象列出来了:初始化 poller、把 fd 注册进去、等待网络事件、唤醒被事件命中的 goroutine。
Linux 版本里能看到 EpollCreate1、EpollCtl、EpollWait。macOS 版本里能看到 kqueue、kevent。
你平时不需要直接调用这些东西。标准库 net 包把它们封在了 net.Conn、net.Listener 后面。
这也是 Go 网络编程舒服的地方:代码写起来是同步的,性能模型接近事件驱动。
这不代表所有阻塞都没成本
netpoller 主要解决的是网络 fd 的等待问题。
如果你在 goroutine 里做的是普通文件 I/O、CPU 密集计算、长时间持锁、调用 C 代码,那就不是同一个模型了。
比如:
mu.Lock()defer mu.Unlock()resp, err := http.Get("https://example.com")if err != nil { return err}defer resp.Body.Close()这段代码的问题不在 http.Get 本身,而在你持锁做网络请求。这个请求慢了,所有想拿这把锁的 goroutine 都得等。
netpoller 能帮你把网络等待从 OS thread 上拆开,但不能帮你修掉错误的同步边界。
这也接上上一篇:并发安全不只是“有没有 data race”,还要看你的临界区有没有把不该放进去的慢操作也锁住。
Deadline:网络服务必须有超时
net.Conn 提供了三个 deadline 方法:
conn.SetDeadline(t)conn.SetReadDeadline(t)conn.SetWriteDeadline(t)这里有个细节很重要:deadline 是一个绝对时间点,不是“从现在开始持续多久”。
_ = conn.SetReadDeadline(time.Now().Add(30 * time.Second))超过这个时间点之后,当前阻塞的读和未来的读都会失败,直到你重新设置新的 deadline。
所以如果你想做 idle timeout,应该在每次成功读写后重新延长 deadline,而不是连接建立时只设置一次。
func readMessage(conn net.Conn, timeout time.Duration) ([]byte, error) { if err := conn.SetReadDeadline(time.Now().Add(timeout)); err != nil { return nil, err } buf := make([]byte, 4096) n, err := conn.Read(buf) if err != nil { if errors.Is(err, os.ErrDeadlineExceeded) { return nil, fmt.Errorf("read timeout: %w", err) } return nil, err } return buf[:n], nil}如果完全不设 deadline,一个恶意客户端可以连上来,然后一直不发完整请求。你的 goroutine 会一直等,连接资源也一直占着。
goroutine 很便宜,但不是免费。fd、内存、连接状态、业务资源都不是无限的。
HTTP server 的连接生命周期
我们平时更多写的是 net/http:
func RunHTTP(ctx context.Context, addr string, handler http.Handler) error { srv := &http.Server{ Addr: addr, Handler: handler, ReadHeaderTimeout: 3 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, } errCh := make(chan error, 1) gofunc() { errCh <- srv.ListenAndServe() }() select { case err := <-errCh: if errors.Is(err, http.ErrServerClosed) { return nil } return err case <-ctx.Done(): shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := srv.Shutdown(shutdownCtx); err != nil { return err } err := <-errCh if errors.Is(err, http.ErrServerClosed) { return nil } return err }}不要小看这几个 timeout。
ReadHeaderTimeout 限制读请求头的时间,能挡住很多慢速发送 header 的连接。
ReadTimeout 限制读完整请求的时间,包括 body。
WriteTimeout 限制响应写回的时间。
IdleTimeout 限制 keep-alive 连接空闲等待下一次请求的时间。
直接用 http.ListenAndServe(":8080", handler) 当然能跑,但它没有给你机会配置这些字段。写 demo 可以,公网服务最好别裸着用。
标准库里也是一连接一个 goroutine
net/http 的 Server.Serve 里,核心流程很直白:
Accept 一个连接创建 conn标记 StateNewgo c.serve(connCtx)也就是说,HTTP/1.x 下标准库也是每个连接一个 goroutine。
这个 goroutine 里会循环读取请求、调用 handler、写响应。如果连接可以复用,就进入 idle 状态,等下一个请求;如果不能复用,就关闭。
大致状态是:
StateNew │ ▼StateActive ── handler 处理请求 │ ├── 不复用/出错 ──> StateClosed │ └── keep-alive ──> StateIdle │ └── 下一个请求再回到 StateActiveHTTP/2 会复杂一些,因为一个连接上可以同时有多个 stream。ConnState 也只能表示连接整体状态,不能拿它当每个请求的生命周期钩子。
但主线没有变:连接等待网络事件时,不是靠一个 OS thread 傻等,而是交给 runtime 的网络轮询器。
handler 里的 Context 很关键
HTTP handler 里拿到的 r.Context() 不是摆设。
func userHandler(store *Store) http.HandlerFunc { returnfunc(w http.ResponseWriter, r *http.Request) { user, err := store.FindUser(r.Context(), r.PathValue("id")) if err != nil { if errors.Is(err, context.Canceled) { return } http.Error(w, "query user failed", http.StatusInternalServerError) return } if err := json.NewEncoder(w).Encode(user); err != nil { return } }}客户端断开、请求被取消、handler 返回,这个 context 都会走到生命周期终点。数据库查询、RPC 调用、下游 HTTP 请求都应该把它传下去。
不要在 handler 里新起一个完全脱离请求的 context.Background() 去做阻塞操作。那样客户端都走了,你的下游调用还在继续跑。
如果确实要做异步任务,应该把它交给队列或者后台 worker,并明确它和当前请求的关系,而不是偷偷开一个没人管的 goroutine。
连接多,不代表可以不做背压
Go 能撑很多连接,不等于你的业务能撑无限请求。
网络层能把 goroutine 挂起来,但下面这些资源还是会被耗尽:
• 文件描述符 • 内存 • 数据库连接池 • 下游服务并发 • 每个请求里的业务锁 • 日志、队列、缓存连接
所以真正的服务还要有背压:
func Limit(next http.Handler, max int) http.Handler { sem := make(chan struct{}, max) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { select { case sem <- struct{}{}: deferfunc() { <-sem }() next.ServeHTTP(w, r) case <-r.Context().Done(): return default: http.Error(w, "server busy", http.StatusServiceUnavailable) } })}这里限制的不是连接数,而是进入业务 handler 的并发数。很多时候这比盲目调大系统参数更有用。
最后把边界说清楚
Go 网络模型厉害的地方,是把同步写法和事件驱动运行时结合起来了。
你可以写:
n, err := conn.Read(buf)代码像阻塞调用,心智负担低。
运行时会在 fd 不可读时挂起 goroutine,让 OS thread 去跑别的活;等 epoll、kqueue 这类机制通知 fd 就绪,再把 goroutine 放回调度队列。
但它没有替你解决所有问题。deadline 要你设,handler 的 context 要你传,业务并发要你限,错误要你判断。
下一篇就接着看错误处理。网络代码里最常见的不是“有没有错误”,而是怎么区分 EOF、超时、客户端取消、服务端关闭、临时网络错误,以及这些错误到底该不该打日志、该不该重试。
夜雨聆风