go http1.1 长连接编程

http1.1 长连接编程

前言

作为 server to server 模式的程序,http 长连接必不可少,本文假设的应用条件是高并发下的场景。
在 go 中,官方 http 包默认启用了长连接,但为了更好地理解,我们进行手动配置来说明。

服务端

服务启动代码
服务端开启 http 长连接,设置了超时时间为5秒。

func (h1 *H1Server) Open() {
	l, err := net.Listen("tcp", h1.addr)
	if err != nil {
		panic(err)
	}
	server := &http.Server{
		Handler:h1,
	}
	server.SetKeepAlivesEnabled(true)
	server.IdleTimeout = 5 * time.Second
	server.Serve(l)
}

请求处理代码,我们假设处理每个请求需要 10ms

func (h1 *H1Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	// 假设 http 处理一个请求需要10 毫秒
	time.Sleep(10 * time.Millisecond)

	var status int
	w.WriteHeader(http.StatusOK)
	_, err := w.Write([]byte("hello world"))
	if err != nil {
		status = http.StatusExpectationFailed
	}
	status = http.StatusOK

	slog := fmt.Sprintf(`%s %s %s [%s] "%s %s %s" %s %d`,
		r.Host,
		detect(parseUsername(r)), "-",
		r.Header.Get("Request-Id"),
		time.Now().Format("02/Jan/2006:15:04:05 -0700"),
		r.Method,
		r.URL.RequestURI(),
		r.Proto,
		status)
	fmt.Println(slog)
}

默认客户端

接下来,我们使用 http 默认的客户端进行测试

客户端代码

func NewH1Client() *H1Client {
	return &H1Client{
		c: &http.Client{
			Transport:http.DefaultTransport,
			//Transport: &http.Transport{
			//	MaxConnsPerHost:1,
			//	MaxIdleConns:1,
			//	MaxIdleConnsPerHost:1,
			//	DisableKeepAlives:   false,
			//},
		},
	}
}

http 的客户端实际上是用 transport 进行 http 消息传递。假如我们什么都不设置,transport 会使用默认的 transport。
而默认的 transport 并不适合 server to server 模式的通信。我们测试一下默认的 transport 性能。

benchmark 测试
在benchmark 测试中,我们设置并发数为 NumCPU,但实际上 benchmark 设置的并发是单个 cpu 的并发,所以总并发为 NumCPU * NumCPU

func BenchmarkPing_H1P1(b *testing.B) {
	c := NewH1Client()
	b.ReportAllocs()
	b.SetParallelism(runtime.NumCPU())
	for i := 0; i < b.N; i++ {
		b.RunParallel(func(pb *testing.PB) {
			for pb.Next() {
				Ping(c.c, "http://127.0.0.1:8086")
			}
		})
	}
}

测试结果

goos: windows
goarch: amd64
pkg: github.com/bemyth/go-language-advanced/httpx/client
BenchmarkPing_H1P1-4   	     100	  79405498 ns/op	  549047 B/op	    5522 allocs/op
PASS

执行了100次,每个请求耗时80ms,看上去没什么问题,但是执行 netstat 查看网络状态时,却发现大量的 time_wait。
统计发现,共有 1000 多个 tcp 连接。连接过多肯定不是好事,因为每个主机的可使用端口是受限制的,并且我们使用长连接,
很大的原因就是想限制连接数目,而现在看起来似乎链接数目并没有被限制。
在高并发的server to server模式下,这种使用肯定是不被允许的。

C:\Users\dier\netstat -an | find "8086" /C
1883

自定义客户端

为了改变这种情况,我们自定义客户端。

客户端代码

func NewH1Client() *H1Client {
	return &H1Client{
		c: &http.Client{
			//Transport:http.DefaultTransport,
			Transport: &http.Transport{
				MaxConnsPerHost:1,
				MaxIdleConns:1,
				MaxIdleConnsPerHost:1,
				DisableKeepAlives:   false,
			},
		},
	}
}

说明一下这段代码,限制长连接数为 1,即全部请求都只能通过同一个长连接进行。

