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 可解析 | 只占一个 goroutine | netpoller 管理,不阻塞 M |
| cgo(macOS 默认) | 复杂 nsswitch 规则、macOS | 占一个 OS 线程 | 阻塞 M,限制 500 并发 |
控制:GODEBUG=netdns=go 或 GODEBUG=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 握手) | 无 |
IdleTimeout | keep-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
Client.Timeout:固定超时,简单粗暴context.WithTimeout:可手动 Cancel,更灵活- 注意:context 取消会关闭底层连接(不可复用),
Client.Timeout也是
6. 连接池管理
http.Transport 默认值(坑)
| 参数 | 默认值 | 问题 |
|---|---|---|
MaxIdleConns | 100 | 全局空闲连接上限,通常够 |
MaxIdleConnsPerHost | 2 | 每个 host 只保留 2 个空闲连接——这是大坑 |
MaxConnsPerHost | 0(无限) | 无限制 |
IdleConnTimeout | 90s | 空闲连接存活时间 |
MaxIdleConnsPerHost = 2 的后果
100 个 goroutine 并发请求同一个 host:
- 100 个连接被创建
- 请求完成后,只有 2 个连接被保留为空闲
- 其余 98 个连接关闭 → 进入
TIME_WAIT(2MSL,通常 60 秒) - 下次请求又创建 98 个新连接
- 循环几次后:数千个
TIME_WAITsocket → 临时端口耗尽 →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/http | fasthttp |
|---|---|---|
| 性能 | 基准线 | 合成测试快 ~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。
信息来源
- The complete guide to Go net/http timeouts (Cloudflare)(确定)
- Gotchas in the Go Network Packages Defaults (Martin Baillie)(确定)
- Graceful shutdown of a TCP server in Go (Eli Bendersky)(确定)
- net package documentation(确定)
- DNS Resolution in Go and Cgo (Grab Engineering)(确定)
- Go 源码
net/dial.go、net/fd_posix.go、internal/poll/fd_unix.go(确定)