go net/http: http/2连接管理放大了网络超时的影响

i86rm4rw  于 6个月前  发布在  Go
关注(0)|答案(3)|浏览(47)

你使用的Go版本是什么(go version)?

$ go1.21 version
go version go1.21.2 darwin/arm64

这个问题在最新版本的Go中是否重现?

是的,这个问题出现在Go 1.21系列和开发分支中。

你使用的操作系统和处理器架构是什么(go env)?

go env 输出

$ go1.21 env                     
GO111MODULE=''
GOARCH='arm64'
GOBIN=''
GOCACHE='/Users/rhys/Library/Caches/go-build'
GOENV='/Users/rhys/Library/Application Support/go/env'
GOEXE=''
GOEXPERIMENT=''
GOFLAGS=''
GOHOSTARCH='arm64'
GOHOSTOS='darwin'
GOINSECURE=''
GOMODCACHE='/Users/rhys/go/pkg/mod'
GONOPROXY='*'
GONOSUMDB='*'
GOOS='darwin'
GOPATH='/Users/rhys/go'
GOPRIVATE='*'
GOPROXY='direct'
GOROOT='/Users/rhys/go/version/go1.21'
GOSUMDB='sum.golang.org'
GOTMPDIR=''
GOTOOLCHAIN='local'
GOTOOLDIR='/Users/rhys/go/version/go1.21/pkg/tool/darwin_arm64'
GOVCS=''
GOVERSION='go1.21.2'
GCCGO='gccgo'
AR='ar'
CC='clang'
CXX='clang++'
CGO_ENABLED='1'
GOMOD='/dev/null'
GOWORK=''
CGO_CFLAGS='-O2 -g'
CGO_CPPFLAGS=''
CGO_CXXFLAGS='-O2 -g'
CGO_FFLAGS='-O2 -g'
CGO_LDFLAGS='-O2 -g'
PKG_CONFIG='pkg-config'
GOGCCFLAGS='-fPIC -arch arm64 -pthread -fno-caret-diagnostics -Qunused-arguments -fmessage-length=0 -ffile-prefix-map=/var/folders/lf/n4ry0qv5639_3z0nhyhy3z7c0000gs/T/go-build3568422790=/tmp/go-build -gno-record-gcc-switches -fno-common'

你做了什么?

我在网络上进行HTTP请求,有时路由更改会导致TCP流从0%数据包丢失变为100%数据包丢失。

你期望看到什么?

我期望一个TCP流有100%数据包丢失时,不会被用于新的请求。

你看到了什么?

对于HTTP/1,每个连接一次只用于一个请求。连接上的完全数据包丢失会导致分配给它的请求(如果有的话)超时失败,或者分配给该连接的下一个请求失败。http.Transport池通过关闭连接来应对这种情况,防止它被用于任何其他请求。这是好的。
对于HTTP/2,每个连接可以同时用于多个并发请求。连接上的完全数据包丢失会导致分配给它的所有请求(如果有的话)超时失败。这是可以理解的,但如果客户端没有为打开的流设置用户定义的限制,那么似乎就无法避免这种情况。
但是继续使用HTTP/2,如果连接尚未达到其并发流的限制,http.Transport池将继续将新请求分配给该连接,即使它分配给该连接的其他请求未能导致在客户端接收到任何HTTP/2帧。如果我理解正确,Go httptest.Server允许每个连接最多250个并发流。当一个连接遇到数据包丢失时,成百上千的请求失败是不幸的,似乎是我们可以改进的地方。
最后,在HTTP/2中,当连接经历100%数据包丢失并且如我上面所述导致超时时,任何超时的请求都会停止计入连接的并发流限制。这导致死亡连接看起来有能力处理新的请求。这可能导致数千甚至数万个请求失败,特别是如果并发性从未超过http.Transport预期单个连接可以处理的数量。这可能会持续到操作系统的TCP连接缓冲区在OS中填满,或者直到操作系统超时该连接(这可能需要几分钟)。当一个连接遇到数据包丢失时,成千上万的请求超出了连接的最大流限制,这是我们应该能够解决的问题。
这些HTTP/1和Go的HTTP/2对网络扰动的React之间的巨大差异使得很难依赖Go的HTTP/2支持进行生产。
CC @neild
下面的复现器(折叠)结构化为一个无条件失败的测试以打印日志。它一次只发出一个请求,带有超时。每隔一段时间,它会“拔掉”池中的一个TCP连接以模拟(从Go的Angular 来看,如果不是操作系统的Angular )100%的数据包丢失。通常池中只有一个连接。
以下是在具有10ms超时的HTTP/1.1下进行30,000个请求以及在5000、15,000和25,000个请求后拔掉连接的样子:

$ go1.21 test ./h2_unplug_test.go -test.run=/http1 -request-timeout=10ms -request-count=30000 -unplug-interval=10000
--- FAIL: TestNetsplit (2.17s)
    --- FAIL: TestNetsplit/http1 (2.17s)
        h2_unplug_test.go:155: total=30000 unplugs=3 failures=3
FAIL
FAIL    command-line-arguments  2.358s
FAIL

以下是在具有10ms超时的HTTP/2下进行30,000个请求以及在5000个请求后尝试拔掉连接以及在15,000和25,000个请求后尝试拔掉连接的样子(尽管没有新的连接可以拔掉)。它观察到了25,000个请求的失败:

$ go1.21 test ./h2_unplug_test.go -test.run=/http2 -request-timeout=10ms -request-count=30000 -unplug-interval=10000
--- FAIL: TestNetsplit (275.02s)
    --- FAIL: TestNetsplit/http2 (275.02s)
        h2_unplug_test.go:155: total=30000 unplugs=1 failures=25000
FAIL
FAIL    command-line-arguments  275.201s
FAIL

