Go 正在给开发者一把更锋利的刀:ClientConn

 

解析 net/http 新提案「ClientConn API」的潜台词、争议与未来写法

在 Go 官方仓库最近的一则提案#75772里,Go 团队放出了一个看似不起眼、但可能影响未来原生服务端写法的更新:net/http 增加“连接级别(ClientConn)”操作能力
这个提案最近已被标记为 Proposal-Accepted 预计会在 Go 1.27 发布。
这意味着一件大事:

Go 准备在标准库里正式暴露 “我想拿到一条 HTTP 客户端连接,并直接控制它” 的能力。

对于只写 REST API 的普通用户来说,你可能根本不关心连接。
但对于那些做:

  • 高性能 HTTP 客户端
  • 自定义连接池
  • IoT 长连接
  • 代理、隧道
  • HTTP/2 / HTTP/3 多流复用
    的工程师,这个能力就像开了天眼。

本文将浅度的探讨一下这个改动

起点:Transport 池是个“黑盒”,高级用户被卡死很多年

先看一个图,解释现在 net/http 的模型:

Pasted image 20251129081239

你能看到的:

  • Client → Transport → Do()
    你看不到的:
  • 哪条连接被用?
  • 我能不能让多条请求固定在同一个连接上?
  • HTTP/2 连接的并发限制是多少?
  • 这条连接是不是刚被服务器发送 GOAWAY?
  • 这条连接还能不能继续承担更多 stream?

对普通用户来说看不到没关系,但对高级用户就非常痛苦。

过去几年里,你可能见过这些 “hack"的 编程方式:

  • DisableKeepAlives: true —— 强行每次新建连接
  • 设置 MaxConnsPerHost 来“勉强”控制连接数量
  • 复制 DefaultTransport 然后塞各种私货
  • 手工在 x/net/http2 里拿 ClientConn

但这些都不是“拿到一条我能掌控的连接”的解决方案。
Go 一直避免暴露“连接层”能力,因为那是“专家模式”。
但随着 HTTP/2 走向标准库、HTTP/3 成熟,这堵墙快挡不住了。

Go 新提案:给你一个 ClientConn,你来决定怎么用

在提案中,新 API(草案)大概长这样:

1
2
3
4
5
6
7
func (t *Transport) NewClientConn(ctx context.Context, scheme, address string) (*ClientConn, error)
type ClientConn struct{}
func (cc *ClientConn) RoundTrip(req *http.Request) (*http.Response, error)
func (cc *ClientConn) Close() error
func (cc *ClientConn) Err() error
func (cc *ClientConn) RequestLimit() int64
func (cc *ClientConn) RequestsCompleted() int64

几个关键点:

1. 这是“单条连接”,绝不进 Transport 池

就算 Transport 里已经有 5 条到 example.com 的连接,
NewClientConn() 仍会建一条全新的
这也是高级用户想要的:

“我不要你的池,我要我自己的池。”

在这条连接上发请求:cc.RoundTrip(req)

  • HTTP/1:串行复用,req2 会等 req1 的 Body 被 close
  • HTTP/2:并发 stream
  • HTTP/3:以后也能无缝进入同一抽象

RequestLimit / RequestsCompleted 是关键控制变量

你可以把它理解为“连接还能跑多少请求”“已经跑了多少”。
对于 HTTP/2,这可能对应:

  • MAX_CONCURRENT_STREAMS
  • server 发来的 GOAWAY
  • 内部错误状态
  • 流控制窗口等

未来你可以据此写自己的“调度器”。

这不是小更新,这是 Go 连接模型的重大变化

推进这个 API 意味着 Go 要解决几个本质难题:

1. 如何统一 HTTP/1 / HTTP/2 / HTTP/3 的“连接语义”?

  • HTTP/1:一次一个
  • HTTP/2:多 stream,多资源限制
  • HTTP/3:QUIC stream,动态限制

提供一个统一接口意味着:

Go 要把协议差异塞进一个“能力视图(RequestLimit)”模型。

这很复杂,但也非常优雅。

2. Transport 池和 ClientConn 之间的边界怎么划?

  • 两者不能混淆
  • 不能让用户创建无限连接炸掉系统
  • dial/TLS/Proxy 的逻辑要共享但又不污染彼此
    Go 要做抽象分层重构。

3. x/net/http2 的迁移问题

现在 HTTP/2 客户端和服务端还在 x/net/http2
这个提案实际上是:

把 HTTP/2 真正搬进 std 的必经之路。

未来几个版本里:

  • x/net/http2 会变成过渡层
  • net/http 将会变成真正统一的 HTTP 栈

四、最关键的部分——代码对照:

现在你没办法“锁定一条连接”

只能这样写:

1
2
client := &http.Client{}
resp, _ := client.Get("https://example.com")

你根本不知道走了哪条连接。

如果想每次新建连接,只能靠破坏性的 hack:

1
2
3
tr := &http.Transport{
    DisableKeepAlives: true, // 简陋且副作用大
}

将来:你能真正拿到一条连接,并在其上发多个请求

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
ctx := context.Background()

tr := http.DefaultTransport.(*http.Transport)

// 关键:创建“单条连接”
cc, err := tr.NewClientConn(ctx, "https", "example.com:443")
if err != nil { log.Fatal(err) }
defer cc.Close()

// 所有请求都固定走这条连接
do := func(path string) {
    req, _ := http.NewRequest("GET", "https://example.com"+path, nil)
    resp, _ := cc.RoundTrip(req)
    defer resp.Body.Close()
}

do("/api/a")
do("/api/b")
do("/api/c")

log.Println("limit:", cc.RequestLimit())
log.Println("done:", cc.RequestsCompleted())

这是彻底不同的能力。

你甚至可以自己实现一个“用户态连接池”

下面是一段高度精炼但完整的示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
type pooledConn struct {
    cc    *http.ClientConn
    inUse int64
}

type ConnPool struct {
    mu    sync.Mutex
    conns []*pooledConn
    tr    *http.Transport
    addr  string
}

func (p *ConnPool) get(ctx context.Context) (*pooledConn, error) {
    p.mu.Lock()
    defer p.mu.Unlock()

    for _, pc := range p.conns {
        if pc.cc.Err() != nil {
            continue
        }
        if pc.inUse < pc.cc.RequestLimit() {
            pc.inUse++
            return pc, nil
        }
    }

    cc, err := p.tr.NewClientConn(ctx, "https", p.addr)
    if err != nil {
        return nil, err
    }

    pc := &pooledConn{cc: cc, inUse: 1}
    p.conns = append(p.conns, pc)
    return pc, nil
}

func (p *ConnPool) put(pc *pooledConn) {
    p.mu.Lock()
    defer p.mu.Unlock()
    pc.inUse--
}

你会看到:

这已经接近 Java Netty / Rust hyper 级别的“用户态连接调度”。

Go 以前根本做不了。

五、Mermaid:未来连接模型的完整视图

1)双路径:默认池 & 手工连接

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
flowchart LR
    subgraph app["你的应用 / HTTP 客户端库"]
        CL["http.Client (默认用法)"]
        ADV["高级用法:自定义连接池"]
    end

    subgraph std["net/http 标准库"]
        TR["http.Transport"]

        subgraph pool["Transport 默认连接池"]
            direction LR
            PConn1["pool conn #1"]
            PConn2["pool conn #2"]
        end

        subgraph userpool["你自己管理的 ClientConn 集合"]
            direction LR
            UConn1["ClientConn #1"]
            UConn2["ClientConn #2"]
        end
    end

    CL -->|"Do (普通请求)"| TR
    TR --> PConn1
    TR --> PConn2

    ADV -->|"调用 Transport.NewClientConn"| TR
    TR --> UConn1
    TR --> UConn2

    ADV -->|"cc.RoundTrip"| UConn1
    ADV -->|"cc.RoundTrip"| UConn2

2)HTTP/1 下 RequestLimit 的变化

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
sequenceDiagram
    participant App as 你的代码
    participant CC as ClientConn
    participant S as Server

    Note over CC: RequestLimit = 1<br/>RequestsCompleted = 0

    App->>CC: RoundTrip(req1)
    CC->>S: 发送 req1
    S-->>CC: 返回 resp1

    App->>CC: RoundTrip(req2)
    Note over CC: req2 被阻塞(HTTP/1)

    App->>App: Close(resp1.Body)
    Note over CC: RequestsCompleted = 1<br/>RequestLimit = 2

    CC->>S: 发送 req2

总结

1. Go 正在统一 HTTP/1/2/3 的客户端连接层抽象。

这是一项长期工程,而 ClientConn 是关键拼图。

2. 高级用户将获得史上第一次“官方支持的连接级控制”。
可以:

  • 自己写连接池
  • 自己决定调度策略
  • 自己管理 keep-alive、错误迁移、GOAWAY、流并发

以前要么做不到,要么只能靠有副作用 hack。
3. 一般用户不会受影响,但生态会变得更专业。
未来你会看到更多:

  • 高性能 HTTP 客户端库
  • 专用负载均衡器
  • 自定义代理和网关
  • 更强的可观测性工具

4. 这把刀很锋利,但只给懂得用的人。

Go 团队的态度很明确:

我们不会让 Transport 更复杂,但我们会把“真正的控制权”给那些需要的人。

Licensed under CC BY-NC-SA 4.0
使用 Hugo 构建
主题 StackJimmy 设计