笔试得刷算法题,那面试就离不开八股文,所以特地对着小林coding的图解八股文系列记一下笔记。
这一篇笔记是图解系统的内容。
硬件结构
CPU执行程序
计算机基本结构为 5 个部分,分别是运算器、控制器、存储器、输入设备、输出设备,这 5 个部分也被称为冯诺依曼模型。
存储单元和输入输出设备的通讯如下:
内存地址从0编号,自增排列。
CPU一次计算的数据量分为32位(4个字节)以及64位(8个字节),CPU中有寄存器来存储计算时数据,有通用寄存器、程序计数器以及指令寄存器。
总线有地址总线、数据总线以及控制总线,分为完成寻址、读写内存数据以及发送和接收信号。
CPU位宽一般不小于线路位宽,32位CPU一般为32位地址总线。
程序执行的时候,耗费的 CPU 时间少就说明程序是快的,对于程序的 CPU 执行时间,可以拆解成 CPU 时钟周期数(CPU Cycles)和时钟周期时间(Clock Cycle Time)的乘积。
CPU 时钟周期数可以进一步拆解成:「指令数 x 每条指令的平均时钟周期数(Cycles Per Instruction,简称 CPI)」,由此继续化简公式:
磁盘与内存
CPU中有数据存储,也就是CPU Cache,成为CPU高速缓存,通常分为L1、L2、L3三成,L1 Cache通常分为“数据缓存”和“指令缓存”。
CPU中,操作最快的是寄存器,然后依次是CPU Cache,然后是内存,最后是硬盘。
CPU Cache 用的是一种叫 SRAM(Static Random-Access Memory,静态随机存储器) 的芯片。而三层Cache如下所示:
L1 高速缓存的访问速度几乎和寄存器一样快,通常只需要 2~4 个时钟周期,而大小在几十 KB 到几百 KB 不等。每个CPU核心都有,且会分成指令缓存和数据缓存。
L2 高速缓存同样每个 CPU 核心都有,但是 L2 高速缓存位置比 L1 高速缓存距离 CPU 核心 更远,它大小比 L1 高速缓存更大,CPU 型号不同大小也就不同,通常大小在几百 KB 到几 MB 不等,访问速度则更慢,速度在 10~20 个时钟周期。
L3 高速缓存通常是多个 CPU 核心共用的,位置比 L2 高速缓存距离 CPU 核心 更远,大小也会更大些,通常大小在几 MB 到几十 MB 不等,具体值根据 CPU 型号而定。访问速度相对也比较慢一些,访问速度在 20~60个时钟周期。
内存用的芯片和 CPU Cache 有所不同,它使用的是一种叫作 DRAM (Dynamic Random Access Memory,动态随机存取存储器) 的芯片。访问速度更慢,内存速度大概在 200~300 个 时钟周期之间。
CPU访问内存中数据的流程:
提升CPU中代码运行效率
CPU Cache 的数据是从内存中读取过来的,它是以一小块一小块读取数据的,而不是按照单个数组元素来读取数据的,在 CPU Cache 中的,这样一小块一小块的数据,称为 Cache Line(缓存块)。Cache Line 是 CPU 从内存读取数据的基本单位,而 Cache Line 是由各种标志(Tag)+ 数据块(Data Block)组成。
一般CPU访问内存,就会通过直接映射Cache来完成(Direct Mapped Cache)。一个内存的访问地址,包括组标记、CPU Cache Line 索引、偏移量这三种信息,于是 CPU 就能通过这些信息,在 CPU Cache 中找到缓存的数据。而对于 CPU Cache 里的数据结构,则是由索引 + 有效位 + 组标记 + 数据块组成。
如果 CPU 所要操作的数据在 CPU Cache 中的话,这样将会带来很大的性能提升。访问的数据在 CPU Cache 中的话,意味着缓存命中,缓存命中率越高的话,代码的性能就会越好,CPU 也就跑的越快。
按照内存布局顺序访问,将可以有效的利用 CPU Cache 带来的好处,这样我们代码的性能就会得到很大的提升,提升数据缓存的命中率。
现代 CPU 都是多核心的,线程可能在不同 CPU 核心来回切换执行,这对 CPU Cache 不是有利的,虽然 L3 Cache 是多核心之间共享的,但是 L1 和 L2 Cache 都是每个核心独有的,如果一个线程在不同核心来回切换,各个核心的缓存命中率就会受到影响,相反如果线程都在同一个核心上执行,那么其数据的 L1 和 L2 Cache 的缓存命中率可以得到有效提高,缓存命中率高就意味着 CPU 可以减少访问内存的频率。所以可以把线程绑定到某个CPU核心来提升多核CPU的缓存命中率。
CPU的缓存一致性
把CPU的Cache数据写回到内存的时机很重要。
为了要减少数据写回内存的频率,就出现了写回(Write Back)的方法。
当发生写操作时,新的数据仅仅被写入 Cache Block 里,只有当修改过的 Cache Block「被替换」时才需要写到内存中,减少了数据写回内存的频率,这样便可以提高系统的性能。
由于CPU当前大多是多核的,而L1和L2都是多个核心独有的,就会有缓存一致性问题(Cache Coherence)。
写传播的原则就是当某个 CPU 核心更新了 Cache 中的数据,要把该事件广播通知到其他核心。最常见实现的方式是总线嗅探(Bus Snooping)。但是这就需要 CPU 时刻监听总线,负担很重。
MESI协议则通过添加状态机来改善总线嗅探的问题。具体的方法可以在面试前直接看下面的表格熟悉一下:
CPU执行任务
多个线程同时读写同一个 Cache Line 的不同变量时,而导致 CPU Cache 失效的现象称为伪共享(False Sharing)。在 Linux 内核中存在 __cacheline_aligned_in_smp 宏定义,其可以在原先a、b两个在物理内存地址上连续的变量,通过这个宏定义将b的地址设置为 Cache Line 对齐地址,避免伪共享。
CPU选择执行的线程,在 Linux 中,线程和进程都是用 task_struct 结构体表示,调度器调度这个结构体,来完成执行。这些任务分为普通任务和实时任务。优先级数值越小则优先级越高。
对于普通任务,一般用完全公平调度(Completely Fair Scheduling)算法,优先选择虚拟运行时间 vruntime 少的任务来执行,当然还要考虑普通任务的权重值,nice 级别越低权重值越大。nice 的值能设置的范围是 -20~19, 值越低,表明优先级越高,因此 -20 是最高优先级,19 则是最低优先级,默认优先级是 0。
软中断
Linux 系统为了解决中断处理程序执行过长和中断丢失的问题,将中断过程分成了两个阶段,分别是「上半部和下半部分」。
- 上半部用来快速处理中断,一般会暂时关闭中断请求,主要负责处理跟硬件紧密相关或者时间敏感的事情。
- 下半部用来延迟处理上半部未完成的工作,一般以「内核线程」的方式运行。
中断处理程序的上部分和下半部可以理解为:
- 上半部直接处理硬件请求,也就是硬中断,主要是负责耗时短的工作,特点是快速执行;
- 下半部是由内核触发,也就说软中断,主要是负责上半部未完成的工作,通常都是耗时比较长的事情,特点是延迟执行;
数据存储
计算机中,负数通过补码来完成保存。所谓的补码就是把正数的二进制全部取反再加 1。用了补码的表示方式,对于负数的加减法操作,实际上是和正数加减法操作一样的。
存小数的方法可以直接去看这个链接:小林图解系统中的数据存储部分
内存管理
虚拟内存
操作系统引入了虚拟内存,进程持有的虚拟地址会通过 CPU 芯片中的内存管理单元(MMU)的映射关系,来转换变成物理地址,然后再通过物理地址访问内存。
为了减少传统的分段管理导致的内存碎片,提出了内存分页(Paging)方法,把整个虚拟和物理内存空间切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间,我们叫页(Page)。在 Linux 下,每一页的大小为 4KB。
内存管理单元(MMU)会负责映射工作,采用了分页,页与页之间是紧密排列的,所以不会有外部碎片。
如果内存空间不够,操作系统会把其他正在运行的进程中的「最近没被使用」的内存页面给释放掉,也就是暂时写在硬盘上,称为换出(Swap Out)。一旦需要的时候,再加载进来,称为换入(Swap In)。所以,一次性写入磁盘的也只有少数的一个页或者几个页,不会花太多时间,内存交换的效率就相对比较高。
在分页机制下,虚拟地址分为两部分,页号和页内偏移。页号作为页表的索引,页表包含物理页每页所在物理内存的基地址,这个基地址与页内偏移的组合就形成了物理内存地址。但这种简单的分页,存在空间缺陷,因为页表由于多个进程会变得非常庞大,故采用多级页表(Multi-Level Page Table)来解决。
例如在32位 CPU 下,页大小 4KB ,一个进程的页表需要 4 MB 空间,而对其进行二次分页,将页表(一级页表)分为 1024 个页表(二级页表),每个表(二级页表)中包含 1024 个「页表项」,形成二级分页。这样,如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表。
但是多级页表,会造成虚拟内存到物理地址上更多的转换步骤,所以把最常访问的几个页表项存储到访问速度更快的硬件,于是计算机科学家们,就在 CPU 芯片中,加入了一个专门存放程序最常访问的页表项的 Cache,这个 Cache 就是 TLB(Translation Lookaside Buffer) ,通常称为页表缓存、转址旁路缓存、快表等。
内存分段和内存分页并不是对立的,它们是可以组合起来在同一个系统中使用的,那么组合起来后,通常称为段页式内存管理。
Linux 系统中的每个段都是从 0 地址开始的整个 4GB 虚拟空间(32 位环境下),也就是所有的段的起始地址都是一样的。这意味着,Linux 系统中的代码,包括操作系统本身的代码和应用程序代码,所面对的地址空间都是线性地址空间(虚拟地址),这种做法相当于屏蔽了处理器中的逻辑地址概念,段只被用于访问控制和内存保护。
在 Linux 操作系统中,虚拟地址空间的内部又被分为内核空间和用户空间两部分, 32 位系统的内核空间占用 1G,位于最高处,剩下的 3G 是用户空间。
内核空间与用户空间的区别:
- 进程在用户态时,只能访问用户空间内存;
- 只有进入内核态后,才可以访问内核空间的内存;
其中用户态的分布:代码段、全局变量、BSS、函数栈、堆内存、映射区。
虚拟内存作用:
- 第一,虚拟内存可以使得进程对运行内存超过物理内存大小,因为程序运行符合局部性原理,CPU 访问内存会有很明显的重复访问的倾向性,对于那些没有被经常使用到的内存,我们可以把它换出到物理内存之外,比如硬盘上的 swap 区域。
- 第二,由于每个进程都有自己的页表,所以每个进程的虚拟内存空间就是相互独立的。进程也没有办法访问其他进程的页表,所以这些页表是私有的,这就解决了多进程之间地址冲突的问题。
- 第三,页表里的页表项中除了物理地址之外,还有一些标记属性的比特,比如控制一个页的读写权限,标记该页是否存在等。在内存访问方面,操作系统提供了更好的安全性。
malloc分配内存
malloc 申请内存的时候,会有两种方式向操作系统申请堆内存。
- 方式一:通过 brk() 系统调用从堆分配内存
- 方式二:通过 mmap() 系统调用在文件映射区域分配内存;
方式一实现的方式很简单,就是通过 brk() 函数将「堆顶」指针向高地址移动,获得新的内存空间。方式二通过 mmap() 系统调用中「私有匿名映射」的方式,在文件映射区分配一块内存,也就是从文件映射区“偷”了一块内存。这两个方法切换的阈值是 128KB, 小于 128KB 就会采用 brk() 来分配。
malloc会分配的时虚拟内存,且会预分配更大的空间作为内存池。free 内存后堆内存还存在,是针对 malloc 通过 brk() 方式申请的内存的情况。如果 malloc 通过 mmap 方式申请的内存,free 释放内存后就会归归还给操作系统。
如果都用 mmap 来分配内存,等于每次都要执行系统调用。执行系统调用会需要切换进入内核态,然后回到用户态,需要消耗时间。同时,mmap 分配内存释放会归还给操作系统,所以分配的地址都会缺页,第一次访问该虚拟地址就会触发缺页中断,而 brk() 申请的堆空间连续,再次申请只需要从内存池取出;但如果都用 brk(),会造成堆内大量不可用碎片,导致内存泄漏。
缺页中断出发后,进程从用户态进入内核态,缺页中断函数会查看是否有空余的物理内存,有的话就分配并建立映射关系。
内存满了会发生什么
缺页中断触发,如果没有空闲物理内存,就会进行回收内存。
- 后台内存回收(kswapd):在物理内存紧张的时候,会唤醒 kswapd 内核线程来回收内存,这个回收内存的过程异步的,不会阻塞进程的执行。
- 直接内存回收(direct reclaim):如果后台异步回收跟不上进程内存申请的速度,就会开始直接回收,这个回收内存的过程是同步的,会阻塞进程的执行。
如果直接内存回收后,空闲的物理内存仍然无法满足此次物理内存的申请,那么内核就会放最后的大招了 ——触发 OOM (Out of Memory)机制。OOM Killer 机制会根据算法选择一个占用物理内存较高的进程,然后将其杀死,以便释放内存资源,如果物理内存依然不足,OOM Killer 会继续杀死占用物理内存较高的进程,直到释放足够的内存位置。
主要有两类内存可以被回收,而且它们的回收方式也不同。
- 文件页(File-backed Page):内核缓存的磁盘数据(Buffer)和内核缓存的文件数据(Cache)都叫作文件页。大部分文件页,都可以直接释放内存,以后有需要时,再从磁盘重新读取就可以了。而那些被应用程序修改过,并且暂时还没写入磁盘的数据(也就是脏页),就得先写入磁盘,然后才能进行内存释放。所以,回收干净页的方式是直接释放内存,回收脏页的方式是先写回磁盘后再释放内存。
- 匿名页(Anonymous Page):这部分内存没有实际载体,不像文件缓存有硬盘文件这样一个载体,比如堆、栈数据等。这部分内存很可能还要再次被访问,所以不能直接释放内存,它们回收的方式是通过 Linux 的 Swap 机制,Swap 会把不常访问的内存先写到磁盘中,然后释放这些内存,给其他更需要的进程使用。再次访问这些内存时,重新从磁盘读入内存就可以了。
从文件页和匿名页的回收操作来看,文件页的回收操作对系统的影响相比匿名页的回收操作会少一点,因为文件页对于干净页回收是不会发生磁盘 I/O 的,而匿名页的 Swap 换入换出这两个操作都会发生磁盘 I/O。所以尽可能先回收文件页。
尽早触发 kswapd 内核线程异步回收内存。具体看这里:kswapd 回收设置
- 在 32 位操作系统,因为进程理论上最大能申请 3 GB 大小的虚拟内存,所以直接申请 8G 内存,会申请失败。
- 在 64位 位操作系统,因为进程理论上最大能申请 128 TB 大小的虚拟内存,即使物理内存只有 4GB,申请 8G 内存也是没问题,因为申请的内存是虚拟内存。如果这块虚拟内存被访问了,要看系统有没有 Swap 分区:
- 如果没有 Swap 分区,因为物理空间不够,进程会被操作系统杀掉,原因是 OOM(内存溢出);
- 如果有 Swap 分区,即使物理内存只有 4GB,程序也能正常使用 8GB 的内存,进程可以正常运行;
如何避免预读失效和缓存污染
LInux 中,读取的文件数据缓存在文件系统中的 Page Cache,大小有限,所以希望通过保留频繁访问的数据在其中,经典的是采用 LRU(Least recently used)算法完成,一般采用链表,头部是最近使用的数据,尾部就是最久没被使用的。
预读机制会把多个 Page 数据装入 Page Cache,以此减少磁盘 I/O 次数,提高吞吐量。但如果这些被提前加载进来的页,并没有被访问,相当于这个预读工作是白做了,这个就是预读失效。为了避免此类问题,最好就是让预读页停留在内存里的时间要尽可能的短,让真正被访问的页才移动到 LRU 链表的头部,从而保证真正被读取的热数据留在内存里的时间尽可能长。Linux 操作系统实现两个了 LRU 链表:活跃 LRU 链表(active_list)和非活跃 LRU 链表(inactive_list);将数据分为了冷数据和热数据,然后分别进行 LRU 算法。预读页就只需要加入到 inactive list 区域的头部,当页被真正访问的时候,才将页插入 active list 的头部。
缓存污染:当我们在批量读取数据的时候,由于数据被访问了一次,这些大量数据都会被加入到「活跃 LRU 链表」里,然后之前缓存在活跃 LRU 链表(或者 young 区域)里的热点数据全部都被淘汰了,如果这些大量的数据在很长一段时间都不会被访问的话,那么整个活跃 LRU 链表(或者 young 区域)就被污染了。为了解决缓存污染,只要我们提高进入到活跃 LRU 链表(或者 young 区域)的门槛,就能有效地保证活跃 LRU 链表(或者 young 区域)里的热点数据不会被轻易替换掉。在 Linux 中,只有内存页被访问第二次,才将页从 inactive list 升级到 active list 中。
Linux 虚拟内存管理、物理内存管理
直接看这里:Linux 虚拟内存
进程管理
基础知识
进程
编写的代码只是一个存储在硬盘的静态文件,通过编译后就会生成二进制可执行文件,当我们运行这个可执行文件后,它会被装载到内存中,接着 CPU 会执行程序中的每一条指令,那么这个运行中的程序,就被称为「进程」(Process)。
单核的 CPU 在某一个瞬间,只能运行一个进程。但在 1 秒钟期间,它可能会运行多个进程,这样就产生并行的错觉,实际上这是并发。
在一个进程的活动期间至少具备三种基本状态,即运行状态、就绪状态、阻塞状态。
再加上创建状态和结束状态,完整的进程状态图如下:
同时,阻塞装填太多,进程可能占用大量物理内存空间,可以把这些空间换出到硬盘,也就是 swap out,那么需要一个挂起状态来描述没有占用实际物理内存空间的情况。同时挂起还需分成阻塞挂起和就绪挂起。那么就变成了下图:
PCB 是进程存在的唯一标识,这意味着一个进程的存在,必然会有一个 PCB,如果进程消失了,那么 PCB 也会随之消失。包含了进程描述信息、进程控制和管理信息、资源分配清单以及 CPU 相关信息。
PCB 一般通过链表形式组织,把具有相同状态的进程链在一起,组成各种队列。
01 创建进程
操作系统允许一个进程创建另一个进程,而且允许子进程继承父进程所拥有的资源。
创建进程的过程如下:
- 申请一个空白的 PCB,并向 PCB 中填写一些控制和管理进程的信息,比如进程的唯一标识等;
- 为该进程分配运行时所必需的资源,比如内存资源;
- 将 PCB 插入到就绪队列,等待被调度运行;
02 终止进程
进程可以有 3 种终止方式:正常结束、异常结束以及外界干预(信号 kill 掉)。
当子进程被终止时,其在父进程处继承的资源应当还给父进程。而当父进程被终止时,该父进程的子进程就变为孤儿进程,会被 1 号进程收养,并由 1 号进程对它们完成状态收集工作。
终止进程的过程如下:
- 查找需要终止的进程的 PCB;
- 如果处于执行状态,则立即终止该进程的执行,然后将 CPU 资源分配给其他进程;
- 如果其还有子进程,则应将该进程的子进程交给 1 号进程接管;
- 将该进程所拥有的全部资源都归还给操作系统;
- 将其从 PCB 所在队列中删除;
03 阻塞进程
当进程需要等待某一事件完成时,它可以调用阻塞语句把自己阻塞等待。而一旦被阻塞等待,它只能由另一个进程唤醒。
阻塞进程的过程如下:
- 找到将要被阻塞进程标识号对应的 PCB;
- 如果该进程为运行