TCP状态机11个状态全梳理(以java netty 结合bio,nio, io多路复用模型为线索在linux上梳理)

Java Socket:从 BIO、NIO 到 Linux epoll 的完整内核链路

本文以 Linux TCP、Java 17 附近的 OpenJDK 实现为背景,串联 Java API、Linux 系统调用、文件描述符、TCP 内核结构、等待队列和 epoll。

不同 JDK 和 Linux 内核版本中的具体函数名、字段名可能略有变化,但核心对象关系和事件链路是一致的。

1. 先建立整体认知

BIO、NIO 和 epoll 不在同一个抽象层次:

名称 所在层次 核心含义
BIO Java 编程模型 accept/read/connect 等调用没有完成时,调用线程阻塞
Java NIO Java API 与编程模型 Channel 可设置非阻塞,并用 Selector 管理多个 Channel
O_NONBLOCK Linux fd 状态 系统调用不能立即完成时返回 EAGAINEINPROGRESS
epoll Linux I/O 多路复用机制 一个线程等待大量 fd 的"就绪事件"

必须先记住四句话:

  1. BIO 和 NIO 最终操作的都是 Linux socket。
  2. NIO 不会绕过 TCP 协议栈,仍然需要 socket/bind/listen/connect/accept/read/write
  3. epoll 只通知"哪个 fd 现在可能可以操作",不负责替应用执行 accept/read/write
  4. epoll 不保存业务数据,TCP 数据仍然保存在各连接 socket 的接收队列中。

整体关系如下:

flowchart LR A["Java BIO: ServerSocket / Socket"] --> C["JDK Socket 实现"] B["Java NIO: Channel / Selector"] --> D["JDK NIO 实现"] C --> E["Linux socket 系统调用"] D --> E D --> F["epoll_create1 / epoll_ctl / epoll_wait"] E --> G["Linux TCP/IP 协议栈"] F --> G

2. fd、struct file、struct socket 和 struct tcp_sock

2.1 fd 只是进程文件描述符表的下标

假设 Linux 为 socket 返回 fd=3,这个数字不是内核地址,只是数组下标:

text 复制代码
current->files->fdt->fd[3] → struct file *

核心对象关系是:

text 复制代码
进程 fd=3
    │
    ▼
struct file
    │ file->private_data
    ▼
struct socket
    │ socket->sk
    ▼
一整块 struct tcp_sock 内存

struct file 不一定表示磁盘文件。Linux 的 socket、管道、eventfd 和 epoll 实例都可以通过 struct file 暴露成 fd。

2.2 struct socket 是系统调用层包装

简化后的结构:

c 复制代码
struct socket {
    socket_state state;
    short type;                  // SOCK_STREAM
    struct file *file;
    struct sock *sk;             // 指向协议对象
    const struct proto_ops *ops; // bind/listen/accept/connect 等
};

对于 IPv4 TCP:

text 复制代码
socket->type = SOCK_STREAM
socket->ops  = inet_stream_ops
socket->sk   = TCP 协议对象

2.3 四种 sock 结构是同一块内存的不同视图

它们不是四个对象用指针串起来,而是结构体逐层嵌套:

c 复制代码
struct inet_sock {
    struct sock sk;
    // IP 地址、端口等
};

struct inet_connection_sock {
    struct inet_sock icsk_inet;
    // Accept 队列、连接定时器等
};

struct tcp_sock {
    struct inet_connection_sock inet_conn;
    // TCP 序列号、窗口、拥塞控制等
};

内存布局可以理解成:

text 复制代码
一整块 struct tcp_sock 内存
┌──────────────────────────────────────────┐
│ struct sock                              │
│   sk_state、sk_wq、sk_receive_queue 等    │
├──────────────────────────────────────────┤
│ struct inet_sock 新增部分                 │
│   本地/远端 IP 和端口                     │
├──────────────────────────────────────────┤
│ struct inet_connection_sock 新增部分      │
│   icsk_accept_queue、连接定时器等          │
├──────────────────────────────────────────┤
│ struct tcp_sock 新增部分                  │
│   snd_nxt、rcv_nxt、snd_cwnd 等            │
└──────────────────────────────────────────┘

内核根据需要切换视角:

c 复制代码
struct sock *sk = socket->sk;
struct inet_sock *inet = inet_sk(sk);
struct inet_connection_sock *icsk = inet_csk(sk);
struct tcp_sock *tp = tcp_sk(sk);

这些指针通常拥有相同的起始地址,只是编译器用不同类型解释该内存。

2.4 重要字段和队列

字段或队列 作用 哪类 socket 主要使用
sk->sk_state TCP_LISTEN/TCP_SYN_SENT/TCP_ESTABLISHED 所有 TCP socket
inet->inet_num 本地主机字节序端口 所有已绑定 socket
inet->inet_sport 本地网络字节序端口 所有已绑定 socket
inet->inet_daddr/dport 对端 IP 和端口 已连接 socket
icsk->icsk_accept_queue 等待应用 accept() 的连接 监听 socket
sk->sk_receive_queue 等待应用读取的数据 已连接 socket
sk->sk_wq socket 事件等待队列 阻塞 I/O、poll、epoll 都会关联
sk->sk_ack_backlog 当前 Accept 队列长度 监听 socket
sk->sk_max_ack_backlog Accept 队列上限 监听 socket

客户端和服务端都使用相同大小的 struct tcp_sock。客户端对象在内存布局上也有 icsk_accept_queue 字段,但只有 TCP_LISTEN socket 会真正使用它。

3. BIO 服务端:bind、listen、accept

示例:

java 复制代码
ServerSocket serverSocket = new ServerSocket(8080);
Socket socket = serverSocket.accept();

3.1 创建、绑定和监听

第一行核心系统调用链路:

text 复制代码
socket(AF_INET/AF_INET6, SOCK_STREAM, IPPROTO_TCP)
  → bind(fd, 0.0.0.0:8080 或 [::]:8080)
  → listen(fd, 50)

状态变化:

text 复制代码
socket() 后
  sk_state   = TCP_CLOSE
  local_port = 0

bind() 后
  sk_state   = TCP_CLOSE
  local_ip   = 0.0.0.0
  local_port = 8080
  加入端口绑定哈希体系

listen() 后
  sk_state            = TCP_LISTEN
  sk_max_ack_backlog  = min(50, net.core.somaxconn)
  初始化 Accept 队列
  加入监听哈希表

bind() 解决"这个 socket 占用哪个本地地址和端口";listen() 解决"这个 TCP socket 是否接受被动连接"。

3.2 accept 队列与线程等待队列不是同一个队列

监听 socket 同时涉及两个概念:

text 复制代码
listener struct tcp_sock
├── icsk_accept_queue
│     保存已完成握手、等待 accept() 的 child socket
│
└── sk_wq->wait
      保存正在等待 socket 事件的线程或 poll/epoll 回调节点

前者放"连接",后者关联"等待者"。

3.3 BIO accept 阻塞链路

大致内核调用链:

text 复制代码
accept4()
  → __sys_accept4()
  → socket->ops->accept()
  → inet_accept()
  → inet_csk_accept()

如果 Accept 队列非空:

text 复制代码
从 icsk_accept_queue 取出 child struct sock
  → 为它创建 struct socket
  → 创建 struct file
  → 分配新 fd
  → accept4() 返回新 fd

如果 Accept 队列为空且 fd 是阻塞模式:

text 复制代码
当前线程加入 listener->sk_wq->wait
  → 线程状态设为 TASK_INTERRUPTIBLE
  → schedule()
  → 从 CPU 可运行队列移出

简化伪代码:

c 复制代码
while (accept_queue_empty(listener)) {
    prepare_to_wait_exclusive(sk_sleep(listener), current);
    set_current_state(TASK_INTERRUPTIBLE);
    schedule();
}

child = dequeue_accept_queue(listener);

线程阻塞时不自旋,不持续占用 CPU。它保留内核栈和执行上下文,等待网络事件或信号将其唤醒。

4. BIO 客户端:connect

示例:

java 复制代码
Socket socket = new Socket();
socket.connect(new InetSocketAddress("localhost", 8080));

4.1 Java 对象与内核 socket

现代 JDK 通常延迟创建 native socket:

text 复制代码
new Socket()
  → 创建 Java Socket 和 SocketImpl
  → 可能尚未调用 Linux socket()

