乐于分享
好东西不私藏

AI 时代重新学 Go(八):netpoller 底层,Go 为什么能撑住这么多连接

AI 时代重新学 Go(八):netpoller 底层,Go 为什么能撑住这么多连接

刚开始写 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 版本里能看到 EpollCreate1EpollCtlEpollWait。macOS 版本里能看到 kqueuekevent

你平时不需要直接调用这些东西。标准库 net 包把它们封在了 net.Connnet.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                         │                         └── 下一个请求再回到 StateActive

HTTP/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、超时、客户端取消、服务端关闭、临时网络错误,以及这些错误到底该不该打日志、该不该重试。