作者: andylin02
学习章节: 第十六章 非阻塞I/O
关键词: 非阻塞I/O, 阻塞I/O, fcntl, O_NONBLOCK, EINPROGRESS, 非阻塞connect, 非阻塞accept, 非阻塞read, 非阻塞write, str_cli, web客户端, SO_RCVTIMEO, 轮询, select
一、章节概述
1.1 本章焦点
第十六章在前一章Unix域协议的基础上,深入探讨网络编程中提升效率的核心手段——非阻塞I/O。套接字的默认状态是阻塞的,当发出一个不能立即完成的套接字调用时,进程将被投入睡眠,等待相应操作完成。这种机制简单可靠,但在高并发、高响应性要求的场景中,效率瓶颈十分明显——一个慢速连接可能阻塞整个进程,使其他所有连接都无法得到处理。
本章的核心任务是:理解阻塞与非阻塞I/O的本质区别,掌握将套接字设置为非阻塞模式的方法,深入剖析非阻塞read/write、accept和connect在各种边界条件下的行为特征,并通过完整的Web客户端示例展示非阻塞I/O在工程实践中的威力。
💡 本章核心价值:读完第十六章,你将能够——
- 理解阻塞I/O与非阻塞I/O的本质区别,掌握四种可能阻塞的套接字调用及其行为
- 熟练使用fcntl设置和取消套接字的非阻塞标志
- 编写非阻塞版本的str_cli函数,实现输入和网络传输的并行处理
- 掌握非阻塞connect的标准处理模式(EINPROGRESS + select + getsockopt)
- 理解非阻塞accept的注意事项和RST竞态问题
- 编写高性能的非阻塞Web并发客户端
二、阻塞I/O vs 非阻塞I/O
2.1 可能阻塞的套接字调用分类
套接字的默认状态是阻塞的。当发出一个不能立即完成的套接字调用时,进程将被投入睡眠,等待相应操作完成。可能阻塞的套接字调用可以分为以下四类:
| 输入操作 | read, readv, recv, recvfrom, recvmsg | TCP:接收缓冲区无数据;UDP:接收缓冲区无数据报 |
| 输出操作 | write, writev, send, sendto, sendmsg | TCP:发送缓冲区无空间;UDP:不因空间不足阻塞(无真正发送缓冲区) |
| 接受外来连接 | accept | 监听套接字的已完成连接队列为空 |
| 发起外出连接 | connect | TCP三次握手未完成(至少阻塞一个RTT) |
2.2 阻塞I/O的困境:以str_cli为例
前面章节的str_cli函数使用的是阻塞式I/O:如果标准输入有一行文本可读,调用read读入它,再调用writen把它发送给服务器。然而如果套接字发送缓冲区已满,writen调用将会阻塞。
实际测试数据显示,阻塞版本执行耗时长达354秒,而使用select加阻塞I/O的版本缩短至12.3秒,简单非阻塞I/O版本仅需6.9秒——提升了近两倍的效率。fork版本约8.7秒,thread版本约8.5秒。虽然非阻塞I/O效率最高,但其代码量最大、复杂度最高,容易引入难以调试的问题,因此必须谨慎权衡。
💡 非阻塞I/O与I/O复用不是互斥的——恰恰相反,非阻塞I/O通常需要与select/poll/epoll等I/O复用机制配合使用,才能发挥最大效用。非阻塞I/O解决了“单次I/O操作不会阻塞进程”的问题,而select解决了“如何同时等待多个描述符就绪”的问题,两者结合才能构建高效的事件驱动网络程序。
2.3 阻塞 vs 非阻塞核心区别
| 立即返回机制 | 无——操作完成才返回 | 有——不能立即完成时立即返回错误(EWOULDBLOCK/EAGAIN) |
| 进程状态 | 操作期间投入睡眠 | 不投入睡眠,继续执行 |
| 资源占用 | 每个连接需独占线程 | 单线程可处理多个连接 |
| 编程模型 | 简单直观 | 复杂,需处理各种边界条件 |
| 适用场景 | 低并发、简单程序 | 高并发、事件驱动系统 |
| CPU占用 | 等待期间几乎不占用CPU | 若不与I/O复用配合,轮询会空耗大量CPU |
非阻塞I/O的核心思想是:通知内核当所请求的I/O操作非得把本进程投入睡眠才能完成时,不把进程投入睡眠,而是返回一个错误。这是提高程序并发能力的关键技术。
2.4 阻塞I/O与非阻塞I/O对比图
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ 阻塞I/O vs 非阻塞I/O 核心区别 │
├─────────────────────────────────────────────────────────────────────────────────────┤
│ │
│ 阻塞I/O模型 非阻塞I/O模型 │
│ │
│ 进程 进程 │
│ │ │ │
│ │ recvfrom() │ recvfrom() │
│ │ ──────────────────────────────────→ │ ──────────────────────────────────────→ │
│ │ │ (立即返回EWOULDBLOCK) │
│ │ 无数据到达 → 进程阻塞 │ ←────────────────────────────────────── │
│ │ ┌─────────────┐ │ │
│ │ │ 睡眠中 │ │ │ 继续执行其他任务 │
│ │ │ │ │ │ │
│ │ └─────────────┘ │ │ 稍后再次轮询检查 │
│ │ │ │ │
│ │ 数据到达,唤醒进程 │ │ 数据就绪时调用recvfrom │
│ │ │ │ │
│ │ 拷贝数据 → 返回成功 │ │ 拷贝数据 → 返回成功 │
│ │ │ │
│ │ 💡 等待和拷贝都阻塞 │ 💡 等待阶段不阻塞,拷贝阶段阻塞 │
│ │
└─────────────────────────────────────────────────────────────────────────────────────┘
💡 关键理解:非阻塞I/O的“不阻塞”仅限于第一阶段(等待数据)。当数据就绪后,第二阶段(从内核向用户空间拷贝数据)仍然是阻塞的。因此,非阻塞I/O本质上是同步I/O——进程仍需等待数据拷贝完成。这是与异步I/O的核心区别。
三、设置非阻塞I/O
3.1 fcntl函数详解
设置套接字为非阻塞模式最标准的方法是通过fcntl函数,使用F_SETFL命令修改文件状态标志,添加O_NONBLOCK标志。
#include <fcntl.h>
int flags;
/* 1. 获取当前文件状态标志 */
if ((flags = fcntl(sockfd, F_GETFL, 0)) < 0)
err_sys("F_GETFL error");
/* 2. 添加O_NONBLOCK标志(注意:不是直接赋值,而是位或操作) */
flags |= O_NONBLOCK;
/* 3. 设置新的文件状态标志 */
if (fcntl(sockfd, F_SETFL, flags) < 0)
err_sys("F_SETFL error");
设置O_NONBLOCK标志后,该文件描述符上的所有阻塞操作将不再阻塞,而是在无法立即完成时返回错误并设置errno为EAGAIN或EWOULDBLOCK。对于connect函数,将返回EINPROGRESS错误。
3.2 取消非阻塞模式
/* 清除O_NONBLOCK标志,恢复阻塞模式 */
flags &= ~O_NONBLOCK;
if (fcntl(sockfd, F_SETFL, flags) < 0)
err_sys("F_SETFL error");
3.3 设置非阻塞模式的几种途径
| fcntl设置O_NONBLOCK | 任何时候 | 最通用的方法,适用于所有文件描述符 |
| accept4的SOCK_NONBLOCK标志 | 创建套接字时 | Linux特有,减少系统调用次数 |
| socket的SOCK_NONBLOCK标志 | 创建套接字时 | 创建时就设置为非阻塞,需Linux 2.6.27+ |
| ioctl的FIONBIO命令 | 任何时候 | BSD风格的设置方式 |
四、非阻塞I/O的类型详解
4.1 非阻塞read和write
TCP输入操作
对于阻塞的TCP套接字,如果接收缓冲区中没有数据可读,进程将投入睡眠直到有数据到达。由于TCP是字节流协议,唤醒条件是有任意数量的数据到达——可能是单个字节,也可能是一个完整的TCP分节中的数据。
对于非阻塞TCP套接字,如果输入操作不能被满足(即至少1个字节数据可读),read调用会立即返回EWOULDBLOCK错误。
#include "unp.h"
/* 非阻塞读取的典型处理模式 */
ssize_t n = read(sockfd, buf, sizeof(buf));
if (n == –1) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
/* 无数据可读,稍后再试——不是错误 */
/* 可以去做其他事情,或者等待select通知 */
} else {
err_sys("read error");
}
}
UDP输入操作
UDP是数据报协议,对于阻塞的UDP套接字,如果接收缓冲区为空,进程将被投入睡眠,直到有完整的数据报到达。对于非阻塞UDP套接字,如果没有完整数据报可读,recvfrom同样返回EWOULDBLOCK错误。
💡 UDP与TCP的重要区别:UDP以完整数据报为单位,不存在“部分数据”的概念。因此非阻塞UDP要么返回整个数据报,要么返回EWOULDBLOCK。
输出操作(write/send等)
对于TCP套接字,内核从应用进程缓冲区复制数据到该套接字的发送缓冲区。对于阻塞套接字,如果发送缓冲区没有空间,输出函数将被阻塞直到有空间为止。
对于非阻塞TCP套接字:
- 如果发送缓冲区完全没有空间 → 立即返回EWOULDBLOCK错误
- 如果发送缓冲区有部分空间 → 返回值是内核复制到缓冲区中的字节数(不足计数/short count),这正是TCP字节流协议的特性
对于UDP套接字,不存在真正的发送缓冲区——内核只是复制应用进程数据并将其沿协议栈向下传送。因此对于阻塞的UDP套接字,输出函数不会因发送缓冲区空间不足而阻塞。
/* 非阻塞写入的典型处理模式 */
ssize_t n = write(sockfd, buf, len);
if (n == –1) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
/* 发送缓冲区满,稍后再试 */
} else {
err_sys("write error");
}
}
4.2 非阻塞accept
行为说明
对于阻塞的套接字调用accept,如果没有新连接到达,调用进程将被投入睡眠。如果对一个非阻塞套接字调用accept,且尚无新连接到达,accept函数会立即返回EWOULDBLOCK错误。
select配合非阻塞accept的必要性
通常情况下,如果使用select在某个监听套接字上等待外来连接,当select返回监听套接字可读时,accept调用不应该阻塞。然而在某些边界条件下,accept仍可能阻塞,因此最佳实践是将监听套接字设置为非阻塞。
特殊情况:RST竞态问题
在繁忙的服务器上,可能出现以下场景:
解决方案:将监听套接字设置为非阻塞,并在accept调用中忽略以下错误:
- EWOULDBLOCK(Berkeley实现)
- ECONNABORTED(POSIX实现,表示客户终止连接)
- EPROTO(SVR4实现)
- EINTR(被信号中断)
#include "unp.h"
/* 将监听套接字设置为非阻塞后,安全调用accept */
int accept_nonblock(int listenfd, SA *cliaddr, socklen_t *addrlen)
{
int connfd;
for ( ; ; ) {
if ((connfd = accept(listenfd, cliaddr, addrlen)) < 0) {
if (errno == EWOULDBLOCK || errno == ECONNABORTED ||
errno == EPROTO || errno == EINTR) {
/* 这些错误不是真正的连接失败,可以重试 */
continue;
} else {
err_sys("accept error");
}
}
break;
}
return connfd;
}
4.3 非阻塞connect
核心挑战
connect函数用于TCP连接的建立。在阻塞模式下,connect会阻塞直到收到对SYN的ACK(至少一个RTT时间)。这种阻塞行为在需要同时建立多个连接时(如Web客户端同时请求多个页面)会成为性能瓶颈。
EINPROGRESS错误
如果对非阻塞的TCP套接字调用connect,且连接不能立即建立,则连接照样会发起(仍会送出三路握手过程的第一个分组),但调用会返回EINPROGRESS错误。
⚠️ 注意:有些连接可以立即建立(通常发生在服务器和客户在同一主机上),因此处理非阻塞connect时,必须同时准备connect成功返回和返回EINPROGRESS两种情况。
标准处理流程
非阻塞connect的标准处理流程如下:
#include "unp.h"
int connect_nonb(int sockfd, const SA *saptr, socklen_t salen, int nsec)
{
int flags, n, error;
socklen_t len;
fd_set rset, wset;
struct timeval tval;
/* 1. 获取当前套接字标志 */
flags = fcntl(sockfd, F_GETFL, 0);
if (flags < 0)
return –1;
/* 2. 设置为非阻塞模式 */
if (fcntl(sockfd, F_SETFL, flags | O_NONBLOCK) < 0)
return –1;
/* 3. 尝试连接 */
if ((n = connect(sockfd, saptr, salen)) < 0) {
/* 关键:必须检查 errno == EINPROGRESS */
if (errno != EINPROGRESS)
goto errout;
}
/* 4. 连接成功立即返回的情况(同一主机) */
if (n == 0) {
/* 恢复阻塞模式 */
fcntl(sockfd, F_SETFL, flags);
return 0;
}
/* 5. 使用select等待连接完成 */
FD_ZERO(&rset);
FD_SET(sockfd, &rset);
wset = rset;
tval.tv_sec = nsec;
tval.tv_usec = 0;
if ((n = select(sockfd + 1, &rset, &wset, NULL,
nsec ? &tval : NULL)) == 0) {
errno = ETIMEDOUT;
goto errout;
}
/* 6. 检查select返回条件 */
if (FD_ISSET(sockfd, &rset) || FD_ISSET(sockfd, &wset)) {
/* 获取套接字错误状态 */
len = sizeof(error);
if (getsockopt(sockfd, SOL_SOCKET, SO_ERROR, &error, &len) < 0) {
/* getsockopt出错,不一定是连接失败 */
goto errout;
}
if (error) {
errno = error;
goto errout;
}
} else {
errno = ETIMEDOUT;
goto errout;
}
/* 7. 连接成功,恢复阻塞模式 */
if (fcntl(sockfd, F_SETFL, flags) < 0)
return –1;
return 0;
errout:
close(sockfd);
return –1;
}
关键点详解
| 检查EINPROGRESS | 非阻塞connect的EINPROGRESS不是真正的错误,只是表示连接正在后台进行 |
| select监视写就绪 | 连接完成时,套接字变为可写(若成功)或可读(若有错误) |
| getsockopt(SO_ERROR) | 获取连接状态:返回0表示成功,非0表示失败——这是POSIX要求的标准做法 |
| 超时处理 | 可通过select的超时参数实现自定义连接超时 |
| 恢复阻塞模式 | 连接完成后根据需要决定是否恢复阻塞模式 |
五、非阻塞I/O与select配合的经典模式
5.1 str_cli的非阻塞实现(输入/输出双向缓冲区)
前面章节的str_cli函数使用阻塞I/O,存在输入和输出互相阻塞的问题。当一次性输入大量数据时(如10000行),第1个请求的回射已经到达,却卡在fgets等待下一行输入;终端输出同理被fputs阻塞。
解决方案:将每个描述符设置为非阻塞,设置两个缓冲区——终端输入→发出缓冲区和接收→终端输出缓冲区。每当任何一个缓冲区可读或可写时,从select返回,执行对应的I/O操作。
改进后的非阻塞str_cli框架:
#include "unp.h"
/* 客户端状态机 */
#define MAXLINE 1024
#define MAXFILES 20
void str_cli(FILE *fp, int sockfd)
{
int maxfdp1, stdineof;
fd_set rset;
char sendline[MAXLINE], recvline[MAXLINE];
stdineof = 0;
FD_ZERO(&rset);
for ( ; ; ) {
if (stdineof == 0)
FD_SET(fileno(fp), &rset);
FD_SET(sockfd, &rset);
maxfdp1 = max(fileno(fp), sockfd) + 1;
Select(maxfdp1, &rset, NULL, NULL, NULL);
/* 套接字可读 → 接收服务器回射 */
if (FD_ISSET(sockfd, &rset)) {
if (Readline(sockfd, recvline, MAXLINE) == 0) {
if (stdineof == 1)
return; /* 正常结束 */
else
err_quit("str_cli: server terminated prematurely");
}
Fputs(recvline, stdout);
}
/* 标准输入可读 → 读取并发送 */
if (FD_ISSET(fileno(fp), &rset)) {
if (Fgets(sendline, MAXLINE, fp) == NULL) {
stdineof = 1;
Shutdown(sockfd, SHUT_WR); /* 发送FIN */
FD_CLR(fileno(fp), &rset);
continue;
}
Writen(sockfd, sendline, strlen(sendline));
}
}
}
5.2 非阻塞I/O配合select的典型架构图
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ 非阻塞I/O + select事件驱动架构 │
├─────────────────────────────────────────────────────────────────────────────────────┤
│ │
│ 应用程序主循环 │
│ ┌─────────────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 1. 用FD_ZERO/FD_SET构建fd_set │ │
│ │ │ │
│ │ 2. select()阻塞等待 │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ 3. 检查FD_ISSET │ │
│ │ ├── 监听套接字就绪 → 非阻塞accept → 添加新fd到集合 │ │
│ │ ├── 已连接套接字可读 → 非阻塞read → 处理数据 │ │
│ │ ├── 已连接套接字可写 → 非阻塞write → 发送待发数据 │ │
│ │ └── 标准输入就绪 → 非阻塞read → 放入发送缓冲区 │ │
│ │ │ │
│ │ 4. 循环回到步骤1 │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────────────┘ │
│ │
│ 非阻塞I/O配合select的精髓:select告诉我们何时可以安全地进行非阻塞I/O操作, │
│ 从而避免了轮询(busy waiting)带来的CPU浪费。 │
│ │
└─────────────────────────────────────────────────────────────────────────────────────┘
六、非阻塞Web客户端
6.1 问题背景
Web客户端需要同时向多个服务器发起HTTP请求并收集响应。如果用阻塞I/O顺序处理,总耗时等于所有请求耗时之和。通过非阻塞connect和I/O复用,可以同时发起多个连接,显著缩短总体响应时间。这正是非阻塞I/O在处理大量并发连接时的核心价值——在单线程中实现接近多线程的并发能力,避免了多进程/多线程的上下文切换开销和内存占用。
6.2 Web客户端数据结构
#include "unp.h"
#define MAXFILES 20
#define SERV "80" /* HTTP服务端口 */
struct file {
char *f_name; /* 文件名(URL路径) */
char *f_host; /* 主机名 */
int f_fd; /* 套接字描述符 */
int f_flags; /* 标志:F_CONNECTING或F_READING */
} file[MAXFILES];
6.3 主程序框架
#include "web.h"
int main(int argc, char **argv)
{
int i, fd, n, maxnconn, flags, error;
char buf[MAXLINE];
fd_set rset, wset;
/* 解析命令行参数 */
if (argc < 5)
err_quit("usage: web <#conns> <hostname> <homepage> <file1> …");
maxnconn = atoi(argv[1]);
/* 初始化每个文件的结构 */
nfiles = min(argc – 4, MAXFILES);
for (i = 0; i < nfiles; i++) {
file[i].f_name = argv[i + 4];
file[i].f_host = argv[2];
file[i].f_flags = 0;
}
/* 建立到每个HTTP服务器的连接 */
for (i = 0; i < nfiles; i++) {
while (nconn < maxnconn && i < nfiles) {
/* 非阻塞connect启动连接 */
start_connect(&file[i]);
i++;
}
/* 等待某个连接完成或数据就绪 */
/* 使用select监听所有套接字 */
}
exit(0);
}
6.4 非阻塞connect的核心函数
#include "web.h"
void start_connect(struct file *fptr)
{
int fd, flags, n;
struct addrinfo *ai;
/* 解析主机名和服务名 */
ai = host_serv(fptr->f_host, SERV, 0, SOCK_STREAM);
/* 创建套接字 */
fd = Socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
fptr->f_fd = fd;
/* 设置为非阻塞 */
flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
/* 发起非阻塞连接 */
if ((n = connect(fd, ai->ai_addr, ai->ai_addrlen)) < 0) {
if (errno != EINPROGRESS)
err_sys("connect error");
fptr->f_flags = F_CONNECTING;
} else {
/* 连接立即建立(本地服务器) */
write_get_cmd(fptr); /* 发送HTTP请求 */
}
}
七、错误码详解
7.1 EAGAIN和EWOULDBLOCK
对于非阻塞套接字,当输入操作没有数据可读或输出操作没有空间可写时,函数会立即返回并设置errno。历史原因导致存在两个错误码:
- System V:返回EAGAIN(资源暂时不可用)
- 源自Berkeley的实现:返回EWOULDBLOCK(操作将阻塞)
POSIX规定两个错误码都可返回。目前大多数系统将这两个错误码定义为相同的值(可检查/usr/include/asm-generic/errno.h头文件),因此在实际编码中只需处理一个或两者都处理。
⚠️ 可移植性建议:为了在可移植性和代码清晰度之间取得平衡,建议同时检查两个错误码。
if (errno == EAGAIN || errno == EWOULDBLOCK) {
/* 非阻塞操作无法立即完成 */
}
7.2 EINPROGRESS
EINPROGRESS专用于非阻塞connect场景。在man文档中描述如下:
EINPROGRESS: The socket is nonblocking and the connection cannot be completed immediately. It is possible to select or poll for completion by selecting the socket for writing. After select indicates writability, use getsockopt to read the SO_ERROR option to determine whether connect completed successfully (SO_ERROR is zero) or unsuccessfully (SO_ERROR is one of the usual error codes listed here, explaining the reason for the failure).
7.3 其他常见错误码
| EINTR | 阻塞系统调用被信号中断 | 应重试系统调用 |
| ECONNREFUSED | connect失败,目标端口无服务 | 对端无监听服务 |
| ETIMEDOUT | 连接超时 | TCP重传超时或select超时 |
| EPIPE | 向已关闭的连接写入 | 通常伴随SIGPIPE信号 |
八、关键图表
8.1 阻塞I/O vs 非阻塞I/O流程图
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ 阻塞I/O vs 非阻塞I/O流程图 │
├─────────────────────────────────────────────────────────────────────────────────────┤
│ │
│ 阻塞I/O: │
│ ┌─────────────────────────────────────────────────────────────────────────────┐ │
│ │ 应用程序调用read() │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ 内核检查接收缓冲区 ──→ 无数据 → 进程投入睡眠 │ │
│ │ │ ┌─────────────┐ │ │
│ │ │ │ 睡眠中 │ │ │
│ │ │ └──────┬──────┘ │ │
│ │ │ │ 数据到达 │ │
│ │ │ ┌──────▼──────┐ │ │
│ │ │ │ 被唤醒 │ │ │
│ │ └──────────────┐ └──────┬──────┘ │ │
│ │ │ │ │ │
│ │ ▼ ▼ │ │
│ │ ┌─────────────────────────────────────────────────────────────────────┐ │ │
│ │ │ 将数据从内核拷贝到用户空间 │ │ │
│ │ └─────────────────────────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ 返回读取的字节数 │ │
│ └─────────────────────────────────────────────────────────────────────────────┘ │
│ │
│ 非阻塞I/O: │
│ ┌─────────────────────────────────────────────────────────────────────────────┐ │
│ │ 应用程序调用read() │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ 内核检查接收缓冲区 ──→ 无数据 → 立即返回EWOULDBLOCK │ │
│ │ │ │ │
│ │ │ 有数据 │ │
│ │ ▼ │ │
│ │ 将数据从内核拷贝到用户空间 │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ 返回读取的字节数 │ │
│ └─────────────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────────────┘
8.2 非阻塞connect状态机图
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ 非阻塞connect状态机 │
├─────────────────────────────────────────────────────────────────────────────────────┤
│ │
│ ① socket创建(默认阻塞) │
│ ┌─────────────────────────────────────────────────────────────────────────────┐ │
│ │ sockfd = socket(AF_INET, SOCK_STREAM, 0); │ │
│ │ 套接字状态:CLOSED │ │
│ └─────────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────────────┐ │
│ │ ② 设置非阻塞模式 │ │
│ │ fcntl(sockfd, F_SETFL, flags | O_NONBLOCK); │ │
│ └─────────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────────────┐ │
│ │ ③ connect()发起连接 │ │
│ │ │ │
│ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ │
│ │ │ 返回0 │ │ 返回-1,errno= │ │ 其他错误 │ │ │
│ │ │ │ │ EINPROGRESS │ │ │ │ │
│ │ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │ │
│ │ │ │ │ │ │
│ │ ▼ ▼ ▼ │ │
│ │ 连接立即建立 连接正在进行 真正的错误 │ │
│ │ (本地连接) (等待select) 直接退出 │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────────────┐ │
│ │ ④ select监听套接字可写 │ │
│ │ │ │
│ │ select返回 → 套接字可写 │ │
│ └─────────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────────────┐ │
│ │ ⑤ getsockopt检查SO_ERROR │ │
│ │ │ │
│ │ ┌─────────────────┐ ┌─────────────────┐ │ │
│ │ │ error == 0 │ │ error != 0 │ │ │
│ │ │ 连接成功 │ │ 连接失败 │ │ │
│ │ └────────┬────────┘ └────────┬────────┘ │ │
│ │ │ │ │ │
│ │ ▼ ▼ │ │
│ │ 开始发送HTTP请求 根据errno报告错误 │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────────────┘
九、常见问题与注意事项
9.1 移植性问题
非阻塞I/O在不同系统之间存在一定的可移植性问题:
| EINPROGRESS的select处理 | select对处于EINPROGRESS状态的socket在某些系统上可能不起作用,需参考具体系统文档 |
| getsockopt返回值差异 | 对于出错的socket,Linux上getsockopt返回-1,源自伯克利的UNIX返回0——但关键不是返回值,而是error中存储的错误码 |
| 错误码值差异 | EAGAIN和EWOULDBLOCK在大多数现代系统上值相同,但极少数系统仍然区分 |
| 抽象命名空间(Linux特有) | 使用sun_path[0] = 0时,名字从第2个字节开始,长度需正确计算 |
9.2 非阻塞I/O的效率权衡
| 低并发(<10个连接) | 阻塞I/O + 多进程/多线程(编程简单) |
| 中等并发(10-1000个连接) | I/O复用(select/poll)+ 非阻塞I/O |
| 高并发(>1000个连接) | epoll + 非阻塞I/O(Linux)或 kqueue + 非阻塞I/O(BSD/macOS) |
💡 性能权衡原则:非阻塞I/O并非在所有场景下都优于阻塞I/O。当并发度很低时,阻塞I/O可能更高效——因为read/write系统调用已高度优化,比多个线程协作的非阻塞方案开销更低。但在高并发场景下,非阻塞I/O的优势会充分体现出来。正确的选择取决于具体应用场景。
9.3 与阻塞I/O配合使用
在某些场景下,需要临时将非阻塞套接字恢复为阻塞模式进行特定操作。可以使用fcntl保存原始标志,操作完成后恢复。也可以利用MSG_DONTWAIT标志为单次recv/send调用临时指定非阻塞模式,无需修改文件描述符状态。
9.4 常见陷阱与最佳实践
| 忽略EINPROGRESS后的select | connect返回EINPROGRESS后必须等待select通知,不可立即尝试读写 |
| select返回后直接读写 | 即使select通知可写,仍需用getsockopt检查SO_ERROR确认连接成功 |
| 非阻塞accept忽略RST处理 | 必须处理ECONNABORTED等错误,不可假设select返回后accept一定成功 |
| 轮询空耗CPU | 非阻塞I/O必须与select/poll/epoll配合,不能仅靠循环检查 |
| 未保存fcntl原始标志 | 修改文件状态标志前应先用F_GETFL获取原始值,否则无法正确恢复 |
| 仅检查单个错误码 | 非阻塞操作的返回值检查必须包含EAGAIN、EWOULDBLOCK和EINTR三种情况 |
十、本章小结
10.1 核心知识点回顾
| 阻塞I/O的四个类型 | 输入操作、输出操作、accept、connect——每个都有特定的阻塞条件和唤醒机制 |
| 非阻塞标志设置 | fcntl配合O_NONBLOCK是最标准的方法,accept4和socket的SOCK_NONBLOCK可减少系统调用 |
| 非阻塞read/write | 返回EAGAIN/EWOULDBLOCK表示操作无法立即完成——不是真正的错误,只是通知应用稍后重试 |
| 非阻塞accept | 必须处理ECONNABORTED、EPROTO等错误——因为客户端可能在select和accept之间发送RST |
| 非阻塞connect | 返回EINPROGRESS后需用select等待,再用getsockopt检查SO_ERROR——这是POSIX要求的标准做法 |
| select配合非阻塞I/O | 避免轮询空耗CPU的关键——select告诉内核何时可以安全地进行非阻塞I/O |
| 错误码处理 | EAGAIN/EWOULDBLOCK通常值相同,EINPROGRESS专用于connect |
| Web客户端示例 | 非阻塞connect和I/O复用实现同时连接多个HTTP服务器 |
10.2 本章思维导图
第十六章 非阻塞I/O
├── 阻塞I/O vs 非阻塞I/O
│ ├── 阻塞I/O:操作完成前进程被投入睡眠
│ ├── 非阻塞I/O:无法立即完成时立即返回错误
│ └── 四种可能阻塞的套接字调用
│ ├── 输入操作(read/recv等)
│ ├── 输出操作(write/send等)
│ ├── accept
│ └── connect
├── 设置非阻塞I/O
│ ├── fcntl + O_NONBLOCK(最通用)
│ ├── accept4 + SOCK_NONBLOCK(Linux)
│ └── socket + SOCK_NONBLOCK(Linux 2.6.27+)
├── 非阻塞read/write
│ ├── TCP:发送缓冲区满/接收缓冲区空 → EWOULDBLOCK
│ ├── UDP:接收缓冲区空 → EWOULDBLOCK(输出不因空间不足阻塞)
│ └── 典型处理:if (errno == EAGAIN || errno == EWOULDBLOCK) { retry }
├── 非阻塞accept
│ ├── 无新连接 → EWOULDBLOCK
│ ├── RST竞态:select返回后客户端发送RST
│ └── 需处理ECONNABORTED、EPROTO、EINTR
├── 非阻塞connect(核心重点)
│ ├── 立即成功 → 0(本地连接)
│ ├── 不能立即完成 → EINPROGRESS
│ ├── select监听写就绪
│ ├── getsockopt检查SO_ERROR
│ └── 自定义超时(select的timeout)
├── 错误码
│ ├── EAGAIN / EWOULDBLOCK:非阻塞操作无法立即完成
│ ├── EINPROGRESS:非阻塞connect正在进行
│ └── EINTR:系统调用被信号中断,应重试
└── 实用封装
├── connect_nonb():非阻塞connect带超时
├── 非阻塞str_cli:输入输出双向缓冲区
└── Web客户端:同时发起多个HTTP请求
十一、下一章预告
📌 下一篇:《UNIX网络编程》读书笔记(十七):第十七章 ioctl操作
第十七章将详细讲解:
学习目标:学完第十七章后,你将能够——
- 理解ioctl在套接字编程中的多种用途(控制、配置、信息获取)
- 使用FIONREAD获取套接字接收缓冲区中待读的数据量
- 编写简单的网络配置工具(如获取本机IP地址列表)
- 理解netstat和ifconfig等网络工具的底层实现原理
- 在程序中动态获取和设置网络接口参数
敬请期待!
参考资料
本文为个人学习笔记,仅用于知识分享。如有错误,欢迎指正。
👍🏻 点赞 + 收藏 + 分享,让更多开发者看到这篇深度解析!❤️ 如果觉得有用,请给个赞支持一下作者!