benchmark 测试结果

goos: windows
goarch: amd64
pkg: github.com/bemyth/go-language-advanced/httpx/client
BenchmarkPing_H1P1-4   	     100	1149716865 ns/op	  352871 B/op	    4931 allocs/op
PASS

同样是执行了 100 次,每次操作耗时 1000 ms,比起默认的客户端差了 12 倍。细心的会发现,内存分配有略微下降。
我们再看一下网络情况 netstat

C:\Users\dier\netstat -an | find "8086" /C
3

这次只有三个连接,我们可以打印出来看一下

C:\Users\dier\netstat -an | find "8086"
TCP 127.0.0.1:8086   0.0.0.0:0           LISTENING
TCP 127.0.0.1:8086   127.0.0.1:52425     LISTENING
TCP 127.0.0.1:52425  127.0.0.0:8086      LISTENING

因为我们的服务端和客户端都是在同一主机上运行的,所以同一个连接会显示两个,从端口可以看出来。
确实是只有一个连接,长连接成功了,但是性能并不乐观。

自定义客户端2

虽然通过自定义客户端,我们限制了连接数,但是性能并不乐观,这在高并发场景下也是不能接受的。
那么我们进行二次自定义来调整性能。

客户端代码

func NewH1Client() *H1Client {
	return &H1Client{
		c: &http.Client{
			//Transport:http.DefaultTransport,
			Transport: &http.Transport{
				MaxConnsPerHost:10,
				MaxIdleConns:10,
				MaxIdleConnsPerHost:10,
				DisableKeepAlives:   false,
			},
		},
	}
}

以上代码,我们将客户端的连接数置为10,看一下效果。

goos: windows
goarch: amd64
pkg: github.com/bemyth/go-language-advanced/httpx/client
BenchmarkPing_H1P1-4   	     100	 113806224 ns/op	  352494 B/op	    4911 allocs/op
PASS

还是 100次请求,每次耗时为 100ms。效率提升了10倍。但是仅提高长连接数量并不能无限的提高效率,因为客户端并发数的限制。
所以长连接数量和并发量共同作用于效率,一般来说,当并发量增加,长连接数也应该随之增加。

本机极限测试

经过我反复调试,当并发量为NumCPU * NumCPU * 30。长连接数量为100时,每次操作耗时达到 14 ms。

goos: windows
goarch: amd64
pkg: github.com/bemyth/go-language-advanced/httpx/client
BenchmarkPing_H1P20-4   	     100	  14451606 ns/op	  370297 B/op	    5209 allocs/op
PASS

问题

  1. 默认的 transport 长连接为什么失效
  2. 三个参数各自代表什么含义

由于第一个问题就是第二个问题的一个反映,所以我们来看一下,这三个参数各自有什么意义。

长连接参数详解

  • MaxConnsPerHost
  • MaxIdleConns
  • MaxIdleConnsPerHost

官方解释

  • MaxConnsPerHost
	MaxConnsPerHost optionally limits the total number of
	connections per host, including connections in the dialing,
	active, and idle states. On limit violation, dials will block.
	
	Zero means no limit.
	
	For HTTP/2, this currently only controls the number of new
	connections being created at a time, instead of the total
	number. In practice, hosts using HTTP/2 only have about one
	idle connection, though.
MaxConnsPerHost 控制了每个客户端中包含呼叫中,活动,空闲状态的连接总数。超出这个限制的呼叫将会阻塞。
0 表示无限制。
对 HTTP/2 来说,这个参数当前仅控制同一时间能够被创建的连接数,而不是总数。实际上, HTTP/2 也总是使用
一个空闲连接(长连接)。

我们当前使用的 HTTP/1.1 也就是说,这个链接控制了每个客户端的最大连接数,一旦创建的连接等于这个最大连接数,就不会创建新连接了。
不会创建新的连接怎么办呢,那当然是等待了,等一个有缘人(idle空闲连接)。

  • MaxIdleConns
	// MaxIdleConns controls the maximum number of idle (keep-alive)
	// connections across all hosts. Zero means no limit.
  • MaxIdleConnsPerHost
	// MaxIdleConnsPerHost, if non-zero, controls the maximum idle
	// (keep-alive) connections to keep per-host. If zero,
	// DefaultMaxIdleConnsPerHost is used.

