tcp 中使用的定时器

定时器的使用场景主要有两种。

(1)周期性任务

这是定时器最常用的一种场景,比如 tcp 中的 keepalive 定时器,起到 tcp 连接的两端保活的作用,周期性发送数据包,如果对端回复报文,说明对端还活着;如果对端不回复数据包,就会判定对端已经不存在了;再比如分布式系统中,各个组件之间的心跳报文也是定时发送来维护组件之间的状态。

(2)兜底功能

一些不立即执行的任务的时间底线。比如 tcp 中的延迟 ack 功能,说的就是在接收到一个报文的时候,并不会立即向对方回复 ack,而是会看看本端最近是不是会发送报文,如果是的话,那么 ack 就跟随这个报文一块发送, 这样可以减少链路上的报文数量,提高带宽利用率。如果本端很长时间内没有数据发向对端呢,当前这个线程不会一直在这里等待,而是使用一个定时器来完成后边的工作,也就是说最多可以等待多长时间,即等待的底线,如果超过这个底线之后还没有等到发送数据,那么这个定时器就会直接将 ack 发送出去。重传定时器,0 窗口探测定时器,也起到了兜底的作用。定时器通过异步的方式解放了线程,有了定时器就不需要线程在这里等待。

tcp 中使用的定时器有多个,本文主要有介绍以下 6 个。6 个定时器可以按照 tcp 连接的生命周期进行划分,划分结果如下表所示:

定时器分类

定时器

定时器成员

所在结构体

超时处理函数

建立连接过程

syn + ack 定时器

rsk_timer

struct request_sock

reqsk_timer_handler()

数据传输过程

重传定时器

icsk_retransmit_timer

struct inet_connection_sock

tcp_retransmit_timer()

延时 ack 定时器

icsk_delack_timer

struct inet_connection_sock

tcp_delack_timer()

保活定时器

sk_timer

struct sock

tcp_keepalive_timer()

窗口探测定时器

icsk_retransmit_timer

struct inet_connection_sock

tcp_probe_timer()

断开连接过程

TIME_WAIT 定时器

tw_timer

struct inet_timewait_sock

tw_timer_handler()

不同的定时器,维护的 socket 是不一样的。syn + ack 定时器在 struct request_sock 中维护,TIME_WAIT 定时器在 struct inet_timewait_sock 中维护,这两个定时器也是只在建立连接阶段或断开连接阶段存在,并且前者是服务端需要使用的定时器,后者是主动断开连接的一方需要使用的定时器,并不是连接的每一端都需要。数据传输过程中使用的定时器是连接的两端都要使用到的定时器。

1 连接建立过程定时器

1.1 syn 定时器

在介绍 syn + ack 定时器之前,先介绍一下 syn 定时器。顾名思义,syn 定时器就是重传 syn 包的定时器。之所以上边表格中没有单独列出来 syn 定时器,是因为 syn 定时器就是重传定时器。

syn 定时器即发起连接的一方(客户端),发送 syn 包之后,会启动一个定时器,这个定时器和后边讲的连接建立完成之后的重传定时器是同一个定时器。作用也是一样的,即发送 syn 包之后,如果在超时时间之内没有收到 syn + ack 报文,便会重传 syn 包。

发送 syn 包和启动定时器的工作在 tcp_connect() 函数中完成。这个定时器只有客户端才需要使用,所以不是在 socket 的初始化函数中创建的,而是在 tcp_connect() 函数中创建的。

syn 包最大重传次数可通过 /proc/sys/net/ipv4/tcp_syn_retries 配置,默认是 6。

int tcp_connect(struct sock *sk)
{
    struct sk_buff *buff;

    // 构造一个 syn 报文
    tcp_init_nondata_skb(buff, tp->write_seq++, TCPHDR_SYN);
    
    // 将报文放入重传队列中,重传队列使用红黑树来维护
    tcp_rbtree_insert(&sk->tcp_rtx_queue, buff);

    // 发送 syn 包
    err = tp->fastopen_req ?
              tcp_send_syn_data(sk, buff) :
              tcp_transmit_skb(sk, buff, 1, sk->sk_allocation);
    // 启动重传定时器
    inet_csk_reset_xmit_timer(sk, ICSK_TIME_RETRANS, inet_csk(sk)->icsk_rto,
                  TCP_RTO_MAX);
    return 0;
}

1.2 syn + ack 定时器

服务端收到 syn 之后,便会进行第二次握手,即发送 syn + ack 报文。syn + ack 定时器和 syn 定时器的作用类似,也是检测发送 syn + ack 报文之后,在一定时间内有没有收到第三次握手的 ack 报文,如果没有收到,该定时器超时之后便会重传 syn + ack。

syn + ack 定时器的创建调用栈是 :

tcp_conn_request()

调用

inet_csk_reqsk_queue_hash_add()

调用

reqsk_queue_hash_req()

当服务端收到 syn 报文时,说明有新的连接请求,该请求在函数 tcp_conn_request() 中处理,在该函数中的主要工作有三个:

① 申请一个 struct request_sock,然后将之加入到 ehash 中,便于第三次握手到来之后查找到这个套接字

② 向对端发送 syn + ack 报文,即第二次握手

③ 启动 syn + ack 定时器

