十三、select、poll和epoll机制的介绍
相关链接:
一、概述
1、IO操作类型介绍
(1)同步IO
在操作系统中,程序运行的空间分为内核空间和用户空间,用户空间所有对io操作的代码(如文件的读写、socket的收发等)都会通过系统调用进入内核空间完成实际的操作。
CPU的速度远快于硬盘、网络等IO。在一个线程中,CPU执行代码的速度极快,然而,一旦遇到IO操作,如读写文件、发送网络数据时,就需要等待IO操作完成,才能继续进行下一步操作,这种情况称为同步IO.
在某个应用程序运行时,假设需要读写某个文件,此时就发生了 I/O 操作,在 I/O 操作的过程中,系统会将当前线程挂起,而其他需要 CPU 执行的代码就无法被当前线程执行了,这就是同步 I/O操作,因为一个 IO 操作就阻塞了当前线程,导致其他代码无法执行,所以我们可以使用 多线程或者多进程来并发执行代码,当某个线程/进程被挂起后,不会影响其他线程或进程。多线程和多进程虽然解决了这种并发的问题,但是系统不能无上限地增加线程/进程。由于系统切换线程/进程的开销也很大,所以,一旦线程/进程数量过多,CPU 的时间就花在线程/进程切换上了,真正运行代码的时间就少了,这样子的结果也导致系统性能严重下降。多线程和多进程只是解决这一问题的一种方法,另一种解决 I/O 问题的方法是异步 I/O,当然还有其他解决的方法
(2)异步IO
(3)阻塞IO
(4)非阻塞IO
- 当用户进程调用 read()/recvfrom() 等系统调用函数时,如果内核空间中的数据还没有准备好,那么它并不会阻塞用户进程,而是立刻返回一个 error。
- 对于应用进程来说,它发起一个 read() 操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个 error 时,它就知道内核中的数据还没有准备好,那么它可以再次调用 read()/recvfrom() 等函数。
- 当内核空间的数据准备好了,它就会将数据从内核空间中拷贝到用户空间,此时用户进程也就得到了数据。
(5)多路复用IO
- 当客户处理多个描述符时。
- 服务器在高并发处理网络连接的时候。
- 服务器既要处理监听套接口,又要处理已连接套接口,一般也要用到 I/O 复用。
- 如果一个服务器即要处理 TCP,又要处理 UDP,一般要使用 I/O 复用。
- 如果一个服务器要处理多个服务或多个协议,一般要使用 I/O 复用。
二、多路复用IO操作介绍
1、select
(1)select整个处理过程
- 用户进程调用 select() 函数,如果当前没有可读写的 socket,则用户进程进入阻塞状态。
- 对于内核空间来说,它会从用户空间拷贝 fd_set 到内核空间,然后在内核中遍历一遍所有的 socket 描述符,如果没有满足条件的 socket 描述符,内核将进行休眠,当设备驱动发生自身资源可读写后,会唤醒其等待队列上睡眠的内核进程,即在 socket 可读写时唤醒,或者在超时后唤醒。
- 返回 select() 函数的调用结果给用户进程,返回就绪 socket 描述符的数目,超时返回0,出错返回-1。
- 注意,在 select() 函数返回后还是需要轮询去找到就绪的 socket 描述符的,此时用户进程才可以去操作 socket 。
(2)优缺点
优点:
- select 目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点。
缺点:
-
单个进程能够监视的文件描述符的数量存在最大限制,在 Linux 上一般为 1024,可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但是这样也会造成效率的降低。
-
需要维护一个用来存放大量 fd 的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大。
-
每次在有 socket 描述符活跃的时候,都需要遍历一遍所有的 fd 找到该描述符,这会带来大量的时间消耗(时间复杂度是 O(n),并且伴随着描述符越多,这开销呈线性增长)
(3)函数原型
#include <sys/select.h>
/* According to earlier standards */
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
int select(int maxfdp1,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout)
参数说明:
- maxfdp1 指定感兴趣的文件 描述符个数,它的值是套接字最大文件描述符加 1,socket描述符 0、1、2 …maxfdp1-1 均将被设置为感兴趣(即会查看他们是否可读、可写)。(加1的原因是文件描述符的值是从0开始的,所以文件描述符的个数是文件描述符的个数+1)
- readset:指定这个 文件描述符是可读的时候才返回。
- writeset:指定这个 文件描述符是可写的时候才返回。
- exceptset:指定这个 文件描述符是异常条件时候才返回。
- timeout:指定了超时的时间,当超时了也会返回。
如果对某一个的条件不感兴趣,就可以把它设为空指针。
返回值:
(4)基本用法
-
创建
fd_set rset , allset;
//用来清空fd_set集合,即让fd_set集合不再包含任何文件句柄。
FD_ZERO(&allset);
//用来将一个给定的文件描述符加入集合之中
FD_SET(listenfd, &allset);
-
监听
/*只select出用于读的描述字,阻塞无timeout*/
nready = select(maxfd+1 , &rset , NULL , NULL , NULL);
-
获取
if(FD_ISSET(listenfd,&rset)) //检测fd在fdset集合中的状态是否变化,当检测到fd状态发生变化时返回真,否则,返回假(也可以认为集合中指定的文件描述符是否可以读写)。
-
删除文件描述符
FD_CLR(int fd ,fd_set* set);
用来将一个给定的文件描述符从集合中删除
(5)实例1
#include <stdio.h>
#include <stdlib.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
int main(void)
{
fd_set rfds;
struct timeval tv;
int retval;
/* Watch stdin (fd 0) to see when it has input. */
FD_ZERO(&rfds);
FD_SET(0, &rfds);
/* Wait up to five seconds. */
tv.tv_sec = 5;
tv.tv_usec = 0;
retval = select(1, &rfds, NULL, NULL, &tv); //监听标准输出是否有数据,最多阻塞5s ,第一个参数值为1,代表标准输出的文件描述符的值
//在Linux进程中,默认情况下会有3个缺省打开的文件描述符,分别是标准输入(stdin)0,标准输出(stdout)1,标准错误(stderr)2。
// 文件描述符0,1,2对应的物理设备一般是:键盘,显示器,显示器。
/* Don't rely on the value of tv now! */
if (retval == -1)
perror("select()");
else if (retval)
printf("Data is available now.\n");
/* FD_ISSET(0, &rfds) will be true. */
else
printf("No data within five seconds.\n");
exit(EXIT_SUCCESS);
}
执行结果:
终端无任何输入时,等待5s后select返回
终端有输入时,select会监控读文件描述符返回非-1
(6)实例2
TCP的服务器端,可以监听多个客户端连接,只要有客户端发数据过来,select就返回。然后进行数据的接收
#include <stdio.h>
#include <string.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <sys/select.h>
const static int MAXLINE = 1024;
const static int SERV_PORT = 10001;
int main()
{
int i , maxi , maxfd, listenfd , connfd , sockfd ;
/*nready 描述字的数量*/
int nready ,client[FD_SETSIZE];
int n ;
/*创建描述字集合,由于select函数会把未有事件发生的描述字清零,所以我们设置两个集合*/
fd_set rset , allset;
char buf[MAXLINE];
socklen_t clilen;
struct sockaddr_in cliaddr , servaddr;
/*创建socket*/
listenfd = socket(AF_INET , SOCK_STREAM , 0);
/*定义sockaddr_in*/
memset(&servaddr , 0 ,sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(SERV_PORT);
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
printf("s_addr=%s\n",servaddr.sin_addr.s_addr);
bind(listenfd, (struct sockaddr *) & servaddr , sizeof(servaddr));
listen(listenfd , 100); //监听socket连接,100是连接请求队列的最大个数,表示最大可以连接100个客户端
/*listenfd 是第一个描述字*/
/*最大的描述字,用于select函数的第一个参数*/
maxfd = listenfd;
/*client的数量,用于轮询*/
maxi = -1;
/*init*/
for(i=0 ;i<FD_SETSIZE ; i++)
client[i] = -1;
FD_ZERO(&allset); //清空文件描述符集合
FD_SET(listenfd, &allset); //将listenfd文件描述符加入集合
for (;;)
{
rset = allset;
nready = select(maxfd+1 , &rset , NULL , NULL , NULL); //监听集合中的读socket,如果读文件描述符处于就绪状态就返回,否则一直阻塞
if(FD_ISSET(listenfd,&rset)) //判断集合中是否存在listenfd文件描述符
{
clilen = sizeof(cliaddr);
connfd = accept(listenfd , (struct sockaddr *) & cliaddr , &clilen);
/*寻找第一个能放置新的描述字的位置*/
for (i=0;i<FD_SETSIZE;i++)
{
if(client[i]<0)
{
client[i] = connfd;
break;
}
}
/*找不到,说明client已经满了*/
if(i==FD_SETSIZE)
{
printf("Too many clients , over stack .");
return -1;
}
FD_SET(connfd,&allset);//设置fd
/*更新相关参数*/
if(connfd > maxfd) maxfd = connfd;
if(i>maxi) maxi = i;
if(nready<=1) continue;
else nready --;
}
for(i=0 ; i<=maxi ; i++)
{
if (client[i]<0) continue;
sockfd = client[i];
if(FD_ISSET(sockfd,&rset))
{
n = read(sockfd , buf , MAXLINE);
if (n==0)
{
/*当对方关闭的时候,server关闭描述字,并将set的sockfd清空*/
close(sockfd);
FD_CLR(sockfd,&allset);
client[i] = -1;
}
else
{
buf[n]=' ';
printf("Socket %d said : %s",sockfd,buf);
write(sockfd,buf,n); //Write back to client
}
nready--;
if(nready<=0) break;
}
}
}
return 0;
}
2、poll
(1)概述
(2)函数原型
int poll(struct pollfd *fds, unsigned int nfds,int timeout);
参数:
- fds :一个struct pollfd类型的数组.
struct pollfd {
int fd; /* 文件描述符 */
short events; /* 请求的事件类型 */
short revents; /* 返回的事件类型 */
};
-
-
fd:要监视的文件描述符
-
-
- events:是要监视的事件revents是返回事件,内核设置具体的返回事件
-
POLLIN:系统内核通知应用层指定数据已经备好,读数据不会被阻塞
-
POLLPRI :有紧急的数据需要读取
-
POLLOUT :系统内核通知应用层IO缓冲区已准备好,写数据不会被阻塞
-
POLLERR :指定的文件描述符发生错误
-
POLLNVAL:无效的请求
-
- events:是要监视的事件revents是返回事件,内核设置具体的返回事件
-
nfds:pollfd数组的元素个数,要监控的文件描述符数量
-
timeout:超时时间(ms)
返回值:
- 成功:发生事件的文件数量,超时返回0
- 失败:-1
(3)实例
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>
#include <poll.h>
int main(int argc, char *argv[])
{
struct pollfd fds ={0};
fds.fd =0;
fds.events=POLLIN; //系统内核通知应用层指定数据已经备好,读数据不会被阻塞
int ret = poll(&fds,1,5000); //监视读事件,超时事件为5s
if(ret == -1)
printf("poll error!\r\n");
else if(ret)
printf("data is ready!\r\n");
else if(ret ==0)
printf("time out!/r/n");
}
执行结果:
终端无输入时:
终端有输入时:
3、epoll
(1)概述
其实相对于select和poll来说,epoll更加灵活,但是核心的原理都是当socket描述符就绪(可读、可写、出现异常),就会通知应用进程,告诉他哪个socket描述符就绪,只是通知处理的方式不同而已。
epoll使用一个epfd(epoll文件描述符)管理多个socket描述符,epoll不限制socket描述符的个数,将用户空间的socket描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。当epoll记录的socket产生就绪的时候,epoll会通过callback的方式来激活这个fd,这样子在epoll_wait便可以收到通知,告知应用层哪个socket就绪了,这种通知的方式是可以直接得到那个socket就绪的,因此相比于select和poll,它不需要遍历socket列表,时间复杂度是O(1),不会因为记录的socket增多而导致开销变大。
- LT 模式:即水平触发模式,当 epoll_wait 检测到 socket 描述符处于就绪时就通知应用程序,应用程序可以不立即处理它。下次调用 epoll_wait 时,还会再次产生通知。
-
ET 模式:即边缘触发模式,当 epoll_wait 检测到 socket 描述符处于就绪时就通知应用程序,应用程序 必须立即处理它。如果不处理,下次调用 epoll_wait 时,不会再次产生通知。ET 模式在很大程度上减少了 epoll 事件被重复触发的次数,因此效率要比 LT 模式高。epoll 工作在 ET 模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。
(2)创建一个 epoll 的文件描述符
当创建好 epoll 句柄后,它就是会占用一个 fd 值,必须调用 close() 关闭,否则可能导致 fd 被耗尽,这也是为什么我们前面所讲的是:epoll 使用一个 epfd 管理多个 socket 描述符
#include <sys/epoll.h>
int epoll_create(int size);
参数:
- size :用来告诉内核这个监听的数目一共有多大,它其实是在内核申请一空间,用来存放用户想监听的 socket fd 上是否可读可行或者其他异常,只要有足够的内存空间,size 可以随意设置大小,1G 的内存上能监听约 10 万个端口。
返回值:
-
成功:返回一个非负的文件描述符。
-
失败:返回-1
(3)epoll事件控制函数
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
参数:
-
epdf:由 epoll_create() 函数返回的 epoll 文件描述符(句柄)。
-
op:op 是操作的选项,目前有以下三个选项:
-
EPOLL_CTL_ADD:注册要监听的目标 socket 描述符 fd 到 epoll 句柄中。
-
EPOLL_CTL_MOD:修改 epoll 句柄已经注册的 fd 的监听事件。
-
EPOLL_CTL_DEL:从 epoll 句柄删除已经注册的 socket 描述符。
-
- fd:指定监听的 socket 描述符。
- event:event 结构如下
typedef union epoll_data { void *ptr; int fd; uint32_t u32; uint64_t u64; } epoll_data_t;
struct epoll_event { uint32_t events; /* Epoll events */ epoll_data_t data; /* User data variable */ };
-
EPOLLIN:表示对应的文件描述符可以读(包括对端 SOCKET 正常关闭)。
-
EPOLLOUT:表示对应的文件描述符可以写。
-
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来)。
-
EPOLLERR:表示对应的文件描述符发生错误。
-
EPOLLHUP:表示对应的文件描述符被挂断。
-
EPOLLET:将 EPOLL 设为边缘触发 (Edge Triggered) 模式,这是相对于水平触发(Level Triggered) 来说的。
-
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个 socket 的话,需要再次把这个 socket 加入到 EPOLL 队列里
(4)epoll等待事件函数
等待监听的事件的发生,类似于调用 select() 函数
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);
参数:
-
events:用来从内核得到事件的集合。
-
maxevents:告知内核这个 events 有多大,这个 maxevents 的值不能大于创建 epoll_create() 时的指定的 size。
-
timeout:超时时间。
-
函数的返回值表示需要处理的事件数目,如返回 0 表示已超时。
(5)epoll 为什么更高效
1. 当我们调用 epoll_wait() 函数返回的不是实际的描述符,而是一个代表就绪描述符数量的值,这个时候需要去 epoll 指定的一个数组中依次取得相应数量的 socket 描述符即可,而不需要遍历扫描所有的 socket 描述符,因此这里的时间复杂度是 O(1)。
2. 此外还使用了内存映射(mmap )技术,这样便彻底省掉了这些 socket 描述符在系统调用时拷贝的开销(因为从用户空间到内核空间需要拷贝操作)。mmap 将用户空间的一块地址和 内核空间的一块地址同时映射到相同的一块物理内存地址(不管是用户空间还是内核空间都是虚拟地址,最终要通过地址映射映射到物理地址),使得这块物理内存对内核和对用户均可见,减少用户态和内核态之间的数据交换,不需要依赖拷贝,这样子内核可以直接看到 epoll 监听的 socket 描述符,效率极高。
3. 另一个本质的改进在于 epoll 采用基于事件的就绪通知方式。在 select/poll 中,进程只有在调用一定的方法后,内核才对所有监视的 socket 描述符进行扫描,而 epoll 事先通过 epoll_ctl() 来注册一个 socket 描述符,一旦检测到 epoll 管理的 socket 描述符就绪时,内核会采用类似 callback 的回调机制,迅速激活这个 socket 描述符,当进程调用 epoll_wait() 时便可以得到通知,也就是说 epoll 最大的优点就在于它 只管就绪的socket 描述符,而跟 socket 描述符的总数无关(6)实例
#define MAX_EVENTS 10
struct epoll_event ev, events[MAX_EVENTS];
int listen_sock, conn_sock, nfds, epollfd;
/* Set up listening socket, 'listen_sock' (socket(),
bind(), listen()) */
epollfd = epoll_create(10); /
if (epollfd == -1) {
perror("epoll_create");
exit(EXIT_FAILURE);
}
ev.events = EPOLLIN;
ev.data.fd = listen_sock;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, listen_sock, &ev) == -1) {
perror("epoll_ctl: listen_sock");
exit(EXIT_FAILURE);
}
for (;;) {
nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);
if (nfds == -1) {
perror("epoll_pwait");
exit(EXIT_FAILURE);
}
for (n = 0; n < nfds; ++n) {
if (events[n].data.fd == listen_sock) {
conn_sock = accept(listen_sock,
(struct sockaddr *) &local, &addrlen);
if (conn_sock == -1) {
perror("accept");
exit(EXIT_FAILURE);
}
setnonblocking(conn_sock);
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = conn_sock;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, conn_sock,
&ev) == -1) {
perror("epoll_ctl: conn_sock");
exit(EXIT_FAILURE);
}
} else {
do_use_fd(events[n].data.fd);
}
}
}