-3

I ask this question because I had a very weird puzzling experience that I am about to tell.

I am instrumenting an HTTP API server to observe it's behavior in the presence of latency between the server and the clients. I had a setup consisting of a single server and a dozen of clients connected with a 10Gbps Ethernet fabric. I measured the time it took to serve certain API requests in 5 scenarios. In each scenario, I set the latency between the server and the clients to one of the values: No latency (I call this baseline), 25ms, 50ms, 250ms or 400ms using the tc-netem(8) utility.

Because I am using histogram buckets to quantify the service time, I observed that all the requests were processed in less than 50ms whatever the scenario is, which clearly doesn't make any sense as, for example, in the case of 400ms, it should be at least around 400ms (as I am only measuring the duration from the moment the request hits the server to the moment the HTTP Write()function returns). Note that the response objects are between 1Kb to 10Kb in size.

Initially, I had doubts that the *http.ResponsWriter's Write() function was asynchronous and returns immediately before data is received by the client. So, I decided to test this hypothesis by writing a toy HTTP server that services the content of a file that is generated using dd(1) and /dev/urandom to be able to reconfigure the response size. Here is the server:

var response []byte

func httpHandler(w http.ResponseWriter, r * http.Request) {

    switch r.Method {
        case "GET":
            now: = time.Now()
            w.Write(response)
            elapsed: = time.Since(now)
            mcs: = float64(elapsed / time.Microsecond)
            s: = elapsed.Seconds()
            log.Printf("Elapsed time in mcs: %v, sec:%v", mcs, s)
    }
}

func main() {
    response, _ = ioutil.ReadFile("BigFile")

    http.HandleFunc("/hd", httpHandler)
    http.ListenAndServe(":8089", nil)
}

Then I start the server like this: dd if=/dev/urandom of=BigFile bs=$VARIABLE_SIZE count=1 && ./server

from the client side, I issue time curl -X GET $SERVER_IP:8089/hd --output /dev/null

I tried with many values of $VARIABLE_SIZE from the range [1Kb, 500Mb], using an emulated latency of 400ms between the server and each one of the clients. To make long story short, I noticed that the Write() method blocks until the data is sent when the response size is big enough to be visually noticed (on the order of tens of megabytes). However, when the response size is small, the server doesn't report a mentally sane servicing time compared to the value reported by the client. For a 10Kb file, the client reports 1.6 seconds while the server reports 67 microseconds (which doesn't make sense at all, even me as a human I noticed a little delay on the order of a second as it is reported by the client).

To go a little further, I tried to find out starting from which response size the server returns a mentally acceptable time. After many trials using a binary search algorithm, I discovered that the server always returns few microseconds [20us, 600us] for responses that are less than 86501 bytes in size and returns expected (acceptable) times for requests that are >= 86501 bytes (usually half of the time reported by the client). As an example, for a 86501 bytes response, the client reported 4 seconds while the server reported 365 microseconds. For 86502 bytes, the client reported 4s and the sever reported 1.6s. I repeated this experience many times using different servers, the behavior is always the same. The number 86502 looks like magic !!

This experience explains the weird observations I initially had because all the API responses were less than 10Kb in size. However, this opens the door for a serious question. What the heck on earth is happening and how to explain this behavior ?

I've tried to search for answers but didn't find anything. The only thing I can think about is maybe it is related to Linux's sockets size and whether Go makes the system call in a non-blocking fashion. However, AFAIK, TCP packets transporting the HTTP responses should all be acknowledged by the receiver (the client) before the sender (the server) can return ! Breaking this assumption (as it looks like in this case) can lead to disasters ! Can someone please provide an explanation for this weird behavior ?

Technical details:

Go version: 12
OS: Debian Buster
Arch: x86_64
Karim Manaouil
  • 1,177
  • 10
  • 24
  • 3
    Calls to net/http server's ResponseWriter.Write implementation do not block until the data is received by the client. – Charlie Tumahai May 01 '20 at 04:42
  • It seems that for longer requests it is ! And if it doesn't block then how can it return successfully making sure the client received all the data correctly ? – Karim Manaouil May 01 '20 at 05:33
  • 1
    The server side handler cannot determine whether the client received the data correctly. Any server in between may drop data. – Volker May 01 '20 at 06:19
  • @KarimManaouil Regarding your observed behavior for large response bodies: Write blocks when there's no space in available in the response writer buffer and the operating system networking buffers. These buffers drain as the networking peer acks received data. – Charlie Tumahai May 01 '20 at 06:58
  • @Volker can you please check my comment on the answer by @ kostix ? I have explained my point there. – Karim Manaouil May 06 '20 at 15:10
  • @CeriseLimón can you please check my comment on the answer by @ kostix ? I have explained my point there. – Karim Manaouil May 06 '20 at 15:10
  • @KarimManaouil It's impossible to determine that an HTTP client application received an HTTP response from the HTTP transaction itself. Your assumptions are not correct. – Charlie Tumahai May 06 '20 at 16:02
  • @KarimManaouil HTTP works in one way: A client can request data from a server and the client will know when it has all data. The server doesn't know and cannot know as kostix explained. If you need that: You must implement your own protocol. – Volker May 06 '20 at 17:08

