TCP零窗口探测

TCP零窗口探测用于获取触发对端的窗口更新报文,防止在窗口更新报文丢失之后,导致的死循环。其也有助于本端Qdisc满或者数据被发送节奏(Pacing)阻止导致的发送停滞。

窗口探测开启

在TCP报文发送函数tcp_write_xmit的处理中,如果最终未能发送任何报文,而且网络中报文为空(packets_out),套接口的发送队列中有数据,将返回true。造成此情况可能是由于惰性窗口综合征(SWS),或者其它原因,如拥塞窗口限制、接收窗口限制等。

static bool tcp_write_xmit(struct sock *sk, unsigned int mss_now, int nonagle,
               int push_one, gfp_t gfp)
{
    ...
    if (likely(sent_pkts)) {
        ...
        return false;
    }
    return !tp->packets_out && !tcp_write_queue_empty(sk);
}

在发送暂缓的报文时,如果以上函数tcp_write_xmit返回true,调用函数tcp_check_probe_timer检查探测定时器。

void __tcp_push_pending_frames(struct sock *sk, unsigned int cur_mss, int nonagle)
{   
    /* If we are closed, the bytes will have to remain here.
     * In time closedown will finish, we empty the write queue and
     * all will be happy.
     */
    if (unlikely(sk->sk_state == TCP_CLOSE))
        return;
    
    if (tcp_write_xmit(sk, cur_mss, nonagle, 0, sk_gfp_mask(sk, GFP_ATOMIC)))
        tcp_check_probe_timer(sk);
}

如果此时网络中没有任何发送的报文,packets_oout为空,并且本地也没有启动任何定时器,icsk_pending为空意味着重传定时器、乱序定时器、TLP定时器和窗口探测定时器都没有启动(这四个定时器由内核中的一个定时器结构实现,以icsk_pending中的标志位区分)。此种情况下启动零窗口探测定时器。

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,
                     NULL);
}

零窗口定时器的时长由函数tcp_probe0_base决定,取值为当前的RTO时长,但是最短不低于TCP_RTO_MIN(200毫秒)。如下函数的注释可见,其定时器除了用于零窗口探测,也会因本端的Qdisc满或者发送节奏导致的发送失败,而启动。

/* Something is really bad, we could not queue an additional packet,
 * because qdisc is full or receiver sent a 0 window, or we are paced.
 * We do not want to add fuel to the fire, or abort too early,
 * so make sure the timer we arm now is at least 200ms in the future,
 * regardless of current icsk_rto value (as it could be ~2ms)
 */
static inline unsigned long tcp_probe0_base(const struct sock *sk)
{
    return max_t(unsigned long, inet_csk(sk)->icsk_rto, TCP_RTO_MIN);
}

窗口探测定时器

定时器的超时处理由函数tcp_probe_timer完成。如果网络中存在发送的报文,packets_out有值,或者套接口发送队列中没有数据,退出不进行处理。

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);

    if (tp->packets_out || !skb) {
        icsk->icsk_probes_out = 0;
        return;
    }

如果用户设置了UTO(变量icsk_user_timeout的值),并且发送队列中还有待发送报文,此报文等待的时长不能超过UTO,否则,认为此连接已经出错。

    /* RFC 1122 4.2.2.17 requires the sender to stay open indefinitely as
     * long as the receiver continues to respond probes. We support this by
     * default and reset icsk_probes_out with incoming ACKs. But if the
     * socket is orphaned or the user specifies TCP_USER_TIMEOUT, we
     * kill the socket when the retry count and the time exceeds the
     * corresponding system limit. We also implement similar policy when
     * we use RTO to probe window in tcp_retransmit_timer().
     */
    start_ts = tcp_skb_timestamp(skb);
    if (!start_ts)
        skb->skb_mstamp_ns = tp->tcp_clock_cache;
    else if (icsk->icsk_user_timeout &&
         (s32)(tcp_time_stamp(tp) - start_ts) > icsk->icsk_user_timeout)
        goto abort;

以下代码涉及到两个PROC文件中的控制变量:tcp_retries2和tcp_orphan_retries,前者表示在对端不响应时,进行的最大重传次数;后者表示本地已经关闭的套接,接收不到对端响应时的最大重传次数。

$ cat /proc/sys/net/ipv4/tcp_retries2
15 
$ cat /proc/sys/net/ipv4/tcp_orphan_retries 
0

