侧边栏壁纸
  • 累计撰写 143 篇文章
  • 累计创建 20 个标签
  • 累计收到 3 条评论

目 录CONTENT

文章目录

三次握手后,accept 做了什么?

YaFuX
2026-06-20 / 0 评论 / 0 点赞 / 1 阅读 / 0 字
温馨提示:
部分素材来自网络,若不小心影响到您的利益,请联系我们删除。

前言

Java 服务端最经典的一段代码,大概是这样:

try (ServerSocket server = new ServerSocket(9090)) {
    while (true) {
        Socket client = server.accept();
        new Thread(() -> handle(client)).start();
    }
}

代码非常短:

创建 ServerSocket
        ↓
调用 accept()
        ↓
获得 Socket
        ↓
读取客户端数据

但真正的问题是:

new ServerSocket(9090) 执行后,Linux 内核做了什么?

客户端的三次握手,是由 Java 代码完成的吗?

服务端还没有调用 accept(),客户端能否成功连接?

三次握手已经结束,为什么还要调用 accept()

accept() 返回的 Socket,和原来的 ServerSocket 是同一个 Socket 吗?

backlog 控制的是半连接队列,还是已连接队列?

一个服务端只监听 9090 端口,为什么能同时接收成千上万个连接?

客户端已经发送数据,但服务端还没有 accept(),数据会被放在哪里?

接收缓冲区满了以后,TCP 会直接丢弃后续数据吗?

BIO 为什么通常被称为“一连接一线程”?

这些问题看似都围绕 Java Socket API,实际上涉及一条完整的内核链路:

Java ServerSocket
        ↓
socket()
        ↓
bind()
        ↓
listen()
        ↓
TCP 三次握手
        ↓
内核连接队列
        ↓
accept()
        ↓
新的文件描述符
        ↓
read() / write()

只有理解这条链路,才能真正理解:

BIO
NIO
Selector
epoll
Netty

究竟是在优化什么。


一、先看服务端

先写一个简单的 BIO 服务端:

import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;

public final class BioServer {

    public static void main(String[] args) throws IOException {
        try (ServerSocket server = new ServerSocket(9090, 128)) {
            System.out.println("server started");

            while (true) {
                Socket client = server.accept();

                Thread thread = new Thread(() -> handle(client));
                thread.start();
            }
        }
    }

    private static void handle(Socket client) {
        try (client;
             InputStream input = client.getInputStream()) {

            byte[] buffer = new byte[1024];

            while (true) {
                int count = input.read(buffer);

                if (count == -1) {
                    break;
                }

                String message = new String(
                        buffer,
                        0,
                        count,
                        StandardCharsets.UTF_8
                );

                System.out.println(message);
            }
        } catch (IOException exception) {
            exception.printStackTrace();
        }
    }
}

这里存在两个看起来非常相似,实际用途完全不同的对象:

ServerSocket server
Socket client

ServerSocket 用于监听连接,accept() 返回的 Socket 用于和某一个具体客户端通信。Java 官方 API 也把 ServerSocket 定义为等待网络连接请求的服务端 Socket,而 accept() 会等待并接收一个连接。


二、两种 Socket

1. 监听 Socket

执行:

ServerSocket server = new ServerSocket(9090);

从操作系统角度,可以抽象为:

socket()
    ↓
bind(0.0.0.0:9090)
    ↓
listen()

首先创建一个 Socket,获得文件描述符:

fd = 3

然后把它绑定到本地地址:

0.0.0.0:9090

最后调用 listen(),把它转换为监听 Socket:

fd 3
  ↓
LISTEN

Linux 的 listen() 会把一个流式 Socket 标记为被动 Socket,用来接收传入的连接请求。

它的主要工作是:

监听指定地址和端口
接收新的连接请求
维护等待处理的连接队列

它不直接代表某一个客户端。

2. 连接 Socket

accept() 成功后,会返回另一个 Socket:

Socket client = server.accept();

假设它对应:

fd = 5

结构就变成了:

fd 3
  ↓
监听 Socket
  ↓
0.0.0.0:9090
  ↓
继续接收新连接

fd 5
  ↓
连接 Socket
  ↓
192.168.1.20:53126
        ↕
192.168.1.10:9090

监听 Socket 和连接 Socket 不会互相替代:

监听 Socket
    负责接收新连接

连接 Socket
    负责和具体客户端传输数据

Linux 的 accept() 会创建一个新的已连接 Socket,并返回指向它的新文件描述符;原来的监听 Socket 不受影响,仍然可以继续接收其他连接。


三、连接先建立

很多人会把服务端的工作流程理解为:

服务端调用 accept()
        ↓
客户端开始三次握手
        ↓
连接建立

这个顺序并不准确。

更接近实际情况的是:

服务端完成 listen()
        ↓
客户端调用 connect()
        ↓
内核完成三次握手
        ↓
连接进入等待队列
        ↓
服务端调用 accept()
        ↓
应用获得连接 Socket

也就是说:

三次握手由两端操作系统的 TCP 协议栈完成,不需要服务端 Java 代码先进入 accept() 才能开始。

只要服务端已经完成:

socket
bind
listen

并且内核连接队列还有空间,客户端就可能完成三次握手。

标准 TCP 状态机会让服务端连接经历:

LISTEN
   ↓ 收到 SYN
SYN-RECEIVED
   ↓ 收到最终 ACK
ESTABLISHED

进入 ESTABLISHED 后,TCP 连接已经进入正常数据传输状态。

所以,即使 Java 主线程此时被其他代码卡住:

System.in.read();

// 尚未执行
Socket client = server.accept();

客户端的三次握手仍然可能已经完成。


四、两条队列

为了理解连接为什么可以先于 accept() 建立,需要理解 Linux 监听 Socket 背后的两类状态。

可以先使用一个简化模型:

客户端发送 SYN
        ↓
SYN 队列
        ↓
完成三次握手
        ↓
Accept 队列
        ↓
应用调用 accept()

1. SYN 队列

客户端发送:

SYN

服务端回复:

SYN + ACK

但还没有收到客户端最终 ACK 时,连接通常处于:

SYN-RECEIVED

这类尚未完成握手的连接请求,可以概念性地理解为位于:

SYN Queue
半连接队列

2. Accept 队列

服务端收到客户端最终 ACK 后,连接进入:

ESTABLISHED

但应用还没有调用 accept() 把它取走。

此时连接位于:

Accept Queue
已完成连接队列

Linux 从 2.2 开始,listen() 的 backlog 参数表示等待 accept() 的已完成连接队列长度;尚未完成握手的队列另有 tcp_max_syn_backlog 等内核机制管理。

因此:

三次握手完成
≠
应用已经 accept

连接进入 ESTABLISHED
≠
Java 已经获得 Socket 对象

三次握手属于 TCP 协议栈。

accept() 属于应用取走连接的过程。


五、accept 的本质

Linux 对 accept() 的定义非常明确:

从监听 Socket 的等待队列中
取出一个连接请求

创建一个新的已连接 Socket

返回指向新 Socket 的文件描述符

原监听 Socket 保持不变。

可以把它画成:

监听 Socket:fd 3

Accept Queue
┌────────────────────────────┐
│ 连接 A │ 连接 B │ 连接 C   │
└────────────────────────────┘
        ↓ accept()

进程文件描述符表
┌──────┬─────────────────────┐
│ fd 3 │ 监听 Socket         │
│ fd 5 │ 连接 A              │
└──────┴─────────────────────┘

剩余队列
┌────────────────────────────┐
│ 连接 B │ 连接 C            │
└────────────────────────────┘

所以,accept() 不是:

开始三次握手

也不是:

创建监听端口

更不是:

从网络中读取业务数据

它真正做的是:

把一个已经等待应用处理的连接,转换成应用可以通过文件描述符操作的已连接 Socket。

