Atom

思考、故事和创意

TCP

对 Google TCP BBR 的浅薄认识

关于 Google 发布的新的 TCP 拥塞控制算法 BBR (Bottleneck Bandwidth and RTT) 的一些记录,仍然在学习中,难免有误解。之后会持续修正和完善。

网络链路上的包比较少时,道路很通畅,这个阶段,对于一个 TCP 连接来说,它的速度由这个连接两端之间的距离决定,也可以说是由 RTT 决定。当发包速率变大,把道路基本上填满了之后,这个阶段,带宽的大小决定了这个连接的速度,这时两端之间可能就会有包要排队,延迟时间除了 RTT 还有排队时间。

BBR 目的是要尽量跑满带宽,并且尽量不要有排队的情况。

cwnd 是普通的拥塞控制算法里最终要求得的一个值,用来控制发包速率。BBR 也要求到这个值,但是它不是最主要的控制发包速率的变量,主要的变量是 pacing_rate

这两个变量都由探测到的带宽值和 RTT 值得到,整个过程都围绕着这两个值。在 BBR 算法中,有四种状态,几种状态可能会有如下的转换:

            |
            V
   +---> STARTUP  ----+
   |        |         |
   |        V         |
   |      DRAIN   ----+
   |        |         |
   |        V         |
   +---> PROBE_BW ----+
   |      ^    |      |
   |      |    |      |
   |      +----+      |
   |                  |
   +---- PROBE_RTT <--+

另外还有一个重要变量,是在不同状态,不同阶段取的不同增益系数,比如有时需要多发包探测到最大瓶颈带宽,有时需要把发包量降下来探测较为准确的 RTT。

STARTUP 类似与普通拥塞控制里的慢启动,增益系数是 2ln2,每一个来回都以这个系数增大发包速率,估测到带宽满了就进入 DRAIN 状态 —— 连续三个来回,测得的最大瓶颈带宽没有比上一轮增大 25% 以上,就算做带宽满了。

进入 DRAIN 状态,增益系数小于 1,也就降速了。一个包来回,把 STARTUP 状态中产生的队列“抽干”,怎么样测算到队列空了?发出去还没有 ACK 的包量 inflight,与 BDP (带宽延迟积)进行比较,inflight < BDP 说明空了,道路不那么满了,如果 inflght > BDP 说明还不能到下一个状态,继续 DRAIN。

PROBEBW 是稳定状态,这时已经测出来一个最大瓶颈带宽,而且尽量不会产生排队现象。之后的每个来回,在 PROBEBW 状态循环(除非要进入下面提到的 PROBERTT 状态),轮询下面这些增益系数,5/4, 3/4, 1, 1, 1, 1, 1, 1,如此,最大瓶颈带宽就会在其停止增长的地方上下徘徊。大部分时间都应该处于 PROBEBW 状态。

前面三种状态,都可能进入 PROBERTT 状态。超过十秒没有估测到更小的 RTT 值,这时进入 PROBERTT 状态,把发包量降低,空出道路来比较准确得测一个 RTT 值,至少 200ms 或一个包的来回之后退出这个状态。检查带宽是否是满的,进入不同的状态:如果不满,进入 STARTUP 状态,如果满,进入 PROBEBW 状态。

用 epoll 的 echo 程序

完整代码放在最前面,只是一个简单的 echo 程序,没有业务逻辑,收到数据之后立即发送回去。然而,还是有些细节值得记录一下,关于 TCP Server 通常需要设置的 TCP 套接字属性,屏蔽 SIGPIPE 信号,以及最重要的:如何使用 epoll,ET 模式和 LT 模式是怎样的。

#include <stdio.h>
#include <unistd.h>
#include <stdint.h>
#include <stdlib.h>
#include <fcntl.h>
#include <string.h>
#include <sys/socket.h>
#include <signal.h>
#include <netinet/tcp.h>
#include <arpa/inet.h>
#include <sys/epoll.h>
#include <errno.h>
#include <assert.h>

#define MAX_EVENT 1024
#define BACKLOG 128
#define MAX_DATALEN 1024

typedef struct sockaddr SA;

int set_tcp_reuse(int sock)
{
    int opt = 1;
    return setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, (void *)&opt, sizeof(opt));
}

int set_tcp_nodelay(int sock)
{
    int opt = 1;
    return setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (void *)&opt, sizeof(opt));
}

