TCP が 接続指向で信頼性の高い、バイト ストリーム ベースのトランスポート層通信プロトコルであることは誰もが知っています。
そして、ここで言及されている「接続指向」は、それです。は、接続を確立し、接続を使用し、接続を解放する必要があることを意味します。
接続の確立とは、よく知られた TCP スリーウェイ ハンドシェイクを指します。
そして、コネクションを利用して、送信1回、確認1回の形でデータ送信が行われます。
「接続を解放する」もあります。これは、一般的な TCP 4 つのウェーブ ジェスチャ です。
TCP 4 つの波 誰もがよく知っているはずですが、3 つの波 を見たことはありますか?そして 2 回手を振りました ? ######見たことありますか? 4ウェイハンドシェイク
についてはどうですか?今日のトピックは単なる好奇心やトリビアに関するものではありません。
4 つの波から始めて、実践的な知識を取得しましょう。
TCP ウェーブは 4 回
通常の状況では。データ送信が完了している限り、クライアントとサーバーの両方が 4 つのウェーブをアクティブに開始できます。
図のように、4 つのウェーブがクライアントによって開始されたと仮定すると、それは アクティブ パーティ です。サーバーはクライアントの Wave リクエストを受動的に受信します。これは パッシブ パーティー と呼ばれます。
クライアントとサーバーは、最初は ESTABLISHED
状態にあります。
初めて手を振る: 通常の状況では、アクティブなパーティが close()
または shutdown()
メソッドを実行すると、 ## が送信されます。#FIN メッセージ が表示され、「
データを送信しません 」と表示されます。
第 2 波 : アクティブなパーティから FIN メッセージを受信した後、パッシブなパーティはすぐに
ACK で応答します。これは、「私は」を意味します。 FIN を受け取りましたが、今後データを送信しないことも承知しています。」
アクティブな当事者がデータを送信しなくなったことです。ただし、この時点で 受動的なパーティ にまだ送信するデータがある場合は、送信を続けます。なお、2波目から3波目まではパッシブ側からアクティブ側にデータを送信できますが、アクティブ側が正常に受信できるかどうかは後述しますので不明です。
第 3 の波 : 受動的な側は第 2 の波を感知した後、一連の仕上げ作業を行い、最後に close() を呼び出します。そのとき、
FIN-ACK の第 3 波が発行されます。
第 4 ウェーブ : アクティブなパーティは、受信されたことを意味する ACK
で応答します。
最初のウェーブと 3 番目のウェーブは両方とも、アプリケーション内でアクティブにトリガーされます (close()
メソッドの呼び出しなど)。これは、記述するときに注意する必要があることです。コード、場所。
2 番目と 4 番目のウェーブはカーネル プロトコル スタックによって自動的に行われますが、コードを書くときにこの場所に触れることができないため、あまり気にする必要はありません。
さらに、アクティブかパッシブかに関係なく、各当事者は FIN
と ACK
を送信します。 FIN
と ACK
も受け取りました。 この点については後述しますのでご注意ください。
不確実です。通常、FIN
は、socket
で close()
または shutdown()
メソッドを実行することによって発行されます。しかし実際には、アプリケーションが終了する限り、active が終了するか passive が終了するかに関係なく (何らかの不可解な理由で killed
されます)、 は 問題 FIN
。
FINは「データを送信しなくなりました」という意味なので、
shutdown()
は読み込みがクローズされた場合には相手にFINを送信しませんが、書き込みがクローズされた場合にはFINが送信されます。
FIN-WAIT-2 が
アクティブ パーティ のステータスであることがわかります。
FIN の 第 3 波を待っています。 3 番目のウェーブは、受動的側がコード内で
close() を実行して発行する必要があります。
FIN-WAIT-2 状態がある場合、一般的に、別のマシン上にも多数の
CLOSE_WAIT が存在します。
CLOSE_WAIT が多数発生するマシンが、接続を閉じるために
close() を呼び出すことに消極的な理由を確認する必要があります。
FIN-WAIT-2 状態が多すぎる場合、通常はピアが
close() メソッドを実行していないことが原因です。第3波を起こす。
以前に書いた記事「コードの実行が成功したら、データは送信されますか?」 》では、ソース コードの観点から、通常の状況では 、プログラムがアクティブに close()
;
を実行する場合に言及されています。現在の接続に対応する socket
の 受信バッファ にデータがある場合、RST
が送信されます。
送信バッファ にデータがある場合は、送信が完了するまで待機してから最初のウェーブ FIN を送信します。
全二重通信 であり、データを送信しながらデータを受信できることを意味します。
Close() は、
メッセージの送受信機能を 同時に閉じる必要があることを意味します。
理論上はですが、第2波と第3波の間では、パッシブ側がアクティブ側にデータを送信できます。
ただし、アクティブ パーティの 4 つのウェーブがclose() によってトリガーされた場合、アクティブ パーティはこのメッセージを受信しません。また、
#RST と応答します。この接続を直接終了します。
メッセージの送受信機能を 同時に閉じることを意味します。
メッセージの送信のみを無効にし 、メッセージの受信機能を無効にしない
half-close 関数は、shutdown() メソッドを呼び出すことで実現できます。
int shutdown(int sock, int howto);
Howto は切断方法です。次の値が利用可能です:
SHUT_RD: 読み取りを終了します。このとき、アプリケーション層はデータの受信を試行する必要がなく、受信バッファがカーネルプロトコルスタック内のデータを受信したとしても、そのデータは破棄されます。
SHUT_WR: 書き込みを終了します。送信バッファ内に未送信のデータが残っている場合、そのデータはターゲットホストに配信されます。
SHUT_RDWR: 読み取りと書き込みを閉じます。 close()
と同等。- シャットダウンによる TCP 4 ウェーブ
不管主动关闭方调用的是close()
还是shutdown()
,对于被动方来说,收到的就只有一个FIN
。
被动关闭方就懵了,"我怎么知道对方让不让我继续发数据?"
其实,大可不必纠结,该发就发。
第二次挥手和第三次挥手之间,如果被动关闭方想发数据,那么在代码层面上,就是执行了 send()
方法。
int send( SOCKET s,const char* buf,int len,int flags);
send()
会把数据拷贝到本机的发送缓冲区。如果发送缓冲区没出问题,都能拷贝进去,所以正常情况下,send()
一般都会返回成功。