这两个合在一起说,MaxIdleConns 是总的长连接数量。MaxIdleConnsPerHost 是每个 Host(ip:port) 长连接数量,并且如果这个参数是0的话,会默认置为2。
看完了官方解释,说实话还是不懂这三个参数是干啥的,后两个还好区分,属于一类。那么第一个和第二个都是控制链接数量的,这二者之间有什么关系吗。

源码导读

为了理解这三个参数的作用,我们只能看一下源码。

MaxIdleConns

我们先看一下 MaxIdleConns 参数,这个比较简单,通过查找引用,我发现这个参数只使用了一次,并且这一次是在放回连接的时候使用的。

func (t *Transport) tryPutIdleConn(pconn *persistConn) error {
        ...
	
	if t.MaxIdleConns != 0 && t.idleLRU.len() > t.MaxIdleConns {
		oldest := t.idleLRU.removeOldest()
		oldest.close(errTooManyIdle)
		t.removeIdleConnLocked(oldest)
	}
	
	...
}

这个参数的作用很简单,就是在将使用完的连接放回连接池的时候,如果设置了连接池允许的最大连接数量,
并且已存在的连接大于这个设定,则移除旧的连接,以便将当前较新的连接放回。

MaxConnsPerHost

同样的对于 MaxIdleConnsPerHost, 我们查看引用,也只在放回的时候使用。

func (t *Transport) tryPutIdleConn(pconn *persistConn) error {
        ...
	if len(idles) >= t.maxIdleConnsPerHost() {
		return errTooManyIdleHost
	}
		
	...
}

这个使用也很简单。如果空闲连接超过设定,就会返回错误,而返回错误之后,上层都会将这个链接关闭。
这个连接当然也不能放回连接池了。

MaxConnsPerHost

我们查看一下 MaxConnsPerHost 的引用

func (t *Transport) getConn(treq *transportRequest, cm connectMethod) (*persistConn, error) {
	...
	if t.MaxConnsPerHost > 0 {
		select {
		case <-t.incHostConnCount(cmKey):
			// count below conn per host limit; proceed
		case pc := <-t.getIdleConnCh(cm):
			if trace != nil && trace.GotConn != nil {
				trace.GotConn(httptrace.GotConnInfo{Conn: pc.conn, Reused: pc.isReused()})
			}
			return pc, nil
		case <-req.Cancel:
			return nil, errRequestCanceledConn
		case <-req.Context().Done():
			return nil, req.Context().Err()
		case err := <-cancelc:
			if err == errRequestCanceled {
				err = errRequestCanceledConn
			}
			return nil, err
		}
	}
	...
}

果然,MaxConnsPerHost 是在申请连接的时候使用的,只有设置了 MaxConnsPerHost ,客户端在申请连接时才会从连接池中拿连接,
否则,每次都会新建连接。

参数结论

  • MaxConnsPerHost
    申请连接时使用,限制申请连接的最大数量,如果不设置,则每次申请连接都会新建连接。
  • MaxIdleConns
    放回连接时使用,限制连接池中长连接的数量,如果不设置,默认无上限。
  • MaxIdleConnsPerHost
    放回连接时使用,限制到每个服务(ip:port)的连接池中长连接的数量.如果不设置,默认为 2。

结合连接池的知识,弄懂了这三个参数,就可以根据实际情况来调整长连接了。值得一提的是,
默认的 transport 没有设置 MaxConnsPerHost, 导致每次请求都是一个新的连接,表现出来就是占用了大量的 tcp 端口。
如果不设置 MaxConnsPerHost,每次使用的连接倒是放回了连接池,但是这些被放回的连接并不会被使用,长连接也就无效。

上一篇:javamail发邮件


下一篇:Python模块学习 - Paramiko