int set_non_block(int fd)
{
    int flags;
    flags = fcntl(fd, F_GETFL, NULL);
    if (flags == -1) {
        perror("fcntl F_GETFL error");
        return -1;
    }
    flags |= O_NONBLOCK;
    if (fcntl(fd, F_SETFL, flags) == -1) {
        perror("fcntl F_SETFL error");
        return -1;
    }
    return 0;
}

int ignore_sigpipe()
{
    struct sigaction sa;
    memset(&sa, 0, sizeof(struct sigaction));
    sa.sa_handler = SIG_IGN;
    if (sigaction(SIGPIPE, &sa, NULL)) {
        perror("sigaction error");
        return -1;
    }
    return 0;
}

int create_tcp_server(const char *ip, uint16_t port, int backlog)
{
    int ret = -1;
    socklen_t len = 0;

    if (ignore_sigpipe()) {
        printf("setting ignore sigpipe failed\n");
        return -1;
    }

    int sock = socket(AF_INET, SOCK_STREAM, 0);
    if (sock < 0) {
        perror("create socket error");
        return -1;
    }
    struct sockaddr_in addr;
    addr.sin_port = htons(port);
    addr.sin_family = AF_INET;
    if (!ip) {
        addr.sin_addr.s_addr = htonl(INADDR_ANY);
    } else {
        ret = inet_pton(AF_INET, ip, (SA *)&addr.sin_addr);
        if (ret <= 0) {
            if (ret == 0) {
                fprintf(stderr, "not invalid ip: [%s]\n", ip);
            } else {
                perror("inet_pton error");
            }
            return -1;
        }
    }

    if (set_tcp_reuse(sock) == -1) {
        perror("setsockopt SO_REUSEADDR error");
        return -1;
    }

    if (set_tcp_nodelay(sock) == -1) {
        perror("setsockopt TCP_NODELAY error");
        return -1;
    }

    len = sizeof(SA);
    if (bind(sock, (SA *)&addr, len) == -1) {
        perror("bind error");
        return -1;
    }

    if (listen(sock, backlog) == -1) {
        perror("listen error");
        return -1;
    }

    return sock;
}

int set_read_event(int epfd, int ctl, int fd)
{
    struct epoll_event ev;
    ev.data.u64 = 0;
    ev.data.fd = fd;
    ev.events = EPOLLIN | EPOLLET;

    if (epoll_ctl(epfd, ctl, fd, &ev) == -1) {
        perror("epoll_ctl error");
        return -1;
    }
    return 0;
}

int handle_recv(int fd)
{
    int read = 0;
    int n = 0;
    int ret = 0;
    char buf[MAX_DATALEN + 1] = {0};
    int space = 0;
    size_t cap = MAX_DATALEN;
    while (1) {
        n = recv(fd, buf + read, cap - read, 0);
        if (n < 0) {
            if (errno == EWOULDBLOCK || errno == EAGAIN) {
                break;
            } else {
                ret = -1;
                break;
            }
        } else if (n == 0) {
            ret = -1;
            break;
        } else {
            space = cap - read;
            read += n;
            if (n == space) {
                if (cap - read == 0) {
                    break;
                }
                continue;
            } else {
                break;
            }
        }
    }

    buf[read] = '\0';

    if (ret < 0) {
        return -1;
    }

    // not a good way, send can fail.
    send(fd, buf, read, 0);
    return 0;
}

int main(int argc, char *argv[])
{
    const char *ip = NULL;
    uint16_t port = 32667;
    int srv = create_tcp_server(ip, port, BACKLOG);
    if (srv < 0) {
        fprintf(stderr, "create tcp server failed\n");
        return -1;
    }

    struct epoll_event ev, events[MAX_EVENT];
    int epfd = -1;
    if ((epfd = epoll_create(1)) == -1) {
        perror("epoll_create error");
        return -1;
    }
    memset(&ev, 0, sizeof(ev));
    ev.events = EPOLLIN;
    ev.data.fd = srv;
    if (epoll_ctl(epfd, EPOLL_CTL_ADD, srv, &ev) == -1) {
        perror("epoll_ctl add srv error");
        close(srv);
        return -1;
    }
    int i, ret, cli, fd;
    struct sockaddr_in addr;
    socklen_t addrlen = sizeof(SA);
    while (1) {
        ret = epoll_wait(epfd, events, MAX_EVENT, -1);
        if (ret > 0) {
            for (i = 0; i < ret; i++) {
                fd = events[i].data.fd;
                if (events[i].events & EPOLLIN) {
                    if (fd == srv) {
                        cli = accept(srv, (SA *)&addr, &addrlen);
                        if (cli == -1) {
                            perror("accept error");
                        } else {
                            if (set_non_block(cli) == -1) {
                                fprintf(stderr, "set cli non block failed\n");
                                close(cli);
                                continue;
                            }
                            if (set_read_event(epfd, EPOLL_CTL_ADD, cli) != 0) {
                                close(cli);
                                continue;
                            }
                        }
                    } else {
                        if (handle_recv(fd) == -1) {
                            if (epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL)) {
                                perror("epoll_ctl EPOLL_CTL_DEL cli error");
                            }
                            close(fd);
                            continue;
                        }
                    }
                }
            }
        } else if (ret == 0) {
            continue;
        } else {
            perror("epoll_wait error");
            continue;
        }
    }
    return 0;
}

