公开课 redis4 --- 从NIO到BIO 到 多路复用 到epoll的发展历程

2022-05-09 16:34:35 浏览数 (1)

接着昨天的继续学习: 里面会用到昨天学过的东西

博客连接如下: https://cloud.tencent.com/developer/article/1997421

今天的目标, 学epoll

为什么学epoll, 用redis举例. epoll是所有模型中, 占用内核空间最小的, 执行速度最快的.

redis用了epoll, nginx也是用了epoll


 一. 早期的socket模型--NIO--一连接一线程的多线程模型

什么是socket? 

计算有内核, 内核上面有app, app有server服务端和client客户端. 

进行socket通讯,server端首先会有一个端口号, 然后绑定端口号, 然后监听端口号

看看ooxx中打印的启动redis的时候的进程消息, 搜索socket, 可以看到有绑定端口号6379, 监听端口号6379

 我们可以用man命令来看一下socket的文档

代码语言:javascript复制
man 2 socket

这个命令的含义时,

代码语言:javascript复制
man: 查询linxu的文档, 
2: 查询2类文档
socket: 查询的内容

 1. 操作系统内核提供了一个socket

  1. 类似于java中提供的一个方法 in socket(int domain, int type, int protocol)

这个socket会做一件什么事呢?

 他有一个返回值, 返回一个file descriptor文件描述符, 简称fd, 其实就是一个数值

拿着这个数值可以做什么事?

 比如: accept, bind, listen等

接下来我们来看bind的example. 

我们是怎么创建socket连接的呢?

第一步: 创建一个socket  (sfd=socket(AF_UNIX, SOCK_STREAM,0)) ,返回一个文件描述符

第二步:  拿到文件描述符fd, 去绑定, 然后监听.

第三步: 执行accept等待, 等待看看有没有进入到这个fd的东西, 然后得到一个客户端的文件描述符cfd.  

开看看redis是如何建立socket连接的

 首先, 程序开始没多久, 就建了了一个socket连接, 返回文件描述符6

第二: 给文件描述符6绑定端口号

第三: 监听文件描述符6

 比如,现在有一个客户端, 要建立socket连接, 按照上面的步骤

  1. 客户端发起tcp请求连接. 
  2. 建立socket连接, 返回一个fd文件描述符6
  3. 给fd6绑定端口号
  4. 监听fd 6 文件描述符
  5. 调用accept(fd6), 等待客户端连接, 返回cfd. 客户端文件描述符. 假如我们返回的是cfd8

连接建立成功了. fd6代表的是服务端, fd8代表的是客户端. 

建立连接, 目的是要获取数据,

这时候会有一个read(cfd8), 参数是cfd8

我们可以查看是否有这个系统调用: man 2  read

read是一个阻塞的状态. client建立连接了, 可能一直也不发数据. 他阻塞了, 其他的连接就进不来了. 这是一个主线程.

那么怎么解决这个问题呢?

建立多线程. 让一个连接开启一个线程, 什么意思呢? read不是阻塞的么? 那么阻塞不要发送在主线程, 我给你重新开一个线程, 你去那里面阻塞去. 

这样新建一个客户端连接, 就要多开一个线程

 这就是早期的模型-----一客户端一线程的多线程模型. 有客户端发数据, 就发数据, 然后在相应的线程处理就可以了

存在什么问题. 

  1. 创建线程的成本比较高. 我们想创建线程, 需要系统调用, 克隆一份原来的数据地址. 也就是fork, 这一过程要完成, 程序调用内核的过程. 克隆的成本比较高, 有一个用户态和内核态的转换.
  2. 线程栈是独立的, 线程栈也占用线程资源, java中线程的大小是1M, 1000个线程就是1G, 4000个线程就是4G, 以前32位机器的内存是4G, 能用的不到G, 这就是为什么32位系统, 线程达到3000个就会内存溢出的原因. 

问题的原因?

我们使用了一连接一线程的多线程模型. 为什么使用多线程模型呢?

因为阻塞. 建立socket连接后, 等待客户端连接进来, 连接以后, 调用内核的系统方法read, 这是一个阻塞的方法. 一阻塞就执行不动了, 卡死了 

怎么解决呢? 

我们知道是因为阻塞使用的多线程, 如果客户端一年不发消息, 这个线程就阻塞1年, 10年不发消息, 这个线程就阻塞10年, 系统浪费太严重了. 

那么我们要解决的问题, 就是阻塞.

但是,我们知道这个方法是系统内核的方法. 程序调用系统内核的socket, 程序调用系统内核的bind, listen, accept, 也是程序调用系统内核的read. 程序改版不了阻塞这件事, 只有内核改变, 这事才能解决.

于是内核升级了, 从此进入了NIO的时代

二. NIO

先来看man手册

代码语言:javascript复制
man 2 socket

 这里有一个SOCK_NONBLOCK, 非阻塞的socket 

 NIO有两层含义, 一个是new io, 一个是non io, java中NIO的含义是new io的意思

