【网络】8.五种 I/O 模型与多路转接详解

目录

[一、五种 I/O 模型](#一、五种 I/O 模型)

[1. I/O 为什么慢?](#1. I/O 为什么慢?)

[2. 五种 I/O 模型](#2. 五种 I/O 模型)

[3. 模型比较](#3. 模型比较)

[4. 各模型特点](#4. 各模型特点)

[二、非阻塞 I/O](#二、非阻塞 I/O)

[1. 设置文件非阻塞属性](#1. 设置文件非阻塞属性)

[2. 非阻塞读取标准输入](#2. 非阻塞读取标准输入)

[三、多路转接 select 接口](#三、多路转接 select 接口)

[1. select 函数原型](#1. select 函数原型)

[2. fd_set 结构体](#2. fd_set 结构体)

[3. fd_set 操作接口](#3. fd_set 操作接口)

[4. select 服务器实现](#4. select 服务器实现)

成员变量与构造

主循环

事件分发

处理新连接

处理消息

[5. select 的缺点](#5. select 的缺点)

[四、多路转接 poll 接口](#四、多路转接 poll 接口)

[1. poll 函数原型](#1. poll 函数原型)

[2. pollfd 结构体](#2. pollfd 结构体)

[3. poll 服务器实现(基于 select 修改)](#3. poll 服务器实现(基于 select 修改))

成员变量与构造

主循环

事件分发

处理新连接

处理消息

[4. poll 与 select 对比](#4. poll 与 select 对比)

[五、epoll 简介](#五、epoll 简介)

[1. epoll 核心接口](#1. epoll 核心接口)

[2. epoll 原理](#2. epoll 原理)

[3. epoll 效率分析](#3. epoll 效率分析)


一、五种 I/O 模型

1. I/O 为什么慢?

网络 I/O 的本质是操作网卡等硬件设备。当进程读取数据时,数据并不是一直就绪的:

  • 没有数据时,进程需要阻塞等待

  • 数据到达后,进行数据拷贝

类比钓鱼

  • 等待鱼上钩的时间很长

  • 拉杆子(拷贝数据)的时间很短

因此,I/O = 等待 + 拷贝,主要耗时在等待阶段。

2. 五种 I/O 模型

I/O 模型 特点
阻塞 I/O 没有数据时就一直等待
非阻塞 I/O 没有数据时立即返回,可以干别的事
信号驱动 I/O 用信号通知进程数据就绪
I/O 多路复用 同时等待多个文件描述符
异步 I/O 让操作系统帮忙完成整个 I/O 过程

3. 模型比较

阻塞 vs 非阻塞

  • 阻塞 I/O:检测到数据未就绪,进程被挂起直到就绪

  • 非阻塞 I/O:检测到数据未就绪,立即返回错误(EAGAINEWOULDBLOCK

非阻塞效率为什么高?

  • 不是因为能更快地处理同一个任务

  • 而是因为可以同时处理不同种类的任务(在等待期间干别的事)

谁效率最高?

  • 多路复用效率最高,因为它在单位时间内等待的占比最小

同步 vs 异步

  • 同步 I/O:只要进程参与了 I/O 过程(无论是等待还是拷贝),就是同步 I/O

    • 阻塞 I/O、非阻塞 I/O、信号驱动 I/O、多路复用都是同步 I/O
  • 异步 I/O:进程只发起 I/O 请求,后续工作完全由内核完成,完成后通知进程

4. 各模型特点

  • 非阻塞 I/O:使用最广泛

  • 信号驱动 I/O:信号由针脚连接 CPU,如果信号来得太快,可能会丢失,因此不常用

  • 异步 I/Oaio_read 等系统调用,让内核帮助等待,数据准备好后直接拷贝到用户缓冲区,然后通知进程

二、非阻塞 I/O

1. 设置文件非阻塞属性

使用 fcntl 系统调用修改文件描述符的属性:

cpp 复制代码
void setnoblock(int fd) {
    int fl = fcntl(fd, F_GETFL);
    if (fl < 0) {
        perror("fcntl error");
        exit(1);
    }
    fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}

2. 非阻塞读取标准输入

cpp 复制代码
setnoblock(0);  // 设置标准输入为非阻塞

char buff[1024];
while (1) {
    int n = read(0, buff, sizeof(buff));
    
    if (n > 0) {
        buff[n - 1] = 0;  // 去掉换行符
        std::cout << buff << std::endl;
    } else if (n == 0) {
        break;  // EOF
    } else {
        // 数据未就绪不是错误,但 read 返回 -1
        if (errno == EAGAIN || errno == EWOULDBLOCK) {
            std::cout << "数据空" << std::endl;
            sleep(1);
            continue;
        } else {
            break;  // 其他错误
        }
    }
}

关键点

  • 数据未准备好时,read 返回 -1,错误码为 EAGAINEWOULDBLOCK

  • 在 C/C++ 标准库中,回车键(\n)通常被过滤掉

  • read 等系统调用会读取换行符,需要在 n-1 位置放 \0

三、多路转接 select 接口

1. select 函数原型

cpp 复制代码
int select(int nfds, fd_set *readfds, fd_set *writefds,
           fd_set *exceptfds, struct timeval *timeout);

参数说明

参数 说明
nfds 最大文件描述符值 + 1
readfds 可读文件描述符集合(输入输出参数)
writefds 可写文件描述符集合
exceptfds 异常文件描述符集合
timeout 超时时间(输入输出参数)

timeout 结构体

cpp 复制代码
struct timeval {
    long tv_sec;   // 秒
    long tv_usec;  // 微秒
};
  • 输入:设置超时时间

  • 输出:超时后变为 {0, 0};否则返回剩余时间

返回值

  • 大于 0:就绪的文件描述符个数

  • 等于 0:超时

  • 小于 0:出错

2. fd_set 结构体

fd_set 是一个位图,每一位代表一个文件描述符是否被监视。

复制代码
std::cout << 8 * sizeof(fd_set) << std::endl;  // 通常输出 1024

限制fd_set 最大只能表示 1024 个文件描述符(可调整,但一般不推荐)。

select 的特点

  • 是一个较老的技术,但兼容性好

  • 在一些老系统上,新的多路转接接口可能没有,但 select 一定有

3. fd_set 操作接口

作用
FD_CLR(fd, set) 从集合中移除 fd
FD_ISSET(fd, set) 判断 fd 是否在集合中
FD_SET(fd, set) 将 fd 加入集合
FD_ZERO(set) 清空集合

4. select 服务器实现

成员变量与构造
cpp 复制代码
const static int defaultsize = sizeof(fd_set) * 8;  // 1024
const int defaultid = -1;

std::unique_ptr<mysocket> _listensocket;
int _fdarray[defaultsize];  // 存储需要监视的文件描述符
bool _isrunning;

pollserver(int port)
    : _listensocket(std::make_unique<tcpsocket>())
    , _isrunning(1) {
    _listensocket->buildtcpsocket(port);
    
    // 初始化数组
    for (int i = 0; i < defaultsize; i++) {
        _fdarray[i] = defaultid;
    }
    _fdarray[0] = _listensocket->fd();  // 监听套接字放在第一个位置
}

为什么需要数组

  • fd_set 每次调用 select 后都会被修改(只保留就绪的 fd)

  • 需要一个数组保存所有需要监视的 fd,每次循环开始前重新构建 fd_set

主循环
cpp 复制代码
void start() {
    _isrunning = 1;
    while (_isrunning) {
        fd_set fs;
        FD_ZERO(&fs);
        int maxfd = -1;
        
        // 构建 fd_set
        for (int i = 0; i < defaultsize; i++) {
            if (_fdarray[i] != defaultid) {
                FD_SET(_fdarray[i], &fs);
                maxfd = std::max(maxfd, _fdarray[i]);
            }
        }
        
        print();
        // struct timeval to = {1, 0};  // 可选超时
        int n = select(maxfd + 1, &fs, nullptr, nullptr, nullptr);
        
        switch (n) {
            case -1:
                LOG(LogLevel::ERROR) << "select失败";
                break;
            case 0:
                LOG(LogLevel::WARNING) << "select超时";
                break;
            default:
                LOG(LogLevel::INFO) << "有事件就绪";
                Dispatcher(fs);
                break;
        }
    }
    _isrunning = 0;
}
事件分发
cpp 复制代码
void Dispatcher(fd_set& fs) {
    for (int i = 0; i < defaultsize; i++) {
        if (_fdarray[i] == defaultid) continue;
        
        if (FD_ISSET(_fdarray[i], &fs)) {
            if (_fdarray[i] == _listensocket->fd()) {
                Accept();      // 新连接
            } else {
                Recever(_fdarray[i], i);  // 已有连接的消息
            }
        }
    }
}

区分新连接和已有连接

  • 监听套接字收到新连接时,内核中其状态变化,select 会将其标记为可读

  • 已有连接的套接字收到数据时,也会被标记为可读

  • 通过比较 fd 是否为监听套接字的 fd 来区分

处理新连接
cpp 复制代码
void Accept() {
    addr ad;
    int fd = _listensocket->accept(&ad);
    if (fd >= 0) {
        LOG(LogLevel::INFO) << "收到新连接" << fd;
        
        int pos = 0;
        for (int i = 0; i < defaultsize; i++) {
            if (_fdarray[i] == defaultid) {
                pos = i;
                break;
            }
        }
        
        if (pos == 0) {
            LOG(LogLevel::WARNING) << "服务器满了,丢弃fd" << fd;
            close(fd);
        } else {
            _fdarray[pos] = fd;
        }
    }
}
处理消息
cpp 复制代码
void Recever(int fd, int pos) {
    char buffer[1024];
    ssize_t n = recv(fd, buffer, sizeof(buffer) - 1, 0);
    
    if (n < 0) {
        LOG(LogLevel::WARNING) << "读取失败";
        _fdarray[pos] = defaultid;
        close(fd);
        return;
    } else if (n == 0) {
        LOG(LogLevel::INFO) << "退出" << fd;
        _fdarray[pos] = defaultid;
        close(fd);
        return;
    } else {
        buffer[n] = 0;
        std::cout << fd << " say: " << buffer << std::endl;
    }
}

5. select 的缺点

  1. 每次调用都要重置 fd_set:用户态需要遍历数组重建

  2. 每次调用都要拷贝 fd_set 到内核态(严格来说,这不是 select 独有的缺点)

  3. 内核需要遍历整个 fd_set:检查哪些 fd 就绪

  4. 支持的文件描述符数量太小(通常 1024)

四、多路转接 poll 接口

1. poll 函数原型

cpp 复制代码
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

参数说明

  • fdspollfd 结构体数组的指针

  • nfds:数组元素个数

  • timeout:超时时间(毫秒)

    • 大于 0:等待对应毫秒

    • 等于 0:非阻塞

    • 小于 0:阻塞

返回值:就绪的文件描述符个数

2. pollfd 结构体

cpp 复制代码
struct pollfd {
    int fd;          // 文件描述符
    short events;    // 需要监视的事件(输入)
    short revents;   // 实际发生的事件(输出)
};

常见事件

  • POLLIN:可读事件

  • POLLOUT:可写事件

  • POLLERR:错误事件

3. poll 服务器实现(基于 select 修改)

成员变量与构造
cpp 复制代码
const static int defaultsize = 1024;
const int defaultid = -1;

std::unique_ptr<mysocket> _listensocket;
bool _isrunning;
struct pollfd _fds[defaultsize];

pollserver(int port)
    : _listensocket(std::make_unique<tcpsocket>())
    , _isrunning(1) {
    _listensocket->buildtcpsocket(port);
    
    // 初始化 pollfd 数组
    for (int i = 0; i < defaultsize; i++) {
        _fds[i].fd = -1;
        _fds[i].events = 0;
        _fds[i].revents = 0;
    }
    
    _fds[0].fd = _listensocket->fd();
    _fds[0].events = POLLIN;
}
主循环
cpp 复制代码
void start() {
    _isrunning = 1;
    while (_isrunning) {
        int n = poll(_fds, defaultsize, -1);  // 阻塞等待
        
        if (n < 0) {
            LOG(LogLevel::ERROR) << "poll失败";
            break;
        } else if (n == 0) {
            LOG(LogLevel::WARNING) << "poll超时";
        } else {
            LOG(LogLevel::INFO) << "有事件就绪";
            Dispatcher();
        }
    }
    _isrunning = 0;
}
事件分发
cpp 复制代码
void Dispatcher() {
    for (int i = 0; i < defaultsize; i++) {
        if (_fds[i].fd == defaultid) continue;
        
        if (_fds[i].revents & POLLIN) {
            if (_fds[i].fd == _listensocket->fd()) {
                Accept();
            } else {
                Recever(i);
            }
        }
    }
}
处理新连接
cpp 复制代码
void Accept() {
    addr ad;
    int fd = _listensocket->accept(&ad);
    if (fd >= 0) {
        LOG(LogLevel::INFO) << "收到新连接" << fd;
        
        int pos = 0;
        for (int i = 0; i < defaultsize; i++) {
            if (_fds[i].fd == defaultid) {
                pos = i;
                break;
            }
        }
        
        if (pos == 0) {
            LOG(LogLevel::WARNING) << "服务器满了,丢弃fd" << fd;
            close(fd);
        } else {
            _fds[pos].fd = fd;
            _fds[pos].events = POLLIN;
            _fds[pos].revents = 0;
        }
    }
}
处理消息
cpp 复制代码
void Recever(int pos) {
    char buffer[1024];
    ssize_t n = recv(_fds[pos].fd, buffer, sizeof(buffer) - 1, 0);
    
    if (n < 0) {
        LOG(LogLevel::WARNING) << "读取失败";
        close(_fds[pos].fd);
        _fds[pos].fd = defaultid;
        return;
    } else if (n == 0) {
        LOG(LogLevel::INFO) << "退出" << _fds[pos].fd;
        close(_fds[pos].fd);
        _fds[pos].fd = defaultid;
        return;
    } else {
        buffer[n] = 0;
        std::cout << _fds[pos].fd << " say: " << buffer << std::endl;
    }
}

4. poll 与 select 对比

poll 的优势

  • 没有文件描述符数量的硬性限制(数组大小可调)

  • 使用 eventsrevents 分离,不需要像 select 那样每次重置集合

poll 的缺点(与 select 相同):

  • 仍然需要遍历整个数组来获取就绪的 fd(O(n))

  • 每次调用都要将 pollfd 数组从用户态拷贝到内核态

如果大量客户端连接,但大部分只是占位没有数据,效率会降低。

五、epoll 简介

1. epoll 核心接口

接口 作用
epoll_create() 创建 epoll 实例,返回文件描述符
epoll_ctl() 控制 epoll 实例:添加、修改、删除监视的 fd 和事件
epoll_wait() 等待事件就绪,返回就绪的事件列表

2. epoll 原理

内核数据结构

  • 红黑树:存储所有被监视的 fd 和事件信息,键为 fd

  • 就绪队列:存储已经就绪的事件

回调机制

  • 网络协议栈有回调机制

  • 当底层事件就绪时,会触发回调函数

  • 回调函数将对应的 epoll 节点挂载到就绪队列上

  • 一个节点可以通过指针同时存在于红黑树和就绪队列中

工作流程

  1. epoll_create:创建红黑树、就绪队列,注册回调机制

  2. epoll_ctl:对红黑树进行增删改操作

  3. epoll_wait:从就绪队列中取出就绪事件

3. epoll 效率分析

操作 select/poll epoll
检查是否有就绪 O(n) 遍历 O(1) 检查队列是否为空
用户态到内核态拷贝 O(n) O(n)(每次调用)
查找 fd O(n) O(log n)(红黑树)

epoll 的优势

  • 就绪事件直接放入队列,无需遍历所有 fd

  • 就绪队列是生产者消费者模型,缓冲区满时可以暂存,下次继续取

  • 线程安全

  • 内核按顺序拷贝就绪事件(从下标 0 开始),不会有 poll 中 fd = -1 的空位问题

回调机制的触发

  • epoll_ctl 调用 sys_epoll_ctl 系统调用,初始化回调

  • 当数据就绪后,通过中断向量表触发回调,唤醒等待的进程

相关推荐
fff9811182 小时前
C++与Qt图形开发
开发语言·c++·算法
xht08322 小时前
PHP vs Python:编程语言终极对决
开发语言·python·php
计算机安禾2 小时前
【数据结构与算法】第3篇:C语言核心机制回顾(二):动态内存管理与typedef
c语言·开发语言·数据结构·c++·算法·链表·visual studio
23.2 小时前
【Java】char字符类型的UTF-16编码解析
java·开发语言·面试
无小道2 小时前
关于mmap的理解和使用
开发语言·mmap
froginwe112 小时前
jQuery 隐藏/显示详解
开发语言
码云数智-大飞2 小时前
分布式数据库:2026年数据架构的基石与挑战
开发语言
查古穆2 小时前
python进阶-推导式
开发语言·python
njidf3 小时前
C++中的访问者模式
开发语言·c++·算法