Loading

【Linux网络编程】I/O 多路复用技术

【Linux网络编程】I/O 多路复用技术

什么是 I/O 多路复用?为什么需要 I/O 多路复用

最简单的 socket 网络模型,就是单线程模型,一个同时进行监听、处理,然而,单线程模型同时只能服务一个客户端,当线程发生阻塞的时候,其他客户端只能排队等待,甚至连接失败。

为了能够同时服务更多的客户端,可以使用多进程模型,多进程模型中,主进程负责监听 socket 连接,当有客户端连接后,创建新的进程负责专门处理该客户端的请求,但多进程往往占用资源较多。

多线程模型与多进程模型类似,但是线程的资源占用远远小于进程。然而,在实际生产环境中,大多数网络请求往往处理很快,相对客户端请求处理而言,线程的创建和销毁占用了更多的资源。

为了减少线程的创建和销毁,可以采用线程池模型,线程池模型预先创建多个线程,使用“线程池”对这些线程进行管理,当有客户端连接后,主线程将请求放入待处理请求队列,而线程池从待处理请求队列中获取请求,分配给空闲的线程,线程处理完后并不直接销毁,而是阻塞等待新的请求产生。同时,线程池可以根据当前请求产生速度自适应改变线程数量,当请求少的时候,缩减线程池规模,减少资源占用,当请求多的时候,线程池扩容,防止请求大量堆积。

然而,以上的这些模型,一个线程都只能处理一个客户端的请求,使用“I/O 多路复用”,可以使单个线程处理多个请求,I/O 多路复用将等待连接、读、写Socket等事件发生交给内核,因此线程不会因为这些事件阻塞。I/O 多路复用模型通过API将SOCKET交给内核,等待事件发生,当一个或多个事件,API函数将事件从内核态返回,因此线程可以一次处理一个或多个事件。

Linux操作系统提供了三种 I/O 多路复用的 API,即 select/poll/epoll。

select

select 系统调用的原型如下:

#include <sys/select.h>
int select(int nfds, fd_set* readfds, fd_set* writefds, fd_set* exceptfds, struct timeval* timeout);

nfds 参数指定被监听的文件描述符的总数。它被设置为 select 监听所有文件描述符中的最大值加 1,因为文件描述符是从 0 开始计数的。readfds、writefds 和 exceptfds 参数分别指向可读、可写与异常等事件对应的文件描述符集合。

如下为 select 的工作模式:

select 工作模式

当调用 select,fd 集合需要从用户态拷贝至内核态,然后由内核遍历传递进来的 fd 集合,并改变发送数据的 fd 的状态,再由内核态拷贝至用户态,用户再遍历已改变状态的 fd 集合,并读取数据。

缺点:

每次调用 select,都需要将 fd 集合由用户态拷贝至内核态,当 fd 很多时开销很大。
每次调用 select,都需要在内核态与用户态遍历一遍 fd 集合,当 fd 很多时仍很大。
select 支持的文件描述符数量有限,默认是 1024(fd_set 类型决定的)。
fd 集合不能重用,每次都需要重置。

poll

poll 系统调用原型如下:

#include <poll>
int poll(struct pollfd* fds, nfds_t nfds, int timeout);

struct pollfd {
  int fd;         // 文件描述符
  short events;   // 注册的事件
  short revents;  // 实际发生的事,由内核填充
}

其中 events 告诉 poll 监听 fd 上的哪些事件,由一系列事件的按位或,revents 则有内核修改,通知应用程序 fd 实际发生了哪些事件。poll 支持的事件类型可参考:pool 事件类型

poll 与 select 相似,相比于 select 优点则是没有文件描述符数量上的限制与 fd 集合也无需每次调用 poll 就要重置。

epoll

内核事件表

epoll 在实现和使用上与 select、poll 有很大的差异。首先 epoll 是使用一组函数来完成任务,而不是单个函数,其次 epoll 把用户关心的文件描述符上的事件放在内核的一个事件表中,从而无需像 select 和 poll 那样每次调用都要重复传入文件描述符集或事件集,但 epoll 需要一个额外的文件描述符,来唯一标识内核中的这个事件表。

使用 epoll_create() 来创建这个文件描述符:

#include <sys/epoll.h>
int epoll_create(int size);

该函数返回的文件描述符将用作其它所有 epoll 系统调用的第一个参数,以指定要访问的内核事件表。

使用 epoll_ctl() 来操作 epoll 的内核事件表:

#include <sys/epoll.h>
int epoll_ctl(int efd, int op, int fd, struct epoll_event* event);

fd 是要操作的文件描述符,op 则指定操作类型,操作类型有如下三种:

  • EPOLL_CTL_ADD:往事件表中注册 fd 上的事件。
  • EPOLL_CTL_MOD:修改 fd 上的注册事件。
  • EPOLL_CTL_DEL:删除 fd 上的注册事件。

event 指定事件,它是 epoll_event 结构指针类型。epoll_event 的定义如下:

struct epoll_event {
  __uint32_t events;  // epoll 事件
  epoll_data_t data;  // 用户数据
};

events 为成员描述事件类型,epoll 支持的事件类型与 poll 基本相同。data 成员中有一个 fd ,以指定事件所属目标的文件描述符。

epoll_ctl() 成功返回 0,失败返回 -1 并设置 errno。

epoll_wait 函数

epoll 系列系统调用的主要接口是 epoll_wait(),它在一段超时时间内等待一组文件描述符上的事件,其原型如下:

#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout);

关于函数的参数,我们从后往前讨论。timeout 指定 epoll 的超时值,当 timeout 为 -1 时,epoll_wait() 将永远阻塞直至某个事件发生,当 timeout 为 0 时,epoll_wait() 立即返回,当 timeout 大于 0 时,表示其阻塞的时长。maxevents 指定最多监听多少个事件,必须大于 0。

当 epoll_wait() 检测到事件,会将所有的就绪事件从内核事件表(epfd 参数指定)复制到第二个参数 events 所指向的数组中。而这个数组只用于 epoll_wait() 检测到的就绪事件,以极大提高应用程序索引就绪文件描述符的效率。

LT 触发与 ET 触发

LT(Level Trigger,水平触发):LT 是默认的工作模式,当 epoll_wait() 检测到其上有事件发生并将此事件通知应用程序后,应用程序可以不立即处理该事件,因为应用程序下一次调用 epoll_wait() 后,epoll_wait() 还会再次向应用程序告知此事件,直至事件被处理。

ET(Edge Trigger,边沿触发):在文件描述符上注册 EPOLLET 事件后,当 epoll_wait() 检测到其上有事件发生并将此事件通知应用程序后,应用程序必须立即处理该事件,因为后续的 epoll_wait() 不会再向应用程序通知这一事件。可见 ET 模式是是一种高效的工作模式,因为它很大程度上减少了同一个 epoll 事件被触发的次数,同时 ET 模式是需要与非阻塞一起使用,因为需要循环的处理读写事件直至完全。

posted @ 2024-08-28 16:54  杨谖之  阅读(19)  评论(0编辑  收藏  举报