syn + ack 报文,同样也有最大重传次数限制,可以通过配置 /proc/sys/net/ipv4/tcp_synack_retries 进行修改,默认是 5。

2 数据传输过程中的定时器

ESTABLISHED 状态下的定时器包括重传定时器,延迟 ack 定时器,窗口探测定时器(又叫坚持定时器)以及保活定时器。这四个定时器在函数 tcp_init_xmit_timers() 创建,该函数被 tcp_init_sock() 调用,也就是说不管是客户端还是服务端都会创建这四个定时器。

void tcp_init_xmit_timers(struct sock *sk)
{
    // 创建三个定时器,分别是重传定时器,延时 ack 定时器,保活定时器
    // 三个定时器的超时处理函数即后三个入参
	inet_csk_init_xmit_timers(sk, &tcp_write_timer, &tcp_delack_timer,
				  &tcp_keepalive_timer);
    ...
}

2.1 重传定时器

重传定时器,简单来说就是发送侧发送一个报文之后,就启动一个定时器等接收方的 ack,如果超时没有等到 ack,那么发送方就会认为发生了丢包,然后会重新发送这个报文;反之,如果在超时时间内收到了对端回应的 ack, 说明接收侧已经收到了这个报文,发送侧就可以放心地把这个报文从重传队列中取出,然后释放报文占用的资源了。

重传定时器示意图如下,发送方发送报文序列号 1000,长度为 200,发送之后便会启动重传定时器。正常情况下,在定时器超时之前,接收方会返回 ack,如果定时器超时的时候没有收到 ack,发送方便会认为这个报文丢失,从而会重传这个报文。

(1)什么时候启动重传定时器 ?

发包路径

// 函数 tcp_write_xmit() 中会调用 tcp_transmit_skb() 进行发包
// 如果 tcp_transmit_skb() 返回成功,则调用函数 tcp_event_new_data_sent()
// 在函数 tcp_event_new_data_sent() 中将报文放入重传队列中,同时启动重传定时器
static void tcp_event_new_data_sent(struct sock *sk, struct sk_buff *skb)
{ 
    // packets_out 表示发送出去,但是还没有收到 ack 的报文
    // 在该函数的后边会更新这个变量,把刚发送的报文加上去
    // 当收到 ack 报文的时候会对这个变量做减法
    unsigned int prior_packets = tp->packets_out;

    // 更新 snd_nxt
	WRITE_ONCE(tp->snd_nxt, TCP_SKB_CB(skb)->end_seq);
       
    // 将 skb 从发送队列中移除,然后将 skb 放入重传队列
    // 报文发向 ip 层成功之后并不能立即释放 skb, 因为报文在链路上可能会丢失 
    // 所以先将报文移入重传队列,如果这个报文在链路上丢了的话还可重传
    // 只有收到这个报文的 ack 时,说明接收侧已经收到了这个报文
    // 这个时候才可以将报文从重传队列中移除,释放 skb 资源
	__skb_unlink(skb, &sk->sk_write_queue);
	tcp_rbtree_insert(&sk->tcp_rtx_queue, skb);

    // 更新 packets_out
	tp->packets_out += tcp_skb_pcount(skb);

    // prior_packets 即不包括这次发送的报文,之前发送出去但是还没有确认的报文
    // 如果都已经确认了,说明重传定时器这个时候没有工作,需要启动重传定时器
    // 如果还有没被确认的,说明上次发包的时候就已经启动了重传定时器,并且没有超时
    // 这种情况下就不需要再次启动重传定时器了
    // 具体启动重传定时器的工作在 tcp_rearm_rto() 中完成
	if (!prior_packets || icsk->icsk_pending == ICSK_TIME_LOSS_PROBE)
		tcp_rearm_rto(sk);
}

// 函数 tcp_rearm_rto() 中首先计算 rto,即重传定时器的超时时间
// 由此可见重传定时器的超时时间不是固定不变的,而是和链路状态有关系
// 计算 rto 之后便会通过函数 tcp_reset_xmit_timer() 启动重传定时器
void tcp_rearm_rto(struct sock *sk)
{
    // 如果 packets_out 是 0,说明发送出去的报文已经全部确认,则可以停掉重传定时器
    if (!tp->packets_out) {
        inet_csk_clear_xmit_timer(sk, ICSK_TIME_RETRANS);
    } else {
        u32 rto = inet_csk(sk)->icsk_rto;
        /* Offset the time elapsed after installing regular RTO */
        if (icsk->icsk_pending == ICSK_TIME_REO_TIMEOUT ||
            icsk->icsk_pending == ICSK_TIME_LOSS_PROBE) {
            s64 delta_us = tcp_rto_delta_us(sk);
            /* delta_us may not be positive if the socket is locked
             * when the retrans timer fires and is rescheduled.
             */
            rto = usecs_to_jiffies(max_t(int, delta_us, 1)); 
        }
        tcp_reset_xmit_timer(sk, ICSK_TIME_RETRANS, rto,
                     TCP_RTO_MAX);
    }
}

(2) 收到 ack 报文的时候如何改变重传定时器

收到 ack 报文之后,如果发现发送的报文都已经被确认,那么就会停掉重传定时器;否则,则会重启重传定时器。

