一、阻塞 IO(Blocking IO)
定义 :调用 IO 函数(recv)时,如果内核缓冲区没有数据,进程直接阻塞休眠,直到数据到达才唤醒、拷贝数据、函数返回。没数据就睡觉,等内核喊醒我
- 用户层代码:
recv(fd, buf, sizeof(buf), 0); - 内核执行流程
① 检查 socket 接收缓冲区是否存在数据
② 无数据:进程状态置为 TASK_INTERRUPTIBLE(可中断睡眠) ,移出 CPU 运行队列,CPU 调度其他进程运行(不占用 CPU )
③ 数据到来:网卡触发硬件中断 → 内核把数据拷贝到内核接收缓冲区 → 唤醒进程,进程重新进入运行队列,把数据拷贝到用户空间 buf,recv 返回 - 优缺点
- 优点:代码简单,空闲时进程休眠,不浪费 CPU 资源
- 缺点:函数会卡住等待;单进程下同一时间只能等待一个 socket,无法同时处理多个连接
二、非阻塞 IO(Non-Blocking IO)
定义 :IO 函数(recv)无论缓冲区有没有数据,都立刻返回,不会休眠 ;没有数据时返回特定错误,由用户层循环不断询问内核(轮询 )。问内核有没有数据,内核说没有,程序继续跑,一会再来问
- 用户层
- 设置非阻塞:
fcntl(fd, F_SETFL, O_NONBLOCK); - 循环调用 recv 轮询,判断返回值和错误码
- 设置非阻塞:
- 内核执行流程 ① 收到 recv 调用,立刻检查接收缓冲区 ② 有数据:正常读取数据,返回读到字节数 ③ 无数据:直接返回,设置 errno = EAGAIN ( EAGAIN 不是故障,代表暂时没有数据),进程继续向下执行,不会进入睡眠
- 优缺点
- 优点:不会卡死在单个 fd 上,单进程可以轮流处理多个 socket
- 缺点:需要循环轮询;不加延时会空跑,CPU 占用飙升;大部分轮询都是无效查询,效率低
- 优化写法:循环里加
usleep()降低 CPU 占用
| 对比项 | 阻塞 IO------内核主动通知(唤醒) | 非阻塞 IO------用户程序主动反复轮询内核 |
|---|---|---|
| 无数据时进程状态 | 进入可中断睡眠 TASK_INTERRUPTIBLE(可被信号唤醒),让出 CPU | 不睡眠,继续运行,返回 EAGAIN(不等于出错! 代表数据还没就绪,重试即可) |
| recv 行为 | 卡住等待,不返回 | 立刻返回 |
| CPU 消耗 | 低,休眠不占 CPU | 高,轮询空跑占用 CPU |
| 多连接处理 | 单进程很难同时监听多个 socket | 可以循环轮询多个 fd,但麻烦低效 |
| 开启方式 | socket 默认就是阻塞模式 | fcntl 文件控制函数设置 O_NONBLOCK 非阻塞标志 |
三、IO多路复用
使用非阻塞 IO需要用户自己写 while 循环挨个问 fd,所以可以使用IO多路复用--交给内核同时监视一堆 fd,哪个 fd 数据准备好了再通知应用,不用用户不停循环查询,解决非阻塞轮询浪费 CPU 的问题。
select 函数详解
cpp
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
参数含义
nfds:要监视 fd 里最大 fd 值 + 1(内核需要知道遍历到哪里为止)readfds:读集合,关心哪些 fd 可读(新连接、收到数据)writefds:写集合,关心哪些 fd 缓冲区可写exceptfds:异常集合,关心 fd 异常事件timeout:超时时间- NULL:永久阻塞,直到有 fd 就绪
- 填 0:不阻塞,轮询
- 设置时间:等待一段时间,超时直接返回 0
返回值
- 0:超时,没有 fd 就绪
- -1:出错
select 缺点
- fd_set 是位图,默认有最大 fd 上限(一般 1024),能监视的 fd 数量有限
- 每次调用 select,都要把 fd 集合从用户区拷贝到内核;内核返回时会修改 fd_set,清空未就绪 fd → 每次循环调用前,用户必须重新设置 fd 集合
- 内核内部是轮询扫描全部 fd;返回就绪数量之后,用户程序也要遍历所有 fd,挨个判断谁就绪了(O (n))
poll 函数详解
cpp
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
fds:pollfd 结构体数组,每一项记录一个 fd + 要监听的事件(events)+ 内核返回的就绪事件(revents)nfds:数组里面有效 fd 的个数timeout:等待时间,单位毫秒;-1 永久阻塞,0 不阻塞
pollfd 结构体
cpp
struct pollfd{
int fd; // 要监视的文件描述符
short events; // 用户设置:想要监听什么事件(读POLLIN)
short revents; // 内核返回:实际发生了什么就绪事件
};
返回值
- 0:超时无事件
- -1:失败
poll 改进点
- 没有 1024 的硬性上限,理论上可以监视很多 fd
- 使用结构体数组,不会修改用户传入的监视事件 events,不用每次循环重新设置监听事件
同样缺点:内核还是轮询扫描所有 fd;返回后应用程序依旧要遍历全部 fd 找就绪 fd(O (n))。
select 与 poll 对比
| 对比项 | select | poll |
|---|---|---|
| 存储 fd 方式 | fd_set 位图 | pollfd 结构体数组 |
| 最大连接限制 | 有上限(默认 1024) | 无硬性上限 |
| 参数是否被内核修改 | 是,每次返回会改写 fd_set,每次循环要重置 | 只修改 revents,events 不变,不用重置监听事件 |
| 效率 | 内核轮询所有 fd,用户也要遍历 | 内核轮询所有 fd,用户也要遍历 |
| 超时参数 | timeval 结构体 | int,单位 ms |
易错点
- IO 多路复用只是等事件、通知就绪,不会帮你 recv 读取数据;就绪之后自己调用 recv
- 一般搭配非阻塞 IO使用:如果用阻塞 recv,有可能一次读不完缓冲区数据,recv 卡住,整个主循环卡死
- select/poll 本身是阻塞调用:在没有 fd 就绪的时候,进程休眠,不占 CPU,这是和非阻塞 while 轮询的本质区别
- 三者递进:阻塞 IO → 非阻塞轮询 → select/poll 多路复用 → epoll(epoll 是事件通知,不用全量遍历,性能更强)
四、两大服务器基础模型
1. 循环服务器(迭代服务器)
特点:同一时刻只能处理一个客户端请求,处理完一个,才接待下一个
- TCP 默认单进程阻塞写法就是循环服务器 原因:代码顺序是
accept → recv;卡在accept就不能读已有连接的数据,卡在recv就不能接收新客户端连接,两个阻塞函数互相阻塞、抢占执行流 - UDP 默认是循环,但UDP 的循环服务器特殊 :只有一个阻塞函数
recvfrom,收到哪个客户端的数据就回复谁,看起来可以交替响应多个客户端(PPT 说法:UDP 默认并发,这个是考点原话)
通俗区分:TCP 循环是串行排队;UDP 不需要维持连接,单 recvfrom 可以收到任意客户端发来报文
2. 并发服务器
特点:同一时刻,同时响应、处理多个客户端请求 三大经典实现方案:多进程 fork、多线程、IO 多路复用(select/poll/epoll)
多进程并发服务器(fork)
原理:主进程专门循环 accept 接收新连接;每 accept 拿到 connfd,调用 fork 创建子进程,子进程单独负责这条连接收发数据
流程
- 主进程:
socket→bind→listen - 循环:
accept得到新连接 →fork() - 子进程:
- close (监听 listenfd)(子进程不需要监听,继承来的 listenfd 关掉,引用计数减 1)
- recv/send 和客户端通信
- 通信结束,close (connfd)、exit 退出
- 主进程:继续循环 accept,等待下一个客户端
优点:进程之间地址空间隔离,一个子进程崩溃,不会影响主进程和其他连接,稳定性高
缺点:fork 创建进程开销大;进程数量多的时候占用内存高;进程间通信麻烦;要处理僵尸进程(wait 回收)
拓展:预 fork 优化(Apache 模型):服务器启动的时候一次性预先创建一批子进程,多个子进程共享同一个监听 socket,内核负载均衡分发新连接,减少运行时 fork 开销
相关代码
cpp
#include <stdio.h>
#include <unistd.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <wait.h>
#define PORT 8888
void handle_client(int connfd)
{
char buf[1024];
while(1)
{
int n = recv(connfd, buf, sizeof(buf)-1,0);
if(n <=0) break;
printf("收到:%s\n",buf);
send(connfd, buf, n,0);
}
close(connfd);
exit(0);
}
int main()
{
int listenfd = socket(AF_INET,SOCK_STREAM,0);
int opt=1;
setsockopt(listenfd,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt));
struct sockaddr_in serv={AF_INET,htons(PORT),htonl(INADDR_ANY)};
bind(listenfd,(struct sockaddr*)&serv,sizeof(serv));
listen(listenfd,10);
printf("多进程服务器启动\n");
while(1)
{
int connfd = accept(listenfd,NULL,NULL);
pid_t pid = fork();
if(pid > 0)
{
//父进程:继续accept,关闭connfd(父进程不用它)
close(connfd);
waitpid(-1,NULL,WNOHANG); // 非阻塞回收僵尸子进程
}
else if(pid == 0)
{
//子进程:关闭监听fd,专心处理客户端
close(listenfd);
handle_client(connfd);
}
}
close(listenfd);
return 0;
}
多线程并发服务器
原理:主线程负责 accept;每来一个新连接,创建子线程,子线程专门处理这一条连接读写
流程
- 主线程:
socket→bind→listen - while 循环:
accept拿到 connfd → pthread_create 创建子线程 - 子线程函数:
recv/send通信;结束 close (connfd,线程退出)
优点:线程创建、切换开销远小于进程;同一个进程内,线程共享地址空间,数据传递简单
缺点:线程共享资源,一个线程崩溃,整个进程直接挂,全部连接全部断开;需要加锁处理共享资源
对比:进程隔离、稳、重;线程共享、轻、风险高
相关代码
cpp
#include <stdio.h>
#include <unistd.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <pthread.h>
#define PORT 8888
void *client_thread(void *arg)
{
int connfd = *(int*)arg;
free(arg);
char buf[1024];
while(1)
{
int n = recv(connfd, buf, sizeof(buf)-1,0);
if(n <=0) break;
printf("收到:%s\n",buf);
send(connfd, buf, n,0);
}
close(connfd);
return NULL;
}
int main()
{
int listenfd = socket(AF_INET,SOCK_STREAM,0);
int opt=1;
setsockopt(listenfd,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt));
struct sockaddr_in serv={AF_INET,htons(PORT),htonl(INADDR_ANY)};
bind(listenfd,(struct sockaddr*)&serv,sizeof(serv));
listen(listenfd,10);
printf("多线程服务器启动\n");
while(1)
{
int *pconn = malloc(sizeof(int));
*pconn = accept(listenfd,NULL,NULL);
pthread_t tid;
pthread_create(&tid, NULL, client_thread, pconn);
pthread_detach(tid); // 线程结束自动回收资源
}
close(listenfd);
return 0;
}
五、IO 多路复用并发服务器
三种IO多路复用方法以及各自的特点
| 特性 | select--轮询式多路复用 | poll--轮询式多路复用 | epoll--回调通知式 |
|---|---|---|---|
| 存储结构 | 位图 fd_set | pollfd 数组 | 内核红黑树 + 就绪链表 |
| fd 上限 | 默认 1024(硬限制) | 无内置上限 | 无内置上限 |
| 每次调用传参 | 每次全部 fd 重新传入 | 每次整个数组传入 | 只等事件,fd 提前注册 |
| 就绪查找 | 遍历全部 fd | 遍历全部 fd | 只遍历就绪 fd |
| 触发模式 | 仅 LT 水平触发 | 仅 LT 水平触发 | LT 默认,支持 ET 边缘触发 |
| 跨平台 | 好 | 一般 | Linux 独有 |
| 适合场景 | 少量连接、兼容老平台 | 中小连接 | 高并发海量连接 |
select代码
cpp
// 标准输入输出头文件,printf、perror 使用
#include <stdio.h>
// UNIX标准头文件,close()、read、write等系统调用
#include <unistd.h>
// socket、bind、listen、accept 套接字相关函数和结构体
#include <sys/socket.h>
// 网络地址转换:htons、htonl、inet_ntoa,sockaddr_in
#include <arpa/inet.h>
// select、fd_set、FD_ZERO/FD_SET/FD_ISSET 多路复用
#include <sys/select.h>
// memset 内存初始化函数
#include <string.h>
// 宏定义服务器端口号
#define PORT 8888
// select的限制:默认最多监听1024个fd,这是select经典短板
#define MAX_FD 1024
int main()
{
// ====================== 1. 创建监听套接字 listenfd ======================
// socket(协议族, 套接字类型, 协议)
// 参数1 AF_INET:IPv4协议
// 参数2 SOCK_STREAM:TCP流式套接字
// 参数3 0:自动匹配TCP协议
// 返回值:成功返回文件描述符fd;失败返回-1
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
if(listenfd < 0) // 判断socket是否创建失败
{
perror("socket"); // 打印socket对应的错误原因
return -1; // 程序异常退出
}
// ====================== 设置端口复用选项 SO_REUSEADDR ======================
int opt = 1; // 打开这个选项,1代表启用
// setsockopt(fd, 协议层, 选项名, 选项值地址, 值大小)
// 参数1 listenfd:要设置的套接字
// 参数2 SOL_SOCKET:套接字通用层(不是IP层/TCP层)
// 参数3 SO_REUSEADDR:端口复用,服务器关闭后可以立刻重启绑定同一个端口,避免Address already in use
// 参数4 &opt:保存选项值的变量地址
// 参数5 sizeof(opt):opt变量所占字节大小
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// sockaddr_in:IPv4专用地址结构体
struct sockaddr_in serv_addr;
serv_addr.sin_family = AF_INET; // 地址族,固定AF_INET代表IPv4
serv_addr.sin_port = htons(PORT); // 端口号,htons:主机字节序转网络字节序(大端)
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
// INADDR_ANY = 0.0.0.0,代表监听本机所有网卡IP;htonl把主机序转网络长整型
// ====================== bind绑定:套接字 和 IP+端口绑定 ======================
// bind(fd, 通用地址指针, 地址结构体长度)
if(bind(listenfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0)
{
perror("bind");
close(listenfd); // 失败要关闭已经创建的套接字,释放资源
return -1;
}
// ====================== listen开启监听,变成被动套接字,可以接收连接 ======================
// listen(fd, 等待连接队列长度(backlog))
// backlog:半连接+全连接队列上限,这里填10
if(listen(listenfd, 10) < 0)
{
perror("listen");
close(listenfd);
return -1;
}
printf("服务器启动,端口%d,等待客户端...\n", PORT);
fd_set readfds; // fd集合:select用来监视【读事件】的集合
int fd_arr[MAX_FD]; // 自定义数组:保存所有需要被select监视的fd
// 为什么额外数组?因为select会修改fd_set,我们需要保存一份完整fd列表
memset(fd_arr, -1, sizeof(fd_arr)); // 全部初始化为-1,-1代表该位置没有有效fd
fd_arr[0] = listenfd; // 数组第0号位置放监听fd,它一直都要被监视
// 死循环:服务器持续运行,不断处理事件
while(1)
{
// select会修改readfds!每次循环必须清空集合,重新添加fd
FD_ZERO(&readfds); // FD_ZERO:清空fd集合,全部标记为不监听
// 遍历fd_arr,把里面所有有效fd,加入select监听集合readfds
for(int i = 0; i < MAX_FD; i++)
{
if(fd_arr[i] != -1) // 如果这个位置存了有效的fd
{
FD_SET(fd_arr[i], &readfds); // FD_SET:把fd加入集合,让select监视它的读事件
}
}
// ====================== select 核心多路复用函数 ======================
// select(nfds, readfds, writefds, exceptfds, timeout)
// 参数1 nfds:要监听的最大fd+1(这里直接写MAX_FD,简单写法)
// 参数2 &readfds:监视读事件的fd集合(新连接到来、客户端发数据都属于读事件)
// 参数3 NULL:不监视写事件集合
// 参数4 NULL:不监视异常事件集合
// 参数5 NULL:阻塞模式,没有事件就一直等,不超时
// 返回值ret:就绪fd的个数;-1出错;0超时
int ret = select(MAX_FD, &readfds, NULL, NULL, NULL);
if(ret < 0)
{
perror("select");
break;
}
// 遍历所有fd,挨个判断谁就绪了
for(int i = 0; i < MAX_FD; i++)
{
int fd = fd_arr[i];
if(fd == -1) continue; // 无效fd直接跳过
// FD_ISSET(fd, &readfds):判断该fd是不是就绪(内核标记有读事件)
if(FD_ISSET(fd, &readfds))
{
if(fd == listenfd)
{
// ==========分支1:监听fd就绪 = 有新客户端发起连接 ==========
struct sockaddr_in cli_addr; // 保存客户端地址信息
socklen_t cli_len = sizeof(cli_addr); // 地址结构体长度,accept需要
// accept(监听fd, 输出客户端地址, 地址长度指针)
// 返回connfd:和这个客户端通信的套接字(数据fd)
int connfd = accept(listenfd, (struct sockaddr*)&cli_addr, &cli_len);
if(connfd < 0)
{
perror("accept");
continue;
}
// inet_ntoa:网络字节序IP转字符串;ntohs:网络端口转回主机字节序
printf("新客户端接入:%s:%d, connfd=%d\n",
inet_ntoa(cli_addr.sin_addr), ntohs(cli_addr.sin_port), connfd);
// 把新拿到的connfd存入fd_arr,后续select监视这个客户端
for(int j = 0; j < MAX_FD; j++)
{
if(fd_arr[j] == -1)
{
fd_arr[j] = connfd;
break;
}
}
}
else
{
// ==========分支2:普通connfd就绪 = 客户端发来数据 ==========
char buf[1024] = {0}; // 接收缓冲区,初始清零
// recv(fd, buf, 长度, flag)
// 参数1 fd:客户端通信fd
// 参数2 buf:数据存放缓冲区
// 参数3 sizeof(buf)-1:预留一个位置放'\0',方便打印字符串
// 参数4 0:默认阻塞模式
// 返回n:读到的字节数;0=对方正常关闭;<0=出错
int n = recv(fd, buf, sizeof(buf)-1, 0);
if(n > 0)
{
printf("fd=%d收到消息:%s\n", fd, buf);
// send(fd, 数据首地址, 发送长度, flag)
send(fd, buf, n, 0); // 回显服务:原样把数据发回客户端
}
else if(n == 0)
{
// recv返回0:客户端调用close,四次挥手断开连接
printf("fd=%d 客户端断开连接\n", fd);
close(fd); // 关闭通信fd,释放资源
fd_arr[i] = -1; // 数组里面标记为无效,不再交给select监视
}
else
{
perror("recv");
close(fd);
fd_arr[i] = -1;
}
}
}
}
}
close(listenfd); // 正常退出关闭监听fd(本程序理论跑不到这里)
return 0;
}
poll代码
cpp
// 标准输入输出,printf perror
#include <stdio.h>
// close()
#include <unistd.h>
// socket bind listen accept
#include <sys/socket.h>
// htons inet_ntoa sockaddr_in
#include <arpa/inet.h>
// poll函数、struct pollfd、POLLIN等宏
#include <poll.h>
// memset
#include <string.h>
// 服务器端口
#define PORT 8888
// poll数组最大容量,自己设定,不像select是硬限制
#define MAX_POLL_FD 1024
int main()
{
// ========== 1. 创建监听套接字 ==========
// 参数1 AF_INET:IPv4
// 参数2 SOCK_STREAM:TCP流式套接字
// 参数3 0:默认TCP协议
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
if (listenfd < 0)
{
perror("socket");
return -1;
}
// ========== 设置端口复用,解决重启时地址被占用 ==========
int opt = 1;
// 参数1:要设置的fd
// 参数2 SOL_SOCKET:套接字层选项
// 参数3 SO_REUSEADDR:端口复用
// 参数4:选项值地址
// 参数5:选项值大小
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// ========== 填充IPv4地址结构体 ==========
struct sockaddr_in serv_addr;
serv_addr.sin_family = AF_INET; // IPv4地址族
serv_addr.sin_port = htons(PORT); // 端口转网络字节序(大端)
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY); // INADDR_ANY=0.0.0.0,监听本机所有网卡
// ========== bind:套接字绑定IP+端口 ==========
// 参数1 fd,参数2地址指针,参数3地址结构体长度
if (bind(listenfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0)
{
perror("bind");
close(listenfd);
return -1;
}
// ========== listen开启监听,变成被动套接字 ==========
// 参数2 backlog:全连接队列上限
if (listen(listenfd, 10) < 0)
{
perror("listen");
close(listenfd);
return -1;
}
printf("poll多路复用服务器启动,端口:%d\n", PORT);
// ========== poll核心:pollfd结构体数组 ==========
// struct pollfd {
// int fd; // 要监听的文件描述符,-1代表这个位置无效,poll自动跳过
// short events; // 用户设置:想要监听什么事件(POLLIN读事件)
// short revents; // 内核输出:实际发生的事件,由内核填充,只读
// };
struct pollfd pfd[MAX_POLL_FD];
// 全部初始化为0
memset(pfd, 0, sizeof(pfd));
// 把数组里所有fd先置为-1,表示空位无效
for (int i = 0; i < MAX_POLL_FD; i++)
{
pfd[i].fd = -1;
}
// 第一个位置放监听套接字
pfd[0].fd = listenfd;
pfd[0].events = POLLIN; // POLLIN:监听【读事件】:新连接到来就是读事件
// 服务器循环
while (1)
{
// ================= poll 函数 =================
// poll( pollfd数组首地址, 数组元素个数, 超时时间ms )
// 参数1:pollfd数组
// 参数2:数组里面有效元素数量上限
// 参数3 timeout:-1 代表永久阻塞,直到有事件发生;0非阻塞;>0等待毫秒数
// 返回值:>0 就绪fd数量;0超时;-1出错
int ret = poll(pfd, MAX_POLL_FD, -1);
if (ret < 0)
{
perror("poll");
break;
}
// 遍历poll数组,检查哪一个fd有事件
for (int i = 0; i < MAX_POLL_FD; i++)
{
// fd=-1,是空位,直接跳过
if (pfd[i].fd == -1)
continue;
// 判断revents里面有没有POLLIN读事件
// revents是内核写进去的,events是我们告诉内核要监听什么
if (pfd[i].revents & POLLIN)
{
if (pfd[i].fd == listenfd)
{
// --------分支1:监听fd就绪 → 新客户端连接 --------
struct sockaddr_in cli_addr;
socklen_t cli_len = sizeof(cli_addr);
// accept参数:监听fd,输出客户端地址,地址长度指针
int connfd = accept(listenfd, (struct sockaddr*)&cli_addr, &cli_len);
if (connfd < 0)
{
perror("accept");
continue;
}
printf("新客户端接入:%s:%d, connfd=%d\n",
inet_ntoa(cli_addr.sin_addr), ntohs(cli_addr.sin_port), connfd);
// 找数组里面一个空位,存入新的客户端fd
for (int j = 0; j < MAX_POLL_FD; j++)
{
if (pfd[j].fd == -1)
{
pfd[j].fd = connfd;
pfd[j].events = POLLIN; // 监听这个客户端的读事件(发数据过来)
break;
}
}
}
else
{
// --------分支2:客户端connfd就绪 → 收到客户端数据 --------
char buf[1024] = {0};
// recv(fd,缓冲区,长度,flag=0默认阻塞)
int n = recv(pfd[i].fd, buf, sizeof(buf) - 1, 0);
if (n > 0)
{
printf("fd=%d 收到消息:%s\n", pfd[i].fd, buf);
send(pfd[i].fd, buf, n, 0); // 回显,原样发回去
}
else if (n == 0)
{
// recv返回0:客户端正常关闭连接(四次挥手)
printf("fd=%d 客户端断开连接\n", pfd[i].fd);
close(pfd[i].fd); // 关闭套接字释放资源
pfd[i].fd = -1; // 标记为空位,poll下一轮自动忽略
}
else
{
perror("recv");
close(pfd[i].fd);
pfd[i].fd = -1;
}
}
}
}
}
close(listenfd);
return 0;
}
epoll代码
cpp
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/epoll.h>
#define DEF_VAL 0 //socket 第三个参数,TCP 默认 0
#define ERR_VAL -1 // 函数出错返回值标记
// 错误处理宏:打印错误原因,直接退出程序;do while (0) 是宏标准写法,保证语法安全
#define ERR_LOG (VAL) do {perror (VAL);exit (1);} while (0)
#define MAX_EVENTS 2 //epoll 一次最多返回就绪事件数量
#define BUF_SIZE 1024 // 缓冲区大小
#define SERV_PORT 8888 // 服务器绑定端口号
int main (int argc, const char *argv []){
// ========== 1. 创建 TCP 监听套接字 listenfd ==========
// AF_INET:IPv4;SOCK_STREAM:TCP 流式套接字;DEF_VAL=0,默认协议
int listenfd = socket (AF_INET, SOCK_STREAM, DEF_VAL);
if (listenfd == ERR_VAL){ //socket 创建失败
ERR_LOG ("socket");
}
printf ("创建监听套接字成功!fd = % d\n", listenfd);
// 设置端口复用,解决服务器重启时 TIME_WAIT 导致端口无法绑定的问题
int opt = 1;
setsockopt (listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof (opt));
// ========== 2. 填充服务器地址结构体 ==========
struct sockaddr_in serv_addr;
memset (&serv_addr, 0, sizeof (serv_addr)); // 清空结构体内存,防止脏数据
serv_addr.sin_family = AF_INET; // 指定地址族为 IPv4
serv_addr.sin_port = htons (SERV_PORT); // 端口:主机字节序转为网络大端序
serv_addr.sin_addr.s_addr = htonl (INADDR_ANY);// INADDR_ANY:监听本机所有网卡 IP
// ========== 3. bind 绑定:套接字 和 IP + 端口绑定 ==========
int ret = bind (listenfd, (struct sockaddr *)&serv_addr, sizeof (serv_addr));
if (ret == ERR_VAL){
ERR_LOG ("bind");
}
printf ("bind 成功!端口:% d\n", SERV_PORT);
// ========== 4. listen 开启监听 ==========
// 第二个参数 backlog:全连接队列最大长度,存放已经完成三次握手的连接
ret = listen (listenfd, 10);
if (ret == ERR_VAL){
ERR_LOG ("listen");
}
printf ("服务器开始监听,等待客户端连接...\n");
// ========== 5. 创建 epoll 实例 ==========
// EPOLL_CLOEXEC:进程 exec 执行新程序时,自动关闭这个 epollfd,防止文件描述符泄露
int epfd = epoll_create1 (EPOLL_CLOEXEC);
if (epfd == ERR_VAL){
ERR_LOG ("epoll_create1");
}
struct epoll_event ev1, res [MAX_EVENTS]; //ev1:用来配置事件;res:存放 epoll_wait 返回的就绪事件
// 将 listenfd 加入 epoll,监听可读事件 EPOLLIN
ev1.data.fd = listenfd; // 绑定要监听的文件描述符
ev1.events = EPOLLIN; // 监听可读事件:有新连接到来
if (epoll_ctl (epfd, EPOLL_CTL_ADD, listenfd, &ev1) == ERR_VAL){
ERR_LOG ("epoll_ctl listenfd");
}
char buf [BUF_SIZE] = {0}; // 数据缓冲区,用来收发数据
// ========== epoll 主循环,持续等待事件 ==========
while (1){
//epoll_wait:阻塞等待就绪事件
// 参数:epoll 句柄、就绪事件数组、数组最大长度、超时时间 (-1 = 永久阻塞)
int num = epoll_wait (epfd, res, MAX_EVENTS, -1);
if (num == ERR_VAL){
ERR_LOG ("epoll_wait");
}
// 遍历本次所有就绪的 fd
for (int i = 0; i < num; ++i){
// 分支 1:就绪的是 listenfd,代表有新客户端发起连接
if (res [i].data.fd == listenfd){
struct sockaddr_in cli_addr; // 存放客户端地址信息
socklen_t cli_len = sizeof (cli_addr);
//accept:从全连接队列取出已经完成三次握手的连接,返回通信 fd
int connfd = accept (listenfd, (struct sockaddr *)&cli_addr, &cli_len);
if (connfd == ERR_VAL){
perror ("accept");
continue;
}
printf ("新客户端接入:% s:% d,connfd=% d\n", inet_ntoa (cli_addr.sin_addr), ntohs (cli_addr.sin_port), connfd);
// 把新的通信套接字 connfd 加入 epoll,监听可读事件
ev1.data.fd = connfd;
ev1.events = EPOLLIN;
if (epoll_ctl (epfd, EPOLL_CTL_ADD, connfd, &ev1) == ERR_VAL){
ERR_LOG ("epoll_ctl connfd");
}
}
// 分支 2:就绪的是普通通信 connfd,客户端发来数据
else {
int fd = res [i].data.fd;
ret = read (fd, buf, sizeof (buf)); // 从内核缓冲区读取客户端发来的数据
if (ret == 0){
//read 返回 0:收到对端 FIN 报文,客户端关闭连接
printf ("客户端 fd=% d 主动退出 \n", fd);
epoll_ctl (epfd, EPOLL_CTL_DEL, fd, NULL); // 先从 epoll 移除监听
close (fd); // 关闭通信套接字
memset (buf, 0, sizeof (buf)); // 清空缓冲区
continue;
}
else if (ret == ERR_VAL){
ERR_LOG ("read");
}
// 打印收到的消息
printf ("收到客户端 fd=% d 消息:% s", fd, buf);
write (fd, buf, strlen (buf)); // 回显:把收到的数据原样发回客户端
memset (buf, 0, sizeof (buf)); // 清空缓冲区,避免下次数据残留
}
}
}
close (listenfd); // 关闭监听套接字
close (epfd); // 关闭 epoll 句柄
return 0;
}