Go net/http 服务端底层实现全解析:每连接一个 goroutine、路由决策树与 HTTP/2 多路复用


引言:一个高频但常被答错的面试题

「Go 的 HTTP 服务器为什么能支撑高并发?」很多候选人脱口而出:「一个请求一个 goroutine!」——这是错的。对照 net/http 源码就会发现:HTTP/1.x 下是一条 TCP 连接一个 goroutine,请求在这条连接上顺序处理;只有 HTTP/2 才因为多路复用,退化为每个流一个 goroutine。

再追两问:「ListenAndServe 底层做了什么?」「http.HandleFunc 注册的路由是怎么匹配到 handler 的?」能答到源码层的人寥寥无几。本文对照 go1.26.0 的 src/net/http 逐一核实,从 accept 循环讲到路由决策树。goroutine 模型部分可与 第 11 期 GMP 调度 对照阅读。

一、Server.Serve:accept 循环与每连接一个 goroutine

http.ListenAndServe(addr, handler) 最终调用 Server.Serve(l),其核心是一个无限循环(照录 go1.26.0 源码结构):

for {
    rw, err := l.Accept()
    if err != nil {
        // 临时错误(如文件描述符耗尽)按 5ms 起步指数退避,上限 1s
        // 非临时错误或服务器已关闭则直接返回
        // ...
    }
    c := s.newConn(rw)
    c.setState(c.rwc, StateNew, runHooks)
    go c.serve(connCtx)   // 关键点:一条连接一个 goroutine
}

三个面试加分细节:

  1. 每条连接一个 goroutine,连接关闭 goroutine 才退出。所以高并发靠的是 goroutine 的轻量(初始栈 2KB 级别、GMP 调度),而不是「每请求一个 goroutine」。
  2. accept 失败不会直接退出:临时性错误(net.Error.Temporary)从 5ms 起步翻倍退避,上限 1 秒,然后重试——服务器不会因为瞬时抖动挂掉。
  3. 每个连接可以携带 context:ConnContext 钩子能为每条连接的请求注入自定义 context(与 第 36 期 context 底层 的 propagateCancel 衔接)。

二、conn.serve:HTTP/1.x 请求在连接 goroutine 上顺序执行

连接 goroutine c.serve 内部是一个 keep-alive 循环:读取请求 → 调用 handler → 写回响应 → 复用连接读下一个请求。源码里有一段直白的注释,值得背下来:

// HTTP cannot have multiple simultaneous active requests.[*]
// Until the server replies to this request, it can't read another,
// so we might as well run the handler in this goroutine.
serverHandler{c.server}.ServeHTTP(w, w.req)

翻译过来:HTTP/1.x 协议本身不允许一条连接上同时有多个在途请求(不算实践中几乎不用的 pipelining),所以 handler 直接在连接 goroutine 上同步执行——白白多开 goroutine 没有任何收益。这就是为什么 handler 里的慢操作(sleep、慢查询)会阻塞整条连接上的后续请求,长连接 + 慢 handler 是高并发服务的经典陷阱。

handler 的寻找逻辑在 serverHandler.ServeHTTP:

handler := sh.srv.Handler
if handler == nil {
    handler = DefaultServeMux   // 这就是 http.HandleFunc 注册到的地方
}
handler.ServeHTTP(rw, req)

http.HandleFunc 用的是包级变量 DefaultServeMux,传了自定义 mux 时包级注册完全失效——这是很多人排查「路由不生效」时的盲区。

三、HTTP/2:退化为一股一流一个 goroutine

HTTP/2 在单条 TCP 连接上多路复用多个请求流,上述「顺序处理」模型不再成立。go1.26.0 的 h2_bundle.go 里,每个流的 handler 都单独起 goroutine:

go sc.runHandler(rw, req, sc.handler.ServeHTTP)

所以准确的说法是:HTTP/1.x 每连接一个 goroutine,HTTP/2 每流一个 goroutine。面试时把这句话说完整,比笼统的「每请求一个 goroutine」精确得多。HTTP/2 下超时控制同样依赖 context,参见 第 34 期定时器底层。

四、ServeMux 路由:从线性遍历到决策树

4.1 Go 1.22 起的新 pattern 语法

go1.26.0 的 ServeMux 支持方法、Host、路径通配符的组合 pattern:

package main

import (
	"fmt"
	"net/http"
)

