文件描述符
在unix/linux系统中,一切皆文件。具体的意思是unix/Linux操作系统在运行时,会将例如标准输入输出流(stdio),tcp的socket套接字,普通的打开的文件统一看为文件来进行读写。并且每一个“文件”会对应一个文件描述符,例如标准输入对应文件描述符0,标准输出对应文件描述符1,标准错误对应文件描述符2。创建一个socket套接字时也会返回该socket的文件描述符。
io多路复用
在客户端--服务器的架构中。每当客户端与服务器建立TCP连接,传统的服务器都会新建一个进程或者线程来处理这个连接。因为每一个进程和线程都会占用大量的内存空间,比如每个线程开8M的栈空间,假定有10000个连接,开这么多个线程需要10000*8M=80G的内存空间,再如果有竞争对手的攻击,容易导致服务器崩溃。于是就有了io多路复用,在一个进程中监听多个TCP连接或其它的“文件”(实质是监听其文件描述符),当该“文件”可读或可写时,就通知当前进程去处理,节省了内存空间,提升了系统并发性。io多路复用主要包含接口select,poll,epoll。
select
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
通过一个位数组最大1024位,位数组的每一位代表其对应的描述符是否需要被检查。第二三四参数表示需要关注读、写、错误事件的文件描述符位数组,这些参数既是输入参数也是输出参数,可能会被内核修改用于标示哪些描述符上发生了关注的事件,
所以每次调用select前都需要重新初始化fdset。timeout参数为超时时间,该结构会被内核修改,其值为超时剩余的时间。
select函数的执行步骤
- 将fd_set从用户空间加载到内核空间中,
- 注册回调函数__pollwait
- 遍历所有fd,调用其对应的poll方法(对于socket,这个poll方法是sock_poll,sock_poll根据情况会调用到tcp_poll,udp_poll
或者datagram_poll) - 以tcp_poll为例,其核心实现就是__pollwait,也就是上面注册的回调函数。
- __pollwait的主要工作就是把current(当前进程,例如下面例子中的主函数所在进程)挂到设备的等待队列中,不同的设备有不同的等待队列,对于tcp_poll 来说,其等待队列是sk->sk_sleep(注意把进程挂到等待队列中并不代表进程已经睡眠了)。在设备收到一条消息(网络设备)或填写完文件数据(磁盘设备)后,会唤醒设备等待队列上睡眠的进程,这时current便被唤醒了。
- select方法返回时会返回一个描述读写操作是否就绪的mask掩码,根据这个mask掩码给fd_set赋值。
- 如果遍历完所有的fd,还没有返回一个可读写的mask掩码,则会调用schedule_timeout是调用select的进程(也就是 current)进入睡眠。当设备驱动发生自身资源可读写后,会唤醒其等待队列上睡眠的进程。如果超过一定的超时时间(schedule_timeout 指定),还是没人唤醒,则调用select的进程会重新被唤醒获得CPU,进而重新遍历fd,判断有没有就绪的fd。
- 把fd_set从内核空间拷贝到用户空间。
总结下select的几大缺点:
(1)每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
(2)同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
(3)select支持的文件描述符数量太小了,默认是1024
select的示例代码
用poll监控标准输入是因为当没有输入的时候,进程就一直处于阻塞状态,当有数据输入时时间就立即就绪,如果监听标准输出,由于写缓冲区比较大,可能一直处于就绪状态,不利于观察。
1 #include<stdio.h>
2 #include<unistd.h>
3 #include<poll.h>
4
5 int main()
6 {
7 struct pollfd poll_fd;
8 poll_fd.fd=0;
9 poll_fd.events=POLLIN;
10
11 for(;;)
12 {
13 int ret=poll(&poll_fd,1,2000);
14 if(ret<0)
15 {
16 perror("poll");
17 continue;
18 }
19 if(ret==0)
20 {
21 printf("poll timeout!\n");
22 continue;
23 }
24 if(poll_fd.revents==POLLIN)
25 {
26 char buf[1024];
27 read(0,buf,sizeof(buf)-1);
28 printf("sdin:%s",buf);
29 }
30 }
31 }
结果显示:
poll
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
poll与select不同,通过一个pollfd数组向内核传递需要关注的事件,故没有描述符个数的限制,pollfd中的events字段和revents分别用于标示关注的事件和发生的事件,故pollfd数组只需要被初始化一次(上面的就需要被初始化3次)。
poll的实现机制与select类似,其对应内核中的sys_poll,只不过poll向内核传递pollfd数组,然后对pollfd中的每个描述符进行poll, 相比处理fdset来说,poll效率更高。poll返回后,需要对pollfd中的每个元素检查其revents值,来得指事件是否发生。
相比于select,poll没有文件描述符大小限制,也不许计算最大的文件描述符加1.
epoll
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
epoll是在select和poll的基础上改进的。epoll可以同时支持水平触发和边缘触发(Edge Triggered,只告诉进程哪些文件描述符刚刚变为就绪状态,它只说一遍,如果我们没有采取行动,那么它将不会再次告知,这种方式称为边缘触发),理论上边缘触发的性能要更高一些,但是代码实现相当复杂。
当我们调用epoll_wait()获得就绪文件描述符时,返回的不是实际的描述符,而是一个代表就绪描述符数量的值,你只需要去epoll指定的一个数组中依次取得相应数量的文件描述符即可,这里也使用了内存映射(mmap)技术,这样便彻底省掉了这些文件描述符在系统调用时复制的开销。另一个本质的改进在于epoll采用基于事件的就绪通知方式。
在select/poll中,进程只有在调用一定的方法后,内核才对所有监视的文件描述符进行扫描,而epoll事先通过epoll_ctl()来注册一个文件描述符,一旦基于某个文件描述符就绪时,内核会采用类似callback的回调机制,迅速激活这个文件描述符,当进程调用epoll_wait()时便得到通知。
epoll既然是对select和poll的改进,就应该能避免上述的三个缺点。那epoll都是怎么解决的呢?在此之前,我们先看一下epoll 和select和
poll的调用接口上的不同,select和poll都只提供了一个函数——select或者poll函数。而epoll提供了三个函 数,
epoll_create,epoll_ctl和epoll_wait,epoll_create是创建一个epoll句柄;epoll_ctl是注 册要监听的事件类型;
epoll_wait则是等待事件的产生。
对于第一个缺点,epoll的解决方案在epoll_ctl函数中。每次注册新的事件到epoll句柄中时(在epoll_ctl中指定 EPOLL_CTL_ADD),会把所有的fd拷贝进内核,而不是在epoll_wait的时候重复拷贝。epoll保证了每个fd在整个过程中只会拷贝 一次。
对于第二个缺点,epoll的解决方案不像select或poll一样每次都把current轮流加入fd对应的设备等待队列中,而只在 epoll_ctl时把current挂一遍(这一遍必不可少)并为每个fd指定一个回调函数,当设备就绪,唤醒等待队列上的等待者时,就会调用这个回调函数,而这个回调函数会把就绪的fd加入一个就绪链表)。epoll_wait的工作实际上就是在这个就绪链表中查看有没有就绪的fd(利用 schedule_timeout()实现睡一会,判断一会的效果,和select实现中的第7步是类似的)。
对于第三个缺点,epoll没有这个限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,
在1GB内存的机器上大约是10万左右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。
epoll的代码:
/*
* -[ 一般epoll接口使用描述01 ]-
*/
int main(void)
{
/*
* 此处省略网络编程常用初始化方式(从申请到最后listen)
* 并且部分的错误处理省略,我会在后面放上所有的源码,这里只放重要步骤
* 部分初始化也没写
*/
// [1] 创建一个epoll对象
ep_fd = epoll_create(OPEN_MAX); /* 创建epoll模型,ep_fd指向红黑树根节点 */
listen_ep_event.events = EPOLLIN; /* 指定监听读事件 注意:默认为水平触发LT */
listen_ep_event.data.fd = listen_fd; /* 注意:一般的epoll在这里放fd */
// [2] 将listen_fd和对应的结构体设置到树上
epoll_ctl(ep_fd, EPOLL_CTL_ADD, listen_fd, &listen_ep_event);
while(1) {
// [3] 为server阻塞(默认)监听事件,ep_event是数组,装满足条件后的所有事件结构体
n_ready = epoll_wait(ep_fd, ep_event, OPEN_MAX, -1);
for(i=0; i<n_ready; i++) {
temp_fd = ep_event[i].data.fd;
if(ep_event[i].events & EPOLLIN){
if(temp_fd == listen_fd) { //说明有新连接到来
connect_fd = accept(listen_fd, (struct sockaddr *)&client_socket_addr, &client_socket_len);
// 给即将上树的结构体初始化
temp_ep_event.events = EPOLLIN;
temp_ep_event.data.fd = connect_fd;
// 上树
epoll_ctl(ep_fd, EPOLL_CTL_ADD, connect_fd, &temp_ep_event);
}
else { //cfd有数据到来
n_data = read(temp_fd , buf, sizeof(buf));
if(n_data == 0) { //客户端关闭
epoll_ctl(ep_fd, EPOLL_CTL_DEL, temp_fd, NULL) //下树
close(temp_fd);
}
else if(n_data < 0) {}
do {
//处理数据
}while( (n_data = read(temp_fd , buf, sizeof(buf))) >0 ) ;
}
}
else if(ep_event[i].events & EPOLLOUT){
//处理写事件
}
else if(ep_event[i].events & EPOLLERR) {
//处理异常事件
}
}
}
close(listen_fd);
close(ep_fd);
}
为什么说select和poll是基于轮询的就绪通知方式,而epol是基于事件的就绪通知方式?因为select和poll需要一遍一遍的复制进内核态,一遍一遍轮询文件描述符是否就绪。而epoll是先通过epoll_ctl将文件描述符加入到就绪队列中,调用epoll_wait时直接检查队列是否为空。