// tcp_ack() 函数处理接收到的 ack 报文
// tcp_ack() 函数中调用 tcp_clean_rtx_queue() 来将已经 ack 的报文从重传队列中移除,
// 同时对 tp->packets_out 做减法
// tcp_clean_rtx_queue() 中会判断是不是有新的报文被确认,
// 如果是,则返回的 flag 中包含 FLAG_SET_XMIT_TIMER 标志
// 在 tcp_ack() 中就会重置重传定时器
static int tcp_ack(struct sock *sk, const struct sk_buff *skb, int flag)
{
	// 如果有新的数据被确认,则返回的 flag 中带有标志 FLAG_SET_XMIT_TIMER
	flag |= tcp_clean_rtx_queue(sk, skb, prior_fack, prior_snd_una,
				    &sack_state, flag & FLAG_ECE);
        
    // FLAG_SET_XMIT_TIMER 这个标志说明有数据被确认,
    // 这种情况下就需要重新设置重传定时器
    // tcp_set_xmit_timer() 最终会调用到 tcp_rearm_rto()
    // 在 tcp_rearm_rto() 中判断,
    // 如果发送出去的报文都已经确认,则停止重传定时器,否则 reset 重传定时器
	if (flag & FLAG_SET_XMIT_TIMER)
		tcp_set_xmit_timer(sk);
}

(3) 重传定时器回调函数中如何重传 ?

重传定时器超时,最终会调用函数 tcp_retransmit_timer() 进行重传。在该函数中主要做的工作有三个:

① 从重传队列中取出第一个报文,进行重传。

② 重传之前要判断,重传次数是不是已经达到最大值,如果达到最大值,则放弃重传,设置套接字为错误状态。重传次数并不是无限的,而是有最大值限制。放弃重传的判断条件有两个,分别是时间维度和数量维度,函数 tcp_write_timeout() 中进行具体判断。

③ 发生重传说明存在丢包,这种情况下进入 loss 状态。

void tcp_retransmit_timer(struct sock *sk)
{
	struct tcp_sock *tp = tcp_sk(sk);
	struct net *net = sock_net(sk);
	struct inet_connection_sock *icsk = inet_csk(sk);
	struct request_sock *req;
	struct sk_buff *skb;

	// tp->packets_out 为 0,说明发送的报文都已经 ack 了
    // 没有报文需要重传,直接 return
	if (!tp->packets_out)
		return;
        
    // 从重传队列中取出第一个报文
	skb = tcp_rtx_queue_head(sk);
	if (WARN_ON_ONCE(!skb))
		return;
        
    // 判断重传是否超时,如果超时,则将套接字设置为错误状态,然后退出
    // 将套接字设置为错误状态通过函数 tcp_write_err() 完成
    // 重传采用退避策略,重传定时器超时时间倍数增长
    // 最小重传时间是 0.5s,最大是 120s,由下边两个宏来定义
    // #define TCP_RTO_MAX    ((unsigned)(120*HZ))
    // #define TCP_RTO_MIN    ((unsigned)(HZ/5))
	if (tcp_write_timeout(sk))
		goto out;

    // 进入 loss 状态
	tcp_enter_loss(sk);
        
    // 重传报文
	icsk->icsk_retransmits++;
	if (tcp_retransmit_skb(sk, tcp_rtx_queue_head(sk), 1) > 0) {
		/* Retransmission failed because of local congestion,
		 * Let senders fight for local resources conservatively.
		 */
		inet_csk_reset_xmit_timer(sk, ICSK_TIME_RETRANS,
					  TCP_RESOURCE_PROBE_INTERVAL,
					  TCP_RTO_MAX);
		goto out;
	}

out_reset_timer:
    // 计算下次重传超时时间并重置重传定时器
	if (sk->sk_state == TCP_ESTABLISHED &&
	    (tp->thin_lto || net->ipv4.sysctl_tcp_thin_linear_timeouts) &&
	    tcp_stream_is_thin(tp) &&
	    icsk->icsk_retransmits <= TCP_THIN_LINEAR_RETRIES) {
		icsk->icsk_backoff = 0;
		icsk->icsk_rto = min(__tcp_set_rto(tp), TCP_RTO_MAX);
	} else {
		/* Use normal (exponential) backoff */
		icsk->icsk_rto = min(icsk->icsk_rto << 1, TCP_RTO_MAX);
	}
	inet_csk_reset_xmit_timer(sk, ICSK_TIME_RETRANS,
				  tcp_clamp_rto_to_user_timeout(sk),
				  TCP_RTO_MAX);
out:;
}

2.2 延时 ack 定时器

当接收到数据之后,并不一定是立即发送 ack。而是等待一段时间,如果在这段时间之内,有发往对方的数据,则 ack 随着该数据一块发送;如果在超时时间之内,没有数据发往对方,则在定时器回调函数中单独发送 ack。

延时 ack 也叫捎带 ack,相比于收到一个报文之后就立即发送 ack,延时 ack 可以减少链路上纯 ack 报文的比例,提高网络带宽利用率。