connect() 时才确保 native fd 存在:

text 复制代码
解析 localhost
  → socket()
  → connect()

localhost 通常通过 /etc/hosts 解析成 127.0.0.1::1

4.2 connect 内核链路

IPv4 TCP 大致经过:

text 复制代码
connect()
  → __sys_connect()
  → socket->ops->connect()
  → inet_stream_connect()
  → tcp_v4_connect()

内核自动选择本地地址和临时端口:

text 复制代码
local  = 127.0.0.1:52346
remote = 127.0.0.1:8080

客户端状态:

text 复制代码
TCP_CLOSE
  → TCP_SYN_SENT
  → TCP_ESTABLISHED

阻塞式 connect() 在握手未完成时,大致等待于:

text 复制代码
sk_stream_wait_connect()
  → 当前线程关联到 client socket 的 sk_wq
  → TASK_INTERRUPTIBLE
  → schedule()

4.3 connect 和服务端 accept 的关系

TCP 三次握手完成后:

text 复制代码
客户端 socket
  sk_state = TCP_ESTABLISHED
  connect() 可以返回

服务端 listener
  Accept 队列加入 child socket
  唤醒可能阻塞的 accept() 线程

客户端 connect() 不等待服务端 Java 代码实际调用 accept()。只要三次握手完成、服务端内核能把 child socket 放入 Accept 队列,客户端 connect() 就可以成功返回。

5. BIO read 阻塞在哪里

连接建立后:

java 复制代码
int n = socket.getInputStream().read(buffer);

最终进入类似:

text 复制代码
read()/recvfrom()
  → sock_read_iter()
  → inet_recvmsg()
  → tcp_recvmsg()

如果 sk_receive_queue 中有数据,内核复制到用户缓冲区并立即返回。

如果没有数据,阻塞线程等待的是:

text 复制代码
connection->sk_receive_queue 非空
或者收到 FIN、RST、错误、超时、信号

线程仍然通过连接 socket 的 sk_wq 进入等待:

text 复制代码
connection struct tcp_sock
├── sk_receive_queue = empty
└── sk_wq->wait
      └── read() 线程,TASK_INTERRUPTIBLE

accept()read() 的底层休眠机制相似,但条件不同:

调用 操作对象 等待条件
accept() TCP_LISTEN socket Accept 队列非空
read() TCP_ESTABLISHED socket 接收队列有数据、EOF 或错误
connect() TCP_SYN_SENT socket 进入 TCP_ESTABLISHED 或发生错误

6. BIO 的线程模型

典型 BIO 服务端:

java 复制代码
while (true) {
    Socket client = serverSocket.accept();
    executor.submit(() -> handle(client));
}

结构:

text 复制代码
一个 accept 线程
  ├── 连接 A → 工作线程 A 阻塞 read()
  ├── 连接 B → 工作线程 B 阻塞 read()
  └── 连接 C → 工作线程 C 阻塞 read()

空闲连接仍可能占用一个平台线程。线程阻塞本身不持续消耗 CPU,但大量线程会带来:

  • 线程栈内存;
  • 调度和上下文切换;
  • 线程池容量管理;
  • 慢连接导致的线程长期占用。

BIO 并不等于"一定性能差"。连接数量有限、业务简单、使用虚拟线程或者线程模型容易维护时,BIO 可能是更合适的实现。

7. Java NIO:Channel 的非阻塞含义

服务端核心代码:

java 复制代码
ServerSocketChannel server = ServerSocketChannel.open();
server.configureBlocking(false);
server.bind(new InetSocketAddress(8080));

SocketChannel client = server.accept();
if (client == null) {
    // 当前 Accept 队列为空,立即返回
}

7.1 ServerSocketChannel.open()

通常会创建 native socket:

text 复制代码
ServerSocketChannel.open()
  → socket(AF_INET/AF_INET6, SOCK_STREAM, ...)
  → 得到 fd

此时内核对象仍然是普通 TCP socket:

text 复制代码
fd
  → struct file
  → struct socket
  → struct tcp_sock
       sk_state = TCP_CLOSE

7.2 configureBlocking(false)

JDK 通常通过类似调用把 fd 设置为非阻塞:

text 复制代码
fcntl(fd, F_SETFL, O_NONBLOCK)

重要的是:

text 复制代码
O_NONBLOCK 修改的是 fd 对应 open file description 的行为
不会把 struct tcp_sock 换成另一种结构
不会改变 TCP 协议状态机
不会创建 epoll

BIO socket 和 NIO socket 在 Linux TCP 层仍然使用相同的 struct tcp_sock

7.3 非阻塞 accept

如果 Accept 队列有连接:

text 复制代码
accept4()
  → 取出 child socket
  → 返回新 fd

如果 Accept 队列为空:

text 复制代码
accept4()
  → 不把线程加入 listener 等待队列休眠
  → 返回 -EAGAIN/-EWOULDBLOCK
  → Java ServerSocketChannel.accept() 返回 null

如果应用不断这样调用:

java 复制代码
while (server.accept() == null) {
}

就会形成用户态忙轮询,占满 CPU。Selector/epoll 的意义就是避免这种忙轮询。

7.4 非阻塞 read

java 复制代码
int n = channel.read(buffer);

可能返回:

返回值 含义
n > 0 读取了 n 字节
n == 0 当前没有数据,非阻塞立即返回
n == -1 对端正常关闭写方向,读到 EOF

Linux 层没有数据时返回 EAGAIN,JDK 把它转换成 Java NIO 的 0

7.5 非阻塞 connect

java 复制代码
SocketChannel channel = SocketChannel.open();
channel.configureBlocking(false);
boolean done = channel.connect(address);

通常发生:

text 复制代码
connect(fd, server)
  → 开始发送 SYN
  → sk_state = TCP_SYN_SENT
  → 握手尚未完成
  → 返回 -EINPROGRESS
  → Java connect() 返回 false

握手完成后,fd 通常表现为"可写",应用再调用:

java 复制代码
channel.finishConnect();

finishConnect() 检查连接最终结果:

text 复制代码
TCP_ESTABLISHED → 成功
SO_ERROR 非 0   → 抛出 ConnectException 等异常
仍在连接中      → 尚未完成

8. Selector 与 epoll 的关系

Java API:

java 复制代码
Selector selector = Selector.open();

在 Linux OpenJDK 中通常使用基于 epoll 的 Selector 实现:

text 复制代码
Java Selector
  → EPollSelectorImpl
  → epoll_create1()
  → epoll_ctl()
  → epoll_wait()

但规范只规定 Selector 行为,不保证所有操作系统都使用 epoll:

text 复制代码
Linux   → 通常 epoll
macOS   → kqueue
其他系统 → 对应平台实现

8.1 Selector 事件与 epoll 事件映射

Java SelectionKey 典型 epoll 事件 内核就绪条件
OP_ACCEPT EPOLLIN 监听 socket 的 Accept 队列非空
OP_CONNECT EPOLLOUT 加错误状态 非阻塞 connect 完成或失败
OP_READ EPOLLIN 接收队列有数据、FIN 或错误
OP_WRITE EPOLLOUT 发送缓冲区有足够空间

同一个 EPOLLIN 在不同 socket 状态下含义不同:

text 复制代码
TCP_LISTEN socket
  EPOLLIN → 可以 accept()

TCP_ESTABLISHED socket
  EPOLLIN → 可以 read(),或者存在 EOF/错误

9. epoll 的内核对象

9.1 epoll_create1()

调用:

c 复制代码
int epfd = epoll_create1(EPOLL_CLOEXEC);

Linux 会创建一个 epoll 实例,并同样通过 fd 暴露:

text 复制代码
epfd=6
  → struct file
       → private_data
            → struct eventpoll

简化后的 eventpoll

c 复制代码
struct eventpoll {
    struct rb_root_cached rbr; // 关注集合,保存注册的 epitem
    struct list_head rdllist;  // 就绪链表
    wait_queue_head_t wq;      // epoll_wait() 线程等待队列
    // 锁、溢出链表、被监控 file 等
};

每个被监控的 fd 对应一个类似 epitem 的注册项:

