3、NIO 内核版实现原理

要理解 Epoll 内核级别的原理,进程的组成结构,Socket 的组成结构,Epoll 对象的组成结构以及三者之间的关联是必须要理解的。

1、进程结构

在 linux 中,进程结构大致分为几个模块:

  1. 进程模块:用于描述进程的数据结构,也被称为进程描述符或进程控制块(PCB)。它包含了进程所需的所有信息,如进程标识符(PID)、父进程标识符(PPID)、进程状态、优先级、地址空间、打开的文件描述符等
  2. 文件模块:用于描述进程打开文件的数据结构。每个进程都有一个唯一的 file_struct 结构体,用于记录该进程打开的所有文件及其文件描述符的使用情况
  3. 文件描述符模块:用于描述进程文件描述符表的数据结构。它包含了进程打开的文件描述符的具体信息,如文件描述符的数量、指向打开文件的指针等。以位图(bitmap)来管理文件描述符的使用情况,以高效地查找和分配空闲的文件描述符

可以这样理解进程与文件描述符之间的关系:

Linux 中一切皆文件,比如说当前进程对应的代码中创建了一个 Socket 对象,那么最终 socket 对象就会被记录在 fdtable 中,又比如说代码中创建了一个 file 对象,那这个 file 对象信息最终也会被记录在 fdtable 中

2、Socket 对象结构

socket 对象的创建整体分为四步:

  1. 初始化 socket 对象
  2. 为 socket 对象申请 file
  3. 接收连接(在这一步中会执行 tcp 的三次握手机制)
  4. 添加至进程的打开文件描述符中

2.1、初始化 socket 对象

Socket 对象分为三个部分:

  • file 指针:指向的是 socket 的文件描述符,在初始化阶段并不会被赋值
  • sk 指针:socket 的核心内核对象。发送队列、接收队列、等待队列等核心数据结构都位于此,在初始化阶段并不会被赋值
  • ops 指针:指向的是 Socket 对象对应的协议栈操作函数,为固定的 4 个函数:accept,sendmsg,recvmsg,poll

初始化 socket 对象主要分为两个步骤:

  1. 申请一个内存空间且创建 socket 对象
  2. 赋值协议栈操作函数,固定为 4 个函数:accept,sendmsg,recvmsg,poll

2.2、申请 file 对象

申请 file 对象主要步骤:

  1. 创建文件描述符对象:Socket 对象中的 file 属性指向这个对象;文件描述符对象中的 data 指向 Socket 对象;进程的文件描述符模块存储这个对象的内存地址
  2. 初始化文件描述符对象:f_op 属性赋值为 Socket 文件操作函数

2.3、接收连接

接收连接过程比较复杂:

  1. 创建 Sock 对象,其中会初始化 sk_prot,sk_receive_queue 接收队列,发送队列 sk_write_queue,等待队列 sk_wq
  2. 执行三次握手机制

2.4、添加至进程打开文件列表中

加入到文件打开列表中其实就是在文件打开列表的 bitmap 中的某个元素中添加 文件描述符对象的内存地址。执行完这个步骤之后,那么通过进程就可以找到对应的 socket 对象了。

3、Epoll_create 创建 Epoll 对象

理解 Epoll 其实就是理解三个函数:

  1. epoll_create:创建 Epoll 对象,在 Linux 中会创建一个 eventPoll 对象
  2. epoll_ctl: ①、创建 epitem 对象;②、构建对象存入到 socket 等待队列;③、将 epitem 对象存入到红黑树
  3. 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 程序中:

serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT) ​ 底层会调用 epoll_ctl 函数。最终就会创建出一个 epitem 对象,最终会被存入到 socket 的等待队列和红黑树中。假设现在还是相同的 socket 和 Epoll 对象,只是关注的事件不同:serverSocketChannel.register(selector, SelectionKey.OP_READ) ​ 底层调用 epoll_ctl 函数的时候,是会新创建出一个 epitem 对象并且存入到 socket 等待队列和红黑树中的,即只要关注的事件不同,那么就是一个新的 epitem 对象。

4.2.3、插入红黑树

将创建出来的 epitem 对象存入到红黑树中

5、Epoll_wait

