事件处理与IO模型

一、事件处理

服务器程序通常需要处理三类事件:I/O事件,信号及定时事件。有两种事件处理模式:

  • Reactor模式:要求主线程(I/O处理单元)只负责监听文件描述符上是否有事件发生(可读、可写),若有,则立即通知工作线程(逻辑单元),将socket可读可写事件放入请求队列,交给工作线程处理。这个过程是同步的,读取完数据后应用进程才能处理数据。
  • Proactor模式:将所有的I/O操作都交给主线程和内核来处理(进行读、写),工作线程仅负责处理逻辑,如主线程读完成后users[sockfd].read(),选择一个工作线程来处理客户请求pool->append(users + sockfd)。

所以,Reactor 可以理解为「来了事件操作系统直接通知,自己啥也不干,让子线程来处理读写」,而 Proactor 可以理解为「来了事件操作系统来处理,处理完再通知主线程」。这里的「事件」就是有新连接、有数据可读、有数据可写的这些 I/O 事件。这里的「处理」包含从驱动读取到内核以及从内核读取到用户空间。

无论是 Reactor,还是 Proactor,都是一种基于「事件分发」的网络编程模式,区别在于 Reactor 模式是基于「待完成」的 I/O 事件,而 Proactor 模式则是基于「已完成」的 I/O 事件。

理论上来说Proactor是更快的。由于Proactor模式需要异步I/O的一套接口,而在Linux环境下,没有异步AIO接口,想要实现只有用同步I/O模拟实现Proactor,即主线程完成读写后通知工作线程。但意义不大,所以大部分都是采用Reactor。


二、IO模型

阻塞模型

在阻塞模型中,当程序进行IO操作时,它会等待直到数据完全准备好或者操作完成。在这个的等待的过程中,程序的执行会被阻塞,无法执行其他任务。

优点是简单易用,缺点是可能导致资源浪费,毕竟被阻塞在那等待任务而不能干别的事情

非阻塞模型

在非阻塞模型中,程序在进行IO操作时不会一直等待,而是会立即返回,即使数据还没有准备好。程序可以不断轮询或使用回调机制来检查IO 操作是否完成,从而允许执行其他任务。

优点是提高系统的相应性,但缺点是需要额外的代码来处理非阻塞IO的状态,复杂度相对较高。

IO多路复用

IO多路复用是一种特定的IO模型,它通过一个线程或进程来监视多个IO事件,使程序能够同时处理多个连接(或套接字)而不需要创建多个线程。常见的IO多路复用技术包括select、poll和epoll。

1、select

它的特点是维护一个监控的监控的描述符集合,并且每次调用select都会进入阻塞,描述符集合会被拷贝到内核,直到集合的缓冲区发生变化了就会返回。

优点:

跨平台支持较好

缺点:

1、调用select后内核会监视缓冲区集合,缓冲区集合发生变化后释放用户态线程,用户态线程处理完后又需要调用select(因为是while(1)循环读取,除非碰到例外,例如客户端关闭),每次调用select描述符集合都会被拷贝到内核,高并发场景下这样的拷贝会使得消耗的资源是很大的。

2、监听端口的数量有限,单个进程所能打开的最大连接数由FD_SETSIZE宏定义(1024个),也可以自行修改FD_SETSIZE

3、因为每次都需要遍历一遍缓冲区集合,花费更多时间,而数据的到来又是异步的,所以是可能存在数据丢失的。

2、poll

相比select使用了链式的数据结构存储文件描述符集合,改善了select的第二条缺点,能够使用加入更多的文件描述符。除此之外和select无差别。

3、epoll

epoll解决了select的三个缺点。一是在执行epoll_ctl会直接把文件描述符注册到内核中。这样不用频繁将文件描述符集合搬到内核中。二是加入多少个描述符在epoll中是没有限制的。三是内核返回的是就绪事件的个数以及就绪事件的数组events(所以不断调用epoll_wait会不断覆盖events,需要把events进行初始化)

有三个接口