c 复制代码
struct epitem {
    struct rb_node rbn;         // 挂在关注集合中
    struct list_head rdllink;   // 就绪时挂入 rdllist
    struct epoll_filefd ffd;    // 被监控的 file/fd
    struct eventpoll *ep;       // 所属 epoll 实例
    struct epoll_event event;   // EPOLLIN/EPOLLOUT 等
    // 与目标文件等待队列关联的信息
};

可以理解成:

text 复制代码
eventpoll
├── 关注集合 rbr
│     ├── epitem(listener fd)
│     ├── epitem(client A fd)
│     └── epitem(client B fd)
│
├── 就绪链表 rdllist
│     └── 当前已经就绪的 epitem
│
└── epoll_wait 等待队列 wq
      └── 当前事件循环线程

红黑树是常见内核实现细节,用于管理关注集合。对应用最重要的是"关注集合"和"就绪链表"的区别。

9.2 epoll_ctl(ADD) 做了什么

Java:

java 复制代码
server.register(selector, SelectionKey.OP_ACCEPT);

最终会使 JDK 执行类似:

c 复制代码
epoll_ctl(epfd, EPOLL_CTL_ADD, serverFd, &event);

内核大致完成:

  1. 找到 epfd 对应的 struct eventpoll
  2. 找到 serverFd 对应的 struct file
  3. 创建 struct epitem
  4. 把 epitem 插入 eventpoll 的关注集合。
  5. 调用目标文件的 poll 方法查询当前状态。
  6. 把 epoll 回调节点注册到目标 socket 的等待队列。

关键不是 epoll 反复扫描所有 socket,而是注册时建立回调关系:

text 复制代码
listener socket 的 sk_wq
    │
    └── epoll callback
           │
           ▼
      eventpoll 中对应的 epitem

更完整的关系:

flowchart LR A["listener struct sock"] --> B["sk_wq socket等待队列"] B --> C["epoll callback"] C --> D["epitem(listener fd)"] D --> E["eventpoll 就绪链表 rdllist"] E --> F["eventpoll.wq"] F --> G["epoll_wait 线程"]

内核注册阶段会通过 socket 的 poll 实现检查就绪条件。TCP 常见路径可以概念化为:

text 复制代码
epoll_ctl(ADD)
  → target_file->f_op->poll()
  → sock_poll()/tcp_poll()
  → poll_wait(file, sk_sleep(sk), ...)
  → 将 epoll 回调挂到 socket 的 sk_wq

9.3 epoll_wait() 阻塞在哪里

Java:

java 复制代码
int count = selector.select();

Linux:

c 复制代码
epoll_wait(epfd, events, maxEvents, timeout);

如果 rdllist 已经有就绪项,立即把事件复制到用户态。

如果 rdllist 为空:

text 复制代码
当前事件循环线程加入 eventpoll->wq
  → TASK_INTERRUPTIBLE
  → schedule()/带超时调度

此时线程主要睡在 epoll 实例的等待队列上,而不是分别睡到每一个 socket 的等待队列上:

text 复制代码
多个 socket
  ├── socket A sk_wq ──callback──┐
  ├── socket B sk_wq ──callback──┼──> eventpoll
  └── socket C sk_wq ──callback──┘        │
                                          ▼
                                  eventpoll->wq
                                          │
                                          ▼
                                  一个 epoll_wait 线程

这就是一个线程能够等待大量连接的核心原因。

10. 新连接如何通过 epoll 唤醒 Selector

假设 listener 已注册 OP_ACCEPT,事件循环阻塞在:

text 复制代码
selector.select()
  → epoll_wait()

客户端完成三次握手后:

text 复制代码
1. 内核创建 child struct tcp_sock
2. child 加入 listener 的 Accept 队列
3. listener 的 sk_ack_backlog 增加
4. 内核触发 listener 的可读事件
5. 唤醒 listener->sk_wq 上的回调
6. epoll callback 把 listener 对应 epitem 放入 rdllist
7. 唤醒 eventpoll->wq 上的 epoll_wait 线程
8. epoll_wait 返回 EPOLLIN
9. JDK 转换为 SelectionKey.OP_ACCEPT
10. Java 调用 ServerSocketChannel.accept()

结构变化:

text 复制代码
握手前
listener
├── Accept 队列 = empty
└── epitem 不在 rdllist

握手完成
listener
├── Accept 队列
│     └── child TCP_ESTABLISHED
└── epitem 加入 eventpoll.rdllist

epoll_wait 返回后
Java Selector selectedKeys
└── listener SelectionKey,isAcceptable() = true

Java 必须继续调用:

java 复制代码
SocketChannel channel = server.accept();

epoll 只通知,真正从 Accept 队列取连接的仍然是 accept4()

11. TCP 数据如何通过 epoll 唤醒 Selector

假设连接 channel 注册了 OP_READ

数据到达大致经过:

text 复制代码
网卡/loopback 收包
  → IP 层
  → TCP 层
  → 根据四元组在 established hash 找到 child tcp_sock
  → TCP 校验、排序、组装
  → 数据进入 socket 接收队列
  → 触发 sk_data_ready
  → 唤醒 child->sk_wq 上的 epoll callback
  → epitem 加入 eventpoll.rdllist
  → 唤醒 epoll_wait 线程
  → epoll_wait 返回 EPOLLIN
  → Java SelectionKey.isReadable() == true

Java 随后执行:

java 复制代码
int n = channel.read(buffer);

这一步才真正把数据从内核 socket 接收缓冲区复制到 Java 的 ByteBuffer。

因此:

text 复制代码
EPOLLIN
  不表示数据已经进入 Java ByteBuffer
  只表示 read() 现在大概率不会阻塞

在并发竞争、事件已被其他线程消费等情况下,即使收到就绪通知,非阻塞 read() 仍可能返回 0/EAGAIN,所以应用必须正确处理返回值。

12. 非阻塞 connect 如何通过 epoll 完成

客户端:

java 复制代码
SocketChannel client = SocketChannel.open();
client.configureBlocking(false);

if (!client.connect(address)) {
    client.register(selector, SelectionKey.OP_CONNECT);
}

状态变化:

text 复制代码
socket()
  sk_state = TCP_CLOSE

connect()
  自动绑定本地临时端口
  设置远端地址
  sk_state = TCP_SYN_SENT
  返回 EINPROGRESS

三次握手完成
  sk_state = TCP_ESTABLISHED
  fd 表现为 EPOLLOUT/连接完成

事件循环收到 OP_CONNECT 后:

java 复制代码
if (key.isConnectable()) {
    SocketChannel channel = (SocketChannel) key.channel();
    if (channel.finishConnect()) {
        key.interestOps(SelectionKey.OP_READ);
    }
}

必须调用 finishConnect(),因为"可连接事件"既可能表示成功,也可能表示连接失败。最终错误需要通过 socket 错误状态确认。

13. OP_WRITE 为什么容易造成空转

TCP socket 的发送缓冲区通常大部分时间都有空间,因此 EPOLLOUT 往往长期处于就绪状态。

如果一直注册:

java 复制代码
key.interestOps(SelectionKey.OP_READ | SelectionKey.OP_WRITE);

那么 selector.select() 可能持续立即返回,形成高 CPU 空转。

正确模式通常是:

text 复制代码
平时只关注 OP_READ

出现待发送数据,并且一次 write() 没写完
  → 打开 OP_WRITE

待发送队列全部写完
  → 移除 OP_WRITE

非阻塞 write() 返回值:

text 复制代码
n > 0  → 写入了一部分或全部数据
n == 0 → 当前发送缓冲区空间不足,等待下一次 OP_WRITE

14. LT 与 ET

epoll 有两种典型通知模式。

14.1 LT:水平触发

只要就绪条件仍然成立,每次 epoll_wait() 都可以继续返回该事件:

text 复制代码
接收队列还有数据
  → EPOLLIN
  → 只读取一部分
  → 接收队列仍有数据
  → 下次 epoll_wait 继续返回 EPOLLIN

Java 标准 NIO Selector 在 Linux 上通常使用接近 LT 的行为。

14.2 ET:边缘触发

主要在状态从"不就绪"变为"就绪"时通知:

text 复制代码
接收队列从空变为非空
  → 通知一次 EPOLLIN

ET 下必须循环读到 EAGAIN

c 复制代码
for (;;) {
    n = read(fd, buf, sizeof(buf));
    if (n > 0) {
        consume(buf, n);
        continue;
    }
    if (n == -1 && errno == EAGAIN) {
        break;
    }
    handle_close_or_error();
    break;
}

