HTTP协议
HTTP协议是规范服务端和客户端请求应答标准的应用层协议,即它是一个应用层的请求响应协议。客户端的请求和服务端响应共同构成了一次HTTP事务,他们之间的通信通过HTTP报文进行。HTTP1.1的报文是纯文本的,协议规定了报文的格式,报文格式都是起始行+Header+Body的格式,只是请求起始行包含方法+URI,响应起始行是状态码和状态消息。
TCP连接管理
在TCP/IP协议族里,HTTP协议是基于TCP这个传输层协议的。即虽然HTTP本身无状态,TCP为他提供了可靠面向连接的传输,HTTP发送请求需要先建立底层的TCP连接。本节总结跟HTTP性能相关的TCP时延。
- TCP建立连接时延 client发起HTTP请求,可能会在TCP建立连接的过程消耗50%以上的时间,因此http的client侧应该重用现有的连接来减小影响。
- 延迟确认算法引起的时延
- TCP慢启动 由于拥塞控制,TCP新连接的传输速度是有限制的,只有当它交换一定数量数据后拥塞窗口才慢慢打开。所以HTTP重用现有连接很重要。
- Nagle算法 导致小HTTP报文无法填满一个分组,可能会因为等待永远不会到来的额外数据而产生时延。通过配置TCP参数TCP_NODELAY来禁用Nagle算法,提高性能。但要保证会向TCP写入大块的数据,而不是一堆小分组。
- TIME_WAIT累积和端口耗尽
HTTP2的区别
首先HTTP2 的引入时为了解决HTTP1.X的一些问题或缺陷,它虽然保留了HTTP Header等协议格式,但在tcp层上通过binary帧层把数据做了进一步封装,最终传输在连接上的是二进制数据。
- HTTP1.X时代Server端实现主动推送,需要引入类似long poll,web socket等折中方式。HTTP2的编码格式和传输协议天然支持Server Push,不需要Chunked编码等等。
- head-Of-Blocking问题:不支持真正的基于一个连接的并行多会话。HTTP1.X在同一个TCPconn上Transport同时只有一个roundTrip在运行,并发的请求时通过另外创建连接以及连接复用来实现的。HTTP2支持多路复用,通过streamID实现在同一个连接上处理不同stream的消息,消息间通过id区分。非常自然的实现了TCP的单连接。
- 协议头部的数据冗余,HTTP2支持Hpack的头部压缩算法。
- 引入了流控以及请求优先级的概念。
HTTPS引入
HTTPS就是HTTP on SSL/TLS,它的引入本身是为了应对明文传输场景下的三个风险:窃听风险、篡改风险、冒充风险。它通过信息加密传输让第三方无法窃听,提供校验机制让篡改可以被发现,配备身份证书防止身份被冒充。
加密基本原理就是客户端用公钥加密,服务端用自己的私钥解密。但是这种不对称加密的效率很低,所以通常是为每次对话双方协商生成一个session key对话密钥,用它来对称加密信息。公钥只用于加密对话密钥就行了。这个sessionkey在缓存,不是每次建立连接都需handshake。在这个handshake过程中通过散列值校验数据完整性,同时双方都提供证书让对方验证身份。
所以在TLS需要四次握手:
- 客户端clientHello说明客户端支持的TLS版本、加密方法、压缩方法、客户端生成的随机数。
- 服务端serverHello回应TLS版本、确认使用的加密方式、提供服务端随机数、服务端证书。这个证书包含公钥、服务端证书颁发者的数字签名。签名是用私钥加密的,所以客户端使用公钥若能解密,则完成了服务端身份验证。
- 客户端生成一个加密的随机数pre-master key,编码改变通知,客户端握手结束通知,同时发送所有内容的hash值,用来给服务端校验。
- 服务端通过三个随机数生成对话密钥,然后发编码变更通知和hash校验值。之后双方进入加密通信阶段。
go-http-server
以下是go写的最简单的HTTP server实例。它将TCP连接建立和分发的过程封装在了http.ListenAndServe
当中,本节以此为开头记录http包的实现。
1 | func sayHello(w http.ResponseWriter, r *http.Request) { |
listen & serve
http.ListenAndServe
通过net.Listen建立tcpListener,服务端可以进入accept循环,只要有listenFD上有accept新的TCPconn,每个TCPconn会通过newConn封装为一个server侧的httpConn,并通过new goroutine分发这次httpConn的相关处理。看起来go server是同步阻塞的方式,相关原理可参考我之前写的IO多路复用与Go网络库的实现。最外层的过程如图所示,下图转自go-web编程。
默认accept的rw设置了keepalive,时间默认为3分钟。
conn
server侧的连接conn是在net.Conn上的一层封装,它完成了HTTP协议解析req请求,处理请求,返回response的过程。下面是conn的数据结构。
1 | type conn struct { |
节选conn.serve()
的一段,conn
处理请求的基本过程如下。其中readRequest
从读conn.buf
按照HTTP协议解析请求,然后将请求丢给对应的Handler
处理。读取req的过程是从bufio
到limitReader
,再到liveSwitchReader
,最后到net.Conn
。
1 | func (c *conn) serve() { |
server mux
Server包含一个重要成员,就是Handler,它是一个接口,描述如何处理req和response。如果server中没有初始化handler,将调用http默认的路由器DefaultServeMux
。
1 | type Handler interface { |
service mux的数据结构如下,其中m存了mux的pattern对应的handler。这里的m是通过http.HandleFunc
注册到默认路由器中的。
1 | type ServeMux struct { |
截取service mux实现路由选择的过程,其实Handler
就是对比r.Host + r.URL.Path
和m中的pattern,匹配到muxEntry的Handler,最后调用handler的ServeHTTP处理。
1 | func (mux *ServeMux) ServeHTTP(w ResponseWriter, r *Request) { |
go-http-client
由于TCP建立的消耗和时延,以及拥塞控制等原因,希望对TCP连接重用,而不是HTTP事务完成后close掉。在HTTP1.0时期,server端显示的在response头部中加入 Connection: Keep-alive来告诉client侧,连接不close。而HTTP1.1后默认连接都是持久连接。
tcp的keepalive是用来侦测双方都健在的机制,若长时间不在则close连接。http的keepalive是为了让tcpconn生命周期更长,以便重用已有的tcpconn,提高通信性能。
client
client的数据结构包括Transport,用来做一次HTTP的RoundTrip。CheckRedirect处理重定向策略。Jar处理cookie。Timeout是请求过期时间。
1 | type Client struct { |
client.send精简下来就是用Transport完成了一次HTTP事务。通信细节都封装在了transport里。本节主要记录http包的DefaultTransport
的实现。
1 | func send(req *Request, t RoundTripper) (resp *Response, err error) { |
1 | type RoundTripper interface { |
transport
以下数据结构省略了TLS和代理相关内容。Transport结构体包含了两个重要的map用来存持久连接persistConn
,connectMethodKey
代表协议和地址,也就是对每个server端的每种协议都有persistConn的映射。MaxIdleConnsPerHost
配置为每个Host的最大空闲连接数。Dial
方法可以看成对connect这个socket调用的封装。
1 | type Transport struct { |
transport从Req获取method+host,然后从连接池获取persistconn
,然后开始这次roundTrip
。所有的读写过程和维护连接池都在这里做的。
1 | func (t *Transport) RoundTrip(req *Request) (resp *Response, err error) { |
idleConn
getconn的来源有三个,除了idleConn连接池,还有一个idleConnCh维护了ch,这个ch在getConn时发现没有可用空闲连接是就创建
- idleConn缓存了与某个scheme+addr的空闲连接,getIdleConn从缓存中获取
- 若idle缓存中没有,则开始dialconn,即通过net.Dial新建TCP连接
- dialConn需要时间,若这期间
getIdleConnCh
获取到别的已使用完回收的idleConn,则复用这个刚回收的conn
1 | func (t *Transport) getConn(req *Request, cm connectMethod) (*persistConn, error) { |
最后不能忘记处理正在dialConn的这个连接,要用handlePendingDial,把之后返回的dialconn放到idleConnCh这个map中,若发失败了,就把它直接放回缓存。失败的两种可能:
t.idleConnCh[key]
的ch为nil,说明没有getConn需要新的连接t.idleConnCh[key]
的ch不为nil,但getConn已经退出对t.idleConnCh[key]的读取,说明dialConn已经返回了,这时需要把这个delete(t.idleConnCh, key)
删掉
1 | func (t *Transport) putIdleConn(pconn *persistConn) bool { |
roundtrip & loop
roundtrip处理读写的大致流程涉及三个goroutine,其实逻辑很简单清晰:
- dialconn新建TCP连接时,然后开始readLoop和writeLoop
- 通过getConn获得连接后,roundtrip将req和writeErrCh发给writeLoop,writeLoop把发请求的结果通过writeErrCh通知roundtrip这个主协程
- 同时roundtrip将req和responseAndErrorCh发给readLoop,readLoop把相应和error通知主协程。
1 | func (t *Transport) dialConn(cm connectMethod) (*persistConn, error) { |
roundtrip其实就是把req写到writeCh,即writeLoop开始往conn上发request,同时把resc这个用来收集response和error的ch通过pc.reqch上发给连接的readLoop。然后开始等结果,若写req错误,则返回,若读循环resc有结果也返回。
1 | func (pc *persistConn) roundTrip(req *transportRequest) (resp *Response, err error) { |
writeLoop原本阻塞在<-pc.writeCh,直到roundtrip开始传入req,于是往pconn上写请求。处理完一次req并返回结果后,writeLoop重新阻塞在pc.writeCh直到这个连接被复用,有另一个http请求发送。
1 | func (pc *persistConn) writeLoop() { |
readLoop本来阻塞在pc.reqch,直到roundtrip开始,readloop开始读取pconn的response,并把结果返回给主循环。
1 | func (pc *persistConn) readLoop() { |
遗留问题
整个golang http包的实现很容易理解,就好像同步阻塞的在处理并发请求,当然这有赖于runtime层封装了epoll等事件驱动,并结合goroutine实现并发处理请求。
- 默认client的Transport有keepAlive机制,server侧也有,那如果两端都不关闭net.Conn也不发送数据,将持久占用这条连接。事实上tcpdump发现最后client主动向server发了FIN包关闭连接,golang的垃圾回收与Finalizer 提到这跟net的GC有关,待考证。Linux中每个TCP连接最少占用多少内存 提到3K左右,长期不断地连接将耗尽资源。
- 如果resp.Body.Close不执行,连接将无法被复用。这个问题 Go HTTP Client持久连接 一文中提到。
- Dial的TimeOut时间以及其他TimeOut时间,如果不设置将很快连接泄露,耗尽所有文件描述符。Go net/http超时机制完全手册[译]译文中详细解释了各种timeout时间,可参考。
- CLOSE_WAIT与TIME_WAIT问题的产生原因和解决
总结:想保持http的keepalive复用连接,首先使用一个Transport,配置最大连接复用数目(默认为2)为合理值,记得关闭相应的body体,同时设置合理的timeout时间,不让一次HTTP事务长时间占据conn。