bigywater.tech
Gin · 17

Engine.ServeHTTP 的完整流程

一个请求进入 Gin,到底经过了哪几步?这篇从 ServeHTTP 的源码开始,用实验验证每一步:Context 是不是真的复用、reset 清了什么、每次请求分配多少内存、状态行什么时候发出,以及第一次请求时被悄悄执行的那一步。

0 次
静态、参数、通配路由的一次 ServeHTTP 的内存分配
1 个
连续 3 个请求用到的 Context 对象个数
9 次
c.JSON(200, gin.H{"a":1}) 的内存分配

六步骨架

实测 · 本机执行输出
  gin.go 的 ServeHTTP(Gin v1.12.0):
     662 | func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
     663 |     engine.routeTreesUpdated.Do(func() {
     664 |         engine.updateRouteTrees()
     665 |     })
     666 | 
     667 |     c := engine.pool.Get().(*Context)
     668 |     c.writermem.reset(w)
     669 |     c.Request = req
     670 |     c.reset()
     671 | 
     672 |     engine.handleHTTPRequest(c)
     673 | 
     674 |     engine.pool.Put(c)
     675 | }
  gin.go 的 handleHTTPRequest(前半)(Gin v1.12.0):
     690 | func (engine *Engine) handleHTTPRequest(c *Context) {
     691 |     httpMethod := c.Request.Method
     692 |     rPath := c.Request.URL.Path
     693 |     unescape := false
     694 | 
     695 |     if engine.UseEscapedPath {
     696 |         rPath = c.Request.URL.EscapedPath()
     697 |         unescape = engine.UnescapePathValues
     698 |     } else if engine.UseRawPath && len(c.Request.URL.RawPath) > 0 {
     699 |         rPath = c.Request.URL.RawPath
     700 |         unescape = engine.UnescapePathValues
     701 |     }
     702 | 
     703 |     if engine.RemoveExtraSlash {
     704 |         rPath = cleanPath(rPath)
     705 |     }
     706 | 
     707 |     // Find root of the tree for the given HTTP method
     708 |     t := engine.trees
     709 |     for i, tl := 0, len(t); i < tl; i++ {
     710 |         if t[i].method != httpMethod {
     711 |             continue
     712 |         }
     713 |         root := t[i].root
     714 |         // Find route in tree
     715 |         value := root.getValue(rPath, c.params, c.skippedNodes, unescape)
     716 |         if value.params != nil {
     717 |             c.Params = *value.params
     718 |         }
     719 |         if value.handlers != nil {
     720 |             c.handlers = value.handlers
     721 |             c.fullPath = value.fullPath
     722 |             c.Next()
     723 |             c.writermem.WriteHeaderNow()
     724 |             return
     725 |         }
     726 |         if httpMethod != http.MethodConnect && rPath != "/" {
     727 |             if value.tsr && engine.RedirectTrailingSlash {
     728 |                 redirectTrailingSlash(c)
     729 |                 return
     730 |             }
     731 |             if engine.RedirectFixedPath && redirectFixedPath(c, root, engine.RedirectFixedPath) {
     732 |                 return
     733 |             }
     734 |         }
     735 |         break

ServeHTTP 的代码不到二十行:第一次调用时用 sync.Once 执行 updateRouteTrees,然后从 sync.Pool 取 Context、绑定 writer 和 request、reset、handleHTTPRequest、放回 pool。handleHTTPRequest 负责选路径、在对应方法的树里查找、命中就跑完 handler 链,没命中则走重定向、405 或 404(前面几篇已经分别验证过)。

Context 复用与 reset

实测 · 本机执行输出
  context.go 的 reset(Gin v1.12.0):
     103 | func (c *Context) reset() {
     104 |     c.Writer = &c.writermem
     105 |     c.Params = c.Params[:0]
     106 |     c.handlers = nil
     107 |     c.index = -1
     108 | 
     109 |     c.fullPath = ""
     110 |     c.Keys = nil
     111 |     c.Errors = c.Errors[:0]
     112 |     c.Accepted = nil
     113 |     c.queryCache = nil
     114 |     c.formCache = nil
     115 |     c.sameSite = 0
     116 |     *c.params = (*c.params)[:0]
     117 |     *c.skippedNodes = (*c.skippedNodes)[:0]
     118 | }
    请求 a        Context=0x6ac7820a4300  进入时 Keys=map[] Errors=0 Params=[{id a}]
    请求 b        Context=0x6ac7820a4300  进入时 Keys=map[] Errors=0 Params=[{id b}]
    请求 c        Context=0x6ac7820a4300  进入时 Keys=map[] Errors=0 Params=[{id c}]
    三次请求是否同一个 Context: true
    请求结束后还拿着第 1 个 Context 指针去读:Keys=map[secret:来自请求 c] Errors=1 Params=[{id c}](已经是第 3 个请求的了)

串行请求拿到的是同一个 Context。reset 把 Keys、Errors、Params、查询与表单缓存等全部清掉,所以不会有数据泄漏到下一个请求——但这也意味着请求结束后任何还持有这个指针的 goroutine 读到的都是别的请求的数据。要在 goroutine 里用,必须先 c.Copy()(后面专门验证)。

分配次数

实测 · 本机执行输出
    静态路由,空 handler                               0 allocs/请求
    1 个参数 /u/42                                  0 allocs/请求
    3 个参数                                        0 allocs/请求
    通配 /f/x/y/z                                  0 allocs/请求
    404                                          0 allocs/请求
    c.Status(204)                                0 allocs/请求
    c.String(200, "hello")                       1 allocs/请求
    c.JSON(200, gin.H{"a":1})                    9 allocs/请求
    3 个空中间件 + 静态路由                               0 allocs/请求
    gin.Default()(Logger+Recovery)+ 静态路由         10 allocs/请求

路由匹配本身不分配:静态、参数、通配、404、空中间件,每个请求都是 0 次分配。分配来自 handler 自己:c.String 1 次,c.JSON 9 次(包含 gin.H 这个 map 和序列化)。带 Logger 和 Recovery 的 gin.Default() 即使 handler 为空,每个请求也有 10 次分配,主要来自 Logger 格式化日志。

状态行什么时候发出

实测 · 本机执行输出
    /nothing  → 状态 200 正文 "" X-A=""
    /status   → 状态 201 正文 "" X-A=""
    /header   → 状态 200 正文 "" X-A="1"
    /after    → 状态 200 正文 "先写正文" X-A=""

handler 链结束后 WriteHeaderNow 才把还没发出的状态行补上,所以 c.Status(201) 不写正文也能让客户端看到 201,什么都不做是默认的 200。反过来,一旦已经写了正文,状态行就发出去了,之后再 c.Status(500) 无效。

第一次请求时的 updateRouteTrees

实测 · 本机执行输出
  gin.go 的 updateRouteTree(Gin v1.12.0):
     504 | func updateRouteTree(n *node) {
     505 |     n.path = strings.ReplaceAll(n.path, escapedColon, colon)
     506 |     n.fullPath = strings.ReplaceAll(n.fullPath, escapedColon, colon)
     507 |     n.indices = strings.ReplaceAll(n.indices, backslash, colon)
     508 |     if n.children == nil {
     509 |         return
     510 |     }
     511 |     for _, child := range n.children {
     512 |         updateRouteTree(child)
     513 |     }
     514 | }
    第一次请求前 路由表:"/v1/\\:action"
    第一次请求前 路由表:"/v1/items/:id"
    GET /v1/:action → 200 命中 /v1/:action
    第一次请求后 路由表:"/v1/:action"
    第一次请求后 路由表:"/v1/items/:id"
    GET /v1/other → 404(\: 是字面量冒号,不是参数)

路由里用 \: 转义的冒号在注册时保留反斜杠,避免被当成参数;第一次 ServeHTTP 时才统一还原成 :,所以在第一次请求之前读 Routes() 会看到带反斜杠的路径。这样的路由匹配的是字面量的冒号,比如 /v1/:action。

分步演示

请求流程
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew)/ Gin v1.12.0(go.mod 里 require,源码在模块缓存里);用 go run / go test 直接编译运行,需要库时经代理从 proxy.golang.org 下载;HTTP 实验用 httptest 或本机回环端口(9700 以上);计时用 time.Now / testing.B,数字每次运行会有小幅抖动。请求用 httptest 直接调用 ServeHTTP;分配次数是 testing.AllocsPerRun(2000 次平均,向下取整)测的,writer 是丢弃输出的自定义实现,Default() 的 Logger 输出被设为 io.Discard;每种情形各一次
    • 没有验证:真实 http.Server 下每个请求的分配(net/http 本身会分配);并发请求时 pool 的复用率与 GC 对 pool 的清空影响;c.JSON 的 9 次分配各自来自哪里(没有逐个定位);Logger 的 10 次分配的具体来源
    京ICP备2026067502号-1京公网安备11010502063487号