HTTP协议与go的实现

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帧层把数据做了进一步封装,最终传输在连接上的是二进制数据。

  1. HTTP1.X时代Server端实现主动推送,需要引入类似long poll,web socket等折中方式。HTTP2的编码格式和传输协议天然支持Server Push,不需要Chunked编码等等。
  2. head-Of-Blocking问题:不支持真正的基于一个连接的并行多会话。HTTP1.X在同一个TCPconn上Transport同时只有一个roundTrip在运行,并发的请求时通过另外创建连接以及连接复用来实现的。HTTP2支持多路复用,通过streamID实现在同一个连接上处理不同stream的消息,消息间通过id区分。非常自然的实现了TCP的单连接。
  3. 协议头部的数据冗余,HTTP2支持Hpack的头部压缩算法。
  4. 引入了流控以及请求优先级的概念。

HTTPS引入

HTTPS就是HTTP on SSL/TLS,它的引入本身是为了应对明文传输场景下的三个风险:窃听风险、篡改风险、冒充风险。它通过信息加密传输让第三方无法窃听,提供校验机制让篡改可以被发现,配备身份证书防止身份被冒充。

加密基本原理就是客户端用公钥加密,服务端用自己的私钥解密。但是这种不对称加密的效率很低,所以通常是为每次对话双方协商生成一个session key对话密钥,用它来对称加密信息。公钥只用于加密对话密钥就行了。这个sessionkey在缓存,不是每次建立连接都需handshake。在这个handshake过程中通过散列值校验数据完整性,同时双方都提供证书让对方验证身份。

所以在TLS需要四次握手:

  1. 客户端clientHello说明客户端支持的TLS版本、加密方法、压缩方法、客户端生成的随机数。
  2. 服务端serverHello回应TLS版本、确认使用的加密方式、提供服务端随机数、服务端证书。这个证书包含公钥、服务端证书颁发者的数字签名。签名是用私钥加密的,所以客户端使用公钥若能解密,则完成了服务端身份验证。
  3. 客户端生成一个加密的随机数pre-master key,编码改变通知,客户端握手结束通知,同时发送所有内容的hash值,用来给服务端校验。
  4. 服务端通过三个随机数生成对话密钥,然后发编码变更通知和hash校验值。之后双方进入加密通信阶段。

go-http-server

以下是go写的最简单的HTTP server实例。它将TCP连接建立和分发的过程封装在了http.ListenAndServe当中,本节以此为开头记录http包的实现。

1
2
3
4
5
6
7
8
9
10
11
12
func sayHello(w http.ResponseWriter, r *http.Request) {
log.Print("req [%v]", *r)
fmt.Fprintln(w, "hello world")
}

func main() {
http.HandleFunc("/", sayHello)
err := http.ListenAndServe(":8080", nil)
if err != nil {
log.Fatal("ListenAndServe error", err)
}
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type conn struct {
remoteAddr string // network address of remote side
server *Server // the Server on which the connection arrived
rwc net.Conn // i/o connection
w io.Writer
werr error // any errors writing to w
sr liveSwitchReader
lr *io.LimitedReader // io.LimitReader(sr)
buf *bufio.ReadWriter
tlsState *tls.ConnectionState // or nil when not using TLS
lastMethod string // method of previous request, or ""

mu sync.Mutex // guards the following
clientGone bool // if client has disconnected mid-request
closeNotifyc chan bool // made lazily
hijackedv bool // connection has been hijacked by handler
}

节选conn.serve()的一段,conn处理请求的基本过程如下。其中readRequest从读conn.buf按照HTTP协议解析请求,然后将请求丢给对应的Handler处理。读取req的过程是从bufiolimitReader,再到liveSwitchReader,最后到net.Conn

1
2
3
4
5
6
7
8
9
10
func (c *conn) serve() {
// set deadline and deal with tlsconn handshake
for {
w, err := c.readRequest()
req := w.req
serverHandler{c.server}.ServeHTTP(w, w.req)
w.finishRequest()
c.setState(c.rwc, StateIdle)
}
}

server mux

Server包含一个重要成员,就是Handler,它是一个接口,描述如何处理req和response。如果server中没有初始化handler,将调用http默认的路由器DefaultServeMux

1
2
3
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}

service mux的数据结构如下,其中m存了mux的pattern对应的handler。这里的m是通过http.HandleFunc注册到默认路由器中的。