epoll_wait 内核原理:

  1. 判断 rdllist 中是否存在需要处理的事件信息,存在就唤醒用户线程并且执行;若不存在则执行第二步
  2. 构建等待对象(由回调函数:default_wake_function 和用户进程组成)
  3. 将等待对象加入到 Epoll 的等待队列中
  4. 阻塞用户线程

执行完 Epoll_wait 函数之后,epoll 对象具体信息为:

6、数据来临

在描述下面理论之前需先知道一个点:数据过来肯定是通过 socket 发送过来的,Linux 内核能够知道两个点:

  1. 对应的 socket 对象
  2. 事件类型,比如说现在过来的是连接事件,读取事件还是写入事件等

最终会进入到 tcp 协议栈的入口函数 tcp_v4_rcv,这个函数会去 socket 对象的等待队列中找到对应事件的等待对象,然后调用起 fun 指针指向的回调函数:ep_poll_callback。这个函数存在两个步骤:

  1. 把当前等待对象的 base 指针对应的 epitem 对象存储到 Epoll 对象的就绪队列(rdllist)中
  2. 调用 epoll 对象等待队列中等待对象的 fun 指向的回调函数:default_wake_function 去唤醒等待对象 private 指针指向的用户线程

用户线程被唤醒之后就是程序员自己的事情了,可以按照事件类型执行不同的逻辑。

7、整体例子

  1. 服务端启动,执行 Selector.open()​ 底层会调用 epoll_create 函数去创建 Event_Poll 对象,Epoll 对象结构如下图所示

  2. Java 程序继续执行:serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT)​ 这个函数底层是不会调用 epoll_ctl 函数的,最终会把事件信息存储在 EpollArrayWrapper 的成员变量 eventsLow 和 eventsHigh 中,Channel 信息存储在 pollWrapper 数组中,在调用 epoll_ctl 函数的时候会从这些数据结构中获取出事件信息和 socket 信息(通过 Channel 能得到 socket 对象)进行操作

  3. 接下去执行 select 函数,底层会调用 epoll_ctl 函数和 epoll_wait 函数,epoll_ctl 底层会执行三个步骤:

    • 分配一个红黑树节点对象 epitem
    • 添加一个对象到 socket 等待队列中,这个对象分为两个部分:①、回调函数:ep_poll_callback;②、base 指针:指向 epitem
    • 将 epitem 插入到红黑树中

    执行完这个函数之后,结构图为:

  4. 继续执行 epoll_wait 函数,这个函数主要的过程为:

    1. 判断 rdllist 中是否存在需要处理的事件信息,存在就唤醒用户线程并且执行;若不存在则执行第二步
    2. 构建等待对象(由回调函数:default_wake_function 和服务器端主线程组成)
    3. 将等待对象加入到 Epoll 的等待队列中
    4. 阻塞用户线程,最终服务器端的主线程会被阻塞

    执行完成之后,内存结构图为:

  5. 一段时间之后,客户端执行了 socketChannel.connect(new InetSocketAddress(host, port))​ 函数,最终内核会调用到 sock_def_readable 函数,在这个函数中会去对应的 socket 对象的等待队列中找 连接事件 对应的等待队列对象,然后调用这个等待队列对象的回调函数:ep_poll_callback,在这个函数中会去把 base 指针指向的 epitem 对象存入到 Epoll 对象的就绪队列中并且执行 Epoll 对象等待对象中的回调函数:default_wake_function,这个函数会去唤醒 private 指针对应的用户进程,即服务器端的主线程并且将 epitem 对象返回给主线程

  6. 虽然返回给主线程的是 epitem 对象,但最终会被数据结构转换,最终服务器端主线程得到的是 selectionKey 对象,此时就能根据类型做不同的逻辑,在当前的 epitem 中 event 属性是连接事件,那么就取出这个事件类型,然后做不同的逻辑。

  • Linux

    Linux 是一套免费使用和自由传播的类 Unix 操作系统,是一个基于 POSIX 和 Unix 的多用户、多任务、支持多线程和多 CPU 的操作系统。它能运行主要的 Unix 工具软件、应用程序和网络协议,并支持 32 位和 64 位硬件。Linux 继承了 Unix 以网络为核心的设计思想,是一个性能稳定的多用户网络操作系统。

    929 引用 • 937 回帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...