【Linux】网络专题(六)——网络栈重传时机梳理

在上一节中我们从tcp_ack自顶向下分析,找到了网络栈的重传入口——__tcp_transmit_skb,本节我们会尽可能全面地梳理该函数是从哪条路径被调用到的。

1. 谁调用了__tcp_transmit_skb?

答:

  • tcp_output.c: tcp_retransmit_skb
  • tcp_output.c: tcp_send_loss_probe
  • tcp_input.c: tcp_rcv_fastopen_synack

前一种是标准的重传接口,会在调用__tcp_transmit_skb后进行报文段数和时间戳的记录;后两种直接调用__tcp_transmit_skb进行重传,它们对应的情况更少,我们先从它们入手。

2. 直接调用__tcp_transmit_skb的情况

2.1. Tail Loss Probe (TLP)

尾丢包不会带来任何的dupACK,因此FR无法生效;为此Google的大神们提出TLP,这是一个用于解决因尾丢包而导致RTO的算法。TCB有一个Probe Timeout计时器,每次发包后,当PTO到来时,内核会采用超时处理机制。这一机制实现于tcp_send_loss_probe

首先检查是否有未发送的包。为减少RTO处理启动的可能,先从队首挑一个报文段并尝试交给IP层进行发送。tcp_write_xmit的参数“2”表示暂时忽略cwnd的限制,强制发送一个报文段。

如果发送成功,会将tp->tlp_high_seq置为snd_nxt,之后接收ACK的时候就会触发FR。

	tp->tlp_retrans = 0;
	skb = tcp_send_head(sk);
	if (skb && tcp_snd_wnd_test(tp, skb, mss)) {
		pcount = tp->packets_out;
		tcp_write_xmit(sk, mss, TCP_NAGLE_OFF, 2, GFP_ATOMIC);
		if (tp->packets_out > pcount)
			goto probe_sent;
		goto rearm_timer;
	}

probe_sent:
	/* Record snd_nxt for loss detection. */
	tp->tlp_high_seq = tp->snd_nxt;

	NET_INC_STATS(sock_net(sk), LINUX_MIB_TCPLOSSPROBES);
	/* Reset s.t. tcp_rearm_rto will restart timer from now */
	inet_csk(sk)->icsk_pending = 0;
rearm_timer:
	tcp_rearm_rto(sk);

如果没有未被ACK的报文,一般而言是有重传的报文触发了该机制。根据TLP算法,取出序号最大的一个报文段,调用重传函数。

	if (skb_still_in_host_queue(sk, skb))
		goto rearm_timer;

	pcount = tcp_skb_pcount(skb);
	if (WARN_ON(!pcount))
		goto rearm_timer;

	if ((pcount > 1) && (skb->len > (pcount - 1) * mss)) {
		if (unlikely(tcp_fragment(sk, TCP_FRAG_IN_RTX_QUEUE, skb,
					  (pcount - 1) * mss, mss,
					  GFP_ATOMIC)))
			goto rearm_timer;
		skb = skb_rb_next(skb);
	}

	if (WARN_ON(!skb || !tcp_skb_pcount(skb)))
		goto rearm_timer;

	if (__tcp_retransmit_skb(sk, skb, 1))
		goto rearm_timer;

	tp->tlp_retrans = 1;

2.2. Fastopen中SYN数据重传

Fastopen在SYN中携带了数据,从而有效利用三次握手的时间进行数据传输。

在启动Fastopen TCP连接时,客户端需要设置Fastopen启用,并将选项字段中的cookie清零。如果服务器也支持Fastopen,则会提供一个cookie以供客户端在下次建立连接时使用,直至cookie过期。之后建立连接时,客户端将cookie包含在选项字段,即可在SYN中发送数据,并等待服务器通过SYN-ACK进行确认,弹射起步!

