彻底实现Linux TCP的Pacing发送逻辑-普通timer版_tcp pacing

又到了周末,过年前的倒数第2个周末,工作和生活上的压力早已卸载,自己也就有必要写点自己觉得感兴趣或者不公道的事情了。即便如此,白天我并不自由,不管是工作日还是周末,我必须在公司或者家里做一些例行的事情,白天无论如何我要去公司上班,不去的话要请假,下班我就要回家,要想彻夜不归,基本没的请假,就这样我一般把路上和晚上的时间当成一种享受,我比较喜欢住在离公司很远的地方,这样我在路上就可以有更多的时间研究古代罗马历史,至于晚上,我是那种随便睡两个小时就好的人。

        如果要我说怎么给TCP做优化,我的答案非同寻常,也许有点哗众取宠,曰,降速!
作恶的,必被剪除

        Linux的协议栈并不是仅仅指的是TCP,这一点必须要强调!UDP,IP,Netfilter,Bridge,Vlan等等,这些要比TCP重要得多,也好玩的多!说TCP是傻逼协议我不敢,但是我觉得我有资格对它的诸多实现评说一二。

        UDP,IP,Netfilter,Bridge,Vlan这些要比TCP重要得多,至少加在一起可以比TCP重要,但是程序员们太待见TCP了,恨不得跪添。视TCP为网络的全部,其实根本就不懂网络,嘴里念念有词,其实也就知道个词。我对这种现状非常气愤,但也只能气愤。

TCP毁了整个网络世界的和谐!

至少迄今为止大多数的TCP实现,根本就不是按照TCP协议最初的规范实现的。你无法重现《TCP/IP详解(卷一)》第20章“TCP的成块数据流”20.7节中的TCP自时钟场景(中文版P218-219),至少是很难重现。大多数人总觉得Linux的实现就是标准的实现,因为他们只见过Linux,TCP也并不例外,所以Linux TCP就是标准。然而这错了!

        简单举例,Linux连最基本的双向NAT都没有实现(我自己实现了一个,本寄希望于nftables,然而等了好久一场空!),它在路由和Neighbour以及Bridge实现中也有不如人意之处,你难道能指望Linux的TCP很完美吗?事实上,只是因为用的人多了,认同感就足了,然后就成了标准了吧。外面的世界大得很呐!

        Linux TCP实现的最大问题在于TCP数据发送的突发性,如果你计算出来的拥塞窗口是N字节,那么系统会尽可能一次性最多发送N-inflight字节大小的数据,在ACK到达不规律的情况下,这可能是一场悲剧。Linux实现的TCP太依赖ACK时钟了,无奈这个ACK时钟频率可能会变化,这种变化反过来会对数据的发送进行整形!我的意思是说,ACK到达发送端的时候,一般都是被整过形的,一个ACK会触发多个(可能是大量的)数据段的发送,这会影响负反馈的过程。

        网络上真实的情况是什么样子的?真实的样子是数据的pacing传输!我们以千兆铜线以太网为例,网卡的极限是一秒传输1000Mbit的数据,一个数据包的大小以1000字节算也就是8000bit,那么网卡在一秒钟内可以传输大约1000000000/8000即125000个数据包,熟悉铜线传输的一般都知道,数据是载波传输的,中间要有几个bit的隔离脉冲,所以说无论怎样,数据都不是背靠背发送的。

        仍以上述以太网为例,我们可以算出发送一个数据包所需要的主机延迟,即1/125000秒,几个微秒,这已经非常接近主机的C/Java函数延迟了,问题似乎并不严重。然而现实中,我们的带宽一般都是20-100Mbit/s,此时平均发送一个数据包的时延要几十微秒接近一个毫秒,然而事实上,主机依然是按照主机延迟发送数据的,即几个微秒就发送一个数据包,pacing的任务从网卡(此时可能你依然在使用1000Mbit网卡)转嫁到了更远的交换机/路由器或者其它设备。根源在于主机眼里只有主机,没有BDP!主机意识不到网络延迟和节点处理延迟,主机一厢情愿地以为数据包按照主机延迟到达目的地!

        所以,实现TCP的pacing发送势在必行。

        其实,社区也早就意识到了这个问题,因此在TCP层根据cwnd和rtt计算了一个pacing rate,其实就是根据BDP的公式计算而得的:

static void tcp_update_pacing_rate(struct sock *sk)
{
    const struct tcp_sock *tp = tcp_sk(sk);
    u64 rate;

    /* set sk_pacing_rate to 200 % of current rate (mss * cwnd / srtt) */
    rate = (u64)tp->mss_cache * ((USEC_PER_SEC / 100) << 3);

    /* current rate is (cwnd * mss) / srtt
     * In Slow Start [1], set sk_pacing_rate to 200 % the current rate.
     * In Congestion Avoidance phase, set it to 120 % the current rate.
     *
     * [1] : Normal Slow Start condition is (tp->snd_cwnd < tp->snd_ssthresh)
     *     If snd_cwnd >= (tp->snd_ssthresh / 2), we are approaching
     *     end of slow start and should slow down.
     */
    if (tp->snd_cwnd < tp->snd_ssthresh / 2)
        rate *= sysctl_tcp_pacing_ss_ratio;
    else
        rate *= sysctl_tcp_pacing_ca_ratio;

    rate *= max(tp->snd_cwnd, tp->packets_out);

    if (likely(tp->srtt_us))
        do_div(rate, tp->srtt_us);

    /* ACCESS_ONCE() is needed because sch_fq fetches sk_pacing_rate
     * without any lock. We want to make sure compiler wont store
     * intermediate values in this location.
     */
    ACCESS_ONCE(sk->sk_pacing_rate) = min_t(u64, rate,
                        sk->sk_max_pacing_rate);
}

然而这个pacing并无卵用。我们看下这个函数的注释:

/* Set the sk_pacing_rate to allow proper sizing of TSO packets.
 * Note: TCP stack does not yet implement pacing.
 * FQ packet scheduler can be used to implement cheap but effective
 * TCP pacing, to smooth the burst on large writes when packets
 * in flight is significantly lower than cwnd (or rwin)
 */


这个注释显然并不需要负任何责任。

        现在开始本文。

------------------------------------

首先,我必须要说的有三点:

1.TCP发送数据完全是突发形式发送,根本就无所谓pacing,这是典型的中国人风格;
2.后来有人在Qdisc层面实现了一个FQ,辅助TCP来完成pacing,但有点词不达意;
3.即便如此,底层Qdisc实现的FQ就是个垃圾!


至于为什么垃圾,为什么不尽人意,尽多的细节,请参见《
不同位置的tcptrace分析以及FQ如何减少TCP无效重传》以及《
在Wireshark的tcptrace图中看清TCP拥塞控制算法的细节(CUBIC/BBR算法为例)》一文的最后。

        这里,我要给出一个“划时代”的思路,有点简单,有点毫无技术含量,但足以抛砖引玉。

        在2.6.18版本内核运行的年代,有人提出了一个patch,该patch的LWN文章为《
TCP Pacing》,这篇文章作于2006年,那时我还没有毕业但已经参加了工作,我是弃学去工作的,那时我好像已经已经接触到了iptables,但还不懂TCP,所以也就无缘这篇文章。

        我是在很晚的时候才开始深入挖掘TCP细节的,因为我并不看好它,我甚至深深地抵触它,直到现在我依然在深深抵触它!XXX!然而,我觉得要想骂谁,首先要了解谁,我觉得我稍微有这个资格。

我一直都想把《
TCP Pacing》移植到现在的内核中(几个月前我将它移植到2.6.32内核了),而这是一件非常简单的事情,我的理由只有下面一个:

现在的内核不再需要你自己去计算pacing rate了,内核直接就支持pacing rate!因此,我不再需要那个令人恶心的tcp_pacing_recalc_delta函数了!一切更加简单了!

我的修改在于:

1.我不在针对每一个数据包的传输实施pacing,我只对正常的数据包实施pacing,这意味着重传数据包和纯ACK不会被pacing逻辑控制;
2.我想用Linux内核的hrtimer来作为pacing定时器,这样粒度更加精细,但是失败了;
3.即使使用hrtimer失败了,我依然可以使用timer_list,不同的是,我在计算时间和速度的时候,我使用u64而是u32,最终我只是将结果转成了u32的jiffies;
4.虽然使用hrtimer失败了(原因在于在hrtimer的function里做的事情太多了,造成了interrupt overflow...),我依然可以学着TSQ的样子把事情办成!


好了,代码看起来有点傻,但还是要展出来备忘,懂的人自然知道有多傻,不懂的人自然会觉得高大上。

1.增加数据结构的字段

include/linux/tcp.h里面的tcp_sock结构体里增加pacing支持:

struct {
    struct timer_list timer;
    u8      pacing;
    u64     next_to_send;
} pacing;

这里就不解释了。

2.修改tcp_write_xmit

我的思路很简单。

        所有TCP数据的正常发送,最终都要归结于tcp_write_xmit!当然,你可能认为是tcp_transmit_skb,然而我要求的是“更上层”的传输行为,毕竟,我不希望重传数据进行pacing整形,tcp_transmit_skb太底层了!

        对于TCP数据的正常发送(而非重传),有两个地方,第一个是顺着socket的write/send调用下来的tcp_write_xmit,这个路径会调用tcp_push_pending_frames,另一个是收到ACK后,在tcp_ack之后的“顺便发送”,这个路径会调用tcp_data_snd_check(内部还是调用tcp_push_pending_frames)。不管怎样,改tcp_write_xmit函数就对了,怎么改呢?超级简单(我基于4.9内核修改),基本就是FQ的逻辑(我并非说FQ的算法垃圾,而是它放错了地方):