接收侧收到数据之后会调用函数 __tcp_ack_snd_check(),在这个函数中判断是不是需要立即发送 ack,如果需要立即发送 ack,则立即发送;否则的话,如果满足发送延时 ack 的条件,则调用函数 tcp_send_delayed_ack() 进行发送延时 ack 的逻辑。延时 ack 定时器的最小超时时间是 40ms, 最大超时时间是 200ms,分别用宏 TCP_DELACK_MIN 和 TCP_DELACK_MAX 来定义。

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

    // 收到的报文大于 mss
    // 或者设置了 quick ack
    // 或者设置了 ICSK_ACK_NOW
    // 直接发送 ack
    if (((tp->rcv_nxt - tp->rcv_wup) > inet_csk(sk)->icsk_ack.rcv_mss &&
         (tp->rcv_nxt - tp->copied_seq < sk->sk_rcvlowat ||
          __tcp_select_window(sk) >= tp->rcv_wnd)) ||

        tcp_in_quickack_mode(sk) ||
        /* Protocol state mandates a one-time immediate ACK */
        inet_csk(sk)->icsk_ack.pending & ICSK_ACK_NOW) {
    send_now:
        tcp_send_ack(sk);
        return;
    }
    
    // 延时 ack 的逻辑在 tcp_send_delayed_ack() 中进行处理
    if (!ofo_possible || RB_EMPTY_ROOT(&tp->out_of_order_queue)) {
        tcp_send_delayed_ack(sk);
        return;
    }
    
    ...
}

如果想要接收到报文之后就立即发送 ack,那么需要设置 socket 选项 TCP_QUICKACK。socket 选项中,有一些设置之后就会一直生效,比如 SO_RCVTIMEO 选项,可以设置接收数据的超时时间,如果阻塞这么长时间,数据还没有到来,那么 recv() 就会返回。还有一些选项,设置之后并不是一直生效,比如 TCP_QUICKACK,设置之后,就会立即回应 ack,但是这个选项并不一定一直生效,还会受到 tcp 协议栈内部判断的影响,所以需要每次收到数据之后都重新设置一次这个选项。

 2.3 窗口探测定时器

在建立 tcp 连接时,两端会向对方通告自己的接收窗口大小。接收窗口用于流量控制,tcp 发送数据时不能超过对端接收窗口的大小。

如果出现发送方的发送速度大于接收方的接收速度,或者接收侧应用长时间没有从接收缓冲区接收数据的时候,接收窗口会变成 0,并将 0 窗口通知给发送方,发送方便会停止发送数据。

当接收方的窗口从 0 变为非 0 时,便会向对端发送 ack 报文,通告窗口的大小。对端收到该报文后,知道接收窗口不是 0 了,便会开始发送数据。

当通知报文在链路上丢失了, 会进行重传吗 ?不会重传。如果该报文丢失了,那么连接的两端就会死锁(发送方仍然认为接收窗口是 0,停止发送数据;接收方认为自己的通知报文已经发送出去了,已经通知了对方,自己责任已经完成),数据传输不会开启。

窗口探测定时器的作用就是应对死锁情况的补偿措施。发送方会定期发送探测报文,接收方收到探测报文之后便会回复 ack 报文,该 ack 报文同时也包含窗口信息。窗口探测定时器直到收到窗口非 0 的 ack 之后才会停止。这样就保证了即使两端发送死锁,定时器也能探测到窗口非 0 的情况,起到了兜底的作用。

窗口字段在 tcp 首部,接收侧收到报文之后,便会基于该字段更新本端发送窗口。

当 tcp 接收到 ack 报文之后,会通过函数 tcp_ack_update_window() 更新发送窗口,snd_wnd 是发送窗口,发送报文的时候会进行检查,发送的数据不会大于发送窗口。 

static int tcp_ack_update_window(struct sock *sk, const struct sk_buff *skb,
                 u32 ack, u32 ack_seq)
{
    struct tcp_sock *tp = tcp_sk(sk);
    int flag = 0;
    u32 nwin = ntohs(tcp_hdr(skb)->window);
    
    // 窗口扩展因子
    if (likely(!tcp_hdr(skb)->syn))
        nwin <<= tp->rx_opt.snd_wscale;

    if (tcp_may_update_window(tp, ack, ack_seq, nwin)) {
        flag |= FLAG_WIN_UPDATE;
        tcp_update_wl(tp, ack_seq);
        
        // 更新发送窗口
        if (tp->snd_wnd != nwin) {
            tp->snd_wnd = nwin;
        }
    }

    return flag;
}

 2.3.1 定时器什么时候启动

窗口探测定时器在发送路径上启动。

void __tcp_push_pending_frames(struct sock *sk, unsigned int cur_mss,
                   int nonagle)
{
    // tcp_write_xmit() 返回 true, 说明这次调用没有发送任何报文
    // 则调用 tcp_check_probe_timer() 进行判断,需不需要开启窗口探测定时器
    if (tcp_write_xmit(sk, cur_mss, nonagle, 0,
               sk_gfp_mask(sk, GFP_ATOMIC)))
        tcp_check_probe_timer(sk);
}