客户端对SYN-ACK的处理在tcp_rcv_synsent_state_process中实现,该函数定义于tcp_input.c。我们看到其中与Fastopen相关的部分:

		fastopen_fail = (tp->syn_fastopen || tp->syn_data) &&
				tcp_rcv_fastopen_synack(sk, skb, &foc);

但不幸的是,假如客户端使用了过期的cookie,服务器就不会ACK客户端在SYN中的数据。为此,客户端必须重传这一份数据。重传的判断和调用在tcp_rcv_fastopen_synack中实现。

static bool tcp_rcv_fastopen_synack(struct sock *sk, struct sk_buff *synack,
				    struct tcp_fastopen_cookie *cookie)
{
	struct tcp_sock *tp = tcp_sk(sk);
	struct sk_buff *data = tp->syn_data ? tcp_rtx_queue_head(sk) : NULL;

	/* ... */

	if (data) { /* Retransmit unacked data in SYN */
		if (tp->total_retrans)
			tp->fastopen_client_fail = TFO_SYN_RETRANSMITTED;
		else
			tp->fastopen_client_fail = TFO_DATA_NOT_ACKED;
		skb_rbtree_walk_from(data) {
			if (__tcp_retransmit_skb(sk, data, 1))
				break;
		}
		tcp_rearm_rto(sk);
		NET_INC_STATS(sock_net(sk),
				LINUX_MIB_TCPFASTOPENACTIVEFAIL);
		return true;
	}

	/* ... */

	return false;
}

TFO_SYN_RETRANSMITTED表示SYN报文超时,TFO_DATA_NOT_ACKED表示到来的SYN-ACK报文未确认SYN中发送的数据。不论如何,总是遍历这些需要重发的报文段,然后调用__tcp_retransmit_skb发送出去。

3. Path MTU Discovery中的重传

tcp_simple_retransmit是专门为PMTUD设计的重传接口。下面对PMTUD作介绍。

IPv4有一个机制叫分片,能把上层报文段拆分开,产生能被链路层支持的数据报。但是对于某些运输层协议来说,这个机制就比较屑了:

  • 如果一个报文段的某个分片丢失,整个报文段都要重传;
  • 如果分片乱序到达接收方,分片的组装会受到延迟。TCP报文段乱序的话,报文交付也会延迟,但后者是为了保证可靠传输,前者却降低了可靠传输的效率。

为此,Path MTU Discovery应运而生。

Linux中,PMTUD机制会在收到ICMP目标不可达报文(错误代码为需要分片)时触发,这时会按ICMP报文的指示减小MTU,并通过tcp_v4_mtu_reduced进行后处理。由于收到ICMP报文时,之前发出的报文段肯定被丢弃了,所以一定会重传。这里表示重传的一个fast path:

	mtu = dst_mtu(dst);

	if (inet->pmtudisc != IP_PMTUDISC_DONT &&
	    ip_sk_accept_pmtu(sk) &&
	    inet_csk(sk)->icsk_pmtu_cookie > mtu) {
		tcp_sync_mss(sk, mtu);

		/* Resend the TCP packet because it's
		 * clear that the old packet has been
		 * dropped. This is the new "fast" path mtu
		 * discovery.
		 */
		tcp_simple_retransmit(sk);
	} /* else let the usual retransmit timer handle it */

当然更一般的情况下,ICMP可能会被禁用,这时只好设计主动探测MTU的机制,在超时的时候触发(MTU Probe),并在超时或接收报文时处理。如果接收到的ACK有异常,则触发快速重传警报tcp_fastretrans_alert,这一点在上篇【Linux】网络专题(五)——TCP拥塞控制中提过。但警报也只是警报而已,如果发现正在进行MTU探测,且期望收到ACK的字节号snd_una和MTU探测开始时相同,这代表上一次MTU探测失败,但和拥塞无关,此时只要通过快速路径tcp_simple_retransmit进行重传就好。

		/* MTU probe failure: don't reduce cwnd */
		if (icsk->icsk_ca_state < TCP_CA_CWR &&
		    icsk->icsk_mtup.probe_size &&
		    tp->snd_una == tp->mtu_probe.probe_seq_start) {
			tcp_mtup_probe_failed(sk);
			/* Restores the reduction we did in tcp_mtup_probe() */
			tp->snd_cwnd++;
			tcp_simple_retransmit(sk);
			return;
		}