下面是对代码的一些解释。

TCP Server 的三个“必要”的设置

set_tcp_reuse 函数设置了 TCP 的 SO_REUSEADDR,以免进程重启时一直无法重新监听端口。

set_tcp_nodelay 函数设置了 TCP_NODELAY 属性,关闭 TCP 的 Nagle 算法,是为了让信息尽快地真正发送出去。如果开启 Nagle 算法,有可能会多等一会才把数据真正发出去。这里的代码需要 #include &lt;netinet/tcp.h&gt; 头文件。

ignore_sigpipe 函数用来忽略 SIGPIPE 信号,对于 SIGPIPE 信号,默认的处理是退出当前进程。当客户端关闭了连接,服务端仍然在调用 send/write 发送数据,就会产生 SIGPIPE 信号,这显然是不能接受的。

epoll 的 ET 模式

使用 epoll 的默认 LT 模式,基本上不用担心 recv/read 读不全数据,内核缓冲区里只要有数据,就会得到 EPOLLIN 事件,通知进程去读数据,不需要循环调用 recv 保证读到所有数据。

使用 ET 模式的工作方式 (EPOLLET) 跟 LT 有一点区别,当 socket 可读时,只会通知一次,如果收到通知之后没有读完数据,不会再通知。为了保证数据读完,需要循环读一直到某些条件再跳出循环。

如何保证数据全部读完

下面一段代码是个例子:

// fd 为非阻塞套接字
while (1) {
    n = read(fd, buf, bufsiz, 0);
    if (n < 0) {
        if (errno == EAGAIN || errno == EWOULDBLOCK) {
            break;
        } else {
            // .....
        }
    } else if (n == 0) {
        break;    
    } else {
        if (n < bufsiz) {
            break;
        } else {
            continue;
        }
    }
}

从代码中可以看到跳出循环的条件:

  1. recv 返回错误是 EAGAINEWOULDBLOCK 时,说明已经没有数据可读。当设置套接字为非阻塞模式,或者设置了超时时间,才会有这样的错误。
  2. recv 返回了 0,说明对方已经关闭连接,服务端自然也不再继续读它,而是要关闭对应的套接字。
  3. recv 返回的值小于预定的 buffer 的大小(n &lt; bufsiz),说明内核缓冲区里还剩下 n 个字节数据,都已经读完了,不需要继续读了。

如果 n == bufsiz 说明还可能有数据没读完,就要进入下一个循环。也有可能恰好这次读了 bufsiz 字节的数据,而且再没有数据了,这样下一个循环调用 recv 时就会遇到上述的第一个条件: EAGAINEWOULDBLOCK 错误。

设想一下,如果是用的默认的阻塞套接字,也没有设置超时时间,这次 recv 是得不到 EAGAINEWOULDBLOCK 错误的,程序被一直阻塞在这里,所以 EPOLLET 要和非阻塞套接字配合使用

没读完数据会怎样

使用 ET 模式,一次 EPOLLIN 时间之后,即使没读完数据,也不再通知程序去读数据。一直到下一次网卡又接收到了数据,程序又收到通知,然后调用 recv,这里先读到的是上一次没读完的在内核缓冲区里地数据,或许会产生一些预料之外的问题。

具体的一个试验

场景(S 表示服务端,C 表示客户端)

  • 监听 EPOLLIN|EPOLLET 事件(使用 ET 模式)
  • S 端 每次 recv 接收 5 字节的数据

