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网卡直连单向流的测试结果:
下面是测试中可能用到的一些简单命令:
# 接收端配置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。
下面是修改前后的吞吐对比:
提升了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的优化便难也不难,简也不简了。
页:
[1]