4. 快速重传

上篇我们讲过,在tcp_ack中可以触发快速重传。它由tcp_fastretrans_alert决定,如果判断当前确实应该进入快速重传,那么重设CA状态,并且设一个位:

	*rexmit = REXMIT_LOST;

tcp_ack随后会调用tcp_xmit_recovery进行重传判断,一旦发现rexmitREXMIT_LOST,就通过tcp_xmit_retransmit_queue执行真正的重传。但是应当注意,正常的工作流也会经过tcp_xmit_recovery,但只有在需要快速重传时,重传函数才被调用。

/* Congestion control has updated the cwnd already. So if we're in
 * loss recovery then now we do any new sends (for FRTO) or
 * retransmits (for CA_Loss or CA_recovery) that make sense.
 */
static void tcp_xmit_recovery(struct sock *sk, int rexmit)
{
	struct tcp_sock *tp = tcp_sk(sk);

	if (rexmit == REXMIT_NONE || sk->sk_state == TCP_SYN_SENT)
		return;

	if (unlikely(rexmit == REXMIT_NEW)) {
		__tcp_push_pending_frames(sk, tcp_current_mss(sk),
					  TCP_NAGLE_OFF);
		if (after(tp->snd_nxt, tp->high_seq))
			return;
		tp->frto = 0;
	}
	tcp_xmit_retransmit_queue(sk);
}

5. TSQ机制中的重传

TCP Small Queue,小队列,字面含义。摘取原文如下:

TSQ goal is to reduce number of TCP packets in xmit queues (qdisc &
device queues), to reduce RTT and cwnd bias, part of the bufferbloat
problem.

这一机制在tasklet中实现,回调函数如下:

/*
 * One tasklet per cpu tries to send more skbs.
 * We run in tasklet context but need to disable irqs when
 * transferring tsq->head because tcp_wfree() might
 * interrupt us (non NAPI drivers)
 */
static void tcp_tasklet_func(unsigned long data)
{
	struct tsq_tasklet *tsq = (struct tsq_tasklet *)data;
	LIST_HEAD(list);
	unsigned long flags;
	struct list_head *q, *n;
	struct tcp_sock *tp;
	struct sock *sk;

	local_irq_save(flags);
	list_splice_init(&tsq->head, &list);
	local_irq_restore(flags);

	list_for_each_safe(q, n, &list) {
		tp = list_entry(q, struct tcp_sock, tsq_node);
		list_del(&tp->tsq_node);

		sk = (struct sock *)tp;
		smp_mb__before_atomic();
		clear_bit(TSQ_QUEUED, &sk->sk_tsq_flags);

		tcp_tsq_handler(sk);
		sk_free(sk);
	}
}

接下来,调用tcp_tsq_handler进行发送。此处有一个问题需要解决:同一时间段,tasklet只能在一个CPU上执行,为什么还需要先获取下半部锁?这里我的推测是,该锁与进程上下文共用,此时如果进程上下文要访问TCB,就要在自旋锁上等待。

之后判断当前TCB是否正被进程空间使用,如果进程空间已经释放了使用权,就可以调用tcp_tsq_write进行发送。发送前,先看看有没有报文段还没有被重传,如果有,且重发后拥塞窗口能留出足够大的空间,就调用tcp_xmit_retransmit_queue进行重传。之后正常清空待发送队列。