在 Java 中,这个新文件描述符被封装成:

Socket client

之后应用才能通过:

client.getInputStream();
client.getOutputStream();

访问该连接。


六、没有连接会怎样?

如果 Accept 队列是空的,阻塞模式下执行:

server.accept();

当前线程会停在这里。

Accept Queue 为空
        ↓
线程进入等待
        ↓
新连接进入队列
        ↓
内核唤醒线程
        ↓
accept() 返回 Socket

可以为 ServerSocket 设置超时:

server.setSoTimeout(3000);

这样 accept() 最多阻塞三秒。

超时后会抛出:

SocketTimeoutException

ServerSocket 本身仍然有效,应用可以再次调用 accept()

需要注意:

accept() 阻塞

不代表 CPU 一直循环检查连接。

普通阻塞调用会让线程进入等待,由内核在条件满足时唤醒。


七、数据去哪了

现在考虑一个更有意思的场景:

客户端完成三次握手
        ↓
服务端还没有调用 accept()
        ↓
客户端立即发送业务数据

这些数据会去哪里?

1. 先进入内核

连接进入 ESTABLISHED 后,客户端可以发送数据。

服务端 TCP 协议栈接收数据后,会把它放入该连接对应的内核接收缓冲区。

此时:

客户端
   ↓ TCP 数据
服务端内核
   ↓
连接 Socket 的接收缓冲区

但应用还没有获得连接文件描述符,因此暂时无法调用 read() 读取这些数据。

等应用执行:

Socket client = server.accept();

并获得新 Socket 后,再执行:

client.getInputStream().read(buffer);

就可以读取之前已经到达内核的数据。

TCP 标准规定,连接进入 ESTABLISHED 后可以进行数据传输,接收到的数据可以在接收缓冲区中排队,等待交付给应用。

2. 缓冲区会无限增长吗?

不会。

每个 TCP Socket 的接收缓冲区都有限制。

如果应用迟迟不读取:

接收缓冲区数据不断增加
        ↓
可用空间不断减少
        ↓
接收窗口逐渐缩小
        ↓
最终可能通告零窗口

发送方收到零窗口后,会暂时停止继续发送普通数据,并通过窗口探测等待接收方重新开放窗口。TCP 的接收窗口负责流量控制,防止快速发送方持续压垮处理较慢的接收方。

因此,下面的说法不准确:

接收缓冲区一满,
TCP 就直接把后续数据永久丢掉

更准确的过程是:

接收方处理不过来
        ↓
接收窗口缩小
        ↓
发送方降低或停止发送
        ↓
接收方读取数据
        ↓
窗口重新增大
        ↓
发送方继续发送

网络拥塞、异常丢包和资源耗尽仍可能导致重传或连接失败,但这不是简单的“缓冲区满后丢弃尾部数据”。

3. 流量控制不是拥塞控制

这两个概念经常被混在一起。

流量控制
    防止发送方压垮接收方
    主要关注接收窗口 rwnd

拥塞控制
    防止发送方压垮网络
    主要关注拥塞窗口 cwnd

TCP 发送端实际可发送的数据量,会同时受到接收窗口和拥塞窗口约束。RFC 5681 将拥塞窗口定义为发送方用于限制注入网络中的未确认数据量的状态变量。


八、backlog 真相

Java 可以这样设置 backlog:

ServerSocket server = new ServerSocket(9090, 128);

也可以这样:

ServerSocket server = new ServerSocket();

server.bind(
        new InetSocketAddress(9090),
        128
);

1. backlog 不是连接总数

假设:

backlog = 128

它不表示:

服务端最多只能保持 128 个连接

已经被 accept() 取走的连接,不再位于等待 Accept 队列中。

服务端可能拥有:

Accept 队列中 128 个等待连接

+

已经 accept 的 10000 个活动连接

所以:

backlog
≠
最大在线连接数

2. backlog 不是线程数

它也不表示:

服务端最多创建 128 个线程

