前面我们已经掌握了Linux网络编程的核心基础:TCP/UDP Socket编程、线程池(半同步半异步模型)、MySQL数据库操作。在实际开发中,我们会遇到一个核心问题:一个服务器需要同时处理多个客户端连接(比如TCP服务器同时接收多个客户端的请求),如果用"一个客户端一个线程"的方式,高并发场景下会创建大量线程,导致系统资源耗尽、内核调度压力剧增。
为了解决这个问题,Linux提供了I/O复用技术------通过一个函数监听多个文件描述符(fd),当某个fd就绪(有数据可读、可写或出错)时,函数返回通知进程处理。本节课我们将学习最基础、最常用的两个I/O复用函数:select和poll,理解I/O复用的核心思想,掌握其基本用法,为后续学习高性能的epoll函数打下基础。
核心重点:掌握I/O复用的核心概念、select/poll函数的参数与用法、实操代码(实现多客户端连接监听),理解I/O复用与多线程的区别,明确其适用场景。
一、I/O复用核心认知
1. 什么是I/O复用
I/O复用(I/O Multiplexing),简单来说,就是一个进程/线程可以同时监听多个文件描述符(fd),当其中任意一个或多个fd就绪(满足可读、可写、出错条件)时,通知进程/线程去处理对应的fd。
这里的"文件描述符",不仅包括Socket的文件描述符(lfd监听fd、cfd客户端fd),还包括普通文件、管道、信号等,但其核心应用场景是网络编程(处理多客户端连接)。
2. 为什么需要I/O复用(解决什么痛点)
在学习I/O复用之前,我们处理多客户端连接的方式有两种,均存在明显缺陷:
-
方式1:单线程同步处理------主线程accept一个客户端后,阻塞在read()/write(),无法处理其他客户端,并发为1,完全无法满足实际需求;
-
方式2:多线程/多进程处理------一个客户端对应一个线程/进程,高并发(如千级、万级客户端)时,会创建大量线程/进程,占用大量内存(每个线程默认占用1MB栈空间),且内核调度线程的开销极大,容易导致服务器崩溃。
I/O复用的优势的是:用一个线程监听多个fd,无需创建大量线程,低资源消耗,支持高并发。其核心思想是"等待"------让内核帮忙监听多个fd,当有fd就绪时再去处理,避免进程/线程无效阻塞。
3. I/O复用的适用场景
结合Linux网络编程,I/O复用主要用于以下场景:
-
服务器需要同时处理多个客户端连接(如TCP服务器、HTTP服务器);
-
服务器需要同时处理多个I/O操作(如同时监听多个端口、同时处理读写请求);
-
高并发、低资源消耗的场景(替代多线程,减少内核调度压力)。
注意:I/O复用本身是"同步I/O"------虽然能监听多个fd,但当fd就绪后,仍需要进程/线程主动去读取/写入数据,不会像异步I/O那样自动完成数据读写。
4. 常用的I/O复用函数
Linux提供了3种常用的I/O复用函数,本节课学习前两种基础函数,下一节重点学习高性能的epoll:
-
select:最古老的I/O复用函数,兼容性好(跨平台),但存在fd数量限制;
-
poll:对select的改进,解决了fd数量限制,接口更简洁,但效率仍一般;
-
epoll:Linux特有,高性能、无fd数量限制,是高并发网络编程的首选(如Nginx、Redis底层均使用epoll)。
二、select函数详解(基础重点)
1. 函数原型与参数
cpp
#include <sys/select.h>
// 返回值:就绪的fd数量;-1表示出错;0表示超时
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
逐个解析参数(重点,务必理解):
(1)nfds:最大文件描述符+1
select需要知道要监听的fd的范围,nfds是所有监听fd中的最大值加1。例如:监听的fd为3、5、7,最大fd是7,那么nfds=8。
原因:select内部通过数组管理fd,数组下标从0到nfds-1,因此最大fd+1才能覆盖所有监听的fd。
(2)readfds:监听"可读"事件的fd集合
fd_set是一个"文件描述符集合",本质是一个位图(bit数组),每一位对应一个fd,1表示监听该fd,0表示不监听。
作用:告诉select,我们要监听哪些fd的"可读"事件(如客户端发送数据、新客户端连接(lfd可读)、fd出错)。
注意:select调用后,readfds会被内核修改------只保留"就绪的可读fd",未就绪的fd会被置为0,因此每次调用select前,都需要重新初始化readfds。
(3)writefds:监听"可写"事件的fd集合
与readfds类似,用于监听哪些fd的"可写"事件(如fd缓冲区空闲,可以写入数据)。
如果不需要监听可写事件,可设为NULL。
(4)exceptfds:监听"异常"事件的fd集合
用于监听fd的异常事件(如fd出错、连接异常),一般用不到,设为NULL即可。
(5)timeout:超时时间
struct timeval结构体定义:
cpp
struct timeval {
long tv_sec; // 秒
long tv_usec; // 微秒(1秒=1000000微秒)
};
timeout有三种取值方式:
-
timeout = NULL:无限等待,直到有fd就绪才返回;
-
timeout->tv_sec=0且tv_usec=0:不等待,立即返回(轮询);
-
timeout->tv_sec和tv_usec设为具体值:等待指定时间,若期间有fd就绪则立即返回,超时则返回0。
2. fd_set相关操作宏(必用)
fd_set是位图,无法直接操作,Linux提供了4个宏来操作fd集合:
bash
// 1. 清空fd集合(将所有位设为0)
FD_ZERO(fd_set *set);
// 2. 将指定fd加入集合(将对应位设为1)
FD_SET(int fd, fd_set *set);
// 3. 将指定fd从集合中移除(将对应位设为0)
FD_CLR(int fd, fd_set *set);
// 4. 检查fd是否在集合中(判断对应位是否为1,返回非0表示在,0表示不在)
FD_ISSET(int fd, fd_set *set);
3. select函数工作流程(结合TCP服务器)
以TCP服务器监听多个客户端连接为例,select的工作流程如下:
-
创建监听fd(lfd),绑定端口、开始监听;
-
初始化readfds集合,将lfd加入集合(监听lfd的可读事件,即新客户端连接);
-
设置超时时间timeout;
-
调用select函数,阻塞等待fd就绪;
-
select返回后,遍历所有监听的fd,用FD_ISSET判断哪个fd就绪;
-
若lfd就绪:调用accept()接收新客户端连接(cfd),将cfd加入readfds集合,继续监听;
-
若cfd就绪:调用read()读取客户端数据,处理后回复客户端;
-
重复步骤4-6,循环监听。
4. 实操代码:select实现多客户端监听
编写一个简单的TCP服务器,用select监听lfd和多个cfd,实现多客户端同时连接、发送数据的功能,代码有详细注释,可直接编译运行。
cpp
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <sys/select.h>
#define PORT 8888
#define MAX_FD 1024 // select默认最大fd限制(可修改内核参数)
#define BUF_SIZE 1024
int main() {
// 1. 创建监听fd
int lfd = socket(AF_INET, SOCK_STREAM, 0);
if (lfd == -1) {
perror("socket create failed");
exit(1);
}
// 端口复用(避免TIME_WAIT导致端口占用)
int opt = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 2. 绑定端口和IP
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(PORT);
serv_addr.sin_addr.s_addr = INADDR_ANY;
if (bind(lfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1) {
perror("bind failed");
close(lfd);
exit(1);
}
// 3. 开始监听
if (listen(lfd, 5) == -1) {
perror("listen failed");
close(lfd);
exit(1);
}
printf("TCP服务器启动,端口%d,等待客户端连接...\n", PORT);
// 4. 初始化select相关参数
fd_set readfds; // 监听可读事件的fd集合
int max_fd = lfd; // 最大fd,初始为lfd
// 循环监听
while (1) {
// 每次调用select前,重新初始化readfds(因为select会修改它)
FD_ZERO(&readfds);
FD_SET(lfd, &readfds); // 将lfd加入监听集合
// 遍历所有可能的fd,将已连接的cfd加入监听集合
for (int i = 0; i < MAX_FD; i++) {
if (i != lfd && i != 0) { // 排除lfd和标准输入(0是stdin)
FD_SET(i, &readfds);
}
}
// 设置超时时间(5秒,5秒内无fd就绪则返回0)
struct timeval timeout;
timeout.tv_sec = 5;
timeout.tv_usec = 0;
// 5. 调用select,监听fd就绪
int ret = select(max_fd + 1, &readfds, NULL, NULL, &timeout);
if (ret == -1) {
perror("select failed");
continue;
} else if (ret == 0) {
// 超时,无fd就绪,继续循环监听
printf("select超时,继续等待...\n");
continue;
}
// 6. 遍历所有fd,判断哪个fd就绪
for (int i = 0; i <= max_fd; i++) {
// 判断当前fd是否在就绪集合中
if (FD_ISSET(i, &readfds)) {
// 情况1:lfd就绪(有新客户端连接)
if (i == lfd) {
struct sockaddr_in clnt_addr;
socklen_t clnt_len = sizeof(clnt_addr);
int cfd = accept(lfd, (struct sockaddr*)&clnt_addr, &clnt_len);
if (cfd == -1) {
perror("accept failed");
continue;
}
printf("新客户端连接:fd=%d,IP=%s,端口=%d\n",
cfd, inet_ntoa(clnt_addr.sin_addr), ntohs(clnt_addr.sin_port));
// 更新最大fd(如果cfd大于当前max_fd)
if (cfd > max_fd) {
max_fd = cfd;
}
}
// 情况2:cfd就绪(客户端发送数据)
else {
char buf[BUF_SIZE] = {0};
ssize_t read_len = read(i, buf, BUF_SIZE - 1);
if (read_len == -1) {
perror("read failed");
close(i);
FD_CLR(i, &readfds); // 将fd从集合中移除
continue;
} else if (read_len == 0) {
// 客户端主动关闭连接
printf("客户端fd=%d 主动关闭连接\n", i);
close(i);
FD_CLR(i, &readfds); // 将fd从集合中移除
continue;
}
// 处理客户端数据
printf("收到客户端fd=%d 消息:%s\n", i, buf);
// 回复客户端
write(i, buf, read_len);
}
}
}
}
// 关闭监听fd(实际不会执行到这里)
close(lfd);
return 0;
}
5. 编译运行与测试
cpp
# 编译代码
gcc select_server.c -o select_server
# 启动服务器
./select_server
# 打开多个终端,启动客户端(每个终端一个客户端)
telnet 127.0.0.1 8888
# 或用nc命令
nc 127.0.0.1 8888
测试效果:多个客户端可同时连接服务器,发送数据后,服务器会回复相同的消息,实现多客户端并发处理。
6. select的缺点(必记)
select虽然简单易用、兼容性好,但存在明显缺点,这也是后续学习poll和epoll的原因:
-
fd数量限制:默认最大fd数为1024(可通过修改内核参数调整,但有限制),无法支持万级以上高并发;
-
效率低:每次调用select都需要遍历所有监听的fd,fd数量越多,效率越低;
-
需要重复初始化fd集合:select会修改fd集合,每次调用前都要重新初始化,增加开发复杂度;
-
内核与用户空间拷贝开销:每次调用select,fd集合都会从用户空间拷贝到内核空间,fd越多,拷贝开销越大。
三、poll函数详解(select的改进版)
1. 函数原型与参数
poll函数解决了select的"fd数量限制"问题,接口更简洁,无需维护最大fd值,其原型如下:
cpp
#include <poll.h>
// 返回值:就绪的fd数量;-1表示出错;0表示超时
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
(1)fds:struct pollfd数组
struct pollfd结构体用于描述一个要监听的fd及其监听的事件,定义如下:
cpp
struct pollfd {
int fd; // 要监听的文件描述符
short events; // 要监听的事件(输入参数)
short revents; // 实际发生的事件(输出参数,由内核修改)
};
核心事件(常用):
-
POLLIN:监听"可读"事件(与select的readfds功能一致);
-
POLLOUT:监听"可写"事件(与select的writefds功能一致);
-
POLLERR:监听"异常"事件(与select的exceptfds功能一致)。
注意:events是"要监听的事件"(我们告诉内核要监听什么),revents是"实际发生的事件"(内核告诉我们发生了什么),调用poll后,内核会修改revents的值。
(2)nfds:pollfd数组的长度
即要监听的fd的数量,无需像select那样计算"最大fd+1",直接传入数组长度即可。
(3)timeout:超时时间(单位:毫秒)
与select的timeout不同,poll的timeout单位是毫秒,取值方式:
-
timeout = -1:无限等待,直到有fd就绪;
-
timeout = 0:不等待,立即返回;
-
timeout > 0:等待指定毫秒数,超时返回0。
2. poll函数工作流程(与select对比)
poll的工作流程与select基本一致,核心区别在于"fd的管理方式":
-
创建监听fd(lfd),绑定、监听;
-
初始化pollfd数组,将lfd加入数组,设置events为POLLIN;
-
调用poll函数,阻塞等待fd就绪;
-
poll返回后,遍历pollfd数组,判断revents是否包含POLLIN(可读);
-
若lfd就绪:accept新客户端,将cfd加入pollfd数组;
-
若cfd就绪:read数据、处理、回复;
-
重复步骤3-6。
3. poll的优势与缺点
(1)优势(对比select)
-
无fd数量限制:pollfd数组的长度由用户自己定义,理论上无限制(仅受系统内存限制);
-
无需重复初始化fd集合:poll不会修改fd和events,仅修改revents,无需每次调用前重新初始化;
-
接口更简洁:无需维护最大fd值,直接传入数组长度即可。
(2)缺点(仍未解决的问题)
-
效率低:每次调用poll后,仍需要遍历所有pollfd元素,判断revents,fd数量越多,效率越低;
-
内核与用户空间拷贝开销:pollfd数组仍需要从用户空间拷贝到内核空间,拷贝开销随fd数量增加而增大;
-
无法直接定位就绪的fd:必须遍历整个数组,才能找到就绪的fd。
四、select与poll对比总结(表格清晰记忆)
| 对比维度 | select函数 | poll函数 |
|---|---|---|
| fd数量限制 | 有(默认1024,可修改) | 无(仅受系统内存限制) |
| fd管理方式 | fd_set位图,需用宏操作 | pollfd数组,结构清晰 |
| 初始化开销 | 每次调用需重新初始化fd集合 | 无需重新初始化,仅修改revents |
| 效率 | 低,遍历所有fd | 低,遍历所有pollfd元素 |
| 拷贝开销 | fd集合拷贝,开销随fd增加而增大 | pollfd数组拷贝,开销随fd增加而增大 |
| 兼容性 | 跨平台(Windows、Linux、Unix) | 仅Linux、Unix,不支持Windows |
| 适用场景 | 低并发、跨平台场景 | 低并发、fd数量较多的场景 |
五、学习小结
-
I/O复用的核心思想:用一个进程/线程监听多个fd,由内核等待fd就绪,避免创建大量线程,降低资源消耗,支持高并发。
-
select是最基础的I/O复用函数,兼容性好,但有fd数量限制、效率低、需重复初始化fd集合,适合低并发、跨平台场景。
-
poll是select的改进版,解决了fd数量限制,接口更简洁,无需重复初始化fd集合,但仍存在效率低、拷贝开销大的问题。
-
两者的共同缺点:遍历所有fd/pollfd元素才能找到就绪fd,效率随fd数量增加而下降,无法满足万级以上高并发需求------这也是下一节我们学习epoll的核心原因。
-
实操重点:掌握select函数的参数、fd_set操作宏,能编写多客户端监听的TCP服务器,理解select的工作流程和缺点,为后续epoll学习打下基础。
下一节,我们将学习Linux高性能I/O复用函数------epoll,它解决了select和poll的所有缺点,是高并发网络编程(如Nginx、Redis)的核心,也是Linux网络编程的重点难点。