static void tcp_tsq_write(struct sock *sk)
{
	if ((1 << sk->sk_state) &
	    (TCPF_ESTABLISHED | TCPF_FIN_WAIT1 | TCPF_CLOSING |
	     TCPF_CLOSE_WAIT  | TCPF_LAST_ACK)) {
		struct tcp_sock *tp = tcp_sk(sk);

		if (tp->lost_out > tp->retrans_out &&
		    tp->snd_cwnd > tcp_packets_in_flight(tp)) {
			tcp_mstamp_refresh(tp);
			tcp_xmit_retransmit_queue(sk);
		}

		tcp_write_xmit(sk, tcp_current_mss(sk), tp->nonagle,
			       0, GFP_ATOMIC);
	}
}

static void tcp_tsq_handler(struct sock *sk)
{
	bh_lock_sock(sk);
	if (!sock_owned_by_user(sk))
		tcp_tsq_write(sk);
	else if (!test_and_set_bit(TCP_TSQ_DEFERRED, &sk->sk_tsq_flags))
		sock_hold(sk);
	bh_unlock_sock(sk);
}

由于一些情况下TSQ的重传可能被推迟,tcp_release_cb还要负责兜底,执行被推迟的重传。它直接调用tcp_tsq_write函数。

	if (flags & TCPF_TSQ_DEFERRED) {
		tcp_tsq_write(sk);
		__sock_put(sk);
	}

6. 超时重传

上篇已经讲过超时重传的入口函数tcp_write_timer,它在icsk_retransmit_timer重传计时器到期的时候就会被执行。此时是在软中断上下文,也会尝试获取下半部锁。同时,在确保TCB不被进程上下文使用的情况下进行重传处理。

static void tcp_write_timer(struct timer_list *t)
{
	struct inet_connection_sock *icsk =
			from_timer(icsk, t, icsk_retransmit_timer);
	struct sock *sk = &icsk->icsk_inet.sk;

	bh_lock_sock(sk);
	if (!sock_owned_by_user(sk)) {
		tcp_write_timer_handler(sk);
	} else {
		/* delegate our work to tcp_release_cb() */
		if (!test_and_set_bit(TCP_WRITE_TIMER_DEFERRED, &sk->sk_tsq_flags))
			sock_hold(sk);
	}
	bh_unlock_sock(sk);
	sock_put(sk);
}

tcp_write_timer_handler会判断发生什么事了,并选择合适的处理函数。对于超时重传,我们有两种情况:

  • RACK算法启用时,尝试恢复乱序到达的报文。但超时的话就应当对还未确认收到的报文进行重传。此时调用tcp_rack_reo_timeout函数进行处理,后者会调用tcp_xmit_retransmit_queuetcp_retransmit_skb进行重传。
  • 一个报文发送后在RTO的时间内没有收到确认,此时调用tcp_retransmit_timer函数进行处理,后者会调用tcp_retransmit_skb进行重传。
	tcp_mstamp_refresh(tcp_sk(sk));
	event = icsk->icsk_pending;

	switch (event) {
	case ICSK_TIME_REO_TIMEOUT:
		tcp_rack_reo_timeout(sk);
		break;
	case ICSK_TIME_LOSS_PROBE:
		tcp_send_loss_probe(sk);
		break;
	case ICSK_TIME_RETRANS:
		icsk->icsk_pending = 0;
		tcp_retransmit_timer(sk);
		break;
	case ICSK_TIME_PROBE0:
		icsk->icsk_pending = 0;
		tcp_probe_timer(sk);
		break;
	}

7. TCP-LCD连接中断处理机制

有时TCP超时重传不是拥塞导致的,而是因为网络本来就故障了。假如发出一个重传报文之后,我们收到了ICMP错误报文(网络不可达或主机不可达),而且里面还携带着一点重传报文的碎片,那么我们有两个结论:

  • 网络中断了,或者主机掉线了;
  • 网络没有发生拥塞。

