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。