网络编程4-并发服务器、阻塞与非阻塞IO、信号驱动模型、IO多路复用..
一、并发服务器
1、单循环服务器(顺序处理)
一次只能处理一个客户端连接,只有当前客户端断开连接后,才能接受新的客户端连接
2、多进程/多线程并发服务器
while(1) {
connfd = accept(listenfd);
pid = fork(); // 或 pthread_create()
if (pid == 0) {
// 子进程/线程处理通信
recv(connfd, ...);
send(connfd, ...);
close(connfd);
exit(0); // 或 pthread_exit
}
close(connfd); // 父进程关闭已交给子进程的 connfd
}
优点:
实现真正并发
客户端可长时间通信
缺点:
创建/销毁进程或线程开销大
资源占用高(内存、CPU)
存在僵尸进程问题(需
waitpid()
回收)
二、IO 模型分类(5种)
1、阻塞IO模型
- 常见阻塞IO模型:
- i--读 scanf、getchar、fgets、read、recv
- o--写 管道:读端存在,写管道 写操作阻塞>>>>内存不足,写不进去便阻塞了
- 优点:简单、方便、要等 效率不高
2、 非阻塞IO模型
1)以读为例:
- 特点:需要不停去看,资源开销大
2)实现方法
方法一:
open()
时指定int fd = open("fifo", O_RDONLY | O_NONBLOCK);
方法二:运行时用
fcntl()
修改int flag = fcntl(fd, F_GETFL, 0);
flag |= O_NONBLOCK;
fcntl(fd, F_SETFL, flag);
- 注意事项
- 适用于
read/write
等系统调用。 对recv()
可使用MSG_DONTWAIT
标志实现非阻塞
3)示例
方法一
方法二
3、信号驱动IO模型
1)使用 SIGIO
信号通知数据到达,异步但支持有限
- 有数据发个信号,然后系统调用
- 通知粒度粗:仅能告知 “有 IO 事件”,无法区分事件类型与细节
2)利用函数:fcntl 实现
3)实现步骤
// 1. 设置文件描述符支持异步通知
int flags = fcntl(fd, F_GETFL);
fcntl(fd, F_SETFL, flags | O_ASYNC);// 2. 设置信号接收者(当前进程)
fcntl(fd, F_SETOWN, getpid());// 3. 注册信号处理函数
signal(SIGIO, sig_handler);
- 不足之处
支持的文件类型有限(如 socket、tty)
不适合大量连接场景
实际应用较少
4)示例
4、异步 IO 模型
信号驱动IO和一步IO区别
核心结论 | 信号驱动 IO | 异步 IO |
---|---|---|
异步能力的完整性 | “半异步”:仅解决 “IO 就绪通知”,未解决 “数据拷贝异步” | “全异步”:从 “IO 就绪” 到 “数据拷贝完成” 全程异步 |
内核与应用的职责划分 | 内核仅通知 “就绪”,数据拷贝需应用程序主动做 | 内核包办 “就绪检测 + 数据拷贝”,应用程序仅用结果 |
工业界定位 | 早期异步 IO 的过渡方案,已被淘汰 | 现代高并发 / 高性能 IO 的标准方案 |
简单来说:信号驱动 IO 是 “让内核喊你‘饭好了’,但你得自己去盛饭”;现代异步 IO 是 “内核把‘饭盛好端到你面前’,你直接吃就行”—— 后者才是真正意义上 “无感知等待、无主动操作” 的异步 IO
5、IO多路复用模型
1)概念
用一个线程监控多个文件描述符(fd),当其中任意一个就绪时通知程序进行处理
n个客户端-->>用一个线程或进程服务器去答复
优点:避免创建大量线程/进程,节省资源,适合高并发场景(如 Web 服务器)
常见函数:select()、
poll()、
epoll()
2)函数介绍
① select
头文件: #include <sys/select.h>
函数原型:
int select(int nfds,
fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
struct timeval *timeout);功能: 实现IO多路复用
参数:
nfds //是关心的文件描述符中最大的那个文件描述符 + 1
readfds //代表 要关心 的 读操作的文件描述符的集合
writefds //代表 要关心 的 写操作的文件描述符的集合 >>> 与read类似
exceptfds //代表 要关心 的 异常的文件描述符的集合 >>> 与read类似(error--2)
timeout //超时 设置一个超时时间
//NULL 表示select是一个阻塞调用
{0,0}
:非阻塞效果
{sec, usec}
:指定超时时间最小单位写到ms
返回值:
成功:就绪的 fd 数量(>0)
超时:返回 0
失败:返回 -1
辅助宏函数:
FD_ZERO(fd_set *set); // 清空集合
FD_SET(int fd, fd_set *set); // 添加 fd 到集合
FD_CLR(int fd, fd_set *set); // 从集合中移除 fd
FD_ISSET(int fd, fd_set *set); // 判断 fd 是否在集合中
基本实现流程
文字版过程
- 建立一张表 >>>监控 目前只关心读
- fd_set readfds;一张表
- FD_ZERO(&readfds);清空表(初始化)
- 将要监控的文件描述符 添加到表中
- FD_SET(0,&readfds);//stdin
- FD_SET(fd,&readfds);//建的管道或者文件描述符
- 准备参数
- maxfds 是关心的文件描述符中最大的那个文件描述符 + 1
- int maxfds = fd + 1;
- 每次系统调用只会留下就绪的文件描述符(每次监控都会重新遍历一遍)
- fd_set backfds; //设置这个等于最初的表
- maxfds 是关心的文件描述符中最大的那个文件描述符 + 1
- 一般在循环内进行系统调用
- 具体内容如下
- 最前面建立tcp网络连接的基本步骤
- 利用select函数实现步骤
- 加上定时定次功能
优点
内核负责轮询,减少用户态频繁切换
支持跨平台(Windows/Linux 均可用
缺点
最大监听数受限:
FD_SETSIZE
默认 1024(Linux)每次调用需重置 fd_set:内核会修改集合,必须每次重新
FD_SET
用户态与内核态拷贝开销大
返回后仍需遍历所有 fd 才能知道哪个就绪
效率随 fd 数量增长下降明显
知识点
- stdin --->0
- stdout --->1
- error --->2
② poll
头文件: #include <poll.h>
函数原型: int poll(struct pollfd *fds, nfds_t nfds, int timeout);
功能: 实现IO多路复用
参数:
struct pollfd *fds :struct pollfd {
int fd; // 文件描述符
short events; // 关注的事件(输入)
short revents; // 实际发生的事件(输出)
};nfds_t nfds:表示要监控的文件描述符的数量
timeout :时间值
返回值:
成功 表示 就绪的数量 ;0 超时情况下表示 没有就绪实际
失败 -1
事件标志:
POLLIN:数据可读(等价于
select
的读)
基本实现流程
优点
无 1024 限制:只要系统允许打开足够多 fd
无需重置集合:
events
和revents
分离更清晰的事件机制
效率更高:仅遍历传入的数组,不遍历整个 fd 范围
缺点
每次调用仍需将整个
fds[]
拷贝到内核返回后仍需遍历全部元素查找就绪 fd
时间复杂度仍是 O(n),连接数多时性能下降
③ epoll
< 水平触发 >
只要缓冲区有数据就持续触发
结果展现
epoll_create
函数原型: int epoll_create(int size);
功能: 创建 epoll 实例
参数:
size
:提示内核初始分配空间大小(现已忽略)返回值: 成功 epoll 文件描述符(用于后续操作)
失败 -1
注意事项: 使用完需
close(epfd)
epoll_ctl()
函数原型: int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
功能: 控制监听列表
参数: epfd:epoll 句柄(
epoll_create
返回op :操作类型
EPOLL_CTL_ADD
EPOLL_CTL_DEL
EPOLL_CTL_MOD
fd:要监听的目标文件描述符
event:事件结构体
struct epoll_event
返回值: 成功 epoll 文件描述符(用于后续操作)
失败 -1
struct epoll_event
epoll_event
结构体:
struct epoll_event {
uint32_t events; // 监听的事件类型
epoll_data_t data; // 用户数据(共用体)
};
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
常见事件类型
事件 | 含义 |
---|---|
EPOLLIN | 可读 |
EPOLLOUT | 可写 |
EPOLLRDHUP | 对端关闭连接(TCP 半关闭) |
EPOLLERR | 错误(自动监听) |
EPOLLHUP | 挂起(自动监听) |
EPOLLET | 边沿触发模式(Edge Triggered) |
EPOLLONESHOT | 触发一次后失效,需重新注册 |
epoll_wait()
函数原型: int epoll_wait(int epfd,
struct epoll_event *events,
int maxevents,
int timeout);功能: 等待事件发生
参数: epfd:epoll 句柄(
epoll_create
返回
events
:用户提供的数组,用于接收就绪事件
maxevents
:最大接收事件数(通常 10~100
timeout
:超时(单位 ms )
-1
:永久阻塞
0
:非阻塞
>0
:等待指定毫秒
返回值: 成功 就绪事件数量(无需遍历所有 fd)
失败 -1
tcp 实现epoll并发服务器
封装添加和删除函数
完整内容
#include "head.h"int add_fd(int listenfd,int epfd) //将文件描述符添加到 epoll 监控列表
{struct epoll_event ev; //定义结构体ev.events = EPOLLIN; //表示监控可读事件(文件描述符有数据可读时触发)ev.data.fd = listenfd;if (epoll_ctl(epfd,EPOLL_CTL_ADD,listenfd,&ev))//epoll_ctl添加、修改、删除监控的文件描述符{perror("epoll_ctl add fail");return -1;}return 0;
}
int del_fd(int fd,int epfd)
{if (epoll_ctl(epfd,EPOLL_CTL_DEL,fd,NULL)){perror("epoll_ctl del fail");return -1;}return 0;}
int main(void)
{int serfd = socket(AF_INET,SOCK_STREAM,0);if (serfd < 0){perror("fail to socke");return -1;}struct sockaddr_in seraddr;seraddr.sin_family = AF_INET;seraddr.sin_port = htons(50000);seraddr.sin_addr.s_addr = inet_addr("127.0.0.1");if (bind(serfd,(const struct sockaddr *)&seraddr,sizeof(seraddr)) < 0){perror("bind fial");return -1;}if (listen(serfd,5) < 0){perror("listen fail");return -1;}struct sockaddr_in cliaddr;bzero(&cliaddr,0);socklen_t len = sizeof(cliaddr);/************正片开始******************/char buf[1024] = {0};int epfd = epoll_create(1); //创建,返回一个用于操作的文件描述符efd//括号内要求大于0的值就行add_fd(serfd,epfd); //添加serfd到epoll监控int t = 3000;struct epoll_event ret_ev[1024]; //可以容纳的个数while (1){int ret = epoll_wait(epfd,ret_ev,10,t); //等待事件发生,超时时间为3000ms//ret_ev数组用于存储发生的事件//就序最多处理 10 个事件printf("ret = %d\n",ret);if (ret < 0) //出错处理{perror("epoll fail");return -1;}if (ret > 0) //遍历每个事件{int i = 0;for (i = 0; i < ret; i++){if (ret_ev[i].data.fd == serfd) { int connfd = accept(serfd,(struct sockaddr *)&cliaddr,&len);if (connfd < 0){perror("accept fail");return -1;}printf("----client connect---\n");printf("client ip:%s\n",inet_ntoa(cliaddr.sin_addr));printf("port :%d\n",ntohs(cliaddr.sin_port));//添加到表里add_fd(connfd,epfd);}else //如果不是serfd我们就要开始收数据了{recv(ret_ev[i].data.fd,buf,sizeof(buf),0);printf("buf = %s\n",buf); if (0 == strncmp(buf,"quit",4)){del_fd(ret_ev[i].data.fd,epfd); //从epfd内删除close(ret_ev[i].data.fd);}}}}}close(serfd);return 0;
}
< 边缘触发 >
仅在状态变化时触发一次(必须配合非阻塞 IO)
两种情况:
正常数据:实际只能触发一次,但数据还在,利用循环可以打印出来,但是读完数据就没有了()---n <0
quit退出:实际只能触发一次,但数据还在,利用循环可以打印出来,读完数据就没有了,---n= 0
主要改变
注意事项:
fd 必须设置为 非阻塞
必须一次性读完所有数据(直到 read()
返回 EAGAIN
)
否则会丢失后续事件
结果展现
具体水平触发的代码区别(改动的地方)
完整代码
#include "head.h"
#include <sys/socket.h>
#include <stdio.h>
#include <errno.h>
#include <poll.h>
#include <sys/epoll.h>#include <unistd.h>
#include <fcntl.h>int add_fd(int fd, int epfd)
{struct epoll_event ev;ev.events = EPOLLIN | EPOLLET;// EPOLLET(ET)边缘触发//$$$--改1--$$$ev.data.fd = fd; //标准输入 if ( epoll_ctl(epfd,EPOLL_CTL_ADD, fd, &ev)){perror("epoll_ctl add fail");return -1;} return 0;
}int del_fd(int fd, int epfd) //删除
{//struct epoll_event ev;//ev.events = EPOLLIN;//ev.data.fd = fd; //标准输入 if ( epoll_ctl(epfd,EPOLL_CTL_DEL, fd, NULL)){perror("epoll_ctl add fail");return -1;} return 0;
}//$$$$$$$$$$--改2--$$$$$$$$$$$$$$$
void set_nonblock(int fd)
{int flags = fcntl(fd,F_GETFL);flags = flags | O_NONBLOCK;fcntl(fd,F_SETFL,flags);return;
}int main(int argc, char const *argv[])
{//step1 socket int fd = socket(AF_INET,SOCK_STREAM,0);if (fd < 0){perror("socket fail");return -1;}struct sockaddr_in seraddr;bzero(&seraddr,sizeof(seraddr));seraddr.sin_family = AF_INET;seraddr.sin_port = htons(50000);seraddr.sin_addr.s_addr = inet_addr("127.0.0.1");//step2 bind if (bind(fd,(const struct sockaddr *)&seraddr,sizeof(seraddr)) < 0){perror("connect fail");return -1;}//step3 listenif (listen(fd,5) < 0){perror("listen fail");return -1;}struct sockaddr_in cliaddr;bzero(&cliaddr,0);socklen_t len = sizeof(cliaddr);//1.准备表 int epfd = epoll_create(1);if (epfd < 0){perror("epoll_create fail");return -1;}//2.添加 fd add_fd(fd,epfd);char buf[1024] = {0};struct epoll_event ret_ev[10];while (1){int ret =epoll_wait(epfd,ret_ev,10,-1);if (ret < 0){perror("epoll fail");return -1;}if (ret > 0){int i = 0;for (i = 0; i < ret; ++i){if (ret_ev[i].data.fd == fd) //listenfd {int connfd = accept(fd,(struct sockaddr *)&cliaddr,&len);if (connfd < 0){perror("accept fail");return -1;}printf("---client connect---\n");printf("client ip:%s\n",inet_ntoa(cliaddr.sin_addr));printf("port: %d\n",ntohs(cliaddr.sin_port));//设置非阻塞set_nonblock(connfd);//添加到表中add_fd(connfd,epfd);}else //$$$$$$$$$$--改3--$$$$$$$$$$$$$$${while(1){int n = recv(ret_ev[i].data.fd,buf,1,0);printf("n = %d buf = %s\n",n,buf);if (n < 0 && errno != EAGAIN) //正常数据{ perror("recv ");del_fd(ret_ev[i].data.fd,epfd);close(ret_ev[i].data.fd);}if (n== 0 || strncmp(buf,"quit",4) == 0) //退出{del_fd(ret_ev[i].data.fd,epfd);close(ret_ev[i].data.fd);}sleep(1);}}}}} return 0;
}
3)函数对比
特性 | select | poll | epoll |
---|---|---|---|
平台兼容性 | 高(POSIX) | 高 | 仅 Linux |
最大连接数 | ~1024 | 无限制(但性能差) | 无限制 |
时间复杂度 | O(n) | O(n) | O(1) |
用户/内核拷贝 | 每次全量拷贝 | 每次全量拷贝 | 共享内存 |
是否修改输入参数 | 是(需备份) | 否(revents 分离) | 否 |
触发模式 | 仅 LT | 仅 LT | LT + ET |
遍历开销 | 高(需遍历所有 fd) | 中(遍历数组) | 低(只处理就绪) |
适用场景 | 小规模连接、跨平台 | 中小规模连接 | 大规模高并发(如 Nginx) |
4)应用建议
场景 | 推荐方案 |
---|---|
小型工具程序(<100 连接) | select (简单、跨平台) |
中等规模服务(几百连接) | poll 或 select |
高并发服务器(数千以上) | epoll (Linux) |
需跨平台(如 Windows) | select 或 libevent /libuv 封装 |
5)总结
整体使用思路:
1.准备监控表
2.添加监控的文件描述符
3.调用函数监控事件发生