然后被动方内核协议栈会把数据发给主动关闭方。
如果上一次主动关闭方调用的是shutdown(socket_fd, SHUT_WR)
。那此时,主动关闭方不再发送消息,但能接收被动方的消息,一切如常,皆大欢喜。
最後の active クロージング パーティーが close()
を呼び出した場合。 アクティブ パーティ は、パッシブ パーティ からデータを受信した後、直接 破棄 し、RST
で応答します。
2 番目の状況について。
パッシブ側カーネルプロトコルスタックは、RST
を受信した後に接続を閉じます。しかし、カーネル接続は閉じられており、アプリケーション層は (通知されない限り) 知りません。
この時点で、受動側アプリケーション層次の操作は読み取りまたは書き込みにすぎません。
これが読み取られると、RST
のエラーが返されます。これは、ピア による一般的な
接続のリセットです。
これが記述されると、プログラムは SIGPIPE
信号を生成します。アプリケーション層のコードは信号をキャプチャして処理できます。処理されない場合、プロセスは終了します。デフォルトでは予期せず終了します。
要約すると、パッシブ終了メソッド recv() が EOF## を返す場合、 #、アクティブなパーティが
close() または
shutdown(fd, SHUT_WR) を通じて最初のウェーブを開始したことを意味します。
この時点で受動側が を 2 回実行した場合 send()
。
初回 send()
は通常、正常に返されます。
2 回目 send()
。アクティブなパーティが shutdown(fd, SHUT_WR)
を介して最初のウェーブを開始した場合、この時点では send()
は引き続き成功します。アクティブなパーティが close()
を通じて最初のウェーブを開始すると、この時点で SIGPIPE
シグナルが生成され、プロセスはデフォルトで終了し、異常終了します。異常終了したくない場合は、必ずこのシグナルをキャッチして処理してください。
3 回目の手を振るclose() の呼び出しなど、受動的なパーティ
によってアクティブにトリガーされます。
コードエラーまたはその他の理由により、受動的側は第 3 ウェーブを実行しません。
現時点では、アクティブなパーティは、最初に手を振るときに 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四次挥手里,第二次和第三次挥手之间,是有可能有数据传输的。第三次挥手的目的是为了告诉主动方,"被动方没有数据要发了"。
所以,在第一次挥手之后,如果被动方没有数据要发给主动方。第二和第三次挥手是有可能合并传输的。这样就出现了三次挥手。
上記は、送信するデータがない状況です。 send. 2回目の場合、3回目のウェーブの間に送信するデータがあるので、3回振るのは無理でしょうか? ######### ではありません ###。 TCP には 遅延確認 と呼ばれる機能もあります。これは単純に次のように理解できます。
受信側は、データを受信した直後に ACK 確認パケットで応答する必要はありません。これに基づいて、受信者は確認をマージできるため、データ パケットが送信されるたびに ACK 確認パケットを受信できるわけではありません。
そして、このマージ確認を4つのウェーブに配置し、第2ウェーブ、第3ウェーブ、それらの間のデータ送信をマージしてまとめて送信することができます。つまり3つの波がありました。 #TCP ウェーブ 3 回遅延確認
が FIN を送信し、ACK
を受信しました。
しかし、IP ポート が TCP 接続の両端で同じである場合、接続を閉じるときに同じことが起こります。
一方の端は FIN を送信します。 ACKを受信しましたが、たまたま両端が実際には 同じソケットでした。
TCP は 2 回ウェーブしますそして、両端で同じ
IP ポート
是的,你没看错,我也没打错别字。同一个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 は接続を確立できますか?」 と質問したら、誰もが答え方を知っているはずです。
しかし、問題が再び発生します。クライアントが 2 つしかなく、listen()
がありません。なぜ TCP
接続が確立できるのでしょうか?
ご興味がございましたら、将来的にこの穴を再度埋める機会がございます。
プログラムの有無に関係なく、4 回手を振ります close()
がアクティブに実行されるか、プロセスが強制終了されると、最初のウェーブ FIN
パケットが送信される可能性があります。マシン上の FIN-WAIT-2
状態が多すぎる場合は、通常、ピアが close()
メソッドを実行して 3 番目のウェーブを発行していないことが原因です。
Close()
は、 同時にメッセージを送受信する機能を無効にします。 shutdown()
メッセージの送受信を個別にシャットダウンできます。
になりました。
が発生し、セルフコネクションのウェーブは 2 回ウェーブします。
listen
を行わなくても、2 つのクライアント間で接続を確立できます。この状況は TCP が同時に開くと呼ばれ、4 ウェイ ハンドシェイク によって引き起こされます。
本日、2回手を振るか#に関係なく言及されました。 ##自己接続、または TCP が同時にオープン など。
一見すると、毎日のレンガ移動には役に立たないかもしれませんが、実際には本当に役に立ちません。 そして面接では聞かれない可能性が高いです。結局のところ、ほとんどの面接官は、フェンネルという単語が何通りに書かれるかなど気にしません。
この記事の主な目的は、socket を別の角度から皆さんに再理解していただくことです。
TCP 自体は接続でき、サーバーなしでも 2 つのクライアントさえ接続できることがわかりました。
以上がまた近いうちにお会いしましょう! TCP が 2 回振られるのを見たことがありますか? 4回の握手はどうなるの?の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。