我们来看看NIO是如何实现的

 和上一个模型对比,

  1. 在程序调用 的地方多了一个while(true){} 死循环.  在循环里面有accept和客户端建立连接, 以及read读取客户端数据. 注意,这里的accept和read都是非阻塞的. 连接来了, 就读, 如果没有就抛出一个异常. 完事了不会阻塞, 保证你能够往下执行.
  2. 另外一个客户端来了, 得到cfd9, 在执行cfd9的read
  3. 我们将cfd8和cfd9都放到一个list中, 下次进来了直接循环遍历list就可以了
  4. 某一时刻谁有数据, 就可以读出来了 

解决了什么问题?

解决了BIO一个客户端一个连接一个线程的多线程问题. 现在NIO一个线程就可以和多个客户端建立连接了.

存在的问题:

  1. 加入吧客户端放大成10万个, 每次while循环, 里面要执行10w次read. read是程序和系统内核的交互, 这样就要不停的在用户态和内核态之间来回的切换. 但是, 问题来了, 如果这10w个连接中, 只有1个链接有数据, 剩下99999个链接都是空的. 无用的, 这就是浪费, 这里有一个复杂度, 复杂度是o(n), 
  2. 我们希望的复杂度不是o(n), 而是o(1)

怎么解决这个问题呢?

发生这个问题的根本原因是while循环, 那就要减少循环的次数. 如果有一个客户端有连接, 那就直接返回这个连接就可以了

所以就有了多路复用

三. 多路复用

我们先来看看一个手册

代码语言:javascript复制
man 2 select

系统调用里面多了一个系统命令, select

 select什么意思? 我们来看看他的是参数

  1. ndfs: 连接的客户端的个数
  2. *readfds: 读取文件描述符的集合
  3. *writefds: 写入文件描述符 的集合

调select可以做意见什么样的事情呢? 看文档下面的描述

 select允许程序监控多个文件描述符, 等待直到有一个或多个文件描述符状态达到ready.

这个时候是怎么做的呢?

 1. 在while循环的地方, 有一个, 首先调用系统方法select(10w), 把10w个连接传给内核. 这个过程的复杂度是O(1)

  1. 内核会对10w个线程进行循环遍历, 循环遍历10w次, 返回有数据的客户端连接, 同时返回有数据的客户端连接个数. 这个复杂度也是o(1)
  2. 怎么返回的客户端连接, 参数里接收的是地址
  3. 为什么叫多路复用? 使用一个系统调用, 管理了很多个客户端的通讯事件. 这叫多路复用器, 复用了select. 

解决了什么样的问题?

  1. 在NIO中, 程序和内核之间要不停的来回交互.如果有10w个链接, 就要通信10w次.  多路复用, 减少了和内核的交互次数, 程序只需要和内核交互一次.  

有什么样的问题?

程序不用循环遍历了, 循环遍历转移到了内核. 但是内核依然会循环遍历10w次, 其实内核的遍历里面可能也是只有1个链接是有效的, 剩下的99999个链接都浪费了.  

分析问题:

  1. 内核的遍历是主动的, 因为他不知道谁达到了, 他要主动去循环遍历, 怎样才能减少循环的次数呢?
  2. 如果到达的过程能够变成一个事件, 有到达的事件了, 触发内核调用. 也就是告诉内核,我到了, 可以处理了

下面说什么是事件

我们知道, 操作系统有一个cpu, 还有一块内存. 内存里放的是程序. 程序有内核程序和用户自定义的程序. 除了cpu和内存, 还有网卡, 键盘. 

那么问题来了? 如果只有1核cpu的时候, 如何保证网卡能上网, 键盘能敲字, 用户的应用程序也能正常运行. cpu是怎么保证他们都能工作的?

 1核cpu怎么保证他们之间交替运行的?

中断, 有一个中断器, 在cpu中有一个晶振器 -- 时间中断. 晶振器一秒钟震动1w或者10w次

假如, 每秒振10下, 每下就是1/10秒

每次震荡, 会产生一个时间中断, 时间中断器会告诉cpu , 放下手里的事. 

放下手里的事干嘛呢? cpu就是一个硬件, 他知道有哪些程序呢? 他不知道

中断有一个中断号, 中断号会在内核里维护一个中断号的callback

硬件, 网卡等驱动程序, 会根据自己的硬件获得一个中断号, 埋一个callback函数到内核

硬件, 网卡等驱动程序, 会根据自己的硬件获得一个终端号, 埋一个callback函数

切换程序可以靠晶振来切换 

用户程序想调用内核, 会埋一个软中断, 表示从程序切换到内核去

硬件调用中断器, 会埋一个硬中断

这样就保证了, 1核cpu在在中断产生的时候, 接管所有事情的发生

这时候, 客户端通过电缆, 一堆的010101访问到网卡了. 网卡有缓存, 但缓存是有限的, 随着数据量的增大, 缓存存不下了, 也不能立刻让cpu取走, 这时候怎么办呢?

