Linux操作系统———I/O多路复用

上篇文章我们介绍了两种服务端接收多个TCP连接的实现方式:分别基于多线程和多进程实现,我们知道,进程和i西安城的创建都是有开销的,如果需要同时维护上万连接,这两种方式都是扛不住的,此时,需要使用更加节省资源,更加高效的方式:I/O多路复用

I/O多路复用是Linux中用于处理多个I/O操作的机制,使得单个线程或进程可以同时监视多个文件描述符,以处理多路I/O请求。它主要通过一下系统调用实现:select、poll和epoll

作用和意义:
节省资源:IO多路复用允许单个进程或线程同时监视多个文件描述符,而不是为每个I/O操作创建一个线程或进程。只需要维护文件描述符,极大地提高了节约了资源,减少了系统开销。应用场景
监控系统:例如日志监控、数据库连接池管理等需要同时监视多个输入源的系统。
聊天系统:实时聊天应用程序需要高效地管理多个用户的消息。
网络服务器:例如HTTP服务器、FTP服务器等需要同时处理大量客户端请求的场景。
效率高:使用IO多路复用省去了进程或线程上下文切换的开销,提升了处理效率,减少了系统资源(如内存和CPU时间)的消耗,从而提高了应用程序的整体性能和响应速度。
简化编程模型:尽管IO多路复用增加了代码的复杂性,但它简化了高并发程序的设计,使得程序员可以更容易地管理多个I/O操作,而不必处理大量的线程同步问题。

IO多路复用是Linux系统中处理多路I/O操作的重要技术,它通过提高资源利用率和系统性能,为开发高效、高并发的应用程序提供了强大的支持。

select和poll底层都是基于线性结构实现的,需要对文件描述符集执行多次遍历和拷贝,效率低下,而epoll底层是基于红黑树(一种平衡二叉树)实现的,且通过维护就绪事件链表,效率更高,本文只介绍epoll。

在 epoll 的使用中,有两种事件触发模式:边缘触发(Edge Triggered, ET)和水平触发(Level Triggered, LT)。这两种模式决定了 epoll 如何通知应用程序有事件发生。可以类比单片机的边缘触发和电平触发。

水平触发是epoll的默认模式。在这种模式下,只要文件描述符上有未处理的事件,epoll就会不断通知应用程序。

边缘触发模式下,当文件描述符从未就绪状态变为就绪状态时,epoll 会通知应用程序。如果应用程序没有在通知后及时处理事件(例如,读出所有可读的数据),epoll 不会再次通知,除非文件描述符再次从未就绪变为就绪状态。即只在状态变化时通知一次,因而叫边缘触发。

下面介绍一下几个核心函数:

一,epoll_create/epoll_create1
#include <sys/epoll.h>
int epoll_create(int size); // 已废弃(size参数被忽略)
int epoll_create1(int flags); // 推荐使用

参数:flages:通常设为0(或EPOLL_CLOEXEC使fd在exec时自动关闭)

返回值:成功:epoll实例的文件描述符(epfd)

失败:-1(设置errno)

作用:创建一个epoll实例,内核会分配数据结构用于管理监控的文件描述符

二,epoll_ctl

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

  • 参数
    epfd: epoll_create1返回的文件描述符
    op: 操作类型(三选一)
    • EPOLL_CTL_ADD: 添加新的监控项
    • EPOLL_CTL_MOD: 修改已有监控项
    • EPOLL_CTL_DEL: 删除监控项(此时event参数可忽略)
      fd: 需要监控的文件描述符(如socket)
      event: 指向epoll_event结构体的指针(见下文)
  • 返回值
    成功:0
    失败:-1(设置errno)
  • 作用
    向epoll实例注册/修改/删除需要监控的文件描述符及事件类型

struct epoll_event结构体:

typedef union epoll_data {

void *ptr;

int fd; // 常用:存储关联的文件描述符

uint32_t u32;

uint64_t u64;

} epoll_data_t;