// 判断两个条件,如果这两个条件均满足,则开启窗口探测定时器
// 条件一:所有发送的数据都 ack 了
// 只有这个条件满足,才会开启定时器,因为如果现在还有发送的数据没有被 ack,
// 那么不需要定时器来探测,因为 ack 很快就会来了,ack 中带有窗口信息
//
// 条件二:窗口探测定时器没有启动。
static inline void tcp_check_probe_timer(struct sock *sk)
{
    if (!tcp_sk(sk)->packets_out && !inet_csk(sk)->icsk_pending)
        tcp_reset_xmit_timer(sk, ICSK_TIME_PROBE0,
                     tcp_probe0_base(sk), TCP_RTO_MAX);
}

2.3.2 定时器回调函数做什么工作 ?

窗口探测定时器超时之后调用函数 tcp_probe_timer(),在该函数中发送一个特殊的报文,对端收到该报文后便会回一个 ack,通过 ack 便可知道对端的窗口是不是已经变成非 0。

那么窗口探测报文有什么特殊之处呢 ?

特殊之处在序号,序号是已经 ack 的报文。假如本端收到的最后一个 ack 是 1000, 下一个要发送的字节序号是 1000,而窗口探测报文发送的序列号是 999。

tcp_probe_timer() 发送 0 窗口探测报文:

static void tcp_probe_timer(struct sock *sk)
{
    struct inet_connection_sock *icsk = inet_csk(sk);
    struct sk_buff *skb = tcp_send_head(sk);
    struct tcp_sock *tp = tcp_sk(sk);
    int max_probes;
    
    // tp->packets_out 是已发送,但是还没有 ack 的包的个数
    // 如果这个数不是 0,说明最近会收到 ack,或者收不到 ack 就会重传
    // 不需要窗口探测报文来探测
    // !skb 说明 skb 是空,当前没有要发送的数据
    // 这种情况下,也不需要探测窗口,直接返回
    if (tp->packets_out || !skb) {
        icsk->icsk_probes_out = 0;
        icsk->icsk_probes_tstamp = 0;
        return;
    }
    
    // 最大重传次数
    max_probes = sock_net(sk)->ipv4.sysctl_tcp_retries2;

    // 如果达到最大重传次数,则关闭连接
    if (icsk->icsk_probes_out >= max_probes) {
    abort:
        tcp_write_err(sk);
    } else {
        // 发送窗口探测报文
        tcp_send_probe0(sk);
    }
}

void tcp_send_probe0(struct sock *sk)
{
    struct inet_connection_sock *icsk = inet_csk(sk);
    struct tcp_sock *tp = tcp_sk(sk);
    struct net *net = sock_net(sk);
    unsigned long timeout;
    int err;
    
    // 这个函数中完成窗口探测报文的发送
    err = tcp_write_wakeup(sk, LINUX_MIB_TCPWINPROBE);
    
    // 后边要重启窗口探测定时器,在重启之前,要判断一下需不需要重启
    // 如下两个条件满足,则不需要重启
    if (tp->packets_out || tcp_write_queue_empty(sk)) {
        icsk->icsk_probes_out = 0;
        icsk->icsk_backoff = 0;
        icsk->icsk_probes_tstamp = 0;
        return;
    }

    icsk->icsk_probes_out++;
    if (err <= 0) {
        if (icsk->icsk_backoff < net->ipv4.sysctl_tcp_retries2)
            icsk->icsk_backoff++;
        timeout = tcp_probe0_when(sk, TCP_RTO_MAX);
    } else {
        /* If packet was not sent due to local congestion,
         * Let senders fight for local resources conservatively.
         */
        timeout = TCP_RESOURCE_PROBE_INTERVAL;
    }

    timeout = tcp_clamp_probe0_to_user_timeout(sk, timeout);
    tcp_reset_xmit_timer(sk, ICSK_TIME_PROBE0, timeout, TCP_RTO_MAX);
}

// 这个函数用于 0 窗口探测定时器
// 同时也用于 keepalive 定时器
int tcp_write_wakeup(struct sock *sk, int mib)
{
    struct tcp_sock *tp = tcp_sk(sk);
    struct sk_buff *skb;

    if (sk->sk_state == TCP_CLOSE)
        return -1;

    skb = tcp_send_head(sk);
    
    // 如果当前发送队列中有报文了,并且接收窗口已经打开
    // 那么就不需要发送探测报文,直接发送用户数据
    if (skb && before(TCP_SKB_CB(skb)->seq, tcp_wnd_end(tp))) {
        int err;
        unsigned int mss = tcp_current_mss(sk);
        unsigned int seg_size = tcp_wnd_end(tp) - TCP_SKB_CB(skb)->seq;

        if (before(tp->pushed_seq, TCP_SKB_CB(skb)->end_seq))
            tp->pushed_seq = TCP_SKB_CB(skb)->end_seq;

        /* We are probing the opening of a window
         * but the window size is != 0
         * must have been a result SWS avoidance ( sender )
         */
        if (seg_size <
                TCP_SKB_CB(skb)->end_seq - TCP_SKB_CB(skb)->seq ||
            skb->len > mss) {
            seg_size = min(seg_size, mss);
            TCP_SKB_CB(skb)->tcp_flags |= TCPHDR_PSH;
            if (tcp_fragment(sk, TCP_FRAG_IN_WRITE_QUEUE, skb,
                     seg_size, mss, GFP_ATOMIC))
                return -1;
        } else if (!tcp_skb_pcount(skb))
            tcp_set_skb_tso_segs(skb, mss);

        TCP_SKB_CB(skb)->tcp_flags |= TCPHDR_PSH;
        err = tcp_transmit_skb(sk, skb, 1, GFP_ATOMIC);
        if (!err)
            tcp_event_new_data_sent(sk, skb);
        return err;
    } else {
        // 发送探测报文
        return tcp_xmit_probe_skb(sk, 0, mib);
    }
}