类似地,监听 fd 在 ET 模式下一般要循环 accept()EAGAIN,否则 Accept 队列中可能残留连接而暂时得不到新的边缘通知。

Netty 的 NioEventLoopGroup 使用 Java NIO Selector;Netty 的 native epoll transport 则可以直接使用 Linux epoll,并根据实现采用更贴近 native 的事件模式。两者不要仅凭名称混为一谈。

15. 完整 NIO + Selector 服务端示例

java 复制代码
package com.lgw.nio;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;

public final class SelectorServer {

    public static void main(String[] args) throws IOException {
        try (Selector selector = Selector.open();
             ServerSocketChannel server = ServerSocketChannel.open()) {

            server.configureBlocking(false);
            server.bind(new InetSocketAddress(8080));
            server.register(selector, SelectionKey.OP_ACCEPT);

            while (true) {
                selector.select();

                Iterator<SelectionKey> iterator =
                        selector.selectedKeys().iterator();

                while (iterator.hasNext()) {
                    SelectionKey key = iterator.next();
                    iterator.remove();

                    if (!key.isValid()) {
                        continue;
                    }

                    if (key.isAcceptable()) {
                        acceptAll((ServerSocketChannel) key.channel(), selector);
                    }

                    if (key.isReadable()) {
                        read((SocketChannel) key.channel(), key);
                    }
                }
            }
        }
    }

    private static void acceptAll(ServerSocketChannel server,
                                  Selector selector) throws IOException {
        SocketChannel client;

        // 即使标准 Selector 通常是 LT,也使用循环 accept 的稳健写法。
        while ((client = server.accept()) != null) {
            client.configureBlocking(false);
            ByteBuffer buffer = ByteBuffer.allocateDirect(4096);
            client.register(selector, SelectionKey.OP_READ, buffer);
        }
    }

    private static void read(SocketChannel client,
                             SelectionKey key) throws IOException {
        ByteBuffer buffer = (ByteBuffer) key.attachment();
        int n = client.read(buffer);

        if (n > 0) {
            buffer.flip();

            while (buffer.hasRemaining()) {
                // 示例只消费数据;真实协议需要处理拆包、粘包和半包。
                buffer.get();
            }

            buffer.clear();
            return;
        }

        if (n == -1) {
            key.cancel();
            client.close();
        }
    }
}

这段代码对应的内核行为:

text 复制代码
Selector.open()
  → epoll_create1()

ServerSocketChannel.open()
  → socket()

configureBlocking(false)
  → fcntl(O_NONBLOCK)

bind()
  → bind()
  → listen()

register(OP_ACCEPT)
  → epoll_ctl(ADD/MOD, listenerFd, EPOLLIN)

selector.select()
  → epoll_wait()

server.accept()
  → accept4()

client.register(OP_READ)
  → epoll_ctl(ADD/MOD, clientFd, EPOLLIN)

client.read()
  → read()/recvmsg()

JDK 可能把注册变更先放入更新队列,在下一次 select() 前统一执行 epoll_ctl(),所以 Java 的 register() 与系统调用不一定严格一一同步发生。

16. 完整非阻塞客户端示例

java 复制代码
package com.lgw.nio;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.SocketChannel;
import java.util.Iterator;

public final class SelectorClient {

    public static void main(String[] args) throws IOException {
        try (Selector selector = Selector.open();
             SocketChannel client = SocketChannel.open()) {

            client.configureBlocking(false);

            boolean connected = client.connect(
                    new InetSocketAddress("localhost", 8080));

            if (connected) {
                client.register(selector, SelectionKey.OP_READ);
            } else {
                client.register(selector, SelectionKey.OP_CONNECT);
            }

            while (client.isOpen()) {
                selector.select();

                Iterator<SelectionKey> iterator =
                        selector.selectedKeys().iterator();

                while (iterator.hasNext()) {
                    SelectionKey key = iterator.next();
                    iterator.remove();

                    if (!key.isValid()) {
                        continue;
                    }

                    if (key.isConnectable()) {
                        SocketChannel channel = (SocketChannel) key.channel();

                        if (channel.finishConnect()) {
                            key.interestOps(SelectionKey.OP_READ);
                        }
                    }

                    if (key.isReadable()) {
                        // 按协议读取响应;示例省略。
                    }
                }
            }
        }
    }
}

17. BIO、NIO 和 epoll 阻塞点对比

17.1 BIO accept

text 复制代码
线程
  → accept4(listenerFd)
  → Accept 队列为空
  → 关联 listener->sk_wq
  → TASK_INTERRUPTIBLE
  → schedule()

17.2 BIO read

text 复制代码
线程
  → read(connectionFd)
  → sk_receive_queue 为空
  → 关联 connection->sk_wq
  → TASK_INTERRUPTIBLE
  → schedule()

17.3 NIO 非阻塞调用

text 复制代码
accept4/read/connect
  → 当前不能完成
  → 立即返回 EAGAIN/EINPROGRESS
  → 不在这个系统调用里睡眠

17.4 Selector/epoll

text 复制代码
一个事件循环线程
  → epoll_wait(epfd)
  → eventpoll.rdllist 为空
  → 加入 eventpoll->wq
  → TASK_INTERRUPTIBLE
  → schedule()

因此 NIO 并不是"完全不阻塞"。通常是:

text 复制代码
业务 socket 操作不阻塞
事件循环线程集中阻塞在一次 epoll_wait()

18. socket 和 epoll 结构的完整关系

注册三个 fd 后,可以概括为:

text 复制代码
Java Selector
  │
  ▼
epfd=6
  → struct file
  → struct eventpoll
       ├── 关注集合
       │     ├── epitem(fd=3 listener)
       │     ├── epitem(fd=4 connection A)
       │     └── epitem(fd=5 connection B)
       │
       ├── 就绪链表 rdllist
       │     └── 已就绪 epitem
       │
       └── epoll_wait 等待队列 wq
             └── EventLoop 线程

fd=3 listener
  → struct file
  → struct socket
  → listener struct tcp_sock
       ├── sk_state = TCP_LISTEN
       ├── icsk_accept_queue
       └── sk_wq ──epoll callback──> epitem(fd=3)

fd=4 connection A
  → struct file
  → struct socket
  → child struct tcp_sock
       ├── sk_state = TCP_ESTABLISHED
       ├── sk_receive_queue
       └── sk_wq ──epoll callback──> epitem(fd=4)

fd=5 connection B
  → struct file
  → struct socket
  → child struct tcp_sock
       ├── sk_state = TCP_ESTABLISHED
       ├── sk_receive_queue
       └── sk_wq ──epoll callback──> epitem(fd=5)

核心不是 epoll 把所有 socket 数据搬到自己内部,而是:

text 复制代码
每个 socket 仍拥有自己的协议状态和数据队列
epoll 只维护关注关系和就绪引用

19. 服务端与客户端 socket 的结构变化时间线

19.1 服务端监听 socket

text 复制代码
socket()
  fd=3
  sk_state = TCP_CLOSE
  local_port = 0

bind(:8080)
  sk_state = TCP_CLOSE
  local = 0.0.0.0:8080
  加入 bind hash

listen(50)
  sk_state = TCP_LISTEN
  初始化 Accept 队列
  sk_max_ack_backlog = 50
  加入 listen hash

epoll_ctl(ADD, fd=3, EPOLLIN)
  创建 epitem
  epoll callback 关联 listener->sk_wq
  TCP 状态不变

19.2 客户端连接 socket

text 复制代码
socket()
  fd=4
  sk_state = TCP_CLOSE

connect(server:8080)
  自动绑定 local:临时端口
  设置 remote:8080
  sk_state = TCP_SYN_SENT

握手完成
  sk_state = TCP_ESTABLISHED
  非阻塞 connect fd 就绪

epoll_ctl(ADD, fd=4, EPOLLIN)
  创建 epitem
  callback 关联 client->sk_wq

19.3 服务端 child socket

text 复制代码
客户端 SYN
  → listener 创建 request_sock

三次握手完成
  → 创建 child struct tcp_sock
  → child.sk_state = TCP_ESTABLISHED
  → child 加入 listener Accept 队列
  → listener 产生 EPOLLIN

