跳转到正文
zeno's blog
返回

Go 网络(一):goroutine-per-connection 模型与生产实践

Table of contents

Open Table of contents

TL;DR

Go 的网络编程模型是 goroutine-per-connection:每个连接一个 goroutine,写同步阻塞风格的代码,runtime 的 netpoller 在底层用 epoll/kqueue 实现异步 I/O。关键在于超时管理(Deadline 是绝对时间不是超时)和连接池配置(MaxIdleConnsPerHost 默认 2 是生产环境的坑)。


1. 编程模型:goroutine-per-connection

与事件循环模型的本质区别

维度Go(goroutine-per-connection)Node.js(事件循环)
代码风格同步阻塞(看起来)async/await/callback
并发单元goroutine(~2KB,M:N 调度)闭包/Promise(单线程)
多核利用天然(GOMAXPROCS)需 cluster 模块
CPU 密集型可以,异步抢占保证公平阻塞事件循环
错误处理if err != nil(线性).catch() / try-catch(嵌套)

Go 的优势在于:写出来像阻塞代码的可读性,跑起来是异步的性能。事件循环的复杂性(回调地狱、状态机)被 runtime 完全吸收。

goroutine 不是免费的

每个 goroutine ~2KB 初始栈(按需增长),加上调度开销。100 万并发连接 ≈ 2GB 内存纯栈开销。必须限制并发数

// 信号量模式限制并发连接
var sem = make(chan struct{}, 10000)

for {
    conn, err := listener.Accept()
    if err != nil { continue }
    sem <- struct{}{}  // 获取槽位,满则阻塞
    go func(c net.Conn) {
        defer func() { c.Close(); <-sem }()
        handleConn(c)
    }(conn)
}

2. net 包核心接口

接口层次

// 流式连接(TCP、Unix socket)
type Conn interface {
    Read(b []byte) (n int, err error)
    Write(b []byte) (n int, err error)
    Close() error
    LocalAddr() Addr
    RemoteAddr() Addr
    SetDeadline(t time.Time) error
    SetReadDeadline(t time.Time) error
    SetWriteDeadline(t time.Time) error
}

// 监听器
type Listener interface {
    Accept() (Conn, error)
    Close() error
    Addr() Addr
}

// 数据报连接(UDP)
type PacketConn interface {
    ReadFrom(b []byte) (n int, addr Addr, err error)
    WriteTo(b []byte, addr Addr) (n int, err error)
    Close() error
    // ... Deadline 方法同上
}

net.Dialer — 连接配置

dialer := &net.Dialer{
    Timeout:   5 * time.Second,   // 连接超时
    KeepAlive: 30 * time.Second,  // TCP keep-alive 探测间隔
    Resolver:  customResolver,     // 自定义 DNS 解析器
    Control: func(network, address string, c syscall.RawConn) error {
        // 在 bind/connect 前操作原始 socket
        // 设置 SO_REUSEPORT、TCP_NODELAY 等
        return nil
    },
    FallbackDelay: 300 * time.Millisecond, // Happy Eyeballs: IPv6 先试 300ms
}
conn, err := dialer.DialContext(ctx, "tcp", "example.com:443")

net.Listen 底层流程

net.Listen("tcp", ":8080")
  → ListenConfig.Listen()
    → sysListener.listenTCP()
      → net.internetSocket()
        → net.socket()
          → syscall.Socket(AF_INET, SOCK_STREAM, 0)
          → syscall.SetNonblock(fd, true)  // 非阻塞!
          → syscall.Bind(fd, addr)
          → syscall.Listen(fd, backlog)
          → newFD → fd.init() → pollDesc.init()  // 注册到 netpoller

DNS 解析:两条路径

解析器条件代价阻塞什么
纯 Go(默认 Unix)/etc/resolv.conf 可解析只占一个 goroutinenetpoller 管理,不阻塞 M
cgo(macOS 默认)复杂 nsswitch 规则、macOS占一个 OS 线程阻塞 M,限制 500 并发

控制:GODEBUG=netdns=goGODEBUG=netdns=cgo

Go 不缓存 DNS。每次新建连接都会发起 DNS 查询(但 http.Transport 的连接池复用减轻了这个问题)。如需缓存,用第三方 dnscache 包。


3. TCP 编程模式

基础 TCP 服务器