backlog 管理的是内核等待队列,不是 Java 线程池。

3. backlog 不是绝对值

Java 文档把 backlog 描述为“请求的最大等待连接队列长度”,并明确说明其精确语义取决于具体实现:操作系统或实现可以设置上限,甚至选择其他处理方式。

在 Linux 中,如果应用传给 listen() 的 backlog 大于:

/proc/sys/net/core/somaxconn

就会被静默限制到 somaxconn

所以:

Java backlog = 100000

不代表内核一定建立一个长度为 100000 的队列。

4. 队列满了会怎样?

不能简单断言:

第 129 个连接一定立即收到拒绝

具体表现会受到:

操作系统实现
SYN Cookie
TCP 重传
队列状态
内核参数
网络环境

等因素影响。

客户端可能表现为:

连接超时
连接被拒绝
SYN 被忽略后重传
握手延迟

Linux listen() 文档也说明,队列满时,请求可能收到错误,也可能在支持重传的协议中被忽略,以等待之后重试。


九、四元组

一条 TCP 连接通常使用四元组区分:

源 IP
源端口
目标 IP
目标端口

例如:

192.168.1.20:53126
        ↓
192.168.1.10:9090

另一台客户端可以连接同一个服务端端口:

192.168.1.21:53126
        ↓
192.168.1.10:9090

虽然客户端端口同样是 53126,但客户端 IP 不同,因此两条连接仍然可以区分。

同一客户端也可以建立多条连接:

192.168.1.20:53126 → 192.168.1.10:9090
192.168.1.20:53127 → 192.168.1.10:9090
192.168.1.20:53128 → 192.168.1.10:9090

这也是为什么服务端只监听一个 9090 端口,却可以处理大量客户端连接。

1. 监听 Socket 不是完整四元组

监听 Socket 可能是:

0.0.0.0:9090

它没有绑定到某一个固定远端客户端。

连接 Socket 才具有明确的本地和远端端点:

本地地址:192.168.1.10:9090
远端地址:192.168.1.20:53126

因此,不应该直接把所有 Socket 都定义成四元组。

更准确地说:

一条已建立的 TCP 连接通常通过本地和远端的 IP、端口组合来区分。

2. 端口数不是连接数

端口号是 16 位无符号值,因此范围为:

0 ~ 65535

但这不代表一台服务器只能保持 65535 条连接。

服务端可以复用同一个监听端口,因为不同连接拥有不同的远端 IP 或远端端口组合。IP Socket 地址由 IP 地址和 16 位端口号组成。

实际连接数还受到:

文件描述符上限
内存
Socket 缓冲区
CPU
网络带宽
应用处理能力
临时端口范围
内核参数

等资源限制。


十、文件描述符

Linux 中的 Socket 最终通过文件描述符暴露给应用。

服务端启动后:

fd 0 → 标准输入
fd 1 → 标准输出
fd 2 → 标准错误
fd 3 → 监听 Socket

执行一次 accept() 后:

fd 5 → 客户端连接 A

再执行一次:

fd 6 → 客户端连接 B

结构为:

Java 进程
┌──────┬───────────────────────────┐
│ fd 3 │ 监听 Socket              │
│ fd 5 │ 客户端连接 A             │
│ fd 6 │ 客户端连接 B             │
│ fd 7 │ 客户端连接 C             │
└──────┴───────────────────────────┘

socket() 创建通信端点并返回文件描述符;accept() 返回另一个新的文件描述符,用于访问新建立的连接。

1. fd 只在进程内有意义

客户端可能使用:

fd 8

服务端可能使用:

fd 5

它们仍然可以表示同一条 TCP 连接的两端。

文件描述符编号只在当前进程的描述符表中有意义:

客户端 fd 8
≠
服务端 fd 5

但它们可以对应同一条网络连接

2. Java 线程共享 Socket

同一个 Java 进程中的线程共享进程资源,因此主线程 accept() 获得的 Socket,可以传给另一个线程处理。

Socket client = server.accept();

