在Linux系统中,多路转接(Multiplexing)是一种重要的事件处理机制,它允许一个进程或线程同时监视多个文件描述符(sockets、文件等),然后一次性提取出有多个就绪事件的文件描述符进行挨个处理。将多进程多线程以及多路转接结合在一起,是实现高并发服务器的常用手段。
一、select
select()是一个系统调用,用于在一组文件描述符中选择已准备好进行某种I/O操作的文件描述符。select()函数的原型如下:
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
- nfds:待测试的文件描述符个数,即fd_set集合中所有文件描述符的范围,即所有文件描述符的最大值加1。
- readfds:可读文件描述符集合。
- writefds:可写文件描述符集合。
- exceptfds:异常文件描述符集合。
- timeout:超时时间。
返回值:
- -1:出错,错误原因存于errno中。
- 0:超时时间到
- 正整数:准备好的文件描述符数目。
其中,readfds、writefds、exceptfds都是指向fd_set结构体的指针,它们是开关,比如readfds指向的fd_set值为......00000001的话,即意味着让selsect监视文件描述符0的可读事件(fd_set第0位为0),而不关心文件描述符2、3、4......的可读事件(fd_set其余位皆为0)。fd_set默认是一个结构体,内部是一个数组,作为位图的载体,占128字节,1024位,每一位都代表了一个文件描述符,可以操控0-1024的文件描述符。
需要使用规定得宏才能对fd_set结构体进行操作:
- FD_ZERO(fd_set *set):将set清零。
- FD_SET(int fd, fd_set *set):将fd加入set中。
- FD_CLR(int fd, fd_set *set):将fd从set中删除。
- FD_ISSET(int fd, fd_set *set):测试fd是否在set中。
timeout参数至关重要,它所指向得结构体结构如下:
struct timeval {
__time_t tv_sec; /* Seconds. */
__suseconds_t tv_usec; /* Microseconds. */
};
其中,tv_sec代表秒数,tv_usec为微秒数,即秒后面的零头。
当timeout值大于0时(结构体内两个值有任一大于0),select会等待timeout时间,其间任一监控的文件有就绪事件,该函数立刻返回,返回值为就绪文件数量,timeout同时是一个输出型参数,内部存有剩余的时间;readfds、writefds、exceptfds也是输出型参数,此时内部第n位为1,就代表文件描述符n有就绪事件。如果超过了timeout时间仍没有文件产生就绪事件,函数返回0。
如果timeout参数值为NULL,意味着select进入阻塞模式,直到有至少一个监控的文件内产生就绪事件才返回,否则陷入阻塞。
优点:
可以一次等待多个fd,在一定程度,提高I0的效率。
缺点:
1. 由于函数参数是输入输出型参数,因此每次函数返回后,再次调用都要重新设置;每次完成之后,也需要遍历检测readfds、writefds、exceptfds的值。
2. fd_set,它能够让select同时检测的fd是有上限的,默认为0-1024。
3. select底层原理是通过轮询检测的方式得知哪些fd就绪了,效率有限。
二、poll
在Linux系统中,除了select之外,还有另一种多路转接机制称为poll。与select类似,poll也允许一个进程或线程监视多个文件描述符,并在有就绪事件时进行处理。相比select,poll有一些优点,但也有一些局限性,相比select其实并没有特别大的提升,较少sh。
函数原型:
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd
:是一个结构体数组,用于传递待监视的文件描述符信息和就绪事件。nfds
:待测试的文件描述符个数,即结构体数组中元素的数量。timeout
:超时时间,与select的timeout类似,单位是毫秒。- 返回值:与select一样,返回准备好的文件描述符数目,0表示超时,-1表示出错,错误原因存于errno中。
结构体struct pollfd
:
struct pollfd {
int fd; // 待监视的文件描述符
short events; // 期望的事件类型,由下面的宏组合而成
short revents; // 实际发生的事件类型,由内核填充
};
事件类型宏:
POLLIN
:表示文件描述符可以读取(包括TCP连接中的新数据、socket接收缓冲中的数据)。POLLOUT
:表示文件描述符可以写入。POLLERR
:表示文件描述符发生了错误。POLLHUP
:表示文件描述符挂起(连接关闭)。POLLNVAL
:表示文件描述符没有打开。
使用方法:
和select不同,poll不需要对大型位图进行操作,而是使用结构体数组,每个数组元素对应一个文件描述符。然后用户从pollfd数组元素的revents中,得知该pollfd中存储的fd是否有监控的就绪事件类型被触发。
#include <stdio.h>
#include <poll.h>
int main() {
struct pollfd fds[2];
int timeout = 5000; // 5秒超时
// 设置第一个文件描述符
fds[0].fd = fd1;
fds[0].events = POLLIN; // 只关注可读事件
// 设置第二个文件描述符
fds[1].fd = fd2;
fds[1].events = POLLIN | POLLOUT; // 同时关注可读和可写事件
int ret = poll(fds, 2, timeout);
if (ret == -1) {
perror("poll");
return 1;
} else if (ret == 0) {
printf("Timeout\n");
} else {
// 检查就绪事件
for (int i = 0; i < 2; i++) {
if (fds[i].revents & POLLIN) {
printf("fd%d is ready for reading\n", i+1);
}
if (fds[i].revents & POLLOUT) {
printf("fd%d is ready for writing\n", i+1);
}
if (fds[i].revents & (POLLERR | POLLHUP | POLLNVAL)) {
printf("Error on fd%d\n", i+1);
}
}
}
return 0;
}
优点:
- 不需要使用fd_set,避免了select中的1024文件描述符的限制。
- 不需要每次调用都重新设置监视的文件描述符,使用结构体数组更加灵活。
缺点:
-
和select一样,也是轮询检测,效率有限。当文件描述符数量较大时,性能可能不如其他更高级的机制,如epoll。
三、epoll
在Linux系统中,epoll是一种高效的多路转接(Multiplexing)机制,用于监视多个文件描述符,并在有就绪事件时进行处理。相较于select和poll,epoll在处理大量文件描述符的并发时性能更优。
epoll的底层原理要比select和poll复杂的多,首先它有三个接口:
#include <sys/epoll.h>
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
epoll_create
:创建一个epoll实例,返回epoll文件描述符(epfd)。epoll_ctl
:控制epoll的行为,如添加、修改、删除监视的文件描述符。epoll_wait
:等待就绪事件,并返回就绪的文件描述符。
(1)epoll_create
epoll_creat函数的作用,是新申请一个文件描述符(即返回值),其内部创建了一个红黑树,用来存储epoll需要监控的文件描述符以及需要监控的就绪时间类型(可读、可写、异常等)。
红黑树的每一个节点都是一个epoll_event:
struct epoll_event {
__uint32_t events; // 存放发生的事件类型,可以是多个事件的位掩码
epoll_data_t data; // 用户数据,可以是指针、文件描述符等
};
typedef union epoll_data {
void *ptr;
int fd;
__uint32_t u32;
__uint64_t u64;
} epoll_data_t;
其中event中存储的是需要监控的就绪事件类型,data是一个共用体,用户可以自己决定用来存储什么信息,比如存储对应的文件描述符,或是一个结构体指针,结构体内部定义更丰富的信息。红黑树内得每个节点,都代表着内核要监控的文件及就绪事件类型。
(2)epoll_ctl
epoll_ctl函数得作用其实就是向epoll_create所创建得红黑树内部添加、删除或修改节点。
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
-
epfd
是epoll文件描述符,通过epoll_create
创建的。 -
op
是操作类型,有三种取值:EPOLL_CTL_ADD
:将文件描述符fd
添加到epoll实例中。EPOLL_CTL_MOD
:修改文件描述符fd
在epoll实例中的监视事件。EPOLL_CTL_DEL
:从epoll实例中删除文件描述符fd
。
-
fd
是要进行操作的文件描述符,可以是socket、文件等。 -
event
是一个指向struct epoll_event
结构体的指针,用于指定要添加或修改的事件。struct epoll_event
结构体中的events
字段用于指定感兴趣的事件类型,data
字段用于存储用户数据,可以是指针、文件描述符等。 -
函数返回值:成功返回0;失败返回-1,失败原因存储在errno中。
(3)epoll_wait
理所当然,epoll_wait就是用来从内核中获取哪些被监控的文件产生了就绪事件的接口了。
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
-
epfd
是epoll文件描述符,通过epoll_create
创建的。 -
events
是一个指向struct epoll_event
结构体数组的指针,用于存储就绪的事件。 -
maxevents
是events
数组的大小,即最多可以处理的就绪事件数量。 -
timeout
是超时时间,单位为毫秒。当timeout
为-1时,表示阻塞模式,即直到有就绪事件才返回;当timeout
为0时,表示非阻塞模式,即立即返回,不管是否有就绪事件;当timeout
大于0时,表示等待指定时间,超时后如果没有就绪事件则返回。 -
函数返回值:成功返回就绪的文件描述符数量,如果超时返回0;失败返回-1,失败原因存储在errno中。
epoll与select和poll最大的不同就在这里了,epoll底层不仅存在着一个红黑树,还要一个回调机制,和就绪队列。
当文件描述符上有就绪事件时,内核会触发内部的回调机制,内核检查该文件描述符是否在epoll对象的红黑树中(log2 N的时间复杂度),如果在,则再检测该就绪事件是否是红黑树对应节点中的events所设置了要监控的。如果是就将该节点的epoll_event存储到内核中的就绪epoll_event队列中,等待用户调用epoll_wait函数后,就将内核中的epoll_event就绪队列中的内容拷贝到用户通过epoll_wait函数提供的struct epoll_event *events参数中去。
如此一来,用户直接就可以从struct epoll_event *events直接得出哪些fd产生了就绪事件,不需要再像select和poll中那样对位图或数组进行遍历查询了,大幅提高了效率。
优点:
-
在大量文件描述符的并发情况下,epoll性能更高,因为它使用了回调机制,不需要像select和poll一样轮询检测。
-
支持ET(边缘触发)和LT(水平触发)两种工作模式。默认是LT模式,ET模式可以通过epoll_ctl函数的
EPOLLET
标志来设置。 -
不受文件描述符数量限制,因为它是基于红黑树实现的。
缺点:
-
epoll是Linux特有的系统调用,在跨平台要求的情况下需要考虑其他方案。
-
对于少量文件描述符的情况,select和poll可能比epoll略微更高效,因为epoll需要维护红黑树的数据结构。
总的来说,epoll是Linux下高效的多路转接机制,特别适用于处理大量并发连接的网络服务器。在实际开发中,可以根据具体的需求选择合适的多路转接机制。
(4)epoll的两种触发模式
水平触发模式(LT,Level-Triggered):
默认情况,在水平触发模式下,当文件描述符上有就绪事件时,内核会持续触发回调机制,直到用户程序将事件处理完毕。也就是说,只要文件描述符处于就绪状态,每次调用epoll_wait
都会返回该文件描述符,无论用户程序是否处理了就绪事件。这种模式需要用户程序自己负责处理所有就绪事件。
特点:
-
在水平触发模式下,用户程序需要注意在处理就绪事件时确保数据完全读取或写入,否则下次调用
epoll_wait
时该文件描述符仍然会被返回,导致重复触发。 -
水平触发模式是默认的工作模式,也是与select和poll的行为相似的模式。
边缘触发模式(ET,Edge-Triggered):
在边缘触发模式下,当文件描述符上的事件状态从未就绪变为就绪时,内核会触发回调机制,确保只通知一次。也就是说,只有当文件描述符的就绪状态发生变化时,epoll_wait
才会返回该文件描述符,一旦返回,用户程序需要处理所有就绪事件,否则下次调用epoll_wait
时不会再次返回该文件描述符。边缘触发模式要求用户程序能够及时处理就绪事件。
特点:
-
边缘触发模式在应对大量并发连接时具有更高的性能,因为它避免了重复通知的开销。
-
在边缘触发模式下,用户程序需要注意在处理就绪事件时尽可能多地读取或写入数据,确保将缓冲区中所有的就绪数据处理完毕,以免造成数据丢失或阻塞。
-
用户程序需要谨慎处理ET模式,因为它要求用户能够以更高的效率处理文件描述符上的数据。
工作模式选择:
在使用epoll时,可以通过设置epoll_ctl
函数的EPOLLET
标志来选择工作模式。默认情况下,epoll是使用水平触发模式的。
示例:
struct epoll_event event;
event.events = EPOLLIN | EPOLLET; // 设置边缘触发模式
event.data.fd = fd; // 你要监视的文件描述符fd
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event);
需要注意的是,边缘触发模式可能会导致更多的通知事件,因此在使用边缘触发模式时,用户程序需要更加高效地处理就绪事件,以免造成性能问题。另外,边缘触发模式在一些特定情况下可能会有一些坑,需要谨慎使用和处理。如果用户程序无法及时处理就绪事件,可能会导致文件描述符一直处于就绪状态,导致epoll_wait一直返回该文件描述符,进而引起CPU高占用。
四、Reactor反应堆模式
有了epoll后,就可以做一些有趣的事了,将一个文件描述符与将对它进行读写等处理的函数进行封装(通过函数指针或者仿函数),组成一个对象;再将若干个这样的对象交由一个基于epoll的对象进行管理,即可实现自动化处理文件I/O操作。这样的模式,就叫做Reactor模式,这个管理若干个fd对象的基于epoll的对象就像一个反应堆一样,一个个fd对象就是原材料,一旦加入到反应堆中去,就会被自动调用其内部封装的各个函数,这也叫做事件驱动。
- Reactor模式是一种基于事件驱动的网络编程模式,它将I/O事件和业务逻辑分离,实现了高内聚和低耦合的设计原则。
- Reactor模式有四个主要角色:Reactor、Handler、Acceptor和Dispatcher。
- Reactor是一个单例对象,它负责创建和管理一个epoll对象,并提供注册和取消注册Handler的接口。
- Handler是一个抽象类,它定义了处理I/O事件的接口,如handle_read、handle_write等。每个Handler都关联一个fd和一个Reactor对象。
- Acceptor是一个特殊的Handler,它负责监听客户端连接请求,并创建新的Handler来处理连接。
- Dispatcher是一个线程池对象,它负责调度Handler来执行相应的业务逻辑。
- Reactor模式的工作流程如下:
- 首先,Reactor对象创建一个epoll对象,并注册一个Acceptor对象来监听listen_fd。
- 然后,Reactor对象调用epoll_wait来等待I/O事件,并将就绪的事件集合传递给Dispatcher对象。
- 接着,Dispatcher对象根据事件类型,从线程池中分配一个线程来执行对应的Handler对象的回调函数。
- 最后,Handler对象根据事件类型,执行相应的业务逻辑,如读取或发送数据,或者关闭连接。
总的来说,Reactor模式是一种基于事件驱动的网络编程模式,它利用epoll的事件驱动机制,将I/O事件和业务逻辑分离,实现了高并发和高性能的服务器。