How HTTP1.1 protocol is implemented in Golang net/http package: part two - write HTTP message to socket
Background
In the previous article, I introduced the main workflow of an HTTP request implemented inside Golang net/http package. As the second article of this series, I’ll focus on how to pass the HTTP message to TCP/IP stack, and then it can be transported over the network.
Architecture diagram
When the client application sends an HTTP request, it determines what is next step based on whether there is an available persistent connection in the cached connection pool. If no, then a new TCP connection will be established. If yes, then a persistent connection will be selected.
The details of the connection pool is not in this article’s scope. I’ll discuss it in the next article. For now you can regard it as a block box.
The overall diagram of this article goes as follows, we can review each piece of it in the below sections
if s := pconn.tlsState; s != nil && s.NegotiatedProtocolIsMutual && s.NegotiatedProtocol != "" { if next, ok := t.TLSNextProto[s.NegotiatedProtocol]; ok { alt := next(cm.targetAddr, pconn.conn.(*tls.Conn)) if e, ok := alt.(http2erringRoundTripper); ok { returnnil, e.err } return &persistConn{t: t, cacheKey: pconn.cacheKey, alt: alt}, nil } } pconn.br = bufio.NewReaderSize(pconn, t.readBufferSize()) // buffer io wrapper for writing request pconn.bw = bufio.NewWriterSize(persistConnWriter{pconn}, t.writeBufferSize()) // read loop go pconn.readLoop() // write loop go pconn.writeLoop() return pconn, nil }
At line 4, it creates a new persistConn object, which is also the return value for this method.
At line 22 and line 46, it calls dial method to establish a new TCP connection (note line 22 handles TLS case). In Golang a TCP connection is represented as net.Conn type. And then the underlying TCP connection is bound to the conn field of persistConn.
Now that we have the TCP connection, how can we use it? We’ll skip the many lines of code and go to the end to this function.
At line 166, it creates bufio.Writer based on persistConn. Buffer IO is an interesting topic, in detail you can refer to my previous article. In one word, it can optimize the performance by reducing the number of system calls. For example in the current case, it can avoid too many socket system calls.
At line 171, it creates a Goroutine and execute writeLoop method. Let’s take a look at it.
// writeLoop method in transport.go file func(pc *persistConn) writeLoop() { deferclose(pc.writeLoopDone) for { select { // receive request from writech channel case wr := <-pc.writech: startBytesWritten := pc.nwrite // call write method err := wr.req.Request.write(pc.bw, pc.isProxy, wr.req.extra, pc.waitForContinue(wr.continueCh)) if bre, ok := err.(requestBodyReadError); ok { err = bre.error wr.req.setError(err) } if err == nil { err = pc.bw.Flush() } if err != nil { wr.req.Request.closeBody() if pc.nwrite == startBytesWritten { err = nothingWrittenError{err} } } pc.writeErrCh <- err // to the body reader, which might recycle us wr.ch <- err // to the roundTrip function if err != nil { pc.close(err) return } case <-pc.closech: return } } }
As the function name writeLoop implies, there is a for loop, and it keeps receiving data from the writech channel. Everytime it receive a request from the channel, call the write method at line 10. Then let’s review what message it actually writes:
if waitForContinue != nil { if bw, ok := w.(*bufio.Writer); ok { err = bw.Flush() if err != nil { return err } } if trace != nil && trace.Wait100Continue != nil { trace.Wait100Continue() } if !waitForContinue() { r.closeBody() returnnil } }
if bw, ok := w.(*bufio.Writer); ok && tw.FlushHeaders { if err := bw.Flush(); err != nil { return err } } err = tw.writeBody(w) if err != nil { if tw.bodyReadError == err { err = requestBodyReadError{err} } return err }
if bw != nil { return bw.Flush() } returnnil }
We will not go through every line of code in above function. But I bet you find many familiar information, for example, at line 37 it write HTTP request line as the first information in the HTTP message. Then it continues writing HTTP headers such as Host and User-Agent(at line 42 and line 56), and finally add the blank line after the headers (at line 86). An HTTP request message is built up bit by bit. All right.
Bufio and underlying writer
Next piece of this puzzle is how it’s related to the underlying TCP connection.
Note this method call in the write loop:
1 2
// write method call in writeLoop wr.req.Request.write(pc.bw, pc.isProxy, wr.req.extra, pc.waitForContinue(wr.continueCh))
The first parameter is pc.bw mentioned above. It’s time to take a deep look at it. pc.bw, a bufio.Write, is created by calling the following method from bufio package:
1 2
// pconn.bw is created by this method call pconn.bw = bufio.NewWriterSize(persistConnWriter{pconn}, t.writeBufferSize())
Note that this bufio.Writer isn’t based on persistConn directly, instead a simple wrapper over persistConn called persistConnWriter is used here.
1 2 3 4
// persistConnWriter in transport.go file type persistConnWriter struct { pc *persistConn }
What we need to understand is bufio.Writer wraps an io.Writer object, creating another Writer that also implements the interface but provides buffering functionality. And bufio.Writer’s Flush method writes the buffered data to the underlying io.Writer.
In this case, the underlying io.Writer is persistConnWriter. Its Write method will be used to write the buffered data:
1 2 3 4 5 6
// persistConnWriter in transport.go file func(w persistConnWriter) Write(p []byte) (n int, err error) { n, err = w.pc.conn.Write(p) // TCP socket Write system call is called here! w.pc.nwrite += int64(n) return }
Internally it delegates the task to the TCP connection bond to pconn.conn!
roundTrip
As we mentioned above, writeLoop keeps receiving reqeusts from writech channel. So on the other hand, it means the requests should be sent to this channel somewhere. This is implemented inside the roundTrip method:
var respHeaderTimer <-chan time.Time cancelChan := req.Request.Cancel ctxDoneChan := req.Context().Done() for { testHookWaitResLoop() select { case err := <-writeErrCh: if debugRoundTrip { req.logf("writeErrCh resv: %T/%#v", err, err) } if err != nil { pc.close(fmt.Errorf("write error: %v", err)) returnnil, pc.mapRoundTripError(req, startBytesWritten, err) } if d := pc.t.ResponseHeaderTimeout; d > 0 { if debugRoundTrip { req.logf("starting timer for %v", d) } timer := time.NewTimer(d) defer timer.Stop() respHeaderTimer = timer.C } case <-pc.closech: if debugRoundTrip { req.logf("closech recv: %T %#v", pc.closed, pc.closed) } returnnil, pc.mapRoundTripError(req, startBytesWritten, pc.closed) case <-respHeaderTimer: if debugRoundTrip { req.logf("timeout waiting for response headers.") } pc.close(errTimeout) returnnil, errTimeout case re := <-resc: if (re.res == nil) == (re.err == nil) { panic(fmt.Sprintf("internal error: exactly one of res or err should be set; nil=%v", re.res == nil)) } if debugRoundTrip { req.logf("resc recv: %p, %T/%#v", re.res, re.err, re.err) } if re.err != nil { returnnil, pc.mapRoundTripError(req, startBytesWritten, re.err) } return re.res, nil case <-cancelChan: pc.t.cancelRequest(req.cancelKey, errRequestCanceled) cancelChan = nil case <-ctxDoneChan: pc.t.cancelRequest(req.cancelKey, req.Context().Err()) cancelChan = nil ctxDoneChan = nil } } }
At line 48, you can find it clearly. In last article, you can see that pconn.roundTrip is the end of the HTTP request workflow. Now we had put all parts together. Great.
Summary
In this article (as the second part of this series), we reviewed how the HTTP request message is written to TCP/IP stack via socket system call.