很多資料講了關于TCP的CLOSING和CLOSE_WAIT狀態以及所謂的優雅關閉的細節,多數側重與Linux的內核實現(除了《UNIX網絡編程》)。本文不注重代碼細節,只關注邏輯。所使用的工具,tcpdump,packetdrill以及ss。
關于ss可以先多說幾句,它展示的信息跟netstat差不多,只不過更加詳細。netstat的信息是通過procfs獲取的,本質上來講就是遍歷/proc/net/netstat文件的內容,然后將其組織成可讀的形式展示出來,然而ss則可以針對特定的五元組信息提供更加詳細的內容,它不再通過procfs,而是用過Netlink來提取特定socket的信息,對于TCP而言,它可以提取到甚至tcp_info這種詳細的信息,它包括cwnd,ssthresh,rtt,rto等。
本文展示的邏輯使用了以下三樣工具:
1).packetdrill
使用packetdrill構造出一系列的包序列,使得TCP進入CLOSING狀態或者CLOSE_WAIT狀態。
2).tcpdump/tshark
抓取packetdrill注入的數據包以及協議棧反饋的包,以確認數據包序列確實如TCP標準所述的那樣。
3).ss/netstat
通過ss抓取packetdrill相關套接字的tcp_info,再次確認細節。
我想,我使用上述的三件套解析了CLOSING狀態之后,接下來的CLOSE_WAIT狀態就可以當作練習了。
我來一個一個說。
1.關于CLOSING狀態
首先我來描述一下而不是細說概念。
什么是CLOSING狀態呢?我們來看一下下面的局部狀態圖:
也就是說,當兩端都主動發送FIN的時候,并且在收到對方對自己發送的FIN之前收到了對方發送的FIN的時候,兩邊就都進入了CLOSING狀態,這個在狀態圖上顯示的很清楚。這個用俗話說就是”同時關閉“。時序圖我就不給出了,請自行搜索或者自己畫。
有很多人都說,這種狀態的TCP連接在系統中存在了好長時間并百思不得其解。這到底是為什么呢?通過狀態圖和時序圖,我們知道,在進入CLOSING狀態后,只要收到了對方對自己的FIN的ACK,就可以雙雙進入TIME_WAIT狀態,因此,如果RTT處在一個可接受的范圍內,發出的FIN會很快被ACK從而進入到TIME_WAIT狀態,CLOSING狀態應該持續的時間特別短。
以下是packetdrill腳本,很簡單的一個腳本:
0.000 socket(..., SOCK_STREAM, IPPROTO_TCP) = 3
0.000 setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
0.000 bind(3, ..., ...) = 0
0.000 listen(3, 1) = 0
0.100 < S 0:0(0) win 32792 < mss 1460,sackOK,nop,nop,nop,wscale 7 >
0.100 > S. 0:0(0) ack 1 win 5840 < mss 1460,nop,nop,sackOK,nop,wscale 7 >
0.200 < . 1:1(0) ack 1 win 257
0.200 accept(3, ..., ...) = 4
// 象征性寫入一些數據,裝的像一點一個正常的TCP連接:握手-傳輸-揮手
0.250 write(4, ..., 1000) = 1000
0.300 < . 1:1(0) ack 1001 win 257
// 主動斷開,發送FIN
0.400 close(4) = 0
// 在未對上述close的FIN進行ACK前,先FIN
0.500 < F. 1:1(0) ack 1001 win 260
// 至此,成功進入同時關閉的CLOSING狀態。
// 由于packetdrill不能用dup調用,也好用多線程,為了維持進程不退出,只能等待
10000.000 close(4) = 0
同時,我啟用tcpdump抓包,確認了TCP狀態圖的細節,即,還沒有收到對方對FIN的ACK時,收到了對方的FIN:
有個異常,沒有收到FIN的ACK(packetdrill沒有回復,這正常,因為腳本里本來就沒有這個語句),然而也沒有看到重傳,此時該連接應該是處于CLOSING狀態了,用ss來確認:
CLOSING 1 1 192.168.0.1:webcache 192.0.2.1:54442
cubic wscale:7,7 rto:2000 rtt:50/25 ssthresh:2 send 467.2Kbps rcv_space:5840
果然,進入了CLOSING狀態且沒有消失,時不我待,當過了2秒以后,ss的結果變成了:
CLOSING 1 1 192.168.0.1:webcache 192.0.2.1:54442
cubic wscale:7,7 rto:4000 rtt:50/25 ssthresh:2 send 467.2Kbps rcv_space:5840
明顯在退避!如果繼續觀察,你會發現rto退避到了64秒之多。在我的場景中,CLOSING狀態的套接字維持了兩分鐘之久。
然而,為什么呢?為什么CLOSING狀態會維持這么久?為什么它沒有繼續維持下去直到永久呢?
很明顯,一端的FIN發出去后,沒有收到ACK,因此會退避重發,知道4次退避,即22222*2秒之久?,F在的問題是,為什么重發FIN始終不成功呢?要是成功了的話,估計ACK瞬間也就回來了,那么CLOSING狀態也就可以進入TIME_WAIT了,但是沒有成功重傳FIN!
到此為止,我們知道,進入CLOSING狀態之后,兩邊都會等待接收自己FIN的ACK,一旦收到ACK,就會進入TIME_WAIT,如此反復,如果收不到ACK,則會不斷重傳FIN,直到忍無可忍,將socket銷毀。現在,我們集中于解釋為什么重傳沒有成功,但是請記住,并不是每次都這樣,只是在我這個packetdrill構造的場景中會有重傳不成功,不然如果大概率不成功的話。豈不是每個CLOSING狀態都要維持很長時間???。?/p>
在我的場景下,通過hook重傳函數以及抓包確認,發現所有的重傳雖然退避了,但是都沒有真正將數據包發送出去,究其原因,最終確認問題出在以下代碼上:
if (atomic_read(&sk- >sk_wmem_alloc) >
min(sk- >sk_wmem_queued + (sk- >sk_wmem_queued > > 2), sk- >sk_sndbuf))
return -EAGAIN;
在Linux協議棧的實現中,tcp_retransmit_skb由tcp_retransmit_timer調用,即便是這里出了些問題沒有重傳成功,也還是會退避的,退避超時到期后,繼續在這里出錯,直到”不可容忍“銷毀socket。
我們可以得知,不管如何CLOSING狀態的TCP連接即便沒有收到對自己FIN的ACK,也不會永久保持下去,保持多久取決于自己發送FIN時刻的RTT,然后RTT計算出的RTO按照最大的退避次數來退避,直到最終執行了固定次數的退避后,算出來的那個比較大的超時時間到期,然后TCP socket就銷毀了。
因此,CLOSING狀態并不可怕,起碼,不管怎樣,它有一個可控的銷毀時限。
...
現在我來解釋重傳不成功的細節。
我們知道,根據上述的代碼段,sk_wmem_alloc要足夠大,大到它比sk_wmem_queued+sk_wmem_queued/4更大的時候,才會返回錯誤造成重傳不成功,然而我們的packetdrill腳本中構造的TCP連接的生命周期中僅僅傳輸了1000個字節的數據,并且這1000個字節的數據得到了ACK,然后就結束了連接。一個socket保有一個sk_wmem_alloc字段,在skb交給這個socket的時候,該字段會增加skb長度的大小(skb本身大小包括skb數據大小),然而當skb不再由該socket持有的時候,也就是其被更底層的邏輯接管之后,socket的sk_wmem_alloc字段自然會減去skb長度的大小,這一切的過程由以下的函數決定,即skb_set_owner_w和skb_orphan。我們來看一下這兩個函數:
static inline void skb_set_owner_w(struct sk_buff *skb, struct sock *sk)
{
skb_orphan(skb);
skb- >sk = sk;
// sock_wfree回調中會遞減sk_wmem_alloc相應的大小,其大小就是skb- >truesize
skb- >destructor = sock_wfree;
/*
* We used to take a refcount on sk, but following operation
* is enough to guarantee sk_free() wont free this sock until
* all in-flight packets are completed
*/
atomic_add(skb- >truesize, &sk- >sk_wmem_alloc);
}
static inline void skb_orphan(struct sk_buff *skb)
{
// 調用回調函數,遞減sk_wmem_alloc
if (skb- >destructor)
skb- >destructor(skb);
skb- >destructor = NULL;
skb- >sk = NULL;
}
也就是說,只要skb_orphan在skb通向網卡的路徑上被正確調用,就會保證sk_wmem_alloc的值隨著skb進入socket的管轄時而增加,而被實際發出后而減少。但是根據我的場景,事實好像不是這樣,sk_wmem_alloc的值只要發送一個skb就會增加,絲毫沒有減少的跡象...這是為什么呢?
有的時候,當你對某個邏輯理解足夠深入后,一定要相信自己的判斷,內核存在BUG!內核并不完美。我使用的是2.6.32老內核,這個內核我已經使用了6年多,這是我在這個內核上發現的第4個BUG了。
請注意,我的這個場景中,我使用了packetdrill來構造數據包,而packetdrill使用了tun網卡。為什么使用真實網卡甚至使用loopback網卡就不會有問題呢?這進一步引導我去調查tun的代碼,果不其然,在其hard_xmit回調中沒有調用skb_orphan!也就說說,但凡使用2.6.32內核版本tun驅動的,都會遇到這個問題呢。在tun的xmit中加入skb_orphan之后,問題消失,抓包你會發現大量的FIN重傳包,這些重傳隨著退避而間隔加大(注意,用ss命令比對一下rto字段的值和tcpdump抓取的實際值):
(為了驗證這個,我修改了packetdrill腳本,中間增加了很多的數據傳輸,以便盡快重現sk_wmem_alloc在使用tun時不遞減的問題)于是,我聯系了前公司的同事,讓他們修改OpenVPN使用的tun驅動代碼,因為當時確實出現過關于TCP使用OpenVPN隧道的重傳問題,然而,得到的答復卻是,xmit函數中已經有skb_orphan了...然后我看了下代碼,發現,公司的代碼已經不存在問題了,因為我在前年搞tun多隊列的時候,已經移植了3.9.6的tun驅動,這個問題已經被修復。
自己曾經做的事情,已然不再憶起...
2.關于CLOSE_WAIT狀態
和CLOSING狀態不同,CLOSE_WAIT狀態可能會持續更久更久的時間,導致無用的socket無法釋放,這個時間可能與應用進程的生命周期一樣久!
我們先看一下CLOSE_WAIT的局部狀態圖。
然后我來構造一個packetdrill腳本:
0.000 socket(..., SOCK_STREAM, IPPROTO_TCP) = 3
0.000 setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
0.000 bind(3, ..., ...) = 0
0.000 listen(3, 1) = 0
0.100 < S 0:0(0) win 32792 < mss 1460,sackOK,nop,nop,nop,wscale 7 >
0.100 > S. 0:0(0) ack 1 win 14600 < mss 1460,nop,nop,sackOK,nop,wscale 7 >
0.200 < . 1:1(0) ack 1 win 257
0.200 accept(3, ..., ...) = 4
// 什么也不發了,直接斷開
0.350 < F. 1:1(0) ack 1 win 260
// 協議棧會對這個FIN進行ACK,然則應用程序不關閉連接的話...
//0.450 close(4) = 0
// 該連接就會變成CLOSE_WAIT,并且只要其socket引用計數不為0,就一直僵死在那里
2000.000 close(4) = 0
同樣的,我來展示抓包結果:
最后,和描述CLOSING狀態不同的是,隔了N個小時之后,我來看ss -ip的結果:
CLOSE-WAIT 1 0 192.168.0.1:webcache 192.0.2.1:53753 users:(("ppp",2399,8))
cubic wscale:7,7 rto:300 rtt:100/50 ato:40 cwnd:10 send 1.2Mbps rcv_space:14600
這個CLOSE_WAIT還在!這是為什么呢?
很遺憾,上述的packetdrill腳本并不能直觀地展示這個現象,還得靠我說一說。
CLOSE_WAIT是一端在收到FIN之后,發送自己的FIN之前所處的狀態,那么很顯然,如果一個進程/線程始終不發送FIN,那么在該連接所隸屬的socket的生命周期內,這個socket就會一直存在,我們知道,在UNIX/Linux/WinSock中,socket作為一個描述符出現,只要進程/線程繼續持有它,它就會一直存在,因此大多數情況下進程/線程的生命周期內,此TCP套接字就會始終處在CLOSE_WAIT狀態。進程/線程長時間持有不需要的socket描述符,更多的并不是有意的,而是在進行諸如fork/clone之類的系統調用后,dup了父親的文件描述符,然后在孩子那里又沒有及時關閉,另外的原因就是編程者對socket描述符的close接口以及shutdown接口不是很理解了。
現在,我們用一個問題來繼續我們的討論。
什么時候進程在超長的生命周期內不會如愿關閉TCP從而發送FIN呢?
我的答案比較直接:不能指望close會發送FIN!
相信很多人在想斷開一個TCP連接的時候,都會調用close吧。并且這種做法幾乎都是正確的,以至于很多人都把這作為一種標準的做法。但是這是不對的!Why?!在《UNIX網絡編程》中,曾經提到了所謂的”優雅關閉TCP連接“,何謂優雅??!如果你充分理解close,shutdown,應該就會知道,CLOSE_WAIT出現,你應該可以給出一些解釋。
close調用
close的參數只是一個文件描述符號,它不理解這個文件真正的細節,它只是一個文件系統內范疇的一個調用,它只是關閉文件描述符,保證此進程不會在讀取它而已。如果你關閉了文件描述符4,即close(4),你知道4代表的文件會作何反應嗎??文件系統并不知道4號描述符代表的文件到底是什么,更不知道有多少進程共享這個底層的”實體“,所以一個進程層面上邏輯根本沒有權力去徹底關閉一個socket。如果你想了解close的細節,更應該去看看UNIX文件抽象或者文件系統的細節,而不是socket。請參見位于fs/open.c中的:
SYSCALL_DEFINE1(close, unsigned int, fd)
{
...
fdt = files_fdtable(files);
...
filp = fdt- >fd[fd];
...
retval = filp_close(filp, files);
...
return retval;
...
}
EXPORT_SYMBOL(sys_close);
在filp_close中會有fput調用:
void fput(struct file *file)
{
if (atomic_long_dec_and_test(&file- >f_count))
__fput(file);
}
看到那個引用計數了嗎?只有當這個文件的引用計數變成0的時候,才會調用底層的關閉邏輯,對于socket而言,如果仍然還有一個進程或者線程持有這個socket對應的文件系統的描述符,那么即便你調用了close,也不會進入了socket的close邏輯,它在文件系統層面就返回了!
shutdown調用
這個才是真正關閉一個TCP連接的調用!shutdown并沒有文件系統的語義,它專門針對內核層的TCP socket。因此,調用shutdown的邏輯,才是真正關閉了與之共享信道的tcp socket。
所謂的優雅關閉,就是在調用close之前, 首先自己調用shutdown(RD or WD)。這樣的時序才是關閉TCP的必由之路!
如果你想優雅關閉一個TCP連接,請先用shutdown,然后后面跟一個close。不過有點詭異的是,Linux的shutdown(SHUT_RD)貌似沒有任何效果,不過這無所謂了,本來對于讀不讀的,就不屬于TCP的范疇,只有SHUT_WR才會實際發送一個FIN給對方。
-
數據
+關注
關注
8文章
7134瀏覽量
89515 -
代碼
+關注
關注
30文章
4823瀏覽量
68985 -
腳本
+關注
關注
1文章
391瀏覽量
14932
發布評論請先 登錄
相關推薦
評論