我們都知道,TCP是個連接導向的、可靠的、基於位元組流的傳輸層通訊協定。
#那這裡面提到的"面對連結",意味著需要建立連接,使用連接,釋放連接。
建立連線是指我們熟知的TCP三次握手。
而使用連接,則是透過一發送、一確認的形式,進行資料傳輸。
還有就是釋放連線,也就是我們常見的TCP四次揮手。
TCP四次揮手大家應該比較了解了,但大家看過三次揮手嗎?還有兩次揮手呢?
都看過?那四次握手呢?
今天這個話題,不想只是獵奇,也不想搞冷知識。
我們從四次揮手開始說起,搞點實用的知識點。
#簡單回顧下TCP四次揮手。
正常情況下。只要資料傳輸完了,不管是客戶端還是服務端,都可以主動發起四次揮手,釋放連線。
就跟上圖畫的一樣,假設,這次四次揮手是由客戶端主動發起的,那它就是主動方。伺服器是被動接收客戶端的揮手請求的,叫被動方。
客戶端和伺服器,一開始,都是處於ESTABLISHED
狀態。
第一次揮手:一般情況下,主動方執行close()
或shutdown()
方法,會發個FIN報文
出來,表示"我不再發送資料了"。
第二次揮手:在收到主動方的FIN
報文後,被動方立馬回應一個ACK
,意思是"我收到你的FIN了,也知道你不再發資料了"。
上面提到的是主動方不再發送資料了。但如果這時候,被動方還有資料要發,那就繼續發。注意,雖然第二次和第三次揮手之間,被動方是能發數據到主動方的,但主動方能不能正常收就不一定了,這個待會說。
第三次揮手:在被動方在感知到第二次揮手之後,會做了一系列的收尾工作,最後也調用一個close()
, 這時候就會發出第三次揮手的FIN-ACK
。
第四次揮手:主動方回一個ACK
,意思是收到了。
其中第一次揮手和第三次揮手,都是我們在應用程式中主動觸發的(例如呼叫close()
方法),也就是我們平常寫程式碼需要關注的地方。
第二和第四次揮手,都是核心協定堆疊自動幫我們完成的,我們寫程式碼的時候碰不到這地方,因此也不需要太在意。
另外不管是主動或被動,每方發出了一個 FIN
和一個ACK
。也收到了一個 FIN
和一個ACK
。 這一點大家關注下,待會還會提到。
不一定是。一般情況下,透過對socket
執行 close()
或 shutdown()
方法會發出FIN
#。但實際上,只要應用程式退出,不管是主動退出,還是被動退出(因為一些莫名其妙的原因被kill
了), 都會發出FIN
。
FIN 是指"我不再發送資料",所以
shutdown()
關閉讀取不會給對方發FIN, 關閉寫才會發FIN。
根據上面的四次揮手圖,可以看出,FIN-WAIT-2
是主動方那邊的狀態。
處於這個狀態的程序,一直在等待第三次揮手的FIN
。而第三次揮手需要由被動方在程式碼裡執行close()
發出。
因此當機器上FIN-WAIT-2
狀態特別多,那一般來說,另外一台機器上會有大量的 CLOSE_WAIT
。需要檢查有大量的 CLOSE_WAIT
的那台機器,為什麼遲遲不願意呼叫close()
關閉連線。
所以,如果機器上FIN-WAIT-2
狀態特別多,一般是因為對端一直不執行close()
方法發出第三次揮手。
之前寫的一篇文章《代碼執行send成功後,數據就發出去了嗎? 》中,從原始碼的角度提到了,一般情況下,程式主動執行close()
的時候;
socket的
接收緩衝區有數據,會發RST。
發送緩衝區有數據,那會等待發送完,再發第一次揮手的FIN。
全雙工通訊,意思是發送資料的同時,還可以接收資料。
Close()的意思是,此時要同時
關閉發送和接收訊息的功能。
理論上,第二次和第三次揮手之間,被動方是可以傳數據給主動方的。
但如果 主動方的四次揮手是透過close() 觸發的,那麼主動方是不會去收這個訊息的。而且還會回一個
RST。直接結束掉這次連線。
也不是。前面提到Close()
的意思是,要同時關閉發送和接收訊息的功能。
那如果能做到只關閉發送訊息,不關閉接收訊息的功能,那就能繼續收訊息了。這種 half-close
的功能,透過呼叫shutdown()
方法就能做到。
int shutdown(int sock, int howto);
其中 howto 為斷開方式。有以下取值:
SHUT_RD:關閉讀取。這時應用層不應該再嘗試接收數據,內核協定棧中就算接收緩衝區收到數據也會被丟棄。
SHUT_WR:關閉寫入。如果發送緩衝區中還有資料沒發,會將資料傳遞到目標主機。
SHUT_RDWR:關閉讀取和寫入。相當於
close()
了。
##
不管主动关闭方调用的是close()
还是shutdown()
,对于被动方来说,收到的就只有一个FIN
。
被动关闭方就懵了,"我怎么知道对方让不让我继续发数据?"
其实,大可不必纠结,该发就发。
第二次挥手和第三次挥手之间,如果被动关闭方想发数据,那么在代码层面上,就是执行了 send()
方法。
int send( SOCKET s,const char* buf,int len,int flags);
send()
会把数据拷贝到本机的发送缓冲区。如果发送缓冲区没出问题,都能拷贝进去,所以正常情况下,send()
一般都会返回成功。
![tcp_sendmsg 逻辑](https://cdn.jsdelivr.net/gh/xiaobaiTech/image/tcp_sendmsg 逻辑.png)
然后被动方内核协议栈会把数据发给主动关闭方。
如果上一次主动关闭方调用的是shutdown(socket_fd, SHUT_WR)
。那此时,主动关闭方不再发送消息,但能接收被动方的消息,一切如常,皆大欢喜。
如果上一次主動關閉方呼叫的是close()
。那主動方在收到被動方的資料後會直接丟棄,然後回一個RST
。
針對第二種情況。
被動方核心協定堆疊收到了RST
,會把連線關閉。但核心連線關閉了,應用層也不知道(除非被通知)。
此時被動方應用層接下來的操作,無非就是讀取或寫入。
如果是讀,則會回傳RST
的報錯,也就是我們常見的Connection reset by peer
。
如果是寫,那麼程式會產生SIGPIPE
訊號,應用層程式碼可以擷取並處理訊號,如果不處理,則預設情況下進程會終止,意外關閉.
總結,當被動關閉方recv()
傳回EOF
時,說明主動方透過close()
或shutdown(fd, SHUT_WR)
發起了第一次揮手。
如果此時被動方執行兩次 send()
。
第一次send()
, 一般會成功回傳。
第二次send()
時。如果主動方是透過 shutdown(fd, SHUT_WR)
發起的第一次揮手,那麼此時send()
還是會成功。如果主動方透過 close()
發起的第一次揮手,那此時會產生SIGPIPE
訊號,進程預設會終止,異常退出。不想異常退出的話,記得捕獲處理這個訊號。
第三次揮手,是由被動方主動觸發的,例如呼叫close()
。
如果因為程式碼錯誤或其他一些原因,被動方就是不執行第三次揮手。
這時候,主動方會根據自身第一次揮手的時候用的是close()
還是shutdown(fd, SHUT_WR)
,有不同的行為表現。
如果是 shutdown(fd, SHUT_WR)
,说明主动方其实只关闭了写,但还可以读,此时会一直处于 FIN-WAIT-2
, 死等被动方的第三次挥手。
如果是 close()
, 说明主动方读写都关闭了,这时候会处于 FIN-WAIT-2
一段时间,这个时间由 net.ipv4.tcp_fin_timeout
控制,一般是 60s
,这个值正好跟2MSL
一样 。超过这段时间之后,状态不会变成 `TIME-WAIT`,而是直接变成`CLOSED`。
# cat /proc/sys/net/ipv4/tcp_fin_timeout 60
四次挥手聊完了,那有没有可能出现三次挥手?
是可能的。
我们知道,TCP四次挥手里,第二次和第三次挥手之间,是有可能有数据传输的。第三次挥手的目的是为了告诉主动方,"被动方没有数据要发了"。
所以,在第一次挥手之后,如果被动方没有数据要发给主动方。第二和第三次挥手是有可能合并传输的。这样就出现了三次挥手。
上面提到的是沒有資料要發的情況,如果第二、第三次揮手之間有數據要發,就不可能變成三次揮手了嗎?
並不是。 TCP中還有個特性叫延遲確認。可以簡單理解為:接收方收到資料以後不需要立刻馬上回覆ACK確認包。
在此基礎上,不是每一次發送資料包都能對應收到一個 ACK
確認包,因為接收者可以合併確認。
而這個合併確認,放在四次揮手裡,可以把第二次揮手、第三次揮手,以及他們之間的資料傳輸都合併在一起發送。因此也出現了三次揮手。
前面在四次揮手中提到,關閉的時候雙方都發出了一個FIN和收到了一個ACK。
正常情況下TCP連線的兩端,是不同IP 連接埠的進程。
但如果TCP連接的兩端,IP 連接埠是一樣的情況下,那麼在關閉連接的時候,也同樣做到了一端發出了一個FIN,也收到了一個ACK,只不過剛好這兩端其實是同一個socket
。
而這種兩端IP 埠都一樣的連接,叫TCP自連接。
是的,你没看错,我也没打错别字。同一个socket确实可以自己连自己,形成一个连接。
上面提到了,同一个客户端socket,自己对自己发起连接请求。是可以成功建立连接的。这样的连接,叫TCP自连接。
下面我们尝试下复现。
注意我是在以下系统进行的实验。在mac
上多半无法复现。
# cat /etc/os-release NAME="CentOS Linux" VERSION="7 (Core)" ID="centos" ID_LIKE="rhel fedora" VERSION_ID="7" PRETTY_NAME="CentOS Linux 7 (Core)"
通过nc
命令可以很简单的创建一个TCP自连接
# nc -p 6666 127.0.0.1 6666
上面的 -p
可以指定源端口号。也就是指定了一个端口号为6666
的客户端去连接 127.0.0.1:6666
。
# netstat -nt | grep 6666 tcp 0 0 127.0.0.1:6666 127.0.0.1:6666 ESTABLISHED
整个过程中,都没有服务端参与。可以抓个包看下。
可以看到,相同的socket,自己连自己的时候,握手是三次的。挥手是两次的。
上面这张图里,左右都是同一个客户端,把它画成两个是为了方便大家理解状态的迁移。
我们可以拿自连接的握手状态对比下正常情况下的TCP三次握手。
看了自连接的状态图,再看看下面几个问题。
第一次握手过后,连接状态就变成了SYN_SENT
状态。如果此时又收到了第一次握手的SYN包,那么连接状态就会从SYN_SENT
状态变成SYN_RCVD
。
// net/ipv4/tcp_input.c static int tcp_rcv_synsent_state_process() { // SYN_SENT状态下,收到SYN包 if (th->syn) { // 状态置为 SYN_RCVD tcp_set_state(sk, TCP_SYN_RECV); } }
第二握手过后,连接状态就变为SYN_RCVD
了,此时如果再收到第二次握手的SYN+ACK
包。连接状态会变为ESTABLISHED
。
// net/ipv4/tcp_input.c int tcp_rcv_state_process() { // 前面省略很多逻辑,能走到这就认为肯定有ACK if (true) { // 判断下这个ack是否合法 int acceptable = tcp_ack(sk, skb, FLAG_SLOWPATH | FLAG_UPDATE_TS_RECENT) > 0; switch (sk->sk_state) { case TCP_SYN_RECV: if (acceptable) { // 状态从 SYN_RCVD 转为 ESTABLISHED tcp_set_state(sk, TCP_ESTABLISHED); } } } }
第一次挥手过后,一端状态就会变成 FIN-WAIT-1
。正常情况下,是要等待第二次挥手的ACK
。但实际上却等来了 一个第一次挥手的 FIN
包, 这时候连接状态就会变为CLOSING
。
// net/ static void tcp_fin(struct sock *sk) { switch (sk->sk_state) { case TCP_FIN_WAIT1: tcp_send_ack(sk); // FIN-WAIT-1状态下,收到了FIN,转为 CLOSING tcp_set_state(sk, TCP_CLOSING); break; } }
这可以说是隐藏剧情了。
CLOSING
很少见,除了出现在自连接关闭外,一般还会出现在TCP两端同时关闭连接的情况下。
处于CLOSING
状态下时,只要再收到一个ACK
,就能进入 TIME-WAIT
状态,然后等个2MSL
,连接就彻底断开了。这跟正常的四次挥手还是有些差别的。大家可以滑到文章开头的TCP四次挥手再对比下。
可能大家会产生怀疑,这是不是nc
这个软件本身的bug
。
那我们可以尝试下用strace
看看它内部都做了啥。
# strace nc -p 6666 127.0.0.1 6666 // ... socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) = 3 fcntl(3, F_GETFL) = 0x2 (flags O_RDWR) fcntl(3, F_SETFL, O_RDWR|O_NONBLOCK) = 0 setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0 bind(3, {sa_family=AF_INET, sin_port=htons(6666), sin_addr=inet_addr("0.0.0.0")}, 16) = 0 connect(3, {sa_family=AF_INET, sin_port=htons(6666), sin_addr=inet_addr("127.0.0.1")}, 16) = -1 EINPROGRESS (Operation now in progress) // ...
无非就是以创建了一个客户端socket
句柄,然后对这个句柄执行 bind
, 绑定它的端口号是6666
,然后再向 127.0.0.1:6666
发起connect
方法。
我们可以尝试用C语言
去复现一遍。
下面的代码,只用于复现问题。直接跳过也完全不影响阅读。
#include <stdio.h> #include <unistd.h> #include <sys/socket.h> #include <stdlib.h> #include <arpa/inet.h> #include <ctype.h> #include <string.h> #include <strings.h> int main() { int lfd, cfd; struct sockaddr_in serv_addr, clie_addr; socklen_t clie_addr_len; char buf[BUFSIZ]; int n = 0, i = 0, ret = 0 ; printf("This is a client \n"); /*Step 1: 创建客户端端socket描述符cfd*/ cfd = socket(AF_INET, SOCK_STREAM, 0); if(cfd == -1) { perror("socket error"); exit(1); } int flag=1,len=sizeof(int); if( setsockopt(cfd, SOL_SOCKET, SO_REUSEADDR, &flag, len) == -1) { perror("setsockopt"); exit(1); } bzero(&clie_addr, sizeof(clie_addr)); clie_addr.sin_family = AF_INET; clie_addr.sin_port = htons(6666); inet_pton(AF_INET,"127.0.0.1", &clie_addr.sin_addr.s_addr); /*Step 2: 客户端使用bind绑定客户端的IP和端口*/ ret = bind(cfd, (struct sockaddr* )&clie_addr, sizeof(clie_addr)); if(ret != 0) { perror("bind error"); exit(2); } /*Step 3: connect链接服务器端的IP和端口号*/ bzero(&serv_addr, sizeof(serv_addr)); serv_addr.sin_family = AF_INET; serv_addr.sin_port = htons(6666); inet_pton(AF_INET,"127.0.0.1", &serv_addr.sin_addr.s_addr); ret = connect(cfd,(struct sockaddr *)&serv_addr, sizeof(serv_addr)); if(ret != 0) { perror("connect error"); exit(3); } /*Step 4: 向服务器端写数据*/ while(1) { fgets(buf, sizeof(buf), stdin); write(cfd, buf, strlen(buf)); n = read(cfd, buf, sizeof(buf)); write(STDOUT_FILENO, buf, n);//写到屏幕上 } /*Step 5: 关闭socket描述符*/ close(cfd); return 0; }
保存为 client.c
文件,然后执行下面命令,会发现连接成功。
# gcc client.c -o client && ./client This is a client
# netstat -nt | grep 6666 tcp 0 0 127.0.0.1:6666 127.0.0.1:6666 ESTABLISHED
说明,这不是nc的bug。事实上,这也是内核允许的一种情况。
自连接一般不太常见,但遇到了也不难解决。
解决方案比较简单,只要能保证客户端和服务端的端口不一致就行。
事实上,我们写代码的时候一般不会去指定客户端的端口,系统会随机给客户端分配某个范围内的端口。而这个范围,可以通过下面的命令进行查询
# cat /proc/sys/net/ipv4/ip_local_port_range 32768 60999
也就是只要我们的服务器端口不在32768-60999
这个范围内,比如设置为8888
。就可以规避掉这个问题。
另外一个解决方案,可以参考golang
标准网络库的实现,在连接建立完成之后判断下IP和端口是否一致,如果遇到自连接,则断开重试。
func dialTCP(net string, laddr, raddr *TCPAddr, deadline time.Time) (*TCPConn, error) { // 如果是自连接,这里会重试 for i := 0; i < 2 && (laddr == nil || laddr.Port == 0) && (selfConnect(fd, err) || spuriousENOTAVAIL(err)); i++ { if err == nil { fd.Close() } fd, err = internetSocket(net, laddr, raddr, deadline, syscall.SOCK_STREAM, 0, "dial", sockaddrToTCP) } // ... } func selfConnect(fd *netFD, err error) bool { // 判断是否端口、IP一致 return l.Port == r.Port && l.IP.Equal(r.IP) }
前面提到的TCP
自连接是一个客户端自己连自己的场景。那不同客户端之间是否可以互联?
答案是可以的,有一种情况叫TCP同时打开。
大家可以对比下,TCP同时打开在握手时的状态变化,跟TCP自连接是非常的像。
比如SYN_SENT
状态下,又收到了一个SYN
,其实就相当于自连接里,在发出了第一次握手后,又收到了第一次握手的请求。结果都是变成 SYN_RCVD
。
在 SYN_RCVD
状态下收到了 SYN+ACK
,就相当于自连接里,在发出第二次握手后,又收到第二次握手的请求,结果都是变成 ESTABLISHED
。他们的源码其实都是同一块逻辑。
分别在两个控制台下,分别执行下面两行命令。
while true; do nc -p 2224 127.0.0.1 2223 -v;done while true; do nc -p 2223 127.0.0.1 2224 -v;done
上面两个命令的含义也比较简单,两个客户端互相请求连接对方的端口号,如果失败了则不停重试。
执行后看到的现象是,一开始会疯狂失败,重试。一段时间后,连接建立完成。
# netstat -an | grep 2223 Proto Recv-Q Send-Q Local Address Foreign Address State tcp 0 0 127.0.0.1:2224 127.0.0.1:2223 ESTABLISHED tcp 0 0 127.0.0.1:2223 127.0.0.1:2224 ESTABLISHED
期间抓包获得下面的结果。
可以看到,这里面建立连接用了四次交互。因此可以说这是通过"四次握手"建立的连接。
而且更重要的是,这里面只涉及两个客户端,没有服务端。
看到这里,不知道大家有没有跟我一样,被刷新了一波认知,对socket
有了重新的认识。
在以前的观念里,建立连接,必须要有一个客户端和一个服务端,并且服务端还要执行一个listen()
和一个accept()
。而实际上,这些都不是必须的。
那麼下次,面試官問你"沒有listen()
, TCP能建立連線嗎?", 我想大家應該知道該怎麼回答了。
但問題又來了,只有兩個客戶端,沒有listen()
,為什麼能建立TCP
連線?
如果大家有興趣,我們以後有機會再填上這個坑。
# 四次揮手中,不管是程式主動執行close()
,還是進程被殺,都有可能發出第一次揮手FIN
包。如果機器上FIN-WAIT-2
狀態特別多,一般是因為對端一直不執行close()
方法發出第三次揮手。
Close()
會同時關閉傳送和接收訊息的功能。 shutdown()
能單獨關閉發送或接受訊息。
第二、第三次揮手,是有可能合在一起的。於是四次揮手就變成三次揮手了。
同一個socket自己連自己,會產生TCP自連接,自連接的揮手是兩次揮手。
沒有listen
,兩個客戶端之間也能建立連線。這種情況叫做TCP同時開啟,它由四次握手產生。
#今天提到的,不管是兩次揮手 ,還是自連接,或是TCP同時開啟什麼的。
咋一看,可能對日常搬磚沒什麼用,其實也確實沒什麼用。
並且在面試上大機率也不會被問到。
畢竟一般面試官也不在乎 Turner字有幾種寫法。
這篇文章的目的,主要是想從另一個角度讓大家重新認識socket
。原來TCP
是可以自己連自己的,甚至兩個客戶端之間,不用服務端也能連起來。
這實在是,太出乎意料了。
以上是活久見! TCP兩次揮手,你有看過嗎?那四次握手呢?的詳細內容。更多資訊請關注PHP中文網其他相關文章!