一、事件处理
服务器程序通常需要处理三类事件: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: 表示动作,用三个宏来表示:
EPOLL_CTL_ADD
:注册新的 fd 到 epfd 中;EPOLL_CTL_MOD
:修改已经注册的fd的监听事件;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 时,函数为阻塞。
- 返回值:
- 如果成功,表示返回需要处理的事件数目
- 如果返回0,表示已超时
- 如果返回-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);
-
关于ET和LT:
水平触发(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)模型是一种在进行输入输出操作时利用信号通知的模型。以下是该模型的主要特点和工作流程:
-
阻塞等待信号:
程序首先将某个文件描述符设置为信号驱动IO模式,然后进行阻塞式IO操作。
当IO操作完成时,内核会向进程发送一个信号,通知它相应的IO事件已经完成。 -
信号处理函数:
进程需要注册一个信号处理函数,用于处理与IO事件相关的信号。这个处理函数可以执行必要的操作,比如读取数据、写入数据等。
通常,该信号是SIGIO
(或SIGPOLL
)。 -
非阻塞IO:
为了确保程序不在IO操作上阻塞,通常将文件描述符设置为非阻塞模式。
这样,即使没有数据准备好,IO操作也能立即返回,然后通过信号来通知程序何时可以进行实际的IO处理。 -
处理多个IO事件:
信号驱动IO模型也支持处理多个IO事件。当多个文件描述符准备好时,内核会发送相应的信号,进而调用相应的信号处理函数。 -
适用场景:
信号驱动IO适用于需要异步处理多个文件描述符的情况,允许程序在等待IO操作完成时执行其他任务,而不会阻塞整个进程。
尽管信号驱动IO模型提供了异步IO的一种实现方式,但它在复杂性和可移植性方面可能面临一些挑战。因此,选择IO模型时,需要根据具体应用场景权衡其优缺点。
在C语言中,你可以使用fcntl
函数和信号处理函数来实现信号驱动IO模型。以下是一个简单的例子:
- 设置文件描述符为非阻塞模式:
使用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);
}
- 注册信号处理函数:
注册一个信号处理函数,用于处理IO事件。在这里,我们使用SIGIO
信号。
#include <signal.h>
void handle_io(int signo) {
// 处理IO事件的代码
}
- 设置信号驱动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);
}
- 进行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事件完成。这个例子包括以下主要步骤:
- 打开一个文件(
example.txt
)并设置为非阻塞模式。 - 注册一个信号处理函数
handle_io
,该函数将在IO事件发生时被调用。 - 使用
SIGIO
信号来通知程序IO事件的发生。 - 将文件描述符与进程关联,确保信号发送到正确的进程。
- 启用信号驱动IO,使得程序在IO操作时可以立即返回而不阻塞。
这样,当IO操作完成时,内核会发送SIGIO
信号,触发注册的handle_io
函数执行。在实际应用中,你可以在handle_io
函数中执行与IO事件相关的操作,比如读取数据或写入数据。这种模型允许程序在等待IO操作完成的同时执行其他任务,提高了系统的并发性。
异步IO模型
Linux中,可以调用aio_read
函数告诉内核描述字缓冲区指针和缓冲区的大小、文件偏移及通知的方式,然后立即返回,当内核将数据拷贝到缓冲区后,再通知应用程序。