过程:

  1. C 发送 10 字节数据 “helloworld” 到 S,S 网卡收到这 10 字节数据,EPOLLIN 触发,S recv 5 字节数据”hello”,仍然有 5 字节数据在内核缓冲区内,不再通知
  2. C 又发送 5 字节数据 “12345” 到 S,S 网卡收到这 5 字节数据,EPOLLIN 触发,S 继续 recv 5 字节,收到的是 “world”,上一次发送没从内核缓冲区取完的数据。
  3. C 又发送 3 字节数据 “xyz” 到 S,S 网卡收到这 3 字节数据,EPOLLIN 触发,S 继续 recv 5 字节,收到 “12345”
  4. C 又发生 1 字节数据 “a” 到 S,S 网卡收到这 1 字节数据,EPOLLIN 触发,S recv 之后,recv 返回值为 4,收到的是 “xyza”
  5. C 又发送 10 字节数据 “helloworld” 到 S,S 收到 “hello”
  6. C 关闭连接退出,再次触发 EPOLLIN 事件,S 调用 recv,收到 “world”。

如果是一个 echo 程序,那么客户端每次发送了消息之后,“反射”回来的内容都不是预期的了,并且在上面这个试验中,最后一步,S 错过了关闭套接字的好机会。如果程序有更复杂的业务逻辑已经把人搞得焦头烂额,再出现这个例子的情况,或许会使人痛苦不堪。

另外两点

继续以上面的试验为例,如果因为某些原因,S 一直没去读 C 发送的数据, C 仍然按照上面的方式发送了几次数据,总共 23 个字节的数据,都被放在 S 的内核缓冲区内,这样就只会被触发一次,S 只会收到最初的 5 个字节。

还有一点要提一下。当触发一次 EPOLLIN 之后,在处理过程中,而且这次处理有没有读完的数据,又调用了 epoll_ctl,(重复)设置监听 EPOLLIN 事件,那么 EPOLLIN 是会被再次触发的。

比如在最开始的代码中这样修改

int handle_recv(int epfd, int fd)
{
    ....
    ....
    set_read_event(epfd, fd);
    return send(fd, buf, read, 0);
}

处理完这一次 EPOLLIN 事件,又重新设置去监听 EPOLLIN 事件,内核缓冲区里有数据,那就会再来一次 EPOLLIN(好像 LT 模式),这是个奇怪的用法。这里只是为了描述重复设置 EPOLLIN 事件会出现的情况。

HTTP 和 TCP 的 KEEP ALIVE

先把结论放这:TCP 的 keepalive 和 HTTP 请求和响应的包头里的 keepalive 不是一回事。

TCP 的 keepalive 是用来检查 TCP 连接的对方是否还“活着”,Linux 有三个参数跟 keepalive 有关。

  • tcp_keepalive_time 一个连接闲了一定时间,就发 keepalive 的消息,这个时间长度是由 tcp_keepalive_time 来指定。
  • tcp_keepalive_probes 指定发送多少个 keepalive 探测包。如果对方回了 keepalive 探测包,说明对方还在,就继续保持这个连接。
  • tcp_keepalive_intvl 指定发送 keepalive 探测报的间隔时间。跟 tcp_keepalive_probes 相乘(发送多个 keepalive 探测包,对方没有响应),得到的就是从开始 keepalive 检查到放弃这个连接的时间。

HTTP 的 keepalive 是指客户端和 HTTP 服务端建立起 TCP 连接后,在这一个 TCP 连接上进行多次 HTTP 请求和响应,而不是建立 TCP 连接,请求,应答 HTTP,然后就断开 TCP 连接。建立、断开 TCP 的次数大大减少了,TCP 的连接数也减少了。

对 Linux TCP 的若干疑点和误会

整理了一下 Linux 的 TCP 相关的几个疑点和对以往错误认识的纠正。主要是系统出现大量 TIME_WAIT 和 SYN 请求的一些问题,以及一些 TCP 内核参数的意义。

提到的参数(选项)大都在 /proc/sys/net/ipv4/,如果需要永久生效,希望做到重启也不变,可以修改 /etc/sysctl.confnet.ipv4.tcp_*

1. 关于 TIME_WAIT

按照网络上很多文章的说法,当 Linux 服务器上出现大量 TIME_WAIT 状态时,可以通过对 TCP 相关的几个内核参数的修改,来减少 TIME_WAIT 状态。最常看到的是这两个

  • tcp_tw_reuse
  • tcp_tw_recycle