第一点我们无能为力,但第二点却可以指导我们做一些事情,例如不需要指数回退,只要在固定间隔内反复重传即可,因为并不会对网络造成负面影响。为此,可以调用tcp_ld_RTO_revert,判断是否可以让RTO保持原状。

	case ICMP_DEST_UNREACH:
		if (!fastopen &&
		    (code == ICMP_NET_UNREACH || code == ICMP_HOST_UNREACH))
			tcp_ld_RTO_revert(sk, seq);
		break;

由于RTO的减小可能导致原先还在等待的报文段立即超时,所以在tcp_ld_RTO_revert结束的时候进行判断,并在需要的时候“flush”一下。

/* TCP-LD (RFC 6069) logic */
void tcp_ld_RTO_revert(struct sock *sk, u32 seq)
{
	struct inet_connection_sock *icsk = inet_csk(sk);
	struct tcp_sock *tp = tcp_sk(sk);
	struct sk_buff *skb;
	s32 remaining;
	u32 delta_us;

	if (sock_owned_by_user(sk))
		return;

	if (seq != tp->snd_una  || !icsk->icsk_retransmits ||
	    !icsk->icsk_backoff)
		return;

	skb = tcp_rtx_queue_head(sk);
	if (WARN_ON_ONCE(!skb))
		return;

	icsk->icsk_backoff--;
	icsk->icsk_rto = tp->srtt_us ? __tcp_set_rto(tp) : TCP_TIMEOUT_INIT;
	icsk->icsk_rto = inet_csk_rto_backoff(icsk, TCP_RTO_MAX);

	tcp_mstamp_refresh(tp);
	delta_us = (u32)(tp->tcp_mstamp - tcp_skb_timestamp_us(skb));
	remaining = icsk->icsk_rto - usecs_to_jiffies(delta_us);

	if (remaining > 0) {
		inet_csk_reset_xmit_timer(sk, ICSK_TIME_RETRANS,
					  remaining, TCP_RTO_MAX);
	} else {
		/* RTO revert clocked out retransmission.
		 * Will retransmit now.
		 */
		tcp_retransmit_timer(sk);
	}
}

8. 小结

下面每一项中的函数表示重传的调用轨迹,其中一些轨迹进行了省略:

  • tcp_retransmit_skb之后会调用__tcp_retransmit_skb
  • tcp_xmit_retransmit_queue之后会调用tcp_retransmit_skb
  • tcp_retransmit_timer之后会调用tcp_retransmit_skb

现将Linux 5.10的重传事件整理如下:

  • TLP算法由Probe Timeout计时器触发,重传一个序号最大的报文段:tcp_send_loss_probe__tcp_retransmit_skb
  • 重传Fastopen中的SYN数据:tcp_rcv_fastopen_synack__tcp_retransmit_skb
  • Path MTU Discovery的重传:tcp_simple_retransmittcp_xmit_retransmit_queue
  • 快速重传:tcp_xmit_recoverytcp_xmit_retransmit_queue
  • TSQ中的重传:tcp_tsq_writetcp_xmit_retransmit_queue
  • RACK算法整理乱序报文超时:tcp_rack_reo_timeouttcp_xmit_retransmit_queue
  • 报文段在RTO时间内未被确认(由超时计时器或延迟重传触发):tcp_write_timer_handlertcp_retransmit_timer
  • 在连接中断处理中,因RTO的调整导致需要立即重传:tcp_ld_RTO_reverttcp_retransmit_timer

参考资料

TCP Tail Loss Probe(TLP) – perthcharles.github.io

Tail Loss Probe (TLP): An Algorithm for Fast Recovery of Tail Losses

IP分片的缺点 – CSDN博客

linux Tcp Small Queue(TSQ)实现

tcp: TCP Small Queues

TCP系列52—拥塞控制—15、前向重传与RACK重传拥塞控制处理对比

Making TCP More Robust to Long Connectivity Disruptions (TCP-LCD) – RFC 6069

发表评论

您的电子邮箱地址不会被公开。