深入分析网络编程中踩过的坑

时间:2022-04-25
本文章向大家介绍深入分析网络编程中踩过的坑,主要内容包括问题1:server端业务进程响应心跳超时被监控进程kill,导致数据或者逻辑异常、问题2:在接入层调用后端多个逻辑服务时,某一个后端的服务异常,导致接入层不能处理新的请求、问题3:使用spp实现简单的web服务器,压测时短连接功能正常,但是采用keeplive模式,大约有40ms的时延、基本概念、基础应用、原理机制和需要注意的事项等,并结合实例形式分析了其使用技巧,希望通过本文能帮助到大家理解应用这部分内容。

网络编程中经常会遇到一些异常的情况,定位问题需要了解协议栈的实现,以下是工作中遇到的一些常见问题的深入分析和解决思路。

问题1:server端业务进程响应心跳超时被监控进程kill,导致数据或者逻辑异常

我们的后台框架采用的是proxy,worker模型,proxy处理连接和会话,worker处理业务,proxy和worker之间通过共享内存队列进行通信,并有监控进程扫描proxy和worker的运行情况。管理进程会定时向worker发起心跳查询,防止业务进程挂起。业务worker的心跳默认是60s,如果任务处理超过60s没有回复心跳,该进程会被认为异常,被监控进程kill后重启。review代码,没有发现sleep或者耗时操作,初步判定是网络连接异常导致的超时,检查了客户端连接代码,果然是没有使用带超时的连接,导致超时被监控进程kill

分析到这里,应该是找到原因了,但是疑问来了:默认连接超时是多久,该怎样设置连接超时,如果是非阻塞的socket该怎么做?

先看下三次握手的过程:

connect函数的接口并没有设置超时时间,那么默认的超时机制是什么情况

int connect(int sockfd, const struct sockaddr *addr,socklen_t addrlen);

侯捷说源码面前,了无秘密,还是看协议栈源码。

先从系统调用开始,inet_stream_connect是connect调用的socket层实现 代码在内核文件 net/ipv4/af_inet.c

int __inet_stream_connect(struct socket *sock, struct sockaddr *uaddr,
                          int addr_len, int flags)
{
        struct sock *sk = sock->sk;
        int err;
        long timeo;
        ...
        timeo = sock_sndtimeo(sk, flags & O_NONBLOCK);
        ...
}

从代码可以看到,connect只是完成发送syn的过程,后续的两次握手由协议栈完成。如果是非阻塞方式,返回的错误码是EINPROGRESS,超时时间在连接前设置,设置连接超时和设置发送超时是一样的 。

timeo = sock_sndtimeo(sk, flags & O_NONBLOCK);

设置超时的实现

static inline long sock_sndtimeo(const struct sock *sk, bool noblock)
{
    return noblock ? 0 : sk->sk_sndtimeo;
}

如果没有设置发送超时,那么默认的机制是什么?继续看代码,传输层的具体实现是调用tcp_v4_connect(),中间过程忽略,最后调用构造syn并发送的接口是tcp_connect()

代码在文件 net/ipv4/tcp_output.c

从代码可以看到发送后会启用重传定时器,直到应答或者超时,每次重传的超时时间采用指数退避的方式。具体实现是这两个函数

tcp_write_timeout()
retransmits_timed_out()

如果发送syn超时没有响应,重传次数sysctl_tcp_sys_retries,这个值是在tcp的系统参数设置,使用 sysctl 查看,默认设置为5

sysctl net.ipv4.tcp_syn_retries
net.ipv4.tcp_syn_retries = 5

至此,tcp的connect的机制已经很清楚了,如果设置了超时,当syn_retries重传syn次数的累计时间大于超时,那么在超时后返回,否则在syn_retries重传累计时间后返回。

为了验证以上逻辑,使用telnet 和tcpdump进行验证 telnet 192.168.128.254 10086 Trying 192.168.128.254... telnet: Unable to connect to remote host: Connection timed out

耗时:63.128s

tcpdump跟踪如下:

00:37:25.986061 IP 192.168.128.131.46640 > 192.168.128.254.10086: Flags [S], seq 3312153031, win 14600
00:37:26.983700 IP 192.168.128.131.46640 > 192.168.128.254.10086: Flags [S], seq 3312153031, win 14600
00:37:28.987752 IP 192.168.128.131.46640 > 192.168.128.254.10086: Flags [S], seq 3312153031, win 14600
00:37:32.995936 IP 192.168.128.131.46640 > 192.168.128.254.10086: Flags [S], seq 3312153031, win 14600
00:37:41.012194 IP 192.168.128.131.46640 > 192.168.128.254.10086: Flags [S], seq 3312153031, win 14600
00:37:57.059795 IP 192.168.128.131.46640 > 192.168.128.254.10086: Flags [S], seq 3312153031, win 14600

如果目标ip不可达,在5次重试后返回,总耗时63.128s,从时间戳可以看到重传syn的时间是采用指数退避的方式,分别为 1,2,4,8,16,32

如果目标ip可达,只是没有对应监听端口,在一次重试后,对端机器直接发送了reset标志,连接结束,耗时只要1s多,tcpdump跟踪如下:

00:52:21.776637 IP 192.168.128.131.58497 > 192.168.128.1.10086: Flags [S], seq 2415778508, win 14600
00:52:22.775693 IP 192.168.128.131.58497 > 192.168.128.1.10086: Flags [S], seq 2415778508, win 14600
00:52:22.799371 IP 192.168.128.1.10086 > 192.168.128.131.58497: Flags [R.], seq 19086327, ack 2415778509, win 64240

telnet: Unable to connect to remote host: Connection refused

因此,要设置连接超时其实有两种方法,如果是非阻塞方式,按照Stevens的建议

1、设置socket为非阻塞

2、根据connect返回值检查连接是否建立

3、调用select

4、检查超时

5、检查socket状态是可读还是可写

如果是阻塞方式,根据之前的源码分析,只要在连接前设置socket的发送超时即可

int connect_with_timeout()
{
    ...
    struct timeval timeo = {1, 0};
    socklen_t len = sizeof(timeo);
    fd = socket(AF_INET, SOCK_STREAM, 0);
    setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeo, len);
    int ret=connect(fd, (struct sockaddr*)&addr, sizeof(addr))
    ...
}

问题2:在接入层调用后端多个逻辑服务时,某一个后端的服务异常,导致接入层不能处理新的请求

查看机器的log,发现某个后端服务异常,代码使用了短连接请求后端服务,并在失败时自动重试,创建socket时发生错误。查看机器的网络状态,发现有大量的TIME_WAIT状态。

统计机器的TIME_WAIT状态数量有几个命令,最简单的是 cat /proc/net/sockstat

sockets: used 2861 TCP: inuse 603 orphan 0 tw 19 alloc 795 mem 339 UDP: inuse 985 mem 557

如果要查看更详细的状态统计,可以使用netstat 或者ss 加 awk 来处理

netstat -ant |awk '{if(NR>1)++s[$NF]} END {for(k in s) print k,s[k]}'

ss -ant |awk '{if(NR>1)++s[$1]} END {for(k in s) print k,s[k]}'

推荐使用ss命令,当socket数量很大的时候,ss会快很多。

因此,原因很清楚了,是短连接在后端服务异常时大量产生的TIME_WAIT状态导致创建文件描述符失败,不能处理请求。

这种情况通常的处理建议是打开tcp_tw_recycle 或者tcp_tw_reuse 选项,那么是否有效,还会不会有什么坑?

TIME_WAIT是在连接断开时产生,先看下连接断开的过程:

上面就是常说的连接断开四次挥手的过程,TIME_WAIT出现在主动断开连接方,那它存在的意义是什么呢?

stevens在unix网络编程里边讲到有两点:

1、保证TCP连接关闭的可靠性。如果最终发送的ACK丢失,被动关闭的一端会重传最终的FIN包,如果执行主动关闭的一端没有维护这个连接的状态信息,会发送RST包响应,导致连接不正常关闭。

2、允许老的重复分组在网络中消逝。假设在一个连接关闭后,发起建立连接的一端(客户端)立即重用原来的端口、IP地址和服务端建立新的连接。老的连接上的分组可能在新的连接建立后到达服务端,TCP必须防止来自某个连接的老的重复分组在连接终止后再现,从而被误解为同一个连接的化身。要实现这种功能,TCP不能给处于TIME_WAIT状态的连接启动新的连接。