2.3.3 窗口探测定时器什么时候停止 ?

定时器停止的情况有以下几种:

① 发送一次探测报文之后判断当前链路上是不是有已发送但是还没有确认的报文,或者发送队列中是不是有数据。上边两个条件满足其一,则不再重启定时器,也就意味着定时器后边不会再触发了。参考函数 tcp_send_probe0()。

② 收到 ack 得知对端打开接收窗口

static int tcp_ack(struct sock *sk, const struct sk_buff *skb, int flag)
{
    // 已经发送但还没有确认的报文
    int prior_packets = tp->packets_out;
    
    // 如果发送的报文都已经确认了,那么就尝试停止探测定时器
    if (!prior_packets)
        goto no_queue;

no_queue:
    // 这个函数中会进行判断,然后决定停止探测定时器还是重启探测定时器
    tcp_ack_probe(sk);

    return 0;
}

static void tcp_ack_probe(struct sock *sk)
{
    struct inet_connection_sock *icsk = inet_csk(sk);
    struct sk_buff *head = tcp_send_head(sk);
    const struct tcp_sock *tp = tcp_sk(sk);
    
    // 如果发送队列是空的,不对探测定时器做操作
    if (!head)
        return;
    // 如果现在的窗口能把 skb 这个报文全部发送出去,则停掉探测定时器
    // 否则,重启探测定时器
    if (!after(TCP_SKB_CB(head)->end_seq, tcp_wnd_end(tp))) {
        icsk->icsk_backoff = 0;
        icsk->icsk_probes_tstamp = 0;
        inet_csk_clear_xmit_timer(sk, ICSK_TIME_PROBE0);
        /* Socket must be waked up by subsequent tcp_data_snd_check().
         * This function is not for random using!
         */
    } else {
        unsigned long when = tcp_probe0_when(sk, TCP_RTO_MAX);

        when = tcp_clamp_probe0_to_user_timeout(sk, when);
        tcp_reset_xmit_timer(sk, ICSK_TIME_PROBE0, when, TCP_RTO_MAX);
    }
}

2.3.4 窗口探测定时器实验

为了测试 0 窗口的情况,tcp 连接建立之后,客户端向服务端发送数据,但是服务端不接收数据。这样的话,接收侧窗口很快就会变为 0。

伪码如下:

服务端:
socket()
bind()
listen()
accept_fd = accept()
// 服务端 accept 一个连接之后,不立即接收报文,而是 10 s 之后再接收报文
sleep(10)
recv()

客户端:
connect()
// 客户端建立连接之后,就立即发送数据
send()

抓包,如下图所示,192.168.1.104 是客户端,192.168.1.103 是服务端,建立连接之后,客户端向服务端发数据。

① 序号 30 是发送的最后一个报文,序列号是 83313,长度是 6912,所以最后一个序列号是 83313 + 6912 - 1 = 90224。

② 序列号 31 是服务端给客户端的 ack, ack seq 是 90225,意思是客户端下一个要发的数据序号是 90225。

③ 序列号 34 是客户端发送的 0 窗口探测报文,可以看到序列号是 90224,而不是 90225。

④ 序列号 35 是服务端发送给客户端的 ack, 这个 ack 中包含窗口信息,是 0 说明现在窗口仍然是 0。

过了 10s 之后,服务端开始读数据,这个时候,接收侧的窗口就打开了。

① 43 和 44 是服务端向客户端发送的窗口打开通知。

② 45 是客户端向服务端开始发送数据。

2.4 保活定时器

保活定时器,顾名思义,就是当 tcp 连接上长时间没有数据传输时,用来判断对端是否还存在,如果一端给另外一端发送一个保活报文,然后得到回应报文,那么说明对端就是还存在的,这条连接继续保持;反之,如果收不到对端的回应,那么就会认为对端已经不存在了,则会关闭这条连接。

保活定时器和上边的窗口探测定时器,都是探测定时器,一个是窗口探测,一个存活性探测。

保活定时器,默认是没有开启的,用户如果想使能该功能话,需要通过函数 setsockopt() 来设置 SO_KEEPALIVE 选项。

// 用户设置 KEEALIVE 
int val = 1;
setsockopt(sock_fd, SOL_SOCKET, SO_KEEPALIVE, (void *)&val, sizeof(val));


// SO_KEEPALIVE 选项在内核中,通过函数 tcp_set_keepalive 来完成
// 可以看到,如果是打开选项,则启动定时器,关闭选项则停止定时器
void tcp_set_keepalive(struct sock *sk, int val)
{
    if ((1 << sk->sk_state) & (TCPF_CLOSE | TCPF_LISTEN))
        return;

    if (val && !sock_flag(sk, SOCK_KEEPOPEN))
        inet_csk_reset_keepalive_timer(sk,
                           keepalive_time_when(tcp_sk(sk)));
    else if (!val)
        inet_csk_delete_keepalive_timer(sk);
}

