欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

TCP零窗口探测

程序员文章站 2022-07-01 20:31:52
...

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