应用 accept4()
  → child 从 Accept 队列移除
  → 创建新 struct socket/struct file/fd

epoll_ctl(ADD, childFd, EPOLLIN)
  → child 开始由 Selector 监控读事件

20. 一次新连接和一次读事件的完整时序

sequenceDiagram participant C as Client SocketChannel participant K as Linux TCP participant L as Listener Socket participant E as epoll participant T as Selector Thread T->>E: epoll_wait() C->>K: connect() / SYN K->>L: listener lookup L-->>C: SYN-ACK C->>L: ACK K->>L: child 加入 Accept 队列 L->>E: socket waitqueue callback E->>T: EPOLLIN / OP_ACCEPT T->>L: accept4() L-->>T: child fd T->>E: epoll_ctl(ADD child, EPOLLIN) C->>K: 发送业务数据 K->>L: 数据进入 child 接收队列 L->>E: child waitqueue callback E->>T: EPOLLIN / OP_READ T->>L: read(child fd) L-->>T: 复制数据到 ByteBuffer

21. 常见误区

误区一:NIO 使用不同类型的内核 socket

错误。BIO 和 NIO 最终都使用 struct socket + struct tcp_sock。主要差异是 fd 是否设置 O_NONBLOCK,以及应用如何等待就绪事件。

误区二:epoll 帮应用执行 read

错误。epoll 只返回事件,应用仍必须调用 read()

误区三:EPOLLIN 一定表示有普通业务数据

错误。它还可能表示:

  • 监听 socket 有连接可以 accept()
  • 对端发送 FIN,读取将得到 EOF;
  • socket 出现错误;
  • 接收队列有数据。

误区四:NIO 没有任何线程阻塞

错误。事件循环线程通常阻塞在 epoll_wait(),只是没有为每个连接分别阻塞一个线程。

误区五:accept() 才创建服务端 TCP 连接

不准确。TCP 三次握手完成时,内核通常已经创建 child struct sock 并放入 Accept 队列;accept() 主要负责取出它并包装成新的 fd。

误区六:连接建立就代表服务端业务代码已经处理

错误。客户端 connect() 成功只说明 TCP 握手完成。服务端连接可能还在 Accept 队列,甚至尚未被 Java accept() 取出。

误区七:注册 OP_WRITE 越多越好

错误。发送缓冲区通常可写,长期注册 OP_WRITE 很容易让事件循环持续空转。

22. 如何在 Linux 上观察

22.1 查看监听和连接

bash 复制代码
ss -lntp 'sport = :8080'
bash 复制代码
ss -ntp '( sport = :8080 or dport = :8080 )'

常见状态:

text 复制代码
LISTEN      → TCP_LISTEN
SYN-SENT    → TCP_SYN_SENT
SYN-RECV    → TCP_SYN_RECV
ESTAB       → TCP_ESTABLISHED

22.2 查看进程 fd

bash 复制代码
ls -l /proc/<pid>/fd

可能看到:

text 复制代码
3 -> socket:[123456]
4 -> socket:[123457]
6 -> anon_inode:[eventpoll]
7 -> anon_inode:[eventfd]

anon_inode:[eventpoll] 通常就是 Selector 使用的 epoll fd。JDK 还可能创建 eventfd,用于从其他线程唤醒阻塞的 Selector。

22.3 用 strace 观察 BIO

bash 复制代码
strace -ff \
  -e trace=socket,bind,listen,accept,accept4,connect,read,write,close \
  java BioServer

可能看到:

text 复制代码
socket(AF_INET6, SOCK_STREAM, ...) = 3
bind(3, ..., 8080)                 = 0
listen(3, 50)                      = 0
accept4(3, ...)                    = 4
read(4, ...)                       = ...

22.4 用 strace 观察 NIO/epoll

bash 复制代码
strace -ff \
  -e trace=socket,bind,listen,accept4,connect,read,write,fcntl,epoll_create1,epoll_ctl,epoll_wait,eventfd2 \
  java SelectorServer

典型模式:

text 复制代码
epoll_create1(...)                 = 6
socket(...)                        = 3
fcntl(3, F_SETFL, O_NONBLOCK)      = 0
bind(3, ...)                       = 0
listen(3, ...)                     = 0
epoll_ctl(6, EPOLL_CTL_ADD, 3, ...) = 0
epoll_wait(6, ...)                 = 1
accept4(3, ..., SOCK_NONBLOCK)     = 4
epoll_ctl(6, EPOLL_CTL_ADD, 4, ...) = 0
epoll_wait(6, ...)                 = 1
read(4, ...)                       = ...

JDK 的实际系统调用顺序可能因版本、IPv4/IPv6、Selector 唤醒实现和超时设置不同而变化。

23. TCP 建连与关闭状态机

TCP 的状态保存在:

c 复制代码
sk->sk_state

常见状态如下。问题中的 SYN_RECD 正确名称是 SYN_RECV

Linux 状态 /proc/net/tcp 含义
TCP_ESTABLISHED 01 三次握手完成,可以双向传输数据
TCP_SYN_SENT 02 主动连接方已经发送 SYN,等待 SYN-ACK
TCP_SYN_RECV 03 已收到 SYN 并回复 SYN-ACK,等待最后 ACK
TCP_FIN_WAIT1 04 主动关闭方已经发送 FIN,等待 ACK 或对端 FIN
TCP_FIN_WAIT2 05 自己的 FIN 已被确认,等待对端发送 FIN
TCP_TIME_WAIT 06 已确认对端 FIN,等待旧报文过期并处理 FIN 重传
TCP_CLOSE 07 未建立连接或连接已经彻底结束
TCP_CLOSE_WAIT 08 收到对端 FIN,等待本地应用关闭
TCP_LAST_ACK 09 被动关闭方已发送自己的 FIN,等待最后 ACK
TCP_LISTEN 0A 监听端口,等待连接请求
TCP_CLOSING 0B 双方几乎同时发送 FIN,等待自己的 FIN 被确认
TCP_NEW_SYN_RECV 0C Linux 内部表示尚未完成握手的请求状态之一

使用 ss 时通常显示为:

text 复制代码
LISTEN
SYN-SENT
SYN-RECV
ESTAB
FIN-WAIT-1
FIN-WAIT-2
CLOSE-WAIT
LAST-ACK
CLOSING
TIME-WAIT
CLOSE

23.1 完整状态机概览