1
2
3
4
5
6
7
8
9
10
11
type ServeMux struct {
mu sync.RWMutex
m map[string]muxEntry
hosts bool // whether any patterns contain hostnames
}

type muxEntry struct {
explicit bool
h Handler
pattern string
}

截取service mux实现路由选择的过程,其实Handler就是对比r.Host + r.URL.Path和m中的pattern,匹配到muxEntry的Handler,最后调用handler的ServeHTTP处理。

1
2
3
4
func (mux *ServeMux) ServeHTTP(w ResponseWriter, r *Request) {
h, _ := mux.Handler(r)
h.ServeHTTP(w, r)
}

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
2
3
4
5
6
type Client struct {
Transport RoundTripper
CheckRedirect func(req *Request, via []*Request) error
Jar CookieJar
Timeout time.Duration
}

client.send精简下来就是用Transport完成了一次HTTP事务。通信细节都封装在了transport里。本节主要记录http包的DefaultTransport的实现。

1
2
3
func send(req *Request, t RoundTripper) (resp *Response, err error) {
return t.RoundTrip(req)
}
1
2
3
type RoundTripper interface {
RoundTrip(*Request) (*Response, error)
}

transport

以下数据结构省略了TLS和代理相关内容。Transport结构体包含了两个重要的map用来存持久连接persistConnconnectMethodKey代表协议和地址,也就是对每个server端的每种协议都有persistConn的映射。MaxIdleConnsPerHost配置为每个Host的最大空闲连接数。Dial方法可以看成对connect这个socket调用的封装。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type Transport struct {
idleMu sync.Mutex
wantIdle bool // user has requested to close all idle conns
idleConn map[connectMethodKey][]*persistConn
idleConnCh map[connectMethodKey]chan *persistConn

reqMu sync.Mutex
reqCanceler map[*Request]func()

altMu sync.RWMutex
altProto map[string]RoundTripper

Dial func(network, addr string) (net.Conn, error)
DisableKeepAlives bool
MaxIdleConnsPerHost int
ResponseHeaderTimeout time.Duration
}

transport从Req获取method+host,然后从连接池获取persistconn,然后开始这次roundTrip。所有的读写过程和维护连接池都在这里做的。

1
2
3
4
5
6
7
8
9
10
11
12
func (t *Transport) RoundTrip(req *Request) (resp *Response, err error) {
// check req ...
treq := &transportRequest{Request: req}
cm, err := t.connectMethodForRequest(treq)
pconn, err := t.getConn(req, cm)
if err != nil {
t.setReqCanceler(req, nil)
req.closeBody()
return nil, err
}
return pconn.roundTrip(treq)
}

idleConn

getconn的来源有三个,除了idleConn连接池,还有一个idleConnCh维护了ch,这个ch在getConn时发现没有可用空闲连接是就创建

  1. idleConn缓存了与某个scheme+addr的空闲连接,getIdleConn从缓存中获取
  2. 若idle缓存中没有,则开始dialconn,即通过net.Dial新建TCP连接
  3. dialConn需要时间,若这期间getIdleConnCh获取到别的已使用完回收的idleConn,则复用这个刚回收的conn
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
func (t *Transport) getConn(req *Request, cm connectMethod) (*persistConn, error) {
if pc := t.getIdleConn(cm); pc != nil {
return pc, nil
}

type dialRes struct {
pc *persistConn
err error
}
dialc := make(chan dialRes)
handlePendingDial := func() {
go func() {
if v := <-dialc; v.err == nil {
t.putIdleConn(v.pc)
}
}()
}
go func() {
pc, err := t.dialConn(cm)
dialc <- dialRes{pc, err}
}()

idleConnCh := t.getIdleConnCh(cm)
select {
case v := <-dialc:
// Our dial finished.
return v.pc, v.err
case pc := <-idleConnCh:
handlePendingDial()
return pc, nil
}
}

最后不能忘记处理正在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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func (t *Transport) putIdleConn(pconn *persistConn) bool {
waitingDialer := t.idleConnCh[key]
select {
case waitingDialer <- pconn:
t.idleMu.Unlock()
return true
default:
if waitingDialer != nil {
// They had populated this, but their dial won
// first, so we can clean up this map entry.
delete(t.idleConnCh, key)
}
}
if len(t.idleConn[key]) >= max {
t.idleMu.Unlock()
pconn.close()
return false
}
t.idleConn[key] = append(t.idleConn[key], pconn)
t.idleMu.Unlock()
return true
}