struct epoll_event {

uint32_t events; // 监控的事件类型

epoll_data_t data; // 用户数据(通常用data.fd存储fd)

};

  • 关键事件标志
    EPOLLIN: 文件描述符可读
    EPOLLOUT: 文件描述符可写
    EPOLLET: 设置为边缘触发(Edge Triggered)模式
    EPOLLERR: 发生错误(默认监控,无需显式设置)

三,epoll_wait

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

  • 参数
    epfd: epoll实例的文件描述符
    events: 指向用户分配的数组,用于接收就绪事件
    maxevents: events数组的大小(最大返回事件数)
    timeout: 超时时间(毫秒)
    • -1: 无限等待
    • 0: 立即返回(非阻塞)
    • >0: 等待指定毫秒数
  • 返回值
    成功:就绪的事件数量(>0
    超时:0
    失败:-1(设置errno)
  • 作用
    阻塞等待 监控的文件描述符上有事件发生,将就绪事件填充到events数组

四,fcntl(用于设置非阻塞IO)

#include <fcntl.h>

int fcntl(int fd, int cmd, ... /* arg */);

// 获取当前文件状态标志

int flags = fcntl(fd, F_GETFL, 0);

// 设置非阻塞模式(必须与原有标志合并)

fcntl(fd, F_SETFL, flags | O_NONBLOCK);

  • 作用
    将文件描述符设置为非阻塞模式(对边缘触发ET模式至关重要)

------------------------epoll_test.c------------------------

#include <sys/socket.h> // 提供socket相关函数

#include <sys/types.h> // 提供基本数据类型(如socklen_t)

#include <netinet/in.h> // 提供sockaddr_in等网络地址结构

#include <stdio.h> // 标准输入输出(printf, perror等)

#include <stdlib.h> // 标准库函数(malloc, exit等)

#include <string.h> // 字符串操作(memset, strcpy等)

#include <arpa/inet.h> // 提供inet_ntoa等IP地址转换函数

#include <pthread.h> // 线程库(本例未使用,可移除)

#include <unistd.h> // POSIX API(close, read, write等)

#include <sys/epoll.h> // epoll核心头文件

#include <fcntl.h> // 文件控制(fcntl函数)

#include <errno.h> // 错误号定义(EAGAIN等)

// 定义常量

#define SEVER_PORT 6666 // 服务器监听端口

#define BUFFER_SIZE 1024 // 读写缓冲区大小

#define MAX_EVENTS 10 // epoll_wait一次最多处理的事件数

/*

* 错误处理宏:检查函数返回值

* 用法:handle_error("函数名", 返回值);

* 作用:如果返回值<0,打印错误信息并退出程序

*/

#define handle_error(cmd, result) \

if (result < 0) \

{ \

perror(cmd); \ // 打印系统错误信息

exit(EXIT_FAILURE); \ // 退出程序

}

// 全局缓冲区指针

char *read_buf = NULL; // 用于存储从客户端读取的数据

char *write_buf = NULL; // 用于存储要发送给客户端的数据

/*

* 初始化读写缓冲区

* 作用:分配内存并清零

*/

void init_buf()

{

// 为读缓冲区分配内存

read_buf = malloc(sizeof(char) * BUFFER_SIZE);

// 检查内存分配是否成功

if (!read_buf)

{

printf("服务端读缓存创建异常,断开连接\n");

perror("malloc server read_buf"); // 打印详细错误

exit(EXIT_FAILURE); // 失败则退出

}

// 为写缓冲区分配内存

write_buf = malloc(sizeof(char) * BUFFER_SIZE);

if (!write_buf)

{

printf("服务端写缓存创建异常,断开连接\n");

free(read_buf); // 释放已分配的读缓冲区

perror("malloc server write_buf");

exit(EXIT_FAILURE);

}

// 将两个缓冲区内容清零

memset(read_buf, 0, BUFFER_SIZE);

memset(write_buf, 0, BUFFER_SIZE);

}

/*

* 清空指定缓冲区

* 作用:将缓冲区内容全部置为0

* 参数:buf - 要清空的缓冲区指针

*/

void clear_buf(char *buf)

{

memset(buf, 0, BUFFER_SIZE);

}

/*

* 设置socket为非阻塞模式

* 作用:避免I/O操作阻塞线程

* 参数:sockfd - 要设置的socket文件描述符

*/

void set_nonblocking(int sockfd)

{

// 获取当前文件状态标志

int opts = fcntl(sockfd, F_GETFL);

if (opts < 0)

{

perror("fcntl(F_GETFL)"); // 获取失败

exit(EXIT_FAILURE);

}

// 添加非阻塞标志

opts |= O_NONBLOCK;

// 应用新设置

int res = fcntl(sockfd, F_SETFL, opts);

if (res < 0)

{

perror("fcntl(F_SETFL)");

exit(EXIT_FAILURE);

}

}

/*

* 主函数

*/

int main(int argc, char const *argv[])

{

// 初始化读写缓冲区

init_buf();

// 声明变量

int sockfd, client_fd, temp_result; // sockfd:监听socket, client_fd:客户端socket

struct sockaddr_in server_addr, client_addr; // 服务器和客户端地址结构

// 清零地址结构体(避免残留数据)

memset(&server_addr, 0, sizeof(server_addr));

memset(&client_addr, 0, sizeof(client_addr));

// 配置服务器地址

server_addr.sin_family = AF_INET; // IPv4协议族

server_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 绑定所有网络接口

server_addr.sin_port = htons(SEVER_PORT); // 设置端口号(转换为网络字节序)

// 创建监听socket

sockfd = socket(AF_INET, SOCK_STREAM, 0); // TCP socket

handle_error("socket", sockfd); // 检查错误

// 绑定地址到socket

temp_result = bind(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));

handle_error("bind", temp_result);

// 将socket设为监听状态,128是连接队列最大长度

temp_result = listen(sockfd, 128);

handle_error("listen", temp_result);

// 将监听socket设为非阻塞(epoll必需)

set_nonblocking(sockfd);

// 声明epoll相关变量

int epollfd, nfds; // epollfd:epoll实例描述符, nfds:就绪事件数量

struct epoll_event ev, events[MAX_EVENTS]; // ev:单个事件, events:事件数组

// 创建epoll实例

epollfd = epoll_create1(0); // 0表示默认标志

handle_error("epoll_create1", epollfd); // 注意:宏中拼写错误已修正

// 配置监听socket的事件

ev.data.fd = sockfd; // 关联文件描述符

ev.events = EPOLLIN; // 监听可读事件(新连接到来)

// 注意:监听socket不使用ET模式,避免漏处理新连接

// 将监听socket添加到epoll实例

temp_result = epoll_ctl(epollfd, EPOLL_CTL_ADD, sockfd, &ev);

handle_error("epoll_ctl", temp_result);

// 客户端地址结构长度(用于accept)

socklen_t cliaddr_len = sizeof(client_addr);

// 主事件循环

while (1)

{

// 等待事件发生,-1表示永久阻塞

nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);

handle_error("epoll_wait", nfds);

// 遍历所有就绪事件

for (int i = 0; i < nfds; i++)

{

// 情况1:监听socket就绪(有新连接)

if (events[i].data.fd == sockfd)

{

// 接受新连接

client_fd = accept(sockfd, (struct sockaddr *)&client_addr, &cliaddr_len);

handle_error("accept", client_fd);

// 将新连接设为非阻塞(ET模式必需)

set_nonblocking(client_fd);

// 打印客户端连接信息

printf("与客户端 from %s at PORT %d 文件描述符 %d 建立连接\n",

inet_ntoa(client_addr.sin_addr), // 转换IP地址为字符串

ntohs(client_addr.sin_port), // 转换端口号为本地字节序

client_fd); // 客户端socket描述符

// 配置新连接的事件

ev.data.fd = client_fd;

ev.events = EPOLLIN | EPOLLET; // 可读事件 + 边缘触发模式

// 将新连接添加到epoll

epoll_ctl(epollfd, EPOLL_CTL_ADD, client_fd, &ev);

}

// 情况2:客户端socket可读

else if (events[i].events & EPOLLIN)

{

int count = 0, send_count = 0; // 读写字节数

client_fd = events[i].data.fd; // 获取客户端描述符

/*

* ET模式关键:必须循环读取直到无数据

* 原因:ET只在状态变化时通知一次,必须一次性读完所有数据

*/

while ((count = recv(client_fd, read_buf, BUFFER_SIZE, 0)) > 0)

{

// 打印收到的数据

printf("receive message from client_fd: %d: %s\n", client_fd, read_buf);

// 清空读缓冲区(为下次读取准备)

clear_buf(read_buf);

// 准备回复数据

strcpy(write_buf, "received~\n"); // 修正拼写错误

// 发送回复

send_count = send(client_fd, write_buf, strlen(write_buf), 0);

handle_error("send", send_count);

// 清空写缓冲区

clear_buf(write_buf);

}

// 处理recv返回的不同情况

if (count == -1 && errno == EAGAIN)

{

// 非阻塞模式下无数据可读(正常情况)

printf("来自客户端client_fd: %d当前批次的数据已读取完毕,继续监听\n", client_fd);

}

else if (count == 0)

{

// 客户端关闭连接

printf("客户端client_fd: %d请求关闭连接......\n", client_fd);

// 发送关闭确认

strcpy(write_buf, "received your shutdown signal\n");

send_count = send(client_fd, write_buf, strlen(write_buf), 0);

handle_error("send", send_count);

clear_buf(write_buf);

// 从epoll中移除该描述符

printf("从epoll中移除client_fd: %d\n", client_fd);

epoll_ctl(epollfd, EPOLL_CTL_DEL, client_fd, NULL);

// 关闭连接

printf("释放client_fd: %d资源\n", client_fd);

close(client_fd); // 直接关闭即可,无需先shutdown

}

else if (count == -1)

{

// 其他错误处理

perror("recv error");

epoll_ctl(epollfd, EPOLL_CTL_DEL, client_fd, NULL);

close(client_fd);

}

}

// 注意:本例未处理可写事件(EPOLLOUT),适合小数据回复场景

}

}

