网络模型之IO复用模型

一、为什么需要 IO 复用?

  如果在并发的环境下,可能会N个人向应用B发送消息,这种情况下我们的应用就必须创建多个线程去读取数据,每个线程都会自己调用recvfrom 去读取数据。那么此时情况可能如下图:
请添加图片描述
  如上图一样,并发情况下服务器很可能一瞬间会收到几十上百万的请求,这种情况下应用B就需要创建几十上百万的线程去读取数据,同时又因为应用线程是不知道什么时候会有数据读取,为了保证消息能及时读取到,那么这些线程自己必须不断的向内核发送recvfrom 请求来读取数据;
  这么多的线程不断调用recvfrom 请求数据,先不考虑服务器能不能扛得住这么多线程,就算扛得住那么很明显这种方式也是非常资源,线程是我们操作系统的宝贵资源,大量的线程用来去读取数据了,那么就意味着能做其它事情的线程就会少。

  所以就提供了一种方式,可以由一个线程监控多个网络请求(称为fd文件描述符,linux系统把所有网络请求以一个fd来标识),这样就可以只需要一个或几个线程就可以完成数据状态询问的操作,当有数据准备就绪之后再分配对应的线程去读取数据,这么做就可以节省出大量的线程资源出来,这个就是IO复用模型的思路。
请添加图片描述
  正如上图,IO复用模型的思路就是系统提供了一种函数可以同时监控多个fd的操作,这个函数就是我们常说到的select、poll、epoll函数,有了这个函数后,应用线程通过调用select函数就可以同时监控多个fd,select函数监控的fd中只要有任何一个数据状态准备就绪了,select函数就会返回可读状态,这时询问线程再去通知处理数据的线程,对应线程此时再发起recvfrom请求去读取数据。
  换句话说就是进程通过将一个或多个fd传递给select,阻塞在select操作上,select帮我们侦测多个fd是否准备就绪,当有fd准备就绪时,select返回数据可读状态,应用程序再调用recvfrom读取数据。

二、select函数的原理介绍和使用

1.为什么需要 select?

假设:

  • 服务器要同时监听 1000 个 socket
  • 如果每个 socket 一个线程

会出现:

  • 线程爆炸
  • CPU 上下文切换严重
  • 内存占用巨大

如果用阻塞:

recv(fd, buf, ...)

没有数据就卡死。

select 的作用:一个线程同时监控多个 fd;哪个 fd 就绪,就处理哪个

2.select 的工作原理

select 做了三件事:

  1. 用户把所有需要监听的 fd 传给内核
  2. 内核遍历这些 fd
  3. 把“就绪的 fd”返回给用户

本质:

  • 轮询遍历
  • 时间复杂度 O(n)

3.select 的内核机制

在内核中:

  • 维护一个 fd_set 位图
  • 每个 bit 表示一个 fd
  • 内核遍历 0~maxfd

流程:

用户态:
  fd_set 复制到内核

内核态:
  遍历全部 fd
  检查是否就绪
  标记就绪位

拷贝回用户态

所以 select 的性能瓶颈:

  • 每次都拷贝 fd_set
  • 每次都遍历所有 fd

4.select 完整服务器示例

#include <sys/select.h>

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

参数解释:

  • nfds:最大 fd + 1;不是监听数量,而是:maxfd + 1
  • readfds:监听“可读”;例如:新连接、收到数据、对端关闭
  • writefds:监听“可写”;一般用于:非阻塞 connect、发送缓冲区可写
  • exceptfds:异常事件:OOB 数据,但是很少用
  • timeout:NULL → 永久阻塞;0 → 立即返回;指定时间 → 超时返回

fd_set 的操作宏
select 使用位图管理 fd。必须使用以下宏:

FD_ZERO(&set);      // 清空
FD_SET(fd, &set);   // 添加
FD_CLR(fd, &set);   // 删除
FD_ISSET(fd, &set); // 判断是否就绪

完整服务器示例

int listenfd = socket(AF_INET, SOCK_STREAM, 0);
bind(listenfd, ...);
listen(listenfd, 5);

fd_set allset, rset;
FD_ZERO(&allset);
FD_SET(listenfd, &allset);

int maxfd = listenfd;

while (1)
{
    rset = allset;

    int nready = select(maxfd+1, &rset, NULL, NULL, NULL);

    for (int i = 0; i <= maxfd; ++i)
    {
        if (FD_ISSET(i, &rset))
        {
            if (i == listenfd)
            {
                int connfd = accept(listenfd, NULL, NULL);
                FD_SET(connfd, &allset);

                if (connfd > maxfd)
                    maxfd = connfd;
            }
            else
            {
                char buf[1024];
                int n = recv(i, buf, sizeof(buf), 0);

                if (n <= 0)
                {
                    close(i);
                    FD_CLR(i, &allset);
                }
                else
                {
                    send(i, buf, n, 0);
                }
            }
        }
    }
}

5.select 的缺陷

  • 最大 fd 限制:最多监听 1024 个 fd。
  • 每次都遍历全部 fd:即使只有一个连接活跃,也要检查 0~maxfd。时间复杂度:O(n)
  • 每次都拷贝 fd_set:用户态 ↔ 内核态 来回拷贝。
  • 修改原集合,就必须重新设置 fd。

三、poll函数的原理介绍和使用

1.poll 是为了解决什么问题?

select 的主要缺陷:

  • 最大 1024 个 fd(FD_SETSIZE 限制)
  • 位图结构不灵活
  • 每次调用都会修改 fd_set
  • 每次遍历 0~maxfd

poll 解决了:

  • 监听 fd 数量不受 1024 限制
  • 不使用位图,而是使用数组

