1.什么是IO多路复用?
在过去,服务器在处理各个链接IO的时候,为了使recv()在阻塞等待客户端数据的时候,不影响其它的链接IO,往往采用的是一链接一线程。这样我们每个连接,都在一个独立的线程中处理。这样虽然做的了不阻塞,但面对如今的百万并发的情况。一个连接一个线程就不适合了,因为线程的创建会销毁计算机的资源。
那我们能不能在一个线程之中,能不能完成不阻塞不影响其它链接的操作呢?答案是可以的,这就是今天要说的IO多路复用。
IO多路复用是一种同步IO模型,允许单个进程/线程同时处理多个IO请求。其核心思想在于,通过监视多个文件描述符(如套接字)的状态,当某个文件描述符就绪(即可读或可写)时,能够通知应用程序进行相应的读写操作。这种机制显著提高了系统的并发性和响应能力,减少了系统资源的浪费。
常见的IO多路复用技术包括select、poll和epoll等。这些技术的主要区别在于底层实现和性能。例如,select使用数组来存储文件描述符,存在最大连接数的限制;而poll使用链表,无最大连接数的限制,因此在处理大量文件描述符时可能更有效率。
2.select
2.1 select介绍和使用
首先我们来看一下第一种,"select"下面是select的函数原型。
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
nfds
:需要监控的最大文件描述符加1。readfds
:指向fd_set结构的指针,这个集合中包含要监控的可读类型的文件描述符。writefds
:指向fd_set结构的指针,这个集合中包含要监控的可写类型的文件描述符。exceptfds
:指向fd_set结构的指针,这个集合中包含要监控是否有异常条件出现的文件描述符。timeout
:select函数的超时时间。这个参数可以是NULL(表示无限等待),也可以是一个具体的时间值。如果是非零值,则select会在指定的时间内阻塞,等待文件描述符就绪;如果超时时间到达,即使文件描述符没有就绪,select也会返回。
在此之外,还有几个操作宏
此外还几个接口:
void FD_CLR(int fd, fd_set *set);
int FD_ISSET(int fd, fd_set *set);
void FD_SET(int fd, fd_set *set);
void FD_ZERO(fd_set *set);
- FD_ZERO(fd_set *set):这个宏用于清除一个文件描述符集合,将
set
指向的fd_set
结构中的所有位设置为0。- FD_SET(int fd, fd_set *set):这个宏用于将一个文件描述符添加到文件描述符集合中。它将
set
指向的fd_set
结构中对应于文件描述符fd
的位设置为1。- FD_CLR(int fd, fd_set *set):这个宏用于从文件描述符集合中删除一个文件描述符。它将
set
指向的fd_set
结构中对应于文件描述符fd
的位设置为0。- FD_ISSET(int fd, fd_set *set):这个宏用于检查一个文件描述符是否存在于文件描述符集合中。如果
set
指向的fd_set
结构中对应于文件描述符fd
的位为1,则返回非零值(真);否则返回0(假)。
对于他们都会用到一个结构体“fd_set”,我们来看一下这个结构体的原型。
typedef struct {
unsigned long fds_bits[FD_SETSIZE/(8*sizeof(unsigned long))];
} fd_set;
fd_set结构体对于文件描述符的表示是用位图来记录的,关于位图的概念如下。
位图(Bitmap)是一种数据结构,主要用于高效地管理和操作大量的、固定大小的项,通常用于表示哪些项是设置的(或激活的),哪些项是未设置的(或未激活的)。在位图中,每一位(bit)通常代表一个项的状态,其中“1”可能表示该项是激活的,而“0”表示未激活。
具体来说,位图使用连续的二进制位来表示数据。由于每一位只有两种状态(0或1),位图非常节省空间。例如,如果要表示一个包含1000个项的集合,每个项只有两种状态(开/关),那么使用位图只需要大约125字节(1000位 / 8位/字节)的内存空间。如果使用其他数据结构(如数组或链表),则可能需要更多的空间。
位图的主要优点包括:
- 空间效率:如上所述,位图使用极少的空间来表示大量的项。
- 快速访问:由于位图是连续的,因此可以通过简单的位运算来快速检查和修改项的状态。
- 固定大小:位图的大小是固定的,因此很容易进行内存管理。
位图在操作系统、数据库系统、网络编程和许多其他领域都有广泛的应用。例如,在操作系统中,位图经常用于管理内存页、文件描述符或其他系统资源;在数据库系统中,位图索引用于加速某些类型的查询;在网络编程中,位图可用于跟踪哪些套接字是活动的或需要处理。
也就是说,如果一个文件描述符fd的值是5,如果把他添加到fd_set这个结构体的集合中,那么他对应的表示状态就是第5位的0和1.也就是他只能表示两个状态。再此,我们就显现出了select的一个弊端。那就是,他的集合对文件描述符的数量和大小都是有限制的,取决于FD_SETSIZE这个值。不同的平台,FD_SETSIZE的值是不一样的,在编写快平台程序的时候,这一点我们要注意.下面我们用一端完整的代码,来掩饰一些select的建立流程。我们用注释对代码进行讲解。
2.2 实现一个select的使用
#include <errno.h>
#include <stdio.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <string.h>
#include <pthread.h>
#include <unistd.h>
#include <poll.h>
#include <sys/epoll.h>
int main()
{
//###############################建立一个监听本地端口用的文件描述符#######################################
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in servaddr;
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 0.0.0.0
servaddr.sin_port = htons(2000); // 0-1023,
if (-1 == bind(sockfd, (struct sockaddr*)&servaddr, sizeof(struct sockaddr))) {
printf("bind failed: %s\n", strerror(errno));
}
listen(sockfd, 10);
printf("listen finshed: %d\n", sockfd); // 3
struct sockaddr_in clientaddr;
socklen_t len = sizeof(clientaddr);
//****************************************************************************************************
//############################################实现select###############################################
fd_set rfds,rset;/*这里我们创建了两个集合。rfds集合用来保存我们要监测的文件描述符的集合,rset用来记录在rfds这个集合中
哪些文件描述符可读了*/
FD_ZERO(&rfds);//清空rfds这个集合,用来初始化
FD_SET(sockfd, &rfds);//将监听用的文件描述符sockfd添加进这个集合中。
int maxfd = sockfd;//设置当前文件描述符为集合中最大描述符,因为当前只有一个文件描述符,所以他一定是最大的。
while (1) {
rset=rfds;//每次都要重新赋值一遍,因为rset经过select其值会被改变
int nready = select(maxfd+1, &rset, NULL, NULL, NULL);/*调用select,再此我们设置null,此时如果rset集合之中
*/如果有可读就会立刻返回
if (FD_ISSET(sockfd, &rset)) { // accept
//如果是有新的链接建立了,那我们就把他存入到带监测集合rfds中
int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
printf("accept finshed: %d\n", clientfd);
FD_SET(clientfd, &rfds);
if (clientfd > maxfd) maxfd = clientfd;
}
// recv
int i = 0;
//因为sockfd是最先建立的,所以他一定是集合中最小的文件描述符。
for (i = sockfd+1; i <= maxfd;i ++) {
if (FD_ISSET(i, &rset)) {
char buffer[1024] = {0};
int count = recv(i, buffer, 1024, 0);//读取其内容
if (count == 0) { // disconnect
printf("client disconnect: %d\n", i);
close(i);
FD_CLR(i, &rfds);//如果是0说明客户端断开了连接,那我们就把他从待检测集合中删除,
continue;
}
printf("RECV: %s\n", buffer);
count = send(i, buffer, count, 0);
printf("SEND: %d\n", count);
}
}
}
//**********************************************************************************************************
}
上面是一个简单的select的实际用法,再次我们补充一个知识点,就是在linux系统中,创建的文件描述符是当前可用的文件描述符中最小的,所以我们也是利用了这一点确定了sockfd这个文件描述符是最小的,因为他是最先建立的。只有当我们调用close()函数的时候,系统才会回收这个文件描述符,要注意的是,回收并不是立刻触发的。也就是说,当我们close了3这个文件描述符的时候,我们再次建立一个新的文件描述符的时候不一定就是3.因为系统可能还没有把这个3回收。文件描述符的回收机制,一般是等待60S左右,这个时间是可以更改的,具体如何更改我们就不展开说了。
2.3 select的优缺点以及使用场景。
优点
- 跨平台性:
select
在大多数 Unix-like 系统上都是可用的,因此使用select
编写的代码具有较好的跨平台性。 - 简单性:
select
的 API 相对简单,易于理解和使用。它只需要一个fd_set
结构体来表示文件描述符的集合,以及几个简单的宏来操作这个集合。 - 适用性:对于中小规模的并发连接,
select
通常能够胜任。在许多常见的网络编程场景中,select
能够提供足够的性能和灵活性。
缺点
- 性能问题:当监视的文件描述符数量非常大时,
select
的性能会显著下降。这是因为select
使用轮询的方式来检查文件描述符的状态,当文件描述符数量增多时,轮询的开销会变得很大。 - 文件描述符限制:
select
有一个固定的文件描述符上限,通常是FD_SETSIZE
。这个限制可能不适用于需要处理大量并发连接的高性能服务器。 - 精度问题:
select
的超时参数是以秒和微秒为单位的,这对于需要高精度计时的应用来说可能不够精确。 - 信号干扰:
select
在等待期间可能会被信号中断,这可能会导致一些复杂性和错误处理的问题。 - 不可移植性:尽管
select
在许多 Unix-like 系统上可用,但在某些非 Unix 系统(如 Windows)上可能没有直接的等价物,这可能导致跨平台开发的复杂性。
适用场景
select适用于一些并发量很低的场景,而且selcet缺点很明显,他跟我们后面要说的poll的机制类似。但poll相比于select来说,他没有一个固定的文件描述上限。所以他完全可以被poll替代。也就是说,select适合于并发量很小,且该系统上没有poll的场景。
3.poll
3.1 poll的介绍
下面是poll的函数原型。
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
其中,
fds
是一个指向pollfd
结构体数组的指针,每个pollfd
结构体描述了一个文件描述符及其关注的事件;nfds
是fds
数组中的元素个数,即要监视的文件描述符的数量;timeout
是等待事件发生的超时时间,单位是毫秒(ms)。
pollfd
结构体定义如下:
struct pollfd{
int fd; 这是要监控的描述符;
short events; 针对这个描述符要监控的事件; 常用:POLLIN-可读; POLLINT-可写(其都是些比特位)
short revents; 监控调用返回后这个描述符实际就绪的事件;
};
下面是events值的相关描述
POLLIN
:普通或优先级带数据可读。
POLLOUT
:普通或优先级带数据可写。
POLLPRI
:高优先级带数据可读。
POLLERR
:发生错误。
POLLHUP
:发生挂起(hung up)。
POLLNVAL
:无效的文件描述符。
revents
成员是文件描述符的操作结果事件掩码,内核在调用返回时设置这个域。它将是之前events
中所请求的事件和当前实际发生事件的交集。当调用
poll
函数时,它会阻塞进程直到以下情况之一发生:
- 至少有一个文件描述符就绪,并且相关的事件在
revents
中设置。- 超时时间到期(如果
timeout
不是-1)。- 调用被信号中断(此时
errno
被设置为EINTR
)。返回值说明:
- 如果函数调用成功,则返回所有事件就绪的文件描述符个数。
- 如果超时时间到期且没有任何文件描述符就绪,返回0。
- 如果调用失败,返回-1,并设置全局变量
errno
以指示错误。
3.2 poll的实现
对比上述select我们发现,poll并没有对文件描述符的大小和数量的限制,这也就是他对比于select的优势所在。下面我们用代码展示一下,poll函数的适用,并且我们用注释来对其做一个解说.
#include <errno.h>
#include <stdio.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <string.h>
#include <pthread.h>
#include <unistd.h>
#include <poll.h>
#include <sys/epoll.h>
int main()
{
//##########################################创建监听文件描述符################################################
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in servaddr;
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 0.0.0.0
servaddr.sin_port = htons(2000); // 0-1023,
if (-1 == bind(sockfd, (struct sockaddr*)&servaddr, sizeof(struct sockaddr))) {
printf("bind failed: %s\n", strerror(errno));
}
listen(sockfd, 10);
printf("listen finshed: %d\n", sockfd); // 3
struct sockaddr_in clientaddr;
socklen_t len = sizeof(clientaddr);
//**********************************************************************************************************
//###############################################实现poll####################################################
struct pollfd fds[1024] = {0};//创建一个pollfd结构体的数组,每个pollfd结构体将记录一个文件描述符标志和其状态
fds[sockfd].fd = sockfd;//我们把sockfd存入进去
fds[sockfd].events = POLLIN;//这里是我们要关注的事件,
int maxfd = sockfd;//设置最大文件描述的值,方便我们后续遍历数组
while (1) {
int nready = poll(fds, maxfd+1, -1);//调用poll
if (fds[sockfd].revents & POLLIN) {
//如果当前sockfd有可读事件
int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
printf("accept finshed: %d\n", clientfd);
//我们把当前新接收的文件描述符 记录进去
fds[clientfd].fd = clientfd;
fds[clientfd].events = POLLIN;
if (clientfd > maxfd) maxfd = clientfd;
}
int i = 0;
for (i = sockfd+1; i <= maxfd;i ++) { // i fd
if (fds[i].revents & POLLIN) {
char buffer[1024] = {0};
int count = recv(i, buffer, 1024, 0);
if (count == 0) { // disconnect
printf("client disconnect: %d\n", i);
close(i);
fds[i].fd = -1;
fds[i].events = 0;
continue;
}
printf("RECV: %s\n", buffer);
count = send(i, buffer, count, 0);
printf("SEND: %d\n", count);
}
}
}
//**********************************************************************************************************
}
3.3 poll的优缺点以及适用场景
poll函数作为I/O多路复用机制的一种,与select类似,但在某些方面有所改进。以下是poll的主要优缺点:
优点:
- 无文件描述符数量限制:相较于select,poll不受文件描述符数量的限制。这意味着它可以处理更多的并发连接,尤其适用于需要支持大量文件描述符的场景。
- 输入输出参数分离:在poll中,输入和输出参数是分开的,这使得代码更加清晰,也避免了每次调用时都需要重新设置文件描述符集合的麻烦。
- 更好的可移植性:在某些系统中,select可能不受支持,而poll则具有更好的可移植性,可以在更多的Unix-like系统上使用。
缺点:
- 轮询机制效率问题:类似于select,poll使用轮询的方式来检查每个文件描述符的状态。当文件描述符的数量很大时,这种轮询机制会导致不必要的开销,特别是在只有少数文件描述符处于就绪状态的情况下。
- 效率随描述符数量增长而下降:随着监视的文件描述符数量的增长,poll的效率会线性下降。这是因为每次调用poll时,它都需要遍历整个文件描述符集合来查找就绪的描述符。
- 不支持优先级:与select一样,poll也不支持基于优先级的文件描述符处理。所有文件描述符都被平等对待,无法根据优先级进行差异化处理。
综上所述,poll在处理大量文件描述符时相较于select具有更好的可伸缩性,但由于其轮询机制,在处理大量文件描述符时效率仍然可能受到影响。因此,在选择使用poll还是其他I/O多路复用机制时,需要根据具体的应用场景和需求进行权衡。对于需要处理大量并发连接且对性能有较高要求的场景,通常推荐使用更高效的机制,如Linux中的epoll。
适用场景
poll随着并发量的增大,他的轮询遍历效率将会很低,所以他适合处理并发量小的场景下适用,作为select的替代。
4.epoll
4.1 epoll的介绍
epoll是一个非常强大的工具,在epoll出现之前,linux并没有被定义为服务器系统的一哥,直到epoll的诞生,linux成为了服务器系统的标签。
下面是epoll的相关函数
#include <sys/epoll.h>
int epoll_create(int size);
功能:创建一个epoll对象,并返回该对象的文件描述符。此文件描述符将被用于后续的epoll操作。
参数:
size
自Linux内核2.6.8版本起就被忽略,只要求size
大于0即可。返回值:成功时返回epoll文件描述符,失败时返回-1并设置
errno
。
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
功能:对指定的epoll文件描述符执行控制操作,如添加、修改或删除一个文件描述符的监听。
参数:
epfd
:由epoll_create返回的epoll文件描述符。op
:要执行的操作,可以是EPOLL_CTL_ADD(添加)、EPOLL_CTL_MOD(修改)或EPOLL_CTL_DEL(删除)。fd
:要添加、修改或删除的文件描述符。event
:指向一个epoll_event结构体的指针,用于描述对fd
感兴趣的事件。返回值:成功时返回0,失败时返回-1并设置
errno
。
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
功能:等待注册在epoll实例上的事件。如果有事件发生,或者超时,该函数将返回。
参数:
epfd
:由epoll_create返回的epoll文件描述符。events
:指向一个epoll_event结构体的数组,用于存储返回的事件。maxevents
:告知内核这个events的大小,即这个数组一共可以保存多少epoll_event结构体。timeout
:超时时间(毫秒),-1表示永远等待。返回值:成功时返回发生事件的文件描述符个数,超时返回0,失败返回-1并设置
errno
。
struct epoll_event {
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
其中
events
可以是以下的一个或多个宏的按位或:
EPOLLIN
:表示对应的文件描述符可以读(包括对端SOCKET正常关闭)。EPOLLOUT
:表示对应的文件描述符可以写。EPOLLPRI
:表示对应的文件描述符有紧急的数据可读(这里并未使用这一特性,故不做过多解释)。EPOLLERR
:表示对应的文件描述符发生错误。EPOLLHUP
:表示对应的文件描述符被挂起。EPOLLET
:将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
下面是一个epoll的实现代码.
4.2 epoll的代码实现
#include <errno.h>
#include <stdio.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <string.h>
#include <pthread.h>
#include <unistd.h>
#include <poll.h>
#include <sys/epoll.h>
int main()
{
//##########################################创建监听文件描述符################################################
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in servaddr;
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 0.0.0.0
servaddr.sin_port = htons(2000); // 0-1023,
if (-1 == bind(sockfd, (struct sockaddr*)&servaddr, sizeof(struct sockaddr))) {
printf("bind failed: %s\n", strerror(errno));
}
listen(sockfd, 10);
printf("listen finshed: %d\n", sockfd); // 3
struct sockaddr_in clientaddr;
socklen_t len = sizeof(clientaddr);
//**********************************************************************************************************
//############################################实现epoll######################################################
int epfd = epoll_create(1);//创建一个epoll的文件描述符
struct epoll_event ev;//创建一个epoll_event事件结构体
ev.events = EPOLLIN;//设置我们要关注可读事件
ev.data.fd = sockfd;//设置该事件绑定的文件描述符为sockfd
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);//我们将该事件添加到epfd中
while (1) {
struct epoll_event events[1024] = {0};//创建一个用来接收epoll_wait返回的事件数组
int nready = epoll_wait(epfd, events, 1024, -1);//调用epoll
int i = 0;
for (i = 0;i < nready;i ++) {
int connfd = events[i].data.fd;
if (connfd == sockfd) {//如果该文件描述符是sockfd说明有新的连接建立
int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
printf("accept finshed: %d\n", clientfd);
ev.events = EPOLLIN;
ev.data.fd = clientfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, clientfd, &ev);//我们把新的连接建立的文件描述符添加进去epoll中
} else if (events[i].events & EPOLLIN) {//如果是其它的可读事件
char buffer[1024] = {0};
int count = recv(connfd, buffer, 1024, 0);
if (count == 0) { // disconnect
printf("client disconnect: %d\n", connfd);
close(connfd);
epoll_ctl(epfd, EPOLL_CTL_DEL, connfd, NULL);//如果客户端断开连接,我们把该文件描述符从epoll取出
continue;
}
printf("RECV: %s\n", buffer);
count = send(connfd, buffer, count, 0);
printf("SEND: %d\n", count);
}
}
}
//**********************************************************************************************************
}
4.3 epoll的优缺点
优点
- 性能卓越:epoll在处理大量连接时性能出色,这主要得益于其事件驱动机制。当文件描述符就绪时,epoll会立即通知应用程序,避免了不必要的轮询,从而提高了效率。
- 高可扩展性:随着连接数的增加,epoll的性能下降相对较慢。这使得它非常适合处理大规模并发连接的场景。
- 支持边缘触发模式:epoll不仅支持传统的水平触发模式,还支持边缘触发模式。边缘触发模式可以减少不必要的通知,进一步提高应用程序的效率。
- 内存使用效率高:epoll通过内核与用户空间之间的mmap内存映射来加速消息传递,减少了数据拷贝的次数,从而提高了内存使用效率。
缺点:
- 平台限制:epoll是Linux特有的机制,因此在其他操作系统上无法使用。这使得跨平台开发时可能需要考虑其他I/O多路复用机制。
- 兼容性问题:在某些较旧的Linux发行版中,epoll的支持可能不够完善或存在已知的问题。因此,在使用epoll时需要注意目标平台的兼容性问题。
适用场景:
epoll其实是一个万金油,对于现在的网络服务器来说几乎大部分都在用epoll作为底层的IO管理。首先,在高并发网络通信中,epoll能够高效地处理大量并发连接,显著提高网络通信的效率。这得益于其事件驱动的特性,只在文件描述符状态发生变化时通知程序,避免了不必要的轮询,从而降低了系统资源的消耗。
其次,对于实时性要求高的应用,如游戏、语音聊天等,epoll同样表现出色。这些应用需要高效率的I/O操作来保证实时性,而epoll通过其高效的I/O事件通知机制,能够确保及时响应和处理各种I/O事件。
最后,在大规模分布式系统中,epoll通过其稳定性和可靠性,可以实现系统的高效运行。通过使用epoll技术,可以确保系统在处理大量连接和复杂操作时,仍然能够保持稳定的性能和响应速度。
其实epoll的底层实现,采用的是一个红黑树的数据结构,他的实现原理其实是一个很具有研究价值的东西。后续我将会对epoll进行更深入的研究。