如果套接口设置了SOCK_DEAD标志,表明本端已经关闭(Orphaned套接口),按照重传退避系数计数的当前RTO值小于最大值TCP_RTO_MAX(120秒),说明此连接还不应断开。之后,检查内核设置的孤儿套接口的重传次数(tcp_orphan_retries),如果alive为零并且退避次数已经超出最大的Orphaned套接口探测次数,断开连接。否则检查TCP资源使用是否超限,如果超限,将在tcp_out_of_resources函数中断开连接,第二个参数true表明将向对端发送RESET复位报文。

注意,内核默认的tcp_orphan_retries值为零,所以如果alive为零,即当前RTO值超出TCP_RTO_MAX,以下的if判断条件成立,将导致连接的立即断开。这种情况下,连接超时依赖于初始(第一次)RTO的值,如果其值为最小值TCP_RTO_MIN(200ms),那么在经过9次退避之后,RTO值将超过TCP_RTO_MAX。

    max_probes = sock_net(sk)->ipv4.sysctl_tcp_retries2;
    if (sock_flag(sk, SOCK_DEAD)) {
        const bool alive = inet_csk_rto_backoff(icsk, TCP_RTO_MAX) < TCP_RTO_MAX;

        max_probes = tcp_orphan_retries(sk, alive);
        if (!alive && icsk->icsk_backoff >= max_probes)
            goto abort;
        if (tcp_out_of_resources(sk, true))
            return;
    }

如果以上都没有成立,并且,当前的探测次数小于等于以上计算的最大探测次数(tcp_retries2或者Orphan套接口探测次数),调用tcp_send_probe0发送探测报文。否则,使用tcp_write_err终止连接。

    if (icsk->icsk_probes_out >= max_probes) {
abort:      tcp_write_err(sk);
    } else {
        /* Only send another probe if we didn't close things up. */
        tcp_send_probe0(sk);
    }
}

如下tcp_orphan_retries函数,如果alive为零,并且接收到ICMP报错报文(如ICMP_PARAMETERPROB、ICMP_DEST_UNREACH等),不再进行重传,将重传次数设置为零。否则,如果tcp_orphan_retries设置为零,并且alive为真,将重传次数设置为8,对于RTO最小值TCP_RTO_MIN(200ms)而言,经过8此退避之后的值将大于100秒(2**9 * 200 = 102.4秒),符合RFC1122中的规定。

static int tcp_orphan_retries(struct sock *sk, bool alive)
{
    int retries = sock_net(sk)->ipv4.sysctl_tcp_orphan_retries; /* May be zero. */

    /* We know from an ICMP that something is wrong. */
    if (sk->sk_err_soft && !alive)
        retries = 0;

    /* However, if socket sent something recently, select some safe
     * number of retries. 8 corresponds to >100 seconds with minimal
     * RTO of 200msec. */
    if (retries == 0 && alive)
        retries = 8;
    return retries;
}

发送窗口探测

调用tcp_write_wakeup函数时,套接口的发送队列中一定是有数据,不然没有必要进行窗口探测,如果队列中的首报文序号位于发送窗口范围内,表明一定数量的数据可发送(窗口不为零,可能由发送端的SWS预防导致)。此情况下将发送新数据作为探测报文,首先更新pushed_seq,之后发送的数据报文将设置TCPHDR_PSH控制位,对端接收到后应尽快将接收数据提交到应用,以便释放可用的接收空间,打开接收窗口。

