在上一节中我们从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进行重传判断,一旦发现rexmit为REXMIT_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_queue、tcp_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_skbtcp_xmit_retransmit_queue之后会调用tcp_retransmit_skbtcp_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_retransmit、tcp_xmit_retransmit_queue - 快速重传:
tcp_xmit_recovery、tcp_xmit_retransmit_queue - TSQ中的重传:
tcp_tsq_write、tcp_xmit_retransmit_queue - RACK算法整理乱序报文超时:
tcp_rack_reo_timeout、tcp_xmit_retransmit_queue - 报文段在RTO时间内未被确认(由超时计时器或延迟重传触发):
tcp_write_timer_handler、tcp_retransmit_timer - 在连接中断处理中,因RTO的调整导致需要立即重传:
tcp_ld_RTO_revert、tcp_retransmit_timer
参考资料
TCP Tail Loss Probe(TLP) – perthcharles.github.io
Tail Loss Probe (TLP): An Algorithm for Fast Recovery of Tail Losses
TCP系列52—拥塞控制—15、前向重传与RACK重传拥塞控制处理对比
Making TCP More Robust to Long Connectivity Disruptions (TCP-LCD) – RFC 6069