Linux IO模型详解:从阻塞到多路复用

**摘要:**本文通过生动的场景假设,深入浅出地讲解Linux系统中的五种主要IO模型:阻塞IO、非阻塞IO、信号驱动IO、IO多路复用(select/poll/epoll)。文章结合代码示例和对比表格,帮助读者理解不同IO模型的工作原理、优缺点及适用场景。

一、引言:理解IO模型的重要性

在Linux系统编程中,IO(输入/输出)操作是程序与外部世界交互的基础。不同的IO模型对程序的性能、资源消耗和并发处理能力有着决定性影响。理解各种IO模型的工作原理,对于编写高效、稳定的网络服务和系统程序至关重要。

二、场景假设一:妈妈与孩子的故事

假设妈妈有一个孩子,孩子在房间里睡觉,妈妈需要及时获知孩子是否醒了,如何做?

  1. **妈妈在房间待着,和孩子一起睡:**妈妈不累,但是不能干其他的事情
  2. **时不时的看一下孩子,其他时间可以干一些其他的事情:**累,但是可以干其他的事情
  3. **妈妈在客厅干活,听孩子是否哭了:**二者互不耽误

这三种方式分别对应了三种不同的IO模型:

  • 方式1:阻塞IO - 妈妈一直等待,不能做其他事
  • 方式2:非阻塞IO - 妈妈定期检查,可以兼顾其他事但很累
  • 方式3:信号驱动IO - 孩子醒了主动通知妈妈

三、阻塞式IO(Blocking IO)

阻塞IO模式是最普遍使用的I/O模式,大部分程序使用的都是阻塞模式的I/O。

学习的读写函数在调用过程中会发生阻塞相关函数如下:

读操作中的read

读阻塞 → 需要读缓冲区中有数据可读,读阻塞解除

写操作中的write

写阻塞 → 阻塞情况比较少,主要发生在写入的缓冲区的大小小于要写入的数据量的情况下,写操作不进行任何拷贝工作,将发生阻塞,一旦缓冲区有足够的空间,内核将唤醒进程,数据从用户缓冲区拷贝到相应的发送数据缓冲区。

阻塞IO的特点

  • **同步性:**同步
  • **描述:**调用IO操作的线程会被阻塞,直到操作完成
  • **优点:**编程简单,CPU利用率高(等待时不占用CPU)
  • **缺点:**一个线程只能处理一个IO,并发能力差
  • **适用场景:**小规模IO操作,对性能要求不高

四、非阻塞IO(Non-blocking IO)

当我们设置为非阻塞模式,我们相当于告诉系统内核:"当我请求IO操作不能够马上完成,你想让我的进程进行休眠等待的时候,不要这么做。请马上返回一个错误给我。"

当一个应用程序使用了非阻塞模式的套接字,它需要使用一个循环来不停的测试是否一个文件描述符有数据可读(称作polling)。

应用程序不停的polling内核来检查是否IO操作已经就绪,这将是一个极浪费CPU资源的操作。

这种模式使用中不普遍。

4.1 通过函数自带参数设置

4.2 通过设置文件描述符的属性设置非阻塞

fcntl函数详解
cpp 复制代码
int fcntl(int fd, int cmd, ... /* arg */ );
功能:设置文件描述符属性

参数:
   fd: 文件描述符
   cmd:设置方式 - 功能选择
        F_GETFL  获取文件描述符的状态信息     第三个参数忽略
        F_SETFL  设置文件描述符的状态信息     通过第三个参数设置
        
        O_NONBLOCK  非阻塞
        O_ASYNC     异步
        O_SYNC      同步
   arg: 设置的值 (in)

返回值:
       特殊选择返回特殊值 - F_GETFL 返回的状态值(int)
       其他:成功0  失败-1,更新errno

使用示例(以标准输入0为例):
   0-原本:阻塞、读权限  修改或添加非阻塞
示例代码:设置标准输入为非阻塞模式
cpp 复制代码
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>

int main(int argc, char const *argv[])
{
    char buf[32] = {};
    
    // 读/改/写三步法设置非阻塞
    // 1. 获取文件描述符的属性
    int flag = fcntl(0, F_GETFL);
    
    // 2. 修改文件描述符的属性(添加非阻塞标志)
    flag |= O_NONBLOCK;
    
    // 3. 设置文件描述符属性
    fcntl(0, F_SETFL, flag);

    // 测试非阻塞读取
    while (1)
    {
        if (fgets(buf, sizeof(buf), stdin) == NULL)
            printf("读取失败(非阻塞模式下无数据可读)\n");
        else
            printf("读取成功:%s\n", buf);
        sleep(1);
    }

    return 0;
}
恢复阻塞模式