new Thread(() -> handle(client)).start();

这里不是把 Socket 复制成另一条连接,而是让另一个线程操作同一个进程中的连接资源。


十一、BIO 的两次阻塞

经典 BIO 服务端中存在两个主要阻塞点。

1. accept 阻塞

Socket client = server.accept();

如果当前没有等待连接:

主线程等待新连接

2. read 阻塞

int count = input.read(buffer);

连接已经建立,但没有业务数据时:

处理线程等待数据

完整模型为:

主线程
    ↓
accept() 阻塞等待连接
    ↓
获得客户端 Socket
    ↓
创建处理线程

处理线程
    ↓
read() 阻塞等待数据

如果主线程直接读取客户端数据:

while (true) {
    Socket client = server.accept();

    // 主线程在这里等待客户端数据
    handle(client);
}

那么第一个客户端迟迟不发送数据时,主线程就无法再次调用 accept(),后续客户端只能在内核队列中等待。

因此,经典 BIO 通常采用:

一个线程负责 accept
+
一个连接交给一个处理线程

这就是常说的:

一连接一线程

3. read 返回什么?

阻塞式读取通常有三种结果:

大于 0
    读取到的字节数

-1
    对端正常关闭了输出方向,流到达末尾

抛出异常
    超时、连接重置或其他 I/O 错误

当缓冲区长度大于零时,没有数据并不会让阻塞式 read() 持续返回 0,而是会等待至少一个字节、流结束或异常。Java 输入流 API 也规定,非零长度读取会阻塞到至少得到一个字节,流结束则返回 -1

因此,下面这种逻辑通常没有必要:

if (count == 0) {
    continue;
}

十二、几个参数

1. TCP_NODELAY

Java 中:

socket.setTcpNoDelay(true);

表示:

启用 TCP_NODELAY
        ↓
禁用 Nagle 算法

而:

socket.setTcpNoDelay(false);

表示允许使用 Nagle 算法。

Nagle 算法的目标,是减少大量小 TCP 报文。

它大致会避免在前一批小数据尚未确认时,不断发送新的小报文,从而减少:

小包数量
协议头开销
网络处理次数

RFC 1122 将其描述为抑制因应用以小增量写入数据而产生的大量小报文。

适合关注吞吐的场景:

批量传输
大量连续数据
对单次延迟不敏感

适合考虑关闭 Nagle 的场景:

交互式请求
低延迟消息
游戏操作
远程控制
短小且需要及时送达的数据

TCP_NODELAY=true 也不保证:

每次 Java write
=
一个独立 TCP 报文

数据如何分段,还会受到 TCP 栈、拥塞控制、网卡卸载和调度等因素影响。

2. SO_KEEPALIVE

socket.setKeepAlive(true);

表示开启 TCP Keepalive 探测。

它主要用于:

连接长时间没有数据时,帮助系统发现已经失效但没有正常关闭的对端。

它不是:

创建长连接的开关

也不是:

HTTP Keep-Alive

更不是:

业务心跳的完整替代品

在 Linux 中,只有 Socket 开启 SO_KEEPALIVE 后,TCP 才会按照系统或单连接配置,在空闲一段时间后发送 Keepalive 探测。

业务心跳通常还能携带:

节点身份
会话状态
租约信息
业务时间戳
主从角色

这些不是 TCP Keepalive 能表达的。

3. 收发缓冲区

Java 可以设置:

socket.setReceiveBufferSize(size);
socket.setSendBufferSize(size);

对应:

SO_RCVBUF
SO_SNDBUF

它们影响 Socket 的内核接收和发送缓冲能力。

但需要注意:

缓冲区大小
≠
单个 TCP 包大小

缓冲区大小
≠
netstat 中当前积压字节数

缓冲区大小
≠
一次 read 必须返回的字节数

操作系统还可能进行:

自动调优
最小值限制
最大值限制
额外内核开销计算

因此设置值不一定等于最终实际可用的数据容量。

