cpu如何执行程序
工作方式
图灵机
组成:
- 1.纸带: 由一个个连续的格子组成,每个格子可以写入字符
纸带->内存
格子里的字符->内存中的数据或程序
-
2.读写头:用于读取格子中的字符,也用于将字符写到格子中
-
3.读写头上的部件:
- a.存储单元:用于存放数据
- b.控制单元:用于识别字符是数据还是指令以及控制程序的流程等0.1
- c.运算单元:执行运算指令
执行过程:以 1+2为例
- 1.读写头把1 2 + 三个字符吸入到纸带的3个格子中,然后读写头停在1字符对应的格子上
- 2.读写头读入1 到存储单元(即 图灵机的状态)中
- 3.然后读写有向右移动一格,把2读入到图灵机的状态,这时图灵机的状态中就有 1 和 2 两个字符
- 4.读写头向右移动一格,将 + 传输给 控制单元,控制单元识别出不是数字,就不会存入状态中,而是通知 运算单元 工作。然后运算单元 把状态中的1 和 2读入并计算,再将计算结果3存入状态
- 5.运算单元将结果返回给控制单元,控制单元将结果传输给读写头,读写头向右移动,把3写到格子中
冯诺依曼模型
定义计算机的基本结构:
- 1.内存
程序和数据存储的地方,是线性的
存储数的基本单位是字节 byte. 每一个字节都对应一个内存地址
内存地址是从0开始编号的,然后自增排列,最后一个地址就是内存总字节数-1,跟数组有点像
- 2.CPU
32位和64位的CPU最主要的区别就是一次能计算多少字节数据:
32位:一次计算4字节
64位: 一次计算8字节
32位和64位指的是CPU的位宽,代表CPU一次可以计算的数据量,位宽越大,CPU一次能计算的数值也就越大,比如32位CPU能计算最大整数是 4294967295
内部组件:
控制单元:负责控制CPU工作
逻辑运算单元:负责计算
寄存器:存储计算时的数据
- a.通用寄存器:用来存放需要进行运算的数据 比如 1和2
- b.程序计数器:用来存储CPU要执行的下一条指令(所在的内存地址),不是指令本身,而是内存地址
- c.指令寄存器:用来存放当前正在执行的指令,即指令本身,指令执行完成之前,指令都存在这里
- 4.总线
用于CPU和内存以及其他设备之间的通信
类型:
地址总线:用于指定CPU将要操作的内存地址
数据总线:用于读写内存数据
控制总线:用于发送和接收信号,比如中断、设备复位等信号,CPU收到信号后进行响应,也需要通过控制总线
示例:CPU读写内存数据
- 通过内存总线来指定内存地址
- 通过控制总线控制是读或写命令
- 通过数据总线来传输数据
- 5.输入输出设备
输入设备是向计算机输入数据
输出设备是计算机输出的目标
线路位宽&CPU位宽
数据通过线路传输其实是通过操作高低电压来表示的,低电压表示0,高电压表示1
一条线每次只能传1bit的数据,即0或1,要传输101就需要3次
32位CPU只能操作4G大的内存,因为只有32条总线,寻址空间最大为2^32=4G
64位CPU能操作的内存很大,因为有64条总线,寻址空间最大为2^64
若计算数额不超过32位数字的情况下,32位和64位CPU之间没什么区别,只有当计算超过32位数字的情况下,64位的优势才能体现出来
程序执行的具体过程
- 1.cpu读取程序计数器的值,这个值就是指令的内存地址,然后cpu 控制单元操作 地址总线 指定需要访问的内存地址,然后通知内存设备准备数据,数据准备好后通过 数据总线 将指令数据传给cpu, cpu收到数据后,将这个指令存入指令寄存器
- 2.程序计数器自增,表示指向下一个指令。
自增的大小是由CPU的位宽决定的,比如32位CPU,指令是4字节,需要4个内存地址存放,因此自增4
-
3.cpu分析指令寄存器中的指令,确定指令的类型和参数
- 若是计算类型的指令,就把指令交给 逻辑运算单元运算
- 若是存储类型的指令,就把指令交给控制单元执行
以上过程称为 CPU指令周期
一条指令的4个阶段,称为4级流水线:即指令周期
- 1.cpu通过程序计数器读取对应内存地址的指令,这个部分是Fetch(取得指令)
- 2.cpu对指令进行解码,这个部分是 Decode(指令译码)
- 3.cpu执行指令,这个部分是 Execution(执行指令)
- 4.cpu将计算结果写会寄存器或者将寄存器的值写入内存,这是 Store(数据写回)
不同阶段由不同的组件完成:
- 1.取指令阶段,就是通过程序计数器和指令寄存器取出指令的过程,由控制器操作
- 2.指令的译码过程,由控制器进行
- 3.指令执行过程,除了简单的无条件地址跳转直接在控制器中完成,其他的操作如算数操作、逻辑操作等都由算数逻辑单元操作,即由运算器处理
指令的执行速度:
GHz: 一个1GHz的CPU,指的是时钟频率1G,代表着1秒会产生1G次数(简单理解为10亿次)的脉冲信号,每一次脉冲信号高低电平转换就是一个周期,称为时钟周期
时钟频率越高,时钟周期就越短,工作速度也就越快
程序的CPU执行时间 = cpu时钟周期数 * 时钟周期时间
cpu时钟周期数 = 指令数 * 每条指令的平均时钟周期数(CPI)
程序的CPU执行时间 = 指令数 * CPI * 时钟周期时间
优化程序执行,跑的更快:
- 1.指令数,表示程序要执行多少条指令,这个层面基本靠编译器优化
- 2.每条指令的平均时钟周期数CPI, 表示一条指令需要多少个时钟周期数,现代大多数CPU通过流水线计数,让一条指令的需要的cpu时钟周期数尽可能少
- 3.时钟周期时间,表示计算机主频,取决于计算机硬件。打开超频就是把cpu内部时钟调快,cpu工作速度就更快,但是散热压力也会更大
操作系统也是程序
存储器,内存 -> 磁盘
寄存器 半个时钟周期
最靠近cpu控制单元和逻辑计算单元的存储器,价格最贵,速度最快,数量不多
寄存器的数量通常在几十到几百之间,每个寄存器可以存储一定字节的数据:
- 32位CPU中大多数寄存器可以存储4字节
- 64位CPU中大多数寄存器可以存储8字节
寄存器的速度一般要求在半个cpu时钟周期内完成读写
CPU Cache
CPU Cache是一种叫SRAM(静态随机存储器)的芯片,只要有电,数据就可以保持存在,否则,数据就丢失
在sram中,一个bit数据,通常需要6个晶体管,所以sram存储密度不高,虽然存储数据有限,但速度快
L1高速缓存 2~4个时钟周期
距离CPu核心近,访问速度很快,通常只要2~4个时钟周期,大小在几十KB到几百KB不等
每个CPU核心都有,L1分为两块,一块是指令缓存,一块是数据缓存
L2 高速缓存 10~20个时钟周期
每个cpu核心都有,相对L1 Cache距离CPU核心更远,小大在几百KB到几MB不等,CPU型号不同,大小不同,访问速度更慢
L3高速缓存 20~60个时钟周期
通常是多个CPU核心共用,位置比L2 Cache距离CPU核心更远,大小在几MB到几十MB不等,CPU型号不同,大小不同
内存 200~300个时钟周期
内存用的芯片和CPU Cache用的不同,是用的DRAM(动态随机存取存储器)
相比SRAM,DRAM密度更高,功耗更低,但容量更大,造价低
dram存储一个bit数据,只需要一个晶体管和一个电容就能存储
数据被存储在电容里边,电容会不断漏电,所以需要定时刷新电容,才能保证数据不丢失
SSD/HDD硬盘
SSD: 固态,断电后数据依旧存在,内存读写速度是固态的10~1000倍
HDD: 机械,通过物理读写方式访问数据,速度很慢,比内存慢10W倍左右
每个存储器只能和相邻的一层存储器设备交互,并且存储设备为了追求更快的速度,所需材料成本也越来越高,因为成本太高,CPU内部的寄存器、L1、L2、L3 Cache只能用较小的容量,相反,内存和硬盘容量更大
CPU访问内存数据流程:
访问速度对比:
SSD比机械快70倍左右
内存比机械快 100000倍左右
CPU L1 Cache比机械快 10000000倍左右
CPU的运行机制
CPU Cache的数据结构:
L1 Cache L2 Cache L3 Cache 一次读取的数据大小为Cache Line,一般都是64字节或者128字节
CPU Cache由多个Cache Line组成,Cache Line是CPU从内存读取数据的基本单位
Cache Line(缓存块): 各种标志(Tag)+ 数据块(Data Block)
示例补充说明:
有一个int array[100]数组,当载入array[0]时,由于这个元素只有4字节,不足64字节,CPU就会顺序加载数组元素到array[15], array[0]-array[15]数组元素都被缓存在CPU Cache中,当下次访问的时候,就直接从CPU Cache中取数据,数据不存在才会去访问内存
直接映射:
CPU Cache Line的组成:
Index: 索引,定位CPU Cache Line
Valid Bit: 有效位,用于标记数据是否有效,若有效位是0,CPU会直接访问内存,重新加载数据
Tag: 组标记,用于记录当前CPU Cache Line中存储的数据对应的内存块,可以用这个组标记来区分不同的内存块
Data Block: 数据块,存放从内存加载过来的实际数据
内存访问地址组成:
Tag: 组标记,对应CPU Cache Line中的组标记
Index: 索引,对应CPU Cache Line中的索引
Offset: 偏移量,对应CPU Cache Line中数据库的实际读取的数据
若内存中的数据在CPU Cache中,CPU访问一个内存地址的步骤:
- 1.根据内存地址中的索引信息,计算在CPU Cache中的索引,也就是找出对应的CPU Cache Line的地址
- 2.判断CPU Cache Line的有效位,确认CPU Cache Line中的数据是否有效,若无效,则直接访问内存,重新加载数据,否则,往下执行
- 3.对比内存地址中的组标记和CPU Cache Line中的组标记,确认CPU Cache Line中的数据是否是我们要的数据,若是,往下执行,否则直接访问内存
- 4.根据内存地址中的偏移量,从CPU Cache Line的数据块中,读取对应的字
备注:字的含义:
CPU从CPU Cache中读取数据的时候,并不是读取整个数据块,而是读取CPU需要的一个数据片段,这样的数据统称为一个字(Word)
如何找到整个字,就需要偏移量(Offset)
如何优化,写出高效率运行的代码?
1 +1 = 2, +就是指令,1就是数据
- 1.提升数据缓存命中率 - 按照内存布局顺序访问
遇到数组遍历的情况,按照内存布局顺序访问,就可以有效利用CPU Cache的好处,能够提升代码性能
- 2.提升指令缓存命中率 - 分支预测器(先有序)
利用分支预测器,若分支预测可以预测到接下来要执行if里的指令还是else指令,那就可以提前把这些指令放到指令缓存中,这样CPU就可以直接从Cache中读到指令,执行速度就很快
分支预测有效工作的前提是元素有序
- 3.提升多核CPU缓存命中率 - 线程绑定CPU核
避免一个线程在多个核心中切换,L1 Cache和L2 Cache的缓存命中率就可以有效提高,就可以减少CPU访问内存的频率
缓存一致性
写数据 CPU Cache -> 内存 类型:
- 1.写直达 (Write Through)
保持内存与Cache一致性最简单的方式:把数据同时写入内存和Cache中
写入前会先判断数据是否已经在CPU Cache中:
若不在,就直接更新到内存
若在,就更新Cache,再更新到内存
- 2.写回
写直达,每次都要写回到内存,影响性能,针对整个问题,出现了写回(Write Back)方法
这种机制中,当发生写操作时,新数据仅被写入到Cache Block中,当修改过的Cache Block被替换时才需要写到内存中,减少数据写回内存的频率
有点类似渐进式的hashmap扩容,下一次访问的时候,再决定是否更新到内存
-
若当发生写操作时,数据已经在CPU Cache, 则需要把数据更新到CPU Cache, 同时标记CPU Cache中的Cache Block是脏的(表示CPU Cache中的数据跟内存中的不一样)
-
若当发生写操作时,数据对应的Cache Block里存放的是 别的内存地址的数据,那么就要检查整个Cache Block里的数据是不是被标记为脏的
若脏的,就要把整个Cache Block中的数据写到内存,然后再把当前要写入的数据,先从内存读入CPU Cache Block,然后再把当前要写入的数据写到Cache Block,最后标记为脏的
若不是脏的,就把当前要写的数据从内存读入Cache Block, 然后把数据写入到占几个Cache Block,然后标记为脏的
缓存一致性问题
问题概念:各个CPU核的 L1/L2中,同一份数据的值不一致,最终导致执行结果的错误
问题解决:
- 1.写传播 - 类似于java的volatile关键字
某个CPU核心的Cache数据更新时,必须要传播到其他核心的Cache
数据更新完,立即同步到其他核心的Cache
技术实现:总线嗅探 常见机制
每个CPU核心监听总线上的广播事件,A号CPU更改了L1 Cache中的数据,就通过总线把事件广播通知给其他所有核心,B号CPU等其他CPU核心监听到这个事件,就判断自己的L1 Cache中是否有该数据,有就更新
java中的volatile, JVM就是利用现代处理器提供的缓存一致性协议来实现的,而总线嗅探是常见的技术手段之一
总线嗅探,只保证数据可见
- 2.事务串行化
某个CPU核心里对数据的操作顺序,必须在其他核心看起来顺序是一样的
C和D最终的结果是不一样的,这就有问题
要保证C和D都能看到相同顺序的数据变化
- CPU核心对于Cache中数据的操作,需要同步给其他CPU核心
- 引入锁的概念,若多个CPU核心中持有相同的数据,任何CPU核心要更改这个数据,只有拿到了锁,才能进行相应的更新 - 类似java多个线程争夺锁资源,操作共享对象
技术实现:MESI协议
Cache Line的4个状态:
- 1.Modified(已修改):
这个状态就是前面的脏标记,表示Cache Block上上的数据已经被更新过,但还没有写入到内存。
- 2.Invalidated(已失效):
表示Cache Block里的数据已经是失效的,不可以读取该状态的数据
- 3.Exclusive(独占):
表示Cache Block中的数据是干净的,Cahce Block和内存是一致的
数据只存在于一个CPU核心的Cache,其他CPU核心的Cache数据中没有
这时,若要向独占的Cache写数据,可以自由写入,不需要通知其他CPU核心,不存在一致性问题
- 4.Shared(共享)
表示Cache Block中的数据是干净的,Cahce Block和内存是一致的
若独占状态下,有其他核心从内存中读取了相同的数据到Cache, 那么这时,独占状态就变成共享状态
共享状态表示相同的数据存在于多个CPU核心的Cache中,当要更新Cache中的数据时,不能直接修改,要先向其他CPU核心广播一个请求,要求其他核心的Cache中的Cache Line标记为无效状态,然后再更新当前Cache里的数据
4种状态的转换
| 当前状态 | 事件 | 行为 |
|---|---|---|
| 已失效I(Invalid) | LocalRead | 若其他核心Cache没有备份这份数据,本地核心的Cache从若其他核心的Cache有这份数据,且状态为M,则将数据更新到若其他核心的Cache有这份数据,且状态为S或者,本地核心的Cache从 |
| LocalWrite | 从若其他核心的Cache中有这份数据,且状态为M,那么要先将其他核心的Cache中的数据写回到若其他核心的Cache有这份数据,则其他Cache的Cache Line状态变成I; | |
| RemoteRead | 既然是Invalid, 其他核心的擦欧总与它无关 | |
| RemoteWrite | 既然是Invalid, 其他核心的擦欧总与它无关 | |
| 独占E(Exclusive) | LocalRead | 从Cache中读取数据,状态不变 |
| LocalWrirte | 修改Cache中的数据,状态变成M | |
| RemoteRead | 数据和其他核心共用,状态变成S | |
| Remote Write | 数据被修改,本地核心中的数据不能再使用,状态变成I | |
| 共享S(Shared) | LocalRead | 从Cache中读取数据,状态不变 |
| LocalWrite | 修改Cache中的数据,状态变成M,其他核心共享的Cache Line状态变成I | |
| RemoteRead | 状态不变 | |
| RemoteWrite | 数据被修改,本地核心的Cache Line中的数据不能再使用,状态变成I | |
| 已修改M( | LocalRead | 从Cache中取数据,状态不变 |
| LocalWrite | 修改Cache中数据,状态不变 | |
| RemoteRead | Cache Line的数据被写到 | |
| RemoteWrite | Cache Line的数据被写到 |
CPU如何执行任务
读写过程:
L1 Cache一次载入数据的大小是64字节
Cache伪共享问题:
使用单独遍历的时候,则会有Cache伪共享问题,这个问题对性能的损耗很大,应尽量规避
问题分析:
假设变量A和B还都不在Cache中,1号核心绑定了线程A,2号核心绑定了线程B,线程A只会读写变量A,线程B只会读写变量B (两个变量都是long类型)
- 1.核心1读取变量A,由于CPU从内存读取数据的单位是Cache Line,刚好变量A和B都归属于一个Cache Line, 所以变量A和B都被加载到Cache,,然后将此Cache Line标记为 独占 状态
- 2.核心2也开始从内存读取变量B,同样也是读取Cache Line大小的数据据到Cache中,此Cache Line数据包含变量A和B,此时核心1和核心2 的Cache Line状态变为 共享 状态
- 3.核心1需要修改变量A,发现此时Cache Line是共享 状态,所以先通过总线发送消息给核心2,通知它把Cache中对应Cache Line标记为失效状态,然后核心1将对应的Cache Line变成已修改状态 ,并且修改变量A
- 4.之后,核心2要修改变量B,此时核心2Cache中对应的Cache Line已是失效状态,另外由于核心1的Cache也有相同数据,且状态时已修改,所以要先把核心1的Cache对应的Caceh Line写回到内存,然后核心2再从内存读取Cache Line状态数据到Cache中,最后把变量B修改的核心2的Cache中,并将状态标记为已修改
核心1和2持续交替分别修改变量A和B,就会重复步骤4和5,Cache无效
这种因为多个线程同时读写同一个Cache Line的不同变量时,而导致的CPU Cache失效的现象称为伪共享
问题规避:
- 1.内核中处理(字节对齐) - 用空间换时间
示例:
将b设置为Cache Line对齐地址:
- 2.应用层处理 (字节填充)- Java并发框架Disruptor使用 [字节 + 继承]方式避免
Disruptor中的RingBuffer类经常被多个线程使用:
根据JVM对象继承关系中 父类成员和子类成员,内存地址是连续排列的,因此RingBufferPad中的7个long类型作为Cache Line的前置填充,而RingBuffer中的7个long类型数据作为Cahche Line后置填充,这14个long变量没有实际作用,不会有任何读写操作
RingBufferFields里边定义的这些变量都是final修饰的,即第一次加载后不会再修改
又由于前后各填充了7个不会读写的long类型变量,所以无论怎么加载Cache Line,这整个Cache Line里都不会发生更新操作的数据,于是只要数据被频繁的读取访问,就自然没有数据被换出Cache的可能,也因此不会产生伪共享问题
CPU选择线程的过程:
Linux内核中,进程和线程都是用task_struct结构体表示,
线程的task_struct结构体中部分资源是共享进程创建的资源,比如内存地址、代码段、文件描述符等,因此称为轻量级进程
一般情况下,没有创建线程的进程,是只有单个执行流,被称为主线程。若想进程处理更多的事情,可以创建多个线程取去处理
Linux内核中的调度器,调度的对象就是task_struct(任务)
任务优先级:
0-99:实时任务,对系统响应时间要求很高,期望尽可能快的执行实时任务
100-139:普通任务,响应时间没有要求
调度类:
针对任务的优先级,为了保障优先级高的任务能够尽早被执行,分为以下调度类:
Deadline和Realtime用于实时任务,Fair用于普通任务
-
1.SCHED_DEADLINE: 优先调度距离档期那时间点最近的deadline的任务
-
2.SCHED_FIFO:
- a.相同优先级,按照先来先执行的原则
- b.优先级更高的,可以插队
-
3.SCHED_RR:
- a.相同优先级,轮流执行,当时间片用完就被放到队列尾部
- b.优先级更高的,可以插队
-
4.SCHED_NORMAL: 普通任务使用
-
5.SCHED_BATCH: 后台任务使用,类似于GC线程
普通任务:我们启动的任务,默认都是普通任务
为了保证普通任务的公平性,Linux实现了CFS算法
完全公平调度(Completely Fair Scheduling):
让每个任务的CPU时间一样,它会为每个任务安排一个虚拟运行时间vrumtime,一个任务运行的越久,vrumtime就越大,任务没有运行,rruntime就不变
CFS算法会优先选择vruntime少的任务(就是认为vruntime大的任务持有的CPU时间已经很多了,所以需要优先执行没怎么执行CPU时间片的任务)
虚拟运行时间vruntime += 实际运行时间delta_exec * NICE_0_LOAD / 权重
高权重任务的vruntime比低权重任务的vruntime少
CPU运行队列:
每个CPU都有自己的运行队列,用于描述在此CPU上所运行的所有进程,包含以下三个队列:
- 1.dl_rq: Deadline运行队列
- 2.rt_rq: 实时任务运行队列
- 3.cfs_rq: CFS运行队列,用红黑树描述,按照vruntime排序,最左侧的叶子节点就是下次被带哦都的任务
调度优先级: Deadline > Realtime > Fair
实时任务总是会比普通任务优先执行
调整任务的优先级:
软中断
中断
概念:系统用来响应硬件设备请求的一种机制,操作系统收到硬件的中断请求,会打断正在执行的进程,然后调用内核中的中断处理程序来响应请求
大白话就是:你打了个电话给我,我停下手头的工作,跟你通电话
要求,中断处理程序要尽可能快的执行完成,这样可以减少对正常运行调度的影响
大白话就是:有啥事,赶紧说完,别罗里吧嗦,尽可能少影响先前手头工作的进度
临时关闭中断:若当前中断处理程序没有执行完之前,系统中其它中断请求都无法被响应
大白话就是:另外一个人打电话给我,但是我跟你在通电话,无法接他的电话,他那边会被提示正在通话中
问题:
中断中,若中断处理程序执行时间长,不仅会影响正常进程的运行调度,还会影响其他中断请求
问题解决:
理念:优先处理紧急的事情,不紧急后面慢慢处理
Linux为了避免中断处理程序执行时间过长(通话时间过长)和中断丢失(别人无法打通我的电话)的问题,将中断分成两个阶段:
- 1.上半部分:解决中断丢失问题
用于快速处理中断,一般会暂时关闭中断请求,主要负责处理跟硬件紧密相关的或者时间敏感的事情
大白话:跟你说,有啥事,见面详聊,就挂电话
硬中断,处理耗时段的工作,特点是执行快
会打断CPU正在执行的任务
- 2.下半部分:避免执行时间过长问题
用于延迟处理上半部分未完成的工作,一般以 内核线程 的方式运行
大白话:见面后,然后做相关的事情
内核触发,软中断,通常耗时比较长,特点是延迟执行
每一个CPU内核都对应一个软中断内核线程(ksoftirqd/CPU 编号,比如 ksoftirqd/0)
一些自定义事件也属于软中断,比如内核调度,RCU锁(内核使用的锁)
常见的软中断
关注点:
-
1.软中断类型
- a.TIMER:定时中断
- b.NET_TX: 网络发送中断
- c.NET_RX:网络接收中断
- d.RCU: RCU 锁中断
- e.SCHED:内核调度中断
-
2.正常情况下,同一种中断在不同CPU中累计次数差不多
-
3.中断变化速度更重要
示例:
若发现NET_RX网络接收中断次数变化速度过快,可以使用
然后通过tcpdump抓包分析包的来源,然后决定是否加防火墙或者升级硬件
基础复习+ 示例
为什么负数要用补码表示
有符号数,最高位是0,表示正数, 1,表示负数
补码:就是正数取反加1
不用补码记录负数的问题:
若想算对-2 + 1 = -1
就要先判断数字-2是不是负数,若是负数,就要将 + 改成 - ,再计算
多了一步判断的操作,会影响性能
用补码方式,计算逻辑就跟现实逻辑一样
十进制小数与二进制的转换
刚好示例转换二进制:8.625
非刚好示例转换二进制:0.1
发现的问题:
0.1的二进制是一个无限循环
由于计算机资源有限,无法用二进制精确表示0.1,只能用 近似值 来表示,即在有限精度情况下,最大化接近0.1的二进制数,会造成精度缺失
计算机如何存储小数
| 十进制 | 二进制 | 规格化 |
|---|---|---|
| 8.625 | 1000.101 | 1.000101 x 2^3 |
000101: 是尾数,即小数点后的数字
3:是指数,指定了小数点在数据中的位置
IEEE 745国际标准:使用浮点数
-
符号位: 0表示正数,1表示负数
-
指数位: 指定小数点在数据中的位置(二进制小数的小数点位置),指数可以是负数,也可以是正数
- 指数位的长度越长,数值的表达范围就越大
-
尾数: 小数点右侧的数字,也就是小数部分,比如示例中的3
- 尾数的长度决定了这个数的精度,尾数越长,精度越高
十进制 -> float存储
float存储 -> 十进制
1:转换中隐含的1
演示 0.1 + 0.2 = 0.3
01.和0.2的二进制表达式都是无限循环数,所以只能取一个近似值
操作系统结构
内核
概念:应用连接硬件设备的桥梁
内核的能力:
- 1.进程调度:决定哪个进程、线程使用CPU
- 2.内存管理:决定内存的分配和回收
- 3.硬件通信能力:为进程与硬件设备之间提供通信能力
- 4.系统调用:当应用程序需要更高权限运行的服务,就需要系统调用,这是用户程序与操作系统之间的接口
内核工作:
内核具有很高的权限,可以控制cpu、内存、硬盘等硬件, 而应用程序的权限很小
基于这个权限大小,大多数操作系统把内存分为两个区域:
- 内核空间,只有内核程序可以访问
内核空间的代码可以访问所有内存空间
当程序使用内核空间时,通常说该程序在内核态执行
- 用户空间,专门给应用程序使用
用户空间的代码只能访问一个局部的内存空间
当程序使用用户空间时,通常说该程序在用户态执行
当应用程序使用系统调用时,会产生一个中断,之后,CPU会中断当前正在执行的用户程序,转而跳转到中断处理程序(也就是开始执行内核程序)。内核处理完成后,主动触发中断,把CPU执行权限交回给用户程序,回到用户态继续工作
📌
系统调用过程:
- a.执行系统调用的时候,先将系统调用名称转换为系统调用号,接着将系统调用号和请求参数放到寄存器中,然后执软中断指令,CPU会从用户态切换到内核态
- b.CPU跳转到中断处理程序,将当前用户态的现场信息保存到内核栈中,接着根据系统调用号找到对应的系统处理程序,并将寄存器中的参数取出来,作为函数参数,然后在内核中执行系统调用函数
- c.执行完系统调用后,执行中断返回指令,内核栈会弹出之前保存的用户态的现场信息,将原来用户态保存的现场恢复回来,这时CPU恢复到用户态,用户态进程恢复执行
Linux设计
内核设计理念:
-
1.MultiTask:多任务
- a.单核CPU,每个任务执行一小段时间,就切换到另外一个任务。一段时间内执行了多个任务,也就是并发
- b.多核CPU,多个任务同时被不同的CPU执行,也就是并行
- 2.SMP: 对称多处理
所有CPU地位相等,对资源的使用权限也相同,共享一个内存,每个CPU都可以访问完整的内存和硬件资源
- 3.ELF: 可执行文件链接格式
Linux操作系统中可执行文件的存储格式
ELF生成:程序员写的代码 -> 编译器将其编译成汇编代码 -> 汇编器将其变成目标代码(目标文件)-> 链接器把多个目标文件以及调用的各种函数库链接起来,形成一个可执行文件,这个就是ELF
ELF执行:装载器将ELF文件加载到内存 -> CPU读取内存中的指令和数据,这样程序就被执行起来了
- 4.Monolithic Kernel: 宏内核
Linux内核架构就是宏内核,意味着Linux的内核时一个完整的可执行程序,且拥有最高权限
系统内核的所有模块,比如进程调度,内存管理、文件系统、设备驱动等,都运行在内核态
Linux实现了动态加载内核模块的功能,这样能够解耦,比如设备驱动
与之相反的就是微内核,比如鸿蒙操作系统
Windows设计
支持MultiTask和SMP, 但是Windows内核是混合型内核
可执行文件格式是PE(可移植执行文件),比如 .exe .dll .sys
内存管理
为啥需要虚拟内存
防止多进程运行时造成的内存地址冲突
单片机的无奈:
单片机的CPU是直接操作内存(物理地址)的,所以不能在内存中同时运行两个程序
操作系统对这个问题的解决
操作系统为每个进程分配独立的一套 虚拟地址 ,然后通过一种机制将不同进程的虚拟地址和不同内存的物理地址映射起来,程序访问虚拟地址时,操作系统将虚拟地址转换成不同的物理地址
地址概念:
- 虚拟内存地址(Virtual Memory Address): 程序使用的内存地址
人为设计的概念,可以类比为收货地址 xx省xx市xx区xx街道xx小区xx室
- 物理内存地址(Physical Memory Address):实际存在硬件里边的空间地址
实际的地址,可以类比为实际地点
进程持有的虚拟地址会通过CPU芯片中的MMU(内存管理单元)的映射关系,来抓换成物理地址,然后通过物理地址访问内存
管理虚拟地址和物理地址关系的方式:
内存分段
程序由若干个逻辑分段组成,如代码分段、数据分段、栈段、堆段等,不同段有不同的属性,所以就用 分段 的形式把这些段分类出来
虚拟地址 -> 物理地址 映射
分段机制下虚拟地址由段选择因子 和 段内偏移量 组成
段选因子:保存在段寄存器,里边最重要的就是段号,用作段表的索引。段表里边保存的是这个段的基地址、段的界限和特权等
段内偏移量:位于0和段界限之间,若段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址
分段的优缺点:
优点:
- 解决了程序本身不需要关心具体物理内存地址的问题
- 能产生连续的内存空间
缺点:
- 内存碎片问题
原因:
内存碎片主要分为 内部内存碎片和外部内存碎片
内存分段管理可以做到根据实际需求分配内存,所以有多少需求就分配多大的段,不会出现内部内存碎片
由于每个段的长度不固定,所以多个段未必能恰好使用所有的内存空间,就会产生多个不连续的小物理内存,导致新程序无法被装载,会出现外部内存碎片问题(联想到JVM-GC的标记-整理清除算法)
段与段之间会产生间隙非常小的内存,这就是内存碎片
问题解决:
内存交换,内存交换空间就是Linux中的swap空间,这块空间是在硬盘上划分的,用于内存硬盘的空间交换
其实就是把音乐程序占用的256MB写到磁盘,然后再从磁盘读取到内存,这次是挨着512MB存放,这样新的程序就能放入了(类似于JVM-GC的标记-整理算法)
- 内存交换效率低的问题
原因:
多进程情况下,外部内存碎片就很容易产生,这样就需要频繁的swap内存区域,而过多的磁盘IO会影响性能。若内存交换的是一个占内存空间很大的程序,这样整个机器都会显得卡顿
内存分页
为了解决外部内存碎片和内存交换空间太大的问题,提出内存分页
设计思想:就是当需要进行内存交换的时候,让需要交换写入或者从磁盘装载的数更少一点
分页是把整个虚拟和物理内存空间划分成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间,就是页 ,Linux中每页大小默认为4KB
虚拟地址与物理地址之间通过页表来映射,页表存储在内存中,内存管理单元(MMU)就做将虚拟内存地址转换为物理地址的工作
若进程访问的虚拟地址在页表中查不到,系统会产生一个缺页异常,进入系统内核空间分配物理内存、更新进程页表,最后再返回用户空间,恢复进程的运行
分页对分段的问题的解决:
- 碎片问题:
页与页之间紧密排列,不会产生外部碎片
但是当需要分配的空间不足一个页,而分配内存的最小单位是一页,所以页内会出现内存浪费(内部内存碎片问题)
- 内存交换效率低问题:
若内存不够,操作系统会把其他正在运行的进程中 最近没被使用 的内存页面给释放掉,也就是暂时写入磁盘,称为换出(Swap out)。需要的时候,再加载进来,称为换入(Swap in). 这样就不会出现一次性写入磁盘大量的数据(一次只有少数一个页或几个页),不会耗费太多时间,内存交换效率就相对较低
只有在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里边去
虚拟地址 -> 物理地址 映射
虚拟地址分为页号和页内偏移。页号作为页表索引,页表包含物理页每页所在物理内存的基地址,这个基地址与页内偏移的组合形成了物理内存地址
内存地址转换步骤:
- 把虚拟内存地址切分成页号和偏移量
- 根据页号,从页表里边,查询对应的物理页号
- 直接拿物理页号,加上页面偏移量,就得到了物理内存地址
页表设计升级:
简单分页
对于32位操作系统,一个页表项 需要4字节大小存储,4GB相当于就是100多万个页表项,那么整个4GB空间的映射就需要4MB的内存来存储页表,若有100个进程,那么就需要400MB的内存来存储页表,这样对内存的耗费是很高的
多级页表
为了解决简单分页中的问题,提出了多级页表概念
将页表(一级页表)分为1024个页表(二级页表),每个二级页表中包含1024个 页表项,形成二级分页
若某个一级页表项没有被使用,那么就不需要创建整个页表项对应的二级页表(有点懒加载的意思)
对于64位操作系统,需要四级目录
- 全局页目录项PGD(Page Global Directory)
- 上层页目录项PUD(Page Upper Directory)
- 中间页目录项PMD(Page Middle Directory)
- 页表项目PTE(Page Table Entry)
TLB
多级页表解决了空间上的问题,但是虚拟地址到物理地址之间转换却多了几道工序,会降低地址转换速度,带来时间上的开销
程序的局部性,即在一段时间内,整个程序的执行权限仅限于程序中的某一个部分,相应地,执行所访问的存储空间也就局限于某个内存区域
利用这一特性,把最常访问的几个页表项存储到访问速度更快的硬件,在CPU中,有一个专门存放程序最常访问的页表项的Cache, 这个Cache就是TLB(Translation Lookaside Buffer), 通常称为页表缓存、旁路缓存、快表等
内存管理单元(MMU),用于完成地址转换和TLB的访问和交互,CPU寻址时,会先查TLB,没有才会继续查常规的页表(有点类似于MySQL查询先查缓存, 没有再去存储引擎读数据)
段页式内存管理
段页式内存管理的实现方式:
- 先将程序划分为多个有逻辑意义的段,也就是前面提到的分段机制
- 接着再把每个段划分为多个页,也就是对分段划分出来的连续空间
地址结构由段号、段内页号、页内位移三部分组成
虚拟地址 -> 物理地址 映射
- 第一次访问段表,得到页表起始地址
- 第二次访问页表,得到物理页号
- 第三次将物理页号与页内位移组合,得到物理地址
Linux内存布局
Linux内存主要采用的是页式内存管理,但通过是也涉及到段机制
Linux系统中的每个段都是从0地址开始的整个4GB虚拟空间(32位环境下),也就是所有段的起始地址都一样。意味着,Linux系统中的代码,包括操作系统本身的代码和应用程序代码,所面对的地址空间都是线性地址空间(虚拟地址),这种做法相当于屏蔽了处理器种的逻辑地址概念,段只被用于访问控制和内存保护
Linux虚拟地址空间分布
32位系统的内核空间占1GB,用户空间占3GB
64位系统内核空间和用户空间占用128T,分别占据整个内存空间的最高处和最低处,中间部分未定义
内核空间与用户空间的区别:
- 进程在用户态时,只能访问用户空间内存
- 进入内核态后,才可以访问内核空间内存
虽然每个进程都有各自独立的虚拟内存,但是每个虚拟内存中的内核地址,其实关联的都是相同的物理内存。这样,进程切换到内核态后,就可以很方便的访问内核空间内存
用户空间 - 以32位系统为例
从低到高分别是6种不同的内存段:
- 代码段,包括二进制可执行代码
- 数据段,包括已初始化的静态常量和全局变量
- BSS段,包括未初始化的静态变量和全局变量
- 堆段,包括动态分配的内存,从低地址开始向上增长 -> malloc()动态分配内存
- 文件映射段,包括动态库、共享内存等,从低地址开始向上增长 -> mmap()动态分配内存
- 栈段,包括局部变量和函数调用的上下文等。栈的大小是固定的,一般是8MB,当然也可以自定义
虚拟内存的作用
- 1.虚拟内存可以使得进程对运行内存可以超过物理内存大小,因为程序运行符合局部性原理,CPU访问内存会有很明显的重复访问的倾向,对于那些没有经常被使用的内存,就可以把它换出到物理内存之外,比如硬盘上的swap区域
局部性原理:
- 时间局部性:
如果程序中的某条指令一旦执行,则不久之后该指令可能再次被执行
如果某块数据被访问,则不久之后该数据可能再次被访问
- 空间局部性:
一旦程序访问了某个存储单元,则不久之后,其附近的存储单元也将被访问
》》》程序在运行后,对于内存的访问不会一下子就要访问全部内存,相反进程对于内存的访问会表现出明显的倾向性,更加倾向于访问最近访问过的数据一级热点数据附近的数据
无论一个进程实际可以占用的内存资源有多大,根据局部性原理,在某一段时间内,进程真正需要的物理内存其实时很少的一部分,我们只需要为每个进程分配很少的物理内存就可以保证进程的正常运转
- 2.每个进程有自己的页表,所以每个进程的虚拟内存空间就是相互独立的,进程也没有办法访问其他进程的页表,所以这些页表是私有的,这就解决了多进程间地址冲突问题
虚拟地址虽然一样,但是映射的实际物理地址不同
- 3.页表里的页表项除了物理地址之外,还有一些标记属性的比特,比如控制一个页的读写权限,标记该页是否存在等
进程虚拟内存空间
虚拟内存空间大致分布
代码段>
进程运行前,这些存放在二进制文件中的机器码需要被加载进内存中,用于存放这些机器码的虚拟内存空间就是代码段
数据段>
那些在代码中被指定了初始值的全局变量和静态变量在虚拟内存空间中的存储区域就是数据段
BSS段>
没有指定初始值的全局变量和静态变量在虚拟内存空间中的存储区域就是BSS段,这些变量被加载进内存后被初始化为0值
以上都是编译期确定的
堆>
OS堆,不是JVM中的堆
用于存放程序在运行期间动态申请的内存,就是堆
文件映射和匿名映射区>
- 程序运行过程中还需要依赖动态库链接,这些动态链接库以.so文件的形式存放在磁盘, 比如C程序中的glibc,里边堆系统调用进行了封装。glibc库里提供的用于动态申请堆内存的malloc函数就是堆系统调用sbrk和mmap的封装,这些动态链接库也有自己对应的代码段,数据段,BSS段,也需要加载进内存
- 用于内存文件映射的系统调用mmap,会将文件与内存进行映射,映射的这块内存(虚拟内存)也需要在虚拟地址空间中有一块区域存储
栈>
程序运行中调用的各种函数,调用函数过程中使用到的局部变量和函数参数也需要一块内存区域来保存,这块区域就是栈
虚拟内存空间分布
- 1.32位机器
32位机器的指针寻址范围2^32,所以能表达的虚拟内存空间为4GB,进程虚拟内存地址范围:0x0000 0000 - 0xFFFF FFFF
用户态虚拟内存空间为3GB,内核态虚拟内存空间为1GB
堆空间上的待分配区域,用于拓展堆空间的使用
栈下面的待分配区域,用于拓展栈空间
- 2.64位机器
64位机器的指针寻址范围2^64, 所以能表达的虚拟内存空间为16EB,进程虚拟内存地址范围:0x0000 0000 0000 0000 0000 - 0xFFFF FFFF FFFF FFFF
实际上,目前64位机器只用48位来描述虚拟内存空间,寻址范围为2^48, 所以能表达的虚拟内存空间为256TB,进程虚拟内存地址范围:
- 低128T表示用户态虚拟内存空间,0x0000 0000 0000 0000 - 0x0000 7FFF FFFF F000
- 高128T表示内核态虚拟内存空间,0xFFFF 8000 0000 0000 - 0xFFFF FFFF FFFF FFFF
虚拟内存空间管理
物理内存地址
内存:随机访问存储器(random-access memory),RAM
- 1.静态RAM(SRAM),用于CPU高速缓存L1Cache,L2Cache, L3Cache, 访问速度快,为1-30个时钟周期,容量小,造价高
- 2.动态RAM(DRAM), 常用于主存,访问速度慢(相对高速缓存),为50-200个时钟周期,容量大,造价便宜(相对高速缓存)
内存由一个个存储器模块(memory module)组成,它们插在主板的扩展槽上,常见的存储器模块通常以64位为单位(8字节)传输数据到存储控制器上或者从存储控制器传出数据
图中黑色的元器件就是存储器模块,多个存储器模块连接到存储控制器上就聚合成了主存
DRAM芯片包含在存储器模块中,每个存储器模块中包含8个DRAM芯片,依次编号为0-7
每一个DRAM芯片的存储结构是一个二维矩阵,二维矩阵中存储的元素就是超单元(supercell), 每个supercell大小为一个字节(8bit). 每个supercell都有一个坐标地址(i,j)
DRAM 芯片中的信息通过引脚流入流出DRAM芯片,每个引脚携带1bit信号
图中DRAM芯片包含了两个地址引脚(addr), 因为我们要通过RAS, CAS来定位要获取的supercell。还有8个数据引脚(data), 因为DRAM芯片的IO单位为一个字节(8bit),所以需要8个data引脚从DRAM芯片传入传出数据
DRAM芯片的访问
CPU如何读写主存
CPU与内存之间的数据交互是通过总线(bus)完成的,而数据在总线上的传送是通过一系列步骤完成的,这些步骤称为总线事务(bus transaction)
数据从内存到CPU,是读事务(read transaction)
数据从CPU到内存,是写事务(write transaction)
IO brige负责将系统总线上的电子信号转换成存储总线上的电子信号。IO brige也会将系统总线和存储总线连接到IO总线(磁盘等IO设备)上,IO brige起的作用就是转换不同总线上的电子信号
CPU从内存读取数据
CPU只会访问虚拟内存,在操作总线之前,需要把虚拟内存地址转换位物理内存地址
CPU每次都向内存读写一个cache line(64字节)的数据,但是内存一次只能吞吐8字节
DRAM0芯片存储第一个低位字节,DRAM1芯片存储第二个字节,依次类推,DRAM7存储最后有个高位字节
物理内存地址实际上不是连续的,因为连续的8字节其实存储于不同的DRAM芯片上,每个DRAM芯片存储一个字节(supercell)
CPU向内存写入数据
CPU将寄存器中的数据X写到物理内存地址A中
- 1.CPU将要写入的物理内存地址A放入系统总线
- 2.通过IO brige的信号转换,将物理内存地址A传递到存储总线上
- 3.存储控制器感受到存储总线上的地址信号,将物理内存地址A从存储总线上读取出来,并等待数据到达
- 4.CPU将寄存器中的数据拷贝到系统总线上,通过IO brige的信号转换,将数据传递到存储总线上
- 5.存储控制器感受到存储总线上的数据信号,将数据从存储总线上读取出来
- 6.存储控制器通过内存地址A定位到具体的存储器模块,最后将数据写入存储器模块中的8个DRAM芯片中
Malloc动态内存分配
明确一点,malloc不是系统调用,而是C库里的函数,用于动态分配内存
分配的是虚拟内存,若分配后的虚拟内存没有被访问,那么虚拟内存是不会被映射到物理内存的,这样就不会占用物理内存。只有在访问已分配虚拟地址空间的时候,操作系统通过查询页表,发现虚拟内存对应的页没有在物理内存中,就会触发缺页中断,然后操作系统中会建立虚拟内存和物理内存之间的映射关系
堆内存申请方式:
malloc申请内存时,会有两种方式向操作系统申请堆内存
- 1.通过brk() 系统调用从堆分配内存
通过brk()函数将 堆顶 指针向高地址移动,获得新的内存空间
- 2.通过mmap()系统调用在文件映射区域分配内存
通过mmap()系统调用中 私有匿名映射 方式,在文件映射区域分配一块内存,也就是从文件映射区 “偷” 了一块内存
使用场景:glibc版本不同,阈值也不同
malloc()源码默认的阈值控制> (设计思想有点像需要内存小就用数组紧凑式,反之则用链表)
若用户分配的内存小于128KB,则通过brk()申请内存
若用户分配的内存大于128KB,则通过mmap()申请内存
malloc()分配内存时,会预分配更大的空间作为内存池,所以说malloc(1)并不是分配1字节内存
- 若malloc通过brk()方式申请内存,free指令释放内存时,不会把内存归还给操作系统,而是缓存在malloc的内存池中,待下次使用
- 若malloc通过mmap方式申请内存,free指令释放内存时,会归还操作系统,内存得到真正的释放
malloc返回给用户的内存起始地址比进程的堆空间起始地址多了16字节,这16字节用于存储该内存块的描述信息,比如内存块的大小
当执行free函数时,free会对传入的内存地址向左偏移16字节,然后从这个16字节分析处当前内存块的大小,自然就知道要释放的内存有多大了(类似于redis很多底层结构都记录了数据长度)
不全部使用mmap或brk来分配内存的原因
- 1.针对mmap
- 向操作系统申请内存,要通过系统调用,执行系统调用要进入内核态,然后回到用户态,运行态的切换会耗费不少时间,申请内存操作要尽可能地避免频繁地系统调用,若每次都用mmap来分配,等于每次都要执行系统调用
- mmap分配地内存每次释放地时候,都会归还给操作系统,相当于每次mmap分配地虚拟地址都是缺页状态地,然后在第一次访问该虚拟地址地时候,都会触发缺页中断
频繁通过mmap分配内存地话,不仅每次都会发生运行态地切换,还会发生缺页中断(第一次访问虚拟地址后,会导致CPU消耗大)
- 2.针对brk
为了改进mmap分配地问题,提出brk分配方式
- 通过brk系统调用在堆空间申请内存的时候,由于堆空间是连续的,所以直接预分配更大的内存来作为内存池
- 释放内存时,就缓存在内存池中,不会归还给操作系统
等下次在申请内存的时候,就直接从内存池中取出对应的内存块就行了,而且整个内存块的虚拟地址与物理地址的映射关系还存在,这样不仅减少了系统调用的次数,也减少了缺页中断的次数,这就大大降低了CPU消耗
内存回收
内存分配过程
应用程序通过malloc函数申请内存的时候,实际上申请的是虚拟内存,不是物理内存
当应用程序读写这块内存的时候,CPU会去访问这个虚拟内存,会发现这个虚拟内存没有映射到物理内存,CPU会产生缺页中断,进程会切换到内核态,将缺页中断交给内核Page Fault Handler(缺页中断函数)处理
这时,缺页中断函数会看是否有空闲的物理内存,若有,就直接分配物理内存,并建立虚拟内存与物理内存之间的映射关系
若没有空闲的物理内存,内核就会开始进行内存回收工作
内存回收方式:
- 后台内存回收(kswapd): 在物理内存紧张的时候,会唤醒kswapd内核线程来回收内存,这个回收内存的过程是异步的,不会阻塞进程的执行
- 直接内存回收(direct reclaim): 若后台异步回收跟不上进程内存申请的速度,就会开始直接回收,这个回收过程是同步的,会阻塞进程执行
- 若直接内存回收后,空闲的物理内存仍然无法满足此次物理内存的申请,那么内核就会触发OOM
(OOM机制会根据算法选择一个占用物理内存较高的进程,然后将其kill掉以释放内存,若物理内存还是不够,则继续杀死物理内存较高的进程,直到释放足够的内存位置)(有时候进程莫名其妙被kill大概率就是这个原因)
哪些内存可以被回收 - 类似JVM中哪些对象可以被回收
- 1.文件页(File-backed Page):
内核缓存的磁盘数据(Buffer)和内核缓存的文件数据(Cache)都叫做文件页(类似于InnoDB的数据页)。大部分文件页,都可以直接释放内存,后面需要时,再重新读取到磁盘。被程序修改过的且没有被写入磁盘的苏韩剧(脏页,类似于InnoDB中的脏页概念),就得先写入磁盘,然后才能进行内存释放。所以,回收干净页的方式是直接释放内存,回收脏页的方式是先写回磁盘后再释放内存
- 2.匿名页(Anonymous Page):
没有实际载体,这个部分内存很可能还要再次被访问,所以不能直接释放内存,它们回收的方式是通过Linux的Swap机制,Swap会把不常访问的内存先写到磁盘中,然后释放这些内存,给其他更需要的进程使用。再次访问这些内存时,重新从磁盘读入内存就可以
文件页和匿名页的回收都依靠LRU算法(类似于InnoDB的LRU算法),优先回收不常访问的内存,其中维护了两个双向链表:
- active_list:活跃内存页链表,这里存放的是最近被访问的内存页
- inactive_list:不活跃内存页链表,这里存放的是很少被访问的内存页
内存回收的影响
- 1.文件页的回收:
对于干净页的直接回收,这个操作不会影响性能。
对于脏页,要先写回到磁盘再释放,这个操作有磁盘IO,会影响系统性能
- 2.匿名页的回收:
若开启了swap机制,那么swap会将不常访问的匿名页换出到磁盘中,下次访问时,再从磁盘换入到内存中,这个操作会影响性能
内存回收性能问题解决
调整文件页和匿名页的回收倾向
文件页的回收对性能影响较少,匿名页的swap换入换出操作都会发生磁盘IO
尽早触发kswapd内核线程异步回收内存 - 尽早触发 后台内存回收
后台内存回收和直接内存回收指标
- pgscank/s: kswapd(后台回收线程)每秒扫描的page个数
- pgscand/s: 应用程序在内存申请过程中每秒直接扫描的page个数
- pgstral/s: 扫描的page中每秒被回收的个数(pgscank + pgscand)
若系统时不时发生抖动,并且抖动期间,pgscand数值很大,那么大概率是因为直接内存回收导致
触发kswapd内核线程回收内存的条件
内核定义三个内存阈值(watermark 水位),用来衡量当前剩余内存(pages_free)是否充裕或者紧张,分别是:
- 页最小阈值(pages_min)
- 页低阈值(pages_low)
- 页高阈值(page_high)
kswapd会定期扫描内存的使用情况,根据剩余内存(pages_free)来进行内存回收
- pages_free > pages_high,说明剩余内存充足
- pages_low < pages_free <= pages_high, 说明内存有一定压力,但是还可以满足应用程序申请内存的请求
- pages_min < pages_free <= pages_low, 说明内存压力大,剩余内存不多,
这时kswapd0会执行内存回收,直到剩余内存大于高阈值为止
- pages_free <= pages_min, 说明内存都耗尽了,
此时会触发直接内存回收,此时应用程序会被阻塞
- 3.调整kswapd内核线程异步回收内存的时机
- 4.调整NUMA架构下内存回收策略
如何避免进程不被OOM机制杀掉
问题:4GB物理内存机器上申请8G内存的效果
前置条件:
- 操作系统是32位还是64位?
- 申请完8G内存后会不会被使用?
- 操作系统有没有使用Swap机制?
操作系统虚拟内存
32位操作系统的内核占用1G,位于最高处,剩下的是 1G 用户空间
64位操作系统的内核空间和用户空间都是128T,分别占据整个内存的最高和最低处,剩下的中间部分未定义
32位操作系统场景>
32位操作系统,进程最多能申请3GB大小的虚拟内存空间,所以进程申请8GB内存时,在申请虚拟内存阶段就会失败
64位操作系统场景>
64位操作系统,进程最多能申请128TB大小的虚拟内存空间,所以进程申请8GB内存内存是没有问题的,只要不读写这个虚拟内存,操作系统就不会分配物理内存
swap机制作用
以上是针对申请的物理内存大小不超过空闲物理内存大小的情况
若申请物理内存大小超过了空闲物理内存大小,就要看操作系统有没有开启swap机制
- 若没有开启swap机制,程序就会直接OOM,进程被杀
- 若有开启swap机制,程序就可以正常运行
当系统物理内存不足时,需要将内存数据换出磁盘,又从磁盘中恢复数据到内存的过程,就是swap机制
概念:
就是把一块磁盘空间或者本地文件,当成内存来使用,它包含换出和换入的过程
- 换出(swap out),就是把进程暂不使用的内存数据存储到磁盘,并释放这些数据占用的内存
- 换入(swap in), 就是在进程再次访问这些内存的时候,把它们从磁盘读到内存中来
优缺点:
优点:应用程序实际可以使用的内存空间将远超系统物理内存
缺点:频繁读写磁盘,会显著降低操作系统的运行速率(大量的磁盘IO)
触发时机:
- 内存不足
当系统需要的内存超过了可用的物理内存,内核会将内存中不常使用的内存页交换到磁盘上为当前进程让出内存,保证正在执行的进程的可用性。这个内存回收是强制的直接内存回收,会阻塞当前申请内存的进程
- 内存闲置
应用程序在启动阶段使用的大量内存在启动后往往都不会使用,通过后台运行的守护进程kswapd, 可以将这部分只使用一次的内存交换到磁盘上为其他内存申请预留空间。kswapd进程负责交换闲置空间。
如何避免预读失效和缓存污染问题 -> 如何改进LRU算法
预读失效问题
操作系统在读磁盘的时候会额外多读一些到内存中,但是最后这些数据也没有用到,相当于白干活
Redis的缓存淘汰算法是通过实现LFU算法来避免 缓存污染 而导致缓存命中率下降问题(Redis没有预读机制)
缓存污染问题
批量读取数据的时候,可能会把热点数据挤出去
MySQL和Linux操作系统是通过改进LRU算法来避免 预读失效和缓存污染 而导致缓存命中率下降问题
Linux和MySQL的缓存
Linux操作系统的缓存
应用程序读取文件数据的时候,Linux会对文件数据进行缓存,会缓存在文件系统中的Page Cache(如页缓存)
Page Cache属于内存空间里的数据,由于内存访问比磁盘访问快很多,在下一次访问相同数据就不需要通过磁盘IO,命中缓存就直接返回,加速数据访问
MySQL的缓存
其实就是InnoDB中的Buffer Pool
读取数据时,若数据在Buffer Pool中,客户端就会直接读取Buffer Pool中的数据,否则再去磁盘中读取
当修改数据时,首先修改Buffer Pool中数据所在的页,然后将其设置为脏页(flush链表),最后由后台线程将脏页写入到磁盘
传统LRU算法
Linux的Page Cache和MySQL的Buffer Pool缓存的基本单位都是页(Page)
实现思路:
若访问的页在内存里,就直接把该页对应的LRU链表节点移动到链表的头部
若访问的页不在内存里,除了要把对应的页放入LRU链表头部,还要淘汰LRU链表末尾的页
存在的问题
预读失效
Linux操作系统的预读机制
- 应用程序读取一部分数据,但是操作系统还是会读取一页,不管这个页中的数据是否完全需要
- 操作系统出于空间局部性原理(也就是说认为靠近当前被访问的数据,在未来很大概率也会被访问),会选择多加载临近的几个页的数据
MySQL预读机制
- 从磁盘加载页时,会将相邻的页一并加载
目的都是为了减少磁盘IO
若被提前加载进来的数据并没有被访问,这个预读工作相当于就是白干,这就是预读失效
若不会被访问的预读页占用了很多LRU链表的前排位置,而淘汰末尾的页,可能时热点数据,这样就大大降低了缓存命中率
问题解决:二者思想一毛一样
Linux的方案:
实现两个LRU链表:活跃LRU链表(active_list)和非活跃LRU链表(inactive_list)
active_list: 活跃内存页链表,存放最近被访问过(活跃)的内存页
inactive_list: 非活跃页链表,这里存放的是很少被访问(非活跃)的内存页
预读页只需要加入到inactive list区域的头部,当页被真正访问时,才将页插入到active list头部。若预读页一直没有被访问,就会从inactive list移除,这样就不会影响active list中的热点数据
MySQL方案
InnoDB在一个LRU链表上划分两个区域,young区域和old区域
预读页就只需要加入到old区域头部,当页被真正访问的时候,才将页插入young区域的头部,若页一直没有被访问,就会从old区域移除,这样就不会影响young区域中的热点数据
缓存污染
当批量读取数据的时候,由于数据被访问了一次,这些数据都会被加入到 活跃LRU链表 里,然后之前缓存在活跃LRU链表(或者young区域)里的热数据全部被淘汰了,若这些大量的数据的很长时间都不被访问的话,那么整个活跃LRU链表(或者young区域)就被污染了
问题解决:
只要提高进入到活跃LRU链表(或者young区域)的门槛,就能有效保证活跃LRU链表(或者young区域)中的热点数据不会轻易被替换
在批量读取数据的时候,若这些大量数据只会被访问一次,那么它们就不能进入到活跃链表LRU链表(或者young区域)
Linux方案
在内存页被访问第二次的时候,才将页从inactive list升级到active list里
MySQL方案
在内存页被访问第二次的时候,并不会马上将该页从old区域升级到young区域,因为还要进行停留old区域的时间判断:
- 若第二次访问的时间与第一次访问的时间在1秒内(默认值),那么该页就不会被从old区域升级到young区域
- 若第二次访问的时间与第一次访问的时间超过1秒,那么该页就会从old区域升级到young区域
进程管理
进程:
编写的代码被编译后生成一个二进制可执行文件,当运行这个可执行文件后,它会被加载到内存,然后CPU会执行程序中的每一条指令,那么这个运行中的程序,就是 进程(Process)
CPU不需要阻塞等待数据的返回,而是去执行另外的进程。当数据返回时,触发一个中断,CPU收到这个中断再继续运行这个进程
并发和并行的区别
进程与程序的关系类比
CPU可以从一个进程切换到另外一个进程,在切换前必须要记录当前进程运行中的状态,以备下次切换回来的时候可以恢复执行(运行-暂停-运行)
进程的状态
一个进程活动期间至少具备三种基本状态,即运行状态、就绪状态、阻塞状态
运行状态:Running, 该时刻进程占用CPU
就绪状态:Ready,可运行,由于其他进程处于运行状态而暂时停止运行
阻塞状态:Blocked, 该进程正在等待一个事件发生(比如等待输入输出操作完成)而暂时停止运行,这时,即使给他CPU控制权,它也无法运行
...
创建状态:new, 进程正在被创建时的状态
结束状态:exit, 进程正在从系统中消失时的状态
状态变迁>
- NULL -> 创建状态:一个新进程被创建时的第一个状态
- 创建状态 -> 就绪状态:当进程被创建完成并初始化后,一切就绪准备运行时,变为就绪状态,这个过程很快
- 就绪态 -> 运行状态:处于就绪态的进程被操作系统的进程调度器选中后,就分配CPU正式运行该进程
- 运行状态 -> 结束状态:当进程已经运行完成或者出错时,会被操作系统结束状态处理
- 运行状态 -> 就绪状态:处于运行状态的进程在运行过程中,由于分配给它的运行时间片用完,操作系统会把该进程变为就绪态,接着从就绪态中另外选一个进程运行
- 运行状态 -> 阻塞状态:当进程请求某个事件且必须等待时,例如请求IO事件
若出现大量的阻塞状态的进程,进程可能会占用物理内存空间
- 阻塞状态 -> 就绪状态:当进程要等待的事件完成时,它从阻塞状态变到就绪状态
挂起状态:
描述进程没有占用实际的物理内存空间的情况
虚拟内存管理的操作系统中,通常会把阻塞状态的物理内存空间换出到磁盘,等需要运行的时候,再从磁盘换入到物理内存
挂起状态分类:
- 阻塞挂起状态:进程在外存(硬盘)并等待某个事件的出现
- 就绪挂起状态:进程在外存(硬盘),但只要进入内存,即可立刻运行
导致进程被挂起的原因:
- 进程所使用的内存空间不是物理内存
- 通过sleep让进程间歇性挂起(工作原理:设置一个定时器,到期后唤醒进程)
- 用户希望挂起一个程序的执行,比如Linux中的crtl+Z挂起进程
进程控制结构
进程控制块(process control block, PCB), 进程存在的唯一标识
内容>
-
进程描述信息
- 进程标识符:标识各个进程,每个进程都有一个并且唯一的标识符
- 用户标识符:进程归属的用户,用户标识主要为共享和保护服务
-
进程控制和管理信息
- 进程当前状态,如new, ready, running,waiting, blocked等
- 进程优先级:进程抢占CPU时的优先级
-
资源分配清单
- 有关内存地址空间或虚拟地址空间的信息,所打开文件的列表和所使用的IO设备信息
-
CPU相关信息
- CPU中各个寄存器的值,当进程被切换时,CPU状态信息都会被保存在相应的PCB中,以便进程重新执行时,能从断点出继续执行
PCB组织方式
链表方式组织
把相同状态的进程链在一起,组成各种队列
一般情况下默认选择,在面临进程创建,销毁等调度导致进程状态发生变化时,链表更加灵活,方便插入和删除
就绪队列:将所有处于就绪状态的进程链在一起
阻塞队列:将所有因等待某事件而处于阻塞状态的进程链在一起
索引方式组织
将同一状态的进程组织在一个索引表中,索引表项指向相应的PCB,不同状态对应不同的索引表
进程的控制
- 1.创建进程
操作系统允许一个进程创建另一个进程,且允许子进程继承父进程的所有资源
- a.申请一个空白PCB,并向PCB中填写一些控制和管理进程的信息,比如进程的唯一标识
- b.为该进程分配运行时所必须的资源,比如内存
- c.将PCB插入到就绪队列,等待被调度运行
- 4.终止进程
进程终止三剑客:正常结束、异常结束、外界干预(信号kill掉)
- a.查找需要终止的进程的PCB
- b.若处于执行状态,则立即终止该进程的执行,然后将CPU资源分配给其他进程
- c.若其还有子进程,则应将该进程的子进程交给1号进程接管
- d.将该进程的所有资源归还给操作系统
- e.将其从PCB所在队列中删除
- 6.阻塞进程
进程一旦被阻塞等待,它只能由另一个进程唤醒
- a.找到将要被阻塞进程标识号对应的PCB
- b.若该进程为运行状态,则保护其线程,将其转换为阻塞状态,停止运行
- c.将该PCB插入到阻塞队列中
-
4.唤醒进程
- a.在该事件的阻塞队列中找到相应进程的PCB
- b.将其从阻塞队列中移出,并置其状态为就绪状态
- c.把该PCB插入到就绪队列中,等待调度程序调度
进程上下文切换
各个进程之间共享CPU资源,一个进程切换到另一个进程运行,就是进程上下文切换
📌
CPU上下文: CPU寄存器和程序计数器是CPU在运行任何任务前,所有必须依赖的环境,这些环境就是CPU上下文
CPU上下文切换:就是把前一个任务的CPU上下文(CPU寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最好再跳转到程序计数器所指的新位置,运行新任务
进程上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间资源
把交换的信息保存在进程的PCB,当要运行另一个进程的时候,需要从这个进程的PCB取出上下文,然后恢复到CPU中,这个进程才能继续执行
进程上下文切换场景
- 1.为保证所有进程的公平调度,CPU时间片被轮流分配给各个进程。进程的时间片耗尽,就切换
- 2.进程在系统资源不足时,需要等到资源满足后才能运行,这时进程会被挂起,并调度其他进程运行
- 3.进程通过sleep将自己挂起 ,重新调度时
- 4.优先级更高的进程运行时,当前进程会被挂起,优先级更高的先运行
- 5.硬件中断,CPU上的进程被中断挂起,转而执行内核中的中断服务程序
线程
能独立运行的基本单位
线程之间可以并发运行且共享相同的地址空间
使用线程的原因
- 单进程中多个步骤依次执行,效率低,各个步骤无法并发执行
- 多进程中,需要共享相同的地址空间
线程是进程中的一条执行流程
同一个进程内多个线程间可以共享代码段、数据段、打开的文件等资源,但是每个线程各自拥有一套独立的寄存器和栈,这样保证线程的控制流是相对独立的
线程的优缺点
优点:
- 一个进程中可以存在多个线程
- 各个线程间可以并发执行
- 各个线程间可以共享地址空间和文件资源等
缺点:
进程中的一个线程崩溃,会导致其所属进程的所有线程崩溃(针对C/C++语言,Java语言不会)
线程与进程的比较
-
1.进程是资源(包括内存、打开的文件等)分配的基本单位,线程是CPU调度的基本单位
-
2.进程拥有一个完整的资源平台,而线程只独享必不可少的资源,如寄存器和栈
-
3.线程同样具有就绪、阻塞、运行三种基本状态,同样具有状态之间的转换关系
-
4.线程能减少并发执行的时间和空间开销
- a.线程的创建时间比进程快,因为进程创建过程中,还需要资源管理资源,比如内存管理信息、文件管理信息,而线程在创建过程中不会涉及这些资源管理信息,只是共享它们
- b.线程的终止时间比进程快,因为线程释放地资源相比进程少很多
- c.同一个进程内地线程切换比进程切换快,因为线程具有相同地地址空间(虚拟内存共享),这意味着同一个进程的线程都具有同一个页表,那么在切换地时候就不需要切换页表。而对于进程之间的切换,是要把页表给切换掉,而页表的切换过程开销比较大
- d.由于同一个进程的各线程之间共享内存和文件资源,那么在线程之间传递数据的时候,就不需要经过内核,这就使得线程之间的数据交换效率提高了
线程上下文切换
操作系统的任务调度,实际上的调度对象是线程,而进程只是给线程提供虚拟内存、全局变量等资源
当进程只有一个线程时,可以认为进程等于线程
当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源,这些在上下文切换时是不需要修改的
线程也有自己的私有数据,比如栈和寄存器等,这些在上下文切换时需要保存
上下文切换的内容:线程上下文切换相比进程,开销要小很多
若两个线程不属于同一个进程,那么切换过程就和进程上下文切换一样
若两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据
线程的实现
实现方式:
- 1.用户线程:User Thread, 在用户空间实现的线程,不是由内核管理的线程,是由用户态的线程库来完成线程的管理
- 2.内核线程:Kernel Thread, 在内核中实现的线程,是由内核管理的线程
- 3.轻量级进程: LightWeight Process, 在内核中来支持用户线程
用户线程和内核线程的对应关系
- 1.一对多
- 2.一对一
- 3.多对多
用户线程:
用户线程是基于用户态的线程管理库来实现的,线程控制块(Thread Control Block, TCB)也是在库里边来实现的,对于操作系统而言是看不到这个TCB的,它只能看到整个进程的PCB
用户线程的整个线程管理和调度,操作系统不直接参与,而是由用户线程库函数来完成线程的管理,包括线程的创建、终止、同步和调度等
Java创建的线程就是用户线程,由JVM调度
用户级线程模型:
优缺点:
优点>
- 每个进程都需要它私有的线程控制块(TCB)列表,用来跟踪记录它各个线程状态信息(PC、栈指针、寄存器),TCB由用户级线程库函数来维护,可用于不支持线程技术的操作系统
- 用户线程的切换也是由线程库函数来完成,无需用户态与内核态的切换,所以速度特别快
缺点>
- 由于操作系统不参与线程的调度,若一个线程发起了一个系统调用而阻塞,那进程所包含的用户线程都不能执行了
- 当一个线程开始运行后,除非它主动交出CPU的使用权,否则它所在的进程当中的其他线程无法运行,因为用户态的线程无法打断当前运行中的线程,它没有这个特权,只有操作系统才有,但是用户线程不是操作系统管理
- 由于时间片分配给进程,故与其他进程比,在多线程执行时,每个线程得到的时间片比较少,执行会比较慢
内核线程:
内核线程是由操作系统管理的,线程对应的TCB自然是放在操作系统里的,这样线程的创建、终止、管理都是由操作系统负责
内核线程模型:
优缺点:
优点>
- 在一个进程当中,若某个内核线程发起系统调用而被阻塞,并不会影响其它内核线程的运行
- 分配给线程,多线程的进程获得更多的CPU运行时间
缺点>
- 在支持内核线程的操作系统中,由内核来维护进程和线程的上下文信息,如PCB和TCB
- 线程的创建、终止和切换都是通过系统调用方式来进行,因此对于系统来说,系统开销比较大
轻量级进程:
Light-weight process. LWP, 是内核支持的用户线程,一个进程可有一个或多个LWP,每个LWP是跟内核线程一对一映射的,也就是LWP都是由一个内核线程支持,而且LWP是由内核管理并像普通进程一样被调度
LWP跟普通进程的区别在于它只有一个最小的执行上下文和调度程序所需的统计信息
LWP与用户线程的对应关系:
- 1.1:1
一个LWP对应一个用户线程,如上图进程4
优点:实现并行,当一个LWP阻塞,不会影响其他LWP
缺点:每一个用户线程,就产生一个内核线程,创建线程的开销比较大
- 2.N:1
一个LWP对应多个用户线程,如上图进程2,线程管理是在用户空间完成的,此模式中用户的线程对操作系统不可见
优点:用户线程要开几个都没问题,且上下文切换发生在用户空间,切换效率高
缺点:一个用户线程若阻塞了,则整个进程都将被阻塞,另外在多核CPU中,没有办法充分利用CPU
- 3.M:N
多个LWP对应多个用户线程,如上图进程3
优点:综合前两种的优点,大部i分的线程上下文切换发生在用户孔吉纳,且多个线程又可以充分利用多核CPU资源
- 4.组合模式
1:1和M:N的组合,如上图进程5
调度
进程调度:只有主线程的进程的调度
调度时机:
在进程的生命周期中,当进程从一个运行状态到另外一个运行状态变化的时候,就会触发一次调度
-
1.状态变化触发操作系统调度
- a.就绪态 -> 运行态:进程被创建时,会进入到就绪队列,操作系统就会从就绪队列选择一个进程运行
- b.运行态 -> 阻塞态:进程发生IO事件阻塞时,操作系统必须选择另外一个进程运行
- c.运行态 -> 结束态:进程退出结束后,操作系统得从就绪队列中选择另外一个进程运行
-
2.硬件中断,如硬件时钟提供某个频率的周期性中断
- a.非抢占式调度算法:不理会时钟中断,进程被阻塞或者进程退出,才会调用其他进程
- b.抢占式调度算法:CPU时间片执行完,就被挂起,然后调度程序从就绪队列中挑选另外一个进程。这种方式需要在时间间隔的末端发生时钟中断,以便把CPU控制返回给调度程序进行调度,也就是常说的时间片机制
调度原则
- 1.原则一:为了提高CPU使用率,在这种发送IO事件致使CPU空闲的情况下,调度程序需要从就绪队列种选择一个进程来运行
- 2.原则二:要提高系统吞吐率,调度程序要权衡长任务和短任务进程的运行完成数量
- 3.原则三:进程周转时间=进程运行时间+进程等待时间,进程周转时间越小越好,若进程的等待时间很长而运行时间很短,那周转时间就很长,调度程序要避免这种情况发生
- 4.原则四:就绪队列中进程的等待时间也是调度程序所要考虑的,尽可能短
- 5.原则五:对于交互式比较强的应用,响应时间也是需要调度程序考虑的
- CPU利用率:调度程序应确保CPU是始终匆忙的状态,可提高CPU的利用率
- 系统吞吐量:吞吐量表示的是单位时间内CPU完成进程的数量,长作业的进程会占用较长的CPU资源,因此会降低吞吐量,相反,短作业的进程会提升系统吞吐量
- 周转时间:周转时间=进程运行时间+阻塞时间+等待时间,一个进程的周转时间越小越好
- 等待时间:进程处于就绪状态的时间,等待时间越长,用户越不满意
- 响应时间:用户提交请求到系统第一次产生响应所花费的时间,在交互式系统中,响应时间是衡量调度算法好坏的标准
目标就是要使得进程要 快 快 快
调度算法
单核CPU系统中:
- 1.先来先服务调度算法 First Come First Serve, FCFS
就是讲究先来后到,每次从就绪队列中选择最先进入队列的进程,然后一直运行,直到进程退出或者被阻塞,才会继续从队列中选择第一个进程接着运行
适用于CPU繁忙型作业的系统,不适用IO繁忙型系统
- 2.最短作业优先调度算法 Shortest Job First, SJF
优先选择运行时间最短的进程来运行,有助于提高系统的吞吐量
但是对长作业不友好,周转时间变长
- 3.高响应比优先调度算法 Hightest Response Ratio Next, HRRN 无法实现
权衡了短作业和长作业
每次进行进程调度时,先计算 响应比优先级,然后把 响应比优先级 最高的进程投入运行
- 4.时间片轮转调度算法 Round Robin, RR
时间片:每个进程被分配一个时间段,允许该进程在该时间段中运行
- 若时间片用完,进程还在运行,那么将会把此进程从CPU释放出来,并把CPU分配给另外一个进程、
- 若该进程在时间片结束前阻塞或者技术,则CPU立即切换
时间片长度:
- 若时间片设置的太短会导致过多的进程上下文切换,降低CPU效率
- 若设的太短又可能引起对短作业进程的响应变长
一般将时间片设置为20ms~50ms
- 5.最高优先级调度算法 Hightest Priority First, HPF
优先级越高,越被优先调度
进程优先级:
- 静态优先级:创建进程时候,就确定了优先级,然后整个运行时间优先级都不变
- 动态优先级:根据进程的动态变化调整优先级,比如进程运行时间增加,优先级降低等
处理优先级高的方法:
- 非抢占式:当就绪队列中出现优先级高的进程,运行完当前进程,再选择优先级高的进程
- 抢占式:当就绪队列中出现优先级高的进程,当前进程挂起,调度优先级高的进程运行
- 6.多级反馈队列调度算法 Multilevel Feedback Queue
是 时间轮转算法和最高优先级算法的综合
多级:表示有多个队列,每个队列优先级从高到低,同时优先级越高时间片越短
反馈:表示若有新的进程加入优先级高的队列时,立刻停止当前正在运行的进程,转而去运行优先高的队列
进程间的通信方式
每个进程的用户空间都是独立的,不能相互访问,但是内核空间是每个进程共享的,所以进程间的通信必须通过内核
管道
匿名管道:| 表示的管道是匿名管道,用完就销毁
管道,就是内核里边一串缓存。数据写入到内核中,也从内核中读取
通信范围是存在父子关系地进程,因为管道没有实体,也就是没有管道文件,只能通过fork来复制父进程fd文件描述符,来达到通信的目的
fork创建子进程,创建地子进程会复制父进程地文件描述符,两个进程各自拥有fd[0]和fd[1], 两个进程就可以通过各自地fd写入和读取同一个管道文件实现跨进程通信
管道只能一端写入,另一端读取
通常地做法是:
父进程关闭读取地fd[0], 只保留写入地fd[1]
子进程关闭写入地fd[1], 只保留读取地fd[0]
若要实现双向通信,则需要创建两个管道
命名管道:FIFO,数据先进先出
管道这种通信方式效率低,不适合进程间频繁地交换数据
在不相关地进程间也能相互通信,因为命名管道,提前创建了一个类型为管道地设备文件,在进程里,只要使用了这个设备文件,就可以互相通信
消息队列
管道通信方式效率低,不适合进程间频繁的交换数据
消息队列是保存在内核中的消息链表。发送数据时,就是将消息体(数据块)存入链表,读取数据就是从内核中将这个消息体从消息链表中删除
读取和写入都是在用户态和内核中之间切换,拷贝数据,比较耗费性能
缺点:
- 消息队列不适合比较大的数据的传输,Linux内核中有两个宏定义MSGMAX和MSGMNB, 以字节为单位,分别定义了一条消息的最大长度和一个队列的最大长度
- 消息队列通信过程中,存在用户态和内核态之间的数据拷贝开销。因为消息是保存在内核中的
共享内存
消息队列需要在用户态和内核态之间频繁切换
共享内存机制,就是拿出一块虚拟地址空间来,映射到相同的物理内存中。(正常情况下,进程的虚拟内存空间是私有的,相同的虚拟内存地址,映射到的物理内存地址也不同)
信号量
共享内存通信方式,在多进程同时修改同一个共享内存,可能会发生冲突
为了防止多 进程竞争共享资源,而造成的数据错乱,所以需要保护机制,使得共享的资源,在任何时刻只能被一个进程访问
信号量其实就是一个整数计数器,主要用于实现进程间的互斥与同步,而不是同于缓存进程间通信的数据
信号量表示资源的数量,控制信号量的方式有两种原子操作:必须成对出现
- P操作,这个操作会把信号量减去1,相减后若信号量 < 0, 则表示资源已被占用,进程需要阻塞;相减后若信号量 >= 0, 则表示还有资源可用,进程可正常继续执行
用在进入共享资源之前
类似Java中的Semaphore的acquire
- V操作,这个操作会把信号量加上1,相加后若信号量 <= 0, 则表示当前有阻塞中的进程,于是会将该进程唤醒运行;相加后若信号量 > 0, 则表示当前没有阻塞中的进程
用在离开共享资源之后
类似Java中的Semaphore的release
示例演示:
信号初始为1,表示互斥信号量
- 进程A在访问共享内存前,先执行P操作,信号量变为1-1=0,表示资源可用,于是进程A就可以访问共享内存
- 若此时,进程B也想访问共享内存,执行了P操作,信号量变为-1,意味着临界资源已经被占用,因此进程B被阻塞
- 直到进程A访问完成共享内存,才会执行V操作,使得信号量变为0,接着唤醒阻塞中的进程B,使得进程B可以访问共享内存,最好完成共享内存的访问后,执行V操作,信号量恢复为初始值1
用信号量来实现多进程同步的方式,可以初始化信号量为0,表示同步信号量
- 若进程B比进程A先执行了,那么执行到P操作时,由于信号量初始值为0,信号量会变为-1,表示进程A还没生产数据,于是B就阻塞等待
- 接着,当进程A生产完数据后,执行了V操作,信号量变为0,于是就会唤醒阻塞在P操作的进程B
- 最后,进程B被唤醒后,意味着进程A已经生产了数据,于是B进程就可以正常读取数据了
信号
在异常情况下的工作模式,就需要 信号 方式来通知进程
比如:
Ctrl + C 产生 SIGINT信号,表示终止该进程
Ctrl + Z 产生 SIGSTP信号,表示停止该进程,但还未结束
Kill -9 1050, 表示给PID为1050的进程发送SIGKILL信号,用来立即结束该进程
信号是进程间通信机制中唯一的异步通信机制。可以在任何时候发送信号给某一个进程,一旦有信号产生,用户进程堆信号就会有处理:
- 执行默认操作。Linux对每种信号都规定了默认操作
- 捕捉信号,可以为信号定义一个信号处理函数,当信号发生时,我们就执行相应的信号处理函数
- 忽略信号,就是对某些信号进行忽略,不处理,但是SIGKILL 和 SEGSTOP是无法捕捉和忽略的
以上都是同一台主机上进程间的通信,无法跨网络和不同主机的进程间通信
Socket
socket类型 -> 通信方式:
- 实现TCP字节流通信: socket类型是AF_INET和SOCK_STREAM
- 实现UDP数据包通信:socket类型是AF_INET和SOCK_DGRAM
udp是没有链接的,所以不需要三次握手,不需要像TCP那样的listen和connect
- 实现本地进程间通信:本地字节流socket 类型是AF_LOCAL和SOCK_STREAM,本地数据包socket 类型是AF_LOCAL和SOCK_DGRAM, AF_UNIX和AF_LOCAL是等价的,所以AF_UNIX也属于本地socket
本地socket被用于在同一台主机上进程间通信的场景,本地字节流socket和本地数据包socket在bind的时候,是绑定一个本地文件
多线程冲突
竞争与协作
一个程序只有一个执行流程,那么它是单线程的。一个程序有多个执行流程,那么它是多线程的,线程是调度的基本单位,进程是资源分配的基本单位
线程之间可以共享进程的资源,比如代码段、堆空间、数据段、打开的文件资源等,但每个线程都有自己独立的栈空间
互斥
当线程相互竞争操作共享变量时,由于运气不好,即在执行过程中发生了上下文切换,每次都可能得到不同的结果,因此输出的结果存在不确定性
多线程执行操作共享变量代码可能会导致竞争状态,这段代码就是临界区(critical section), 它是访问共享资源的代码片段,一定不能给多线程同时执行
互斥:保证一个线程在临界区执行时,其他线程应该被阻止进入临界区
大白话就是:你别进来
操作A和操作B不能同时执行
同步
同步:就是并发进程/线程在一些关键点上可能需要互相等待互通消息,这种相互制约的等待与互通信息称为进程/线程同步
大白话就是,我在前面等你
操作A应在操作B之前执行,操作C应在操作A和操作B之后执行
互斥与同步的实现和使用
锁
使用加锁、解锁操作可以解决并发线程/进程互斥问题
忙等待锁(自旋锁)spin lock
原子操作指令-测试和置位指令(Test-and-Set)
最简单的锁,一直自旋,利用周期,直到锁可用。在单处理器上,需要抢占式调度器(即不断通过时钟中断一个线程,运行其他线程)。否则,自旋锁在单CPU上无法使用,因为一个自旋的线程永远不会放弃CPU
无忙等待锁
获取到锁的时候,不用自旋,把当前线程放入到锁的等待队列,然后执行调度程序,把CPU让给其他线程执行
信号量
信号量操作系统提供的一种协调共享资源访问的方法
信号量表示资源的数量,对应变量是一个整型(sem)变量
两个原子操作的系统调用函数来控制信号量:
- P操作:sem-1, 相减后,若sem < 0, 则进程/线程进入阻塞等待,否则继续,表明P操作可能会阻塞
- V操作:sem+1, 相加后,若sem <= 0, 唤醒一个等待中进程/线程,表明V操作不会阻塞
信号量数据结构与PV操作算法:
示例:
📌
若sem = 1,有三个线程进行了P操作
- a.第一个线程P操作后,sem=0;
- b.第二个线程P操作后,sem=-1;
- c.第三个线程P操作后,sem=-2;
- d.第一个线程执行V操作后,serm=-1, 因为sem <= 0, 所以要唤醒第二或第三个线程
使用:
- 信号量实现临界区的互斥访问
若互斥信号量为1,表示没有线程进入临界区
若互斥信号量为0, 表示有一个线程进入临界区
若互斥信号量为-1,表示有一个线程进入临界区,另一个线程进入等待区
- 信号量实现事件同步 - 信号量初始值为0
生产者-消费者问题
生产者-消费者问题描述:
- 生产者在生成数据后,放在一个缓冲区
- 消费者从缓冲区取出数据处理
- 任何时刻,只能有一个生产者或者消费者可以访问缓冲区
问题分析结果:
- 任何时刻,只能有一个线程操作缓冲区,说明操作缓冲区是临界代码,需要互斥
- 缓冲区空时,消费者必须等待生产者生成数;缓冲区满时,生产者必须等待消费者取出数据,说明生产者和消费者需要同步
问题解决:
- 互斥信号量mutex: 用于互斥访问缓冲区,初始化值为1;
- 资源信号量fullBuffers:用于消费者询问缓冲区是否有数据,有数据则读取数据,初始化值为0(表明缓冲区一开始为空)
- 资源信号量emptyBuffers: 用于生产者询问缓冲区是否有空位,有空位则生成数据,初始化值为n(缓冲区大小)
死锁
概念:
两个线程为了保护两个不同的共享资源而使用了两个互斥锁,当两个互斥锁应用不当时,可能会造成两个线程都在等待对方释放锁,这就是死锁
死锁同时满足的触发条件:
- 互斥条件
多个线程不能同时使用同一个资源
- 持有并等待条件
当线程A已经持有了资源1,又想申请资源2,而资源2已经被资源C持有,所以线程A就会处于等待状态,但是线程A在等待资源2的同时又不会释放自己已经持有的资源1
- 不可剥夺条件
当线程持有了资源,在自己使用完之前不能被其他线程获取,线程B若想使用此资源,则只能在线程A使用完成之后才能获取
- 环路等待条件
在死锁发生的时候,两个线程获取资源的顺序构成环形链
代码实现:
死锁排查:
📌
Java直接使用jstack工具分析或者arthas工具排查(thread -b快速定位处于死锁的线程)
问题避免:
最常见的并且可行的就是使用资源有序分配法,来破坏路等待条件
锁
互斥锁&自旋锁
最底层锁,很多高级锁基于它们实现
加锁的目的是保证共享资源在任意时间里,只有一个线程访问,这样就可以避免多线程导致贡献数据错乱问题
当已经又一个线程加锁后,其他线程加锁就会失败,互斥锁和自旋锁对于加锁失败后的处理方式不一样:
- 互斥锁加锁失败后,线程会释放CPU,给其他线程
- 自旋锁加锁失败后,线程会忙等待,直到它拿到锁
互斥锁:
互斥锁是独占锁,加锁失败,就会释放CPU,也就意味着加锁的代码被阻塞
对于互斥锁加锁失败而阻塞的现象,是由操作系统内核实现的
互斥锁加锁失败时,会从用户态陷入到内核态,让内核帮我们切换线程,虽然简化了使用锁的难度,但是存在一定的性能开销成本(两次上下文切换的成本)
- 线程加锁失败时,内核会把线程状态从 运行 状态设置为 睡眠状态,然后把CPU切换给其他线程运行
- 接着,当锁释放时,之前 睡眠 状态的线程会变为 就绪状态,然后内核会在合适的时间,把CPU切换给该线程运行
线程上下文切换:
当两个线程属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据
若能确定被锁住的代码执行时间很短,就不应该用互斥锁,而应该用自旋锁,否则使用互斥锁
自旋锁:
通过CPU提供的CAS函数,在用户态完成加锁和解锁的操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快些,开销也会小一些
自旋锁是基于CAS,加了while或者睡眠CPU的操作而产生自旋的效果,加锁失败会忙等待直到拿到锁,自旋锁是需要先拿到锁才能修改数据的,所以是悲观锁
加锁步骤:两步合成一个原子指令
- 1.查看锁的状态,若锁是空闲的,则执行第二步
- 2.将锁设置为当前线程持有
自旋锁是最比较简单的一种锁,一直自旋,利用CPU周期,直到锁可用。在单核CPU上,需要抢占式的调度器(即不断通过时钟中断一个线程,运行其他线程)。否则,自旋锁在单CPU上无法使用,因为一个自旋的线程永远不会主动放弃CPU
自旋锁开销少,在多喝系统下一般不会主动产生线程切换吗,适合异步、协程等在用户态切换请求的编程方式,但若被锁住的代码执行时间过长,自旋的线程会长时间占用CPU资源,所以自旋的时间和被锁住的代码执行时间是成正比关系
读写锁
读写锁适用于能明确区分读操作和写操作的场景
在读多写少的场景,能发挥出优势
工作原理:
- 当 写锁 没有被线程持有时,多个线程能够并发持有读锁,这大大提高了共享资源的访问效率,因为 读锁 是用于读取共享资源的场景,所以多个线程同时持有读锁也不会破坏共享资源的数据
- 但是,一旦 写锁 被线程持有后,读线程的获取读锁的操作会被阻塞,而且其他写线程的获取写锁的操作也会被阻塞
写锁是独占锁,类似于互斥锁和自旋锁
读锁是共享锁,读锁可以被多个线程同时持有
公平读写锁比较见到那的一种方式是:用队列把获取锁的线程排队,不管是写线程还是读线程都按照FIFO原则加锁,这样读线程仍然可以并发,也不会出现 饥饿 现象
读优先锁
写优先锁
乐观锁&悲观锁
互斥锁、自旋锁、读写锁都是悲观锁
悲观锁:
认为多线程同时修改共享资源的概率比较高,容易出现冲突,所以访问共享资源前,先上锁
乐观锁:在线文档、SVN、Git采用方案(冲突概率非常低,加锁成本很高的场景使用)
若多线程同时修改共享资源的概率比较低,就可以使用乐观锁
先修改完共享资源,再验证这段时间内有没有发生冲突,若没有其他线程正在修改资源,那么操作完成,若发现有其他线程已经修改过这个资源,就放弃本次操作(全程不加锁,就是无锁编程)
一个进程最多可以创建多少个线程?
关系点:
- 进程的虚拟内存空间上限
创建一个线程,操作系统需要为其分配分配一个栈空间,若线程数量越多,所需的栈空间就要越大,那么虚拟内存就会占用的越多
- 系统参数限制
虽然linux没有内核参数来控制单个进程创建的最大线程数,但是有系统级别的参数来控制整个系统的最大线程数
📌
Ulimit -a : 查看进程创建时默认分配的栈空间大小
总结:
32位系统,用户态虚拟空间时3G,若创建线程时分配的栈空间是10M,那么一个进程最多只能创建300个左右的线程
64位操作系统,用户态虚拟空间大到128T,理论上不会受虚拟内存大小的限制,而会受系统的参数或性能限制
线程崩溃了,进程也会崩溃吗
C/C++中,线程崩溃后,进程也会崩溃,而Java语言不会
线程崩溃,进程一定崩溃吗?
一般情况下,若线程非法访问内存引起崩溃,进程肯定会崩溃。因为在进程中,各个线程的地址空间是共享的,某个线程对地址的非法访问就会导致内存的不确定性,进而可能会影响其他线程,系统就干脆让整个进程崩溃
线程共享代码段、数据段、地址空间、打开文件
- 1.针对只读内存写入数据
- 2.访问进程没有权限访问的地址空间(比如内核空间)
- 3.访问不存在的内存
进程如何崩溃的
信号机制
- 1.CPU执行正常的进程指令
- 2.调用kill系统调用向进程发送信号
- 3.进程收到操作系统发送的信号,CPU暂停当前程序运行,并将控制权转交给操作系统
- 4.调用kill系统调用向进程发送信号(假设为11,即sigsegv, 一般非法访问内存报的都是这个错误)
- 5.操作系统根据情况执行相应的信号处理程序(函数),一般执行完信号处理程序逻辑后会让进程退出
(若进程没有注册自己的信号处理函数,那么操作系统会执行默认的信号处理函数,最好让进程退出;若注册了,则会执行自己的信号处理函数,这样的话就给了进程一个垂死挣扎的机会,它会收到kill信号后,调用exit()来退出,但也可以使用sigsetjmp、siglongjmp这两个函数来恢复进程的执行
如何让正在运行的Java工程优雅停机?
JVM自己定义了信号处理函数,当发送kill pid命令(默认会传15,也就是SIGTERM)后,JVM就可以在信号处理函数中执行一些资源清理后再调用exit退出
不能用 kill -9, 整个会直接干掉进程,不会清除资源
为什么线程崩溃不会导致JVM崩溃
Java中常见的由于非法访问内存而产生的Exception或error, 常见的是StackoverflowError或者NPE
Linux中栈的大小默认是8M,若无线递归很快就会分配完,此时再调用函数试图分配超过栈的大小内存,就会发生段错误,也就是stackoverflowError
JVM自己定义了自己的信号处理函数,拦截了SIGSEGV信号,针对这两者不让他们崩溃
虚拟级内部定义了信号处理函数,而在信号处理函数中对你这两个做了额外的处理以让JVM不崩溃,另一方面也可以看出如果JVM不对信号做额外的处理,最好会自己退出并产生crash文件hs_err_pid_xxx.log(可以通过 -XX:ErrorFile=/var/log/hs_err.log这样的方式指定),这个文件记录了虚拟机崩溃的原因
文件系统管理
文件系统的基本组成
文件系统是操作系统中负责管理持久数据的子系统,即负责把用户的文件存储到硬盘硬件中
文件系统的基本数据单位是文件,目的是对磁盘上的文件进行组织管理,组织方式不同,就会形成不同的文件系统
Linux中一切皆文件,普通文件和目录、块设备、管道、socket等都是由文件系统管理
每个文件包含两个数据结构:
- 索引节点:也就是inode, 用来记录文件的元信息,比如inode编号、文件大小、访问权限、创建时间、修改时间、数据在磁盘的位置等。索引节点是文件的唯一标识,他们之间一一对应,也同样都会被存储在硬盘中,所以索引节点同样占用磁盘空间
- 目录项:也就是dentry, 用来记录文件的名字、索引节点指针以及其他目录项的层级关联关系。多个目录项关联起来,就会形成目录结构,但它与索引节点不同的是,目录项是由内核维护的一个数据结构,不存放于磁盘,而是先缓存在内存(是内核中的一个数据结构,缓存在内存)
文件数据如何存储在磁盘
磁盘读写的最小单位是扇区,扇区的大小只有512B大小
问题:
每次读写都以一个扇区的大小来读写,效率或很低
问题解决:
文件系统把多个扇区组成一个逻辑块,每次读写的最小单位就是逻辑块(数据块),Linux中逻辑块的大小为4KB,也就是一次性读写8个扇区,这可以提高磁盘的读写效率(类似于InnoDB一次性读写一个页,相当于多行数据)
索引节点是存储在磁盘上的数据,为了加速文件的访问,通常会把索引节点加载到内存中。
另外,磁盘上进行格式化的时候,会被分成三个存储区域,分别是超级块、索引节点区和数据块区
- 超级块:用来存储文件系统的详细信息,比如块个数、块大小、空闲块等(文件系统挂载时进入内存)
- 索引节点区:用来存储索引节点(当文件被访问时进入内存)
- 数据块区:用来存储文件或目录数据
虚拟文件系统
介于用户层和文件系统层
根据存储位置不同,文件系统分为三类:
- 磁盘的文件系统,它是直接把数据存储在磁盘中,比如Ext 2/3/4、XFS等都是这类文件系统
- 内存的文件系统,这类文件系统的数据不是存储在磁盘,而是占用内存空间,我们经常用到的/proc和/sys文件系统都属于这依赖,读写这类文件,实际上时读写内核中的相关数据
- 网络的文件系统, 用来访问其他计算机主机数据的文件系统,比如NFS、SMB等
文件系统首先要挂载到某个目录才可以正常使用,比如Linux系统在启动时,会把文件系统挂载到根目录
文件的使用
读取文件的过程:
- open系统调用打开文件,open的参数包含文件的路径名和文件名
- 使用write写数据,其中write使用open所返回的文件描述符,并不使用文件作为参数
- 使用完文件后,要用close系统调用关闭文件,避免资源泄漏
打开一个文件,操作系统会为每个进程维护一个打开文件表,文件表里每一项代表 文件描述符,文件描述符是打开文件的标识
打开文件表中维护者打开文件的状态和信息:
- 文件指针:系统跟踪上次读写位置作为当前文件位置指针,这种指针对打开文件的某个进程来说是唯一的
- 文件打开计数器:文件关闭时,操作系统必须重用其打开文件表条目,否则表内空间不够用。多个进程可能打开同一个文件,所以系统在删除打开文件条目之前,必须等待最后一个进程关闭文件,该计数器跟踪打开和关闭的数量,当该计数为0时,系统关闭文件,删除该条目;
- 文件磁盘位置:绝大多数文件操作都要求系统修改文件数据,该信息保存在内存中,以免每个操作都从磁盘中读取
- 访问权限:每个进程打开文件都需要有一个访问模式(创建、只读、读写、添加等),该信息保存在进程的打开文件表中,以便操作系统能允许或拒绝之后的IO请求
用户习惯以字节方式来读写文件,而操作系统则是以数据块来读写文件,屏蔽这种差异的就是文件系统
读写文件过程:(文件系统的基本操作单位是数据块)
- 当用户进程从文件读取1字节大小的数据时,文件系统则需要获取字节所在的数据块,再返回数据块对应的用户进程所需的数据部分
- 当用户进程把1字节大小的数据写进文件时,文件系统则需要写入数据的数据块位置,然后修改数据块中对应的部分,最后再把数据块写回磁盘
文件的存储
两种方式:
连续空间存放方式
文件存放在磁盘 连续的 物理空间中,文件数据紧密相连,读写效率高(一次磁盘寻道就可以读出整个文件)
文件头中指定 起始块位置 和 长度
缺陷:磁盘空间碎片、文件长度不易扩展
链表方式
链表方式存放的是离散的,不用连续的,可以消除磁盘碎片,可大大提高磁盘空间的利用率,同时文件的长度可以动态扩展
隐式链表
实现的方式是文件头要包含 第一块 和 最后一块 的位置,并且每个数据块里边留出一个指针空间,用来存放下一个数据块的位置,并且每个数据块里面留出一个指针空间,用来存放下一个数据块的位置
缺点:
无法直接访问数据块,只能通过指针顺序访问文件,以及数据块指针消耗了一定的存储空间。
分配的稳定性较差,系统在运行过程中由于软件或者硬件错误导致链表中的指针丢失或损坏,会导致文件数据的丢失
显示连接
为了解决隐式链表的不足,提出显示链接。
用于链接文件各数据块的指针,显示地存放在内存地一张链表中,该表在整个磁盘仅设置一张,每个表项存放链接指针,指向下一个数据块号
直接在内存中查找记录,可以显著提高检索速度,大大减少了访问磁盘的次数
缺点:
不适用于大磁盘
索引方式
链表方式解决了连续分配的磁盘碎片和文件动态拓展问题,但是不能有效支持直接访问(FAT除外),索引的方式可以有效解决这个问题
实现:
- a.为每个文件创建一个 索引数据块,里面存放的是指向文件数据的指针列表,跟书目录类似
- b.文件头需要包含指向 索引数据块 的指针,这样可以通过文件头知道索引数据块的位置,再通过索引数据块里的索引信息找到对应的数据块
优点:
- a.文件的创建、增大、缩小很方便
- b.不会有碎片问题
- c.支持顺序读写和随机读写
链表 + 索引组合(链式索引块)
在所有数据块留出一个存放下一个索引数据块的指针,当一个索引数据块的索引信息用完了,就可以通过指针方式,找到下一个索引数据块的信息
索引 + 索引组合(多级索引块)
通过一个索引块来存放多个索引数据块
Unix文件的实现方式
早期Unix文件系统时组合了前面的文件存放方式的优点
根据文件的大小,存放方式会有所变化:
- 若存放文件所需的数据块小于10块,则采用直接查找的方式
- 若存放文件所需的数据块超过10块,则采用一级间接索引方式
- 若前面两种方式都不够存放大文件,则采用二级间接索引方式
- 若二级间接索引也不够存放大文件,则采用三级间接索引
文件头需要包含13个指针:
- 10个指向数据块的指针
- 第11个指向索引块的指针
- 第12个指向二级索引块的指针
- 第13个指向三级索引块的指针
这种方式能很灵活的支持小文件和大文件的存放:
- 对于小文件使用直接查找的方式可减少索引数据块的开销
- 对于大文件则以多级索引的方式来支持,所以大文件在方式数据块时需要大量查询
空闲空间管理
以上文件存储是针对已经被占用的数据块组织和管理
现在要处理的问题是:要保存一个数据块,应该放在硬盘上的哪个位置
空闲表法
就是为所有空闲空间建立一张表,表内容包括空闲区第一个块号和该空闲区的块个数(类似于InnDB中的free链表)
适用于有少量的空闲区时,这样空闲链表比较小,查询效率高
当请求分配磁盘空间时,系统依次扫描空闲表的内容,直到找到一个合适的空闲区域为止。当用户撤销一个文件时,系统回收文件空间。这时,也需顺序扫描空闲表,寻找一个空闲表条目并将释放空间的第一个物理块号及它占用的块数填到这个条目中
空闲链表法
每个空闲块里有一个指针指向下一个空闲块,这样也能很方便的找到空闲块并管理起来
适用于小型文件系统
当创建文件需要一块或几块时,就从链表头上依次取下一块或几块,反之,当回收空间时,把这些空闲块依次街道链头上
位图法 - Linux默认
利用二进制的一位来表示磁盘中一个盘块的适用给i情况,磁盘上所有盘块都有一个二进制位与之对应
当值为0时,表示对应的盘块空闲;当值为1时,表示对应的盘块已分配
Linux文件系统采用了位图的方式来管理空闲空间,不仅用于数据块的管理,还用于inode空闲块的管理,因为inode也是存储在磁盘的,自然也要对其管理
文件系统结构
Linux采用位图的方式管理空闲空间,
用户在创建一个新文件时,Linux内核会通过inode的位图找到空闲可用的inode,并进行分配。
要存储数据时,会通过块的位图找到空闲的块,并分配
Linux Ext2>
快组内容:
- 超级块:包含的是文件系统的重要信息,比如inode总个数、块总个数、每个块组的inode个数、每个块组的块个数等等
- 块组描述符:包含文件系统中各个块组的状态,比如块组中空闲块和inode的数目等,每个块组都包含了文件系统中 所有块组描述符信息
- 数据位图和inode位图:用于表示对应的数据块或inode是空闲的,还是被使用中
- inode列表:包含了块组中所有的inode, inode用于保存文件系统中与各个文件和目录相关的所有元数据
- 数据块:包含文件的有用数据
超级块和块组描述符表是全局信息,每个块组都有,原因有两个:
- 若系统崩溃破坏了超级块或块组描述符,有关文件系统结构和内容的所有信息都会丢失。若有冗余副本,该信息可能恢复的
- 通过使文件和管理数据尽可能接近,减少了磁头寻道和旋转,可以提高文件系统的性能(类似于InnoDB的行格式的设计-可变字段长度列表,数据跟字段长度更接近,方便一次Cache Line就能读取)
目录的存储
目录也是文件,目录文件的块里面保存的是目录里面一项一项的文件信息,普通文件的块里面保存的是文件数据
Linux的ext文件系统采用了哈希表来保存目录的内容,查找速度快,插入和删除也比较简单,但是需要一些预备措施来避免哈希冲突
目录查询是通过在磁盘上反复搜搜完成,需要不断地进行IO操作,开销较大。为了减少IO操作,把当前使用地文件目录缓存在目录,以后要使用该文件时只要在内存中操作,从而降低了磁盘操作次数,提高了文件系统地访问速度
软链接和硬链接
- 1.硬链接
是多个目录项中的 索引节点 指向一个文件,也就是指向一个inode,但是inode是不可能跨越文件系统的,每个文件都有各自的inode数据结构和列表,所以硬链接是不可用于跨文件系统的。由于多个目录项都指向一个inode,那么只有删除文件的所有硬链接一级源文件时,系统才会彻底删除该文件
- 2.软链接
相当于重新创建了一个文件,这个文件有独立的inode,但是这个文件的内容是另外一个文件的路径,所以访问软链接的时候,实际上相当于访问到了另外一个文件,所以软链接是可以跨文件系统的,甚至目标文件被删除了,链接文件还是在的,只不过指向的文件找不到了而已
文件I/O
缓冲与非缓冲IO - 是否利用标准库缓冲
文件操作的标准库是可以实现数据的缓存
- 缓冲IO:利用的是标准库的缓存实现文件的加速访问,而标准库再通过系统调用的访问文件
- 非缓冲IO:直接通过系统调用访问文件,不经过标准库缓存
直接与非直接IO - 是否利用操作系统缓存
Linux内核为了减少磁盘IO次数,在系统调用后,会把用户数据拷贝到内核中缓存起来,这个内核缓存空间也就是页缓存,只有当缓存满足某些条件的时候,才发起磁盘IO请求
- 直接IO:不会发生内核缓存和用户程序之间数据复制,而是经过文件系统访问磁盘(系统调用函数时指定O_DIRECT)
- 非直接IO:读操作时,数据从内核缓存中拷贝给用户程序,写操作时,数据从用户程序拷贝给内核缓存,再由内核决定什么时候写入数据到磁盘(默认使用)
非直接IO写数据时,内核将缓存落盘的场景:
- 在调用write的最后,当发现内核缓存的数据太多的时候,内核会把数据写到磁盘上
- 用户主动调用sync, 内核缓存会刷到磁盘上
- 当内存十分紧张,无法再分配页面时,也会把内核缓存的数据刷到磁盘上
- 内核缓存的数据缓存时间超过某个时间时,也会把数据刷到磁盘上
阻塞不阻塞是针对当前进程来说的,被阻塞就是进程不动了,把CPU让出去
阻塞IO
当用户执行read, 线程会被阻塞,一直等到内核数据准备好,并把数据从内核缓冲区拷贝到应用程序的缓冲区中,当拷贝过程完成,read才会返回
阻塞等待的是 内核数据准备好 和 数据从内核态拷贝到用户态 这两个过程
非阻塞IO
非阻塞的read请求在数据未准备好的情况下立即返回,可以继续往下执行,此时应用程序不断轮询内核,直到数据准备好,内核将数据拷贝到应用程序缓冲区,read调用才可以获取到结果
最后一次read调用,获取数据的过程,是一个同步的过程,是需要等待的过程。这里的同步值得是内核态的数据拷贝到用户程序的缓冲区这个过程
问题:一直轮询会占用CPU
问题解决:
I/O多路复用计数,如select、poll, 是通过I/O事件分发,当内核数据准备好时,再以事件通知应用程序进行操作
大大改善了CPU的利用率,当调用I/O多路复用接口,若没有事件发生,那么当前线程就会发生阻塞,这时CPU会切换其他线程执行任务,当内核发现有事件到来的时候,会唤醒阻塞在I/O多路复用接口的线程,然后用户可以进行后续的事件处理
I/O多路复用即可最大的优势在于,用户可以在一个线程内同时处理多个socket的IO请求
用户可以注册多个socket, 然后不断调用I/O多路复用接口读取被激活的socket, 即可达到在同一个线程内同时处理多个IO请求的目的。在同步阻塞模型中,必须通过多线程的方式才能达到这个目的
3.3 同步I/O
阻塞I/O、非阻塞I/O、基于非阻塞I/O的多路复用都是同步调用
在read调用时,内核将数据从内核空间拷贝到应用程序空间,过程都是需要等待的,也就是说这个过程是同步的,若内核实现的拷贝效率不高,read调用就会在这个同步过程中等待比较长的时间
Select I/O多路复用过程:
3.3 异步I/O
异步I/O在 内核数据准备好 和 数据从内核态拷贝到用户态 这两个过程都不用等待
进程写文件时,进程发生了崩溃,已写入的数据是否会丢失?
进程写文件(使用缓冲IO)过程中,写一半的时候,进程发生了崩溃,已写入的数据不会丢失
进程在执行write(使用缓冲IO)系统调用的时候,实际上是将文件数据写到了内核的page cache, 它是文件系统中用于缓存文件数据的缓冲,所以即使进程崩溃了,文件数据还是保留在内核的page cache, 我们读取数据时,也是从内核的page cache中读取,因此还是依然读取进程崩溃前写入的数据
内核会找合适的时机,建page cache中的数据持久化到磁盘,若page cache中的文件数据,在持久化到磁盘之前,系统发生了崩溃,那这部分数据就会丢失
当然,也可以在程序中调用fsync函数,在写文件时,立刻将数据持久化到磁盘,这样就可以解决系统崩溃导致的文件数据丢失问题
Page Cache
概念:
红色部分为Page Cache。Page Cache是由Linux内核管理的内存区域,通过mmap和buffered I/O将文件读取到内存空间实际上都是读取到page cache中
如何查看page cache
page是内存管理的分配的基本单位, Page Cache由多个page构成。page在操作系统中通常为4KB,而Page Cache的大小通常为4KB的整数倍
用户可访问的内存类型:
- File-backed pages: 文件备份页页就是Page Cache中的page, 对应磁盘上的若干数据块;对于这些页最大的问题是脏页回盘
内存回收代价较低。Page Cache通常对应于一个文件上的若干顺序块,因此可以通过顺序I/O方式落盘。另一方面,若Page Cache上没有进行写操作(也就是没有脏页),甚至不会将Page Cache回盘。因为数据内容完全可以通过再次读取磁盘文件得到
- Anonymous pages: 匿名页不对应磁盘上的任何磁盘数据块,它们是进程运行的内存空间(例如方法栈、局部变量表等属性)
内存回收代价较高。因为Anonymous page通常随机地写入持久化交换设备。另一方面,无论是是否有些操作,为了确保数据不会丢失,Anonymous pages在swap时必须持久化到磁盘
Linux不把Page Cache称为block cache的原因
从磁盘加载到内存的数据不仅仅放在Page Cache中,还放在buffer cache中
Swap&缺页中断
Swap机制:
指的是当物理内存不够用,内存管理单元MMU 需要提供调度算法来回收相关内存空间,然后将清理出来内存空间给当前内存申请方
内存泄漏会引起频繁的swap, 非常影响操作系统的性能
Linux通过一个swappiness参数来控制Swap机制:0-100,控制系统swap的优先级:
- 高数值:较高频率的swap, 进程不活跃时主动将其转换出物理内存
- 低数值:较低频率的swap, 可以确保交互式不因为内存空间频繁的交换到磁盘而提高响应延迟
Swap机制存在地本质原因:
Linux系统提供了虚拟内存管理机制,每个进程认为其独占内存空间,因此所有进程地内存空间之和远远大于物理内存。所有进程的内存空间之和超过物理内存地部分就需要交换到磁盘上
缺页中断:
操作系统以page为单位管理内存,当进程发现需要访问地数据不在内存时,操作系统可能会将数据以页地方式加载到内存,这个过程就是缺页中断
页面替换:
主内存空间有限,当主内存中不包含可以使用的空间时,操作系统会选择合适的物理内存页驱逐会磁盘,为新的内存页让出为止,选择驱逐页的过程在操作系统中叫做页面替换(Page Replacement)
为什么SwapCached也是Page Cache的一部分?
当匿名页inactive和active先被交换到磁盘上后,然后再加载回内存中,由于读入到内存后原来的swap file还在,所以swapCached也可以认为是File-backed page, 即属于Page Cache
Page Cache&buffer Cache
cached列表示当前的页缓存(Page Cache)占用量,buffers列表示当前的块缓存(buffer cache)占用量
Page Cache用于缓存文件的页数据,buffer cache用于缓存块设备(如磁盘)的块数据
- 页是逻辑上的概念,因此Page Cache是与文件系统同级的
- 块是物理上的概念,因此buffer cache是与块设备驱动程序同级的
二者目的都是为了加速数据IO
- 写数据时首先写到缓存,将写入的页标记为dirty, 然后向外部存储flush, 也就是缓存写机制中的write-back
- 读数据时首先读取缓存,若未命中,再去外部存储读取,并且将读取来的数据也加入缓存。操作系统总是积极地将所有空闲内存都用作Page Cache和buffer cache, 当内存不够用时也会用LRU等算法淘汰缓存页
Linux2.4版本内核之后,这两块内存近似融合在一起。若一个文件的页加载到了Page Cache,那么同时buffer cache就只需要维护块指向页的指针即可
32位操作系统的一种Page Cache结构
Page Cache & 预读 (类似于InnoDB的预读机制)
操作系统为基于Page Cache的读缓存机制提供预读机制
应用程序利用read系统读取4KB数据,实际上内核使用readcached机制完成了16KB数据的读取
本质上是为了减少磁盘IO。
Page Cache与文件持久化的一致性&可靠性
trade-off:吞吐量与数据一致性保证是一对矛盾
文件 = 数据 + 元数据
文件的元数据包括:文件大小、创建时间、访问时间、属主属组等信息
保证文件一致性包含两个方面: 数据一致 + 元数据一直
Linux实现文件一致性的方式:
- 1.Write Through(写穿):向用户层提供特定接口,应用程序可主动调用接口来保证文件一致性
以牺牲系统IO吞吐量为代价,向上层应用确保一旦写入,数据就已经落盘,不会丢失
- 2.Write back(写回):系统中存在定期任务(表现形式为内核线程),周期性地同步文件系统中国文件脏数据,这是默认的Linux一致性方案
在系统发生宕机的情况下无法确保数据已经落盘,因此存在数据丢失的问题。但是,在程序挂了,比如被kill -9,Page Cache中的数据,操作系统还是会确保落盘的
以上两种方式都依赖于系统调用,主要分三种:
- fsync(int fd):将fd代表地文件地脏数据和脏元数据全部刷新至磁盘中
- fdatasync(int fd):将fd代表地文件的脏数据刷新至磁盘,同时对必要的元数据刷新至磁盘中,这里说的必要的概念是指:对接下来访问文件有关键作用的信息,如文件大小,而文件修改时间等不属于必要信息
- sync():对系统中所有脏的文件数据元数据刷新至磁盘中
内核线程特性:
-
1.创建的针对回写任务的内核线程数由系统中持久存储设备决定,每个存储设备都有一个单独的刷新线程
-
2.关于多线程架构问题,Linux内核采用了Lighthttp的做法,即系统中存在一个管理线程和多个刷新线程(每个持久化设备对应一个刷新线程)。
-
管理线程监控设备上的脏页情况,若设备一段时间内没有产生脏页,就i笑傲会设备上的刷新线程;若监测到设备上有脏页需要回写且尚未为该设备创建刷新线程,那么就创建刷新线程处理脏页回写
-
刷新线程只负责将设备中的脏页回写至存储设备中
-
每个设备保存脏文件列表,保存的是该设备上存储的脏文件的inode节点。所谓的回写文件脏页即回写该inode链表上的某些文件的脏页面
-
系统中存在多个回写时机,
- 第一是应用程序主动调用回写接口(fsync、fdatasync、sync等)
- 第二是管理线程周期性的唤醒设备上的回写线程进行回写
- 第三是某些应用程序/内核任务发现内存不足时要回收部分缓存页面而事先进行脏页回写
-
-
Page Cache的优劣势
优势:
- 1.加快数据访问(缓存最近被访问的数据)
- 2.减少IO次数,提高系统磁盘IO吞吐量(预读机制)
劣势:
- 1.需要额外占用内存空间,物理内存比较紧张的时候可能会导致频繁的swap操作,最终导致系统的磁盘IO负载上升
- 2.对应用层没有提供恒昊的管理API,很难优化(InnoDB选择自己实现page管理,每页16KB)
- 3.在某些场景下比Direct IO多一次磁盘读IO以及磁盘写IO
网络系统
零拷贝
概念:
没有在内存层面区拷贝数据,也就是说全程没有通过CPU来搬运数据,所有数据都是通过DMA来传输的
只需要两次上下文切换和数据拷贝次数,就可以完成文件的传输,而且2次数据拷贝过程都不需要通过CPU,都是由DMA来搬运
传输小文件时使用
DMA技术
产生背景:
IO过程:
- CPU发送对应的指令给磁盘控制器,然后返回
- 磁盘控制器收到指令后,就开始准备上护具,会把数据放入到磁盘控制器的内部缓冲区中,然后产生一个中断
- CPU收到中断信号后,暂停手头工作,接着把磁盘控制器的缓冲区中的数据一次一个自己地读取进自己地寄存器,然后再把寄存器里地数据写入到内存,在此期间,CPU无法执行其他任务
概念:直接内存访问(Direct Memory Access)
在进行IO设备和内存地数据传输地时候,数据搬运地工作全部交给DMA控制器,而CPU不再参与任何与数据搬运相关地事情,这样CPU就可以去处理别的事务
每个IO设备都有自己的DMA控制器
具体过程:
- 用户进程调用read方法,向操作系统发出IO请求,请求读取数据到自己的内存缓冲区中,进程进入阻塞状态
- 操作系统收到请求后,进一步将IO请求发送给DMA,然后让CPU执行其他任务
- DMA进一步将IO请求发送给磁盘
- 磁盘手打DMA的IO请求,把数据从磁盘读取到磁盘控制器的缓冲区中,当磁盘控制缓冲区被读满后,向DMA发送中断信号,告知自己缓冲区满了
- DMA收到磁盘的信号,将磁盘控制器缓冲区中的数据拷贝到内核缓冲区中,此时不占用CPU,CPU可以执行其他任务
- 当DMA读取了足够多的数据,就会发送中断信号给CPU
- CPU收到DMA信号,知道数据已经就绪,于是就将数据从内核拷贝到用户空间,系统调用返回
传统文件传输&性能优化
传统文件传输:
数据读取和写入是从用户空间到内核空间来回复制,而内核空间的数据是通过操作系统层面的IO接口从磁盘读取或写入
共发生4次用户态与内核态的上下文切换,4次数据拷贝
4次拷贝:
- 第一次拷贝:把磁盘上的数据拷贝到操作系统内核的缓冲区里,这个拷贝是通过DMA实现的
- 第二次拷贝:把内核缓冲区的数据拷贝到用户的缓冲区里,于是我们应用程序就可以使用这部分数据,这个拷贝过程是由CPU完成的
- 第三次拷贝:把刚才拷贝到用户缓冲区里的数据,拷贝到内核的socket的缓冲区里,这个过程由CPU完成
- 第四次拷贝:把内核的socket缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程由DMA实现
性能优化:
- 1.减少用户态和内核态的上下文切换
读取磁盘数据时发生上下文切换的原因:用户空间没有权限操作磁盘或者网卡
一次系统调用必然产生2次上下文切换
如何实现零拷贝:
- mmap + write
具体过程:
- 应用进程调用mmap()后,DMA会把磁盘的数据拷贝到内核的缓冲区里。接着应用进程跟操作系统内核 共享 这个缓冲区
- 应用进程再调用write(),操作系统直接将内核缓冲区的数拷贝到socket缓冲区中,这一切都发生再内核,由CPU完成
- 最后,把内核的socket缓冲区的数据,拷贝到网卡的缓冲区里,这个过程由DMA完成
- sendfile
- 网卡支持SG-DMA技术时,sendfile系统调用
具体过程:
- 第一步,通过DMA将磁盘上的数据拷贝到内核缓冲区里
- 第二步,缓冲区描述符和数据长度传到socket缓冲区,这样网卡的SG-DMA控制器就可以直接将内核缓冲区中的数据拷贝到网卡缓冲区中,次过程不需要将数据从操作系统内核缓冲区拷贝到socket缓冲区中,这样就减少了一次数据拷贝
大文件传输怎么实现
在传输大文件(GB级别的文件)的时候,PageCache会不起作用,那就白白浪费DMA多做一次数据拷贝,造成性能的降低,即使使用了PageCache的零拷贝也会损失性能:
PageCache由于长时间被大文件占用,其他 热点 的小文件可能就无法充分使用到PageCache,于是这样磁盘读写性能就会下降
PageCache中的大文件数据,由于没享受到缓存带来的好处,但却耗费DMA多拷贝到PageCache一次
在高并发的场景下,针对大文件的传输方式,应该使用 异步IO + 直接IO 来替代零拷贝技术
IO多路复用
最基本socket模型
服务端socket调用bind()函数的目的:
- 绑定端口的目的:当内核收到TCP报文,通过TCP头里面的端口号,来找到应用程序,然后把数据传递给我们
- 绑定IP地址的目的:一台机器是可以有多个网卡的,每个网卡都有对应的IP地址,当绑定一个网卡时,内核在收到该网卡上的包,才会转发给我们
在TCP连接的连接过程中,服务器的内核实际上为每个Socket维护两个队列:
- 一个是 还没完全建立 连接的队列, 称为TCP半连接队列,这个队列都是没有完成三次握手的连接,此时服务端处于syn_rcvd的状态
- 一个是 已经建立 连接的队列,称为TCP全连接队列,这个队列都是完成了三次握手的连接,此时服务端处于established状态
监听的Socket和真正用来传输数据的Socket是两个:
- 一个叫做监听Socket;
- 一个叫做已连接Socket;
如何服务更多的用户
TCP Socket调用是最简单、最基本的,基本只能一对一通信,采用同步阻塞方式
服务器单机理论最大连接多少个客户端?
TCP连接四元组:本机IP,本机端口,对端IP,对端端口
服务器端的本地IP和端口是固定的,对于服务端TCP连接的四元组只有对端的IP和端口是会变化的,所以最大TCP连接数 = 客户端IP数 x 客户端端口数
对于IPV4, 客户端的IP数最多为2的32次方,客户端的端口数最多为2的16次方,也就是服务端单机最大TCP连接数约为2的48次方
理论值相当 丰满,但是显示很骨感>
- 文件描述符:Scoket实际上一个文件,对应一个文件描述符。在Linux下,单进程打开的文件描述符是有限制的,一般都是1024, 但是可以通过ulimit增大文件描述符的数目
- 系统内存:每个TCP连接在内核中都有对应的数据结构,每个连接都会占用一定内存
- 3.多进程模型
为每个客户端分配一个进程来处理请求
服务器的主进程负责监听客户端的连接,一旦与客户端的连接完成,accept() 函数就会返回一个 已连接Socket, 这时就通过 fork() 函数创建一个子进程,实际上就是把符进程的所有相关的东西都复制一份,包括文件描述符、内存地址空间、程序计数器、执行的代码等,然后就可以直接使用 已连接Socekt和客户端通信
主进程返回非0整数
子进程返回0
子进程不需要关心 监听Socket, 只需要关心 已连接Socket
父进程不需要关心 已连接Socket, 只需要关心 监听Socket
子进程退出后调用wait()和waitpid()函数
进程过多,性能就会下降,进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间资源,还包括了内核堆栈、寄存器等内核空间资源
- 4.多线程模型
轻量级的模型应对多用户的请求
单进程中可以运行多个线程,同进程里的线程可以共享进程的部分资源,比如文件描述符列表、进程空间、代码、全局数据、堆、共享库等,这些共享资源在上下文切换时不需要切换,只需要切换线程的私有数据、寄存器等不共享的数据,因此一个进程下的线程的上下文切换的开销要比进程小
新连接建立时,将这个已连接的Socket放入到一个队列里,然后线程池的线程负责从队列中取出 已连接Socket 进行处理
新来一个连接就要分配一个线程,也是有问题
- 5.IO多路复用
一个进程维护多个Socket
一个进程虽然任一时刻只能处理一个请求,但是处理每个请求的事件时,耗时控制在1毫秒以内,这样1秒内就可以处理上千个请求,把时间拉长看,多个请求复用了一个进程,这就是多路复用(类似于一个CPU并发多个进程,也叫做时分多路复用)
select/poll/epoll 是内核提供给用户态的多路复用调用系统,进程可以通过一个系统调用函数从内核中获取多个事件
在获取事件时,先把所有连接(文件描述符)传给内核,再由内内核返回产生事件的连接,然后在用户态中再处理这些连接对应的请求即可
-
3.select/poll
- a.Select
实现方式:将已经连接的Socket都放到一个文件描述符集合,然后调用select函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生(通过遍历文件描述符集合的方式),当检查到有事件产生后,将此Socket标记为可读或可写,接着再把整个文件描述符集合拷贝回用户态里,然后用户态还需要再次遍历找到可读或可写的Socket, 然后再对其处理
整个过程需要遍历两次文件描述符集合,两次拷贝文件描述符集合
📌
select使用固定长度的BitsMap, 表示文件描述符集合,而且所支持的文件描述符的个数是有限制的
在Linux中,由内核的FD_SETSIZE限制,最大值是1024,只能监听0-1023的文件描述符
- b.poll
使用动态数组来存储文件描述符,突破了select的文件描述符限制,但是还是受系统文件描述符限制
select/poll 都使用 线性结构 存储进程关注的Socket集合,因此都需要遍历文件描述符集合来找到可读或可写的Socket, 时间复杂度O(n), 而且也需要在用户态与内核态之间拷贝文件描述符集合,一旦并发数上来,性能损耗会很大
- 4.Epoll
- 减少拷贝:epoll在内核里使用红黑树来跟踪进程所有待检测的文件描述符,把需要监控的socket通过epoll_ctl()函数加入内核中的红黑树里,红黑树诗歌高效的数据结构,增删改一般时间复杂度是O(logn). 而select/poll没有类似的存储待检测socket的数据结构,所以每次都需要将整个socket集合传入内核,而epoll在内核中维护了红黑树,可以保存所有待检测的socket, 所以只需要传入一个待检测的socket,减少内核和用户空间大量的数据拷贝和内存分配
- 减少遍历:epoll使用事件驱动机制,内核里维护了一个链表来记录就绪事件,当某个socket有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用epoll_wait()函数时,只会返回有事件发生的文件描述符个数,不需要像select/poll那样轮询扫描整个socket集合,大大提高了检测的效率
边缘触发和水平触发
- 使用边缘触发模式时,当被监控的socket描述符上有可读事件发生时,服务器端只会从epoll_wait中苏醒一次,即使进程没有调用read函数从内核读取数据,也依然只苏醒一次,因此程序要保证一次性将内核缓冲区总的数据全部读取完
- 使用水平触发模式时,当被监控的Socket上有可读事件发生时,服务器端不断地从epoll_wait中苏醒,直到内核缓冲区数据被read函数读完才结束
高性能网络模式:Reactor和Proactor
Redis、Nginx、Netty都采用了这个Reactor模式
Reactor模式:Dispatcher模式(非阻塞同步网络模式)
基于 Linux 的高性能网络程序都是使用 Reactor 方案
就是IO多路复用监听事件,收到事件后,根据事件类型分配(Dispatch)给某个进程/线程
核心组成部分:
- Reactor负责监听和分发事件,事件类型包含连接事件、读写事件
Reactor数量可以只有一个,也可以多个
- 处理资源池负责处理事件,如read -> 业务逻辑 -> send
处理资源池也可以是单个进程/线程,也可以是多个进程/线程
Java语言一般使用线程,比如Netty;
C语言使用进程和线程都可以,例如Nginx使用地是进程,Memcache使用地是线程
单Reactor单进程/线程
一般情况下,C语言实现的是 单Reactor单进程 方案,Java语言实现的是 单Reactor单线程 方案
三个对象:
- Reactor对象的作用是监听和分发事件
- Acceptor对象的作用是获取连接
- Handler对象的作用是处理业务
其中,select、accept、read、send是系统调用函数,dispatch 和 业务处理 是需要完成的操作,dispatch是分发事件操作
步骤:
A. Reactor对象通过 select (IO多路复用接口)监听事件,收到事件后通过dispatch进行分发,具体分发给Acceptor对象还是Handler对象,还要看收到的事件类型
B. 若是连接建立的事件,则交由Acceptor对象进行处理,Acceptor对象会通过accept方法获取连接,并创建一个Handlerd对象来处理后续的响应事件
C. 若是其他事件,则交由当前连接对应的Handler对象来响应
D. Handler对象通过read -> 业务处理 -> send的流程来完成完整的业务流程
优缺点:
优点:
- 只有一个进程,实现简单,不需要考虑进程间通信,也没有多进程竞争
缺点:
- 只有一个进程,无法利用多核CPU的性能
- Handler对象在业务处理时,整个进程是无法处理其他的连接事件的,若业务处理耗时,那么就造成响应延迟
适用场景:
不适用于计算密集型的场景,只适用于业务处理非常快速的场景
Redis6.0之前就是采用 单Reactor单进程 方案,因为Redis业务在内存中处理,性能瓶颈不在CPU上
单Reactor多进程/线程
步骤:
上三步跟单Reactor单进程/线程的一样
E. Handler对象不再负责业务处理,只负责数据的接收和发送, Handler对象通过read读取到数据后,会将数据发给子线程里的Processor对象进行业务处理
F. 子线程里的Processor对象进行业务处理,处理完成后,将结果发给主线程中的Handler对象,接着由主线程中的Handler对象通过send方法将响应结果发送给client
优缺点:
优势:
- 能够充分利用多核CPU性能
劣势:
- 引入了多线程,不可避免地带来线程竞争资源问题
- 一个Reactor对象承担所有事情地监听和响应,而且只在主线程中运行,在面对瞬间高并发场景时,容易称为性能瓶颈
多Reactor多进程/线程 - Netty采用
Netty采用了多Reactor多线程模型
步骤:
A. 主线程中的MainReactor对象通过select监控连接建立事件,收到事件后通过Acceptor对象中accept获取连接,将新地连接分配给某个子线程
B. 子线程中的SubReactor对象将MainReactor对象分配地连接加入select继续监听,并创建一个Handler用于处理连接的响应事件
C.若有新事件发生,SubReactor对象会调用当前连接对应的Handler对象来进行响应
D.Handler对象通过read -> 业务处理 -> send 的流程来完成完整的业务流程
多Reactor多线程实现时比单Reactor多线程实现简单的原因:
- 主线程和子线程分工明确,主线程只负责接收新连接,子线程负责完成后续的业务处理
- 主线程和子线程交互简单,主线程只需要将新连接传给子线程,子线程无需返回数据,直接就可以在子线程将处理结果发送给客户端
Proactor模式(异步网络模式)
阻塞IO:
当用户执行read, 线程会被阻塞,一直等到内核数据准备好,并把数据从内核缓冲区拷贝到应用程序的缓冲区中,当拷贝过程完成,read才会返回
阻塞等待的是 内核数据准备好 和 数据从内核态拷贝到用户态 这两个过程
非阻塞IO:
read请求在数据未准备好的情况下立即返回,可以继续往下执行,此时应用程序不断轮询内核,直到数据准备好,内核将数据拷贝到应用程序缓冲区,read调用才能获取到结果
这里最后一次read调用,获取数据的过程,是一个同步的过程,是需要等待的过程,这里的同步是指内核态数据拷贝到用户程序的缓冲区的过程
异步IO:
内核数据准备好 和 数据从内核态拷贝到用户态 这两个过程都不用等待
当发起 aio_read(异步IO)之后,就立即返回,内核自动将数据从内核空间拷贝到用户空间,这个拷贝过程同样是异步的,内核自动完成
Reactor和Proactor的区别:
- Reactor是非阻塞同步网络模式,感知的是就绪可读写事件
在每次感知到有事件发生后,就需要应用程序主动调用read方法来完成数据的读取,也就要应用进程主动将socket接收缓存中的数据读到应用进程内存中,这个过程是同步的,读取完成数据后应用进程才能处理数据
(来了事件操作系统通知应用进程,让应用进程处理) - 类似快递员在楼下打电话告知快递到了,需要自己下楼拿快递
- Proactor是异步网络模式,感知的是已完成的读写事件
在发起异步读写请求时,需要传入数据缓冲区的地址(用来存放结果数据)等信息,这样系统内核才可以自动帮我们把数据的读写工作完成,这里的读写工作全程由操作系统来做,并不需要像Reactor那样需要应用进程主动发起read/write来读写数据,操作系统完成读写工作后,就会通知应用进程直接处理数据
(来了事件操作系统来处理,处理完再通知应用进程)- 类似快递员直接将快递送到你家门口
Proactor模式示意图
流程:
- Proactor Initiator负责创建Proactor和Handler对象,并将Proactor和Handler都通过Asynchronous Operation Processor注册到内核
- Asynchronous Operation Processor负责处理注册请求,并处理IO操作
- Asynchronous Operation Processor 完成IO操作后通知Proactor
- Proactor根据不同的事件类型回调不同的Handler进行业务处理
- Handler完成业务处理
一致性哈希
一致性哈希是什么,使用场景,解决了什么问题
哈希算法:
使用哈希算法的原因:
应对分布式系统的负载均衡
哈希算法的问题:
若节点数量发生变化,就是系统做扩容或者缩容时,必须迁移改变了映射关系的数据,否则会出现查询不到数据的情况
哈希算法问题解决:
数据迁移,但是数据量大的时候,迁移规模就很大,迁移成本太高
一致性哈希算法
能够解决分布式系统在扩容和缩容时,发生过多的数据迁移问题
一致性哈希算法是对 2^32 进行取模运算,是一个固定的值
2^32 个点组成的圆,就是哈希环
两步哈希:
- 1.对存储节点进行哈希计算,也就是对存储节点做哈希映射,比如根据节点的IP地址进行哈希
- 2.当对数据进行存储或者访问时,对数据进行哈希映射
一致性哈希是指将 存储节点 和 数据都映射到一个首尾相连的哈希环上
对数据进行哈希映射得到一个结果要怎么找到存储该数据的节点?
映射的结果值往顺时针方向的找到第一个节点,就是存储该数据的节点
对指定哈希进行读写的时候,寻址步骤有两个:一致性哈希的寻址方式
- 1.首先,对key进行哈希计算,确定此key在环上的位置
- 2.然后,从这个位置沿着顺时针方向走,遇到的第一个节点就是存储key的节点
增加一个节点,只有key-2被迁移到D节点
减少一个节点,只有key-1被迁移到B节点
在一致性哈希算法中,若增加或者删除一个节点,仅影响该节点在哈希环上顺时针相邻的后继节点,其他数据不受影响
问题:一致性哈希算法并不保证节点能够在哈希环上分布均匀(虽然减少了数据迁移,但是存在节点分布不均的问题)
问题解决:
增加虚拟节点,不再将真实节点映射到哈希环上,而是将虚拟节点映射到哈希环上,并将虚拟节点映射到实际节点
演示:3个真实节点映射后是9个虚拟节点
A -> A-01 A-02 A-03
B -> B-01 B-02 B-03
C -> C-01 C-02 C-03
Nginx中的一致性哈希算法,每个权重为1的真实节点含有160个虚拟节点
虚拟节点除了会提高节点的均衡度,还会提高系统稳定性。当节点变化时,会有不同的节点共同分担系统变化,因此稳定性更高(若节点被移除,对应的虚拟节点均会移除,而这些虚拟节点按顺时针方向的下一个虚拟节点,可能对应不同的真实节点,即这些不同的真实节点共同分担了节点变化导致的压力)
带虚拟节点的一致性哈希算法不仅适合硬件配置不同的节点的场景,而且适合节点规模会发生变化的场景
Linux命令
网络的性能指标
Linux网络协议栈是根据TCP/IP模型来实现的,TCP模型由应用层、传输层、网络层、网络接口层组成
性能指标:
- 带宽:表示链路的最大传输速率,单位是b/s(比特/秒),带宽越大,其传输能力就越强
- 延时:表示请求数据包发送后,收到对端响应,所需要的时间延迟。不同场景有着不同的含义,比如可以表示建立TCP连接所需要的时间延时,或一个数据包往返所需的时间延时
- 吞吐率:表示单位时间内成功传输的数据量,单位是b/s(比特/秒)或者 B/s(字节/秒),吞吐受带宽限制,带宽越大,吞吐率的上限才可能越高
- PPS: Packet Per Second(包/秒),表示以网络包为单位的传输速率,一般用来评估系统对于网络的转发能力
- 网络可用性:表示网络是否正常通信
- 并发连接数:表示TCP连接数量
- 丢包率:表示所丢失数据包数量占所发送数据组的比率
- 重传率:表示重传网络包的比例
网络配置查看方式
ifconfig和ip命令都用于查看网口信息,格式不同,但是输出内容基本相同
- 网口的连接状态标志
就是表示对应的网口是否连接到交换机或路由器等设备,若ifconfig输出中看到有RUNNING, 或者IP输出中有LOWER_UP, 则说明物理网络是连通的,若看不到,则表示网口没有接网线
- MTU大小
默认值是1500字节,主要作用是限制网络包的大小
若IP层有一个数据包要传,且网络包的长度比链路层的MTU还大,那么IP层就需要分片,即把数据分成若干片,这样每片都小于MTU。
- 网口的IUP地址、子网掩码、MAC地址、网关地址。这些信息要配置正确,网络功能才能正常工作
- 网络包收发的统计信息。通常有网络收发的字节数、包数、错误数、丢包情况等信息
errors 表示发生错误的数据包数,比如校验错误、帧同步错误等
dropped 表示丢弃的数据包数,即数据包已经收到了Ring Buffer(这个缓冲区是在内核内存中,更具体一点是在网卡驱动程序里),但是因为系统内存不足等原因而发生的丢包
overruns 表示超限数据包数,即网络接收/发送速度过快,导致Ring Buffer中的数据包来不及处理,而导致的丢包,因为过多的数据包挤压在Ring Buffer, 这样Ring Buffer就很容易溢出
carrier 表示发生carrier错误的数据包数,比如双工模式不匹配、物理电缆出现问题等
collisions 表示冲突、碰撞数据包数
socket信息查看方式
socket信息:
当服务端收到客户端的SYN包后,内核会把该连接存储到半连接队列,然后再向客户端发送SYN+ACK包,接着客户端会返回ACK,服务端收到第三次握手的ACK后,内核会把连接从半连接队列移除,然后创建新的完全的连接,并将其增加到全连接队列,等待进程调用accept()函数时把连接取出来
协议栈信息
nsetstat:
Active connections openings:主动连接
Passive connection openings: 被动连接
Failed connection openings: 失败重试
Segments send out: 发送
Segments received: 接收
Ss:
Estab: 已经连接
Closed: 关闭
网络吞吐率和PPS查看方式
- rxpck/s 和 txpck/s 分别是接收和发送的PPS, 单位为包/秒
- rxkB/s 和 rxkB/s 分别是接收和发送的吞吐率,单位是KB/秒
- rxcmp/s 和 txcmp/s 分别是接收和发送的压缩数据包数,单位是包/秒
连通性和延时如何查看
ping命令基于ICMP协议
icmp_seq:ICMP序列号
TTL: 生存时间 或者跳数
Time: 往返延时
若网络没有丢包,packet loss百分比就是0
注意:ping不通服务器并不代表HTTP请求也不通,因为有的服务器防火墙会禁用ICMP协议(网络层,IP协议的补充协议)的
如何从日志分析PV、UV
日志分析原则,若文件大小非常小,最好不要在线上做
文件小,可以用cat
文件大,用scp命令将文件传输到闲置的服务器再分析
慎用cat
cat命令用于查看文件内容,日志文件有多少数据量,它就读多少,文件大,就会卡住
建议使用less命令读文件内容,less是先输出一小页内容,当往下看时才会继续加载
nginx的access.log每一行记录是一次访问的记录,从左到右分别包含如下信息:
- 客户端IP地址
- 访问时间
- HTTP请求的方法、路径、协议版本、返回的状态码
- User Agent,一般是客户端使用的操作系统以及版本、浏览器及版本等
查看文件最新内容
PV分析
Page View,用户访问一个页面就是一次PV,表示点击量,不是真实用户苏慧伦
比如大多数博客平台,点击一次页面,阅读量加1
使用wc -l命令查看多少条PV
PV分组
按照时间分组
UV分析
Uniq Vistor,代表访问人数,比如公众号的阅读量就是以UV统计的,不管单个用户点击了多少次,最终只算1次阅读量
用客户端IP地址来近似统计UV
从左往右的命令意思:
- Awk '{print $1}' access.log 取日志的第一列内容,客户端的IP地址正式第一列
- Sort 对信息排序
- Uniq 去除重复记录
- Wc -l 查看记录条数
UV分组
按天来分组分析每天的UV数量