int epoll_create(int size);
  • **功能:**该函数生成一个 epoll 专用的文件描述符。
  • 参数size: 用来告诉内核这个监听的数目一共有多大,参数 size 并不是限制了 epoll 所能监听的描述符最大个数,只是对内核初始分配内部数据结构的一个建议。自从 linux 2.6.8 之后,size 参数是被忽略的,也就是说可以填只有大于 0 的任意值。
  • **返回值:**如果成功,返回poll 专用的文件描述符,否者失败,返回-1。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
  • **功能:**epoll 的事件注册函数,它不同于 select() 是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型。
  • epfd: epoll 专用的文件描述符,epoll_create()的返回值
  • op: 表示动作,用三个宏来表示:
  1. EPOLL_CTL_ADD:注册新的 fd 到 epfd 中;
  2. EPOLL_CTL_MOD:修改已经注册的fd的监听事件;
  3. EPOLL_CTL_DEL:从 epfd 中删除一个 fd;
  • fd: 需要监听的文件描述符
  • event: 告诉内核要监听什么事件
  • **返回值:**0表示成功,-1表示失败。
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout); 
  • **功能:**等待事件的产生,收集在 epoll 监控的事件中已经发送的事件,类似于 select() 调用。
  • epfd: epoll 专用的文件描述符,epoll_create()的返回值
  • events: 分配好的 epoll_event 结构体数组,epoll 将会把发生的事件赋值到events 数组中(events 不可以是空指针,内核只负责把数据复制到这个 events 数组中,不会去帮助我们在用户态中分配内存)。
  • maxevents: 告之内核这个 events 有多少个 。
  • timeout: 超时时间,单位为毫秒,为 -1 时,函数为阻塞。
  • 返回值:
  1. 如果成功,表示返回需要处理的事件数目
  2. 如果返回0,表示已超时
  3. 如果返回-1,表示失败
  • 关于**epoll_event**:

    struct epoll_event {
        uint32_t events;  // epoll 事件类型,包括可读,可写等
        epoll_data_t data; // 用户数据,可以是一个指针或文件描述符等
    };
    
    typedef union epoll_data {
    void *ptr;
    int fd;
    __uint32_t u32;
    __uint64_t u64;
    } epoll_data_t;
    

    events 可以是以下几个宏的集合:

    • EPOLLIN :表示对应的文件描述符可以读(包括对端 SOCKET 正常关闭);
    • EPOLLOUT:表示对应的文件描述符可以写;
    • EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
    • EPOLLERR:表示对应的文件描述符发生错误;
    • EPOLLHUP:表示对应的文件描述符被挂断;
    • EPOLLET :将 EPOLL 设为边缘触发(Edge Trigger)模式,这是相对于水平触发(Level Trigger)来说的。
    • EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个 socket 的话,需要再次把这个 socket 加入到 EPOLL 队列里(保证同一时间内只有一个线程在处理一个socket)

    这里举一个创建的例子:

    // 创建epoll实例
    int m_epollfd = epoll_create(5);
    // 创建节点结构体将监听连接句柄
    epoll_event event;
    event.data.fd = m_listenfd;
    //设置该句柄为边缘触发(数据没处理完后续不会再触发事件,水平触发是不管数据有没有触发都返回事件),
    event.events = EPOLLIN | EPOLLET | EPOLLRDHUP;
    // 添加监听连接句柄作为初始节点进入红黑树结构中,该节点后续处理连接的句柄
    epoll_ctl(m_epollfd, EPOLL_CTL_ADD, m_listenfd, &event);
    
  • 关于ETLT

    水平触发(LT)

    关注点是数据是否有无,只要读缓冲区不为空,写缓冲区不满,那么epoll_wait就会一直返回就绪,水平触发是epoll的默认工作方式。

    边缘触发(ET)

    关注点是变化,只要缓冲区的数据(事件)有变化,epoll_wait就会返回就绪。这就导致如果使用ET模式,那就必须保证要「一次性把数据读取&写入完」,否则会导致数据长期无法读取/写入。

关于epoll的一些疑点

  • 如果事件在执行epoll_wait之前就发生了,那么我在发生之后调用epoll_wait能否返回该文件描述符?

如果在调用epoll_wait之前已经发生了感兴趣的事件,epoll_wait调用会立即返回,并且返回已经发生事件的文件描述符。

  • 文件描述符非阻塞的情况下,在epoll机制会是什么情况?

当文件描述符被设置为非阻塞,并且使用边缘触发模式(EPOLLET)时,epoll_wait 不会阻塞等待,而是立即返回。即使没有文件描述符发生变化,epoll_wait 也会立即返回,并且返回的事件数为0。

  • 如果在epoll机制中存在着设置为阻塞的和设置为非阻塞的两种文件描述符,那么在执行epoll_wait的时候会出现什么情况?

如果存在设置为阻塞模式的文件描述符,epoll_wait 在等待就绪事件时会阻塞整个线程,直到至少一个文件描述符就绪为止。这是因为阻塞文件描述符的 I/O 操作会导致 epoll_wait 本身也被阻塞。