tcp_tw_reuse 设置为 1 时,就允许系统重用处于 TIME_WAIT 状态的 socket。如果 tcp_timestamp 没有设置为 1,只把 tcp_tw_reuse 设置为 1 是无效的。

tcp_tw_recycle 设置为 1 会开启系统对 TIME_WAIT 状态的 socket 的快速回收。开启这个功能,系统就会存下 TCP 连接的时间戳,当同一个 IP 地址过来的包的时间戳小于缓存的时间戳,系统就直接丢包,“回收”这个 socket。这个选项同样需要开启 tcp_timestamp 才生效。

开启这个功能是有很大风险的,如前面所说,会根据同一个 IP 来的包得时间戳来判断是否丢包,而时间戳是根据发包的客户端的系统时间得来的,如果服务端收到的包是同一出口 IP 而系统时间不一样的两个客户端的包,就有可能会丢包,可能出现的情况就是一个局域网内有的客户端能连接服务端,有的不能。

相对 tcp_tw_recycletcp_tw_reuse 是比较安全的一个选项,但是也可能会导致问题。按照官方文档的说法

It should not be changed without advice/request of technical experts.

还有一个与 TIME_WAIT密切相关的参数,tcp_max_tw_buckets 指定系统在同一时间最多能有多少 TIME_WAIT 状态,当超过这个值时,系统会直接干掉这个 TIME_WAIT 的 socket。不要为了减少 TIME_WAIT 就把这个值改小。

2. SYN 和 listen 函数的 backlog

先从 backlog 说起,根据 Linux 的文档,listen 函数的 backlog 等待应用程序 accept 的连接队列的长度,这个队列里是 ESTABLISHED 状态的连接,而不是还未完成三次握手的连接。如果要设置未完成连接的队列长度,可以设置 tcp_max_syn_backlog 这个参数。

未完成连接,也就是系统可能收到对端的 SYN,已经发送了 SYN/ACK,在等待对端的 ACK。DDOS 攻击中的 SYN 攻击,就是发送大量的 SYN,但是不发送 ACK,服务器大量的资源在维持这些未完成的连接,等待 ACK,这时 netstat 可以看到大量的 SYN_RECV 状态。

不少文章里提到为了应对这种现象,为了能让服务端尽量提供服务,就增大 tcp_max_syn_backlog 的值,以使服务器能够接受更多的连接请求,这样做是合情合理的。但是也有不少文章里建议开启 tcp_syncookies 来减少 SYN 攻击的影响。tcp_syncookies 可以说是一个无奈的参数,它使严重违反 TCP 协议的。开启这个选项,系统会根据缓存的未完成的连接的信息算一个值,也就是 cookie,返回给客户端,如果客户端是正常的连接请求,再往服务端发包时,会把这个 cookie 带回来,服务端就会查看这个 cookie,类似加了一个验证的过程。

如果服务器收到大量的正常请求,导致服务器负载非常高,不要为了应对这种情况开启 tcp_syncookies。文档里用了大写的 “MUST NOT” 来提醒我们,然后很良心地建议去修改 tcp_max_syn_backlog, tcp_synack_retries, tcp_abort_on_overflow

tcp_max_syn_backlog 已经提到过,设置更高的值,允许系统保持更多的“未完成连接”。按照在 listen 函数的文档所说,当开启了 tcp_syncookies 之后,tcp_max_syn_backlog 的值就失去了原本意义,该值被忽略

系统发回给客户端 SYN/ACK 后,如果没收到客户端的 ACK,会重发 SYN/ACK,tcp_synack_retries 用于指定重发 SYN/ACK 的次数,默认是 5 次,可以适当减小这个值。

tcp_abort_on_overflow 是个跟应用程序更相关的参数,当被设置为 1 时,如果应用程序处理速度比较慢,来不及接受新的连接,系统就直接丢弃这个连接,给对端发个 RST。这个选项也要谨慎开启。

有时候会看到 somaxconn (/proc/sys/net/core/somaxconn) 这个值跟 tcp_max_syn_backlog 一起出现,其实 somaxconnlistenbacklog 参数的意义是基本一样的,指定用于保存已完成三次握手等待 accept 的队列长度,somaxconnbacklog 可以设置的最大值。当在应用程序里设置 backlog 的值大于 somaxconn 参数时,系统也会默默地把 backlog 减小为 somaxconn 指定的值。