**注意:**如果需要恢复阻塞模式,可以按以下方式设置:

cpp 复制代码
// 清除非阻塞标志
flag &= ~O_NONBLOCK;

// 重新设置文件描述符属性
fcntl(0, F_SETFL, flag);

非阻塞IO的特点

  • **同步性:**非同步(异步轮询)
  • **描述:**调用IO操作时,如果不能立即完成操作,会立即返回,线程可以继续执行其他操作
  • **优点:**可以同时处理多路IO,提高并发能力
  • **缺点:**轮询方式耗费CPU资源,效率较低
  • **适用场景:**高并发网络服务器,减少线程阻塞时间

五、信号驱动IO(Signal-driven IO)

5.1 异步通知机制

**异步通知:**异步通知是一种非阻塞的通知机制,发送方发送通知后不需要等待接收方的响应或确认。通知发送方可以继续执行其他操作,而无需等待接收方处理通知。

5.2 工作原理

  1. 通过信号方式,当内核检测到设备数据后,会主动给应用发送信号SIGIO
  2. 应用程序收到信号后做异步处理即可
  3. 应用程序需要把自己的进程号告诉内核,并打开异步通知机制

5.3 示例代码:鼠标事件信号驱动

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <signal.h>

int fd;

// 信号处理函数
void handler(int sig)
{
    char buf[32] = {0};
    read(fd, buf, sizeof(buf));
    printf("mouse事件: %s\n", buf);
}

int main(int argc, char const *argv[])
{
    // 打开鼠标设备文件
    fd = open("/dev/input/mouse1", O_RDONLY);
    if (fd < 0)
    {
        perror("open err");
        return -1;
    }

    // 1. 设置将文件描述符和进程号提交给内核驱动
    // 一旦fd有事件响应,则内核驱动会给进程号发送一个SIGIO的信号
    fcntl(fd, F_SETOWN, getpid());

    // 2. 设置异步通知
    int flags;
    flags = fcntl(fd, F_GETFL);  // 获取原属性
    flags |= O_ASYNC;            // 给flags设置异步 O_ASYNC通知
    fcntl(fd, F_SETFL, flags);   // 修改的属性设置进去,此时fd属于异步

    // 3. signal捕捉SIGIO信号 --- SIGIO:内核通知进程有新的IO信号可用
    // 一旦内核给进程发送SIGIO信号,则执行handler
    signal(SIGIO, handler);

    // 主程序可以继续执行其他任务
    while (1)
    {
        printf("主程序正在运行...\n");
        sleep(1);
    }

    return 0;
}

5.4 信号驱动IO的特点

  • **同步性:**异步
  • **描述:**当IO操作可以进行时,内核会发送信号通知进程
  • **优点:**不需要轮询,CPU利用率高
  • **缺点:**需要底层驱动的支持,编程复杂度较高
  • **适用场景:**实时性要求高的应用,避免轮询开销

六、三种IO模型对比

对比项 阻塞IO(Blocking IO) 非阻塞IO(Non-blocking IO) 信号驱动IO(Signal-driven IO)
同步性 同步 非同步 异步
描述 调用IO操作的线程会被阻塞,直到操作完成 调用IO操作时,如果不能立即完成操作,会立即返回,线程可以继续执行其他操作 当IO操作可以进行时,内核会发送信号通知进程
特点 最常见、效率低、不耗费CPU 轮询、耗费CPU,可以处理多路IO,效率高 异步通知方式,需要底层驱动的支持
适应场景 小规模IO操作,对性能要求不高 高并发网络服务器,减少线程阻塞时间 实时性要求高的应用,避免轮询开销

七、场景假设二:妈妈与三个孩子的故事

假设妈妈有三个孩子,分别在不同的房间里睡觉,需要及时获知每个孩子是否醒了,如何做?

  • **阻塞IO?**在一个房间 - 只能照顾一个孩子
  • **非阻塞IO?**不停的每个房间查看 - 超级无敌累,但是也可以干点其他的事情
  • **信号驱动IO?**不行,因为只有一个信号,不知道是哪个孩子醒了

更好的解决方案:

  1. **不停的每个房间看:**超级无敌累,但是也可以干点其他的事情
  2. **妈妈在客厅睡觉,孩子醒了之后自己找妈妈:**既可以休息,也可以及时获取状态

这个场景引出了IO多路复用的概念,即一个进程可以同时监控多个IO事件。

八、IO多路复用:select/poll/epoll

8.1 为什么需要IO多路复用?

