Socket、异步非阻塞 I/O 与 NGINX、Netty 模型
本文以 TCP 服务端和 Linux 为主要背景,串联监听 socket、连接 socket、I/O 多路复用,以及 NGINX 和 Netty 如何接受和处理连接。不同操作系统、传输实现和框架版本的细节可能有所不同。
1. TCP 监听 socket 与连接 socket
socket 是操作系统提供的网络通信抽象。应用通过文件描述符引用内核中的 socket;文件描述符是进程描述符表中的编号,并不是 socket 数据本身。
1.1 服务端如何建立连接
服务端通常先创建监听 socket:
c
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
bind(listen_fd, ...); // 绑定本地地址与端口
listen(listen_fd, 128); // 进入监听状态
int conn_fd = accept(listen_fd, ...); // 接受一个连接
recv(conn_fd, buffer, sizeof(buffer), 0);
客户端发起连接后,内核处理 TCP 握手,并将待接受的连接放入监听队列;应用调用 accept(),取出一个连接,并获得指向该连接 socket 的新描述符。监听 socket 仍可继续接受其他连接。
text
客户端 connect
↓
内核处理 TCP 握手和等待接受队列
↓
服务端在监听 socket 上调用 accept
↓
获得连接 socket,后续使用它收发数据
TCP 的 accept() 返回连接并不表示应用数据已经到达。非阻塞模式下,即便收到了就绪通知,调用 accept() 或 recv() 也仍须处理暂时无可用结果等情况。
1.2 两种 socket 的职责
| 对比 | 监听 socket | 连接 socket |
|---|---|---|
| 作用 | 等待并接受新连接 | 与特定对端收发数据 |
| 常用操作 | bind、listen、accept |
read/recv、write/send |
| 可读事件 | 可以尝试接受连接 | 可以尝试读取数据或发现连接状态变化 |
| 数量关系 | 一个监听入口可以接受多个连接 | 通常每条 TCP 连接对应一个服务端连接 socket |
监听 socket 不能像已连接的 TCP socket 一样直接与具体客户端收发业务数据;已连接的 socket 也不能作为监听 socket 去 accept 其他连接。客户端同样先调用 socket(),再通过 connect() 主动建立连接。两种角色的区别来自后续操作和状态。
连接一般通过源 IP、源端口、目标 IP、目标端口及协议区分。多个客户端可以同时连接同一个服务端端口,因为它们的连接标识不同。
2. fork、文件描述符与共享 socket
在 Linux 中,fork() 创建子进程。父子进程的普通内存拥有各自独立的地址空间,初始内容相同,通常由写时复制实现;共享内存映射等有不同语义。
子进程继承父进程打开的文件描述符副本。父子进程描述符表分离,但对应描述符可以引用同一个内核对象:
text
父进程的 fd ──┐
├──→ 同一个内核监听 socket
子进程的 fd ──┘
因此,父进程先绑定监听端口,再 fork 出多个子进程时,子进程不是分别重新 bind 同一端口,而是继承该监听 socket 的引用,不会因此产生重复绑定冲突。各进程关闭各自的描述符,底层对象在不再有引用时才会释放。
fork 不会复制父进程的其他线程;子进程只包含调用 fork 的线程。父进程的待处理信号、进程关联文件记录锁和部分定时器等也不会原样继承。多线程进程 fork 后,子进程在 execve 前只能安全调用 async-signal-safe 函数,需注意继承的锁状态。
3. 阻塞、非阻塞、异步与 I/O 多路复用
3.1 阻塞与非阻塞
阻塞 描述调用线程是否等待:阻塞 read() 在无数据时可以使线程等待;非阻塞 read() 在暂时无数据时会返回,应用稍后再尝试。
异步描述操作如何完成和交付结果:程序发起操作后可以继续执行,结果之后通过完成通知、回调或 Future 等方式交付。异步 API 的具体底层实现可能因操作系统和运行时而不同。
二者是不同维度:非阻塞调用可以由程序自行反复检查;异步发起的任务也可以在调用 Future.get() 后让调用线程阻塞等待。CompletableFuture 表达的是任务的异步组合,本身不能证明底层网络 I/O 使用内核完成式异步 I/O。
3.2 select、poll、epoll 与就绪通知
I/O 多路复用让一个线程等待多个 socket 的事件。以 Linux epoll 为例:
text
注册 socket 及关注事件
↓
epoll_wait 等待一个或多个事件就绪
↓
收到"可读/可写/可接受连接"等就绪通知
↓
应用调用 read、write 或 accept,处理返回值
epoll 是就绪通知 :它提示应用可以尝试 I/O,并不意味着已经替应用完成读取。epoll_wait() 本身可以阻塞;它让一个线程等待多个连接,而不是让每个连接各占一个等待线程。通常套接字也设置为非阻塞,以便事件状态变化或并发竞争时不会意外长时间阻塞。
3.3 完成通知式异步 I/O
与就绪通知相比,完成通知式接口让应用先提交具体 I/O 请求,在请求完成或失败后获取结果:
text
提交读取请求和缓冲区 → 执行读取 → 完成通知 → 处理结果
- Windows 的 Overlapped I/O 可配合 IOCP,向完成端口投递完成事件。
- Linux 的
io_uring使用提交队列和完成队列;具体操作的内核执行方式依赖操作类型和环境。 - Java
AsynchronousSocketChannel.read可通过CompletionHandler或Future交付结果;底层实现取决于平台,不应仅凭 API 名称断定其内核实现。
完成通知也需要线程或事件循环消费结果;它不代表系统从头到尾完全不使用线程。一条 TCP 消息也可能需要多次读取和组装。
4. NGINX 的 I/O 模型
4.1 Master 与 Worker
NGINX 主要采用多进程、事件驱动模型:
text
master:读取配置、管理 worker,通常创建和绑定监听 socket
├── worker 1:接受连接、处理连接 I/O
├── worker 2:接受连接、处理连接 I/O
└── worker 3:接受连接、处理连接 I/O
默认共享监听 socket 的模式下,多个 worker 持有引用同一个底层监听 socket 的文件描述符。新连接由一个 worker 成功 accept();该 worker 获得连接 socket 的描述符,之后通常由它的事件循环处理这条连接的读取和写入。master 通常不接受客户端连接或处理请求数据。
text
新连接 → 共享监听 socket → worker 2 accept
↓
worker 2 管理连接 socket
↓
可读事件 → recv → 处理
一个 worker 的事件循环可以管理多个连接。若多个 worker 关注同一个监听 socket,具体唤醒和接受行为取决于配置和平台:accept_mutex 可协调接受连接,Linux 的 EPOLLEXCLUSIVE 可减少不必要的唤醒。
4.2 reuseport
配置 listen ... reuseport 时,NGINX 为 worker 创建独立的监听 socket,这些 socket 使用相应的端口复用机制绑定同一地址和端口,由内核分配新连接。连接被某个 worker 接受后,通常仍由该 worker 继续处理。
NGINX 并非完全不使用线程:其主要网络请求处理是 worker 进程内的事件循环,某些工作也可以使用线程池。多进程提供一定的故障和内存隔离,但共享内存等场景仍需同步;它并不必然比多线程模型更快。
5. Netty 的 I/O 模型
5.1 Boss 与 Worker
典型 Netty 服务端在同一个 JVM 内使用两个 EventLoopGroup:
java
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(/* 初始化客户端 Channel 的 Pipeline */)
.bind(8080);
- Boss Group 中的 EventLoop 负责服务端 Channel 的事件,通常包括接受新连接。
- Worker Group 中的 EventLoop 负责被接受的客户端 Channel 的 I/O 事件。
- 这两个变量名是惯例;也可以配置同一个 Group 同时承担两种职责。
使用 Java NIO 时,监听 socket 封装在 NioServerSocketChannel 中。通常单次 bind 得到一个服务端 Channel,并将它注册到 Boss Group 的一个 EventLoop;Boss Group 有多个线程并不意味着同一个监听 Channel 会自动注册到全部 Boss EventLoop。
5.2 接受连接与注册到 Worker
以 Netty 4.1 NIO 模型为例:
text
监听 Channel 的接受事件就绪
↓
Boss 执行 accept,获得客户端连接
↓
创建对应的 Netty child Channel
↓
从 Worker Group 选择 EventLoop,提交注册任务
↓
Worker 在自己的 Selector 上注册连接 Channel
↓
Worker 监听并处理该连接的 I/O 事件
Boss 和 Worker 通常是同一进程中的线程,这里的交接是事件处理职责的交接,不是跨进程传递 socket,也不会创建第二条 TCP 连接。一个 Channel 通常绑定一个 EventLoop;一个 EventLoop 可管理多个 Channel。
注册时,Netty NIO 实现先将 Java Channel 注册到 Selector,随后在开始读取等阶段设置关注事件。连接的默认 Pipeline 回调通常由所属 EventLoop 执行;若 Handler 指定了其他执行器,则按其配置执行。
5.3 连接关闭由谁处理
注册完成后,通常由连接所属的 Worker EventLoop 处理其读写和生命周期事件。例如,对端正常关闭发送方向时,内核通知连接可读,Worker 读取并发现流结束;Netty 根据配置处理关闭,常规完整关闭时触发 channelInactive。启用半关闭时,输入方向关闭不一定意味着整个连接立即关闭。
网络异常断开也未必立即可见,可能要等读写错误、TCP 保活或应用心跳超时才能发现。
默认 EventLoop 同时处理其管理的多个连接;在 Handler 中执行耗时计算或阻塞调用会影响其他连接,应按场景交给合适的业务执行器,并考虑顺序、线程安全和背压。
5.4 NGINX 与 Netty 对比
| 项目 | NGINX 常见模型 | Netty 常见模型 |
|---|---|---|
| 运行单位 | master、worker 是不同进程 | Boss、Worker 通常是同一 JVM 内的线程组 |
| 接受连接 | worker 执行 accept |
Boss EventLoop 执行 accept |
| 处理连接 I/O | 通常由接受连接的 worker 继续处理 | child Channel 注册给 Worker EventLoop |
| 管理职责 | master 管理配置、worker 生命周期等 | Boss 主要负责服务端 Channel 事件,不等同于 master |
| 共通点 | 事件循环管理多个非阻塞连接 | 事件循环管理多个非阻塞连接 |
6. 同一应用中的 Web、Netty 与 Dubbo 端口
一个进程可以监听多个不同端口。例如,Web 容器监听 8080,应用内嵌 Netty 服务监听 80,前提是地址与端口可用且有绑定权限。端口号不会自动决定协议;Netty 要处理 HTTP,仍需配置相应的编解码与处理逻辑。应用关闭时也应关闭 Channel 和 EventLoopGroup。
Dubbo 服务提供者如果使用 dubbo 协议且传输实现采用 Netty,那么配置的 Dubbo 协议端口通常就是 Netty 服务端绑定的端口。实际监听、发布到注册中心的地址、协议和传输实现仍应结合版本、部署及配置核对;注册中心本身的端口是另一回事。