上一篇文章以近乎啰嗦的形式详细描述了BIO与非阻塞IO的各种细节。如果各位还没有读过这篇文章,强烈建议先浏览一下,而后再来看本篇,因为逻辑关系是层层递进的。
1. 多路复用的诞生
非阻塞IO应用一个线程就能够解决所有socket,然而付出的代价是必须频繁调用零碎调用来轮询每一个socket的数据,这种轮询太消耗性能,而且大部分轮询都是空轮询。
咱们心愿有个组件能同时监控多个socket,并在socket把数据筹备好的时候通知过程哪些socket已“就绪”,而后过程只对就绪的socket进行数据读写。
Java在JDK1.4的时候引入了NIO,并提供了Selector
这个组件来实现这个性能。
2. NIO
在引入NIO代码之前,有点事件须要解释一下。
“就绪”这个词用得有点暧昧,因为不同的socket对就绪有不同的表白。比方对于监听socket而言,如果有客户端对其进行了连贯,就阐明处于就绪状态,它并不像连贯socket一样,须要对数据的收发进行解决;相同,连贯socket的就绪状态就至多蕴含了数据筹备好读(is ready for reading)与数据筹备好写(is ready for writing)这两种。
因而,能够设想,咱们让Selector
对多个socket进行监听时,必然须要通知Selector
,咱们对哪些socket的哪些事件感兴趣。这个动作叫注册。
接下来看代码。
public class NIOServer {
static Selector selector;
public static void main(String[] args) {
try {
// 取得selector多路复用器
selector = Selector.open();
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
// 监听socket的accept将不会阻塞
serverSocketChannel.configureBlocking(false);
serverSocketChannel.socket().bind(new InetSocketAddress(8099));
// 须要把监听socket注册到多路复用器上,并通知selector,须要关注监听socket的OP_ACCEPT事件
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
// 该办法会阻塞
selector.select();
// 失去所有就绪的事件,事件被封装成了SelectionKey
Set<SelectionKey> selectionKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectionKeys.iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();
if (key.isAcceptable()) {
handleAccept(key);
} else if (key.isReadable()) {
handleRead(key);
} else if (key.isWritable()) {
//发送数据
}
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
// 解决「读」事件的业务逻辑
private static void handleRead(SelectionKey key) {
SocketChannel socketChannel = (SocketChannel) key.channel();
ByteBuffer allocate = ByteBuffer.allocate(1024);
try {
socketChannel.read(allocate);
System.out.println("From Client:" + new String(allocate.array()));
} catch (IOException e) {
e.printStackTrace();
}
}
// 解决「连贯」事件的业务逻辑
private static void handleAccept(SelectionKey key) {
ServerSocketChannel serverSocketChannel = (ServerSocketChannel) key.channel();
try {
// socketChannel肯定是非空,并且这里不会阻塞
SocketChannel socketChannel = serverSocketChannel.accept();
// 将连贯socket的读写设置为非阻塞
socketChannel.configureBlocking(false);
socketChannel.write(ByteBuffer.wrap("Hello Client, I am Server!".getBytes()));
// 注册连贯socket的「读事件」
socketChannel.register(selector, SelectionKey.OP_READ);
} catch (IOException e) {
e.printStackTrace();
}
}
}
咱们首先应用Selector.open();
失去了selector
这个多路复用对象;而后在服务端创立了监听socket,并将其设置为非阻塞,最初将监听socket注册到selector
多路复用器上,并通知selector
,如果监听socket有OP_ACCEPT
事件产生的话就要通知咱们。
咱们在while循环中调用selector.select();
办法,过程将会阻塞在该办法上,直到注册在selector
上的任意一个socket有事件产生为止,才会返回。如果不信的话能够在selector.select();
的下一行打个断点,debug模式运行后,在没有客户端连贯的状况下断点不会被触发。
当select()
返回,意味着有一个或多个socket曾经处于就绪状态,咱们应用Set<SelectionKey>
来保留所有事件,SelectionKey
封装了就绪的事件,咱们循环每个事件,依据不同的事件类型进行不同的业务逻辑解决。
OP_READ
事件就绪的话,咱们就筹备一个缓冲空间,将数据从内核空间读到缓冲中;如果是OP_ACCEPT
就绪,那就调用监听socket的accept()
办法失去连贯socket,并且accept()
不会阻塞,因为在最开始的时候咱们曾经将监听socket设置为非阻塞了。失去的连贯socket同样须要设置为非阻塞,这样连贯socket的读写操作就是非阻塞的,最初将连贯socket注册到selector
多路复用器上,并通知selector
,如果连贯socket有OP_READ
事件产生的话就要通知咱们。
上个动图对Java的多路复用代码做个解释。
接下来的重点天然是NIO中的select()
的底层原理了,还是那句话,NIO之所以能提供多路复用的性能,实质上还是操作系统底层提供了多路复用的零碎调用。
多路复用实质上就是同时监听多个socket的申请,当咱们订阅的socket上有咱们感兴趣的事件产生的时候,多路复用函数会返回,而后咱们的用户程序依据返回后果持续解决这些就绪状态的socket。
然而,不同的多路复用模型在具体的实现上有所不同,次要体现在三个方面:
- 多路复用模型最多能够同时监听多少个socket?
- 多路复用模型会监听socket上哪些事件?
- 当socket就绪时,多路复用模型如何找到就绪的socket?
多路复用次要有3种,别离是select
、poll
和epoll
,接下来将带着下面3个问题别离介绍这3种底层模型。
下文相干函数的申明以及参数的定义源于64位CentOS 7.9,内核版本为3.10.0
3. select
咱们能够通过select
通知内核,咱们对哪些描述符(这些描述符能够示意规范输出、监听socket或者连贯socket等)的哪些事件(可读、可写、产生异样)感兴趣,或者某个超时工夫之后间接返回。
举个例子,咱们调用select
通知内核仅在下列状况下产生时才返回:
- 汇合{1, 4, 7}中有任何描述符读就绪;
- 汇合{2, 9}中有任何描述符写就绪;
- 汇合{1, 3, 5}中有任何描述符有异样产生;
- 超过了10S,啥事儿也没有产生。
3.1. select应用办法
// 返回:若有就绪描述符则为其数目,若超时则为0,若出错则为-1
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
nfds
参数用来通知select
须要查看的描述符的个数,取值为咱们感兴趣的最大描述符 + 1,依照方才的例子来讲nfds
应该是{{1, 4, 7}, {2, 9}, {1, 3, 5}}
中的最大描述符+1,也就是9 + 1,为10。至于为什么这样,别急,咱们下文再说。
timeout
参数容许咱们设置select
的超时工夫,如果超过指定工夫还没有咱们感兴趣的事件产生,就进行阻塞,间接返回。
readfds
里保留的是咱们对读就绪事件感兴趣的描述符,writefds
保留的是咱们对写就绪事件感兴趣的描述符,exceptfds
保留的是咱们对产生异样这种事件感兴趣的描述符。这三个参数会通知内核,别离须要在哪些描述符上检测数据可读、可写以及产生异样。
然而这些描述符并非像我的例子一样,间接把汇合{1, 4, 7}
作为数组存起来,设计者从内存空间和应用效率的角度设计了fd_set
这个数据结构,咱们看一下它的定义以及某些重要信息。
// file: /usr/include/sys/select.h
/* __fd_mask 是 long int 类型的别名 */
typedef long int __fd_mask;
#define __NFDBITS (8 * (int) sizeof (__fd_mask))
typedef struct {
...
__fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];
...
} fd_set;
因而,fd_set
的定义,其实就是long int
类型的数组,元素个数为__FD_SETSIZE / __NFDBITS
,我间接在我的CentOS上输入了一下两个常量值,如下:
#include <stdio.h>
#include "sys/select.h"
int main(){
printf("__FD_SETSIZE:%d\n",__FD_SETSIZE);
printf("__NFDBITS:%d\n",__NFDBITS);
return 0;
}
// 输入后果
__FD_SETSIZE:1024
__NFDBITS:64
因而该数组中一共有16个元素(1024 / 64 = 16),每个元素为long int类型,占64位。
数组的第1
个元素用于示意描述符0~63
,第2
个元素用于示意描述符64~127
,以此类推,每1个bit位用0
、1
两种状态示意是否检测以后描述符的事件。
假如咱们对{1, 4, 7}
号描述符的读就绪事件感兴趣,那么readfds
参数的数组第1个元素的二进制示意就如下图所示,第1、4、7位别离被标记为1,理论存储的10进制数字为146。
理论应用select
的时候如果让咱们本人推导下面这个过程进行参数设置那可费了劲了,于是操作系统提供了4个宏来帮咱们设置数组中每个元素的每一位。
// 将数组每个元素的二进制位重置为0
void FD_ZERO(fd_set *fdset);
// 将第fd个描述符示意的二进制位设置为1
void FD_SET(int fd, fd_set *fdset);
// 将第fd个描述符示意的二进制位设置为0
void FD_CLR(int fd, fd_set *fdset);
// 查看第fd个描述符示意的二进制位是0还是1
int FD_ISSET(int fd, fd_set *fdset);
还是下面{1, 4, 7}
这个例子,再顺带着介绍一下用法,晓得有这么回事儿就行了。
fd_set readSet;
FD_ZERO(&readSet);
FD_SET(1, &readSet);
FD_SET(4, &readSet);
FD_SET(7, &readSet);
既然fd_set
底层用的是数组,那就肯定有长度限度,也就是说select同时监听的socket数量是无限的,你之前可能听过这个无限的数量是1024
,然而1024
是怎么来的呢?
3.2. 下限为什么是1024
其实select
的监听下限就等于fds_bits
数组中所有元素的二进制位总数。接下来咱们用初中数学的解题步骤推理一下这个二进制位到底有多少。
已知:
证实如下:
论断就是__FD_SETSIZE
这个宏其实就是select
同时监听socket的最大数量。该数值在源码中有定义,如下所示:
// file: /usr/include/bits/typesizes.h
/* Number of descriptors that can fit in an `fd_set'. */
#define __FD_SETSIZE 1024
所以,select函数对每一个描述符汇合fd_set,最多能够同时监听1024个描述符。
3.3. nfds的作用
为什么偏偏把最大值设置成1024
呢?没人晓得,或者只是程序员喜爱这个数字罢了。
最后设计select
的时候,设计者思考到大多数的应用程序基本不会用到很多的描述符,因而最大描述符的下限被设置成了31
(4.2BSD版本),起初在4.4BSD中被设置成了256
,直到现在被设置成了1024
。
这个数量说多不多,说少也不算少,select()
须要循环遍历数组中的位判断此描述符是否有对应的事件产生,如果每次都对1024
个描述符进行判断,在咱们感兴趣的监听描述符比拟少的状况下(比方我上文的例子)那就是一种极大的节约。于是,select
给咱们提供了nfds
这个参数,让咱们通知select()
只须要迭代数组中的前nfds
个就行了,而不要总是在每次调用的时候遍历整个数组。
身为一个零碎函数,执行效率天然须要优化到极致。
3.4. 再谈阻塞
上一篇文章讲过,当用户线程发动一个阻塞式的read零碎调用,数据未就绪时,线程就会阻塞。阻塞其实是调用线程被投入睡眠,直到内核在某个机会唤醒线程,阻塞也就完结。这里咱们借着select
再聊一聊这个阻塞。
本大节中不做「过程」和「线程」的明确辨别,线程作为轻量级过程来对待
内核会为每一个过程创立一个名为task_struct
的数据结构,这个数据结构自身是调配在内核空间的,其中保留了以后过程的过程号、socket信息、CPU的运行上下文以及其余很重要然而我不讲的信息(/狗头)。
Linux内核保护了一个执行队列,里边放的都是处于TASK_RUNNING
状态的过程的task_struct
,这些过程以双向链表的形式排队期待CPU极短时间的临幸。
阻塞的实质就是将过程的task_struct
移出执行队列,让出CPU的调度,将过程的状态的置为TASK_UNINTERRUPTIBLE
或者TASK_INTERRUPTIBLE
,而后增加到期待队列中,直到被唤醒。
那这个期待队列在哪儿呢?比方咱们对一个socket发动一个阻塞式的 read
调用,用户过程必定是须要和这个socket进行绑定的,要不然socket就绪之后都不晓得该唤醒谁。这个期待队列其实就是保留在socket数据结构中,咱们瞄一眼socket源码:
struct socket {
...
// 这个在epoll中会提到
struct file *file;
...
// struct sock - network layer representation of sockets
struct sock *sk;
...
};
struct sock {
...
// incoming packets
struct sk_buff_head sk_receive_queue;
...
// Packet sending queue
struct sk_buff_head sk_write_queue;
...
// socket的期待队列,wq的意思就是wait_queue
struct socket_wq __rcu *sk_wq;
};
不必深刻了解哈,只有晓得socket本人保护了一个期待队列sk_wq
,这个队列中每个元素保留的是:
- 阻塞在以后socket上的过程描述符
- 过程被唤醒之后应该调用的回调函数
这个回调函数是过程在退出期待队列的时候设置的一个函数指针(行话叫,向内核注册了一个回调函数),通知内核:我正等着这个socket上的数据呢,先睡一会儿,等有数据了你就执行这个回调函数吧,里边有把我唤醒的逻辑。
就这样,通过网卡接收数据、硬中断以及软中断再到内核调用回调函数唤醒过程,把过程的task_struct
从期待队列挪动到执行队列,过程再次失去CPU的临幸,函数返回后果,阻塞完结。
当初回到select
。
用户过程会阻塞在select
之上,因为select
会同时监听多个socket,因而以后过程会被增加到每个被监听的socket的期待队列中,每次唤醒还须要从每个socket期待队列中移除。
select
的唤醒也有个问题,调用select
的过程被唤醒之后是一脸懵啊,内核间接扔给他一个整数,过程不晓得哪些socket收到数据了,还必须遍历一下能力晓得。
3.5. select如何多路复用
select
在超时工夫内会被阻塞,直到咱们感兴趣的socket读就绪、写就绪或者有异样事件产生(这话如同啰嗦了好多遍了,是不是自然而然曾经记住了),而后select
会返回已就绪的描述符数。
其实读就绪、写就绪或者有异样事件产生这3种事件里边的道道儿十分多,这里咱们就仅作字面上的了解就好了,更多细节,能够参考《Unix网络编程 卷一》。
用户过程拿到这个整数阐明了两件事件:
- 咱们上文讲的所有
select
操作都是在内核态运行的,select
返回之后,权限交还到了用户空间; - 用户过程拿到这个整数,须要对
select
监听的描述符一一进行检测,判断二进制位是否被设置为1,进而进行相干的逻辑解决。可是问题是,内核把“就绪”的这个状态保留在了哪里呢?换句话说,用户过程该遍历谁?
select
的readfds
、writefds
、exceptfds
3个参数都是指针类型,用户过程传递这3个参数通知内核对哪些socket的哪些事件感兴趣,执行结束之后反过来内核会将就绪的描述符状态也放在这三个参数变量中,这种参数称为值-后果参数。
用户过程通过调用FD_ISSET(int fd, fd_set *fdset)
对描述符集进行判断即可,看个整体流程的动图。
- 用户过程设置
fd_set
参数,调用select()
函数,并将描述符汇合拷贝到内核空间; - 为了提高效率,内核通过
nfds
参数防止检测那些总为0
的位,遍历的过程产生在内核空间,不存在零碎调用切换上下文的开销; select
函数批改由指针readset
、writeset
以及exceptset
所指向的描述符集,函数返回时,描述符集中只有之前咱们标记过的并且处于就绪状态的描述符对应的二进制位才是1,其余都会被重置为0(因而每次从新调用select
时,咱们必须把所有描述符集中感兴趣的位再次设置为1);- 过程依据
select()
返回的后果判断操作是否失常,如果为0
示意超时,-1
示意出错,大于0
示意有相应数量的描述符就绪了,进而利用FD_ISSET
遍历查看所有相应类型的fd_set
中的所有描述符,如果为1
,则进行业务逻辑解决即可。
3.6. 总结
select(包含下文讲到的poll)是阻塞的,过程会阻塞在select之上,而不是阻塞在真正的I/O零碎调用上,模型示意图见下图:
咱们从头到尾都是应用一个用户线程来解决所有socket,同时又防止了非阻塞IO的那种有效轮询,为此付出的代价是一次select
零碎调用的阻塞,外加N次就绪文件描述符的零碎调用。
4. poll
poll
是select
的继任者,接下来聊它。
4.1. 函数原型
先看一下函数原型:
// 返回:若有就绪描述符则为其数目,若超时则为0,若出错则为-1
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
函数有3个参数,第一个参数是一个pollfd
类型的数组,其中pollfd
构造如下:
struct pollfd {
int fd; /* file descriptor */
short events; /* events to look for */
short revents; /* events returned */
};
4.2. poll订阅的事件
pollfd
由3局部组成,首先是描述符fd
,其次events
示意描述符fd
上待检测的事件类型,一个short
类型的数字用来示意多种事件,天然能够想到用的是二进制掩码的形式来进行位操作。
源码中咱们能够找到所有事件的定义,我依据事件的分类对源码的程序做了肯定调整,如下:
// file: /usr/include/bits/poll.h
/* 第一类:可读事件 */
#define POLLIN 0x001 /* There is data to read. */
#define POLLPRI 0x002 /* There is urgent data to read. */
#define POLLRDNORM 0x040 /* Normal data may be read. */
#define POLLRDBAND 0x080 /* Priority data may be read. */
/* 第二类:可写事件 */
#define POLLOUT 0x004 /* Writing now will not block. */
#define POLLWRNORM 0x100 /* Writing now will not block. */
#define POLLWRBAND 0x200 /* Priority data may be written. */
/* 第三类:谬误事件 */
#define POLLERR 0x008 /* Error condition. */
#define POLLHUP 0x010 /* Hung up. */
#define POLLNVAL 0x020 /* Invalid polling request. */
pollfd
构造中还有一个revents
字段,全称是“returned events”,这是poll
与select
的第1个不同点。
poll会将每次遍历之后的后果保留到revents字段中,没有select那种值-后果参数,也就不须要每次调用poll的时候重置咱们感兴趣的描述符以及相干事件。
还有一点,谬误事件不能在events中进行设置,然而当相应事件产生时会通过revents字段返回。这是poll
与select
的第2个不同点。
再来看poll
的第2个参数nfds
,示意的是数组fds
的元素个数,也就是用户过程想让poll
同时监听的描述符的个数。
如此一来,poll函数将设置最大监听数量的权限给了程序设计者,自在管制pollfd构造数组的大小,冲破了select函数1024个最大描述符的限度。这是poll
与select
的第3个不同点。
至于timeout
参数就更好了解了,就是设置超时工夫罢了,更多细节敌人们能够查看一下api。
poll
和select
是齐全不同的API设计,因而要说不同点那真是海了去了,然而因为实质上和select
没有太大的变动,因而咱们也只关注下面的这几个不同点也就罢了。须要留神的是poll
函数返回之后,被唤醒的用户过程仍然是懵的,踉踉跄跄地去遍历文件描述符、查看相干事件、进行相应逻辑解决。
其余的细节就再参考一下select
吧,poll
咱们到此为止。
5. epoll
epoll
是三者之中最弱小的多路复用模型,天然也更难讲,要喋喋不休只讲一下epoll
的劣势倒也不难,不过会丢失很多细节,用源码解释又太干燥,思来想去,于是。。。
我拖更了。。。
5.1. epoll入门
还是先从epoll
的函数应用开始,不同于select/poll
单个函数走天下,epoll
用起来略微麻烦了一点点,它提供了函数三件套,epoll_create
、epoll_ctl
、epoll_wait
,咱们一个个来看。
5.1.1. 创立epoll实例
// size参数从Linux2.6.8之后失去意义,为放弃向前兼容,须要使size参数 > 0
int epoll_create(int size);
// 这个函数是最新款,如果falgs为0,等同于epoll_create()
int epoll_create1(int flags);
epoll_create()
办法创立了一个 epoll 实例,并返回了指向epoll实例的描述符,这个描述符用于下文行将介绍的另外两个函数。也能够应用epoll_create1()
这个新函数,这个函数相比前者能够多增加EPOLL_CLOEXEC
这个可选项,至于有啥含意,对本文并不重要。
这个epoll实例外部保护了两个重要构造,别离是须要监听的文件描述符树
和就绪的文件描述符
(这两个构造下文会讲),对于就绪的文件描述符,他们会被返回给用户过程进行解决,从这个角度来说,epoll防止了每次select/poll之后用户过程须要扫描所有文件描述符的问题。
5.1.2. epoll注册事件
创立完epoll实例之后,咱们能够应用epoll_ctl
(ctl
就是control的缩写)函数,向epoll实例中增加、批改或删除咱们感兴趣的某个文件描述符的某些事件。
// 返回值: 若胜利返回0;若返回-1示意出错
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
第一个参数epfd
就是方才调用epoll_create
创立的epoll实例的描述符,也就是epoll的句柄。
第二个参数op
示意要进行什么管制操作,有3个选项
EPOLL_CTL_ADD
: 向 epoll 实例注册文件描述符对应的事件;EPOLL_CTL_DEL
:向 epoll 实例删除文件描述符对应的事件;EPOLL_CTL_MOD
: 批改文件描述符对应的事件。
第三个参数fd
很简略,就是被操作的文件描述符。
第四个参数就是注册的事件类型,咱们先看一下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
这个字段和poll
的events
参数一样,都是通过二进制掩码设置事件类型,epoll的事件类型在/usr/include/sys/epoll.h
中有定义,更具体的能够应用man epoll_ctl
看一下文档阐明,其中内容很多,晓得有这么回事儿就行了,然而留神一下EPOLLET
这个事件,我特意加了一下正文,下文会讲到。
enum EPOLL_EVENTS {
EPOLLIN = 0x001,
#define EPOLLIN EPOLLIN
EPOLLPRI = 0x002,
#define EPOLLPRI EPOLLPRI
EPOLLOUT = 0x004,
#define EPOLLOUT EPOLLOUT
EPOLLRDNORM = 0x040,
#define EPOLLRDNORM EPOLLRDNORM
EPOLLRDBAND = 0x080,
#define EPOLLRDBAND EPOLLRDBAND
EPOLLWRNORM = 0x100,
#define EPOLLWRNORM EPOLLWRNORM
EPOLLWRBAND = 0x200,
#define EPOLLWRBAND EPOLLWRBAND
EPOLLMSG = 0x400,
#define EPOLLMSG EPOLLMSG
EPOLLERR = 0x008,
#define EPOLLERR EPOLLERR
EPOLLHUP = 0x010,
#define EPOLLHUP EPOLLHUP
EPOLLRDHUP = 0x2000,
#define EPOLLRDHUP EPOLLRDHUP
EPOLLWAKEUP = 1u << 29,
#define EPOLLWAKEUP EPOLLWAKEUP
EPOLLONESHOT = 1u << 30,
#define EPOLLONESHOT EPOLLONESHOT
// 设置为 edge-triggered,默认为 level-triggered
EPOLLET = 1u << 31
#define EPOLLET EPOLLET
};
data
字段比拟有意思,咱们能够在data
中设置咱们须要的数据,具体是什么意思当初说起来还有点麻烦,稍安勿躁,咱们接着看最初一个函数。
5.1.3. epoll_wait
// 返回值: 胜利返回的是一个大于0的数,示意事件的个数;0示意超时;出错返回-1.
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
这个是不是就感觉很相熟了啊。epoll_wait
的用法和select/poll
很相似,用户过程被阻塞。不同的是,epoll
会间接通知用户过程哪些描述符曾经就绪了。
第一个参数是epoll
实例的描述符。
第二个参数是返回给用户空间的须要解决的I/O事件,是一个epoll_event
类型的数组,数组的长度就是epoll_wait
函数的返回值,再看一眼这个构造吧。
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
示意具体的事件类型,至于这个data
就是在epoll_ctl
中设置的data
,这样用户过程收到这个epoll_event
,依据之前设置的data
就能获取到相干信息,而后进行逻辑解决了。
第三个参数是一个大于 0
的整数,示意 epoll_wait
能够返回的最大事件值。
第四个参数是 epoll_wait
阻塞调用的超时值,如果设置为 -1
,示意不超时;如果设置为 0
则立刻返回,即便没有任何 I/O 事件产生。
5.2. edge-triggered 和 level-triggered
epoll
还提供了一个利器——边缘触发(edge-triggered),也就是上文我没解释的EPOLLET
参数。
啥意思呢?我举个例子。如果有个socket有100个字节的数据可读,边缘触发(edge-triggered)和条件触发(level-triggered)都会产生读就绪事件。
然而如果用户过程只读取了50个字节,边缘触发就会陷入期待,数据不会失落,然而你爱读不读,反正老子曾经告诉过你了;而条件触发会因为你还没有读完,脚踏实地地不停产生读就绪事件催你去读。
边缘触发只会产生一次事件揭示,效率和性能要高于条件触发,这是epoll的一个大杀器。
5.3. epoll进阶
5.3.1. file_operations与poll
进阶之前问个小问题,Linux下所有文件都能够应用select/poll/epoll
来监听文件变动吗?
答案是不行!
只有底层驱动实现了 file_operations
中 poll
函数的文件类型才能够被 epoll
监督!
留神,这里的
file_operations
中定义的poll
和上文讲到的poll()
是两码事儿,只是恰好名字一样罢了。
socket 类型的文件驱动实现了 poll 函数,具体实现是sock_poll(),因而才能够被 epoll 监督。
上面我摘录了 file_operations
中咱们常见的函数定义给大家看一下。
// file: include/linux/fs.h
struct file_operations {
...
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
...
unsigned int (*poll) (struct file *, struct poll_table_struct *);
...
int (*open) (struct inode *, struct file *);
int (*flush) (struct file *, fl_owner_t id);
int (*release) (struct inode *, struct file *);
...
};
有点懵对吧,持续看。
Linux对文件的操作做了高度的形象,每个开发者都能够开发本人的文件系统,Linux并不知道其中的具体文件应该怎么open
、read/write
或者release
,所以Linux定义了file_operations
这个“接口”,设施类型须要本人实现struct file_operations
构造中定义的函数的细节。有点相似于Java中的接口和具体实现类的关系。
poll
函数的作用咱们下文再说。
5.3.2. epoll内核对象的创立
epoll_create()
的次要作用是创立一个struct eventpoll
内核对象,后续epoll的操作大部分都是对这个数据结构的操作。
wq
:期待队列。双向链表,软中断就绪的时候会通过wq
找到阻塞在epoll对象上的过程;rdllist
:就绪的描述符链表。双向链表,当描述符就绪时,内核会将就绪的描述符放到rdllist
,这样用户过程就能够通过该链表间接找到就绪的描述符;rbr
:Red Black Root。指向红黑树根节点,里边的每个节点示意的就是epoll监听的文件描述符。
而后,内核将eventpoll
退出到以后过程已关上的文件列表中。啥?eventpoll
也是一个文件?别急,咱们看看epoll_create1
的源码。
//file: /fs/eventpoll.c
SYSCALL_DEFINE1(epoll_create1, int, flags)
{
int error, fd;
struct eventpoll *ep = NULL;
struct file *file;
...
// 1. 为struct eventpoll分配内存并初始化
// 初始化操作次要包含初始化期待队列wq、rdllist、rbr等
error = ep_alloc(&ep);
...
// 2. 获取一个可用的形容符号fd,此时fd还未与具体的file绑定
fd = get_unused_fd_flags(O_RDWR | (flags & O_CLOEXEC));
...
// 3. 创立一个名为"[eventpoll]"的匿名文件file
// 并将eventpoll对象赋值到匿名文件file的private_data字段进行关联
file = anon_inode_getfile("[eventpoll]", &eventpoll_fops, ep,
O_RDWR | (flags & O_CLOEXEC));
// 4. 将eventpoll对象的file指针指向刚创立的匿名文件file
ep->file = file;
// 5. 将fd和匿名文件file进行绑定
fd_install(fd, file);
return fd;
}
好好看一下代码中的正文(肯定要看!),代码执行结束的后果就如下图这般。
调用epoll_create1
后失去的文件描述符实质上是匿名文件[eventpoll]
的描述符,该匿名文件中的private_data
字段才指向了真正的eventpoll
对象。
Linux中的所有皆文件并非虚言。这样一来,eventpoll文件
也能够被epoll自身监测,也就是说epoll实例能够监听其余的epoll实例,这一点很重要。
至此,epoll_create1
调用完结。是不是很简略呐~
5.3.3. 增加socket到epoll
当初咱们思考应用EPOLL_CTL_ADD
向epoll实例中增加fd的状况。
接下来会波及到较多的源码,别恐怖,都很简略
这时候就要用到上文的rbr
红黑树了, epoll_ctl
对fd的增删改操查作实际上就是对这棵红黑树进行操作,树的节点构造epitem
如下所示:
// file: /fs/eventpoll.c
struct epitem {
/* 红黑树的节点 */
struct rb_node rbn;
/* 用于将以后epitem连贯到eventpoll中rdllist中的工具 */
struct list_head rdllink;
...
/* 该构造保留了咱们想让epoll监听的fd以及该fd对应的file */
struct epoll_filefd ffd;
/* 以后epitem属于哪个eventpoll */
struct eventpoll *ep;
};
接着咱们看一下epoll_ctl
的源码。
// file: /fs/eventpoll.c
SYSCALL_DEFINE4(epoll_ctl, int, epfd, int, op, int, fd,
struct epoll_event __user *, event)
{
struct file *file, *tfile;
struct eventpoll *ep;
struct epitem *epi;
...
/* 依据epfd找到eventpoll对应的匿名文件 */
file = fget(epfd);
/* fd是咱们感兴趣的socket描述符,依据它找到对应的文件 */
tfile = fget(fd);
/* 依据file的private_data字段找到eventpoll实例 */
ep = file->private_data;
...
/* 在红黑树中查找一下,看看是不是曾经存在了
如果存在了,那就报错;否则,执行ep_insert */
epi = ep_find(ep, tfile, fd);
switch (op) {
case EPOLL_CTL_ADD:
if (!epi) {
epds.events |= POLLERR | POLLHUP;
error = ep_insert(ep, &epds, tfile, fd);
} else
error = -EEXIST;
clear_tfile_check_list();
break;
...
}
...
}
epoll_ctl
中,首先依据传入的epfd
以及fd
找到相干的内核对象,而后在红黑树中判断这个epitem
是不是曾经存在,存在的话就报错,否则继续执行ep_insert
函数。
ep_insert
故名思义就是将epitem
构造插入到红黑树当中,然而并非单纯插入那么简略,其中波及到一些细节。
5.3.3.1. ep_insert
很多要害操作都是在ep_insert
函数中实现的,看一下源码。
// file: /fs/eventpoll.c
static int ep_insert(struct eventpoll *ep, struct epoll_event *event,
struct file *tfile, int fd)
{
int error, revents, pwake = 0;
unsigned long flags;
long user_watches;
struct epitem *epi;
struct ep_pqueue epq;
// 1. 调配epitem内存空间
if (!(epi = kmem_cache_alloc(epi_cache, GFP_KERNEL)))
return -ENOMEM;
...
// 2. 将epitem进行初始化
INIT_LIST_HEAD(&epi->rdllink);
INIT_LIST_HEAD(&epi->fllink);
INIT_LIST_HEAD(&epi->pwqlist);
epi->ep = ep;
ep_set_ffd(&epi->ffd, tfile, fd);
epi->event = *event;
epi->nwait = 0;
epi->next = EP_UNACTIVE_PTR;
...
/* 3. 初始化 poll table,设置回调函数为ep_ptable_queue_proc */
epq.epi = epi;
init_poll_funcptr(&epq.pt, ep_ptable_queue_proc);
/*
* 4. 调用ep_ptable_queue_proc函数,
* 设置socket期待队列的回调函数为ep_poll_callback
*/
revents = ep_item_poll(epi, &epq.pt);
...
/* 5. epitem插入eventpoll的红黑树 */
ep_rbtree_insert(ep, epi);
...
}
5.3.3.2. 调配与初始化epitem
尽管源码行数不少,然而这一步非常简单,就是将epitem
中的数据筹备好,到插入的时候间接拿来用就行了。用一张图来阐明这一步的重点问题。
epitem
曾经筹备好了,也就是监听的socket对象曾经有了,就差插入到红黑树了,然而在插入之前须要解决个问题,当监听的对象就绪了之后内核该怎么办?
那就是设置回调函数!
这个回调函数是通过函数 ep_ptable_queue_proc
来进行设置的。回调函数是干什么的呢?就是当对应的文件描述符上有事件产生,就会调用这个函数,比方socket缓冲区有数据了,内核就会回调这个函数。这个函数就是 ep_poll_callback
。
5.3.3.3. 设置回调函数
这一大节就是通过源码解说如何设置ep_poll_callback
回调函数的。
没有急躁的话能够临时跳过这一大节,然而强烈建议整体看完之后回看这部分内容。
// file: /include/linux/poll.h
static inline void init_poll_funcptr(poll_table *pt, poll_queue_proc qproc)
{
pt->_qproc = qproc;
pt->_key = ~0UL; /* all events enabled */
}
init_poll_funcptr
函数将poll_table
构造的_qproc
函数指针设置为qproc
参数,也就是在ep_insert
中看到的ep_ptable_queue_proc
函数。
接下来轮到ep_item_poll
了,扒开它看看。
// file: /fs/eventpoll.c
static inline unsigned int ep_item_poll(struct epitem *epi, poll_table *pt)
{
pt->_key = epi->event.events;
// 这行是重点
return epi->ffd.file->f_op->poll(epi->ffd.file, pt) & epi->event.events;
}
重点来了,通过上文咱们晓得了,ffd.file
指的是socket代表的文件,也就是调用了socket文件本人实现的poll
办法,也就是上文提到过的sock_poll()
。
而后通过上面层层函数调用,最终来到了poll_wait
函数。
// file: /include/linux/poll.h
static inline void poll_wait(struct file * filp, wait_queue_head_t * wait_address, poll_table *p)
{
if (p && p->_qproc && wait_address)
p->_qproc(filp, wait_address, p);
}
你看,poll_wait
又调用了poll_table
的_qproc
函数,咱们刚刚在init_poll_funcptr
中将其设置为了ep_ptable_queue_proc
,于是,代码来到了ep_ptable_queue_proc
。
// file: /fs/eventpoll.c
static void ep_ptable_queue_proc(struct file *file, wait_queue_head_t *whead,
poll_table *pt)
{
...
if (epi->nwait >= 0 && (pwq = kmem_cache_alloc(pwq_cache, GFP_KERNEL))) {
// 设置最终的回调办法ep_poll_callback
init_waitqueue_func_entry(&pwq->wait, ep_poll_callback);
...
// 将蕴含ep_poll_callback在内的信息放入socket的期待队列
add_wait_queue(whead, &pwq->wait);
...
}
}
ep_ptable_queue_proc
被我简化地只剩2个函数调用了,咱们在3.4节中提到了socket本人保护了一个期待队列sk_wq
,并且这个期待队列中的每一项保留了阻塞在以后socket上的过程描述符(明确晓得该唤醒谁)以及回调函数(内核明确晓得数据来了该怎么做)。
这一系列的操作就是设置回调函数为ep_poll_callback
,并封装队列项数据结构,而后把这个构造放到socket的期待队列中。
还有一个小问题,不晓得敌人们留神到了没有,我没提保留以后用户过程信息这回事儿。这也是epoll更加高效的一个起因,当初socket曾经齐全托管给epoll了,因而咱们不能在一个socket准备就绪的时候就立即去唤醒过程,唤醒的机会得交给epoll,这就是为什么eventpoll
对象还有一个队列的起因,里边寄存的就是阻塞在epoll上的过程。
再看一遍这个构造。
说完这些,你可能在想,交给epoll不也是让epoll唤醒嘛,有啥区别?还有ep_poll_callback
这个回调具体怎么用也没解释。
别急,当初还不是解释的时候,持续往下。
5.3.3.4. 插入红黑树
最初一步就是通过ep_rbtree_insert(ep, epi)
把epitem
插入到红黑树中。
至此,epoll_ctl
的整个调用过程全副完结。
此过程中我没有解释对于红黑树的任何操作,我也倡议大家把它当成一个黑盒,只须要晓得epoll底层采纳了红黑树对epitem
进行增删改查即可,毕竟学习红黑树不是咱们的重点。
至于为什么内核开发者抉择了红黑树这个构造,天然就是为了高效地治理epitem
,使得在插入、查找、删除等各个方面不会因为epitem
数量的减少而产生性能的激烈稳定。
下面几个大节的所有工作,失去了如下这一张图。
5.3.4. epoll_wait
epoll自身是阻塞的,阻塞也正是在这一步中体现的。
大部分人听到阻塞这个词就感觉很低效,这种想法并不对。
epoll_wait
做的事件就是查看eventpoll
对象中的就绪fd列表rdllist
中是否有数据,如果有,就阐明有socket曾经筹备好了,那就间接返回,用户过程对该列表中的fd进行解决。
如果列表为空,那就将以后过程退出到eventpoll
的过程期待队列wq
中,让出CPU,被动进入睡眠状态。
也就是说,只有有活儿(fd就绪),epoll会玩儿命始终干,相对不阻塞。然而一旦没活儿了,阻塞就是一种正确的抉择,要不然始终占用CPU也是一种极大的节约。因而,epoll防止了很多不必要的过程上下文切换。
好了,当初来看epoll_wait
的实现吧。
// file: /fs/eventpoll.c
SYSCALL_DEFINE4(epoll_wait, int, epfd, struct epoll_event __user *, events,
int, maxevents, int, timeout)
{
...
error = ep_poll(ep, events, maxevents, timeout);
...
}
static int ep_poll(struct eventpoll *ep, struct epoll_event __user *events,
int maxevents, long timeout)
{
...
fetch_events:
...
// 如果就绪队列上没有工夫产生,进入上面的逻辑
// 否则,就返回
if (!ep_events_available(ep)) {
/*
* We don't have any available event to return to the caller.
* We need to sleep here, and we will be wake up by
* ep_poll_callback() when events will become available.
*/
// 定义期待队列项,并将以后线程和其进行绑定,并设置回调函数
init_waitqueue_entry(&wait, current);
// 将期待队列项退出到wq期待队列中
__add_wait_queue_exclusive(&ep->wq, &wait);
for (;;) {
/*
* We don't want to sleep if the ep_poll_callback() sends us
* a wakeup in between. That's why we set the task state
* to TASK_INTERRUPTIBLE before doing the checks.
*/
// 让出CPU,进入睡眠状态
set_current_state(TASK_INTERRUPTIBLE);
...
if (!schedule_hrtimeout_range(to, slack, HRTIMER_MODE_ABS))
timed_out = 1;
...
}
...
}
...
}
源码中有局部英文正文我没有删除,读一下这些正文可能会对了解整个过程有帮忙。
ep_poll
做了以下几件事:
- 判断
eventpoll
的rdllist
队列上有没有就绪fd,如果有,那就间接返回;否则执行上面的步骤; - 定义
eventpoll
的wq
期待队列项,将以后过程绑定至队列项,并且设置回调函数; - 将期待队列项退出到
wq
队列; - 以后过程让出CPU,进入睡眠状态,过程阻塞。
每一步都比拟好了解,咱们重点来看一下第2步,也就是init_waitqueue_entry
函数。
// file: /include/linux/wait.h
static inline void init_waitqueue_entry(wait_queue_t *q, struct task_struct *p)
{
q->flags = 0;
q->private = p;
q->func = default_wake_function;
}
wait_queue_t
就是wq
期待队列项的构造体类型,将其中的private
字段设置成了以后过程的task_struct
构造体指针。而后将default_wake_function
作为回调函数,赋值给了func
字段,至于这个回调函数干嘛用的,还是别急,下文会说的。
于是,这个图又残缺了一些。
5.3.5. 来活儿了
收到数据之后,首先干苦力活的是网卡,网卡会将数据放到某块关联的内存当中,这个操作不须要CPU的参加。等到数据保留完了之后,网卡会向CPU发动一个硬中断,告诉CPU数据来了。
这个时候CPU就要开始对中断进行解决了,然而CPU太忙了,它必须时时刻刻筹备好接管各种设施的中断,比方鼠标、键盘等,而且还不能卡在一个中断上太长时间,要不然能够想像咱们的计算机得“卡”成什么样子。
所以理论设计中硬中断只负责做一些简略的事件,而后接着触发软中断,比拟耗时且简单的工作就交给软中断处理程序去做了。
软中断以内核线程的形式运行,每个CPU都会对应一个软中断内核线程,名字叫做ksoftirqd/CPU编号
,比方 0
号 CPU 对应的软中断内核线程的名字是 ksoftirqd/0
,为了不便,咱们间接叫做ksoftirqd好了。
从这个角度上来说,操作系统就是一个死循环,在循环中一直接管各种中断,解决不同逻辑。
内核线程通过各个函数调用,最终会调用到就绪的socket期待队列项中的回调函数ep_poll_callback
,是时候看看这个函数了。
// file: /fs/eventpoll.c
static int ep_poll_callback(wait_queue_t *wait, unsigned mode, int sync, void *key)
{
...
// 获取期待队列项对应的epitem
struct epitem *epi = ep_item_from_wait(wait);
// 获取epitem对应的eventpoll实例
struct eventpoll *ep = epi->ep;
...
/* 如果以后epitem指向的socket曾经在就绪队列里了,那就间接退出
否则,将epitem增加到eventpoll的就绪队列rdllist中
If this file is already in the ready list we exit soon
*/
if (!ep_is_linked(&epi->rdllink)) {
list_add_tail(&epi->rdllink, &ep->rdllist);
}
// 查看eventpoll期待队列上是否有期待的过程
if (waitqueue_active(&ep->wq))
wake_up_locked(&ep->wq);
...
}
ep_poll_callback
的逻辑十分简洁清晰。
先找到就绪socket对应的期待队列项中的epitem
,继而找到对应的eventpoll
实例,再接口着判断以后的epitem
是不是曾经在rdllist
就绪队列里了,如果在,那就没啥好做的了,函数退出就行了;如果不在,那就把epitem
退出到rdllist
中。
最初看看eventpoll
的期待队列上是不是有阻塞的过程,有的话就调用5.3.4节中设置的default_wake_function
回调函数来唤醒这个过程。
epoll中重点介绍的两个回调函数,ep_poll_callback
和default_wake_function
就串起来了。前者调用了后者,后者唤醒了过程。epoll_wait
的最终使命就是将rdllist
中的就绪fd返回给用户过程。
5.4. epoll总结
咱们来梳理一下epoll的整个过程。
epoll_create
创立了eventpoll
实例,并对其中的就绪队列rdllist
、期待队列wq
以及红黑树rbr
进行了初始化;epoll_ctl
将咱们感兴趣的socket封装成epitem
对象退出红黑树,除此之外,还封装了socket的sk_wq
期待队列项,里边保留了socket就绪之后的函数回调,也就是ep_poll_callback
;epoll_wait
查看eventpoll
的就绪队列是不是有就绪的socket,有的话间接返回;否则就封装一个eventpoll
的期待队列项,里边保留了以后的用户过程信息以及另一个回调函数default_wake_function
,而后把以后过程投入睡眠;- 直到数据达到,内核线程找到就绪的socket,先调用
ep_poll_callback
,而后ep_poll_callback
又调用default_wake_function
,最终唤醒eventpoll
期待队列中保留的过程,解决rdllist
中的就绪fd; - epoll完结!
等下,还没完结!在5.3.2节中还留了一个小坑,我说:eventpoll文件
也能够被epoll自身监测,也就是说epoll实例能够监听其余的epoll实例,这一点很重要。
怎么个重要法,这就波及到eventpoll
实例中的另一个队列了,叫做poll_wait
。
struct eventpoll {
...
wait_queue_head_t wq;
/* 就是它!!!!!!! */
wait_queue_head_t poll_wait;
struct list_head rdllist;
struct rb_root rbr;
struct file *file;
};
如上图所示
epollfd1
监听了2个一般描述符fd1
和fd2
epollfd2
监听了epollfd1
和2个一般描述符fd3
、fd4
如果fd1
或fd2
有可读事件触发,那么就绪的fd的回调函数ep_poll_callback
对将该fd放到epollfd1
的rdllist
就绪队列中。因为epollfd1
自身也是个文件,它的可读事件此时也被触发,然而ep_poll_callback
怎么晓得该把epollfd1
放到谁的rdllist
中呢?
poll_wait
来喽~~
当epoll监听epoll类型的文件的时候,会把监听者放入被监听者的poll_wait
队列中,下面的例子就是epollfd1
的poll_wait
队列保留了epollfd2
,这样一来,当epollfd1
有可读事件触发,就能够在poll_wait
中找到epollfd2
,调用epollfd1
的ep_poll_callback
将epollfd1
放入epollfd2
的rdllist
中。
所以poll_wait
队列就是用来解决这种递归监听的状况的。
到此为止,多路复用彻底完结~~~