应用程序中同时处理多路输入输出流时面临的问题:

  1. **阻塞模式:**若采用阻塞模式,得不到预期的目的
  2. **非阻塞模式:**对多个输入进行轮询,但又太浪费CPU时间
  3. **多进程/线程:**若设置多个进程/线程分别处理一条数据通路,将产生进程/线程间的同步与通信问题,使程序变得更加复杂

**比较好的方法是使用IO多路复用技术。**其基本思想是:

  1. 先构造一张有关描述符的表(最大1024),然后调用一个函数
  2. 当这些文件描述符中的一个或多个已准备好进行IO时函数才返回
  3. 函数返回时告诉进程哪个描述符已就绪,可以进行IO操作

九、select模型

9.1 select的特点

  1. **监听数量限制:**一个进程最多只能监听1024个文件描述符
  2. **效率问题:**select被唤醒之后要重新轮询,效率相对低
  3. **内存拷贝:**select每次都会清空未发生响应的文件描述符,每次拷贝都需要从用户空间到内核空间,效率低,开销大

9.2 select编程步骤

  1. 先构造一张关于文件描述符的表
  2. 清空表 FD_ZERO
  3. 将关心的文件描述符添加到表中 FD_SET
  4. 调用select函数
  5. 判断是哪一个或者哪些文件描述符产生了事件 FD_ISSET
  6. 做对应的逻辑处理

9.3 select函数原型

cpp 复制代码
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

int select(int nfds, fd_set *readfds, fd_set *writefds,
           fd_set *exceptfds, struct timeval *timeout);

功能:实现IO的多路复用

参数:
    nfds:关注的最大的文件描述符+1
    readfds:关注的读表
    writefds:关注的写表 
    exceptfds:关注的异常表
    timeout:超时的设置
        NULL:一直阻塞,直到有文件描述符就绪或出错
        时间值为0:仅仅检测文件描述符集的状态,然后立即返回
        时间值不为0:在指定时间内,如果没有事件发生,则超时返回0,并清空设置的时间值

struct timeval {
    long tv_sec;    /* 秒 */
    long tv_usec;   /* 微秒 = 10^-6秒 */
};

返回值:
    准备好的文件描述符的个数
    -1:失败
    0:超时检测时间到并且没有文件描述符准备好

注意:
    select返回后,关注列表中只存在准备好的文件描述符

操作表函数:
void FD_CLR(int fd, fd_set *set);   // 清除集合中的fd位
void FD_SET(int fd, fd_set *set);   // 将fd放入关注列表中
int  FD_ISSET(int fd, fd_set *set); // 判断fd是否在集合中 是→1 不是→0
void FD_ZERO(fd_set *set);          // 清空关注列表

9.4 练习:同时监听键盘和鼠标输入

**需求:**输入鼠标的时候,响应鼠标事件,输入键盘的时候,响应键盘事件(两路IO)

cpp 复制代码
#include <stdio.h>
#include <sys/time.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>

int main(int argc, char const *argv[])
{
    char buf[32] = {};
    int ret;
    
    // 打开鼠标设备文件
    int fd = open("/dev/input/mouse0", O_RDONLY);
    if (fd < 0)
    {
        perror("open err");
        return -1;
    }

    // 1. 构造一张关于文件描述符的表
    fd_set rfds;
    
    while (1)
    {
        // 2. 清空表
相关推荐
tg_xianheyun1 小时前
2026BytePlus CDN加速应用场景全面解析
服务器·数据库·阿里云·云计算·cdn·全球访问优化·云直播
朝发如雪1 小时前
快速掌握Linux(4)(进程)
java·linux·服务器
啦啦啦啦啦zzzz1 小时前
共享内存的网络聊天室
linux·服务器·网络·c++·网络编程
2401_894915531 小时前
GEO 优化源码性能调优:高并发地域请求缓存与索引优化
java·服务器·网络·数据库·缓存
乐维_lwops1 小时前
网管平台是什么?网管工具如何选?企业网络运维的完整指南
运维·网络·网管平台
manongdashu1881 小时前
应对“SEO需要支付一年服务费”的捆绑消费,静态建站配套终身低成本服务器,全程无需额外运维开销。的方法。
运维·服务器·前端·seo·静态网站
Tanner_SL1 小时前
Linux笔记之vim操作
linux·vim
用户041328465132 小时前
Linux 查看端口占用的 6 种方法:ss、netstat、lsof 实战
linux
2301_780789662 小时前
WAF与Web应用安全的深度融合:从边界防护到业务风控
运维·服务器·网络·云原生·ddos