func main() {
	mux := http.NewServeMux()
	// 方法 + 路径通配符:{id} 匹配单段
	mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "user id = %s\n", r.PathValue("id"))
	})
	// {path...} 匹配剩余所有段(子树)
	mux.HandleFunc("GET /static/{path...}", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "static path = %s\n", r.PathValue("path"))
	})
	// {$} 精确匹配根路径,避免 "/" 前缀匹配吞掉所有路由
	mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "index")
	})
	http.ListenAndServe(":8080", mux)
}

通配符取值通过 r.PathValue("id") 拿到(go1.26.0 Request.PathValue 源码核实存在)。注册冲突的路由(如同时注册 /users/{id} 和 /users/new 之外的互相覆盖组合)会在 Handle 时直接 panic,把错误暴露在启动阶段而非运行阶段。

4.2 路由决策树:host → method → 路径分段

与很多人的印象(「遍历所有 pattern 找最匹配的」)不同,go1.26.0 的匹配走的是一棵决策树。routing_tree.go 的注释写明了结构:

  • 树根按请求的 Host 分支;
  • 第二层按 HTTP 方法 分支;
  • 剩余层级按路径的连续分段分支;
  • 特殊子节点:"/" 对应 {$} 结尾、"" 对应单段通配符 {x}、multiChild 对应 {x...} 多段通配符。

「更具体的 pattern 优先」(more specific wins)规则通过回溯实现:源码注释举了例子——注册 /a/b/z 和 /{x}/c 后,请求 /a/b/c 会先尝试更具体的 /a/b/z 分支,失败再回溯到通配符分支。注册时同一路径位置出现「一个通配符、一个字面量」是允许的(通配符作为回溯的备选),但两个互相冲突的注册会直接报错。

4.3 匹配失败时的两个分支

findHandler 找不到 method + path 同时匹配的节点时,会再查一次「忽略 method 是否能匹配」:能则返回 405 Method Not Allowed(并自动带上 Allow 响应头),否则返回 404。另外注册过 /tree/ 而请求 /tree 时,会返回 307 临时重定向到带斜杠的路径——这些行为全部在源码中逐字核实。

五、面试速查清单

追问 标准答案
Go HTTP 服务器一个请求一个 goroutine 吗? HTTP/1.x 是每连接一个 goroutine,请求在连接上顺序处理;HTTP/2 每流一个 goroutine
ListenAndServe 底层做了什么? 无限 accept 循环,每条连接 go c.serve();accept 临时错误按 5ms→1s 指数退避
handler 在哪个 goroutine 执行?(HTTP/1.x) 就在连接 goroutine 上同步执行,因为协议不允许同连接并发在途请求
http.HandleFunc 注册到哪? 包级变量 DefaultServeMux;Server 传了自定义 Handler 时失效
ServeMux 怎么匹配路由? 决策树:host → method → 路径分段,通配符分支可回溯,「更具体优先」
{id} 和 {id...} 区别? 前者匹配单段路径,后者匹配剩余全部子树;取值用 r.PathValue
路由不存在返回什么? 路径存在但方法不匹配返回 405 + Allow 头;否则 404;/tree → /tree/ 是 307
注册两个冲突路由会怎样? 启动阶段 Handle 直接 panic

相关阅读


本系列持续更新中,本文是「Go语言面试题」系列第 37 篇。

版本说明

本文基于 Go 标准库 src/net/http 源码编写,所述 Server.Serve、conn.serve、serverHandler.ServeHTTP、ServeMux.findHandler 及 routing_tree.go 决策树实现均逐字对照 go1.26.0(2026-11-08 核对,官方源码:go.dev/src/net/http,本地 $GOROOT/src/net/http 可对照)。本文无第三方依赖;文中 ServeMux 新 pattern 语法(方法、{id}、{id…}、{$}、PathValue)要求 Go 1.22 及以上版本,用 Go 1.22+ 编译运行本文示例代码即可;旧行为仍可通过 GODEBUG=httpmuxgo121=1 回退(该兼容开关在 go1.26.0 源码中核实仍存在)。

🛒 站长推荐(京东联盟)

以上链接含京东联盟推广代码;通过链接购买本站可能获得少量佣金,不影响你的购买价格。

📮 关注公众号「GO语言入门之路」
GO语言入门之路 公众号二维码

每周更新 Go + AI 应用开发实战:AI 面试官完整项目、MCP、Agent 工程化,完整源码随文章首发。

微信扫码,或搜索「GO语言入门之路」关注,第一时间获取系列更新与源码,不再错过任何一篇。

wx

关注公众号

©2017-2026 鲁ICP备17023316号-1 Powered by Hugo · 隐私政策