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);}// 進程讀寫TCPint 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 INPUT ACK/Data比 發送端軟中斷比例 帶寬
開LRO delayack N/A 開TSO N/A 1:100 1 23.5Gbps
關LRO quickack N/A 關TSO N/A 1:1 100 5Gbps
關LRO quickack 丟50%pureACK 開TSO N/A 1:2 50 6.5Gbps
關LRO quickack 丟75%pureACK 關TSO N/A 1:4 25 7.5Gbps
關LRO quickack N/A 開TSO 丟50%pureACK 1:4 100 5.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 帶寬
修改前 關LRO quickack(通過iproute2設置) 5Gbps
修改後 關LRO quickack(通過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的優化便難也不難,簡也不簡瞭。

浙江溫州皮鞋濕,下雨進水不會胖。