4. SO_REUSEADDR

server.setReuseAddress(true);

表示允许地址重用规则。

它不等于:

强制立即释放端口

也不代表:

多个普通服务可以随意同时监听同一个地址和端口

它主要影响 Socket 绑定本地地址时的重用条件,尤其与关闭后的地址状态有关。Linux 对端口重用还有具体约束,并要求谨慎使用。

它也不要与 Linux 的:

SO_REUSEPORT

混为一谈。

5. SO_TIMEOUT

ServerSocket

server.setSoTimeout(3000);

限制的是:

accept() 最长阻塞时间

对连接 Socket:

socket.setSoTimeout(3000);

限制的是:

InputStream.read() 最长阻塞时间

超时会抛出 SocketTimeoutException,但 Socket 不一定因此失效。

6. flush

如果直接使用:

OutputStream output = socket.getOutputStream();
output.write(data);

不能仅凭“没有调用 flush()”就断言数据还停留在 Java 缓冲区。

Socket.getOutputStream() 返回的是 Socket 的输出流,Java API 并没有说明它自带 BufferedOutputStream 那样的应用层字节缓冲。

只有显式包装了缓冲流:

BufferedOutputStream output =
        new BufferedOutputStream(socket.getOutputStream());

数据才会明确先进入 BufferedOutputStream 的内部缓冲区,此时需要:

output.flush();

把已缓冲的数据交给底层输出流。

即使没有 Java 应用层缓冲,数据仍然可能进入:

TCP 发送缓冲区

并受到 Nagle 算法、流量控制和拥塞控制等机制影响。


十三、常见误区

1. accept 负责三次握手

错误。

三次握手由内核 TCP 协议栈处理,accept() 负责把等待队列中的连接交给应用。

2. 没有调用 accept,客户端就不能连接

错误。

服务端已经 listen() 且队列有空间时,三次握手可以先完成。

3. accept 返回监听 Socket

错误。

accept() 返回一个新的连接 Socket,原监听 Socket 继续监听。

4. 一个端口只能对应一个连接

错误。

一个监听端口可以对应大量具有不同远端地址和端口的连接。

5. Socket 就等于四元组

不严谨。

已建立 TCP 连接通常用四元组区分,但监听 Socket 并没有固定远端地址和端口。

6. backlog 是服务端最大连接数

错误。

它主要约束等待应用 accept() 的连接队列,不是所有活动连接的总上限。

7. backlog 设置多少就一定生效多少

错误。

Java 把它作为请求值,操作系统可以限制;Linux 还会受到 somaxconn 约束。

8. 接收缓冲区满后,TCP 永久丢弃所有后续数据

错误。

TCP 会通过接收窗口执行流量控制,发送方通常会降低或暂停发送。

9. 接收窗口就是拥塞窗口

错误。

接收窗口 rwnd
    保护接收方

拥塞窗口 cwnd
    保护网络

10. TCP_NODELAY=true 表示开启 Nagle

错误。

它表示启用 No Delay,也就是禁用 Nagle 算法。

11. Keepalive 用来创建长连接

错误。

TCP 连接是否持续存在,取决于双方是否关闭以及网络状态;Keepalive 主要帮助检测长时间空闲的失效连接。

12. SO_REUSEADDR 可以让任意两个服务监听同一端口

错误。

它改变的是地址重用规则,不是无条件的端口共享。

13. Socket 输出流不调用 flush 就一定不发送

错误。

要先判断外层是否存在 BufferedOutputStreamBufferedWriter 等应用缓冲。

14. 阻塞 read 没数据时会不断返回 0

错误。

正常的正长度阻塞读取会等待数据、流结束或异常。


十四、完整链路

现在把整个过程重新串起来。

1. 服务端启动

Java 创建 ServerSocket
        ↓
JVM 调用操作系统 Socket 接口
        ↓
socket()
        ↓
获得监听 fd
        ↓
bind()
        ↓
绑定本地 IP 和端口
        ↓