但仍然:

  • 每次调用都要遍历所有 fd
  • 时间复杂度仍然 O(n)

2.poll 函数原型

#include <poll.h>

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

参数解释
fds:

struct pollfd {
    int   fd;        // 监听的文件描述符
    short events;    // 关心的事件
    short revents;   // 返回的就绪事件
};

nfds:数组元素个数(不是最大 fd)
timeout:

  • -1 → 永久阻塞
  • 0 → 立即返回
  • 0 → 指定毫秒超时

3.poll 监听的事件类型

POLLIN     // 可读
POLLOUT    // 可写
POLLERR    // 出错
POLLHUP    // 对端关闭
POLLNVAL   // 无效fd

通常我们监听:

events = POLLIN;

4.poll 的工作原理

流程:

用户态:
    构造 pollfd 数组
    调用 poll()

内核态:
    复制 pollfd 数组
    遍历所有 fd
    检查状态
    标记 revents
    返回就绪数量

用户态:
    遍历 pollfd 数组
    判断 revents

核心特点:

  • 线性遍历
  • O(n)
  • 不再受 1024 限制

5.poll 与 select 的本质区别

在这里插入图片描述

6.poll 使用步骤

① 定义数组

struct pollfd fds[1024];

② 初始化

fds[0].fd = listenfd;
fds[0].events = POLLIN;

③ 调用 poll

int nready = poll(fds, nfds, -1);

④ 遍历 revents

for (int i = 0; i < nfds; ++i)
{
    if (fds[i].revents & POLLIN)
    {
        // 可读
    }
}

完整服务器示例:

int listenfd = socket(AF_INET, SOCK_STREAM, 0);
bind(listenfd, ...);
listen(listenfd, 5);

struct pollfd fds[1024];

fds[0].fd = listenfd;
fds[0].events = POLLIN;

int nfds = 1;

while (1)
{
    int nready = poll(fds, nfds, -1);

    for (int i = 0; i < nfds; ++i)
    {
        if (fds[i].revents & POLLIN)
        {
            if (fds[i].fd == listenfd)
            {
                int connfd = accept(listenfd, NULL, NULL);

                fds[nfds].fd = connfd;
                fds[nfds].events = POLLIN;
                nfds++;
            }
            else
            {
                char buf[1024];
                int n = recv(fds[i].fd, buf, sizeof(buf), 0);

                if (n <= 0)
                {
                    close(fds[i].fd);
                    fds[i] = fds[nfds-1];
                    nfds--;
                }
                else
                {
                    send(fds[i].fd, buf, n, 0);
                }
            }
        }
    }
}

7.poll 的缺点

  • 每次都遍历所有 fd:即使只有 1 个连接活跃,也要检查全部。
  • 每次都拷贝数组:用户态 → 内核态
  • 高并发性能差:当连接数达到几万:CPU 占用高、延迟增加

四、epoll函数的原理介绍和使用

1.epoll 是什么?

epoll 是 Linux 下的高性能 IO 多路复用机制。它通过红黑树管理所有监听的 fd,通过就绪链表存储活跃事件。相比 select/poll 每次 O(n) 扫描,epoll 采用事件回调机制,实现 O(1) 事件通知。支持 LT 和 ET 两种触发模式,适用于高并发服务器。

2.为什么需要 epoll?

在 epoll 之前:

  • select
  • poll

问题是:
在这里插入图片描述
当连接数 10 万时:每次都扫描 10 万个 fd → 性能崩溃

3.epoll 为什么快?

1️⃣ 事件驱动(回调机制)
内核中:

  • 每个 socket 注册回调函数
  • 只要数据到达,内核主动把 fd 放入“就绪链表”

不再全量扫描

2️⃣ 红黑树管理 fd
内核中使用:

  • 红黑树(管理所有监听的 fd)
  • 就绪链表(ready list)

时间复杂度:
在这里插入图片描述
epoll 内核结构示意

epoll instance
    ├── 红黑树(所有监听 fd)
    └── 就绪链表(ready list)

4.epoll 三个核心函数

1️⃣ epoll_create
创建 epoll 实例

int epfd = epoll_create(1);

本质:在内核创建一个 eventpoll 结构体

2️⃣ epoll_ctl
注册 / 修改 / 删除 fd

epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);

常见操作:
在这里插入图片描述
3️⃣ epoll_wait
等待事件

int n = epoll_wait(epfd, events, maxevents, timeout);

返回:就绪的 fd 数量

完整服务器示例(核心结构)

int epfd = epoll_create(1);

struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = listenfd;

epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev);

while (1) {
    int nfds = epoll_wait(epfd, events, 1024, -1);

    for (int i = 0; i < nfds; i++) {
        int fd = events[i].data.fd;

        if (fd == listenfd) {
            int connfd = accept(...);
            epoll_ctl(epfd, EPOLL_CTL_ADD, connfd, &ev);
        } else {
            read(fd, buf, ...);
        }
    }
}

5.epoll 两种模式

1️⃣ LT(Level Trigger)水平触发
默认模式。
特点:只要缓冲区有数据,就一直通知
优点:不容易丢事件
缺点:可能重复触发

2️⃣ ET(Edge Trigger)边沿触发
需要设置:

ev.events = EPOLLIN | EPOLLET;

特点:只在状态变化时通知一次
要求:

  • 必须使用非阻塞 IO
  • 必须一次性读完数据
  • 否则会丢数据

6.为什么 epoll 适合高并发?

举例:10 万连接
select:每次扫描 10 万个 fd
epoll:只返回有事件的 fd;如果只有 100 个活跃:只处理 100 个

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值