1. 前言

在学习Reactor网络模型之前,需要先学习一些基础知识,下面对这些 知识做一些简单总结。

2. 基本概念

2.1 用户空间和内核空间

现在操作系统都是采用虚拟存储器,那么对32位操作系统而言,它的寻址空间(虚拟存储空间)为4G(2的32次方)。操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操心系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。针对linux操作系统而言,将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,称为内核空间,而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF),供各个进程使用,称为用户空间。

2.2 进程切换

为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。

从一个进程的运行转到另一个进程上运行,这个过程中经过下面这些变化:

  1. 保存CPU上下文环境,包括程序计数器和其他寄存器
  2. 更新PCB信息
  3. 把进程的PCB移入相应的队列,如就绪、在某事件阻塞等队列
  4. 选择另一个进程执行,并更新其PCB
  5. 更新内存管理的数据结构
  6. 恢复CPU上下文环境

2.3 进程阻塞

正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得CPU),才可能将其转为阻塞状态。当进程进入阻塞状态,是不占用CPU资源的

2.4 文件描述符

文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。

文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。

2.5 缓存I/O

缓存 I/O 又被称作标准 I/O,大多数文件系统的默认 I/O 操作都是缓存 I/O。在 Linux 的缓存 I/O 机制中,操作系统会将 I/O 的数据缓存在文件系统的页缓存( page cache)中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。

缓存 I/O 的缺点:

数据在传输过程中需要用户空间和内核空间之间进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。

2.6 同步和异步

  • 同步:在发起一个同步调用后,调用者必须等被调用者处理完成返回结果,否则就会一直等待,无法进行下一步操作。
  • 异步:当发起一个异步调用后,调用者不能立刻得到结果。当被调用者处理完成后,通过状态、通知和回调机制来通知调用者。

2.7 阻塞和非阻塞

  • 阻塞:调用结果返回之前,当前线程会被挂起(线程进入非可执行状态,在这状态下,cpu不会给线程分配时间片,即线程暂停运行)。函数只有在得到结果之后才会返回
  • 非阻塞:不能立刻得到结果之前,该函数不会阻塞当前线程,而会立刻返回

2.8 阻塞和同步

  • 阻塞:该线程会被挂起,让出cpu的时间片,等待后续被唤醒
  • 同步:线程仍然在循行,没有被挂起,只是在被调用者返回结果之前,不能够继续向下执行

3. I/O模型

3.1 同步阻塞IO

reactor.drawio.svg

  • 同步阻塞IO模型是最简单的IO模型,用户线程在内核进行IO操作时被阻塞
  • 用户线程通过recvfrom发起系统调用进行IO读操作,由用户空间切换到内核空间。当内核空间的数据报准备好时,CPU需要将接收的数据拷贝到用户空间,完成recvfrom操作
  • 用户需要等待recvfrom调用结束后,才继续处理接收的数据。整个IO请求的过程中,用户线程是被阻塞的,这导致用户在发起IO请求时,不能够做任何事情,对CPU的资源利用率比较低

3.2 同步非阻塞IO

reactor-第 2 页.drawio.svg

  • 当某些套接字函数操作不能立即完成时,会出现错误码EWOULDBLOCK
  • 用户线程发起IO请求时立即返回。但并未读取到任何数据,用户线程需要不断地发起IO请求,直到数据到达后,才真正读取到数据,继续执行。即“轮询”机制
  • 整个IO请求的过程中,虽然用户线程每次发起IO请求后可以立即返回,但是为了等到数据,仍需要不断地轮询、重复请求,消耗了大量的CPU的资源
  • 比较浪费CPU,一般很少直接使用这种模型,而是在其他IO模型中使用非阻塞IO这一特性

3.3 多路复用IO

reactor-第 3 页.drawio.svg

  • IO多路复用是指内核一旦发现进程指定的一个或者多个IO设备准备就绪,就通知该进程
  • 多个连接共用一个等待机制,本模型会阻塞进程,但是进程是阻塞在select系统调用上,而不是阻塞在真正的IO操作上
  • 从流程上来看,使用select函数进行IO请求和同步阻塞模型没有太大的区别,甚至还多了添加监视IO,以及调用select函数的额外操作,效率更差。并且阻塞了两次,但是第一次阻塞在select上时,select可以监控多个IO上是否已有IO操作准备就绪,即可达到在同一个线程内同时处理多个IO请求的目的。而不像阻塞IO,一次只能监控一个IO
  • select 只是一种多路复用IO的实现方式,select以及另外几种实现方式在下文介绍

3.4 信号驱动IO

reactor-第 4 页.drawio.svg

  • 用户进程可以通过sigaction系统调用注册一个信号处理程序,然后主程序可以继续向下执行,当有IO操作准备就绪时,由内核通知触发一个SIGIO信号处理程序执行,然后将用户进程所需要的数据从内核空间拷贝到用户空间
  • 此模型的优势在于等待数据报到达期间进程不被阻塞。用户主程序可以继续执行,只要等待来自信号处理函数的通知
  • 并不是异步IO,因为在将数据从内核空间复制到用户空间的过程中,进程是阻塞的

3.5 异步IO

reactor-第 5 页.drawio.svg

  • 异步IO与信号驱动IO最主要的区别是信号驱动IO是由内核通知何时可以进行IO操作,而异步IO则是由内核告诉用户线程IO操作何时完成。信号驱动IO当内核通知触发信号处理程序时,信号处理程序还需要阻塞在从内核空间缓冲区复制数据到用户空间缓冲区这个阶段,而异步IO直接是在第二个阶段完成后,内核直接通知用户线程可以进行后续操作了
  • 相比于IO多路复用模型,异步IO并不常用,因为目前操作系统对异步IO的支持并不完善,不少高性能并发服务程序使用IO多路复用模型+多线程任务处理的架构基本可以满足需求。

4 多路复用IO的实现方式

select,poll,epoll,kqueue 都是IO多路复用的机制(前提是该驱动设备支持poll操作)。I/O多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select,poll,epoll,kqueue 本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O会在负责把数据从内核拷贝到用户空间之后,再通知用户注册的回调程序进行处理。

4.1 select

select 函数监视的文件描述符分3类,分别是writefds、readfds、和exceptfds。调用后select函数会阻塞,直到有描述副就绪(有数据 可读、可写、或者有异常发生),或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。当select函数返回后,可以 通过遍历fdset,来找到就绪的描述符。

select目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点。select的一 个缺点在于单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024,可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但 是这样也会造成效率的降低。

4.1.1 API介绍

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
函数原型:
int select(int maxfdp, fd_set *readfds, fd_set *writefds, fd_set *errorfds, struct timeval *timeout);
参数说明:
maxfdp:集合中所有文件描述符的范围,需设置为所有文件描述符中的最大值加1
readfds:要进行监听的是否可以读文件的文件描述符集合。
writefds:要进行监听的是否可以写文件的文件描述符集合。
errorfds:要进行监听的是否发生异常的文件描述符集合。
timeval:select的超时时间,它可以使select处于三种状态:
1、若将NULL以形参传入,即不传入时间结构,就是将select至于阻塞状态,一定要等到监视的文件描述符集合中某个文件描述符发生变化为止。
2、若将时间值设为00毫秒,就变成一个纯粹的非阻塞函数,不管文件描述符是否发生变化,都立刻返回继续执行,文件无变化返回0,有变化返回一个正值。
3、timeout的值大于0,这就是等待的超时时间,即select在timeout时间内阻塞,超时时间之内有事件到来就返回,否则在超时后不管怎样一定返回。
返回值:
>0:表示被监视的文件描述符有变化。
-1:表示select出错。
0:表示超时。

select函数的使用还涉及到一些对fd_set操作的函数,这里说明如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fd_set:可以理解为一个集合,这个集合中存放的时文件描述符fd。

FD_ZERO:
用法:FD_ZERO(fd_set *);
作用:用来清空fd_set集合,即让fd_set集合不再包含任何文件句柄。
FD_SET:
用法:FD_SET(int, fd_set *);
作用:用来将一个给定的文件描述符加入集合之中。
FD_CLR:
用法:FD_CLR(int, fd_set *);
作用:用来将一个给定的文件描述符从集合中删除。
FD_ISSET:
用法:FD_ISSET(int, fd_set *);
作用:检测fd在fdset集合中的状态是否发生变化,当检测到fd状态变化时返回真,否则,返回假(也可以认为集合中指定的文件描述符是否可以读写)。

4.1.2 示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
import (
"fmt"
"golang.org/x/sys/unix"
"greactor/src/socket"
"testing"
)

const MAX_CONNECT_NUM = 1024

func TestSelect(t *testing.T) {
listenerBacklogMaxSize := 128
lfd := -1
var err error
// 先创建服务器端的套接字
if lfd, err = unix.Socket(unix.AF_INET, unix.SOCK_STREAM, unix.IPPROTO_TCP); lfd == -1 || err != nil {
t.Log("套接字创建失败!,", err)
t.Fail()
return
}

sa := &unix.SockaddrInet4{Port: 7777}
t.Log("lfd,", lfd)
if err := unix.Bind(lfd, sa); err != nil {
t.Log("端口绑定失败!,", err)
t.Fail()
return
}

/**
listenerBacklogMaxSize: 该参数指定的是完成队列的最大长度
在TCP建立连接阶段,内核维护着两个队列:
未完成队列: 这是客户端发送SYN过来,服务器回应SYN+ACK之后,服务器当前处于SYN_RECV状态,此时的连接在未完成队列中。
完成队列: 客户端回应ACK之后,两边都处于ESTABLISHED状态,此时连接从未完成队列移到完成队列中,服务器调用accept,就从完成队列移除并返回给程序。
假如指定了一个很小的backlog,比如1,那么完成队列很容易就满,满了以后客户端连接进来会怎么样呢?
从上面可知,客户端connect还是成功返回,但是服务器这个连接进不了完成队列,一段时间后被内核释放了,服务器就没有办法通过accept得到连接。
*/
if err := unix.Listen(lfd, listenerBacklogMaxSize); err != nil {
t.Log("服务器监听失败!,", err)
t.Fail()
return
}

fdSet := &unix.FdSet{}
clients := make([]int, 0)
// 用来读写文件的缓冲区
bs := make([]byte, 4096)
maxfd := lfd
for ; ; {
fdSet.Zero()
fdSet.Set(lfd)
for _, fd := range clients {
fdSet.Set(fd)
}
// 设置30s的过期时间,也可以不设置,为nil则代表永不过期
timeout := &unix.Timeval{Sec: 30}
n, err := unix.Select(maxfd+1, fdSet, nil, nil, timeout)
if err != nil {
t.Log("select失败!, ", err)
t.Fail()
break
}
t.Log("select被唤醒")
// 没有准备就绪的fd
if n <= 0 {
continue
}

for index, cfd := range clients {
if fdSet.IsSet(cfd) {

num, err := unix.Read(cfd, bs)
// 说明客户端已经断开连接
if num <= 0 || err != nil {
// 在客户端集合中移除该客户端的fd
clients = append(clients[:index], clients[index+1:]...)
// 关闭fd回收资源
unix.Close(cfd)
continue
}

data := bs[:num]
// 给客户端回复
unix.Write(cfd, []byte("server response, "+string(data)))
}
}

// 如果服务器的监听fd有读事件就绪说明有新的连接到来
if fdSet.IsSet(lfd) {
newFd, csa, err := unix.Accept(lfd)
caddr := socket.SockaddrToTCPOrUnixAddr(csa)
if newFd == -1 || err != nil {
t.Log("建立新连接失败!, ", err)
continue
}

if newFd > maxfd {
maxfd = newFd
}
if len(clients) < MAX_CONNECT_NUM {
clients = append(clients, newFd)
t.Log("新连接建立成功,客户端地址:{}", caddr.String())
unix.Write(newFd, []byte("新连接建立成功"))
} else {
t.Log("连接数量已经达到上限,连接建立失败")
}
}
}

defer func() {
//程序结束后,需要回收资源
for _, fd := range clients {
unix.Close(fd)
}
unix.Close(lfd)
}()
}

4.1.3 实现原理

select的睡眠过程

支持阻塞操作的设备驱动通常会实现一组自身的等待队列如读/写等待队列用于支持上层(用户层)所需的BLOCK或NONBLOCK操作。当应用程序通过设备驱动访问该设备时(默认为BLOCK操作),若该设备当前没有数据可读或写,则将该用户进程插入到该设备驱动对应的读/写等待队列让其睡眠一段时间,等到有数据可读/写时再将该进程唤醒。

select就是巧妙的利用等待队列机制让用户进程适当在没有资源可读/写时睡眠,有资源可读/写时唤醒。下面我们看看select睡眠的详细过程。

select会循环遍历它所监测的fd_set内的所有文件描述符对应的驱动程序的poll函数。驱动程序提供的poll函数首先会将调用select的用户进程插入到该设备驱动对应资源的等待队列(如读/写等待队列),然后返回一个bitmask告诉select当前资源哪些可用。当select循环遍历完所有fd_set内指定的文件描述符对应的poll函数后,如果没有一个资源可用(即没有一个文件可供操作),则select让该进程睡眠,一直等到有资源可用为止,进程被唤醒(或者timeout)继续往下执行。

select的唤醒过程

前面提到select会循环遍历它所监测的fd_set内的所有文件描述符对应的驱动程序的poll函数。驱动程序提供的poll函数首先会将调用select的用户进程插入到该设备驱动对应资源的等待队列(如读/写等待队列),然后返回一个bitmask告诉select当前资源哪些可用。

唤醒该进程的过程通常是在所监测文件的设备驱动内实现的,驱动程序维护了针对自身资源读写的等待队列。当设备驱动发现自身资源变为可读写并且有进程睡眠在该资源的等待队列上时,就会唤醒这个资源等待队列上的进程。

由于select进程是阻塞在所有监测的文件对应的设备等待队列上的,因此在timeout时间内,只要任意个设备变为可操作,都会立即唤醒该进程,从而继续往下执行。

4.1.4 优点

  • 遵循posix标准,拥有良好的跨平台移植性
  • 超时时间可以精细到微秒,poll函数只能精确到毫秒

4.1.5 缺点

  • select所你能监控的描述符有最大数量上限,上限取决于__FD_SETSIZE默认等于1024
  • select需要每次将集合数据从用户态拷贝到内核态进行监控
  • select在内核中使用轮询遍历进行监控,监控性能随着描述符增多而降低
  • select不会直接返回给用户就绪的描述符,而是返回的一个就绪集合,需要用户自己遍历判断才能找出就绪的描述符,效率较低,增加了代码复杂度
  • select每次调用返回时,都会修改集合,因此每次监控前都需要重新向集合中添加描述符

4.2 poll

与select类似,poll也是在指定时间内轮询一定数量的文件描述符,来获取是否有可读写的事件发生,poll提供了一个易用的方法来实现I/O复用。与select不同的是poll没有监听文件描述符的大小限制,但是由于poll的轮询机制,当监听的文件描述符数量增加时,性能损耗也会越大。

4.2.1 API介绍

1
2
3
4
5
6
7
8
9
10
struct pollfd {
int fd; // 文件描述符
short events; // 告诉poll监听fd上哪些事件
short revents; // 由内核修改,来通知应用程序fd上实际发生了哪些事件
};
函数原型:
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
参数说明
nfds: 为监听事件集合fds的大小
timeout: poll的超时时间,单位毫秒。timeout 为-1时,poll永远阻塞,直到有事件发生。timeout为0时,poll立即返回

4.2.2 示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
import (
"golang.org/x/sys/unix"
"greactor/src/socket"
"testing"
)

func TestPoll(t *testing.T) {

/*
端口的绑定过程与select一致,不再重复
*/

for ; ; {
// 设置30s的过期时间,也可以不设置,为-1代表永不过期,单位是ms
n, err := unix.Poll(fds, 30000)
if err != nil {
t.Log("poll失败!, ", err)
t.Fail()
break
}
t.Log("poll被唤醒")
// 没有准备就绪的fd
if n <= 0 {
t.Log("没有准备好的事件")
continue
}

for index, f := range fds {
// 这个例子中只关注读就绪事件
if (f.Revents & unix.POLLIN) == 0 {
continue
}
// 如果是服务器fd有读事件就绪说明有新连接到达
if f.Fd == int32(lfd) {
newFd, csa, err := unix.Accept(lfd)
caddr := socket.SockaddrToTCPOrUnixAddr(csa)
if newFd == -1 || err != nil {
t.Log("建立新连接失败!, ", err)
continue
}
fds = append(fds, unix.PollFd{Fd: int32(newFd), Events: unix.POLLIN})
t.Log("新连接建立成功,客户端地址:{}", caddr.String())
unix.Write(newFd, []byte("新连接建立成功"))
} else {
num, err := unix.Read(int(f.Fd), bs)
// 说明客户端已经断开连接
if num <= 0 || err != nil {
// 在客户端集合中移除该客户端的fd
fds = append(fds[:index], fds[index+1:]...)
// 关闭fd回收资源
unix.Close(int(f.Fd))
continue
}

data := bs[:num]
// 给客户端回复
unix.Write(int(f.Fd), []byte("server response, "+string(data)))
}
}
}

defer func() {
//程序结束后,需要回收资源
for _, f := range fds {
unix.Close(int(f.Fd))
}
unix.Close(lfd)
}()
}

4.3 epoll

epoll是一个更加高级的操作,上述的select或者poll操作都需要轮询所有的等待IO事件就绪的FD,逐一判断FD中是否有IO事件就绪,时间复杂度为O(n)。epoll给出了一个新的模式,直接申请一个的文件,对这些进行统一的管理,初步具有了面向对象的思维模式。

select()、poll()模型都是水平触发模式,epoll()模型即支持水平触发,也支持边缘触发,默认是水平触发。

  • LT(level triggered 水平触发)是默认的工作方式,并且同时支持block和no-block socket.在这种做法中,内核告诉你一个FD是否就绪了,然后你可以对这个就绪的FD进行IO操作。如果你不作任何操作,内核还是会继续通知你的,所以,这种模式编程出错误可能性要小一点。传统的select/poll都是这种模型的代表。
  • ET (edge-triggered 边缘触发)是高速工作方式,只支持non-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道FD已经就绪,并且不会再为那个FD发送更多的就绪通知。也就说对于每个就绪的FD,内核只会通知调用者1次,因为通知的次数会比水平触发的模式少,因此性能也更好一些。

4.3.1 API介绍

1
2
3
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的句柄,size用来告诉内核这个监听的数目一共有多大。这个参数不同于select()中的第一个参数,给出最大监听的fd+1的值。在最初的实现中,调用者通过 size 参数告知内核需要监听的文件描述符数量。如果监听的文件描述符数量超过 size, 则内核会自动扩容。而现在 size 已经没有这种语义了,但是调用者调用时 size 依然必须大于 0,以保证后向兼容性。需要注意的是,当创建好epoll句柄后,它就是会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的。

epoll_ctl

向 epfd 对应的内核epoll 实例添加、修改或删除对 fd 上事件 event 的监听。op 可以为 EPOLL_CTL_ADD, EPOLL_CTL_MOD, EPOLL_CTL_DEL 分别对应的是添加新的事件,修改文件描述符上监听的事件类型,从实例上删除一个事件。如果 event 的 events 属性设置了 EPOLLET flag,那么监听该事件的方式是边缘触发。

events可以是以下几个掩码的集合

  • EPOLLIN:触发该事件,表示对应的文件描述符上有可读数据。(包括对端SOCKET正常关闭);
  • EPOLLOUT:触发该事件,表示对应的文件描述符上可以写数据;
  • EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
  • EPOLLERR:表示对应的文件描述符发生错误;
  • EPOLLHUP:表示对应的文件描述符被挂断;
  • EPOLLET:将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
  • EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里。
与select/poll的区别:并不是调用epoll_wait的时候才监听文件,而是EPOLL_CTL_ADD的时候就开始监听了

epoll_wait

Linux-2.6.19又引入了可以屏蔽指定信号的epoll_wait: epoll_wait接收发生在被监听的FD上的,用户感兴趣的IO事件。简单点说:通过循环,不断地监听暴露的端口,看哪一个FD可读、可写。当 timeout 为 0 时,epoll_wait 永远会立即返回。而 timeout 为 -1 时,epoll_wait 会一直阻塞直到任一已注册的事件变为就绪。当 timeout 为一正整数时,epoll 会阻塞直到计时结束或已注册的事件变为就绪。因为内核调度延迟,阻塞的时间可能会略微超过 timeout (毫秒级)。epoll文件描述符用完后,直接用close关闭,并且会自动从被侦听的文件描述符集合中删除。

4.3.2 实现原理

调用epoll_create1/epoll_create

eventpoll内部有以下关键数据结构:

  • rbtree:红黑树,每个被加入到epoll监控的文件事件会创建一个epitem结构,作为rbtree节点。使用rbtree的优点:可容纳大量文件事件,方便增删改(O(lgN))
  • rdlist:内核链表,用于存放当前产生了期待事件产生的文件句柄们(这里的一个文件句柄可以理解为一个epoll_event)
  • wq:当进程调用epoll_wait等待时,进程加入等待队列wq
  • poll_wait:eventpoll本身的等待队列,由于eventpoll自己也被当做文件,这个队列用于自己被别人调用select/poll/epoll监听的情况(一般没啥用)

调用epoll_ctl操作句柄新增监控事件流程

  1. 对要注册的事件event->events追加关心事件:EPOLLERR | EPOLLHUP (EPOLLERR、EPOLLHUP事件会被自动监听,即使用户没设置)
  2. 创建epitem结构,加入到红黑树中
  3. 即调用poll,把当前进程放到文件的等待队列上且设置回调函数ep_poll_callback,返回值revent是文件当前已产生事件掩码
  4. 检查返回事件:如果revent与关心事件event->events有交集(说明ADD之前事件就准备好了),则开始如下操作(与回调函数的工作流程一致)
1.把此epitem节点拷贝到rdlist链表中
2.如果有进程在wq等待队列上(即有进程在调用epoll_wait等待),则唤醒
3.如果有进程在poll_wait等待队列上(即有进程调用多路复用来监听当前epoll句柄),则唤醒

调用epoll_wait等待事件

epoll_wait并不监听文件句柄,而是等待rdlist不空 or 收到信号 or 超时这三种条件后返回

主要逻辑

  1. 让调用的进程阻塞,直到rlist有数据,超时,或收到信号
  2. 如果rdlist有数据,则拷贝到用户传入的events数组

拷贝到用户空间这个环节看边缘触发与水平触发的区别

拷贝句柄函数ep_send_events会先遍历rdlist中每个句柄,对于每个句柄,再次调用poll获取实际事件:

  • 如果与关心事件有交集: 如果句柄是水平触发(EPOLLLT),则再次把句柄加入到rdlist;否则从rdlist中删除
  • 如果与关心事件无交集,则直接从rdlist中删除

//todo 画整个epoll的流程图,稍后补充