百种弊病,皆从懒生

从去年的一个patch说起

2018.12.29

去年对线上业务做了一些性能优化, 当时把 http client 从 requests 换成了 geventhttpclient , 上线后发起 rpc 调用的 server 整体负载低了很多, 但 client 端 latency 却高了很多, 经过 debug 觉得问题是 geventhttpclient 把 header 和 body 通过两次 sock send 发出的额外开销造成的, 尝试 修改成一次 send 后 latency 就恢复了: https://github.com/gwik/geventhttpclient/pull/85

最近在调试 gunicorn 的代码时候, 看到它建立 socket 的时候设置了 TCP_NODELAY, 在很多项目里看到过这个 tcp option, 但没细究过, man tcp 得知是用来关闭 tcp 里的 nagle 算法的. nagle 在 linux 的 默认 tcp 协议栈里是开启的, 当发送的数据包 size 小于 mss 的时候会在内存里 buffer 起来, 积累起来后再发送, 目的是提高带宽利用率, 毕竟 payload 只发一次字节也要带上 40 字节的 ip+tcp header.

看到这想起了去年的那个 patch, 其实当时也怀疑分两次 send 发数据包是不是真的有那么大 overhead, 但问题解决了,就没细究. 发现 nagle 这个问题后, 搜了一下, 应该算是 socket 编程里一个很普遍的问题, google tcp 40ms 有很多文章. 尝试用自己的语言把这个问题说清楚吧.

40ms delay = Nagle’s algorithm + TCP delayed ACK + write-write-read

在 40ms 这个问题里, nagle 算法作用在发送方, delayed ACK 作用在接收方.

delayed ACK 也是默认开启的, 目的和 nagle 一样是为了减少传输的 packet 数目, 当 server 收到一个数据包的时候 它会等待一个时间(40 ~ 200ms), 如果在这个时间里有数据包需要返回, ack 标志位就会插在返回的数据包上一起回去, 如果 timeout 时间内没有数据返回, 则返回一个单独的 ack 数据包. delayed ACK 是为了 req-resp-req-resp 这种通讯模式设计的.

和 delay ack 相关的参数是:

  • TCP_ATO_MIN (40ms)
  • TCP_DELACK_MIN (40ms)
  • TCP_DELACK_MAX (200ms)

https://github.com/torvalds/linux/blob/v4.20/include/net/tcp.h#L133

再来看看 nagle 算法, wiki 上 的伪代码:

    1. if there is new data to send
    2.     if the window size >= MSS and available data is >= MSS
    3.        send complete MSS segment now
    4.     else
    5.         if there is unconfirmed data still in the pipe
    6.            enqueue data in the buffer until an acknowledge is received
    7.         else
    8.            send data immediately
    9.         end if
    10.    end if
    11. end if

问题出在第5行, 当缓存里存在未收到 ack 的数据, 并且包比较小没达到 MSS 的时候, 就会将数据写入缓存.

如果 http client 按以下顺序工作:

  • send header
  • send body
  • read response

如果 header 和 body 都很小, 加起来都没到 MSS, 由于第二个 body 被 buffer 起来了, 没发出去, 此时 server 默认又开了 delayed ack, server 端的 tcp 协议栈就会等程序写 response, 但 body 还在 client 端 buffer 着呢, 现象就像死锁了一样, client 等 ack, server 在等 client 发完整数据包. 最后就到了超时时间 40ms.

解决办法有三种:

  • 和我 patch 里做的一样将 http header body 通过一个 send 发送, 将 write-write-read 的方式转换成 write-read 的方式.
  • client 端 socket 设置 TCP_NODELAY, 这样每次 send 都会发出 packet, 缺点是对小的包不太友好
  • server 端 socket 设置 TCP_QUICKACK, 就会关闭 delayed ack. server 端的返回 ack 包会变多.

写个测试程序验证一下这个 40ms 问题, server 端用的是默认配置的 nginx(没有设置 TCP_QUICKACK):

    
        import socket
        import time

        host = '192.168.33.10'
        port = 80

        TOTAL_TIME = 0
        count = 10


        def test_func(s, together=False):
            global TOTAL_TIME

            header = b'POST / HTTP/1.1\r\nHost: 192.168.33.10\r\nConnection: keep-alive\r\nUser-Agent: curl/7.58.0\r\nContent-Length: 3\r\nContent-Type: application/x-www-form-urlencoded\r\nAccept: */*\r\n\r\n'
            body = b'abc'
            _all = header + body
            for i in range(count):
                start = time.time()
                if together:
                    s.send(_all)
                else:
                    s.send(header)
                    s.send(body)
                s.recv(1024)
                TOTAL_TIME += time.time() - start
                print(TOTAL_TIME * 1000)


        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            s.connect((host, port))
            #s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
            test_func(s)
        

不设置 TCP_NODELAY, 并且分开发送 header, body, 耗时结果如下, 很明显出现了 40ms,:

    0.15425682067871094
    55.33957481384277
    55.64403533935547
    99.50447082519531
    100.52800178527832
    143.18251609802246
    143.5527801513672
    191.00356101989746
    191.3611888885498
    240.60606956481934

设置 TCP_NODELAY, 还是分开发 header, body, 解决了 40ms:

    0.2655982971191406
    0.4208087921142578
    0.6325244903564453
    0.8370876312255859
    0.9863376617431641
    1.1348724365234375
    1.2805461883544922
    1.4083385467529297
    1.519918441772461
    1.618862152099609

不设置 TCP_NODELAY, 将 header, body 合并发送, 也能解决 40ms:

    0.21886825561523438
    0.3108978271484375
    0.3769397735595703
    0.5254745483398438
    0.6077289581298828
    0.766754150390625
    0.8800029754638672
    0.9889602661132812
    1.0802745819091797
    1.155853271484375 

python2.7 标准库的 httplib 中, 如果发送的 body 是简单字符串会和 header 拼在一起后调用一次 sendall. requests 底下用的是 httplib, 所以没 40ms 问题.

在 python3.6 标准库的 http.client 里创建 tcp socket 时设了 TCP_NODELAY, 先发 header, 再发 body, 也不会有问题.

有趣的是 nagle 算法的作者几十年后还在网上吐槽了 delayed ack: https://news.ycombinator.com/item?id=10607422

Reference:

comments powered by Disqus