TIME_WAIT的时长通常定义成2*MSL,MSL表示报文在网络上存在的最长时间,如果超过这个时间,报文将被丢弃。linux下TIME_WAIT被定义在tcp.h中,时间是60s,除非重新编译内核,否则不能修改。

/* how long to wait to destroy TIME-WAIT state, about 60 seconds*/
#define TCP_TIMEWAIT_LEN (60*HZ) 

如果每秒有1000个请求,在60秒内产生的TIME_WAIT就有60000个,要控制或者减少TIME_WAIT的数量,协议栈提供了tcp_tw_recycle、tcp_tw_reuse、tcp_max_tw_buckets这几个选项,下面逐一分析。

tcp_tw_recycle

linux协议栈实现的时候提供了一种快速回收TIME_WAIT状态的机制,不用等待2MSL的时间,只要等待一个重传的时间即可回收,在idc内部,这个时间极短,可能不到1ms。但是新建立的连接可能存在风险:

1、如果之前的FIN延迟到达,新连接会被reset

2、如果之前发出的包延时后到达对端,会造成干扰

tcp协议栈设计的时候是如何处理这些风险呢,代码如下:

int tcp_conn_request(struct request_sock_ops *rsk_ops,
                     const struct tcp_request_sock_ops *af_ops,
                     struct sock *sk, struct sk_buff *skb)
{
    ...
    if (!want_cookie && !isn) {
        if (tcp_death_row.sysctl_tw_recycle) {
            bool strict;

            dst = af_ops->route_req(sk, &fl, req, &strict);

            if (dst && strict &&
                !tcp_peer_is_proven(req, dst, true,tmp_opt.saw_tstamp)) {
                    NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_PAWSPASSIVEREJECTED);
                    goto drop_and_release;
            }
        }
    }    
    ...
}

在tcp_tw_recycle模式下,判断是无效连接的条件是:

1、来自对端的tcp syn请求携带时间戳

2、本机在MSL时间内接收过来自同一台ip机器的tcp数据

3、新连接的时间戳小于上次tcp数据的时间戳

以上条件满足时,连接请求会被拒绝,使用netstat -s |grep timestamp 有如下记录

……packets rejects in established connections because of timestamp

因此,在启用了tcp_tw_recycle的情况下,TIME_WAIT时间内(60s),同一源ip主机syn请求中的timestamp必须是递增的,连接才能被接受。

这个看起来很完美,同一个主机的timestamp的一定是递增的,但是NAT环境就悲剧了,NAT下,多个主机映射到同一个或几个对外IP,NAT设备只修改源地址和端口,timestamp不做修改,不能保证来自NAT机器多个主机间连接请求的timestamp是递增的,时间戳小的请求都会被拒绝。

tcp_tw_reuse

TIME_WAIT的重用只满足一定的条件下,处于TIME_WAIT状态的socket连接可以被新请求的syn使用。条件如下:

1、新请求的sequence要大于TIME_WAIT连接的最后的sequence

2、如果启用了tcp的timestamp选项,syn请求的时间戳要大于TIME_WAIT连接最后接收数据的时间戳

这个选项没有太大的意义,满足这个条件的情形并不多,并不能减少TIME_WAIT的数量。

tcp_max_tw_buckets

这个选项其实没有什么可说的,就是设置系统允许的最大TIME_WAIT数量,如果超过这个量,就不再出现TIME_WAIT,直接close

struct inet_timewait_sock *inet_twsk_alloc(const struct sock *sk,
                                           struct inet_timewait_death_row *dr,
                                           const int state)
{
    struct inet_timewait_sock *tw;

    if (atomic_read(&dr->tw_count) >= dr->sysctl_max_tw_buckets)
            return NULL;
    ...
}

这种方式对TIME_WAIT数量控制简单粗暴,但是效果也比较明显。但是问题和tcp_tw_recycle类似,新连接也可能被对端重传的FIN reset。

总结:控制TIME_WAIT的选项都存在一些问题,最好慎用。最好的方式是维持正常的TIME_WAIT状态,通过连接池的方式复用连接,减少TIME_WAIT出现的数量。如果要使用tcp_tw_recycle,一定要确保没有NAT设备接入,如果是只有client场景的机器,可以使用tcp_tw_reuse或增大net.ipv4.ip_local_port_range范围来发起更多的连接。

