Linux TCP吞吐性能缺陷

TCP滑动窗口的停-等限制了吞吐适配带宽,这是协议层面上的缺陷,除非重构TCP协议本身,任何实现都于事无补。

Linux内核协议栈实现的TCP(简称Linux TCP)是实际部署最多的TCP实现,遗憾的是,抛开协议本身的缺陷,Linux TCP还有自身实现的缺陷,实现层面的缺陷更直观可见。

至于其它家操作系统的协议栈实现,我没有亲见,不便多谈。

TCP是一个全双工协议,真的吗?可同时发送和接收数据吗?至少,数据发送和确认可同时进行吗?
对于Linux TCP,答案都是不能。因此必然会损耗吞吐性能。这背后有不可调和的矛盾:

  • 进程希望发送和接收被同一个CPU处理,因此必须串行。
  • 全双工要求不同CPU处理发送和接收,因此才能并行。

Linux TCP通过Socket API来操作,有个问题需要回答:

  • Socket API到底适不适合作为TCP的操作界面?

我认为是不适合的。

Socket API是操作系统进程抽象的VFS接口,它是一个文件描述符。文件是内容的载体,内容的读写需要同步互斥看起来理所当然。然而TCP并不能看作一个合情理的文件,它是两个管道,用一个文件抽象代表一个双向独立的两个管道,显然就有问题了。

离开形而上,来看下实现。

Linux TCP socket系统调用对于send和receive是互斥的:

int tcp_sendmsg(struct sock *sk, struct msghdr *msg, size_t size)
{
        int ret;

        lock_sock(sk);
        ret = tcp_sendmsg_locked(sk, msg, size);
        release_sock(sk);

        return ret;
}
int tcp_recvmsg(struct sock *sk, struct msghdr *msg, size_t len, int nonblock,
                int flags, int *addr_len)
{
        ...
        lock_sock(sk);
        ret = tcp_recvmsg_locked(sk, msg, len, nonblock, flags, &tss,
                                 &cmsg_flags);
        release_sock(sk);
        ...
}

由于需要lock socket,Linux TCP无法同时读写,退化成了半双工。协议层面TCP是全双工的,Linux TCP实现成了半双工。

除了Socket显式读写API层面被半双工化,TCP协议核心也被半双工化。Linux TCP用软中断处理数据接收以及ACK,在tcp_v4_rcv中,软中断处理不会和Socket读写并行:

// softirq接收例程
void softirq_recv(struct skb *skb, struct sock *sk)
{
	spin_lock(sk->lock1);
	if (owner == false) {
		tcp_recv_data(skb);
		tcp_process_ack(skb);
		tcp_write_xmit(sk);
	} else {
		add_backlog(skb, sk);
	}
	spin_unlock(sk->lock1);
}
// 进程读写TCP
int send/recv(struct sock *sk, char *buff)
{
	spin_lock(sk->lock1);
	sk->owner = true;
	spin_unlock(sk->lock1);
	process_data_send/recv(sk, buff);
	spin_lock(sk->lock1);
	process_backlog(sk);
	sk->owner = false;
	spin_unlock(sk->lock1);
}
// 在进程退出读写前处理softirq pending的事务
void process_backlog(struct sock *sk)
{
	for_each_skb(sk->backlog) {
		tcp_recv_data(skb);
		tcp_process_ack(skb);
		tcp_write_xmit(sk);
	}
}

因此TCP ACK处理,拥塞控制,反馈激励发包等TCP拥塞状态机核心因此被串行化:

  • 在tcp_ack完成后,tcp_write_xmit才可发包。

综上,Linux TCP有以下互斥关系:

  • socket发数据和socket收数据互斥。
  • 软中断处理和反馈激励发包互斥。
  • 软中断处理和socket收数据互斥。
  • 软中断处理和socket发数据互斥。

上述互斥关系影响接收性能。在另一端,取决于实现,pureACK频率将会对Linux TCP发送端产生影响。

总结25Gbps网卡直连单向流的测试结果:

接收端Offload接收端quickack接收端iptables OUTPUT发送端Offload发送端iptables INPUTACK/Data比发送端软中断比例带宽
开LROdelayackN/A开TSON/A1:100123.5Gbps
关LROquickackN/A关TSON/A1:11005Gbps
关LROquickack丢50%pureACK开TSON/A1:2506.5Gbps
关LROquickack丢75%pureACK关TSON/A1:4257.5Gbps
关LROquickackN/A开TSO丢50%pureACK1:41005.2Gbps

下面是测试中可能用到的一些简单命令:

# 接收端配置QUICKACK,x.x.x.x为数据发送端地址,y.y.y.y可通过ip route get x.x.x.x/32获取
ip route add x.x.x.x/32 via y.y.y.y quickack 1

# 接收端每2个pureACK丢1个的配置
iptables -A OUTPUT -d $sender -p tcp -m length  --length 52 --sport 5001 -m statistic --mode nth --every 2 --packet 0  -j DROP
# 后面每pending一条,意味着允许通过的pureACK数量为(1/2)^n,n为iptables规则总条目数

# pureACK/Data比通过bpftrace k:tcp_ack,k:__tcp_transmit_skb计数观察,也可以通过下面的命令:
ssar -n DEV 1

分析上表,有以下结论:

  • pureACK数量对吞吐影响可观测,成比例。
  • Linux TCP跑满25Gbps得益于LRO,LRO减少了pureACK总量。
  • 发送端丢pureACK对吞吐无影响,软中断影响超过ACK处理影响。
  • 发送端TSO对吞吐影响不大。

大量pureACK导致软中断增加是吞吐下降原因,热点就是软中断和xmit串行化。以上结论有下列推论:

  • 10ms+级别RTT环境,软中断中ACK/xmit串行化影响和RTT相比可忽略,在广域网传输环境,Linux TCP缺陷影响并不显著。(因此无人在意)
  • 10us级别RTT环境,Linux TCP缺陷无人关注的原因在于该环境下Linux TCP早被诟病,因此普遍采用用户态协议栈。
  • Linux TCP长期将RTO_MIN定为HZ/5,DELAY_ACK定为HZ/25,说明Linux TCP的典型适用场景不是IDC超短肥环境。
  • 作为接收端,MacOS的ACK/Data比几乎1:1,若未刻意调优,安卓手机大概率要比iPhone表现良好。
  • 在IDC场景,由于Linux TCP的DelayACK大于HZ/25,此量约RTT百倍,大大减少了pureACK数量,以至于可忽略ACK/xmit串行处理影响,这让Linux TCP实际表现还不错。

Linux TCP饱受诟病,但核心原因大多数人并未认识到。核心原因就是串行化处理,无论是收发串行化还是ACK/Data串行化,均会伤害TCP吞吐,对于典型的单向传输,ACK/Data串行化带来的伤害更是无以复加。串行化处理破坏了TCP ACK时钟的平滑流逝,这种破坏在下面场景下伤害尤甚:

  • WiFi场景下典型的ACK聚集,大量到来的ACK确认大量的Data,导致ACK时钟节奏抖动。
  • pureACK丢失导致ACK时钟刻度空缺,ACK处理和反馈激励发送全局同步,时钟节奏受损。

以全双工的视角,正确的做法,将收和发两个方向独立处理,仅操作两方共享数据时将操作原子化,典型的共享数据包括不限于:

  • 拥塞窗口。拥塞控制算法写,发送及重传流程读,拥塞窗口可作为发送及重传流程的令牌因子。
  • 通告窗口。ACK处理流程写,发送流程读,通告窗口可作为发送流程的令牌因子。
  • 状态机统计信息。诸如inflight,sack数量,lost数量,retrans数量。
  • 连接统计信息。tcp_info结构体。

以进程的视角,全双工视角下正确的做法恰好是错误的。进程倾向于同一个CPU处理发送和接收数据,数据的起点和终点均为进程,此举可最优化cache利用。

全双工和进程是两个视角,这两个视角之间的矛盾是协议栈实现的根本难题,以至于QUIC依然存在这个问题:

  • QUIC的诸实现,单个Nginx worker,要么收,要么发。

DelayACK可大大减少pureACK的数量,直接降低了发送端CPU利用率,节省的CPU可发送更多数据包。关掉DelayACK是不明智的,除非确信DelayACK和发送端Nagle之间有副作用。上述分析可见,QUICKACK将大大降低吞吐。

我曾经想增加永久sysctl配置永久禁用DelayACK,后来作罢。

有破有立。和同事闲聊,跃跃欲试想分离Linux TCP的收发,至少分离ACK和xmit,但了解到需要重构整个socket层时,就放弃了。

前段时间埃里克的一个patch似乎在这件事上做了一个引子:
https://git.kernel.org/pub/scm/linux/kernel/git/netdev/net-next.git/commit/?id=6fcc06205c15bf1bb90896efdf5967028c154aba

埃里克认为,一旦有进程在读写socket,软中断流程将不得不把skb放入backlog,由进程在release socket前处理backlog中pending的skb。进程处理backlog的过程中,将Data复制到buffer后,kfree_skb将是一个耗时操作,而此时进程尚占有socket,到来的软中断流程会将越来越多的skb放入backlog而不能直接处理,导致额外延时。

由于ACK和xmit是串行的,其中任一环节的耗时操作都是一种HoL阻塞,将这些操作从锁定区域拿出来就是了。

埃里克通过将skb挂在一个list上取代直接free的做法解决了这个问题。free操作将在进程放开socket后进行,or直接在软中断的spinlock临界区之外进行,此举大大提高了吞吐,给埃里克点赞。(不过更好的做法是单独处理,比如单独在一个上下文处理free)

但未竟全功。

埃里克的patch只优化了接收端,对于发送端处理ACK时的行为,也有一个耗时的kfree_skb,即tcp_clean_rtx_queue函数中清理重传队列后的free操作:

static int tcp_clean_rtx_queue(struct sock *sk, u32 prior_fack,
                               u32 prior_snd_una,
                               struct tcp_sacktag_state *sack)
{
        ...
        for (skb = skb_rb_first(&sk->tcp_rtx_queue); skb; skb = next) {
                ...
                tcp_rtx_queue_unlink_and_free(skb, sk);
        }
        ...

这个case简单,我的改法如下:

  • 将tcp_rtx_queue_unlink_and_free其中的kfree_skb换成add_list。
  • 在tcp_v4_rcv中添加刷新list的骚操作:
        } else {
                if (tcp_add_backlog(sk, skb)) 
                        goto discard_and_relse;
                // 软中断路径中,list中超过100个skb才会批量free
                // 然而在进程上下文,批量free阈值会更大,比如2000个skb才free
                sk_defer_rtx_free_flush(sk);        
        }
    
  • 在所有socket系统调用release_sock之后增加sk_defer_rtx_free_flush。

下面是修改前后的吞吐对比:

接收端Offload接收端quickack带宽
修改前关LROquickack(通过iproute2设置)5Gbps
修改后关LROquickack(通过iproute2设置)6.7Gbps

提升了1Gbps~2Gbps,但依然没有质的提升。埃里克的patch已经我后续的补充诚然有效,但依然属于case by case的见招拆招解法,于本质缺陷无补,但我希望这只是热身,即便继承Linux TCP的实现框架,当耗时操作一点一点拆出来之后,Linux TCP也就趋于极致了。

软中断处理中以ACK作为拥塞控制算法和拥塞状态机的输入,将cwnd作为令牌输出给xmit逻辑,将scoreboard输出给传输队列,才是正确的实现:
在这里插入图片描述
但Linux TCP当前的代码逻辑,离这个架构非常遥远(socket接口都不能再用了)。

虽遥远,但非难为。

Linux UDP Socket并没有保持文件语义,Linux UDP仅存在下列互斥:

  • Socket写与Socket写互斥。
  • Socket读在reader_queue上互斥。
  • Softirq在sk_receive_queue上互斥。

曾经Linux UDP并没有reader_queue,仅有sk_receive_queue,这样Socket读和Softirq就不得不互斥,但最终这个互斥通过增加reader_queue被解除了,这是一个典型的拆锁优化思路。于是,如果基于UDP实现一个类TCP协议,反而更容易实现全双工。与此同时,也可以看到,Linux TCP之所以实现成这个样子,背后的缘由并没有多深邃。
Linux TCP实现成这个样子,最初完全因为简单。最初它可以运行,进化到现在它的框架便无法大变。进化的本质目标是生存,而非寻求最优解。如此考虑,Linux TCP的优化便难也不难,简也不简了。

浙江温州皮鞋湿,下雨进水不会胖。

  • 11
    点赞
  • 40
    收藏
    觉得还不错? 一键收藏
  • 7
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值