// Go 1.21+
func main() {
    listener, err := net.Listen("tcp", ":8080")
    if err != nil {
        log.Fatal(err)
    }
    defer listener.Close()

    for {
        conn, err := listener.Accept()
        if err != nil {
            log.Printf("accept: %v", err)
            continue
        }
        go handleConn(conn)
    }
}

func handleConn(conn net.Conn) {
    defer conn.Close()
    reader := bufio.NewReader(conn)
    for {
        conn.SetReadDeadline(time.Now().Add(30 * time.Second))
        line, err := reader.ReadString('\n')
        if err != nil {
            return  // 超时、EOF、或其他错误
        }
        conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
        fmt.Fprintf(conn, "Echo: %s", line)
    }
}

优雅关闭

type Server struct {
    listener net.Listener
    quit     chan struct{}
    wg       sync.WaitGroup
}

func NewServer(addr string) *Server {
    s := &Server{quit: make(chan struct{})}
    ln, err := net.Listen("tcp", addr)
    if err != nil {
        log.Fatal(err)
    }
    s.listener = ln
    s.wg.Add(1)
    go s.serve()
    return s
}

func (s *Server) serve() {
    defer s.wg.Done()
    for {
        conn, err := s.listener.Accept()
        if err != nil {
            select {
            case <-s.quit:
                return
            default:
                log.Printf("accept: %v", err)
            }
        } else {
            s.wg.Add(1)
            go func() {
                defer s.wg.Done()
                s.handleConn(conn)
            }()
        }
    }
}

func (s *Server) Stop() {
    close(s.quit)
    s.listener.Close()  // Accept 立即返回错误
    s.wg.Wait()         // 等所有连接处理完毕
}

长度前缀帧协议

TCP 是字节流,没有消息边界。自定义协议需要自行分帧:

const MaxPayloadSize = 10 << 20 // 10MB

// 编码:4 字节大端长度 + payload
func Encode(w io.Writer, payload []byte) error {
    header := make([]byte, 4)
    binary.BigEndian.PutUint32(header, uint32(len(payload)))
    if _, err := w.Write(header); err != nil {
        return err
    }
    _, err := w.Write(payload)
    return err
}

// 解码:先读 4 字节长度,再读 payload
func Decode(r io.Reader) ([]byte, error) {
    header := make([]byte, 4)
    if _, err := io.ReadFull(r, header); err != nil {
        return nil, err
    }
    length := binary.BigEndian.Uint32(header)
    if length > MaxPayloadSize {
        return nil, fmt.Errorf("payload too large: %d", length)
    }
    payload := make([]byte, length)
    if _, err := io.ReadFull(r, payload); err != nil {
        return nil, err
    }
    return payload, nil
}

io.ReadFull 是关键——Read 可能返回部分数据(TCP 的流特性),ReadFull 保证读满指定字节数。

buffer 复用

高吞吐场景下,频繁分配 buffer 导致 GC 压力:

var bufPool = sync.Pool{
    New: func() any { b := make([]byte, 4096); return &b },
}

func handleConn(conn net.Conn) {
    bp := bufPool.Get().(*[]byte)
    defer bufPool.Put(bp)
    buf := *bp
    // 使用 buf...
}

4. UDP 编程

// UDP 服务器
func udpServer() {
    addr, _ := net.ResolveUDPAddr("udp", ":9999")
    conn, _ := net.ListenUDP("udp", addr)
    defer conn.Close()

    buf := make([]byte, 1500) // MTU 通常 1500
    for {
        n, remoteAddr, err := conn.ReadFromUDP(buf)
        if err != nil { continue }
        // 处理 buf[:n]
        conn.WriteToUDP([]byte("ACK"), remoteAddr)
    }
}

TCP vs UDP 选择

场景选 TCP选 UDP
可靠传输❌ 需自行实现
实时性要求极高❌ 重传延迟✅ 丢了就丢
遥测/日志可以✅ fire-and-forget
服务发现可以✅ 广播/组播
Web/API

5. 超时管理(最重要的部分)

Deadline 是绝对时间,不是超时时间

这是 Go 网络编程最大的认知陷阱。SetDeadline(t) 设置的是绝对时钟时间,不是”从现在开始 N 秒”的倒计时。一旦设置,所有后续操作共享同一个截止时间。

// ❌ 错误理解:每次 Read 都有 5 秒
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
conn.Read(buf1)  // 3 秒后读到
conn.Read(buf2)  // 只剩 2 秒!

