IO多路复用
IO多路复用是一种单线程或者单进程管理多个文件描述符的技术,核心就是通过系统调用监视多个IO操作的状态,当某个IO操作就绪(可读、可写或发生异常)时,通知应用程序处理。
为什么要用多路复用?
传统的单线程阻塞模型,当调用recv后,如果没数据,程序就卡死不动了,只能处理一个链接;多线程/多进程模型是来一个连接开一个线程,一旦线程太多、CPU爆炸、内存爆炸、效率极低,;而IO多路复用使用非阻塞IO写作,用户态和内核态交互极少,不用创建大量线程,不用轮询所有连接,只有真正有事件的才处理,可以高效管理大量连接,提高并发性能。
IO多路复用流程
- 应用程序创建一堆socket以及事件交给内核
- 线程阻塞等待事件(不占CPU)
- 内核监控所有socket,一旦有某个socket有数据/可写,内核调用select修改fd_set通知程序可以开始处理这个了
- 程序遍历就绪socket处理读/写/异常
流程图如下:

核心函数:
1.select函数
函数原型:
int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout);
参数:
- nfds:要监视的最大的文件描述符+1
- readfds:要监视的读文件描述符集合,如果不关心,可以传NULL
- writefds:要监视的写文件描述符集合,如果不关心,可以传NULL
- exceptfds:要监视的异常文件描述符集合,如果不关心,可以传NULL
- timeout:超时时间如果是NULL表示永久阻塞
返回值:
- 成功:返回就绪的文件描述符个数
- 失败:-1(重置错误码)
2.poll函数
函数原型:
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
参数:
- fds:一个指向pollfd结构体数组的指针,它描述了要监视的文件描述符及其事件
- nfdsfds:数组中有效描述符元素的数量
- timeout:是等待时间,单位为毫秒
返回值:
- 成功:返回结构体中revents域不为0的文件描述符个数,如果在超时前没有任何事件发生,poll()返回0
- 失败:-1(重置错误码)
并发服务器
服务器模型
服务器模型有两种:循环服务器和并发服务器
- 循环服务器:同一时刻只能响应一个客户端请求
- 并发服务器:同一时刻可以响应多个客户端的请求
TCP服务器默认是一个循环服务器,因为有两个阻塞的函数accept和recv之间互相影响;
UDP服务器默认是一个并发服务器,因为只有一个阻塞函数recvfrom。
多线程并发服务器
实现原理:主线程负责新连接(accept);每个子线程处理一个连接的读写(recv/sned)。
主线程流程如图所示:

子线程流程如图所示:

示例代码如下:
#include <stdio.h>
#include <pthread.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/ip.h>
#include <string.h>
#include <stdlib.h>
#include <arpa/inet.h>
#include <unistd.h>
#define PORT 8888
#define SERVER_IP "192.168.179.100"
#define BUFSIZE 128
//自定义结构体,封装客户端连接信息
//用于在线程之间传递
typedef struct struct_MSG {
int accpetfd;//客户端套接字描述符
struct sockaddr_in clientaddr;//客户端地址信息
}msg_t;
//创建线程处理函数
void *deal_read_write(void *arg)
{
msg_t msg = *(msg_t*)arg;
char buf[BUFSIZE] = {0};
int nbytes = 0;
printf("客户端[%s:%d]连接到了服务器\n",inet_ntoa(msg.clientaddr.sin_addr),ntohs(msg.clientaddr.sin_port));
//客户端数据接收与发送
while(1) {
nbytes = recv(msg.accpetfd,buf,BUFSIZE,0);
if(nbytes == -1) {
perror("读取客户端信息失败");
} else if(nbytes == 0) {
printf("客户端[%s:%d]断开连接\n",inet_ntoa(msg.clientaddr.sin_addr),ntohs(msg.clientaddr.sin_port));
break;
}
printf("客户端[%s:%d]发来数据:%s\n",inet_ntoa(msg.clientaddr.sin_addr),ntohs(msg.clientaddr.sin_port),buf);
//回显数据给客户端
strcat(buf,"...服务器");
if(send(msg.accpetfd,buf,BUFSIZE,0) == -1) {
perror("回显数据失败");
break;
}
}
close(msg.accpetfd);
pthread_exit(NULL);
}
int main(int argc,const char *argv[])
{
//创建套接字,配置服务器地址结构体
int sockfd = socket(AF_INET,SOCK_STREAM,0);
if(sockfd == -1) {
perror("创建套接字失败");
return -1;
}
struct sockaddr_in server_addr;
memset(&server_addr,0,sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(PORT);
server_addr.sin_addr.s_addr = inet_addr(SERVER_IP);
socklen_t serveraddr_len = sizeof(server_addr);
//绑定,监听,处理客户端的连接
if(bind(sockfd,(struct sockaddr*)&server_addr,serveraddr_len) == -1) {
perror("绑定失败");
return -1;
}
if(listen(sockfd,5) == -1) {
perror("监听失败");
return -1;
}
struct sockaddr_in client_addr;
socklen_t clientaddr_len = sizeof(client_addr);
int accpetfd = 0;
pthread_t pthread_id = 0;
msg_t msg;
int ret = 0;
//主线程接受连接并创建子线程
while(1) {
if((accpetfd = accept(sockfd,(struct sockaddr*)&client_addr,&clientaddr_len)) == -1) {
perror("接受连接失败");
return -1;
}
msg.accpetfd = accpetfd;
msg.clientaddr = client_addr;
//创建子线程
ret = pthread_create(&pthread_id,NULL,deal_read_write,&msg);
if(ret != 0) {
fprintf(stderr,"创建子线程失败,错误信息:%s\n",strerror(ret));
close(sockfd);
return -1;
}
ret = pthread_detach(pthread_id);
if(ret != 0) {
fprintf(stderr,"设置线程分离态失败,错误信息:%s\n",strerror(ret));
close(sockfd);
return -1;
}
}
close(sockfd);
return 0;
}
多进程并发服务器
实现原理:fork模型:主进程监听链接,子进程处理请求,父子进程完全独立,崩溃互不影响;预fork优化:启动时预先创建多个子进程(类似Apache),通过共享监听socket(SO_REUSEPORT)实现负载均衡。
主进程流程图如下:

子进程流程图如下:

示例代码如下:
#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/ip.h>
#include <string.h>
#include <stdlib.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <sys/wait.h>
#include <signal.h>
#define PORT 8888
#define SERVER_IP "192.168.179.100"
#define BUF_SIZE 128
//信号处理函数
void sig_func(int signum)
{
wait(NULL); //处理SIGUSR1信号,并回收子进程资源避免僵尸进程
}
int main(int argc,const char *argv[])
{
//创建套接字与服务器配置
int sockfd = socket(AF_INET,SOCK_STREAM,0);
if(sockfd == -1) {
perror("套接字创建失败");
return -1;
}
struct sockaddr_in server_addr;
memset(&server_addr,0,sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(PORT);
server_addr.sin_addr.s_addr = inet_addr(SERVER_IP);
socklen_t server_len = sizeof(server_addr);
//绑定,监听,信号注册
if(bind(sockfd,(struct sockaddr*)&server_addr,server_len) == -1) {
perror("绑定失败");
close(sockfd);
return -1;
}
if(listen(sockfd,5) == -1) {
perror("监听失败");
close(sockfd);
return -1;
}
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
int acceptfd = 0;
pid_t pid = 0;
int ret = 0;
signal(SIGUSR1,sig_func);
//主循环:接受连接创建子进程
while (1) {
acceptfd = accept(sockfd,(struct sockaddr*)&client_addr,&client_len);
if(acceptfd == -1) {
perror("接受连接失败");
close(sockfd);
}
pid = fork();
if(pid == -1) {
perror("创建子进程失败");
close(acceptfd);
break;
} else if(pid == 0) {
//子进程:处理客户端通信
char buf[BUF_SIZE] = {0};
int nbytes = 0;
printf("客户端[%s:%d]连接到服务器\n",inet_ntoa(client_addr.sin_addr),ntohs(client_addr.sin_port));
//接收数据
nbytes = recv(acceptfd,buf,BUF_SIZE,0);
if(nbytes == -1) {
perror("接受失败");
} else if(nbytes == 0) {
printf("客户端[%s:%d]断开连接\n",inet_ntoa(client_addr.sin_addr),ntohs(client_addr.sin_port));
break;
}
printf("客户端[%s:%d]发来数据:%s\n",inet_ntoa(client_addr.sin_addr),ntohs(client_addr.sin_port),buf);
strcat(buf,"--来自服务端");
//回显
if(send(acceptfd,buf,BUF_SIZE,0) == -1) {
perror("回显失败");
}
close(acceptfd);
kill(getpid(),SIGUSR1);
exit(0);
} else if(pid > 0) {
//父进程
close(acceptfd);
}
}
close(sockfd);
return 0;
}
IO多路复用并发服务器
使用select机制实现并发服务器
流程图如下:

示例代码如下:
#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/ip.h>
#include <string.h>
#include <stdlib.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <sys/time.h>
#define PORT 8888 // 服务器监听端口
#define SERVER_IP "192.168.179.100" // 服务器IP地址
#define BUF_SIZE 128 // 数据缓冲区大小
/**
* @brief select IO多路复用并发服务器主函数
*
* 该服务器使用select机制实现并发,能够同时处理多个客户端连接。
* 主要流程:
* 1. 创建监听套接字
* 2. 设置服务器地址并绑定
* 3. 开始监听
* 4. 初始化select文件描述符集合
* 5. 进入主循环,调用select监控文件描述符事件
* 6. 处理新连接和客户端数据
*
* @param argc 参数个数
* @param argv 参数数组
* @return 成功返回0,失败返回-1
*/
int main(int argc,const char *argv[])
{
//创建监听套接字
int listenfd = socket(AF_INET,SOCK_STREAM,0);
if(listenfd == -1) {
perror("创建监听套接字失败");
return -1;
}
//服务器地址配置
struct sockaddr_in server_addr;
memset(&server_addr,0,sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(PORT);
server_addr.sin_addr.s_addr = inet_addr(SERVER_IP);
socklen_t server_len = sizeof(server_addr);
//绑定
int ret = bind(listenfd,(struct sockaddr*)&server_addr,server_len);
if(ret == -1) {
perror("绑定失败");
close(listenfd);
return -1;
}
//监听
if(listen(listenfd,10) == -1) {
perror("监听失败");
close(listenfd);
return -1;
}
// ========== select机制初始化 ==========
// fd_set是select使用的文件描述符集合类型
// readfd_save_set: 保存所有需要监控的文件描述符(作为备份)
// readfd_modify_set: 每次select调用的临时集合(select会修改此集合,只保留就绪的fd)
fd_set readfd_save_set, readfd_modify_set;
FD_ZERO(&readfd_save_set); // 初始化集合,清空所有位
FD_SET(listenfd, &readfd_save_set); // 将监听套接字加入集合,监控新连接请求
int max_fd = listenfd; // 当前最大文件描述符,用于select第一个参数
char buf[BUF_SIZE] = {0}; // 数据接收缓冲区
printf("开启小型的select并发服务器\n");
// ========== 主循环:持续监控和处理事件 ==========
while(1) {
// 每次调用select前,必须重新设置readfd_modify_set
// 因为select返回时会修改该集合,只保留就绪的文件描述符
readfd_modify_set = readfd_save_set;
// 调用select监控文件描述符
// 参数:max_fd+1(监控范围), 读集合, 写集合(空), 异常集合(空), 超时时间(空=阻塞)
int fds = select(max_fd + 1, &readfd_modify_set, NULL, NULL, NULL);
if(fds == -1) {
perror("select失败");
continue;
}
// ========== 遍历所有可能的文件描述符,处理就绪事件 ==========
int event_fd = 3; // 从3开始,0/1/2是标准输入/输出/错误
struct sockaddr_in client_addr; // 客户端地址结构
socklen_t client_len = sizeof(client_addr);
// 遍历[3, max_fd]范围内的所有文件描述符
for(event_fd = 3; event_fd < max_fd + 1; event_fd++) {
// 检查当前文件描述符是否在就绪集合中
if(FD_ISSET(event_fd, &readfd_modify_set)) {
// ====== 情况1:监听套接字就绪 -> 有新客户端连接 ======
if(event_fd == listenfd) {
// 接受新连接
int acceptfd = accept(event_fd, (struct sockaddr*)&client_addr, &client_len);
if(acceptfd == -1) {
perror("accept失败");
continue;
}
// 将新连接的套接字加入监控集合
FD_SET(acceptfd, &readfd_save_set);
// 更新最大文件描述符
max_fd = max_fd > acceptfd ? max_fd : acceptfd;
printf("客户端[%s:%d]连接服务器\n",
inet_ntoa(client_addr.sin_addr),
ntohs(client_addr.sin_port));
}
// ====== 情况2:客户端套接字就绪 -> 有数据可读 ======
else {
// 处理客户端数据读写
memset(buf, 0, sizeof(buf)); // 清空缓冲区
// 接收客户端数据
int nbytes = recv(event_fd, buf, BUF_SIZE, 0);
if(nbytes == -1) {
perror("获取客户端信息失败");
continue;
}
// nbytes == 0 表示客户端主动断开连接
else if(nbytes == 0) {
printf("客户端[%s:%d]断开连接\n",
inet_ntoa(client_addr.sin_addr),
ntohs(client_addr.sin_port));
// 从监控集合中移除该文件描述符
FD_CLR(event_fd, &readfd_save_set);
// 关闭套接字
close(event_fd);
continue;
}
// 打印收到的数据
printf("客户端[%s:%d]发来的数据:%s\n",
inet_ntoa(client_addr.sin_addr),
ntohs(client_addr.sin_port), buf);
// ====== 回显数据给客户端 ======
nbytes = send(event_fd, buf, BUF_SIZE, 0);
if(nbytes == -1) {
perror("回显失败");
continue;
}
}
}
}
}
return 0;
}
pollfd结构体:
struct pollfd{
int fd;//文件描述符
short events;//等待的事件
short revents;//实际发生的事件
};
| 事件 | 常值 | 说明 |
|---|---|---|
| 读事件 | POLLIN | 普通或优先带数据可读 |
| 读事件 | POLLPRI | 高优先级数据可读 |
| 写事件 | POLLOUT | 普通或优先带数据可写 |
| 写事件 | POLLWRNORM | 普通数据可写 |
| 错误事件 | POLLERR | 发生错误 |
| 错误事件 | POLLHUP | 发生挂起 |
| 错误事件 | POLLNVAL | 描述不是打开的文件 |