int tcp_write_wakeup(struct sock *sk, int mib)
{
    struct tcp_sock *tp = tcp_sk(sk);

    skb = tcp_send_head(sk);
    if (skb && before(TCP_SKB_CB(skb)->seq, tcp_wnd_end(tp))) {
        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;

接下来,看一下允许发送的报文长度,如果发送窗口允许的发送长度小于发送队列中首报文的长度,或者首报文长度大于当前的发送MSS长度,将对首报文进行分片处理,得到一个长度为seg_size长度(不大于MSS)的报文,接下来将发送此报文。

如果以上两个条件都不成立,即可发送窗口允许发送首报文,并且首报文长度小于MSS,此情况下,如果首报文的tcp_gso_segs分段为零,使用函数tcp_set_skb_tso_segs设置GSO参数,由于此时报文长度小于等于mss,分段数量tcp_gso_segs将设置为1,接下来发送一个gso分段。但是,如果发送队列中的首报文由多个小报文分段组成,将发送多个小报文做探测。

注意,在上一种情况中,在tcp_fragment函数中调用了tcp_set_skb_tso_segs函数进行了gso相关设置。

        /* 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;

在发送队列中没有数据,或者对端接收窗口变为零时,以下尝试发送ACK探测报文,由函数tcp_xmit_probe_skb完成。如果紧急指针SND.UP包含在(SND.UNA, SND.UNA+64K)范围内,第二个参数urgent设置为1。

    } else {
        if (between(tp->snd_up, tp->snd_una + 1, tp->snd_una + 0xFFFF))
            tcp_xmit_probe_skb(sk, 1, mib);
        return tcp_xmit_probe_skb(sk, 0, mib);
    }

如下探测报文发送函数tcp_xmit_probe_skb,发送ACK报文,如果urgent不为真,ACK报文序号为SND.UNA减去1(ACK报文不占用新序号),由于此序号已经使用过,并且对端已经接收并确认,所以对端在接收到此重复序号的ACK报文之后,将丢弃此报文,并回复ACK报文通告正确的序号。

否则,如果紧急指针urgent为真,ACK报文序号为SND.UNA,对端并没有确认此序号,所以,对端可能将正常接收此ACK报文,并尽快进行Urgent数据的处理(前提是已经接收到了Urgent数据),释放接收缓存并打开接收窗口。

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

    /* 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);

在上一节介绍的函数tcp_probe_timer的最后,调用tcp_send_probe0函数发送探测报文,如果在发送探测报文之后,检测到用户层发送了新报文,或者套接口发送队列为空,不在需要进行探测,清空探测计数,清空退避计数。

/* A window probe timeout has occurred.  If window is not closed send
 * a partial packet else a zero probe.
 */
void tcp_send_probe0(struct sock *sk)
{
    struct inet_connection_sock *icsk = inet_csk(sk);
    struct tcp_sock *tp = tcp_sk(sk);

    err = tcp_write_wakeup(sk, LINUX_MIB_TCPWINPROBE);

    if (tp->packets_out || tcp_write_queue_empty(sk)) {
        /* Cancel probe timer, if it is not required. */
        icsk->icsk_probes_out = 0;
        icsk->icsk_backoff = 0;
        return;
    }

如果tcp_write_wakeup成功发送探测报文,增加探测计数,如果退避计数小于tcp_retries2中限定的值,增加退避计数(icsk_backoff)。

    if (err <= 0) {
        if (icsk->icsk_backoff < net->ipv4.sysctl_tcp_retries2)
            icsk->icsk_backoff++;
        icsk->icsk_probes_out++;
        probe_max = TCP_RTO_MAX;

否则,如果探测报文未能成功发送,不增加退避计数和探测计数,而是将探测定时器的超时时长限定在TCP_RESOURCE_PROBE_INTERVAL(500ms)内。以上探测报文发送成功时,此限定值为TCP_RTO_MAX(120秒)。再次启动探测定时器。

    } else {
        /* If packet was not sent due to local congestion,
         * do not backoff and do not remember icsk_probes_out.
         * Let local senders to fight for local resources.
         *
         * Use accumulated backoff yet.
         */
        if (!icsk->icsk_probes_out)
            icsk->icsk_probes_out = 1;
        probe_max = TCP_RESOURCE_PROBE_INTERVAL;
    }
    tcp_reset_xmit_timer(sk, ICSK_TIME_PROBE0,
                 tcp_probe0_when(sk, probe_max),
                 TCP_RTO_MAX,
                 NULL);

由以上的介绍可知,tcp_probe0_base取得的是连接的RTO时长(不低于200ms),以下tcp_probe0_when函数,执行退避操作设置探测超时时长。最长不超过限定值参数max_when。

/* Variant of inet_csk_rto_backoff() used for zero window probes */
static inline unsigned long tcp_probe0_when(const struct sock *sk, unsigned long max_when)
{
    u64 when = (u64)tcp_probe0_base(sk) << inet_csk(sk)->icsk_backoff;

    return (unsigned long)min_t(u64, when, max_when);
}

处理探测响应

如果对端回复了ACK报文,但是本端套接口发送队列无数据,直接返回不做处理。只有在发送窗口大小足以容纳发送队列的首个报文时,内核才会停止窗口探测定时器。否则,重设探测定时器超时时间,时长由上节介绍的tcp_probe0_when函数计算而得。

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);

    /* Was it a usable window open? */
    if (!head) return;

    if (!after(TCP_SKB_CB(head)->end_seq, tcp_wnd_end(tp))) {
        icsk->icsk_backoff = 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);
    
        tcp_reset_xmit_timer(sk, ICSK_TIME_PROBE0,
                     when, TCP_RTO_MAX, NULL);

内核版本 5.0

你可能感兴趣的:(TCPIP协议)