要理解 Epoll 内核级别的原理,进程的组成结构,Socket 的组成结构,Epoll 对象的组成结构以及三者之间的关联是必须要理解的。
1、进程结构
在 linux 中,进程结构大致分为几个模块:
- 进程模块:用于描述进程的数据结构,也被称为进程描述符或进程控制块(PCB)。它包含了进程所需的所有信息,如进程标识符(PID)、父进程标识符(PPID)、进程状态、优先级、地址空间、打开的文件描述符等
- 文件模块:用于描述进程打开文件的数据结构。每个进程都有一个唯一的 file_struct 结构体,用于记录该进程打开的所有文件及其文件描述符的使用情况
- 文件描述符模块:用于描述进程文件描述符表的数据结构。它包含了进程打开的文件描述符的具体信息,如文件描述符的数量、指向打开文件的指针等。以位图(bitmap)来管理文件描述符的使用情况,以高效地查找和分配空闲的文件描述符
可以这样理解进程与文件描述符之间的关系:
Linux 中一切皆文件,比如说当前进程对应的代码中创建了一个 Socket 对象,那么最终 socket 对象就会被记录在 fdtable 中,又比如说代码中创建了一个 file 对象,那这个 file 对象信息最终也会被记录在 fdtable 中
2、Socket 对象结构
socket 对象的创建整体分为四步:
- 初始化 socket 对象
- 为 socket 对象申请 file
- 接收连接(在这一步中会执行 tcp 的三次握手机制)
- 添加至进程的打开文件描述符中
2.1、初始化 socket 对象
Socket 对象分为三个部分:
- file 指针:指向的是 socket 的文件描述符,在初始化阶段并不会被赋值
- sk 指针:socket 的核心内核对象。发送队列、接收队列、等待队列等核心数据结构都位于此,在初始化阶段并不会被赋值
- ops 指针:指向的是 Socket 对象对应的协议栈操作函数,为固定的 4 个函数:accept,sendmsg,recvmsg,poll
初始化 socket 对象主要分为两个步骤:
- 申请一个内存空间且创建 socket 对象
- 赋值协议栈操作函数,固定为 4 个函数:accept,sendmsg,recvmsg,poll
2.2、申请 file 对象
申请 file 对象主要步骤:
- 创建文件描述符对象:Socket 对象中的 file 属性指向这个对象;文件描述符对象中的 data 指向 Socket 对象;进程的文件描述符模块存储这个对象的内存地址
- 初始化文件描述符对象:f_op 属性赋值为 Socket 文件操作函数
2.3、接收连接
接收连接过程比较复杂:
- 创建 Sock 对象,其中会初始化 sk_prot,sk_receive_queue 接收队列,发送队列 sk_write_queue,等待队列 sk_wq 等
- 执行三次握手机制
2.4、添加至进程打开文件列表中
加入到文件打开列表中其实就是在文件打开列表的 bitmap 中的某个元素中添加 文件描述符对象的内存地址。执行完这个步骤之后,那么通过进程就可以找到对应的 socket 对象了。
3、Epoll_create 创建 Epoll 对象
理解 Epoll 其实就是理解三个函数:
- epoll_create:创建 Epoll 对象,在 Linux 中会创建一个 eventPoll 对象
- epoll_ctl: ①、创建 epitem 对象;②、构建对象存入到 socket 等待队列;③、将 epitem 对象存入到红黑树
- epoll_wait:①、判断 rdllist 中是否存在 epitem 对象;②、不存在就构建等待对象(回调函数和用户进程)存入到 Epoll 对象的等待队列中;③、阻塞用户进程
EventPoll 对象数据结构:
三个属性含义:
- 等待队列:应用程序调用 selector.select 函数之后会被阻塞住,最终这个用户进程就会被放入到等待队列中
- 就绪描述符队列:对端发出事件之后,最终对端对应的 socket 对象会被存入到就绪描述符队列中
- 红黑树对象:来一个连接就把这个 socket 连接封装成 epitem 对象存入到红黑树中
4、Epoll_ctl 注册 socket
4.1、函数定义
int epoll_ctl(int epfd , int op , int fd , struct epoll_event * event )
-
epfd:Epoll 对象的文件描述是
-
op:具体的操作
EPOLL_CTL_ADD:在 epoll 的监视列表中添加一个文件描述符(即参数 fd),指定监视的事件类型(参数 event)。
EPOLL_CTL_MOD:修改监视列表中已经存在的描述符(即参数 fd),修改其监视的事件类型(参数 event)。
EPOLL_CTL_DEL:将某监视列表中已经存在的描述符(即参数 fd)删除,参数 event 传 NULL -
fd:需要添加,修改,删除的套接字。在 NIO 的例子中代表的就是对端的 socket 对象
-
event:Epoll 对象监听的事件信息,比如说连接,读取,写入等
4.2、Epoll_ctl 内核原理
epoll_ctl 主要分为三个步骤:
- 分配一个红黑树节点对象 epitem
- 添加一个对象到 socket 等待队列中,这个对象分为两个部分:①、回调函数:ep_poll_callback;②、base 指针:指向 epitem
- 将 epitem 插入到红黑树中
4.2.1、构建 epitem
4.2.2、构建对象加入等待队列
构建一个对象,这个对象包含两个部分:
- 回调函数:ep_poll_callback
- base 指针:指向 epitem 对象
构建好这个对象之后,存储到 socket 等待队列中
🧐🧐🧐 在这边存在一个非常重要的点,在很长的一段时间之内自己都搞不清楚。在 Java 程序中:
selector.select
底层会调用 epoll_ctl 函数。最终就会创建出一个 epitem 对象,最终会被存入到 socket 的等待队列和红黑树中。假设现在还是相同的 socket 和 Epoll 对象,只是关注的事件不同:selector.select
底层调用 epoll_ctl 函数的时候,是会新创建出一个 epitem 对象并且存入到 socket 等待队列和红黑树中的,即只要关注的事件不同,那么就是一个新的 epitem 对象。
4.2.3、插入红黑树
将创建出来的 epitem 对象存入到红黑树中
5、Epoll_wait
epoll_wait 内核原理:
- 判断 rdllist 中是否存在需要处理的事件信息,存在就唤醒用户线程并且执行;若不存在则执行第二步
- 构建等待对象(由回调函数:default_wake_function 和用户进程组成)
- 将等待对象加入到 Epoll 的等待队列中
- 阻塞用户线程
执行完 Epoll_wait 函数之后,epoll 对象具体信息为:
6、数据来临
在描述下面理论之前需先知道一个点:数据过来肯定是通过 socket 发送过来的,Linux 内核能够知道两个点:
- 对应的 socket 对象
- 事件类型,比如说现在过来的是连接事件,读取事件还是写入事件等
最终会进入到 tcp 协议栈的入口函数 tcp_v4_rcv,这个函数会去 socket 对象的等待队列中找到对应事件的等待对象,然后调用起 fun 指针指向的回调函数:ep_poll_callback。这个函数存在两个步骤:
- 把当前等待对象的 base 指针对应的 epitem 对象存储到 Epoll 对象的就绪队列(rdllist)中
- 调用 epoll 对象等待队列中等待对象的 fun 指向的回调函数:default_wake_function 去唤醒等待对象 private 指针指向的用户线程
用户线程被唤醒之后就是程序员自己的事情了,可以按照事件类型执行不同的逻辑。
7、整体例子
-
服务端启动,执行
Selector.open()
底层会调用 epoll_create 函数去创建 Event_Poll 对象,Epoll 对象结构如下图所示
-
Java 程序继续执行:
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT)
这个函数底层是不会调用 epoll_ctl 函数的,最终会把事件信息存储在 EpollArrayWrapper 的成员变量 eventsLow 和 eventsHigh 中,Channel 信息存储在 pollWrapper 数组中,在调用 epoll_ctl 函数的时候会从这些数据结构中获取出事件信息和 socket 信息(通过 Channel 能得到 socket 对象)进行操作 -
接下去执行 select 函数,底层会调用 epoll_ctl 函数和 epoll_wait 函数,epoll_ctl 底层会执行三个步骤:
- 分配一个红黑树节点对象 epitem
- 添加一个对象到 socket 等待队列中,这个对象分为两个部分:①、回调函数:ep_poll_callback;②、base 指针:指向 epitem
- 将 epitem 插入到红黑树中
执行完这个函数之后,结构图为:
-
继续执行 epoll_wait 函数,这个函数主要的过程为:
- 判断 rdllist 中是否存在需要处理的事件信息,存在就唤醒用户线程并且执行;若不存在则执行第二步
- 构建等待对象(由回调函数:default_wake_function 和服务器端主线程组成)
- 将等待对象加入到 Epoll 的等待队列中
- 阻塞用户线程,最终服务器端的主线程会被阻塞
执行完成之后,内存结构图为:
-
一段时间之后,客户端执行了
socketChannel.connect(new InetSocketAddress(host, port))
函数,最终内核会调用到 sock_def_readable 函数,在这个函数中会去对应的 socket 对象的等待队列中找 连接事件 对应的等待队列对象,然后调用这个等待队列对象的回调函数:ep_poll_callback,在这个函数中会去把 base 指针指向的 epitem 对象存入到 Epoll 对象的就绪队列中并且执行 Epoll 对象等待对象中的回调函数:default_wake_function,这个函数会去唤醒 private 指针对应的用户进程,即服务器端的主线程并且将 epitem 对象返回给主线程
-
虽然返回给主线程的是 epitem 对象,但最终会被数据结构转换,最终服务器端主线程得到的是 SelectionKey 对象,此时就能根据类型做不同的逻辑,在当前的 epitem 中 event 属性是连接事件,那么就取出这个事件类型,然后做不同的逻辑。
欢迎来到这里!
我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。
注册 关于