4

HTTP协议与go的实现

 1 year ago
source link: https://ninokop.github.io/2018/02/19/go-http/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

HTTP协议

HTTP协议是规范服务端和客户端请求应答标准的应用层协议,即它是一个应用层的请求响应协议。客户端的请求和服务端响应共同构成了一次HTTP事务,他们之间的通信通过HTTP报文进行。HTTP1.1的报文是纯文本的,协议规定了报文的格式,报文格式都是起始行+Header+Body的格式,只是请求起始行包含方法+URI,响应起始行是状态码和状态消息。

req_resp.png

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

frame.jpg

  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包的实现。

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编程

listen_serve.png

默认accept的rw设置了keepalive,时间默认为3分钟。

server侧的连接conn是在net.Conn上的一层封装,它完成了HTTP协议解析req请求,处理请求,返回response的过程。下面是conn的数据结构。

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

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

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

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

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处理。

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是请求过期时间。

type Client struct {
Transport RoundTripper
CheckRedirect func(req *Request, via []*Request) error
Jar CookieJar
Timeout time.Duration
}

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

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

transport

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

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。所有的读写过程和维护连接池都在这里做的。

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
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)删掉
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通知主协程。
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有结果也返回。

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请求发送。

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,并把结果返回给主循环。

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK