Linux 上 TCP QUICKACK 的效果
先前在《Nagle’s Algorithm 和 Delayed ACK 以及 Minshall 的加強版》提過應用層連續送小封包會造成意外的延遲,是因為 Nagle’s Algorithm 和 Delayed ACK 合用衝突的結果。除了用 TCP_NODELAY 關掉 Nagle’s Algorithm 外,有其它的解法嗎?
Nagle’s Algorithm 的作者 John Nagle 在 hackernews 上提到: 若你需要避免延遲,不該用 TCP_NODELAY 關掉 Nagle’s Algorithm,那會造成大量額外的傳輸量,應該要用 TCP_QUICKACK 避免 delayed ACK。於是就研究了一下 TCP_QUICKACK 的效果。
這裡先說結論: TCP_QUICKACK 可以稍微縮短 TCP 建立連線的時間,但整體來說沒有幫助。以下是細節分析。
什麼是 TCP_QUICKACK?
摘錄 man 7 tcp 的說明:
TCP_QUICKACK (since Linux 2.4.4)
Enable quickack mode if set or disable quickack mode if
cleared. In quickack mode, acks are sent immediately, rather
than delayed if needed in accordance to normal TCP operation.
This flag is not permanent, it only enables a switch to or
from quickack mode. Subsequent operation of the TCP protocol
will once again enter/leave quickack mode depending on
internal protocol processing and factors such as delayed ack
timeouts occurring and data transfer. This option should not
be used in code intended to be portable.
設定 TCP_QUICKACK 會影響是否進入 quickack mode,但不像 TCP_NODELAY 那樣是保證開或關。此外,這個參數並不 portable。
實測結果
測試用程式在這裡,server 會接受一個連線,然後和 client 互傳資料。
預設參數
先用預設參數 (沒設 TCP_NODELAY 和 TCP_QUICKACK) 測試:
$ cat tcp_test.conf
no_delay 0
quickack 0
cork 0
msg_more 0
send 1000
n_chunk 100
round 100
執行流程是:
- server 將 1000 bytes 切成 100 份依序送給 client。
- client 收完全部資料。
- client 將 1000 bytes 切成 100 份依序送給 server。
- server 收完全部資料。
- 重覆 1 ~ 4 直到總共作了 100 次。
由於資料都沒塞滿 MSS,可以預期每次傳送都要等另一端 ACK 才會送下一段資料。相關說明可參考《Nagle’s Algorithm 和 Delayed ACK 以及 Minshall 的加強版》。
將 server 跑在 port 8000,然後用本機的 client 連線。執行結果:
- server: send + recv time in seconds: 7.927
- client: recv + send time in seconds: 8.128
- tshark 觀察傳送 server 和 client 傳送的資料量分別是 123.703K 和 123.703K。
tshark 觀察的指令:
$ sudo tshark -i lo -f "src port 8000 or dst port 8000" > t
統計傳送量的指令:
$ file=t;
$ grep "8000 →" $file | awk '{ s += $7} END { print s/1024 }'
$ grep "→ 8000" $file | awk '{ s += $7} END { print s/1024 }'
使用 TCP_NODELAY
設定如下:
$ cat tcp_test.conf
no_delay 1
quickack 0
cork 0
msg_more 0
send 1000
n_chunk 100
round 100
執行結果:
- server: send + recv time in seconds: 0.056
- client: recv + send time in seconds: 0.255
- server 和 client 傳送的資料量分別是 846.287K 和 876.902K。
可看出 TCP_NODELAY 確實避免 delay (7.9s → 0.1s),但也增加太多小封包,增加傳輸量 (123.7K → 846.3K)。
使用 QUICKACK
設定如下:
$ cat tcp_test.conf
no_delay 0
quickack 1
cork 0
msg_more 0
send 1000
n_chunk 100
round 100
執行結果:
- server: send + recv time in seconds: 8.084
- client: recv + send time in seconds: 8.084
- server 和 client 傳送的資料量分別是 124.348K 和 124.348K。
和沒用 TCP_QUICKACK 沒顯著差別 (7.9s → 8.1s 和 123.7K → 124.3K )。
下面是 tshark 觀察預設參數的結果:
下面是 tshark 觀察開啟 TCP_QUICKACK 的結果:
可看出 TCP_QUICKACK 開頭有效但後面就無效了。得看 Linux kernel 程式碼才知道為什麼。
Linux kernel 實作
我是用 Ubuntu 16.04 上的 kernel 4.4.0–98-generic,相關程式和最新版沒差多少。
決定是否要回 ACK 的程式是 net/ipv4/tcp_input.c 的 __tcp_ack_snd_check():
/*
* Check if sending an ack is needed.
*/
static void __tcp_ack_snd_check(struct sock *sk, int ofo_possible)
{
struct tcp_sock *tp = tcp_sk(sk);/* More than one full frame received... */
if (((tp->rcv_nxt - tp->rcv_wup) > inet_csk(sk)->icsk_ack.rcv_mss &&
/* ... and right edge of window advances far enough.
* (tcp_recvmsg() will send ACK otherwise). Or...
*/
__tcp_select_window(sk) >= tp->rcv_wnd) ||
/* We ACK each frame or... */
tcp_in_quickack_mode(sk) ||
/* We have out of order data. */
(ofo_possible && skb_peek(&tp->out_of_order_queue))) {
/* Then ack it now */
tcp_send_ack(sk);
} else {
/* Else, send delayed ack. */
tcp_send_delayed_ack(sk);
}
}
其中 tcp_in_quickack_mode() 回傳 true 的話,就會立即回 ACK。tcp_in_quickack_mode() 的程式如下:
/* Send ACKs quickly, if "quick" count is not exhausted
* and the session is not interactive.
*/
static bool tcp_in_quickack_mode(struct sock *sk)
{
const struct inet_connection_sock *icsk = inet_csk(sk);
const struct dst_entry *dst = __sk_dst_get(sk); return (dst && dst_metric(dst, RTAX_QUICKACK)) ||
(icsk->icsk_ack.quick && !icsk->icsk_ack.pingpong);
}
RTAX_QUICKACK 不知是啥東西,Linux kernel 的原始碼裡,它只有出現在這裡和 include/uapi/linux/rtnetlink.h。另一個條件會看 struct inet_connection_sock 的 icsk_ack.quick 和 icsk_ack.pingpong。
icsk_ack 的定義如下:
struct {
__u8 pending; /* ACK is pending */
__u8 quick; /* Scheduled number of quick acks */
__u8 pingpong; /* The session is interactive */
...
__u16 rcv_mss; /* MSS used for delayed ACK decisions */
} icsk_ack;
用 setsocketopt() 設定 TCP_QUICKACK 後,程式會進到 net/ipv4/tcp.c 的 do_tcp_setsockopt(),和 TCP_QUICKACK 相關的程式如下:
case TCP_QUICKACK:
if (!val) {
icsk->icsk_ack.pingpong = 1;
} else {
icsk->icsk_ack.pingpong = 0;
if ((1 << sk->sk_state) &
(TCPF_ESTABLISHED | TCPF_CLOSE_WAIT) &&
inet_csk_ack_scheduled(sk)) {
icsk->icsk_ack.pending |= ICSK_ACK_PUSHED;
tcp_cleanup_rbuf(sk, 1);
if (!(val & 1))
icsk->icsk_ack.pingpong = 1;
}
}
break;
由此可知 setsockopt() 設定 TCPQUICK 只會影響 icsk_ack.pingpong,icsk_ack.quick 由其它條件決定,這和 man 7 tcp 說的一致。
在回覆 ACK 的時候,icsk_ack.quick 的值會減小。net/ipv4/tcp_input.c 的 tcp_incr_quickack() 則會增加 icsk_ack.quick 的值:
static void tcp_incr_quickack(struct sock *sk)
{
struct inet_connection_sock *icsk = inet_csk(sk);
unsigned int quickacks = tcp_sk(sk)->rcv_wnd / (2 * icsk->icsk_ack.rcv_mss);if (quickacks == 0)
quickacks = 2;
if (quickacks > icsk->icsk_ack.quick)
icsk->icsk_ack.quick = min(quickacks, TCP_MAX_QUICKACKS);
}
不過我用 SystemTap 觀察相關程式,發現整個過程只被呼叫了兩次:
$ cat quickack.stp
global prog_name = "tcp_test";probe begin {
printf("--- ready ---\n");
}probe kernel.statement("tcp_incr_quickack@*:181") {
if (execname() == prog_name) {
printf("%s\n", pp());
print_backtrace();
}
}$ sudo stap quickack.stp
--- ready ---
kernel.statement("tcp_incr_quickack@/build/linux-3phnTq/linux-4.4.0/net/ipv4/tcp_input.c:181")
0xffffffff817896c1 : tcp_event_data_recv+0x131/0x2e0 [kernel]
0xffffffff8178e662 : tcp_data_queue+0x5e2/0xd30 [kernel]
0xffffffff81790e67 : tcp_rcv_established+0x257/0x780 [kernel]
0xffffffff8179be65 : tcp_v4_do_rcv+0x145/0x200 [kernel]
0xffffffff81784367 : tcp_prequeue_process+0x77/0xc0 [kernel]
0xffffffff8178539e : tcp_recvmsg+0x71e/0xbe0 [kernel]
0xffffffff817b380e : inet_recvmsg+0x7e/0xb0 [kernel]
0xffffffff8171b5cd : sock_recvmsg+0x3d/0x50 [kernel]
0xffffffff8171b80a : SYSC_recvfrom+0xda/0x150 [kernel]
0xffffffff8171cc0e : SyS_recvfrom+0xe/0x10 [kernel]
0xffffffff818446b2 : entry_SYSCALL_64_fastpath+0x16/0x71 [kernel]
kernel.statement("tcp_incr_quickack@/build/linux-3phnTq/linux-4.4.0/net/ipv4/tcp_input.c:181")
0xffffffff817896c1 : tcp_event_data_recv+0x131/0x2e0 [kernel]
0xffffffff81791065 : tcp_rcv_established+0x455/0x780 [kernel]
0xffffffff8179be65 : tcp_v4_do_rcv+0x145/0x200 [kernel]
0xffffffff81784367 : tcp_prequeue_process+0x77/0xc0 [kernel]
0xffffffff8178539e : tcp_recvmsg+0x71e/0xbe0 [kernel]
0xffffffff817b380e : inet_recvmsg+0x7e/0xb0 [kernel]
0xffffffff8171b5cd : sock_recvmsg+0x3d/0x50 [kernel]
0xffffffff8171b80a : SYSC_recvfrom+0xda/0x150 [kernel]
0xffffffff8171cc0e : SyS_recvfrom+0xe/0x10 [kernel]
0xffffffff818446b2 : entry_SYSCALL_64_fastpath+0x16/0x71 [kernel]
看起來 icsk_ack.quick 沒那麼容易「補充回來」,所以 tcp_in_quickack_mode() 幾乎都回傳 false 。得更深入讀相關程式才知道什麼時候才會增加 icsk_ack.quick,這裡先打住不追了。
關於 SystemTap 的介紹,可參考先前的介紹 (signal 的例子、TCP MSS 的例子)。
結論
想避免延遲,還是得用 TCP_NODELAY,但用 TCP_NODELAY 會增加很多傳輸量。有沒有兩全齊美的方法呢?
答案是應用層要自己管好傳輸的時機。應用層可以自己 buffer 好再呼叫 send(),也可以在呼叫 send() 時透過設定 MSG_MORE 提示 kernel 先 buffer,之後再一起送出。詳情見《TCP 參數對延遲和傳輸量的影響》。