/* 以下代码在正常运行中不会执行(死循环),仅作资源清理示例 */

printf("释放资源\n");

close(epollfd); // 关闭epoll实例

close(sockfd); // 关闭监听socket

free(read_buf); // 释放缓冲区

free(write_buf);

return 0;

}

核心流程详解(按执行顺序)

初始化阶段

  • 分配缓冲区init_buf() 创建1024字节的读写缓冲区
  • 创建监听socket
    • socket() 创建TCP socket
    • bind() 绑定0.0.0.0:6666
    • listen() 设置连接队列长度为128
  • 设置非阻塞set_nonblocking(sockfd) 确保监听socket非阻塞
  • 创建epoll实例epoll_create1(0) 创建监控中心
  • 注册监听socket
    • 设置事件类型:EPOLLIN(新连接到来)
    • 通过epoll_ctl(ADD) 添加到epoll实例

事件循环(核心)

  • 等待事件epoll_wait() 阻塞等待事件发生
  • 处理就绪事件
    • 新连接事件 (监听socket就绪):
      1. accept() 接受新连接
      2. set_nonblocking() 设置客户端socket为非阻塞
      3. epoll_ctl(ADD) 注册新连接,事件类型:EPOLLIN | EPOLLET
    • 数据可读事件 (客户端socket就绪):
      1. ET模式关键 :循环调用recv()直到返回EAGAIN
      2. 每收到一条消息立即回复"received~\n"
      3. 处理连接关闭:
        • 收到count=0表示客户端关闭
        • 发送确认消息
        • epoll_ctl(DEL) 从epoll移除描述符
        • close() 释放资源