这时候在内存里, 会开辟一块空间, 用来存网卡数据的, 这块空间叫DMA.  Direct Memory Access,直接内存存取

网卡接收到数据了, 就放到DMA里面

这时候, 假如发生了时间中断. 网卡的中断号是88, 这时候在内存中, 有一个88对应的callback函数, 这个函数就指向了DMA的地址, 看看DMA中都有什么数据. 

callback从DMA读取的东西, 内核处理完之后, 再有相应的事件, 通知到应用程序的进程, 应用程序再把数据拷贝过来,进行计算. 这是整体的流程 

这里说了一个什么问题? 

计算机在处理网卡, 键盘等的工作的时候, 是通过事件通知到应用程序的, 而不是一直循环遍历等待.

首先, 客户端到达的时候, 会有一个中断和callback事件, 其实callback事件调用后, 内核间接知道了有网络数据包到达. 根据这个知识点, 回到我们的socket通信上来. 

多路复用, 在内核里会有10w次的循环遍历, 其实我们没必要一直循环遍历, 我们可以定义一个事件, 客户端有消息到达了, 触发事件, 返回应用程序就可以了.

四. epoll

先来看manual手册

代码语言:javascript复制
man epoll

 epoll -- I/O event. 表示io里面有事件了

 epoll的体系中, 有epoll_create, epoll_ctl, epoll_wait, 并且都是2类的系统调用

我们来看看redis的日志

ooxx/目录下

 这里有两个系统调用, epoll_create和epoll_ctl. 然后还会有很多epoll_wait. 不停的等待. 循环等待

下面来了解一下epoll

不管是bio , nio, epoll, socket通信是不变的. socket连接, bind, listen都是一样

接下来我们看看epoll是怎么通信的?

第一步: 调用epoll_create. 调用epoll_create会得到一个什么东西呢?我们来看一下2类的系统文档

代码语言:javascript复制
man 2 epoll_create

如果调用成功, 系统会返回一个二类的文件描述符

通过看redis的调用也能看的出来. 

 调用epoll_create, 返回了一个文件描述符5

我们知道socket连接返回的文件描述符是监听用的, 那epoll返回的文件描述符干什么用的呢?

在计算机内核里开辟了一块空间, 用文件描述符fd5 来表示. 接下来做什么了呢? 我们来看看redis调用

第一步: 调用了 epoll_create, 返回文件描述符5, 在内核开辟了一块空间, 命名为fd5

第二步: 调用socket建立连接, 返回文件描述符fd6

调用bind为文件描述符fd6绑定端口6379

然后调用了listen(fd6), 监听,文件文件描述符fd6

这里建立了两遍socket连接, 一个是ipv4的, 一个是ipv6的

第三步: 调用了epoll_ctl, 把文件描述符fd6放到了内核空间fd5里面. 放在里面干嘛呢? fd6要监听accept事件. accept是客户端和服务端建立连接的事件

第四步: epoll_ctl调用了以后, 开始调用epoll_wait, 疯狂的等待, 他在等什么? 

备注: ctl是control控制, 表示对这个文件描述符执行什么样的操作

比如这时候, 有一个客户端连接来了, fd6的accept就会知道有客户端想要建立连接, 这时候, 会吧fd6放到到内核里的另外一块区域里

 (fd5那块空间是一个红黑树, fd6是一个list)

 然后epoll_wait就会有返回值, 返回的是一个链表. 只不过现在只有一个. epoll_wait拿到了返回值会做什么事呢?

程序拿到fd6了, 然后调用fd6的accept和客户端连接连接的事件. 返回cdf9

第五步: 再次调用epoll_ctl, 将cfd9放入到fd5这块内核空间里, 为什么这么做呢?因为,我不知道客户端什么时候会发消息, 是一年, 还是2年? 放到fd5中,监听cfd9的read时间

第六步: 再次调用epoll_wait. 这次在等什么呢? 

假如这时又有一个客户端连接来了. 又会触发fd6的accept事件. 这时会吧fd6和cfd9 都放入链表中. 程序调用fd6的accept时间, cfd9的read事件.

第七步: 继续循环, 不停的调用

 整个epoll解决了什么事情?

解决在多路复用里面的两个问题

  1. 每次循环要传递10w个文件描述符.
  2. 在内核中要遍历这10w个文件描述符

怎么解决这个两个问题的?

  1. 我们希望每次不要传10w个文件描述符, 每次只传1个. 于是,我们在内核里开辟了一块内存空间, 每次只传一个文件描述符, 而且每个文件描述符只传1次.

内核开辟的这块空间, 保存所有的文件描述符. 剩下的事情, 就是等待,不停的等待

  1. 内核遍历10w个文件描述符, 我们希望, 只把有数据操作的进行遍历, 解决这个办法, 用的是event事件监听. 间fd5内核中的文件描述符拷贝一份到链表中.

0 人点赞