其实是我调式了 N 久的一个 BUG, 最后发现这原来是 TCP 的 Feature. 文章为我转我自己, 原文链接在底部.
我相信绝大多数人都会写 TCP 的服务端代码, 就自己而言, 已经几乎机械式地在写如下代码(就如定式一般):
ln, err := net.Listen("tcp", ":3000")
for {
conn, err := ln.Accept()
...
}
Good! conn 对象到手! 之后便可以安心地从 conn 对象中读取数据, 或写入数据.
但是有没有考虑过一个问题, 如果在 Listen 后不调用 Accept, 会发生什么事? 这并非是无事找事的异想天开, 在现实中, 有很多种情况会导致代码 Accept 失败, 比如 too many open files 发生时.
这是本次实验的服务端伪代码, 可以看到, 在 Listen 端口后, 代码只使用了一个循环 Sleep 将进程永久挂起.
func main() {
listen, err := net.Listen("tcp", ":3000")
for {
time.Sleep(time.Second)
}
}
客户端伪代码主要执行三个步骤: 连接服务器, 等待 10 秒后向服务器发送数据, 关闭连接.
func main() {
conn, err := net.Dial("tcp", "127.0.0.1:3000")
log.Println("Dial conn", conn, err)
time.Sleep(time.Second * 10)
n, err := io.WriteString(conn, "ping")
log.Println("Write", n, "bytes,", "error is", err)
err := conn.Close()
log.Println("Close", err)
}
如此这般, 执行程序!
2020/03/30 17:57:45 Dial conn &{{0xc0000a2080}}
2020/03/30 17:57:45 Write 4 bytes, error is <nil>
2020/03/30 17:57:45 Close <nil>
客户端连接服务器成功未报错, 发送数据成功未报错, 关闭连接成功亦未报错. 重新执行客户端代码, 这次让我们在执行的时候用 netstat 工具查看连接状态. 这里分为三个步骤.
客户端连接到服务器后
tcp 0 0 127.0.0.1:56428 127.0.0.1:8080 ESTABLISHED 18063/client
tcp 0 0 127.0.0.1:8080 127.0.0.1:56428 ESTABLISHED -
客户端调用 Close 后
tcp 0 0 127.0.0.1:56428 127.0.0.1:8080 FIN_WAIT2 -
tcp 5 0 127.0.0.1:8080 127.0.0.1:56428 CLOSE_WAIT -
客户端进程退出后
tcp 5 0 127.0.0.1:8080 127.0.0.1:56428 CLOSE_WAIT -
注意最后的 CLOSE_WAIT, 它将永远存在, 直到服务端进程退出.
当客户端连接服务端后, 通过 netstat 看到连接状态为 ESTABLISHED, 这说明 TCP 三次握手已经成功, 也就是说 TCP 连接已经在网络上建立了起来. 可得知 TCP 握手并不是 Accept 函数的职责.
阅读操作系统的 Accept 函数文档: http://man7.org/linux/man-pages/man2/accept.2.html, 在第一段落中有如下描述:
It extracts the first connection request on the queue of pending connections for the listening socket, sockfd, creates a new connected socket, and returns a new file descriptor referring to that socket.
翻译: 它从 connections 队列中取出第一个 connection, 并返回引用该 connection 的一个新的文件描述符.
验证了我的想法, 无论是否调用 Accept, connection 都已经建立起来了, Accept 只是将该 connection 包装成一个文件描述符, 供程序 Read, Write 和 Close. 那么关于第二步为什么客户端能 Write 成功就很容易解释了, 因为 connection 早已被建立(数据应该被暂存在服务端的接受缓冲区).
接着再分析 CLOSE_WAIT. 正常情况下 CLOSE_WAIT 在 TCP 挥手过程中持续时间极短, 如果出现则表明"被动关闭 TCP 连接的一方未调用 Close 函数". 观察下图的 TCP 挥手过程, 得知"即使被动关闭一方未调用 Close, 依然会响应 FIN 包发出 ACK 包", 因此主动关闭一方处于 FIN_WAIT2 是理所当然的.
+---------+ ---------\ active OPEN
| CLOSED | \ -----------
+---------+<---------\ \ create TCB
| ^ \ \ snd SYN
passive OPEN | | CLOSE \ \
------------ | | ---------- \ \
create TCB | | delete TCB \ \
V | \ \
+---------+ CLOSE | \
| LISTEN | ---------- | |
+---------+ delete TCB | |
rcv SYN | | SEND | |
----------- | | ------- | V
+---------+ snd SYN,ACK / \ snd SYN +---------+
| |<----------------- ------------------>| |
| SYN | rcv SYN | SYN |
| RCVD |<-----------------------------------------------| SENT |
| | snd ACK | |
| |------------------ -------------------| |
+---------+ rcv ACK of SYN \ / rcv SYN,ACK +---------+
| -------------- | | -----------
| x | | snd ACK
| V V
| CLOSE +---------+
| ------- | ESTAB |
| snd FIN +---------+
| CLOSE | | rcv FIN
V ------- | | -------
+---------+ snd FIN / \ snd ACK +---------+
| FIN |<----------------- ------------------>| CLOSE |
| WAIT-1 |------------------ | WAIT |
+---------+ rcv FIN \ +---------+
| rcv ACK of FIN ------- | CLOSE |
| -------------- snd ACK | ------- |
V x V snd FIN V
+---------+ +---------+ +---------+
|FINWAIT-2| | CLOSING | | LAST-ACK|
+---------+ +---------+ +---------+
| rcv ACK of FIN | rcv ACK of FIN |
| rcv FIN -------------- | Timeout=2MSL -------------- |
| ------- x V ------------ x V
\ snd ACK +---------+delete TCB +---------+
------------------------>|TIME WAIT|------------------>| CLOSED |
+---------+ +---------+
最后, 当客户端进程退出后, 客户端保留的 FIN_WAIT2 状态自然被释放, 但服务端由于未获得 connection 的文件描述符无法主动调用 Close 函数, 因此服务端的 CLOSE_WAIT 将一直持续直到服务端进程退出.
在本文的例子中, 服务端没有能力进行处理(代码中没有拿到 conn), 因为 connection 归操作系统管.
但是如果程序是因为 too many open files 等错误导致 Accept 失败, 那么当操作系统的文件描述符数量下降时 Accept 函数将可以成功, 因此应用程序可以拿到引用该 connection 的文件描述符, 在程序代码中按照正常逻辑 Close 掉该文件描述符即可释放该 connection.
1
123444a Mar 31, 2020 via Android
这有什么好看的。。。linus 看到写这种代码的,直接开启暴龙模式
|
2
chashao Mar 31, 2020 via iPhone
学习了
|
3
zxCoder Mar 31, 2020
这个流程图咋画的,手动调的吗
|
4
no1xsyzy Mar 31, 2020
绝大多数人都会写 TCP 的服务端代码 [来源请求]
|
6
paoqi2048 Mar 31, 2020
画这图费了不少精力吧?
|
7
tomychen Mar 31, 2020
你的假设只是在假定在用封装过 socket()场景,对于裸写过 socket()的人而言这种假定不存在。
tcp socket 没有 accept()后面的事情是无法操作的 所以这么写服务端代码,回去重看 socket 吧 |
8
Mohanson OP @tomychen
我做这个实验的起因是 accept 失败: 也就是你说的 "tcp socket 没有 accept()后面的事情是无法操作的". 我正是探究了如果 accept 失败(或没有 accept, 等效的) TCP 的表现是如何的. 希望你在回复之前先看明白我做这个实验的目的. |
9
tomychen Mar 31, 2020
@Mohanson
我说的回去重看 socket 的意思,就是你看完了,连实验都没有必要再做了,是这么一个意思。 不要以为我说这段话的时候是带情绪的,然则没有。 我说写过裸 socket 的意思也在这里 socket 里,tcp 所有的操作都归到一个 sockfd,windows 里 handle 的一个东西上。 因为原生的每一步操作都是操作都依赖于上一个函数,环环相扣,每一个操失误都会导致下步走不下去。 我说重修 socket 的意思就是,过度依赖封装导致忽视应有的基础。 当然,你要觉得我这是无聊嘴炮,就继续你的。 |
10
icexin Mar 31, 2020 listen fd 是通过 socket 函数创建出来的,可以类比 net.Listen,用裸 socket 是可以复现题主的场景的。
|
11
nightwitch Mar 31, 2020
标准 posix APi 里面的 listen 函数带有一个 backlog 的参数,这个参数可以指定,在 listen 之后,accept 之前,有多少个 client 可以排队连接到这个 socket(Linux 的默认值是 128),也就是处于你说的状态,服务端没有调用 accept 客户端就已经申请 connect 了。 不过我猜,在 server 调用 accept 之前,客户端对处于排队状态的 socket 进行写入操作可能属于未定义行为。
|
12
julyclyde Apr 1, 2020
@nightwitch accept 之前不存在这些 socket 吧
|