保活定时器的超时处理函数为 tcp_keepalive_timer()。

static void tcp_keepalive_timer(struct timer_list *t)
{
    struct sock *sk = from_timer(sk, t, sk_timer);
    struct inet_connection_sock *icsk = inet_csk(sk);
    struct tcp_sock *tp = tcp_sk(sk);
    u32 elapsed;

    
    // 该函数首先判断了四种情况,在这几种情况下,不需要发送保活报文,函数直接退出
    // 1、套接字正在被使用,说明最近会有数据收发,所以不需要发送保活报文
    if (sock_owned_by_user(sk)) {
        inet_csk_reset_keepalive_timer(sk, HZ / 20);
        goto out;
    }
    // 2、套接字处于 LISTEN 状态,处于 LISTEN 状态的套接字,不是一条连接套接字,
    //    也不需要发送保活报文。可以看到下边的注释,非常有趣,类似于这样的注释,内核中不少
    if (sk->sk_state == TCP_LISTEN) {
        pr_err("Hmm... keepalive on a LISTEN ???n");
        goto out;
    }
    // 3、这个链接即将关闭,也不需要发送保活报文
    //    TCP_FIN_WAIT2 也会使用这个定时器
    if (sk->sk_state == TCP_FIN_WAIT2 && sock_flag(sk, SOCK_DEAD)) {
        if (tp->linger2 >= 0) {
            const int tmo = tcp_fin_time(sk) - TCP_TIMEWAIT_LEN;

            if (tmo > 0) {
                tcp_time_wait(sk, TCP_FIN_WAIT2, tmo);
                goto out;
            }
        }
        tcp_send_active_reset(sk, GFP_ATOMIC);
        goto death;
    }
    // 4、没有设置 SOCK_KEEPOPEN 标志,不发送保活报文,理论只要设置了 SO_KEEPALIVE 就会设置这个标志
    //    处于关闭状态或者还在连接建立过程中,也不发送保活报文
    if (!sock_flag(sk, SOCK_KEEPOPEN) ||
        ((1 << sk->sk_state) & (TCPF_CLOSE | TCPF_SYN_SENT)))
        goto out;

    // 获取保活定时器超时时间,为了下一行代码直接 goto resched 做准备,
    // 如果这里不获取的话,下一句 goto resched 之后,定时器的超时时间是 0,
    // 很明显是不对的
    elapsed = keepalive_time_when(tp);
    // tp->packets_out 不为 0, 说明本端发出去的包还有包没有收到 ack
    // 这种情况下也不发送保活报文
    // write queue 不为空,说明现在连接还有数据需要传输,也不发送保活报文
    if (tp->packets_out || !tcp_write_queue_empty(sk))
        goto resched;

    // 这句代码是该函数很重要的一行代码
    // tp->rcv_tstamp 是上一次收到数据的时间
    // icsk->icsk_ack.lrcvtime 是上一次收到 ack 的时间
    // tcp_jiffies32 是当前时间
    // 该函数的返回结果就是连接上没有数据的时间,
    // 只有这个时间超过了 /proc/sys/net/ipv4/tcp_keepalive_time,才会发送保活报文
    // 否则不发送保活报文
    // static inline u32 keepalive_time_elapsed(const struct tcp_sock *tp)
    // {
    //     const struct inet_connection_sock *icsk = &tp->inet_conn;
    //     return min_t(u32, tcp_jiffies32 - icsk->icsk_ack.lrcvtime,
    //         tcp_jiffies32 - tp->rcv_tstamp);
    //  }
    elapsed = keepalive_time_elapsed(tp);

    if (elapsed >= keepalive_time_when(tp)) {
        // icsk->icsk_probes_out >= keepalive_probes(tp)
        // 这个条件即保活报文总数限制,默认是 9,如果超过这个数
        // 则关闭连接
        if ((icsk->icsk_user_timeout != 0 &&
             elapsed >= msecs_to_jiffies(icsk->icsk_user_timeout) &&
             icsk->icsk_probes_out > 0) ||
            (icsk->icsk_user_timeout == 0 &&
             icsk->icsk_probes_out >= keepalive_probes(tp))) {
            tcp_send_active_reset(sk, GFP_ATOMIC);
            tcp_write_err(sk);
            goto out;
        }
        if (tcp_write_wakeup(sk, LINUX_MIB_TCPKEEPALIVE) <= 0) {
            // 发送 keepalive 报文返回成功,增加计数
            // elapsed 重新赋值,默认是 75s
            icsk->icsk_probes_out++;
            elapsed = keepalive_intvl_when(tp);
        } else {
            elapsed = TCP_RESOURCE_PROBE_INTERVAL;
        }
    } else {
        elapsed = keepalive_time_when(tp) - elapsed;
    }

    sk_mem_reclaim(sk);

resched:
    inet_csk_reset_keepalive_timer(sk, elapsed);
    goto out;

death:
    tcp_done(sk);

out:
    bh_unlock_sock(sk);
    sock_put(sk);
}

① 三个参数

tcp keepalive 功能,有三个参数可供用户配置

