UDP_CORK,TCP_CORK以及TCP_NODELAY

这三个选项十分有意思,并且困扰了很多人。特别是cork选项,它到底和nodelay有什么区别,到底怎样影响了Nagle算法。在tcp的实现中(特指linux内核的协议栈实现),cork和nodelay非常让人看不出区别,这一块的实现非常复杂,看内核实现之前最好先明白它们大概在说什么,否则很容易迷失的。
     所谓的cork就是塞子的意思,形象地理解就是用cork将连接塞住,使得数据先不发出去,等到拔去塞子后再发出去,而nodelay事实上是为了禁用Nagle算法,Nagle算法为了增加了网络的吞吐量而牺牲了响应时间体验,这在有些应用中是不合适的,比如交互式应用(终端登录或者远程X应用 etc.),因此有必要提供一个选项将它禁用掉,Nagle算法在RFC1122中有提及,它的实现实际上很简单,利用了tcp本身的一些特性,在算法描述中,关键的一点是“什么时候真实的发送数据”,这个问题的解答也是很简单,原则上只要发出的包都被对端ack了就可以发送了,这实际上也是一种权衡,Nagle算法最初的目的在于解决大量小包存在于网络从而造成网络拥塞的问题(一个小包可能只有几个字节,比如ls,cat等等,然而为每个小包封装几个协议头,其大小就不可忽视了,大量此类小包存在于网络势必会使得网络带宽的利用率大大下降),如果包被ack了,说明包已经离开了网络进入了对端主机,这样就可以发送数据了,此时无需再等,有多少数据发送多少(当然要考虑窗口大小和MTU),如果很极端地等待更多的数据,那么响应度会更低,换句话简单的说Nagle算法只允许一个未被ack的包存在于网络,它并不管包的大小,因此它事实上就是一个扩展的停-等协议,只不过它是基于包停-等的,而不是基于字节停-等的。
     可以看出,Nagle算法完全由tcp协议的ack机制决定,这会带来一些问题,比如如果对端ack回复很快的话,Nagle事实上不会拼接太多的数据包,虽然避免了网络拥塞,网络总体的利用率依然很低,Nagle真的做到了“只做好一件事”的原则,然而有没有另外一种算法,可以提高整体网络利用率呢?也就是说尽量以不能再多的数据发送,这里之所以说是尽量还是权衡导致的,某时可以发送数据的时候将会发送数据,即使当前数据再小也不再等待后续的可能拼接成更大包的数据的到来。
     实际上,这样的需求可以用TCP_CORK来实现,但是实现得可能并不像你想象的那么完美,cork并不会将连接完全塞住。内核其实并不知道应用层到底什么时候会发送第二批数据用于和第一批数据拼接以达到MTU的大小,因此内核会给出一个时间限制,在该时间内没有拼接成一个大包(努力接近MTU)的话,内核就会无条件发送,这里给出的只是一个大致的思想,真实的情况还要受到窗口大小以及拥塞情况的影响,因此tcp“何时发送数据”这个问题非常复杂。
     Nagle算法和CORK算法非常类似,但是它们的着眼点不一样,Nagle算法主要避免网络因为太多的小包(协议头的比例非常之大)而拥塞,而CORK算法则是为了提高网络的利用率,使得总体上协议头占用的比例尽可能的小。如此看来这二者在避免发送小包上是一致的,在用户控制的层面上,Nagle算法完全不受用户socket的控制,你只能简单的设置TCP_NODELAY而禁用它,CORK算法同样也是通过设置或者清除TCP_cork使能或者禁用之,然而Nagle算法关心的是网络拥塞问题,只要所有的ack回来则发包,而CORK算法却可以关心内容,在前后数据包发送间隔很短的前提下(很重要,否则内核会帮你将分散的包发出),即使你是分散发送多个小数据包,你也可以通过使能CORK算法将这些内容拼接在一个包内,如果此时用Nagle算法的话,则可能做不到这一点。
     接下来看一下内核代码,然后给出一个测试程序来感性感受这些选项。tcp的发送函数是tcp_sendmsg,这个函数中存在一个大循环,用于将用户数据置入skb中,它的形式如下:
int tcp_sendmsg(struct kiocb *iocb, struct sock *sk, struct msghdr *msg,
        size_t size)
{

    while (--iovlen >= 0) {
        0.更新数据结构元数据;
        while (seglen > 0) {
            int copy;
            skb = sk->sk_write_queue.prev;
            1.如果既有skb的长度过大或者根本还没有一个skb则分配一个skb;
            2.将数据拷贝到既有的skb或者新的skb中;
            3.更新skb和用户数据的元数据;
            //如果数据还没有达到mss,则继续,换句话就是如果数据已经达到mss了就接着往下走来权衡是否马上发送。
            if (skb->len != mss_now || (flags & MSG_OOB))
                continue;
            4.权衡发送与否
            continue;
        }
    }
out:
    //如果循环完成,所有数据都进入了skb,调用tcp_push来权衡是否发送
    tcp_push(sk, tp, flags, mss_now, tp->nonagle);
}
tcp_push很短但是很复杂,
static inline void tcp_push(struct sock *sk, struct tcp_opt *tp, int flags,
                int mss_now, int nonagle)
{
    if (sk->sk_send_head) {
        struct sk_buff *skb = sk->sk_write_queue.prev;
        ...
        //如果有MSG_MORE,则当作cork来处理
        __tcp_push_pending_frames(sk, tp, mss_now,
                      (flags & MSG_MORE) ? TCP_NAGLE_CORK : nonagle);
    }
}
static __inline__ void __tcp_push_pending_frames(struct sock *sk,
                         struct tcp_opt *tp,
                         unsigned cur_mss,
                         int nonagle)
{
    struct sk_buff *skb = sk->sk_send_head;
    if (skb) {
        if (!tcp_skb_is_last(sk, skb)) //如果已经有了很多的skb,则尽量马上发送
            nonagle = TCP_NAGLE_PUSH;
        //只有tcp_snd_test返回1才会发送数据,该函数很复杂
        if (!tcp_snd_test(tp, skb, cur_mss, nonagle) ||
            tcp_write_xmit(sk, nonagle))
            tcp_check_probe_timer(sk, tp);
    }
    tcp_cwnd_validate(sk, tp);
}
static __inline__ int tcp_snd_test(struct tcp_opt *tp, struct sk_buff *skb,
                   unsigned cur_mss, int nonagle)
{
//如果有TCP_NAGLE_PUSH标志(或者tcp_nagle_check同意发送)且未ack的数据够少且...则可以发送
    return (((nonagle&TCP_NAGLE_PUSH) || tp->urg_mode
         || !tcp_nagle_check(tp, skb, cur_mss, nonagle)) &&
        ((tcp_packets_in_flight(tp) < tp->snd_cwnd) ||
         (TCP_SKB_CB(skb)->flags & TCPCB_FLAG_FIN)) &&
        !after(TCP_SKB_CB(skb)->end_seq, tp->snd_una + tp->snd_wnd));
}
tcp_nagle_check函数是一个很重要的函数,它基本决定了数据是否可以发送的80%,内核源码中对该函数有一条注释:
-3. Or TCP_NODELAY was set.
-4. Or TCP_CORK is not set, and all sent packets are ACKed.
就是说如果TCP_NODELAY值为1就可以直接发送,或者cork被禁用的情况下所有发出的包都被ack了也可以发送数据,这里体现的就是Nagle算法和CORK算法的区别了,Nagle算法只要求所有的出发包都ack就可以发送,而不管当前包是否足够大(虽然它通过tcp_minshall_check保证了包不太小),而如果启用cork的话,可能仅仅数据被ack就不够了,这就是为何在代码注释中说cork要比Nagle更stronger的原因,同时这段代码也说明了为何TCP_CORK和TCP_NODELAY不能一起使用的原因,它们有共同的东西,却在做着不同的事情。看看tcp_nagle_check:
static __inline__ int
tcp_nagle_check(struct tcp_opt *tp, struct sk_buff *skb, unsigned mss_now, int nonagle)
{
    return (skb->len < mss_now &&
        !(TCP_SKB_CB(skb)->flags & TCPCB_FLAG_FIN) &&
        ((nonagle&TCP_NAGLE_CORK) ||
         (!nonagle &&
          tp->packets_out &&
          tcp_minshall_check(tp))));
}
看看__tcp_push_pending_frames的最后,有一个tcp_check_probe_timer调用,就是说在没有数据被发送的时候会调用这个函数。这个函数有两个作用,第一个是防止0窗口导致的死锁,另一个作用就是定时发送由于使能了CORK算法或者Nagle算法一直等待新数据拼接而没有机会发送的数据包。这个timer内置在重传timer之中,其时间间隔和rtt有关,一旦触发则会发送数据包或者窗口探测包。反过来可以理解,如果没有这个timer的话,启用cork的连接将几乎(可能根据实现的不同还会受别的因素影响,太复杂了)每次都发送mtu大小的数据包。该timer调用tcp_probe_timer函数:
static void tcp_probe_timer(struct sock *sk)
{
    struct tcp_opt *tp = tcp_sk(sk);
    int max_probes;
    //1.如果有数据在网络上,则期望马上回来ack,ack中会通告对端窗口
    //2.如果没有数据要发送,则无需关注对端窗口,即使为0也无所谓
    if (tp->packets_out || !sk->sk_send_head) {
        tp->probes_out = 0;
        return;
    }
    //这个sysctl_tcp_retries2是可以调整的
    max_probes = sysctl_tcp_retries2;
    if (tp->probes_out > max_probes) {
        tcp_write_err(sk);
    } else {
        tcp_send_probe0(sk);
    }
}
tcp_send_probe0会调用tcp_write_wakeup函数,该函数会要么发送可以发送的数据,如果由于发送队列越过了发送窗口导致不能发送,则发送一个窗口探测包:
int tcp_write_wakeup(struct sock *sk)
{
    if (sk->sk_state != TCP_CLOSE) {
        struct tcp_opt *tp = tcp_sk(sk);
        struct sk_buff *skb;
        if ((skb = sk->sk_send_head) != NULL &&
            before(TCP_SKB_CB(skb)->seq, tp->snd_una+tp->snd_wnd)) {
            ...//在sk_send_head队列上取出一个发送出去,其ack会带回对端通告窗口的大小
            err = tcp_transmit_skb(sk, skb_clone(skb, GFP_ATOMIC));
            ...
            return err;
        } else {
            ...
            return tcp_xmit_probe_skb(sk, 0);
        }
    }
    return -1;
}
这个probe timer虽然一定程度阻碍了cork的满载发送,然而它却是必要的,这是由于tcp并不为纯的ack包(不带数据的ack包)提供确认,因此一旦这种ack包丢失,那么就有可能死锁,发送端的窗口无法更新,接收端由于已经发送了ack而等待接收数据,两端就这样僵持起来,因此需要一个timer,定期发送一个探测包,一个ack丢失,不能所有的ack都丢失吧,在timer到期时,如果本来发送队列上有数据要发送,则直接发送这些数据而不再发送探测包,因为发送了这些数据,所以它“破坏”了cork的承诺,不过也因此增强了响应度。
     在示出应用程序之前,总结一下内核在哪里会发送tcp包,在解释在哪里会发送tcp包之前,首先说明内核协议栈为了高效和低耦合设计,tcp_sendmsg并不一定真实发送数据,真实发送数据的地点在:
1.tcp_sendmsg内部(废话!),如果权衡的结果需要发送则发送;
2.收到对端ack的时候会调用tcp_data_snd_check来发送,它同样完全按照cork策略来的;
3.probe timer到期后作为窗口探测包发送一些数据,它“破坏”了cork,在塞子上捅破一个口子;
4.连接断开或者进程退出时可能会将所有数据刷到对端;
5.当禁用cork或者启用nodelay的时候会将pending的数据刷入对端。
下面看一下应用层的测试程序:
客户端程序:client
#define    BUFF_SIZE    500        
#define    REMOTE_PORT  6800     
signed int len = 0;               
int main(int argc, char *argv[])
{
    int sock;
    struct sockaddr_in remote_addr;
    int on = 1;
    unsigned char buff[BUFF_SIZE];
    int i;
    if (argc != 5) {
        printf("usage: client server_ip on|off cork|nodelay usec/n");
        exit(-1);
    }
    int msd = atoi(argv[4]);
    if (!strcmp(argv[2], "on"))
        on = 1;
    else if (!strcmp(argv[2], "off"))
        on = 0;
    for (i = 0; i < BUFF_SIZE; i++) {
        buff[i] = 'q';   
    }
    sock = socket(AF_INET, SOCK_STREAM, 0);
    if (!strcmp(argv[3], "nodelay")) {
        setsockopt(sock, SOL_TCP, TCP_NODELAY, &dontroute, sizeof(dontroute));
        } else if (!strcmp(argv[3], "cork")) {
        setsockopt(sock, SOL_TCP, TCP_CORK, &dontroute, sizeof(dontroute));
    }
        struct sockaddr_in sa;
    memset (&sa, '/0', sizeof(sa));
        sa.sin_family      = AF_INET;
        sa.sin_addr.s_addr = inet_addr (argv[1]);  
        sa.sin_port        = htons(REMOTE_PORT);        
        connect(sock, (struct sockaddr*) &sa, sizeof(sa));
           while(1) {
        len = send(sock, buff, BUFF_SIZE,  MSG_MORE);
        if (len < 0)
            exit(-1);
               usleep(msd);
    }
    return (0);
}
服务器程序:server
int  main (int argc, char **argv)
{
    int err;
    int listen_sd;
    int sd;
    struct sockaddr_in sa_serv;
    struct sockaddr_in sa_cli;
    size_t client_len;
    char*    str;
    char     buf [500];
    listen_sd = socket (AF_INET, SOCK_STREAM, 0); 
    memset (&sa_serv, '/0', sizeof(sa_serv));
    sa_serv.sin_family      = AF_INET;
    sa_serv.sin_addr.s_addr = INADDR_ANY;
    sa_serv.sin_port        = htons (6800);         
    err = bind(listen_sd, (struct sockaddr*) &sa_serv, sizeof (sa_serv));                 
    err = listen (listen_sd, 5);                  
    client_len = sizeof(sa_cli);
    while (1) {
        sd = accept (listen_sd, (struct sockaddr*) &sa_cli, &client_len);
        while (1) {
            err = read(sd, buf, sizeof(buf));            
            if (err <= 0)
                break;
        }
    }
    close (sd);
}
运行之:
client 192.168.x.y on cork 66000
在我的机器上,第四个参数最大到66000时cork会满载发送,如果usleep的时间再长一些,probe timer就是“帮忙”发送数据了,给你的感觉是,启用了cork为何看起来没有什么用。这个时间在不同环境在有所不同,因为probe timer导致了cork的破坏,而probe timer和rtt有关,rtt又和网络环境有关...再进行一个测试,执行下列命令:sysctl -w  net.ipv4.tcp_retries2=-1
然后以比较高的时间间隔以及比较小的BUFF_SIZE在开启cork情况下运行client程序,我们发现第一个包还没发完进程就会退出,这是由于cork尽力在组包,间隔过大导致probe timer过期,然后tp->probes_out > max_probes判断通过,导致超时退出,这个可以从/proc/net/netstat中的超时计数器中看出来,如果间隔比较短,每次新的数据pending到既有的skb上而不发送,重置probe timer,使得timer总是不过期,终于pending的数据到达了mtu的大小,cork的满载发送起作用进而发送之。
     还有一个概念是“糊涂窗口”,那就是接收端接收缓慢并不断确认,导致窗口一直很小,而发送端收到ack就再次发送小包,这样导致一直发送-确认很小的包...这个是可以通过应用层编程来避免的,另外也可以通过cork算法或者Nagle算法来减轻,但是无论怎样都逃不过一些timer自动帮你发送数据。
     最后,好像遗漏了UDP_CORK,很简单,udp没有连接,没有确认,因此也就不需要什么timer之类的复杂机制,也因此,它是真正承诺的cork,除非你在应用层手工拔掉塞子,否则数据将不会发出。

你可能感兴趣的:(timer,算法,tcp,struct,网络,linux内核)