roundtrip & loop

roundtrip处理读写的大致流程涉及三个goroutine,其实逻辑很简单清晰:

  1. dialconn新建TCP连接时,然后开始readLoop和writeLoop
  2. 通过getConn获得连接后,roundtrip将req和writeErrCh发给writeLoop,writeLoop把发请求的结果通过writeErrCh通知roundtrip这个主协程
  3. 同时roundtrip将req和responseAndErrorCh发给readLoop,readLoop把相应和error通知主协程。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func (t *Transport) dialConn(cm connectMethod) (*persistConn, error) {
pconn := &persistConn{
t: t,
cacheKey: cm.key(),
reqch: make(chan requestAndChan, 1),
writech: make(chan writeRequest, 1),
closech: make(chan struct{}),
writeErrCh: make(chan error, 1),
}
conn, err := t.dial("tcp", cm.addr())
pconn.conn = conn

pconn.br = bufio.NewReader(noteEOFReader{pconn.conn, &pconn.sawEOF})
pconn.bw = bufio.NewWriter(pconn.conn)
go pconn.readLoop()
go pconn.writeLoop()
return pconn, nil
}

roundtrip其实就是把req写到writeCh,即writeLoop开始往conn上发request,同时把resc这个用来收集response和error的ch通过pc.reqch上发给连接的readLoop。然后开始等结果,若写req错误,则返回,若读循环resc有结果也返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func (pc *persistConn) roundTrip(req *transportRequest) (resp *Response, err error) {
writeErrCh := make(chan error, 1)
pc.writech <- writeRequest{req, writeErrCh}

resc := make(chan responseAndError, 1)
pc.reqch <- requestAndChan{req.Request, resc, requestedGzip}
var re responseAndError
WaitResponse:
for {
select {
case err := <-writeErrCh:
if err != nil {
re = responseAndError{nil, err}
pc.close()
break WaitResponse
}
case re = <-resc:
break WaitResponse
}
}
return re.res, re.err
}

writeLoop原本阻塞在<-pc.writeCh,直到roundtrip开始传入req,于是往pconn上写请求。处理完一次req并返回结果后,writeLoop重新阻塞在pc.writeCh直到这个连接被复用,有另一个http请求发送。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func (pc *persistConn) writeLoop() {
for {
select {
case wr := <-pc.writech:
err := wr.req.Request.write(pc.bw, pc.isProxy, wr.req.extra)
if err == nil {
err = pc.bw.Flush()
}
pc.writeErrCh <- err // to the body reader, which might recycle us
wr.ch <- err // to the roundTrip function
case <-pc.closech:
return
}
}
}

readLoop本来阻塞在pc.reqch,直到roundtrip开始,readloop开始读取pconn的response,并把结果返回给主循环。

1
2
3
4
5
6
7
8
9
10
11
func (pc *persistConn) readLoop() {
alive := true
for alive {
pb, err := pc.br.Peek(1)
rc := <-pc.reqch
...
resp, err = ReadResponse(pc.br, rc.req)
rc.ch <- responseAndError{resp, err}
}
pc.close()
}

遗留问题

整个golang http包的实现很容易理解,就好像同步阻塞的在处理并发请求,当然这有赖于runtime层封装了epoll等事件驱动,并结合goroutine实现并发处理请求。

  1. 默认client的Transport有keepAlive机制,server侧也有,那如果两端都不关闭net.Conn也不发送数据,将持久占用这条连接。事实上tcpdump发现最后client主动向server发了FIN包关闭连接,golang的垃圾回收与Finalizer 提到这跟net的GC有关,待考证。Linux中每个TCP连接最少占用多少内存 提到3K左右,长期不断地连接将耗尽资源。
  2. 如果resp.Body.Close不执行,连接将无法被复用。这个问题 Go HTTP Client持久连接 一文中提到。
  3. Dial的TimeOut时间以及其他TimeOut时间,如果不设置将很快连接泄露,耗尽所有文件描述符。Go net/http超时机制完全手册[译]译文中详细解释了各种timeout时间,可参考。
  4. CLOSE_WAIT与TIME_WAIT问题的产生原因和解决

总结:想保持http的keepalive复用连接,首先使用一个Transport,配置最大连接复用数目(默认为2)为合理值,记得关闭相应的body体,同时设置合理的timeout时间,不让一次HTTP事务长时间占据conn。

Reference