// ✅ 正确用法:每次操作前重设
for {
    conn.SetReadDeadline(time.Now().Add(5 * time.Second))
    n, err := conn.Read(buf)
    if err != nil { break }
}

HTTP Server 超时层次

  ReadHeaderTimeout    ReadTimeout              WriteTimeout
  |---- headers ----|---- body ----|---- handler + write ----|
  ^                                                          ^
  Accept                                                   Close

  IdleTimeout
  |---- 等待下一个 keep-alive 请求 ----|
超时控制什么默认值
ReadHeaderTimeout读取请求头的时间无(危险)
ReadTimeout从 Accept 到请求体读完
WriteTimeout从请求头读完到响应写完(HTTPS 包含 TLS 握手)
IdleTimeoutkeep-alive 连接等待下一个请求

http.ListenAndServe() 不设置任何超时,不能用于生产环境。 必须显式创建 http.Server

srv := &http.Server{
    Addr:              ":8080",
    Handler:           mux,
    ReadHeaderTimeout: 2 * time.Second,
    ReadTimeout:       5 * time.Second,
    WriteTimeout:      10 * time.Second,
    IdleTimeout:       60 * time.Second,
}

注意:这些是 TCP 层的截止时间,不是 handler 执行时间限制。handler 可以跑到 WriteTimeout 之后——只是写操作会失败。限制 handler 执行时间用 http.TimeoutHandler

handler := http.TimeoutHandler(myHandler, 5*time.Second, "timeout\n")

HTTP Client 超时层次

|-------------- http.Client.Timeout(覆盖全流程)--------------|
|-- Dial --|-- TLS --|-- Request --|-- Resp Headers --|-- Body --|
  Dialer     TLS       (write)     ResponseHeader       (read)
 .Timeout  Handshake               Timeout
           Timeout
transport := &http.Transport{
    DialContext: (&net.Dialer{
        Timeout:   5 * time.Second,   // TCP 连接超时
        KeepAlive: 30 * time.Second,  // keep-alive 探测
    }).DialContext,
    TLSHandshakeTimeout:   5 * time.Second,
    ResponseHeaderTimeout: 10 * time.Second,
    ExpectContinueTimeout: 1 * time.Second,
    IdleConnTimeout:       90 * time.Second,
}
client := &http.Client{
    Transport: transport,
    Timeout:   30 * time.Second,  // 整体超时
}

context.Context vs Client.Timeout


6. 连接池管理

http.Transport 默认值(坑)

参数默认值问题
MaxIdleConns100全局空闲连接上限,通常够
MaxIdleConnsPerHost2每个 host 只保留 2 个空闲连接——这是大坑
MaxConnsPerHost0(无限)无限制
IdleConnTimeout90s空闲连接存活时间

MaxIdleConnsPerHost = 2 的后果

100 个 goroutine 并发请求同一个 host:

  1. 100 个连接被创建
  2. 请求完成后,只有 2 个连接被保留为空闲
  3. 其余 98 个连接关闭 → 进入 TIME_WAIT(2MSL,通常 60 秒)
  4. 下次请求又创建 98 个新连接
  5. 循环几次后:数千个 TIME_WAIT socket → 临时端口耗尽 → EADDRNOTAVAIL

修复

transport := &http.Transport{
    MaxIdleConnsPerHost: 100,  // 匹配预期并发数
    MaxConnsPerHost:     100,  // 限制总连接数
    IdleConnTimeout:     90 * time.Second,
}

连接归还条件

响应体 必须被读完并关闭,连接才能归还到池:

resp, err := client.Get(url)
if err != nil { return err }
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)  // 必须 drain!

不 drain 的后果:连接无法复用 → 每次请求创建新连接 → fd 泄漏 + goroutine 泄漏。

HTTP/2 差异

HTTP/2 单连接多路复用,连接使用中也不会被从空闲池移除。默认 100 个并发 stream/连接(由 server SETTINGS 控制)。

最佳实践:每个上游服务独立 Client

var userServiceClient = &http.Client{
    Transport: &http.Transport{
        MaxIdleConnsPerHost: 50,
        IdleConnTimeout:     90 * time.Second,
    },
    Timeout: 5 * time.Second,
}

var orderServiceClient = &http.Client{
    Transport: &http.Transport{
        MaxIdleConnsPerHost: 100,
        IdleConnTimeout:     90 * time.Second,
    },
    Timeout: 10 * time.Second,
}