./h2_unplug_test.go

package main

import (
	"context"
	"flag"
	"io"
	"math/rand"
	"net"
	"net/http"
	"net/http/httptest"
	"sync"
	"testing"
	"time"
)

var (
	requestTimeout = flag.Duration("request-timeout", 10*time.Millisecond, "Timeout for each HTTP request")
	requestCount   = flag.Int("request-count", 1000, "Number of HTTP requests to send")
	unplugInterval = flag.Int("unplug-interval", 10, "Number of HTTP requests to issue between each unplug event")
)

func TestNetsplit(t *testing.T) {
	testcase := func(
		srvConfig func(srv *httptest.Server),
		rtConfig func(rt *http.Transport),
		test func(t *testing.T, url string, client *http.Client,
			withConns func(fn func(conns []*proxyConn) []*proxyConn),
		),
	) func(t *testing.T) {
		return func(t *testing.T) {
			srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
				return
			}))
			srvConfig(srv)
			srv.StartTLS()
			defer srv.Close()

			dialer := &net.Dialer{}

			var (
				pipeMu sync.Mutex
				pipes  []*proxyConn
			)

			var proxyMu sync.Mutex
			proxy, err := net.Listen("tcp", "127.0.0.1:0")
			if err != nil {
				t.Fatalf("proxy listen: %v", err)
			}
			defer proxy.Close()

			connPair := func() (net.Conn, net.Conn, error) {
				proxyMu.Lock()
				defer proxyMu.Unlock()

				c1, err := net.Dial(proxy.Addr().Network(), proxy.Addr().String())
				if err != nil {
					return nil, nil, err
				}

				c2, err := proxy.Accept()
				if err != nil {
					return nil, nil, err
				}

				return c1, c2, nil
			}

			transport := srv.Client().Transport.(*http.Transport).Clone()
			transport.ForceAttemptHTTP2 = true
			transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
				c3, err := dialer.DialContext(ctx, network, addr)
				if err != nil {
					return nil, err
				}

				c1, c2, err := connPair()
				if err != nil {
					return nil, err
				}

				pipe := newProxyConn(c2, c3)

				pipeMu.Lock()
				pipes = append(pipes, pipe)
				pipeMu.Unlock()

				return c1, nil
			}

			client := &http.Client{Transport: transport}

			withConns := func(fn func(conns []*proxyConn) []*proxyConn) {
				pipeMu.Lock()
				defer pipeMu.Unlock()
				pipes = fn(pipes)
			}

			test(t, srv.URL, client, withConns)
		}
	}

	makeRequest := func(ctx context.Context, url string, client *http.Client) error {
		req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
		if err != nil {
			return err
		}

		resp, err := client.Do(req)
		if err != nil {
			return err
		}
		defer resp.Body.Close()

		_, err = io.Copy(io.Discard, resp.Body)
		if err != nil {
			return err
		}

		return nil
	}

	run := func(t *testing.T, url string, client *http.Client, withConns func(func(conns []*proxyConn) []*proxyConn)) {
		var failures int64
		var unplugs int64
		total := *requestCount
		for i := 0; i < total; i++ {
			if interval := *unplugInterval; i%interval == interval/2 { // midway or a bit less
				withConns(func(conns []*proxyConn) []*proxyConn {
					if l := len(conns); l > 0 {
						idx := rand.Intn(l)
						pipe := conns[idx]
						pipe.unplug()
						unplugs++

						conns[idx] = conns[l-1]
						conns = conns[:l-1]
					}
					return conns
				})
			}

			err := func() error {
				ctx := context.Background()
				ctx, cancel := context.WithTimeout(ctx, *requestTimeout)
				defer cancel()
				return makeRequest(ctx, url, client)
			}()

			if err != nil {
				failures++
			}
		}

		t.Errorf("total=%d unplugs=%d failures=%d", total, unplugs, failures)
	}

	t.Run("http1", testcase(
		func(srv *httptest.Server) { srv.EnableHTTP2 = false },
		func(rt *http.Transport) {},
		run,
	))

	t.Run("http2", testcase(
		func(srv *httptest.Server) { srv.EnableHTTP2 = true },
		func(rt *http.Transport) {},
		run,
	))
}

// A proxyConn connects two net.Conns together, copying the data in each
// direction, until it is closed or unplugged. Unplugging a proxyConn causes it
// to silently stop copying the data.
type proxyConn struct {
	c1 net.Conn
	c2 net.Conn

	once sync.Once
}

func newProxyConn(c1, c2 net.Conn) *proxyConn {
	pc := &proxyConn{c1: c1, c2: c2}

	go io.Copy(c1, c2)
	go io.Copy(c2, c1)

	return pc
}

func (pc *proxyConn) unplug() {
	dl := time.Now().Add(-1 * time.Second)
	pc.c1.SetDeadline(dl)
	pc.c2.SetDeadline(dl)
}

func (pc *proxyConn) close() {
	pc.once.Do(func() {
		pc.c1.Close()
		pc.c2.Close()
	})
}
3pmvbmvn

3pmvbmvn2#

当你说“100%数据包丢失”时,你是通过DeadlineExceeded(和/或499)超时检测到的吗?
这是一个有趣的问题,我想知道其他语言是如何处理这个问题的。

0qx6xfy6

0qx6xfy63#

这在应用程序中表现为超时/DeadlineExceeded,是的。(或者,如果最严格的超时来自较早的调用者层,则为Canceled。)
"100%数据包丢失"是TCP连接中导致内核没有/不提供我们期望可用的信息的问题。这种可观察行为的其他原因(例如另一端的应用程序决定休眠而不是写入我们的数据)也会对Go的HTTP/2支持的用户产生相同的影响。

相关问题