当一个服务器要同时服务1000给客户端时:

传统方式:为每个客户端开1个线程 ->1000个线程 ->系统崩溃(线程创建/切换开销太大)

epoll方案:1个线程监控所有客户端 -> 只有客户端发送消息时才处理 ->资源消耗极低

步骤一:创建监控中心

int epollfd = epoll_create1(0); // 创建1个监控中心

  • 作用:向系统申请1个"监控器",后续所有socket都注册到这里

步骤二:注册监听socket

ev.data.fd = sockfd; // 告诉监控器:这是监听socket

ev.events = EPOLLIN; // 监控"有新客户端要连接"事件

epoll_ctl(epollfd, EPOLL_CTL_ADD, sockfd, &ev); // 添加到监控中心

  • 为什么:当有新客户端连服务器时,系统会通知这个监控器

步骤三:无限循环等待事件

int nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);

  • 作用 :线程卡在这里,直到有客户端发消息或新连接
  • -1 表示:永远等,直到有事发生
  • nfds = 本次有多少客户端需要处理(比如3个客户端同时发消息,nfds=3

步骤四:处理事件(两种情况)

情况A:有新客户端连接(监听socket被触发)

if (events[i].data.fd == sockfd) {

client_fd = accept(...); // 接受新连接

set_nonblocking(client_fd); // 设置非阻塞(必须!)

ev.events = EPOLLIN | EPOLLET; // 监控"收消息"事件 + 边缘触发

epoll_ctl(epollfd, EPOLL_CTL_ADD, client_fd, &ev); // 添加到监控

}

  • 关键点
    1. EPOLLET = 边缘触发(高效模式,但必须配合非阻塞)
    2. 每个新客户端都会被加入监控中心
情况B:老客户端发消息了(已连接的socket被触发)

else if (events[i].events & EPOLLIN) {
while ( (count = recv(...)) > 0 ) { // 循环收数据
printf("收到: %s", read_buf);
send(client_fd, "received~", ...); // 立刻回复
}
if (count == 0) { // 客户端断开
epoll_ctl(epollfd, EPOLL_CTL_DEL, client_fd, NULL); // 从监控移除
close(client_fd); // 关闭连接
}
}

  • 关键点
    1. 必须用 while 循环收数据(边缘触发要求)
    2. 收到 count=0 表示客户端主动断开

步骤五:资源清理

close(epollfd); // 关闭监控中心

close(sockfd); // 关闭监听socket

free(read_buf); // 释放内存

  • 注意:正常运行时不会执行到这里(死循环),仅用于程序退出时清理
相关推荐
vortex52 小时前
Linux 命令行入门:命令的构成与选项用法
linux·运维·服务器
m0_474606782 小时前
Linux安装docker教程
linux·运维·docker
落霞的思绪2 小时前
Mybatis读取PostGIS生成矢量瓦片实现大数据量图层的“快显”
linux·运维·mybatis·gis
像风一样的男人@3 小时前
linux --防火墙
linux·运维·服务器
网硕互联的小客服3 小时前
Centos系统如何更改root账户用户名?需要注意什么?
linux·运维·服务器·数据库·安全
lisanmengmeng3 小时前
zentao的prod环境升级(一)
linux·运维·数据库·docker·容器·禅道
wunianor3 小时前
[高并发服务器]DEBUG日志
linux·运维·服务器·c++
nbsaas-boot4 小时前
SQL Server 存储过程设计规范(事务与异常处理)
linux·数据库·设计规范
Jason_zhao_MR4 小时前
米尔RK3506核心板SDK重磅升级,解锁三核A7实时控制新架构
linux·嵌入式硬件·物联网·架构·嵌入式·嵌入式实时数据库