1 Answers1

3

I'd speculate the question is stated in a wong way in fact: you seem to be guessing about how HTTP works instead of looking at the whole stack.

The first thing to consider is that HTTP (1.0 and 1.1, which is the standard version since long time ago) does not specify any means for either party to acknowledge data reception.
There exists implicit acknowledge for the fact the server received the client's request — the server is expected to respond to the request, and when it responds, the client can be reasonably sure the server had actually received the request.
There is no such thing working in the other direction though: the server does not expect the client to somehow "report back" — on the HTTP level — that it had managed to read the whole server's response.

The second thing to consider is that HTTP is carried over TCP connections (or TLS, whcih is not really different as it uses TCP as well).

An oft-forgotten fact about TCP is that it has no message framing — that is, TCP performs bi-directional transfer of opaque byte streams. TCP only guarantees total ordering of bytes in these streams; it does not in any way preserve any occasional "batching" which may naturally result from the way you work with TCP via a typical programming interface — by calling some sort of "write this set of bytes" function.

Another thing which is often forgotten about TCP is that while it indeed uses acknowledgements to track which part of the outgoing stream was actually received by the receiver, this is a protocol detail which is not exposed to the programming interface level (at least not in any common implementation of TCP I'm aware of).

These features mean that if one wants to use TCP for message-oriented data exchange, one needs to implement support for both message boundaries (so-called "framing") and acknowledgement about the reception of individual messages in the procotol above TCP. HTTP is a protocol which is above TCP but while it implements framing, it does not implement explicit acknowledgement besides the server responding to the client, described above.

Now consider that most if not all TCP implementations employ buffering in various parts of the stack. At least, the data which is submitted by the program gets buffered, and the data which is read from the incoming TCP stream gets buffered, too.

Finally consider that most commonly used TCP implementations provide for sending data into an active TCP connection through the use of a call allowing to submit a chunk of bytes of arbitrary length. Considering the buffering described above, such a call typically blocks until all the submitted data gets copied to the sending buffer. If there's no room in the buffer, the call blocks until the TCP stack manages to stream some amount of data from that buffer into the connection — freeing some room to accept more data from the client.

What all of the above means for net/http.ResponseWriter.Write interacting with a typical contemporary TCP/IP stack?

  • A call to Write would eventially try to submit the specified data into the TCP/IP stack.
  • The stack would try to copy that data over into the sending buffer of the corresponding TCP connection — blocking until all the data manages to be copied.
  • After that you have essentially lost any control about what happens with that data: it may eventually be successfully delivered to the receiver, or it may fail completely, or some part of it might succeed and the rest will not.

What this means for you, is that when net/http.ResponseWriter.Write blocks, it blocks on the sending buffer of the TCP socket underlying the HTTP connection you're operating on.


Note though, that if the TCP/IP stack detects an irrepairable problem with the connection underlying your HTTP request/response exchange — such as a frame with the RST flag coming from the remote part meaning the connection has been unexpectedly teared down — this problem will bubble up the Go's HTTP stack as well, and Write will return a non-nil error. In this case, you will know that the client was likely not able to receive the complete response.

kostix
  • 51,517
  • 14
  • 93
  • 176
  • Great, thanks for the details @kostix but honestly I already know all of that. The problem lies in the last paragraph you have written. Suppose I called a Write() on a small chunk of data that based on the above experience will immediately return upon handing that to the TCP stack of the OS. Now the TCP stack is probably buffering the data and it will eventually send it implementing all the TCP details like re-transmission. But now if the TCP stack suddenly encoutered a fatal error and couldn't move forward, it should return to the caller. Yet the caller returned ! – Karim Manaouil May 06 '20 at 15:06
  • And because the caller returned, there is no way to bubble up the error and eventually catch it on the application level inside the go-routine as non-nil value of the error returned by `Write()` which is the reason for all my wondering ! And that's why I said this behavior breaks synchronicity assumptions of TCP and HTTP which is built on top. – Karim Manaouil May 06 '20 at 15:08
  • @KarimManaouil, well yes but I'd not say there are any "assumptions" in HTTP about that really — otherwise the protocol would specify a way for the client to acknowledge reception of the response. I have no idea but may be (just may be) the lack of that was directly resulted from the original idea that a single HTTP request/response exchange were to be carried within a single dedicated TCP session, so proper shutdown of such session — when both ends "see" the other end to have consciously closed — would indicate the exchange did happen OK. It's just a guess, though. – kostix May 06 '20 at 15:26
  • @KarimManaouil, IOW, in enterprise-grade code you usually care about such stuff on the client side (the client may retry the request; may be multiple times) and may help clients in some cases (such as the `Range` header field allowing for partial download). If you need peoper bidirectional exchanges between clients and servers, plain HTTP may simply not be really suitable. – kostix May 06 '20 at 15:29