配置参数

默认值

作用

/proc/sys/net/ipv4/tcp_keepalive_time

7200

多长时间没有数据传输就会发送保活探测报文,默认是 7200s,即 2 个小时;

这个时间对于实际应用来说太长,可以根据应用的具体场景做调整。

/proc/sys/net/ipv4/tcp_keepalive_intvl

75

发送保活报文的时间间隔,默认是 75s;保活报文,不是发一次,收不到回应就立即认为对方不存在了,而是可以发送多次,最多可以发送的次数由下边的参数控制。

/proc/sys/net/ipv4/tcp_keepalive_probes

9

发送保活报文的次数,默认是 9,也就是说如果发送了 9 个报文,都没有收到对端的响应,那么就会认为对端不存在了。

 

② 没有数据传输的时间判断

发送 keepalive 报文之前需要进行判断,其中一个条件是这条连接上多久没有数据传输了,只有没有数据传输的时间超过一定值之后,才会发送保活报文,也就是说当连接上有数据传输的时候,这条连接肯定是正常的,不需要发送保活报文。

上文中对函数 tcp_keepalive_timer(struct timer_list *t) 的注释中包括了对该时间的判断,在keepalive_time_elapsed(tp); 这行代码中获取到了没有数据活跃的持续时间。

函数 keepalive_time_elapsed() 中获取时间的方式,通过最后收到数据的时间以及最后收到的 ack 的时间来计算。乍一看是只考虑了接收方向的数据,其实不然,tp->rcv_tstamp 即最后接收到数据的时间,可以代表接收方向, icsk->icsk_ack.lrcvtime  表示最后接收到 ack 的时间,收到了 ack 说明之前肯定发送了数据,所以这个时间可以代表发送方向。

③ 发送保活报文

tcp 中并没有一个特殊的标志来标记这个报文是保活报文,tcp hdr flag 中没有 keepalive 相关的标志,tcp 选项中也没有 keepalive 相关的选项。

那么 tcp 报文有什么特点呢 ?

发送保活报文在函数 tcp_xmit_probe_skb() 中完成。

调用关系如下:

tcp_keepalive_timer()

调用

tcp_write_wakeup()

调用

tcp_xmit_probe_skb()

从函数 tcp_xmit_probe_skb() 的注释中也可以看到,这个报文的特殊之处在于序列号,序列号只一个已经发送过的序列号,并且已经 ack 过了。接收端还存在,收到这样的数据之后,会回应一个 ack 报文;如果接收端已经不存在了,那么就会发过来一个 rst 报文,本端收到 rst 报文之后便会关闭连接。

static int tcp_xmit_probe_skb(struct sock *sk, int urgent, int mib)
{
    struct tcp_sock *tp = tcp_sk(sk);
    struct sk_buff *skb;

    /* We don't queue it, tcp_transmit_skb() sets ownership. */
    skb = alloc_skb(MAX_TCP_HEADER,
            sk_gfp_mask(sk, GFP_ATOMIC | __GFP_NOWARN));
    if (!skb)
        return -1;

    /* Reserve space for headers and set control bits. */
    skb_reserve(skb, MAX_TCP_HEADER);
    /* Use a previous sequence.  This should cause the other
     * end to send an ack.  Don't queue or clone SKB, just
     * send it.
     */
    tcp_init_nondata_skb(skb, tp->snd_una - !urgent, TCPHDR_ACK);
    NET_INC_STATS(sock_net(sk), mib);
    return tcp_transmit_skb(sk, skb, 0, (__force gfp_t)0);
}

为了方便测试,把 keepalive 时间改成了 10s(默认 7200s),进行测试,抓包结果如下,从抓包结果可以看到:

① keepalive 的时间变成了 10s

② 在发送 keepalive 报文之前,3025 + 1072 -1 = 4096,seq 为 4096 的字节已经发送出去了,并且得到了 ack;keepalive 的序列号是 4096,本来正常的数据应该是 4097,接收方想要接收的下一个字节的编号也是 4097。

③ 接收方收到报文之后立即回应了 ack 报文。

3 断开连接过程中的定时器

3.1 TIME_WAIT 定时器

主动发起关闭的一方,最后一个状态是 TIME_WAIT。发送最后一个 ack 之后便从 FIN_WAIT_2 状态进入到 TIME_WAIT 状态。

在函数 tcp_fin() 中处理 FIN 标志,主动断开连接的一方收到对端发送的 FIN 报文之后,返回一个 ack 之后便会进入到 TIME_WAIT 状态。tcp_time_wait() 函数中完成 TIME_WAIT 状态的处理,在这个函数中会启动 TIME_WAIT 定时器,定时器的超时处理函数 tw_timer_handler()。

void tcp_fin(struct sock *sk)
{
	switch (sk->sk_state) {
    ...
	case TCP_FIN_WAIT2:
		/* Received a FIN -- send ACK and enter TIME_WAIT. */
		tcp_send_ack(sk);
		tcp_time_wait(sk, TCP_TIME_WAIT, 0);
		break;
    ...
	}
}

本图文内容来源于网友网络收集整理提供,作为学习参考使用,版权属于原作者。
THE END
分享
二维码
< <上一篇
下一篇>>