避免不同上游服务的连接池互相干扰。


7. 生产环境 Checklist

HTTP Server

srv := &http.Server{
    Addr:              ":8080",
    Handler:           mux,
    ReadHeaderTimeout: 2 * time.Second,   // ✅ 防 Slowloris
    ReadTimeout:       5 * time.Second,   // ✅ 限请求体读取
    WriteTimeout:      10 * time.Second,  // ✅ 限响应写入
    IdleTimeout:       60 * time.Second,  // ✅ 限 keep-alive
    MaxHeaderBytes:    1 << 20,           // ✅ 限请求头大小
}

优雅关闭

ctx, stop := signal.NotifyContext(context.Background(),
    syscall.SIGINT, syscall.SIGTERM)
defer stop()

go func() {
    if err := srv.ListenAndServe(); err != http.ErrServerClosed {
        log.Fatal(err)
    }
}()

<-ctx.Done()

// 1. 标记为不健康(让 LB 摘流量)
isShuttingDown.Store(true)
time.Sleep(5 * time.Second)

// 2. 优雅关闭(等待已有连接处理完)
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
srv.Shutdown(shutdownCtx)

TLS

tlsConfig := &tls.Config{
    MinVersion: tls.VersionTLS13,  // 或 VersionTLS12 兼容旧客户端
    // 不要手动设置 CipherSuites — Go 1.17+ 自动排序(确定)
    // 不要设置 CurvePreferences — 默认即最优
}

OS 调优(高并发场景)

ulimit -n 200000                                    # fd 上限
sysctl -w net.core.somaxconn=65535                  # listen backlog
sysctl -w net.ipv4.ip_local_port_range="10000 65535" # 临时端口范围
sysctl -w net.ipv4.tcp_tw_reuse=1                   # TIME_WAIT 复用
sysctl -w net.ipv4.tcp_fin_timeout=15               # FIN-WAIT-2 超时

8. 选型对比

net/http vs fasthttp

维度net/httpfasthttp
性能基准线合成测试快 ~10x,生产快 ~3x
HTTP/2✅ 自动(TLS ALPN)❌ 不支持
生态兼容✅ 所有 Go middleware❌ API 不兼容
内存分配每请求分配零分配热路径(对象复用)
适用场景绝大多数项目极端吞吐 + 小请求 + 不需要 HTTP/2

除非有明确的性能瓶颈证据,否则用 net/http。

Raw TCP vs HTTP vs gRPC

协议延迟吞吐适用场景
Raw TCP最低最高交易系统、游戏服务器、自定义协议
HTTP/2高(多路复用)公共 API、Web 服务
gRPC低(protobuf)微服务间通信、跨语言、流式

Pitfalls

1. http.DefaultClient 没有超时

http.Get(url)  // ← 用的是 DefaultClient,Timeout = 0(永不超时)

慢服务器会让 goroutine 永远挂起。永远不要在生产环境用 http.DefaultClient

2. 不 drain response body 导致连接泄漏

resp, _ := http.Get(url)
// 忘了 resp.Body.Close() 或者 Close 了但没 drain
// → 连接无法归还池 → fd 泄漏 + goroutine 泄漏

3. MaxIdleConnsPerHost = 2 导致 TIME_WAIT 爆炸

见第 6 节。http.DefaultTransport 的默认值对高并发场景是灾难。

4. Deadline 不重设导致后续操作提前超时

见第 5 节。SetDeadline 设的是绝对时间,后续操作继承剩余时间。

5. http.ListenAndServe 无超时保护

所有超时默认为零(无限),容易被 Slowloris 攻击打垮。

6. GOMAXPROCS 不感知容器 CPU 配额

15 CPU 的宿主机上,容器限 300m CPU,Go 仍然创建 15 个 P → CFS 节流严重。解法:用 Uber 的 automaxprocs 或手动 GOMAXPROCS=1。Go 1.25 起开始自动感知 cgroup。(需验证)

7. localhost 的双栈陷阱

net.Dial("tcp", "localhost:8080") 可能同时尝试 IPv6 和 IPv4。如果 IPv6 失败,返回的错误可能掩盖真正的 IPv4 错误。调试时用 127.0.0.1 替代 localhost


信息来源


分享这篇文章:

上一篇
Go 服务:容量评估与扩容决策
下一篇
C++ 竞赛:ACM 模式 I/O 的组合拳