[参考]:《黑马程序员Redis》https://www.bilibili.com/video/BV1cr4y1671t/?p=166&share_source=copy_web&vd_source=9e65300ccca322aeb367bb1eb677b0fc
[参考]:《操作系统》
[参考]:《UNIX网络编程》
为了避免用户应用导致冲突甚至内核崩溃,用户应用与内核是分离的:
进程的寻址空间会划分为两部分:内核空间、用户空间
用户空间只能执行受限的命令(Ring3),而且不能直接调用系统资源,必须通过内核提供的接口来访问内核空间可以执行特权命令 (Ringo),调用一切系统资源
必要的前置知识:
编译:由编译程序将用户源代码编译成若千个目标模块(编译就是把高级语言翻译为机器语言)
链接:由链接程序将编译后形成的一组目标模块,以及所需库函数链接在一起,形成一个完整的装入模块装入 (装载) : 由装入程序将装入模块装入内存运行
1、程序、内存、与寻址
程序代码通过(预处理、编译、汇编、链接)等步骤,形成可执行的机器指令后,这些指令会告诉CPU去内存的哪个地址读/写数据,然后与寄存器进行交互,进行一些计算操作,等等。
程序生成的指令中指明的地址,是逻辑地址(相对地址),而我们的数据真实所在的内存是物理地址。
在C语言中,可执行文件最终是以装入模块的形式,进入内存。
1.1 程序的装入
装入方式有三种:
1、绝对装入
只适用于单道程序环境,程序驻留在内存的实际位置是已知的,程序中的逻辑地址与内存的实际地址完全相同。由程序员直接赋予,或程序中采用符号地址,在编译或汇编时转换为绝对地址。
2、可重定位装入(静态重定位)
多道程序环境下,多个目标模块的起始地址都是0,程序中的其他地址都是相对于起始地址的。在装入时对目标程序的指令和数据地址进行修改的过程 称为重定位,因其地址变换通常是在进程装入时一次完成的,故称为【静态重定位】
3、动态运行时装入(动态重定位)
程序若在内存中会发生移动,则使用动态重定位,即重定位的过程并不是在装入时完成,而是推迟到了程序运行时进行,需要重定位寄存器支持。
1.2 逻辑地址与物理地址
编译后,每个目标模块都是从0号单元开始编址,这称为该目标模块的相对地址(或逻辑地址),当链接程序将各个模块链接成一个完整的可执行的目标程序时,链接程序的顺序按照各个模块的相对地址构成统一的从0号地址单元开始编址的逻辑地址空间(或虚拟地址空间)。
对于32位系统,逻辑地址空间的范围是 0~2^32-1。进程在运行时,看到的和使用的都是逻辑地址。用户程序和程序员只需要知道逻辑地址,而内存管理的具体机制则是完全透明的(对用户不可视)。不同的进程可以有相同的逻辑,因为他们会被隐射到不同的主存位置。
寻址空间与计算机的位数有关系,因为每个地址单元的大小是1B,如果是32位系统,那么寻址空间大小是 2^32=4GB次方字节 。
2^10 =1024B =1K字节(1KByte), 2^20 =1MB,2^30 =1GB,
2^32 =(2^30)*(2^2) =4GB;
因此地址编号长度要能表示出4GB/1B =2^32个内存单元
32位2进制 可以转换成8位16进制表达
0地址
0x0000 0000
高地址
0xFFFF FFFF
涉及内存地址增长、存储的问题还有一个大小端的情况:
大端存储:地址低位存储数据高位,地址高位存储数据低位
小端存储:地址低位存储数据低位,地址高位存储数据高位
物理地址就是内存中物理单元的集合,是地址转换的最终地址。
逻辑地址通过页表映射到物理内存,页表由操作系统维护并被处理器引用。
1.3 进程的内存映像
当一个程序调入内存运行时,就构成了进程的内存映像。
- 代码段:即程序的二进制代码,代码段是只读的,可以被多个进程共享
- 数据段:即程序运行时加工处理的对象,包括全局变量和静态变量
- 进程控制块PCB:存放在系统区。操作系统通过PCB来控制和管理进程
- 堆:用来存放动态分配的变量,通过调用malloc函数动态的向高地址分配空间。
- 栈:用来实现函数调用,从用户控件的最大地址往低地址增长。
(补充一句:高级语言运行思路都是类似的,即使是Java这种运行逻辑依靠JVM的,其内部运行时数据区的设计思路都是源于操作系统管理的算法和逻辑)。
代码段和数据段在程序调入内存时就指定了大小。(其实我们在学C++时就说了这些东西其实是在编译期,运行之前就确定的,Java也有类似的方法区,其具体落地实现中,final 静态变量的值也是在编译期就已经确定值了)而堆和栈不同,当调用 malloc free 这样C标准库函数时,堆可以在运行时动态扩缩。用户栈也是随着程序中函数调用和返回,进行入栈弹栈操作。(java的堆和栈略有不同,可以参考我的JVM篇知识)。
1.4 内存的分配管理
详细内容可以看我的【操作系统】专栏
1、连续分配管理
- 单一连续分配
- 固定分区分配
- 动态分区分配
2、离散分配管理
1、分页存储
2、分段存储
3、段页式存储
3、虚拟内存管理
2、用户发起IO的基本流程
3、Linux五种IO模型
在《UNIX网络编程》一书中,总结归纳了5种IO模型:
- 阻塞I0 (Blocking IO)
- 非阻塞I0 (Nonblocking IO)
- IO多路复用 (IO Multiplexing)
- 信号驱动IO (SignalDriven IO)
- 异步IO(AsynchronousIO)
五种不同的IO模型其实关注点都是在这个区域
1、阻塞IO
过程:用户发起recvfrom调用,若当前无法获得数据,用户会一直阻塞等待,直到内核完成数据获取、拷贝,并返回ok给用户,通知用户处理,用户进程才被唤醒。
阻塞IO,用户进程在用户在【内核尝试获取数据】,和【内核从内核缓存拷贝数据到用户缓存】,这两个阶段都是阻塞状态。
2、非阻塞IO
用户进程发起recvfrom调用之后会立即返回,而不是阻塞进程。数据拷贝阶段,用户进程仍然是阻塞的。
第一阶段一直轮询,用户进程并没有去做其他的事,并没有提高效率,忙等反而会导致CPU空转,整个系统效率反而不高。
3、IO多路复用
无论是阻塞IO还是非阻塞IO,用户应用在一阶段都需要调用recvfrom来获取数据,差别在于无数据时的处理方案:
如果调用recvfrom时,恰好没有数据,阻塞IO会使进程阻塞,非阻塞IO使CPU空转,都不能充分发挥CPU的作用。
如果调用recvfrom时,恰好有数据,则用户进程可以直接进入第二阶段,读取并处理数据
比如服务端处理客户端Socket请求时,在单线程情况下,只能依次处理每一个socket,如果正在处理的sket恰好未就绪(数据不可读或不可写),线程就会被阻塞,所有其它客户端socket都必须等待,性能自然会很差。
如果用户进程去监听多个Socket,只要某个套接字数据就绪了,可以开始真正的读写了,我们再去调用recvfrom呢
原本是一个服务员窗口,几十个顾客排队点餐。这种情况下,某一顾客一纠结吃啥,后面的人都只能等待。
现在是大家都坐在自己位置上,某个顾客想好了点什么,就叫服务员。
具体实现:
文件描述符(File Descriptor):简称FD,是一个从0 开始递增的无符号整数,用来关联Linux中的一个文件。
在Linux中,一切皆文件,例如常规文件、视频、硬件设备等,当然也包括网络套接字 (Socket)
IO多路复用:是利用单个线程来同时监听多个FD,并在某个FD可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。
第二阶段调用recvfrom之所以要循环,是因为第一阶段可能有多个socketFD准备就绪,
在第二阶段里被循环依次被处理
select系统调用可以接收多个被监听的套接字FD
而recvfrom只能监听一个FD
** 所以前面阻塞IO 和非阻塞IO其实都是逮着一只羊薅(只服务一个FD),而IO多路复用虽然第一阶段也是阻塞的,但是select本质上是一种批处理的思想,同时监听多个FD,只要有一个FD就绪,就先处理它。
监听FD的方式、通知的方式有多种,常见的方式:
- select
- poll
- epoll
差异
- select和poll只会通知用户进程有FD就绪,但不确定具体是哪个FD,需要用户进程逐个遍历FD来确认
- epoll则会在通知用户进程FD就绪的同时,把已就绪的FD写入用户空间
1、Select模型
Select是Linux中最早的IO多路复用实现方案
fd_set 使用了位示图来表示fd状态。32个long型元素 即 32*32 =1024bit=1kB
select模式存在的问题:
- 需要将整个fd_set从用户空间拷贝到内核空间,select结束还要再次拷贝回用户空间
- select无法得知具体是哪个fd就绪,需要遍历整个fd_set
- fd_set监听的fd数量不能超过1024
2、poll模式
fds 是一个结构体指针,同时它也是能构成一个自定义大小的结构体数组,fds+1即指向下一个数组元素。
该结构体内部由 fd,当前要监听的事件类型,和该事件真实发生的类型状态组成。
用户态调用poll函数,传入若干需要被监听fd结构体(每一个结构体实例就代表一个fd)构成结构体数组。
当系统进入内核态后,如果这些fd就绪,内核会修改这些fd对应的结构体实例的revent,最终把整个结构体数组返回给用户态。
IO流程
- 创建pollfd数组,向其中添加关注的fd信息,数组大小自定义调用poll函数,将pollfd数组拷贝到内核空间,转链表存储,无上限
- 内核遍历fd,判断是否就绪
- 数据就绪或超时后,拷贝pollfd数组到用户空间,返回就绪fd数量n
- 用户进程判断n是否大于0
- 大于0则遍历pollfd数组,找到就绪的fd
poll和Select
1、poll没用监听fd数量限制,Select限制为1024,因为二者记录fd的数据结构不同。
2、Select和poll 都需要把 记录fd的数组 从用户态传入内核态。内核处理完后,同样从内核态拷贝回用户态
3、二者从内核态返回的fd数组,都没有直接指明具体是哪个fd就绪,需要用户逐一遍历,找到就绪fd
这就产生一个问题:数组容量变大了,但是任然需要遍历,样本空间变大的情况下,效率反而下降了。
3、epoll
红黑树 的特点 :有序、按序插入删除时间复杂度相对链表要更低。(O(lg2N))
epfd是 eventpoll实例的唯一标识,用户端调用几次epoll_crate, 内核就会创建几个eventpoll,并返回对应的epfd 标识。
select模式存在的三个问题
- 能监听的FD最大不超过1024
- 每次select都需要把所有要监听的FD都拷贝到内核空间
- 每次都要遍历所有FD来判断就绪状态
poll模式的问题:
poll利用链表解决了select中监听FD上限的问题,但依然要遍历所有FD,如果监听较多,性能会下降
epoll模式中如何解决这些问题的?
- 基于epoll实例中的红黑树保存要监听的FD,理论上无上限,而且增删改查效率都非常高,性能不会随监听的FD数量增多而下降
- 每个FD只需要执行一次epoll ctl添加到红黑树,以后每次epol_wait无需传递任何参数,无需重复拷贝FD到内核空间
- 内核会将就绪的FD直接拷贝到用户空间的指定位置,用户进程无需遍历所有FD就能知道就绪的FD是谁
lO多路复用-事件通知机制
当FD有数据可读时,我们调用epoll_wait就可以得到通知。但是事件通知的模式有两种:
LevelTriggered:简称LT。当FD有数据可读时,会重复通知多次,直至数据处理完成。是Epoll的默认模式
EdgeTriggered:简称ET。当FD有数据可读时,只会被通知一次,不管数据是否处理完成
举例:
1、假设一个客户端socket对应的FD已经注册到了epoll实例中
2、客户端socket发送了2kb的数据
3、服务端调用epoll_wait,得到通知说FD就绪服务端从
4、FD读取了1kb数据
5、回到步骤3(再次调用epoll wait,形成循环)
结论:
ET模式避免了LT模式可能出现的惊群现象
ET模式最好结合非阻塞IO读取FD数据,相比LT会复杂一些
4、信号驱动IO
信号驱动10是与内核建立SIGI0的信号关联并设置回调,当内核有FD就绪时,会发出SIGI0信号通知用户,期间用户应用可以执行其它业务,无需阻塞等待。
当有大量IO操作时,信号较多,SIGIO处理函数不能及时处理可能导致信号队列溢出
而且内核空间与用户空间的频繁信号交互性能也较低
5、异步IO
异步IO的整个过程都是非阻塞的,用户进程调用完异步API后就可以去做其它事情,内核等待数据就绪并拷贝到用户空间后才会递交信号,通知用户进程。
异步IO要做好限流,防止异步IO请求请求过多,造成系统负荷过高。
IO操作是同步还是异步,关键看数据在内核空间与用户空间的拷贝过程(数据读写的IO操作),也就是阶段二是同步还是异步: