HTTP/2 通过多路复用解除 HTTP 响应的顺序约束,但不同流仍然共享 TCP 连接及其故障边界。取消就是一个值得单独验证的场景:终止某个请求,不一定需要破坏相邻请求或可复用连接。这是一项有明确作用范围的能力,不能扩写成所有流在任何条件下都互不影响。
流不是底层套接字
HTTP/2 在连接内部标识单条流。RFC 9113 定义的 RST_STREAM 用于终止某条流,既可以表达取消,也可以报告流错误;它并不是重置整个套接字的 TCP reset。只要连接本身仍然可用,实现就可以停止一次交换,同时让其他流继续推进,而不必为每次取消重新建立连接。
这与 HTTP/1.1 的持久连接并不矛盾,后者本来就允许一条连接承载多次交换。HTTP/2 新增的是交错传输与流级生命周期控制,而不是首次发明连接复用。TCP 的有序交付、拥塞控制、连接级 DATA 流控以及服务端资源仍然约束进度;即使一次请求取消没有影响其他流,连接故障也可能同时中断多个请求。
取消还不等于撤销已经完成的业务。服务器可能早已提交事务,或调用了另一个服务。客户端读取正文时得到取消错误,无法证明这些操作没有发生。因此,是否重试仍须依据业务语义、幂等保证或可查询的执行结果,不能仅凭网络层已经取消,就把再次提交认定为安全操作。
收到响应头不代表正文成功
成功状态码出现在正文之前。服务端可以先发送并刷新 HTTP 200,再长时间等待,或者在传输内容时失败。状态、正文完整性和业务结果应分别记录。Client.Do 返回了响应对象,只说明客户端已经拿到响应头,不能据此把整个响应标为读取成功,更不能省略后续读取错误的处理。
Go 的出站请求上下文控制取消;对于入站 HTTP/2 请求,一次取消可以让服务端对应请求的上下文结束,而不需要关闭整条连接。处理函数在等待或访问依赖时,应观察并传递该上下文。运行时不会自动撤销任意业务动作。Client.Timeout 还覆盖响应头到达之后的正文读取,不能把它理解成只约束握手或等候首字节。
坏掉的样子
如果几个请求共用一个父上下文,取消父上下文本来就会影响它们;关闭底层连接也比取消单个请求的范围更大。请求自己的时限应由请求自己的取消范围管理。不能对共享套接字设置整体期限后,又声称产生的超时只作用于一条 HTTP/2 流;排障时必须核对取消信号的真正拥有者。
遗弃正文可能保留资源或占用流控额度。适合完整消费的响应应读完,已经结束或取消的正文也必须关闭。未读数据是否妨碍相邻流,与库的缓冲和窗口更新策略有关。流数量上限、共享连接额度和后端容量仍然存在;一个小实验能成功取消,不代表高负载时也没有相互影响。
快速验证
保存为 cancel_probe.go,使用 Go 1.23 或更新版本运行 go run cancel_probe.go。本地服务使用经过验证的 TLS 1.3 协商 HTTP/2。两个处理函数先刷新 HTTP 200 响应头,再让正文等待。客户端随后只取消第一个请求;另一个响应保持打开,双方都使用同一条被编号的 TCP 连接。
package main
import (
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/http/httptrace"
"runtime"
"sync/atomic"
"time"
)
type connectionKey struct{}
func must(err error) {
if err != nil {
panic(err)
}
}
func check(ok bool, message string) {
if !ok {
panic(message)
}
}
func main() {
cancelObserved, releaseSurvivor, stop := make(chan struct{}), make(chan struct{}), make(chan struct{})
var connections, handled atomic.Int64
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handled.Add(1)
w.Header().Set("X-Connection-ID", fmt.Sprint(r.Context().Value(connectionKey{})))
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
w.(http.Flusher).Flush()
switch r.URL.Path {
case "/cancel":
select {
case <-r.Context().Done():
close(cancelObserved)
case <-stop:
}
case "/survive":
select {
case <-releaseSurvivor:
fmt.Fprint(w, "survived")
case <-r.Context().Done():
case <-stop:
}
case "/after":
fmt.Fprint(w, "ready")
}
}))
server.Config.ConnContext = func(ctx context.Context, _ net.Conn) context.Context {
return context.WithValue(ctx, connectionKey{}, connections.Add(1))
}
server.EnableHTTP2 = true
server.TLS = &tls.Config{MinVersion: tls.VersionTLS13, MaxVersion: tls.VersionTLS13}
server.StartTLS()
defer server.Close()
defer close(stop)
config := server.Client().Transport.(*http.Transport).TLSClientConfig.Clone()
check(!config.InsecureSkipVerify, "certificate verification must stay enabled")
transport := &http.Transport{TLSClientConfig: config, ForceAttemptHTTP2: true, MaxConnsPerHost: 1}
defer transport.CloseIdleConnections()
client := &http.Client{Transport: transport, Timeout: 2 * time.Second}
open := func(ctx context.Context, route string) *http.Response {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+route, nil)
must(err)
response, err := client.Do(request)
must(err)
check(response.StatusCode == 200 && response.Proto == "HTTP/2.0", "HTTP/2 response required")
check(response.Header.Get("X-Connection-ID") == "1", "streams must share the first TCP connection")
check(response.TLS.Version == tls.VersionTLS13 && response.TLS.NegotiatedProtocol == "h2", "TLS 1.3 and h2 required")
check(len(response.TLS.VerifiedChains) > 0, "verified server certificate required")
return response
}
firstContext, cancel := context.WithCancel(context.Background())
defer cancel()
first := open(firstContext, "/cancel")
survivor := open(context.Background(), "/survive")
cancel()
body, readError := io.ReadAll(first.Body)
closeError := first.Body.Close()
must(closeError)
check(errors.Is(readError, context.Canceled) && len(body) == 0, "first stream must report cancellation")
select {
case <-cancelObserved:
case <-time.After(2 * time.Second):
panic("server did not observe stream cancellation")
}
close(releaseSurvivor)
body, readError = io.ReadAll(survivor.Body)
closeError = survivor.Body.Close()
must(readError)
must(closeError)
check(string(body) == "survived", "sibling stream must complete")
var reused atomic.Bool
var handshakes atomic.Int64
trace := &httptrace.ClientTrace{
GotConn: func(info httptrace.GotConnInfo) { reused.Store(info.Reused) },
TLSHandshakeStart: func() { handshakes.Add(1) },
}
after := open(httptrace.WithClientTrace(context.Background(), trace), "/after")
body, readError = io.ReadAll(after.Body)
closeError = after.Body.Close()
must(readError)
must(closeError)
check(string(body) == "ready" && reused.Load() && handshakes.Load() == 0, "connection must remain reusable")
check(connections.Load() == 1 && handled.Load() == 3, "three streams must use one TCP connection")
fmt.Println("cancel: context canceled; server observes cancellation")
fmt.Println("survive: body complete on connection 1")
fmt.Println("after: connection 1 reused, no new TLS handshake")
fmt.Println(runtime.Version())
}
第一个正文的读取必须返回能匹配 context.Canceled 的错误,且没有正文数据;服务端还必须独立观察到该请求上下文被取消。程序随后才释放相邻请求,让它完整返回 survived。这样,两份已经成功到达的响应头,对应不同的正文结果,同时并没有使用两条独立连接掩盖影响。
第三次请求必须在连接一上返回 ready。GotConn 记录复用,第三次请求的 TLSHandshakeStart 计数保持为零;服务端同时断言只接受一条 TCP 连接,却处理了三个请求。这些证据能够区分“原连接仍然可用”和“客户端悄悄换了一条连接才成功”,不能只根据最后一次请求成功就判定前者。
已核验的 macOS、Go 1.27.0 运行通过全部断言。每次请求限时两秒,等待服务端观察取消另设两秒上限;清理时释放等待中的处理函数。证书验证始终开启,不修改公共服务,也不向系统安装信任根。程序正常结束时,响应正文和本地连接资源均已关闭或回收。
这里检查的是 Go 提供的可观察行为,没有抓包、解码或断言某个具体 RST_STREAM 错误码。协议机制的解释来自 RFC 9113。实验也没有测试丢包、流控耗尽、整条连接失败、生产业务副作用或吞吐量。要提出更强的隔离性和安全重试结论,还需要分别设计相应实验,而不能从这次通过的断言直接外推。
延伸阅读
来源
- RFC 9113:HTTP/2,流终止、共享流控与 TCP 边界,已核验 2026-09-09。
- Go net/http,请求取消、响应正文、连接复用与超时,已核验 2026-09-09。
- Go crypto/tls,协议协商与经过验证的证书链,已核验 2026-09-09。