flowchart TD CLOSE[&#34;CLOSE&#34;] -->|&#34;服务端 listen()&#34;| LISTEN[&#34;LISTEN&#34;] CLOSE -->|&#34;客户端 connect() / 发送 SYN&#34;| SYN_SENT[&#34;SYN_SENT&#34;] LISTEN -->|&#34;收到 SYN / 发送 SYN-ACK&#34;| SYN_RECV[&#34;SYN_RECV&#34;] SYN_SENT -->|&#34;收到 SYN-ACK / 发送 ACK&#34;| ESTABLISHED[&#34;ESTABLISHED&#34;] SYN_RECV -->|&#34;收到最终 ACK&#34;| ESTABLISHED ESTABLISHED -->|&#34;本端主动 close / 发送 FIN&#34;| FIN_WAIT1[&#34;FIN_WAIT1&#34;] FIN_WAIT1 -->|&#34;收到本端 FIN 的 ACK&#34;| FIN_WAIT2[&#34;FIN_WAIT2&#34;] FIN_WAIT2 -->|&#34;收到对端 FIN / 发送 ACK&#34;| TIME_WAIT[&#34;TIME_WAIT&#34;] TIME_WAIT -->|&#34;超时&#34;| CLOSE ESTABLISHED -->|&#34;收到对端 FIN / 回复 ACK&#34;| CLOSE_WAIT[&#34;CLOSE_WAIT&#34;] CLOSE_WAIT -->|&#34;本地应用 close / 发送 FIN&#34;| LAST_ACK[&#34;LAST_ACK&#34;] LAST_ACK -->|&#34;收到最终 ACK&#34;| CLOSE FIN_WAIT1 -->|&#34;收到对端 FIN,但本端 FIN 尚未被确认&#34;| CLOSING[&#34;CLOSING&#34;] CLOSING -->|&#34;收到本端 FIN 的 ACK&#34;| TIME_WAIT FIN_WAIT1 -->|&#34;FIN 与 ACK 一并完成&#34;| TIME_WAIT

这是一张正常路径图。RST、超时、应用异常退出和 SO_LINGER=0 可能让连接跳过部分状态,直接进入 TCP_CLOSE

23.2 CLOSE

TCP_CLOSE 有两层含义:

  1. socket() 刚创建、还没有连接时的初始 TCP 状态;
  2. 连接已经结束时的最终协议状态。

刚创建客户端 socket:

text 复制代码
fd=4
  → struct socket
  → struct tcp_sock
       sk_state   = TCP_CLOSE
       local_port = 0
       remote     = 未设置

调用 close(fd) 后,不能简单理解为"这个 tcp_sock 永远留在 CLOSE 状态"。如果没有其他引用,内核还会继续释放:

text 复制代码
fdtable 中移除 fd
  → struct file 引用计数下降
  → struct socket 释放
  → struct sock/tcp_sock 释放或转换成 TIME_WAIT 对象

因此 CLOSE 往往是短暂的协议状态,最终对象本身可能已经不存在。

23.3 LISTEN

服务端调用:

text 复制代码
socket()
  → bind(:8080)
  → listen()

之后:

text 复制代码
listener struct tcp_sock
├── sk_state = TCP_LISTEN
├── local = 0.0.0.0:8080
├── icsk_accept_queue
├── sk_ack_backlog
└── sk_max_ack_backlog

监听 socket 不代表某一条连接,它代表一个本地监听端点。真正的连接由握手过程中创建的 child socket 承载。

如果关闭监听 socket:

java 复制代码
serverSocket.close();

大致发生:

text 复制代码
listener 从监听哈希表移除
  → 不再接受新 SYN
  → 唤醒阻塞的 accept/poll/epoll 等待者
  → 清理尚未被 accept 的连接和握手请求
  → listener 最终释放

已经被 accept() 返回的连接拥有独立 fd 和 tcp_sock,不会因为关闭 listener 而自动关闭:

text 复制代码
关闭 fd=3 listener
  不等于
关闭 fd=4、fd=5 已建立连接

23.4 SYN_SENT

客户端主动调用:

java 复制代码
socket.connect(address);

发送 SYN 后:

text 复制代码
client struct tcp_sock
├── sk_state = TCP_SYN_SENT
├── local = 本地IP:临时端口
├── remote = 服务端IP:8080
├── 初始发送序列号已经生成
└── SYN 重传定时器运行

阻塞式 connect() 会等待状态变化:

text 复制代码
TCP_SYN_SENT
  → TCP_ESTABLISHED
  或者
  → 连接错误/超时/TCP_CLOSE

非阻塞 connect() 通常返回 EINPROGRESS,由 epoll 通过可写或错误事件通知连接完成,再由 finishConnect() 检查最终结果。

如果目标端口没有监听 socket:

text 复制代码
客户端 SYN
  → 对端返回 RST
  → 客户端记录 ECONNREFUSED
  → TCP_SYN_SENT → TCP_CLOSE
  → Java ConnectException

23.5 SYN_RECV 与 request_sock

服务端 listener 收到 SYN 后不会把 listener 自己变成 SYN_RECV

text 复制代码
listener 仍然是 TCP_LISTEN

内核为半连接创建较轻量的:

c 复制代码
struct request_sock

概念结构:

text 复制代码
listener struct tcp_sock
  sk_state = TCP_LISTEN
      │
      └── request_sock
            client = 客户端IP:临时端口
            server = 服务端IP:8080
            state  = SYN_RECV/NEW_SYN_RECV

收到客户端最终 ACK 后,内核才创建完整 child struct tcp_sock

text 复制代码
request_sock
  → child struct tcp_sock
       sk_state = TCP_ESTABLISHED
  → 加入 listener Accept 队列

因此 Linux 内核中可能看到 TCP_NEW_SYN_RECV,而用户工具通常把这一阶段显示为 SYN-RECV

23.6 ESTABLISHED

连接两端都进入:

text 复制代码
TCP_ESTABLISHED

客户端:

text 复制代码
client tcp_sock
  local  = 192.168.1.20:52346
  remote = 192.168.1.10:8080

服务端 child:

text 复制代码
server child tcp_sock
  local  = 192.168.1.10:8080
  remote = 192.168.1.20:52346

此时双方完全对等,都可以读写:

text 复制代码
sk_receive_queue       接收数据
TCP 发送/重传队列       管理待发送与未确认数据
sk_wq                  关联 read/write/poll/epoll 等等待者

"客户端"和"服务端"是连接建立方式上的角色。进入 ESTABLISHED 后,两个完整 tcp_sock 在协议能力上没有本质区别。

23.7 Java close 到 Linux tcp_close

Java:

java 复制代码
socket.close();

大致链路:

text 复制代码
Socket.close()
  → SocketImpl.close()
  → close(fd)
  → struct file 的 release
  → sock_release()/inet_release()
  → tcp_close()

正常优雅关闭时,TCP 会尽量:

  1. 处理或发送尚未发送的数据;
  2. 发送 FIN;
  3. 进入 FIN 相关状态;
  4. 等待对端确认和关闭。

Java close() 返回不代表网络中的关闭握手已经全部结束。fd 可以已经从进程中消失,但内核仍可能保留 FIN_WAIT 或 TIME_WAIT 状态。

23.8 主动关闭:FIN_WAIT1、FIN_WAIT2、TIME_WAIT

假设客户端主动关闭,服务端稍后关闭。

初始状态:

text 复制代码
客户端 ESTABLISHED
服务端 ESTABLISHED

客户端调用 close()shutdownOutput()

text 复制代码
客户端发送 FIN
客户端 ESTABLISHED → FIN_WAIT1

结构仍然是完整 tcp_sock

text 复制代码
client struct tcp_sock
├── sk_state = TCP_FIN_WAIT1
├── 本端 FIN 已占用一个 TCP 序列号
├── 等待 FIN 被 ACK
└── 仍可能接收对端数据

服务端收到 FIN 并回复 ACK 后:

text 复制代码
客户端收到 ACK
FIN_WAIT1 → FIN_WAIT2

FIN_WAIT2 表示:

text 复制代码
本端已经不能再发送普通数据
本端 FIN 已被确认
仍在等待对端发送 FIN
仍可以接收对端尚未发送完的数据

服务端稍后发送 FIN,客户端回复最终 ACK:

text 复制代码
客户端 FIN_WAIT2 → TIME_WAIT

完整主动关闭路径:

text 复制代码
ESTABLISHED
  → FIN_WAIT1
  → FIN_WAIT2
  → TIME_WAIT
  → CLOSE

23.9 被动关闭:CLOSE_WAIT 与 LAST_ACK

服务端收到客户端 FIN 后:

text 复制代码
服务端回复 ACK
ESTABLISHED → CLOSE_WAIT

CLOSE_WAIT 的含义不是"内核还没处理完",而是:

text 复制代码
对端已经不再发送数据
内核已经通知本地应用 EOF
正在等待本地应用调用 close() 或 shutdownOutput()

如果接收队列中还有数据,Java read() 会先读完缓冲数据,再返回:

java 复制代码
-1

对端 FIN 表示对端关闭了发送方向,并不自动禁止本端继续发送。因此处于 CLOSE_WAIT 的一端仍可把剩余响应发送出去。

本地应用调用 close() 后:

text 复制代码
发送本端 FIN
CLOSE_WAIT → LAST_ACK

收到对端最终 ACK:

text 复制代码
LAST_ACK → CLOSE

完整被动关闭路径:

text 复制代码
ESTABLISHED
  → CLOSE_WAIT
  → LAST_ACK
  → CLOSE

服务器上大量长期 CLOSE-WAIT 通常意味着:

text 复制代码
对端已经关闭
但是本地应用没有及时 close 对应 Socket/Channel

这通常是应用资源泄漏或异常路径遗漏关闭,而不是调整内核参数可以根治的问题。

23.10 TIME_WAIT 的内核结构变化

TIME_WAIT 一般由主动完成关闭的一方进入。它有两个核心目的:

  1. 对端如果没有收到最后 ACK、重传 FIN,本端仍能再次回复 ACK;
  2. 等待网络中属于旧连接的重复报文过期,避免污染复用相同四元组的新连接。

在 Linux 中,进入 TIME_WAIT 后通常没有必要继续保留完整且较大的 struct tcp_sock。内核会使用更轻量的时间等待对象:

c 复制代码
struct inet_timewait_sock

TCP 还可能使用扩展的:

c 复制代码
struct tcp_timewait_sock

结构变化可以理解为:

text 复制代码
FIN_WAIT2 阶段
  完整 struct tcp_sock
  ├── 发送/接收状态
  ├── 拥塞控制状态
  ├── 重传信息
  └── 四元组

进入 TIME_WAIT
  释放完整 tcp_sock 的大量资源
  → 创建/保留更轻量的 inet_timewait_sock
       ├── 必要的四元组
       ├── TCP 时间等待状态
       ├── 最后序列号等必要信息
       └── TIME_WAIT 定时器

TIME_WAIT 对象仍需参与连接哈希查找,以处理旧连接报文和端口/四元组复用判断。

通常把 TIME_WAIT 持续时间描述为 2MSL。Linux 的实际定时和实现由内核版本决定,常见观察值大约为 60 秒,不能只把概念公式当成所有系统都完全相同的固定配置。

TIME_WAIT 到期后:

text 复制代码
从相关哈希表移除
  → 释放 inet_timewait_sock
  → TCP_CLOSE/对象消失

23.11 CLOSING:双方同时关闭

如果双方几乎同时调用 close()

text 复制代码
A ESTABLISHED → 发送 FIN → FIN_WAIT1
B ESTABLISHED → 发送 FIN → FIN_WAIT1

A 收到 B 的 FIN,但 A 自己的 FIN 尚未收到 ACK:

text 复制代码
A FIN_WAIT1 → CLOSING

B 同理:

text 复制代码
B FIN_WAIT1 → CLOSING

之后收到自己的 FIN 对应 ACK:

text 复制代码
CLOSING → TIME_WAIT

典型路径:

text 复制代码
ESTABLISHED
  → FIN_WAIT1
  → CLOSING
  → TIME_WAIT
  → CLOSE

CLOSING 较少出现,因为常见业务协议通常由一方先发起关闭,但在双方同时退出或异常关闭协调时可以观察到。

23.12 FIN_WAIT1 直接进入 TIME_WAIT

有时对端的报文同时满足:

text 复制代码
确认了本端 FIN
并携带对端 FIN

那么本端可能不经过可观察的 FIN_WAIT2,直接:

text 复制代码
FIN_WAIT1 → TIME_WAIT

TCP 状态不是每个阶段都一定能被 ss 捕捉到,短暂状态可能在两次采样之间已经完成转换。

23.13 四次挥手的双方状态时间线

假设客户端主动关闭:

网络事件 客户端状态 服务端状态
连接正常 ESTABLISHED ESTABLISHED
客户端发送 FIN FIN_WAIT1 ESTABLISHED
服务端收到 FIN 并回复 ACK FIN_WAIT1 CLOSE_WAIT
客户端收到 ACK FIN_WAIT2 CLOSE_WAIT
服务端应用调用 close 并发送 FIN FIN_WAIT2 LAST_ACK
客户端收到 FIN 并回复 ACK TIME_WAIT LAST_ACK
服务端收到最终 ACK TIME_WAIT CLOSE
TIME_WAIT 到期 CLOSE CLOSE
sequenceDiagram participant C as 主动关闭方 participant S as 被动关闭方 Note over C,S: 双方 ESTABLISHED C->>S: FIN Note over C: FIN_WAIT1 Note over S: 收到 FIN 后进入 CLOSE_WAIT S-->>C: ACK Note over C: FIN_WAIT2 Note over S: 应用处理剩余数据 S->>C: FIN Note over S: LAST_ACK C-->>S: ACK Note over C: TIME_WAIT Note over S: CLOSE Note over C: TIME_WAIT 超时后 CLOSE

23.14 半关闭:shutdownOutput 与 shutdownInput

TCP 是全双工协议,两个传输方向可以分别关闭。

Java:

java 复制代码
socket.shutdownOutput();

表示本端不会再发送数据,内核通常在已排队数据发送完成后发送 FIN:

text 复制代码
本端发送方向关闭
  → 进入 FIN_WAIT1/FIN_WAIT2 等状态
  → 本端仍可以继续 read 对端数据

这适合"请求发送完毕,但还要继续读取响应"的协议。

java 复制代码
socket.shutdownInput();

表示本地应用不再接收数据。它和发送 FIN 不是同一件事:TCP 的 FIN 主要表达"我不会再向你发送数据",所以关闭输出方向才直接对应 TCP FIN 语义。

socket.close() 通常关闭两个应用方向并释放 fd;shutdownOutput() 可以保留 fd 和读取方向。

23.15 read、write 在关闭状态下的表现

收到 FIN

对端 FIN 到达后:

text 复制代码
ESTABLISHED → CLOSE_WAIT

如果接收队列还有数据:

text 复制代码
read() 先返回剩余数据

数据耗尽后:

text 复制代码
Linux read() 返回 0
Java InputStream.read() 返回 -1
Java SocketChannel.read() 返回 -1
CLOSE_WAIT 仍可能 write

收到对端 FIN 只代表对端不再发送。处于 CLOSE_WAIT 时,本端发送方向仍可能正常,因此可以继续写完响应,再主动关闭本端输出。

已发送 FIN 后继续写

本端发送 FIN 后再写通常会失败,最终表现为:

text 复制代码
EPIPE
SIGPIPE(native 程序如果未忽略)
Java SocketException: Broken pipe 等

具体异常还取决于是否先收到了 RST、JDK 实现和写入时机。

23.16 BIO 阻塞调用如何被关闭事件唤醒

read 阻塞

线程原本等待:

text 复制代码
connection->sk_receive_queue 有数据

收到 FIN、RST 或错误后,内核触发 socket 状态/数据回调并唤醒等待队列:

text 复制代码
FIN/RST 到达
  → 更新 sk_state/sk_err
  → 唤醒 connection->sk_wq
  → read 线程恢复
  → 返回 EOF 或错误
accept 阻塞

如果另一个线程关闭 listener:

text 复制代码
listener close
  → TCP_LISTEN 结束
  → 唤醒 listener->sk_wq
  → accept 重新检查状态
  → 返回错误
  → Java 通常抛出 SocketException

23.17 epoll/Selector 如何观察关闭

对已连接 socket,epoll 可能报告:

epoll 事件 常见含义
EPOLLIN 有数据可读,或者读方向已经到 EOF
EPOLLRDHUP 对端关闭写方向,收到 FIN
EPOLLHUP socket 挂断
EPOLLERR 存在待处理 socket 错误

Java 标准 Selector 通常把对端关闭表现成读就绪:

text 复制代码
SelectionKey.isReadable() == true
  → SocketChannel.read(buffer)
  → 返回 -1
  → 应用 cancel key 并 close channel

不能只在"有普通数据"时处理 OP_READ,还必须处理 read() == -1

EPOLLERREPOLLHUP 即使没有显式加入 interest mask,Linux epoll 也会按其语义报告相关状态。应用仍应通过 read/write/getsockopt(SO_ERROR) 等操作确认最终结果。

Channel 关闭后,JDK 会取消 SelectionKey,并在 Selector 更新阶段从 epoll 关注集合删除对应项。Linux 在目标 struct file 最后一个引用关闭时也会清理与 epoll 的关联。

23.18 RST:非优雅关闭

FIN 表示优雅关闭一个发送方向;RST 表示连接被立即复位。

常见 RST 场景:

  • 连接一个没有监听的端口;
  • 对端进程异常退出或协议状态不匹配;
  • 对端关闭后仍收到无法归属的报文;
  • 设置 SO_LINGER=0 后关闭;
  • 应用未读取完接收数据就采用某些中止关闭路径。

收到 RST 后通常不会走完整四次挥手:

text 复制代码
ESTABLISHED/其他状态
  → 记录 ECONNRESET 等错误
  → 唤醒 read/write/poll/epoll 等待者
  → TCP_CLOSE

Java 可能看到:

text 复制代码
Connection reset
Broken pipe
Connection refused

RST 路径通常不会产生正常主动关闭路径的 TIME_WAIT,但具体行为仍取决于哪一端、哪一状态发送或收到 RST。

23.19 SO_LINGER 对 close 的影响

默认情况下,close() 通常把剩余关闭过程交给内核异步完成。

Java 可以设置:

java 复制代码
socket.setSoLinger(true, seconds);

正超时时间可能让 close() 等待一段时间,以尝试发送缓冲数据和完成关闭。

特殊情况:

text 复制代码
SO_LINGER 开启且 timeout=0

通常表示中止式关闭:

text 复制代码
丢弃尚未发送的数据
  → 发送 RST
  → 跳过正常 FIN 四次挥手

它不应被当成普通性能优化,因为对端会观察到连接重置而不是正常 EOF。

23.20 FIN_WAIT2 和 TIME_WAIT 堆积怎么看

大量 FIN-WAIT-2

text 复制代码
本端已经主动关闭发送方向
对端已经确认
但是对端长期不发送自己的 FIN

对已经脱离应用 fd 管理的 orphan FIN_WAIT2,Linux 会使用 tcp_fin_timeout 等机制回收。仍由应用持有的半关闭连接可能有不同生命周期。

大量 TIME-WAIT

text 复制代码
本机频繁作为主动关闭方
连接创建/关闭频率很高

TIME_WAIT 本身是 TCP 正确性机制,不应一看到数量多就直接修改内核参数。首先应判断:

  • 是否应该使用长连接或连接池;
  • 谁应该承担主动关闭;
  • 是否存在连接复用不足;
  • 是否真的发生端口耗尽或资源问题。

23.21 用 ss 观察关闭状态

查看 8080 相关连接:

bash 复制代码
ss -ant '( sport = :8080 or dport = :8080 )'

只看 TIME_WAIT:

bash 复制代码
ss -ant state time-wait

只看 CLOSE_WAIT:

bash 复制代码
ss -antp state close-wait

统计各 TCP 状态:

bash 复制代码
ss -ant | awk 'NR > 1 { count[$1]++ } END { for (state in count) print state, count[state] }'

监听关闭过程时,状态可能非常短暂,建议连续采样:

bash 复制代码
watch -n 0.2 "ss -ant '( sport = :8080 or dport = :8080 )'"

23.22 建连和关闭时的结构生命周期总结

text 复制代码
服务端监听
  完整 listener tcp_sock
  TCP_LISTEN
      │
      │ 收到 SYN
      ▼
  轻量 request_sock
  SYN_RECV/NEW_SYN_RECV
      │
      │ 握手完成
      ▼
  完整 child tcp_sock
  TCP_ESTABLISHED
      │
      │ 主动或被动关闭
      ▼
  完整 child tcp_sock
  FIN_WAIT/CLOSE_WAIT/LAST_ACK/CLOSING
      │
      │ 主动完成关闭
      ▼
  轻量 inet_timewait_sock
  TCP_TIME_WAIT
      │
      │ 定时器到期
      ▼
  对象释放

并不是每个连接都经过 request_sock → tcp_sock → timewait_sock 的全部阶段:

  • 客户端主动连接通常直接创建完整 tcp_sock,不会使用服务端的 request_sock 路径;
  • 被动关闭方走到 LAST_ACK → CLOSE 后通常不进入 TIME_WAIT;
  • RST 可能直接中止连接;
  • 同时关闭可能让双方进入 CLOSING 和 TIME_WAIT。

24. 源码阅读索引

如果准备结合源码继续深入,可以按下面的顺序查找。不同版本目录和函数可能略有调整。

24.1 OpenJDK Java 层

text 复制代码
src/java.base/share/classes/java/net/Socket.java
src/java.base/share/classes/java/net/ServerSocket.java
src/java.base/share/classes/java/net/SocketImpl.java

src/java.base/share/classes/java/nio/channels/SocketChannel.java
src/java.base/share/classes/java/nio/channels/ServerSocketChannel.java
src/java.base/share/classes/java/nio/channels/Selector.java

24.2 OpenJDK NIO 实现层

text 复制代码
src/java.base/share/classes/sun/nio/ch/SocketChannelImpl.java
src/java.base/share/classes/sun/nio/ch/ServerSocketChannelImpl.java
src/java.base/share/classes/sun/nio/ch/SelectorImpl.java
src/java.base/linux/classes/sun/nio/ch/EPollSelectorImpl.java
src/java.base/linux/classes/sun/nio/ch/EPoll.java
src/java.base/share/classes/sun/nio/ch/Net.java

native 方法通常可以继续在 src/java.base/unix/native/libnio/ch/ 等目录中寻找。

24.3 Linux fd 和 socket 层

text 复制代码
include/linux/fs.h               struct file
include/linux/net.h              struct socket、proto_ops
include/net/sock.h               struct sock、socket_wq
net/socket.c                     socket 系统调用入口

24.4 Linux IPv4/TCP 层

text 复制代码
include/net/inet_sock.h          inet_sock、inet_connection_sock
include/linux/tcp.h              tcp_sock
net/ipv4/af_inet.c               inet bind/listen/connect/accept 分发
net/ipv4/inet_connection_sock.c  监听、Accept 队列、连接等待
net/ipv4/tcp.c                   TCP read/write 等通用逻辑
net/ipv4/tcp_input.c             TCP 输入状态机
net/ipv4/tcp_ipv4.c              IPv4 TCP 收包、连接建立

24.5 Linux epoll 层

text 复制代码
fs/eventpoll.c

重点搜索:

text 复制代码
eventpoll
epitem
epoll_create1
epoll_ctl
epoll_wait
ep_poll_callback

推荐阅读顺序:

text 复制代码
Java ServerSocketChannel/Selector
  → OpenJDK EPollSelectorImpl
  → Linux epoll 系统调用
  → fs/eventpoll.c
  → socket poll 回调
  → struct sock 的 sk_wq
  → TCP 接收、连接建立和唤醒路径

25. 最终总结

BIO:

text 复制代码
一个 socket 操作没有完成
  → 当前调用线程睡到该 socket 的等待机制上

NIO:

text 复制代码
业务 fd 设置 O_NONBLOCK
  → accept/read/connect 不能完成时立即返回

epoll:

text 复制代码
每个 socket 的等待队列注册 epoll callback
  → socket 就绪时把对应 epitem 放入 epoll 就绪链表
  → 一个事件循环线程集中阻塞在 epoll_wait()
  → epoll_wait 返回后,Java 再执行 accept/read/write

最核心的结构关系:

text 复制代码
BIO
  thread ──wait──> 某一个 socket.sk_wq

NIO + epoll
  socket A.sk_wq ──callback──┐
  socket B.sk_wq ──callback──┼──> eventpoll.rdllist
  socket C.sk_wq ──callback──┘             │
                                          ▼
                                 一个 epoll_wait 线程

无论使用 BIO、NIO 还是 epoll,TCP 连接最终仍由相同的 Linux 内核对象承载:

text 复制代码
fd
  → struct file
  → struct socket
  → struct tcp_sock

服务端连接从建立到关闭还可能经历结构压缩:

text 复制代码
request_sock(握手中)
  → tcp_sock(ESTABLISHED 和挥手状态)
  → inet_timewait_sock(TIME_WAIT)
  → 对象释放

变化的是线程等待方式和事件分发方式,不是 TCP 协议本身。

相关推荐
用户78136671144524 分钟前
RGW 对象多版本功能系统架构与代码解析
后端
Z思学24 分钟前
Nest 第一步 · 第 3 篇:理解 Controller / Service / Module 三层架构
后端
学长毕业设计25 分钟前
基于SpringBoot的校园爱心志愿管理系统(源码+文档+讲解视频)
vue.js·spring boot·后端
IT_陈寒1 小时前
SpringBoot自动配置失效?这个隐式依赖坑了我三天
前端·人工智能·后端
码视野1 小时前
基于 Spring Boot + Vue3 的【城市雨污水管网液位淤积溯源与立交桥下穿隧洞防汛排涝智控中台】设计与实现(含PRD/三端高保真源码/大屏)
java·前端·人工智能·spring boot·后端
小满zs1 小时前
Go语言第十章(指针)
后端·google·go
十正1 小时前
用 HTTP 条件请求把“强一致“和“低成本
网络·后端·http
2601_962122971 小时前
Spring Cloud——路由网关Zuul
后端·spring·spring cloud
卷无止境1 小时前
从一杯咖啡的订单说起,数据库设计到底是怎么长出来的
后端·python