while ((skb = tcp_send_head(sk))) {
    unsigned int limit;
    u64 now = ktime_get_ns();  // 我采集了u64粒度的时间戳

    tso_segs = tcp_init_tso_segs(skb, mss_now);
    ...

    if (unlikely(!tcp_snd_wnd_test(tp, skb, mss_now)))
        break;

    if (sysctl_tcp_pacing && tp->pacing.pacing == 1) { // 这里的if内是我添加的代码
        u32 plen;
        u64 rate, len;

        if (now < tp->pacing.next_to_send) {
            // 如果此时不允许发送,那就预约延后的某个时间发送
            tcp_pacing_reset_timer(sk, tp->pacing.next_to_send);
            break;
        }
        // 如果设定了确定的rate,那就按照这个rate发送,这个对固定速率需求的流特别好用。更好的做法是将其安装在socket里而不是一个全局的参数!
        // 然则如果安装在socket里,就必须在应用层用setsockopt来设置,需要修改应用程序,而如果是全局参数,则对应用完全透明。利弊由谁决定?!
        rate = sysctl_tcp_rate ? sysctl_tcp_rate:sk->sk_pacing_rate;
        rate = min(rate, sk->sk_pacing_rate);
        plen = skb->len + MAX_HEADER; // 倾倒在网络中的不光有TCP数据段,还有协议头!别光想着TCP!
        // 计算延后时间:时间=数据量/速度
        len = (u64)plen * NSEC_PER_SEC;
        if (rate)
            do_div(len, rate);
        // 设置下一次发送的时间为:当前时间+发送当前数据的时间
        tp->pacing.next_to_send = now + len;
    }

if (tso_segs == 1) {
...

以上代码非常简单!关于发送数据,我指的是正常的发送数据,就是以下一点:

尽情发送,直到速率配额已经完成,然后预约在今后可以发送的时刻继续发送!

3.在net/ipv4/tcp_timer.c中增加pacing timer的init,reset,handler函数

首先看下init过程:

void tcp_init_xmit_timers(struct sock *sk)
{
        inet_csk_init_xmit_timers(sk, &tcp_write_timer, &tcp_delack_timer,
                                  &tcp_keepalive_timer);
        init_timer(&(tcp_sk(sk)->pacing.timer));
        tcp_sk(sk)->pacing.timer.function = &tcp_pacing_timer;
        tcp_sk(sk)->pacing.timer.data = (unsigned long) sk;
}

再看reset过程:

void tcp_pacing_reset_timer(struct sock *sk, u64 expires)
{
        struct tcp_sock *tp = tcp_sk(sk);
        u32 timeout = nsecs_to_jiffies(expires);

        if(!sysctl_tcp_pacing || !tp->pacing.pacing)
                return;

        if (!mod_timer(&tp->pacing.timer, timeout))
                sock_hold(sk);
}

实质上的过程,即timer的回调函数:

static void tcp_pacing_timer(unsigned long data)
{               
        struct sock *sk = (struct sock*)data;
        struct tcp_sock *tp = tcp_sk(sk);

        if(!sysctl_tcp_pacing || !tp->pacing.pacing)
                return;

        bh_lock_sock(sk);
        if (sock_owned_by_user(sk)) {
                // 此时socket被用户态进程占据,比如正在recv,poll等,那么就将handler过程委托给这类用户态进程,好在用户态进程在释放socket时可以有release回调可调用!
                if (!test_and_set_bit(TCP_PACING_TIMER_DEFERRED, &tcp_sk(sk)->tsq_flags))
                        sock_hold(sk);
                goto out_unlock;
        }

        if (sk->sk_state == TCP_CLOSE)
                goto out;

        if(!sk->sk_send_head){
                goto out;
        }

        tcp_push_pending_frames(sk);

out:
        if (tcp_memory_pressure)
                sk_mem_reclaim(sk);

out_unlock:
        bh_unlock_sock(sk);
        sock_put(sk);
}

在用户态进程释放socket的时候,调用tcp_release_cb:

if (flags & (1UL << TCP_PACING_TIMER_DEFERRED)) {
    if(sk->sk_send_head)
        tcp_push_pending_frames(sk);
    __sock_put(sk);
}

------------------------------------

罢了,这就罢了。关于hrtimer实现的版本,效果更好,温州老板为何不先试试然后提供给我一些代码呢?给点提示,那就是不要在hrtimer的function里去做那些什么tcp_write_xmit操作,而只是在里面去schedule一个softirq或者一个tasklet即可,就跟TSQ一样。

        看看效果吧。我为了不引起质疑,我隐藏了刻度。实际上我根本就没有时间去解释什么没有意义的质疑:

彻底实现Linux TCP的Pacing发送逻辑-普通timer版_tcp pacing

左边是pacing的效果,右边是非pacing的效果,统一使用cubic算法。这个pacing对于额定速率的流媒体传输非常有用!

------------------------------------

这个版本大致可以实现pacing的效果,然而却不够,实际上的效果好坏取决于计算的精准度。以HZ250为例,每隔4毫秒会有一个时钟中断,如果用普通timer的话,RTT在4毫秒以内的pacing将会工作不正常,所以我采用了NS来计算,最终再还原到jiffies,但这仍然不够(不过对于长肥网络这个版本就够了)。所以我必须实现基于hrtimer的高精度版本。

原文链接: https://blog.csdn.net/dog250/article/details/54424629

欢迎关注

微信关注下方公众号,第一时间获取干货硬货;公众号内回复【pdf】免费获取数百本计算机经典书籍;

也有高质量的技术群,里面有嵌入式、搜广推等BAT大佬

    彻底实现Linux TCP的Pacing发送逻辑-普通timer版_tcp pacing

原创文章受到原创版权保护。转载请注明出处:https://www.ccppcoding.com/archives/407636

非原创文章文中已经注明原地址,如有侵权,联系删除

关注公众号【高性能架构探索】,第一时间获取最新文章

转载文章受原作者版权保护。转载请注明原作者出处!

(0)
上一篇 2023年4月26日 上午10:37
下一篇 2023年4月26日 上午10:37

相关推荐