《UNIX 网络编程-卷1》阅读笔记07: I/O 多路转接:select、poll
作者: andylin02
学习章节: 第六章 I/O复用——select和poll函数
关键词: I/O复用, I/O模型, select, poll, 描述符集, 批量输入, shutdown, pselect
一、章节概述
1.1 本章焦点
第六章解决了第五章遗留的核心痛点:当客户同时处理标准输入和TCP套接字时,若阻塞于fgets等待用户输入,而服务器提前终止,客户端直到尝试读取套接字时才能发现EOF,这可能需要漫长的等待。本章引入的I/O复用机制,正是为了解决这一类问题。
I/O复用(I/O Multiplexing)是指进程预先告知内核需要监视多个描述符,内核一旦发现其中任何一个描述符就绪(即输入已准备好被读取,或描述符已能承受更多输出),就通知进程。本章重点讲解select和poll两个函数,它们使单进程能够同时等待多个I/O操作,从根本上解决了第五章中标准输入阻塞导致无法及时响应网络事件的问题。
💡 为什么需要I/O复用?
- 当客户同时处理多个描述符(通常是交互式输入和网络套接字)时,必须使用I/O复用
- TCP服务器既要处理监听套接字,又要处理多个已连接套接字时
- 服务器既要处理TCP又要处理UDP时
- 服务器要处理多个服务或多个协议时(如inetd守护进程)
1.2 本章内容结构
| 节号 | 标题 | 核心内容 |
|---|---|---|
| 6.1 | 概述 | 从第五章的问题引出I/O复用的必要性 |
| 6.2 | I/O模型 | 五种I/O模型的详细介绍和对比 |
| 6.3 | select函数 | select函数的参数、返回值、描述符集操作 |
| 6.4 | 批量输入 | 使用select重写str_cli函数 |
| 6.5 | shutdown函数 | 半关闭与close的区别 |
| 6.6 | str_cli函数(续) | 用select和shutdown实现优雅关闭 |
| 6.7 | TCP回射服务器(select版) | 使用select实现单进程并发服务器 |
| 6.8 | pselect函数 | POSIX增强版的select |
| 6.9 | poll函数 | poll函数的用法和与select的对比 |
💡 本章核心价值:读完第六章,你将能够——
- 深入理解五种I/O模型的本质区别
- 掌握
select函数的使用方法和描述符集操作- 使用
select重写str_cli,解决第五章中标准输入阻塞的问题- 实现单进程的select服务器,在单进程中处理多客户端并发
- 理解
shutdown与close的区别,掌握半关闭的正确用法- 了解
poll函数并知道它与select的差异
二、五种I/O模型
2.1 I/O操作的两个阶段
在深入讲解各种I/O模型之前,先要理解一个输入操作通常包含两个不同阶段:
-
第一阶段:等待数据准备好(Waiting for data to be ready)。对于一个套接字上的输入操作,这一步等待数据从网络中到达。当分组到达时,它被拷贝到内核中的某个缓冲区。
-
第二阶段:从内核向进程复制数据(Copying data from kernel to process)。将数据从内核缓冲区拷贝到应用程序缓冲区。
基于这两个阶段,UNIX下有五种I/O模型。
2.2 五种I/O模型图解
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ 五种I/O模型对比图 │
├─────────────────────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. 阻塞式I/O模型 │
│ ┌─────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ 进程 ←─── recvfrom系统调用 ──→ 内核 │ │
│ │ ┌─────────────┐ │ │
│ │ │ 无数据到达 │ │ │
│ │ └──────┬──────┘ │ │
│ │ │ 等待数据 │ │
│ │ ┌──────▼──────┐ │ │
│ │ │ 数据到达 │ │ │
│ │ └──────┬──────┘ │ │
│ │ │ 拷贝数据 │ │
│ │ ┌──────▼──────┐ │ │
│ │ 进程 ←────────────────────│ 拷贝完成 │────────────────→ 进程处理数据 │ │
│ │ (全程阻塞) └─────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────────────────────┘ │
│ │
│ 2. 非阻塞式I/O模型 │
│ ┌─────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ 进程 ────→ recvfrom ────→ 内核 ──→ 返回EWOULDBLOCK ──→ 进程继续轮询 │ │
│ │ (立即返回) (无数据) │ │
│ │ ────→ recvfrom ────→ 内核 ──→ 返回EWOULDBLOCK │ │
│ │ ────→ recvfrom ────→ 内核 ──→ 数据到达 ──→ 拷贝数据 ──→ 返回成功 ──→ 进程处理 │ │
│ └─────────────────────────────────────────────────────────────────────────────────────┘ │
│ │
│ 3. I/O复用模型 │
│ ┌─────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ 进程 ────→ select ────→ 内核 ──→ 等待数据到达 ──→ 返回可读 │ │
│ │ (阻塞等待) │ │
│ │ ────→ recvfrom ────→ 内核 ──→ 拷贝数据 ──→ 返回成功 ──→ 进程处理 │ │
│ └─────────────────────────────────────────────────────────────────────────────────────┘ │
│ │
│ 4. 信号驱动式I/O模型 │
│ ┌─────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ 进程 ────→ 建立SIGIO信号处理函数 ────→ 继续执行 │ │
│ │ │ │ │
│ │ 当数据到达时,内核发送SIGIO信号 │ │
│ │ │ │ │
│ │ 信号处理函数调用recvfrom读取数据 │ │
│ └─────────────────────────────────────────────────────────────────────────────────────┘ │
│ │
│ 5. 异步I/O模型 │
│ ┌─────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ 进程 ────→ aio_read ────→ 内核 ──→ 等待数据 → 拷贝数据 → 完成后通知进程 │ │
│ │ (立即返回) │ │
│ │ ──────────────────────────────────────────────────────→ 进程收到通知后直接使用数据 │ │
│ └─────────────────────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
2.3 五种I/O模型详解
阻塞式I/O(Blocking I/O)
这是最常用的模型。默认情况下,所有套接字都是阻塞的。进程调用recvfrom,系统调用直到数据报到达且被拷贝到应用缓冲区才返回。进程从调用开始到返回的整段时间内被阻塞。
💡 特点:两个阶段(等待数据和拷贝数据)都阻塞。
非阻塞式I/O(Nonblocking I/O)
进程把一个套接字设置成非阻塞模式,是在通知内核:当所请求的I/O操作非得把本进程投入睡眠才能完成时,不把进程投入睡眠,而是返回一个错误。
非阻塞I/O需要循环反复调用recvfrom,这种模式称为轮询,大量浪费CPU时间,实际中很少使用。
💡 特点:第一阶段不阻塞(返回错误),第二阶段阻塞。
I/O复用(I/O Multiplexing)
调用select或poll阻塞在这两个系统调用之上,而不是阻塞在真正的I/O系统调用上。当select返回套接字可读这一条件时,再调用recvfrom把数据复制到应用程序缓冲区。
💡 特点:阻塞在
select上,可以同时等待多个描述符就绪。真正的I/O调用(recvfrom)不会阻塞(因为已经知道数据就绪)。
信号驱动式I/O(Signal-Driven I/O)
让内核在描述符就绪时发送SIGIO信号通知进程。优势在于等待数据报到达期间进程不被阻塞,主循环可以继续执行,只要等待来自信号处理函数的通知即可。
💡 特点:第一阶段不阻塞,第二阶段阻塞。该模型在实际中并不常用。
异步I/O(Asynchronous I/O)
告知内核启动某个操作,并让内核在整个操作完成后通知进程。与信号驱动I/O的区别在于:信号驱动I/O由内核通知进程何时可以启动I/O操作,而异步I/O由内核通知进程I/O操作何时完成。
💡 特点:两个阶段都不阻塞。
2.4 同步I/O与异步I/O
POSIX将这两类操作定义如下:
- 同步I/O操作(Synchronous I/O Operation):导致请求进程阻塞,直到I/O操作完成。
- 异步I/O操作(Asynchronous I/O Operation):不导致请求进程阻塞。
根据这个定义,前四种模型——阻塞式I/O、非阻塞式I/O、I/O复用和信号驱动式I/O——都是同步I/O,因为真正的I/O操作(recvfrom)将阻塞进程。只有异步I/O模型与POSIX定义的异步I/O相匹配。
核心区分: 前四种模型的区别在于等待数据的阶段,它们的数据拷贝阶段相同(都阻塞于recvfrom调用);而异步I/O模型在两个阶段都不阻塞,由内核完成整个操作后通知进程。
2.5 五种I/O模型对比表
| I/O模型 | 等待数据阶段 | 拷贝数据阶段 | 是否为同步 | 适用场景 |
|---|---|---|---|---|
| 阻塞式I/O | 阻塞 | 阻塞 | 同步 | 简单程序,低并发 |
| 非阻塞式I/O | 不阻塞(轮询) | 阻塞 | 同步 | 极少单独使用 |
| I/O复用 | 阻塞于select |
阻塞 | 同步 | 多路并发服务器(最常用) |
| 信号驱动式I/O | 不阻塞 | 阻塞 | 同步 | 极少使用 |
| 异步I/O | 不阻塞 | 不阻塞 | 异步 | 高并发、高性能场景 |
三、select函数深度解析
3.1 函数原型
#include <sys/select.h>
#include <sys/time.h>
int select(int maxfdp1, fd_set *readset, fd_set *writeset,
fd_set *exceptset, const struct timeval *timeout);
// 返回值:成功返回就绪描述符的数量,超时返回0,出错返回-1
3.2 参数详解
timeout参数——等待时间
struct timeval {
long tv_sec; /* 秒 */
long tv_usec; /* 微秒 */
};
timeout有三种可能的取值:
- 传入
NULL空指针:永远等待,直到至少有一个描述符就绪才返回 tv_sec = tv_usec = 0:完全不等待,检查描述符后立即返回(轮询)tv_sec > 0或tv_usec > 0:等待指定时间,超时则返回0
⚠️ 注意:虽然POSIX规定
timeout是const,但部分Linux实现中仍然会修改它。为了可移植性,最好每次调用select前都重新初始化timeout。
三个描述符集
| 参数 | 含义 | 用途 |
|---|---|---|
readset |
读描述符集 | 监视是否有数据可读(普通数据、FIN等) |
writeset |
写描述符集 | 监视是否可写(发送缓冲区有空闲空间) |
exceptset |
异常描述符集 | 监视是否发生异常条件(如TCP带外数据) |
maxfdp1参数——最大描述符值+1
maxfdp1的值是三个描述符集中最大的描述符值加1。例如,监视的描述符为3、5、7时,maxfdp1应设置为8(7+1)。
💡 设计原因:
fd_set内部使用位掩码实现,每个整数对应32个描述符。内核遍历描述符时需要知道从哪里停止,因此需要知道最大描述符值。
3.3 描述符集操作宏
fd_set是一个整数数组,整数的每一位代表一个描述符。为了隐藏实现细节,提供了四个宏来操作描述符集:
#include <sys/select.h>
void FD_ZERO(fd_set *fdset); // 清空所有位
void FD_SET(int fd, fd_set *fdset); // 将指定fd加入集合
void FD_CLR(int fd, fd_set *fdset); // 将指定fd从集合中移除
int FD_ISSET(int fd, fd_set *fdset); // 检查fd是否在集合中
典型用法:
fd_set rset;
FD_ZERO(&rset); // 初始化:清空集合
FD_SET(0, &rset); // 添加描述符0(标准输入)
FD_SET(3, &rset); // 添加描述符3
FD_SET(4, &rset); // 添加描述符4
// 调用select...
if (FD_ISSET(0, &rset)) {
// 描述符0可读
}
💡 重要:
select返回时,内核会修改描述符集,只保留就绪的描述符。因此每次调用select前都需要重新初始化描述符集,或将未修改的副本保存起来用于每次调用前恢复。
3.4 select的返回值
| 返回值 | 含义 |
|---|---|
> 0 |
就绪描述符的数量,此时三个描述符集中只有就绪的位被保留 |
0 |
超时,没有描述符就绪 |
-1 |
出错。常见错误:EINTR(被信号中断) |
⚠️ EINTR处理:阻塞的
select可能被捕获的信号中断,返回-1并设置errno = EINTR。为了可移植性,需要在代码中处理EINTR错误并重试select。
3.5 描述符就绪条件
读就绪条件
- 套接字接收缓冲区中的数据字节数 ≥ 套接字接收缓冲区低水位标记的当前大小
- 连接的读这一半关闭(即收到FIN),对这样的套接字读操作将不阻塞并返回0
- 套接字是一个监听套接字且已完成的连接数不为0,此时
accept通常不阻塞 - 套接字错误待处理,对这样的套接字读操作将不阻塞并返回-1
写就绪条件
- 套接字发送缓冲区中的可用空间字节数 ≥ 套接字发送缓冲区低水位标记的当前大小
- 连接的写这一半关闭,对这样的套接字写操作将产生SIGPIPE信号
- 使用非阻塞式
connect的套接字已建立连接,或connect已失败 - 套接字错误待处理
异常就绪条件
- 套接字存在带外数据(out-of-band data)或仍处于带外标记
四、poll函数深度解析
4.1 函数原型
poll函数提供的功能与select类似,但可以处理更多类型的描述符。
#include <poll.h>
int poll(struct pollfd *fdarray, unsigned long nfds, int timeout);
// 返回值:成功返回就绪描述符的数量,超时返回0,出错返回-1
4.2 pollfd结构
struct pollfd {
int fd; /* 要检查的描述符 */
short events; /* 感兴趣的事件(输入参数) */
short revents; /* 实际发生的事件(输出参数) */
};
fdarray参数指向一个struct pollfd数组,数组中每个元素指定一个描述符以及该描述符上感兴趣的事件。nfds参数指定数组中元素的个数。timeout参数指定等待的毫秒数:
-1:永远等待0:立即返回,不阻塞>0:等待指定的毫秒数
4.3 事件常量
events和revents字段的值由以下常量构成:
| 常量 | 可作为events输入 | 可作为revents输出 | 含义 |
|---|---|---|---|
POLLIN |
✅ | ✅ | 普通或优先级带数据可读 |
POLLRDNORM |
✅ | ✅ | 普通数据可读 |
POLLRDBAND |
✅ | ✅ | 优先级带数据可读 |
POLLPRI |
✅ | ✅ | 高优先级数据可读 |
POLLOUT |
✅ | ✅ | 普通或优先级带数据可写 |
POLLWRNORM |
✅ | ✅ | 普通数据可写 |
POLLWRBAND |
✅ | ✅ | 优先级带数据可写 |
POLLERR |
❌ | ✅ | 发生错误 |
POLLHUP |
❌ | ✅ | 发生挂起 |
POLLNVAL |
❌ | ✅ | 描述符不是一个打开的文件 |
💡 重要:
- 所有正规TCP数据和UDP数据都被认为是普通数据
- TCP的带外数据被认为是优先级带数据
- 当TCP连接的读这一半关闭(如收到FIN),这被认为是普通数据,后续读操作返回0
- TCP连接存在错误既可以认为是普通数据,也可以认为是错误(
POLLERR),无论哪种情况后续读操作都返回-1
4.4 poll与select对比
| 对比维度 | select | poll |
|---|---|---|
| 描述符数量限制 | 通常限于FD_SETSIZE(1024) | 无限制(受系统资源限制) |
| 描述符集结构 | 三个fd_set(读/写/异常) |
pollfd数组 |
| 事件精确度 | 无法精确区分普通数据和优先级数据 | 可精确指定事件类型 |
| 效率 | 每次调用都需要复制描述符集 | 同样需要复制数组,但事件分离 |
| 可移植性 | 几乎所有系统都支持 | POSIX标准,广泛支持 |
💡 poll的优势:不需要计算
maxfdp1,没有FD_SETSIZE限制,可以更精细地控制事件类型。但poll仍然需要遍历整个描述符数组,对于大量描述符的场景效率依然不高——这也是后续epoll出现的原因。
五、完整源代码分析
5.1 使用select的str_cli函数——解决第五章的问题
第五章中,客户端str_cli函数阻塞于fgets,无法及时响应网络事件。本章通过select解决了这个问题。
#include "unp.h"
void str_cli(FILE *fp, int sockfd)
{
int maxfdp1;
fd_set rset;
char sendline[MAXLINE], recvline[MAXLINE];
// 标准输入和套接字描述符
// 注意:需要提前设置标准输入为非阻塞?不需要,select会同时监视两者
FD_ZERO(&rset);
for ( ; ; ) {
FD_SET(fileno(fp), &rset); // 添加标准输入到读集合
FD_SET(sockfd, &rset); // 添加套接字到读集合
maxfdp1 = max(fileno(fp), sockfd) + 1;
// select等待两个描述符中任何一个就绪
Select(maxfdp1, &rset, NULL, NULL, NULL);
// 套接字可读 → 读取服务器回射数据
if (FD_ISSET(sockfd, &rset)) {
if (Readline(sockfd, recvline, MAXLINE) == 0)
err_quit("str_cli: server terminated prematurely");
Fputs(recvline, stdout);
}
// 标准输入可读 → 读取用户输入并发送
if (FD_ISSET(fileno(fp), &rset)) {
if (Fgets(sendline, MAXLINE, fp) == NULL) {
// 读到EOF时,关闭写这一半,但仍可继续接收服务器数据
Shutdown(sockfd, SHUT_WR);
FD_CLR(fileno(fp), &rset); // 不再监视标准输入
continue;
}
Writen(sockfd, sendline, strlen(sendline));
}
}
}
💡 核心改进:
- 使用
select同时监视标准输入和套接字描述符,不再阻塞在单一的fgets上- 当服务器提前终止时,
select会返回套接字的就绪状态,客户端能立刻发现- 当用户在标准输入键入EOF(Ctrl+D)时,调用
shutdown(sockfd, SHUT_WR)关闭连接的写这一半,但仍可读取服务器回射的剩余数据
5.2 使用select的TCP回射服务器(单进程并发版)
#include "unp.h"
int main(int argc, char **argv)
{
int i, maxi, maxfd, listenfd, connfd, sockfd;
int nready, client[FD_SETSIZE];
ssize_t n;
fd_set rset, allset;
char buf[MAXLINE];
socklen_t clilen;
struct sockaddr_in cliaddr, servaddr;
// 1. 创建监听套接字并绑定
listenfd = Socket(AF_INET, SOCK_STREAM, 0);
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(SERV_PORT);
Bind(listenfd, (SA *) &servaddr, sizeof(servaddr));
Listen(listenfd, LISTENQ);
// 2. 初始化描述符集
maxfd = listenfd; // 初始最大描述符值
maxi = -1; // client数组的当前索引
for (i = 0; i < FD_SETSIZE; i++)
client[i] = -1; // 初始化client数组为-1
FD_ZERO(&allset);
FD_SET(listenfd, &allset); // 监听套接字始终在集合中
for ( ; ; ) {
rset = allset; // 每次select前需要重置
nready = Select(maxfd + 1, &rset, NULL, NULL, NULL);
// 3. 处理新连接(监听套接字就绪)
if (FD_ISSET(listenfd, &rset)) {
clilen = sizeof(cliaddr);
connfd = Accept(listenfd, (SA *) &cliaddr, &clilen);
// 将新连接放入client数组
for (i = 0; i < FD_SETSIZE; i++) {
if (client[i] == -1) {
client[i] = connfd;
break;
}
}
if (i == FD_SETSIZE)
err_quit("too many clients");
FD_SET(connfd, &allset); // 添加到全局描述符集
if (connfd > maxfd)
maxfd = connfd; // 更新最大描述符值
if (i > maxi)
maxi = i; // 更新client数组的最大索引
if (--nready <= 0) // 没有更多就绪描述符
continue;
}
// 4. 处理现有客户的数据
for (i = 0; i <= maxi; i++) {
if ((sockfd = client[i]) < 0)
continue;
if (FD_ISSET(sockfd, &rset)) {
if ((n = Read(sockfd, buf, MAXLINE)) == 0) {
// 连接关闭
Close(sockfd);
FD_CLR(sockfd, &allset);
client[i] = -1;
} else {
Writen(sockfd, buf, n); // 回射数据
}
if (--nready <= 0)
break; // 没有更多就绪描述符
}
}
}
}
5.3 select服务器的关键设计要点
| 设计要点 | 说明 |
|---|---|
| 监听套接字与已连接套接字分离 | 通过select同时监视listenfd和所有connfd,实现单进程多客户端 |
| client数组维护 | 用client[]数组记录所有已连接套接字,最大容量为FD_SETSIZE |
| allset与rset分离 | allset保存需要监视的所有描述符,每次select前复制给rset |
| 最大描述符跟踪 | maxfd跟踪最大描述符值,用于select的第一个参数 |
| -1标记空闲槽位 | client[i] = -1标记空闲位置,新连接存入第一个空闲槽 |
| nready计数器 | 利用select返回的计数,在已处理足够描述符后提前退出循环,提高效率 |
5.4 select服务器状态转换示意图
┌─────────────────────────────────────────────────────────────────────────────┐
│ select服务器处理多个客户端的状态图 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 初始状态(无客户端) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ allset = {3}(listenfd=3) │ │
│ │ select返回条件:监听套接字可读(新连接到达) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 第一个客户端连接后 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ allset = {3, 4}(connfd=4) │ │
│ │ client[0] = 4, client[1..] = -1 │ │
│ │ select返回条件:监听套接字可读 OR 已连接套接字4可读 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 第二个客户端连接后 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ allset = {3, 4, 5}(connfd=5) │ │
│ │ client[0] = 4, client[1] = 5, client[2..] = -1 │ │
│ │ select返回条件:3个描述符中任何一个就绪 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 第一个客户端关闭连接后 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ allset = {3, 5}(client[0] = -1, client[1] = 5) │ │
│ │ 描述符4从allset中移除,client[0]设为-1,下次新连接可重用此位置 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
💡 核心思想:select服务器的核心设计是通过单进程+事件驱动的方式处理多客户端并发,避免了第五章中为每个客户fork一个子进程的开销。描述符集
allset中维护着监听套接字和所有已连接套接字,select同时监视所有这些描述符。
六、shutdown函数——半关闭
6.1 为什么需要shutdown?
close函数有两个限制,而shutdown可以避免这两个问题:
| 限制 | close | shutdown |
|---|---|---|
| 引用计数 | 将引用计数减1,仅当计数变为0时才真正关闭 | 不管引用计数,直接激发TCP的正常连接终止序列 |
| 关闭方向 | 同时关闭读和写两个方向 | 可以单独关闭读或写,实现半关闭 |
6.2 函数原型与参数
#include <sys/socket.h>
int shutdown(int sockfd, int howto);
// 返回:0表示成功,-1表示失败
howto参数的值及其含义:
| 值 | 宏定义 | 含义 |
|---|---|---|
0 |
SHUT_RD |
关闭读这一半:不再接收数据,接收缓冲区中的数据被丢弃,进程不能再对该套接字进行任何读操作 |
1 |
SHUT_WR |
关闭写这一半(半关闭):发送缓冲区中的数据被发送出去,后跟正常的TCP连接终止序列(发送FIN),进程不能再对该套接字进行任何写操作 |
2 |
SHUT_RDWR |
同时关闭读和写:等效于调用shutdown两次 |
6.3 半关闭的应用场景
半关闭(SHUT_WR)在客户端非常有用:当客户端从标准输入读到EOF(例如用户键入Ctrl+D)时,它可以关闭连接的写这一半,但仍能读取从服务器回射的数据。这正是6.6节str_cli函数中实现的功能——客户端发送完所有数据后立即通知服务器,但仍等待服务器将剩余数据回射回来,直到服务器关闭连接。
七、pselect函数——select的增强版
pselect是POSIX发明的增强版select,它与select的主要区别如下:
#include <sys/select.h>
#include <signal.h>
#include <time.h>
int pselect(int maxfdp1, fd_set *readset, fd_set *writeset,
fd_set *exceptset, const struct timespec *timeout,
const sigset_t *sigmask);
与select的区别:
-
更高精度的超时时间:使用
struct timespec而不是struct timeval,tv_nsec字段提供纳秒级精度 -
信号掩码参数:
pselect增加了第六个参数sigmask,允许在等待描述符就绪的同时指定一组信号掩码。这可以原子地解除信号阻塞,等待I/O,然后重新阻塞信号,避免了竞态条件 -
超时参数为const:与
select不同,pselect的超时参数是const的,保证不会被修改,提高了可移植性
八、select的局限性
尽管select是I/O复用的核心工具,但它存在以下限制:
| 限制 | 说明 |
|---|---|
| 描述符数量限制 | FD_SETSIZE通常为1024,监视超过1024个描述符时无法工作 |
| 描述符集复制开销 | 每次调用select都需要从用户空间向内核空间复制描述符集,当描述符数量多时开销较大 |
| 结果集遍历效率低 | 需要遍历整个描述符范围来检查哪些描述符就绪,是O(n)操作 |
| 描述符集被修改 | select返回时会修改描述符集,每次调用前都需要重新初始化 |
| 描述符数量增加时性能下降 | 随着监视的描述符数量增加,性能线性下降 |
💡 后续章节预告:这些限制在第七章之后会有更深入的讨论。
poll解决了FD_SETSIZE的限制,但仍然需要遍历整个描述符数组。在更高并发场景下,epoll(Linux特有)提供了更好的扩展性。
九、本章练习题精解
习题6.1
问题:在str_cli函数中,如果服务器提前终止,select会返回套接字的就绪状态,然后readline返回0,客户端检测到服务器终止。但如果服务器在客户端发送完所有数据后才终止,而客户端没有立即检测到,会发生什么?
答案:如果服务器在处理完所有请求后主动关闭连接,客户端下次调用readline时会立即返回0,检测到EOF。这正是半关闭后客户端需要继续读取服务器回射数据的场景。str_cli中当标准输入读到EOF时调用shutdown(sockfd, SHUT_WR),但仍保持读取能力,就是为了正确处理这种情况。
习题6.2
问题:在select服务器中,client数组的大小为FD_SETSIZE。这个设计有什么潜在问题?
答案:因为fd_set能容纳的描述符数量也是FD_SETSIZE,所以两者在容量上是匹配的。但问题在于FD_SETSIZE通常为1024,这意味着服务器最多只能同时服务1024个客户端。对于高并发场景,这是一个严重的限制。
习题6.3
问题:select返回后,为什么需要重新初始化描述符集?
答案:select返回时会修改描述符集,只保留就绪的描述符。如果不重新初始化,下次调用select时丢失了其他需要监视的描述符信息,导致无法正确处理。
十、核心知识点总结
10.1 五种I/O模型汇总表
| I/O模型 | 第一阶段(等待数据) | 第二阶段(拷贝数据) | 同步/异步 |
|---|---|---|---|
| 阻塞式I/O | 阻塞 | 阻塞 | 同步 |
| 非阻塞式I/O | 不阻塞(轮询) | 阻塞 | 同步 |
| I/O复用 | 阻塞于select | 阻塞 | 同步 |
| 信号驱动式I/O | 不阻塞 | 阻塞 | 同步 |
| 异步I/O | 不阻塞 | 不阻塞 | 异步 |
10.2 select vs poll对比表
| 对比维度 | select | poll |
|---|---|---|
| 最大描述符数 | FD_SETSIZE(通常1024) | 无限制 |
| 描述符集结构 | 三个独立的fd_set |
单一的pollfd数组 |
| 事件粒度 | 读/写/异常三种 | 多种精确事件类型(POLLIN/POLLOUT等) |
| 修改副作用 | 返回时修改描述符集 | 通过revents返回,不修改events |
| 描述符关闭 | 需要从描述符集中手动清除 | 设置fd = -1即可 |
| 计算复杂度 | O(maxfdp1) | O(nfds) |
10.3 shutdown vs close对比表
| 对比维度 | close | shutdown |
|---|---|---|
| 引用计数 | 仅当引用计数为0时才关闭 | 不管引用计数,立即触发终止序列 |
| 关闭方向 | 同时关闭读和写 | 可选择关闭读、写或两者 |
| 半关闭支持 | 不支持 | 支持(SHUT_WR实现半关闭) |
| 后续系统调用 | 不能继续使用该套接字 | 关闭读后仍可写,关闭写后仍可读 |
10.4 本章思维导图
第六章 I/O复用:select和poll函数
├── 五种I/O模型
│ ├── 阻塞式I/O(两个阶段都阻塞)
│ ├── 非阻塞式I/O(第一阶段不阻塞,轮询)
│ ├── I/O复用(阻塞于select,然后调用recvfrom)
│ ├── 信号驱动式I/O(SIGIO通知,第二阶段仍阻塞)
│ └── 异步I/O(两个阶段都不阻塞)
├── select函数
│ ├── 参数:maxfdp1, readset, writeset, exceptset, timeout
│ ├── fd_set操作宏:FD_ZERO, FD_SET, FD_CLR, FD_ISSET
│ ├── 就绪条件:读/写/异常分别的判断标准
│ └── 返回值:>0就绪数,0超时,-1出错
├── poll函数
│ ├── pollfd结构:fd, events, revents
│ ├── 事件常量:POLLIN, POLLOUT, POLLERR等
│ └── 与select的区别:无FD_SETSIZE限制,事件分离
├── shutdown函数
│ ├── SHUT_RD:关闭读
│ ├── SHUT_WR:关闭写(半关闭)
│ └── SHUT_RDWR:关闭读写
├── pselect函数
│ ├── 纳秒级精度超时
│ └── 原子信号掩码
└── 应用场景
├── str_cli(select版本)——解决第五章问题
├── select服务器——单进程并发
└── shutdown实现半关闭
十一、下一章预告
📌 下一篇:《UNIX网络编程》读书笔记(七):第七章 套接字选项
第七章将详细讲解:
- getsockopt和setsockopt函数:获取和设置套接字选项的完整API
- SO_REUSEADDR套接字选项:彻底理解TIME_WAIT状态和端口重用
- SO_KEEPALIVE套接字选项:如何检测服务器主机崩溃(第五章的延续)
- SO_LINGER套接字选项:控制
close行为,理解延迟关闭 - SO_RCVBUF和SO_SNDBUF:套接字缓冲区大小的获取与设置
- 通用套接字选项、IPv4/IPv6选项:各协议层选项的完整介绍
- fcntl函数:设置文件描述符标志,将套接字设置为非阻塞式I/O
学习目标:学完第七章后,你将能够——
- 掌握套接字选项的获取与设置方法
- 理解每个核心套接字选项的实际用途
- 知道如何使用
SO_REUSEADDR解决TIME_WAIT导致的端口占用问题 - 了解如何设置
SO_KEEPALIVE检测服务器主机崩溃 - 能够使用
fcntl将套接字设置为非阻塞模式
敬请期待!
参考资料
- W. Richard Stevens, Bill Fenner, Andrew M. Rudoff. 《UNIX网络编程 卷1:套接字联网API(第3版)》. 北京:人民邮电出版社
- UNIX网络编程(UNP) 第六章学习笔记,CSDN,https://blog.csdn.net/a348752377/article/details/103223291
- UNPv1第六章:IO复用select&poll,腾讯云开发者社区,https://cloud.tencent.cn/developer/article/1393616
- unix network programming(3rd)Vol.1 [第6~12章]《读书笔记系列》,博客园,https://www.cnblogs.com/scotth/p/4741008.html
- unix网络编程第2版(卷1)_第6章_同步_异步,博客园,https://www.cnblogs.com/irockcode/p/8125965.html
- I/O复用——单进程服务器(select版),腾讯云开发者社区,https://cloud.tencent.cn/developer/article/1381297
- 手把手实现一个select多路复用TCP服务器,CSDN,https://blog.csdn.net/m0_73823253/article/details/150781335
- Unix网络API函数(2),CSDN,https://blog.csdn.net/cwj649956781/article/details/8642419
- 网络编程释疑之:同步,异步,阻塞,非阻塞,阿里云开发者社区,https://developer.aliyun.com/article/412895
- 一文读懂五大IO模型的前世今生(select、epoll、epoll),腾讯云开发者社区,https://cloud.tencent.cn/developer/article/2435989
- Linux网络编程基石:select函数详解与应用实践,百度开发者中心,https://developer.baidu.com/article/3463501
- poll(2) - CHERI manual pages, https://man.cheribsd.org/cgi-bin/ucgi/man.cgi?poll.2
本文为个人学习笔记,仅用于知识分享。如有错误,欢迎指正。
👍🏻 点赞 + 收藏 + 分享,让更多开发者看到这篇深度解析!❤️ 如果觉得有用,请给个赞支持一下作者!
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)