问题3:使用spp实现简单的web服务器,压测时短连接功能正常,但是采用keeplive模式,大约有40ms的时延

抓包分析发现,server端连续发送了两个小于mss的包,第一个包发出后,经过大约40ms才确认,第二个包才发出。检查代码,回包时,先回了http的包头,再回复http的body,命中nagle算法和delayed ack的应用场景,后一个包延时发出,但是为什么短连接正常,长连接有问题?

wikipedia对nagle算法的描述:

https://en.wikipedia.org/wiki/Nagle%27s_algorithm

算法实现:

if there is new data to send
  if the window size >= MSS and available data is >= MSS
    send complete MSS segment now
  else
    if there is unconfirmed data still in the pipe
      enqueue data in the buffer until an acknowledge is received
    else
      send data immediately
    end if
  end if
end if

xshell或telnet这样的应用,每次键盘输入发送包含一个字符的包,却要耗费40字节的包头(tcp头加ip头),为了改进这种情况,Nagle算法的做法是先把第一个小包发送出去,后面的小包都缓存起来,直到收到前一个数据段的ack,或者缓存数据长度已经达到mss大小才发送,代码文件在 net/ipv4/tcp_output.c

static bool tcp_write_xmit(struct sock *sk, unsigned int mss_now, int nonagle,
                           int push_one, gfp_t gfp)
{
    ...
    if (tso_segs == 1) {
        if (unlikely(!tcp_nagle_test(tp, skb, mss_now,(tcp_skb_is_last(sk, skb) ?nonagle : TCP_NAGLE_PUSH))))
            break;
    } else {
        if (!push_one && tcp_tso_should_defer(sk, skb, &is_cwnd_limited,max_segs))
            break;
    }
    ...
}

具体判断是否启用nagle算法的逻辑:

/* Return false, if packet can be sent now without violation Nagle's rules:
 * 1. It is full sized. (provided by caller in %partial bool)
 * 2. Or it contains FIN. (already checked by caller)
 * 3. Or TCP_CORK is not set, and TCP_NODELAY is set.
 * 4. Or TCP_CORK is not set, and all sent packets are ACKed.
 *    With Minshall's modification: all sent small packets are ACKed.
 */
static bool tcp_nagle_check(bool partial, const struct tcp_sock *tp,
                            int nonagle)
{
    return partial &&
            ((nonagle & TCP_NAGLE_CORK) ||
             (!nonagle && tp->packets_out && tcp_minshall_check(tp)));
}

根据上面代码,nagle算法生效的条件是:

1、当前发送的包小于mss

2、启用TCP_NAGLE_CORK 并禁用TCP_NODELAY,或者启用TCP_NODELAY,有需要发送的数据以及还未ack的数据包

明确nagle算法的条件后,问题来了,为什么上一个包的ack是经过了40ms才返回,正常情况应该就是一个rtt的时间,同一idc的rtt时延小于1ms,怎么会有40ms延时呢?

其实这个涉及到了tcp协议的另外一个机制:延迟确认delayed ack

tcp发送ack有两种方式:quick ack 和 delayed ack quick ack:收到数据包后,立即发送ACK给对端。 delayed ack:收到数据包后,不会立即发送ACK,而是启动延时确认定时器,在此期间: 1. 本端有数据包要发送给对端。就在发送数据包的时候捎带上此ACK。 2. 本端没有数据包要发送,定时器超时后发送ACK给对端。

根据算法的描述可以看到:nagle算法和delayed ack都是为了减少小数据包在网路中传输的数量,优化网络性能。

delayed ack的具体实现在代码文件net/ipv4/tcp_input.c

检查是否需要发送ack:

static void __tcp_ack_snd_check(struct sock *sk, int ofo_possible)
{
    struct tcp_sock *tp = tcp_sk(sk);

        /* More than one full frame received... */
    if (((tp->rcv_nxt - tp->rcv_wup) > inet_csk(sk)->icsk_ack.rcv_mss &&
         /* ... and right edge of window advances far enough.
          * (tcp_recvmsg() will send ACK otherwise). Or...
          */
         __tcp_select_window(sk) >= tp->rcv_wnd) ||
        /* We ACK each frame or... */
        tcp_in_quickack_mode(sk) ||
        /* We have out of order data. */
        (ofo_possible && skb_peek(&tp->out_of_order_queue))) {
            /* Then ack it now */
            tcp_send_ack(sk);
    } else {
            /* Else, send delayed ack. */
            tcp_send_delayed_ack(sk);
    }
}

满足下列条件之一,需要立即发送ack,否则进入延迟确认模式:

1、收到大于mss的包且有能力接收数据

2、满足快速确认模式

3、有乱序的数据,需要对端重传

检查是否快速确认模式:

/* Send ACKs quickly, if "quick" count is not exhausted
 * and the session is not interactive.
 */
static inline int tcp_in_quickack_mode(const struct sock *sk)
{
         const struct inet_connection_sock *icsk = inet_csk(sk);
         return icsk->icsk_ack.quick && !icsk->icsk_ack.pingpong;
}

快速确认模式的初始化:

static void tcp_incr_quickack(struct sock *sk)
{
        struct inet_connection_sock *icsk = inet_csk(sk);
        unsigned int quickacks = tcp_sk(sk)->rcv_wnd / (2 * icsk->icsk_ack.rcv_mss);

        if (quickacks == 0)
                quickacks = 2;
        if (quickacks > icsk->icsk_ack.quick)
                icsk->icsk_ack.quick = min(quickacks, TCP_MAX_QUICKACKS);
}

static void tcp_enter_quickack_mode(struct sock *sk)
{
        struct inet_connection_sock *icsk = inet_csk(sk);
        tcp_incr_quickack(sk);
        icsk->icsk_ack.pingpong = 0;
        icsk->icsk_ack.ato = TCP_ATO_MIN;
}

socket有一个pingpong属性来表明当前会话是否交互模式,如果是,会使用延迟确认机制,这个值是动态计算的。

有数据要发送时,如果当前时间与最近接受数据包的时间间隔小于ato(40ms,min(rtt,200ms)),则进入pingpong模式。因此,一旦有数据交互后,很快就切换到pingpong模式。

综上,在长连接的模式下,会话很快进入pingpong模式,server端先回了一个http头的小包,client收到数据准备回复ack时进入延时确认机制,server端继续发送http body也是一个小包,nagle算法生效,需要等前一个包的ack到达或者发送的数据大于mss时,数据才会发送,等40ms后,前一个包的延时ack到达,http的body内容才发送出去。

至此,问题已经真相大白,但是有个疑问,为什么短连接的时候,server端也是连续写入了两个小包,为什么没有触发nagle算法和delayed ack 呢?玄机在这里:

/* There is something which you must keep in mind when you analyze the
 * behavior of the tp->ato delayed ack timeout interval.  When a
 * connection starts up, we want to ack as quickly as possible.  The
 * problem is that "good" TCP's do slow start at the beginning of data
 * transmission.  The means that until we send the first few ACK's the
 * sender will sit on his end and only queue most of his data, because
 * he can only send snd_cwnd unacked packets at any given time.  For
 * each ACK we send, he increments snd_cwnd and transmits more of his
 * queue.  -DaveM
 */
static void tcp_event_data_recv(struct sock *sk, struct sk_buff *skb)
{
    ...
}

tcp_event_data_recv函数的注释解释的很清楚:

连接刚启动的时候,拥塞算法使用的是慢启动,必须尽快发送ack,发送方才可能尽快增大发送窗口,发送更多的数据,所以在首次收包的时候,启用了快速确认模式,pingpong模式的值为0,而短连接只有一次业务数据的收发,后边连接就关闭了,nagle算法和delayed ack并没有生效。

针对问题描述的情形,解决方案是把http头和http body合并后发送,就不会有问题了,因为接收方收到完整数据后会重新发起新的请求,这时候会把上一个包的ack附带发回,发送方就不用等40ms的超时了。

如果发送方确实存在有多个小包要分别发送,并使用长连接的情况,最好是禁用nagle算法,其实这也是主流的做法,nginx在keeplive模式下就禁用了nagle算法。

注:以上图片来自TCP/IP Illustrated,代码来自linux内核