listen()
        ↓
Socket 进入 LISTEN

2. 客户端连接

客户端 connect()
        ↓
发送 SYN
        ↓
服务端回复 SYN + ACK
        ↓
客户端发送 ACK
        ↓
两端进入 ESTABLISHED

三次握手由 TCP 协议栈完成。

3. 连接等待

服务端内核建立连接状态
        ↓
连接进入 Accept 队列
        ↓
客户端可能继续发送数据
        ↓
数据进入连接接收缓冲区

此时 Java 应用还没有获得连接 Socket。

4. 应用接收

Java 调用 accept()
        ↓
JVM 进入内核
        ↓
内核从等待队列取出连接
        ↓
创建新的连接文件描述符
        ↓
返回 Java Socket

原监听文件描述符继续存在。

5. 读取数据

处理线程调用 read()
        ↓
接收缓冲区是否有数据?
       ┌────────┴────────┐
       │                 │
      有                没有
       │                 │
立即复制到用户缓冲区    线程阻塞等待

6. 继续监听

主线程再次调用 accept()
        ↓
接收下一个客户端

最终形成经典 BIO 模型:

                ┌→ 线程 A → 连接 A → read()
监听线程 → accept ┼→ 线程 B → 连接 B → read()
                ├→ 线程 C → 连接 C → read()
                └→ 线程 D → 连接 D → read()

十五、为什么还需要 NIO?

BIO 的问题不在于 TCP 三次握手。

也不在于 accept() 本身。

它的问题在于:

大量连接
+
大量阻塞 read
+
大量长期等待的线程

当连接数量增加后:

一个连接一个线程
        ↓
线程数量持续增加
        ↓
线程栈占用内存
        ↓
调度和上下文切换成本上升

NIO、Selector 和 epoll 并不会改变:

socket
bind
listen
TCP 三次握手
连接队列
accept
Socket 缓冲区
文件描述符

这些基础机制。

它们改变的是:

应用程序如何等待和管理大量文件描述符的就绪事件。

BIO 是:

一个线程阻塞等待一个连接

多路复用则尝试变成:

少量线程
    ↓
等待大量 fd 的事件
    ↓
哪个 fd 就绪就处理哪个

因此,真正理解 NIO 之前,必须先理解:

监听 Socket
连接 Socket
三次握手
Accept 队列
文件描述符
阻塞 read

总结

服务端执行:

socket
bind
listen

以后,就已经具备接收 TCP 连接请求的条件。

客户端发起连接时:

SYN
    ↓
SYN + ACK
    ↓
ACK

三次握手主要由两端内核的 TCP 协议栈完成。

服务端应用不需要先调用 accept(),握手才会开始。

三次握手完成后,连接进入:

ESTABLISHED

并等待应用接收。

accept() 的本质是:

从等待队列取出一个连接
        ↓
创建新的连接 Socket
        ↓
返回新的文件描述符

监听 Socket 不会消失,仍然继续监听新连接。

客户端在服务端 accept() 之前发送的数据,可以先进入连接的内核接收缓冲区。

缓冲区空间不足时,TCP 会通过接收窗口进行流量控制,而不是简单永久丢弃所有后续数据。

backlog 控制的是等待应用接收的连接队列,不是:

线程数量
服务端总连接数
端口数量

一条已建立的 TCP 连接通常通过四元组区分:

源 IP
源端口
目标 IP
目标端口

因此,一个服务端监听端口可以承载大量客户端连接。

经典 BIO 包含两个阻塞点:

accept() 等待连接
read() 等待数据

为了避免一个连接的读取阻塞整个服务端,传统 BIO 通常采用:

一个监听线程
+
一个连接一个处理线程

最后,可以用一句话概括整篇文章:

三次握手负责在内核中建立 TCP 连接,accept() 负责把已经建立并等待处理的连接交给应用,而 BIO、NIO 和 epoll 的差别,主要在于应用如何等待和管理这些连接对应的文件描述符。

0

评论区