信号驱动模型

信号驱动IO(Signal-Driven I/O)模型是一种在进行输入输出

信号驱动IO(Signal-Driven I/O)模型是一种在进行输入输出操作时利用信号通知的模型。以下是该模型的主要特点和工作流程:

  1. 阻塞等待信号:
    程序首先将某个文件描述符设置为信号驱动IO模式,然后进行阻塞式IO操作。
    当IO操作完成时,内核会向进程发送一个信号,通知它相应的IO事件已经完成。

  2. 信号处理函数:
    进程需要注册一个信号处理函数,用于处理与IO事件相关的信号。这个处理函数可以执行必要的操作,比如读取数据、写入数据等。
    通常,该信号是SIGIO(或SIGPOLL)。

  3. 非阻塞IO:
    为了确保程序不在IO操作上阻塞,通常将文件描述符设置为非阻塞模式。
    这样,即使没有数据准备好,IO操作也能立即返回,然后通过信号来通知程序何时可以进行实际的IO处理。

  4. 处理多个IO事件:
    信号驱动IO模型也支持处理多个IO事件。当多个文件描述符准备好时,内核会发送相应的信号,进而调用相应的信号处理函数。

  5. 适用场景:
    信号驱动IO适用于需要异步处理多个文件描述符的情况,允许程序在等待IO操作完成时执行其他任务,而不会阻塞整个进程。

尽管信号驱动IO模型提供了异步IO的一种实现方式,但它在复杂性和可移植性方面可能面临一些挑战。因此,选择IO模型时,需要根据具体应用场景权衡其优缺点。

在C语言中,你可以使用fcntl函数和信号处理函数来实现信号驱动IO模型。以下是一个简单的例子:

  1. 设置文件描述符为非阻塞模式:
    使用fcntl函数设置文件描述符为非阻塞模式。
#include <fcntl.h>

int set_nonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    if (flags == -1)
    	return -1;

    return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
  1. 注册信号处理函数:
    注册一个信号处理函数,用于处理IO事件。在这里,我们使用SIGIO信号。
#include <signal.h>

void handle_io(int signo) {
    // 处理IO事件的代码
}
  1. 设置信号驱动IO:

使用fcntl函数将文件描述符和信号驱动IO相关联。

int set_signal_driven_io(int fd) {
	if (set_nonblocking(fd) == -1)
    	return -1;

    struct sigaction sa;
    sa.sa_handler = handle_io;
    sa.sa_flags = SA_RESTART | SA_SIGINFO;
    sigemptyset(&sa.sa_mask);

    // 绑定“SIGIO信号”和“handle_io函数”
    if (sigaction(SIGIO, &sa, NULL) == -1)
        return -1;

    // 绑定“IO操作完成”与“SIGIO信号”
    if (fcntl(fd, F_SETOWN, getpid()) == -1)
        return -1;

    // 启用信号驱动IO
    return fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_ASYNC);
}
  1. 进行IO操作:
    在主程序中进行IO操作,此时程序可以立即返回而不阻塞。
int main() {
    int fd = open("example.txt", O_RDONLY);
    if (fd == -1) {
        perror("Error opening file");
        return 1;
    }

    if (set_signal_driven_io(fd) == -1) {
        perror("Error setting up signal-driven IO");
        return 1;
    }

    // 进行非阻塞IO操作

    close(fd);
    return 0;
}

上面提供的信号驱动IO的示例实际上实现了一个简单的非阻塞IO的框架,使程序能够异步地等待IO事件完成。这个例子包括以下主要步骤:

  1. 打开一个文件(example.txt)并设置为非阻塞模式。
  2. 注册一个信号处理函数handle_io,该函数将在IO事件发生时被调用。
  3. 使用SIGIO信号来通知程序IO事件的发生。
  4. 将文件描述符与进程关联,确保信号发送到正确的进程。
  5. 启用信号驱动IO,使得程序在IO操作时可以立即返回而不阻塞。

这样,当IO操作完成时,内核会发送SIGIO信号,触发注册的handle_io函数执行。在实际应用中,你可以在handle_io函数中执行与IO事件相关的操作,比如读取数据或写入数据。这种模型允许程序在等待IO操作完成的同时执行其他任务,提高了系统的并发性。

异步IO模型

Linux中,可以调用aio_read函数告诉内核描述字缓冲区指针和缓冲区的大小、文件偏移及通知的方式,然后立即返回,当内核将数据拷贝到缓冲区后,再通知应用程序。

  • 28
    点赞
  • 27
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值