1
头图

《Linux是怎么样工作的》读书笔记

引言

这本书是个人看过的讲操作系统底层里面讲的最通俗易懂的了,但是200多页的内容确实讲不了多深的内容,所以不要对这本书抱有过高期待,当一个入门书了解即可。

书籍介绍

  1. 原富士通一线Linux内核开发工程师基于十余年经验执笔,专业实用
  2. 聚焦Linux核心功能,简明易懂地讲解操作系统是怎样工作的
  3. 198张示意图帮助理解,详略得当,为读懂大部头开路
  4. 结合大量实验程序,边动手操作边学习,真实体验系统运行过程

个人评价

内容比较基础,但是有关Linux的内容都有涉及,另外作者用C语言程序对于操作系统的缓存,交换内存,CPU进程管理器,固体硬盘和机械硬盘随机读写和顺序读写做验证和测试的程序比较有意思。

但是不得不说能把操作系统这种抽象的东西讲的生动形象实属不易,作者在日本一线大厂(可以翻翻富士通相关资料)搞Linux内核开发专业程度也毋庸置疑,另外这本书的编排是由浅入深的挺不错。

总结:非常难以定位的一本书,建议可以参考里面的知识,根据相关内容深入即可。

资源

2022年3月出版的一本书所以没有找到相关资源。

下面内容为随书一些C语言模拟操作系统底层工作的一些程序,感兴趣可以下载来看一下。

链接: https://pan.baidu.com/s/1eU65... 提取码: pghr

笔记索引

注意笔记的索引并不是按照原书的结构组织,因为个人阅读这本书是“倒着”读的,结合目录发现从后往前读比较符合个人的理解习惯,也就是从外部存储器到内部的工作机制比较符合个人的思考。

可以点击副标题跳转到相关的节点

常规的认识是知识由浅入深,其实有时候用难易交叉学习的方式可能更加符合人的学习习惯

第一部分:Linux与外部结构介绍

主要介绍了机械磁盘和SSD硬盘的工作机制对比,顺序读写和随机读写之间的差别,这部分使用C语言模拟磁盘的读写性能比较有意思。

介绍了Linux和设备交互的文件系统设计,一共分为7层,当然书中只是简单归纳,如果要深入需要阅读更多资料。

讲述了IO调度器和预读机制的相关内容。

第二部分:Linux文件系统设计

这一节讲述如何快速的了解一个Linux文件系统的设计方式,文件系统的设计当然没有不是几页纸就能讲清楚的,但是对于我们大致了解Linux整体的设计思路入门还是不错的。

第三部分:计算机存储层次简析

如果你对笔记电脑或者台式机主板等等基本配置了解,或者对整个操作系统的工作进程有一个大致的了解,这一节的内容完全可以跳过。

计算机的存储器层级结构是越靠近CPU和CPU关系越密切价格越高容量越小,我们常见的存储器,速度从快到慢的排序是:寄存器 -> 高速缓存 -> 内存 -> 外部存储器,这一节则针对这几个存储层级进行介绍。

之后会介绍关于转译后备缓冲区,页面缓存,缓冲区缓存和Linux不常见也几乎不使用的缓存调优参数。

第四部分:Linux内存管理和优化

针对内存的管理是操作系统进程管理核心和关键所在,此部分介绍了关于内存管理的内容,内存管理是整本书介绍最为详细的部分,个人认为核心是掌握 请求分页写时复制,这两个特性被大量使用,除此之外理解内存的分配方式和分配的细节过程也是必要的。

另外这部分个人笔记在补充的同时也将内容拆分为上下部分:

第五部分:进程调度器
CPU进程调度目前主流的方式是两种,第一种是像window那样抢占式调度,每一个CPU可能会出现调度时间分配不等的情况,而另一种是时间分片的方式,时间分片是Linux 常见的进程调度器,特点是每一个进程有近似相等的CPU使用权,在使用完成之后立马交给下一个进程完成工作,使用分片的方式虽然可能导致一些重要任务延迟,但这样的处理和调度方式使得系统最为稳定。

进程调度器本身很复杂,为了减少复杂性作者没有做过多介绍,所以个人笔记内容也相对较少。

第零部分:计算机程序概览
理解操作系统运行我们需要了解有关计算机信息的基础概念,我想如果有想法去研究操作系统底层多少对于计算机的基础理念不会陌生,所以这一部分个人当作总结。

附录
此部分是对于第一个部分物理磁盘的分配方式资料扩展,感兴趣可以阅读。

注意⚠:最后个人的笔记组织形式将会是难-易-难混杂的组织方式。

Linux与外部结构介绍

HDD磁盘介绍

机械磁盘从俯瞰的逻辑结构理解为类似一个同心圆的多个圈,从外层到内层进行编号,磁盘通过顺时针顺序编号,逆时针转动磁盘,这样处理是考虑查找磁盘的时候可以直接按照顺序扫描过去,磁头前进方向就是编号递增方向。

在下面的结构图中,磁道是每一个同心圆,而扇区指的是切割磁道所形成的“扇面”,因为切割之后的样子很像扇子的样子所以被称为扇区,扫描数据需要磁头在磁道上滑动,同时扇区会从0开始编号,一个编号对应一个扇区。

注意这是磁盘的俯视图,也就是说线的部分是物理磁盘上的“沟壑”,而扇区就是编号内的块。

下面是磁盘的侧面切面图,垂直叠加的盘通过一个磁盘一个磁头的组合,通过多磁头加快数据的处理速度。

注意在HDD磁盘中一个扇区读写最小单位为512字节,而且每一个扇区都是512字节,不管扇区是在外层还是内层。

⚠️注意:很多框架或者数据库会把一次读写的大小设置为512字节,因为512是最小读写单位所以可以不需要额外的维护可以保证读写的原子性。

磁盘大小计算

最早期磁盘可以通过下面的公式计算出整个磁盘的大小,因为磁道和扇区的数量是一一对应的:

存储容量=磁头数磁道(柱面)数每道扇区数*每扇区字节数

这样的设计有一个显而易见的问题那就是无论扇区面积大还是小都是固定大小,很显然外层的扇区数据被白白浪费了。

针对这样的问题后续的机械磁盘出现了改进,这项技术叫做ZBR(Zoned Bit Recording,区位记录)技术,这项技术根据每一圈的扇区来划分大小,同一个磁道圈内的扇区分布和大小相同。

这意味着越是外层的扇区数量越多,而内圈则较少,在划分之后密度均匀分布。

由于磁盘扇区存储形式的改进,寻址模式自然也要跟着进步,如今的硬盘大多使用 、LBA(Logical Block Addressing)逻辑块寻址模式,了解这个寻址模式才能了解磁盘的大小的计算方式。

然而现在HDD的磁盘读写受限在随机读写速度上,过去HDD磁盘比较流行的转速为7200转和5400转等等,区别的话是跑的快和跑的慢一点的蚂蚁。

虽然有SAS硬盘能突破15000转,并且现在还有研究团队研究寻找不同材料或者其他方式突破磁盘物理转速的限制(比如双磁盘转动的方式加快旋转),然而始终无法突破机械磁盘的设计的物理限制。

市面上为什么主流贩卖7200转的磁盘和5400的转速的磁盘而不是别的磁盘?

一方面是7200的随机读写性能经过测试是最佳的,同时讨论一块磁盘的性能不能看顺序读写的速度而是要看随机读写的速度。

针对机械磁盘存在一些物理壁垒,自东芝公司在1984年研究出闪存之后,闪存技术不断进步,又经过了5年之后的1989年,SSD磁盘逐渐走进历史舞台。

⚠️注意:为什么是7200转和5400转等等奇怪数字?

这两个数字都要从3600说起,计算机的前十年几乎所有的硬盘都是3600转的,这个3600又是从哪里来的呢?因为美国的交流电是60Hz的!于是就有了下面的公式:

  • 60Hz × 1转/Hz ×60秒/分钟 = 3600转/分钟
  • 5400 RPM = 3600 RPM × 1.5
  • 7200 RPM = 3600 RPM × 2
    另外还有一个原因是专利争夺,你会发现转速有15000却没有10000,9000,8000这种数字,其实都是因为整数和500的倍数转速都被专利注册了,但是专利注册者估计没想到转速能破万吧。

SSD磁盘介绍

SSD 的硬盘分为两种,一种是基于闪存颗粒的闪存固态硬盘,另一种是DRAM 硬盘

闪存颗粒的硬盘也就是我们现代笔记本电脑以及移动固态多数使用的硬盘,这种磁盘的最大优点是可以移动,同时数据的保护不依赖电源就可以存储,在闪存颗粒中通常被分为 QLC,MLC,TLC,哪怕是寿命最短的QLC硬盘也有5-6年的寿命,而MLC寿命最长,保护得当往往可以十几年正常工作。

⚠️注意:固态硬盘过去成本非常高所以机械磁盘是主流,广泛普及也就这几年时间,所以上面说的内容都是理想状态。

在企业级的服务器使用的固态中通常以MLC为主,SSD磁盘的最大特点是不像是HDD一样受到物理冲击有可能造成整块磁盘不可用,但是SSD一旦损坏数据的修复成本很高或者说根本无法修复。

现在来看 SSD已经非常便宜了,但是HDD的大数据低成本存储依然很受一些用户欢迎。

DRAM是介于机械磁盘和固态硬盘中间的形式,其采用DRAM作为存储单元,它效仿传统硬盘的设计,可被绝大部分操作系统的文件系统工具进行卷设置和管理,并提供工业标准的PCI和FC接口用于连接主机或者服务器,但是最大问题应用范围相对较窄

HDD数据读取方式

HDD的磁盘读取数据顺序如下:

  • 设备将需要读写的扇区和设备号码以及扫描多少个扇区告诉磁盘
  • 移动磁头和转动盘片找到对应扇区。
  • 读取数据,把数据写入缓冲。
  • 如果所有的扇区扫描完成,读取的操作则算是完成。

HDD磁盘读写的要点

从逻辑上来看计算扇区扫描位置和扫描数量计算处理速度是很快的,将扇区内的数据读取或者写入的数据也是相对较快的。

然而我们知道因为转速限制的和磁头和盘片物理扫描是非常慢的,整个读写的性能瓶颈是磁头扇区寻址和扫描磁盘所需的物理磁盘开销以及最后带来的随机读写性能的权衡。

读写方式

磁盘扫描的几种情况磁盘扫描的方式直接决定了数据处理读写速度:

  • 顺序扫描:顺序扫描就是在一个磁道上直接划过连续的几个扇区,一次扫描就可以获取数据所以非常快。
  • 多次连续顺序扫描:连续顺序扫描是针对连续的几个扇区进行多次扫描,这个时间开销主要是在盘片的转动上,虽然依然比较快但是盘片转动依然产生一定延迟。
  • 随机读写:随机读写的开销主要在磁道来回寻址上,此时不但可能会产生磁盘转动,磁头还需要寻找分散的扇区,随机读写的效率是非常低的
⚠️注意:对于单次IO的访问如果获取的数据量超过磁盘请求数据量的上限,则会把请求由单次的顺序读写,拆分为多次的顺序扫描

影响硬盘性能的因素

  1. 磁盘寻道时间:磁盘的平均寻道时间一般在3-15ms。
  2. 磁盘转速:转速越快
  3. 磁盘本身的读写性能:和磁盘的设计厂商也有关系,随机读写强的HDD硬盘通常具备更好的IO性能,同时磁盘数据传输的越快传输量越大效果越好(废话)。

机械磁盘需要关注寻道时间旋转延迟,当然HDD的羸弱的读写性能实际上大同小异。

通用块层

通用块层:指的是在Linux系统对于HDD和SSD的抽象。

HDD和SDD它们被称为块设备。块设备的访问方式有两种,第一种是直接通过挂载的方式通过设备文件直接读写,第二种是根据文件系统对于磁盘进行封装以及提供引导入口,当然大部分的软件使用第二种方式。

由于不同类型的块设备处理方式不同,这些设备的处理的需要依赖驱动程序的控制才能实现访问,但是以我们日常使用Windows系统的经验,不可能是一个块设备一个驱动程序,不然我们每一次加入新硬盘都要装一遍驱动,这样也太麻烦了。

那么操作系统如何解决这个问题?这也就是通用块层的作用了:

Linux和设备的交互流程

这一个流程图有很多细节可以了解,抽出任何一个层都能写一篇长文出来,这里我们以简单了解IO调度器和磁盘预读机机制为主。

IO调度器和预读机制

在HDD中还有两个十分影响性能的机制,IO调度器预读机制,但是注意针对HDD的预读要比 SSD的预读效果要好很多,因为有时候因为排序和预读容易导致SSD的负面优化。

⚠️注意:有时候我们升级老设备将机械磁盘换固态的时候重装系统有可能出现黑屏,这是因为部分旧主板会通过BIOS对机械磁盘调优,类似对于磁盘“预热”,然而这种预热会影响固态的启动,所以出现类似情况可以检查BIOS是否有勾选类似的加速机械磁盘启动的选项。

IO调度器

IO调度器:是针对块设备访问的时候将请求积攒到一定的时间之后在进行一次请求。

所以针对IO调度器主要有下面两个重要的工作:

  • 合并:把对于多个扇区的访问IO请求合并为一个请求。
  • 排序:因为每个扇区都有编号,IO调度器会把连续的扇区访问的IO进行排序之后再进行访问,使得磁盘扫描更加趋近顺序扫描。
⚠️注意:IO调度器一般针对并发线程读写或者异步IO过程中等待IO结果的时候使用IO调度器,

预读机制

磁盘的磁头在扫描数据的时候,不会只扫描设备要求的几个扇区,而是会多扫描周边的扇区,注意这个预读只有在顺序扫描的时候发挥作用,当预读机制生效的时候如果发现预读的扇区在下一次访问不会用到,直接丢弃即可。

Linux文件系统设计

简单的文件系统如何设计

从最简单的角度考虑设计基本的文件系统我们可以用一个常规的文件读写举例。

最简单的文件系统包含下面的处理流程:

  • 首先文件数据从0开始记录,每一 个文件在文件系统中有名称,大小和位置三条基本信息。
  • 如果没有文件系统的辅助我们需要自行考虑文件的磁盘存储位置,需要从磁盘区域1到区域10根据文件的大小存储到块设备的对应位置,并且需要记录当前块的文件写入开始结束和结束为止,记录存储的数据大小。

为什么会有“态”?

显然在早期的单进程单用户操作系统中,是不存在态这个概念的。然而随着进程和用户的出现,当时的计算机面临着一个重要问题,就是如何限制不同进程的操作的权限

并不是所有的进程都能允许所有的外部用户操作的,因为不知道未来会出现哪些新的进程运作,所以工程师为了让系统和用户的进程可以分开,就准备让一些危险的操作只允许操作系统的进程去做,用户进程如果要做一些危险操作必须经过操作系统的“盘问”,之后再由操作系统去做。

最后“态”被设计为下面的形式:

模式切换

下面是Linux中用户态内核态硬件三者的关系:

用户态

是用户看得见的操作,比如想要读取某一个文件或者想要某一个文件改几个字,用户发送的这些指令通过内核转为机器码命令,然后在内核态对于磁盘进行IO操作。

也就是说 用户模式:发送指令操作 -> 内核模式,翻译用户指令为块设备可以识别的命令 -> 硬件。

在上面设计的最简单的文件系统中,用户模式切换到内核模式进行文件管理只需要关心文件大小,文件位置和文件名称。

内核态

只有拥有系统操作权限的操作系统才有权进行操作和访问,一般都是执行一些系统的核心工作。

硬件

硬件又被称为外部存储,这些操作只会和内核交互。

Linux文件系统结构

Linux的文件系统是树状结构设计,文件系统可以支持不同格式,不同文件系统的差别主要在最大支持操作文件大小,文件系统本身大小以及各种文件操作速度差别上。

文件系统存在ext2、ext3、ext4,它们的文件大小和存储形式和存储的位置都有差别,那么Linux是如何处理的呢?

Linux文件系统将使用接口的形式将文件IO操作进行抽象,无论文件系统的结构形式如何变动,最终都是通过和下面提到相关的接口来完成交互的。

吐槽:有点像是设计模式的外观模式
  • 创建删除:create/unlink
  • 打开关闭:open/close
  • 打开文件读数据:read
  • 打开文件写数据:write
  • 打开文件移动到指定位置:lseek
  • 特殊文件系统特殊操作:....

这一点和Linux块设备管理类似,在Linux与外部结构介绍中提到了块设备对于文件系统提供了通用层块进行抽象,对于用户模式角度所看到的单块设备之间是没有差别的,而真正的驱动处理出现在内核态中。

读取文件数据流程

在Linux中读取文件的流程如下:

  • 各个文件系统通用处理。
  • 文件系统专用处理,请求系统调用对应处理指令。
  • 设备驱动执行读写数据操作。
  • 块设备驱动完成读写指令操作。

从逻辑结构来看整个交互流程很简单,然而实际上这都是Linux的工程师们不断努力的成果。

数据和元数据

在Linux数据的种类分为元数据数据,元数据和文件名称,文件大小,文件位置相关,这些参数用于内核态读取块设备作为参考,而数据则是我们日常使用的视频数据,文本数据。

元数据除了上面的信息种类之外还包含下面的内容:

  • 种类:判断文件是保存数据的普通文件还是目录还是其他文件,也就是文件类型。
  • 时间信息:文件的创建时间、最后一次访问时间、最后一次修改时间。
  • 权限信息:Linux权限控制用户访问。

我们可以通过df命令和相关参数可以详细的了解文件系统的参数和运行情况,df是重要的运维命令,可以通过它了解到磁盘的容量情况。

g@192 ~ % df

Filesystem                                                   512-blocks       Used Available Capacity  iused      ifree %iused  Mounted on

/dev/disk3s1s1                                                965595304   29663992  73597064    29%   500637  367985320    0%   /

devfs                                                               711        711         0   100%     1233          0  100%   /dev

/dev/disk3s6                                                  965595304         48  73597064     1%        0  367985320    0%   /System/Volumes/VM

/dev/disk3s2                                                  965595304    1034480  73597064     2%     2011  367985320    0%   /System/Volumes/Preboot

/dev/disk3s4                                                  965595304      31352  73597064     1%       48  367985320    0%   /System/Volumes/Update

/dev/disk1s2                                                    1024000      12328    985672     2%        3    4928360    0%   /System/Volumes/xarts

/dev/disk1s1                                                    1024000      15040    985672     2%       27    4928360    0%   /System/Volumes/iSCPreboot

/dev/disk1s3                                                    1024000       1240    985672     1%       39    4928360    0%   /System/Volumes/Hardware

/dev/disk3s5                                                  965595304  859395304  73597064    93%  1212174  367985320    0%   /System/Volumes/Data

/dev/disk6s1                                                 1000179712  807402240 192777472    81%  3153915     753037   81%   /Volumes/Untitled

/dev/disk7s1                                                 1953443840 1019557888 933885952    53%   497831     455999   52%   /Volumes/Extreme SSD

map auto_home                                                         0          0         0   100%        0          0  100%   /System/Volumes/Data/home

//GUEST:@Windows%2011._smb._tcp.local/%5BC%5D%20Windows%2011  535375864  212045648 323330216    40% 26505704   40416277   40%   /Volumes/[C] Windows 11.hidden

/dev/disk5s2                                                     462144     424224     37920    92%      596 4294966683    0%   /private/var/folders/wn/dvqxx9sx4y9dt1mr9lt_v4400000gn/T/zdKbGy

磁盘配额

容量配额是磁盘管理中的核心,对于Linux文件管理系统来说有下面几种磁盘的配额方式:

  • 用户配额:用户配额通常指的是/home,通常每一个用户家目录有固定的额度配比
  • 子卷配额:限制名字为子卷的单元可用容量。
  • 目录配额:可以通过特定目录的可用容量,比如共享目录的用户可用容量,ext4和xfs可以设置目录配额。

除了对于针对不同类型的配额之外,还需要考虑系统正常运行运作的系统配额,也就是说如果给用户和系统按照一刀切的方式配比100%的方式划分磁盘是一件危险的操作,对于一块磁盘保持不超过80%是比较常见设置。

文件系统意外恢复

数据管理最常见的问题是数据不一致,诸如在没有完成写入的时候突然断电,这种情况并不算特别少见,Linux提供了下面的两种方式解决断电数据状态不一致的问题:

  • 日志:通常出现在ext4和xfs的文件系统。
  • 写时复制:通常为btrfs的实现。

日志方式

使用日志处理情况要多一些,因为日志的方式具备一定的可读性也方便恢复,操作主要分为下面两个步骤:

  • 数据修改之前把原子操作记录到日志当中。
  • 宕机恢复的时候根据日志记录内容还原文件状态。

如果异常情况发生在日志记录之前,可以直接丢弃写入一部分的日志并且回滚,当作文件状态没有更改过。而如果异常状态发生在原子操作的过程之后,则根据日志的记录把操作重新执行一遍即可。

现代文件系统更多的是来自于系统的BUG产生的数据不一致问题,现代多使用SSD硬盘,SSD硬盘写入都非常快基本不会出现写入时数据不一致问题。

写时复制方式

不同文件系统对于写时复制的实现不同,介绍写时复制需要了解一些传统的比如ext4和xfs的文件系统工作机制。

这些文件系统在文件创建之后就会固定存放到磁盘的某个位置,哪怕删除或者更新文件内容也只是在原有空间上进行操作。

btrfs的写时复制的文件系统管理方案则比较特殊,创建之后的文件每次更新都会放到不一样的位置。

写时复制就是说更新和写入都是一次类似“复制”的操作,当新数据写入完成再把引用更新即可,原有的内容只要不被新文件覆盖还是可以被找到。

如果在写入的时候突然断电怎么办?

这时候数据是在另一个地方操作的,数据写入到一半也不会对旧数据有影响,如果是其他操作情况下比如写入刚完成没有更新引用的情况,此时只需要把引用更新一下即可。总之就是怎么样都不会影响原来的数据。

⚠️注意:其实磁盘本质上是没有删除这个概念的,计算机所谓的删除只是用户进程无法通过常规操作访问被删除的文件所在地址而已,但是通过一些特殊处理还是有办法通过文件碎片恢复原始文件的。

无法恢复的意外

如果是文件系统的BUG无法恢复的意外,对于不同的文件系统来说处理方案也不同。

几乎所有的文件系统都有通用的fsck命令进行恢复,但是这个命令定义是有可能恢复数据状态。

下面是关于这个命令的介绍:

fsck 命令

Linux fsck(英文全拼:file system check)命令用于检查与修复 Linux 档案系统,可以同时检查一个或多个 Linux 档案系统。

语法
fsck [-sACVRP] [-t fstype] [--] [fsck-options] filesys [...]

参数 :

  • filesys : device 名称(eg./dev/sda1),mount 点 (eg. / 或 /usr)
  • -t : 给定档案系统的型式,若在 /etc/fstab 中已有定义或 kernel 本身已支援的则不需加上此参数
  • -s : 依序一个一个地执行 fsck 的指令来检查
  • -A : 对/etc/fstab 中所有列出来的 partition 做检查
  • -C : 显示完整的检查进度
  • -d : 列印 e2fsck 的 debug 结果
  • -p : 同时有 -A 条件时,同时有多个 fsck 的检查一起执行
  • -R : 同时有 -A 条件时,省略 / 不检查
  • -V : 详细显示模式
  • -a : 如果检查有错则自动修复
  • -r : 如果检查有错则由使用者回答是否修复

案例

检查 msdos 档案系统的 /dev/hda5 是否正常,如果有异常便自动修复 :

fsck -t msdos -a /dev/hda5

fsck 命令存在的问题

然而这个命令看似很强大,但是存在一些很严重的性能问题:

  • 遍历文件系统并且检查文件系统的一致性同时还会修复不一致的地方,文件系统非常庞大恢复速度会长达几个小时或者几天。
  • 如果中途恢复失败,崩溃的不只是机器。
  • 修复成功不一定会恢复到期望状态,同时对于一切不一致的数据和元数据都会删除。

计算机存储层次简析

存储组件介绍

首先我们来看看不同存储层次的介绍,包括上面提到的寄存器,高速缓存,内存以及他们三者之间的关系。

我们从整体上看一下存储层次结构图:

注意⚠️:小字这些参数放到现在都是比较老的了,我们只需要简单理解从左到右速度由最快到快到慢到最慢的递进。

高速缓存

高速缓存是位于CPU与主内存间的一种容量较小但速度很高的存储器

内存的数据被读取之后,数据不会直接进入寄存器而是先在高速缓存进行存储,高速缓存通常分为三层,读取的大小取决于缓存块的大小,读取速度取决于不同层级高速缓存的容量。

高速缓存的执行步骤如下:

  1. 根据指令把数据读取到寄存器。
  2. 寄存器进行计算操作。
  3. 把运算结果传输给内存。

在上面的三个步骤中寄存器和高速缓存基本没有传输消耗,但是内存的传输就就慢不少了,所以整个流程运算的瓶颈也是内存的传输速度,这也是为什么使用高速缓存来解决寄存器和内存之间的巨大差异。

高速缓存分为L1,L2,L3,在讲述理论知识之前,这里先举一个形象一点的例子方便理解:

  • L1 cache:就好像需要工具在我们的腰带上可以随时取用,所以要获取它的步骤最简单也最快
  • L2 cache:就好像需要的工具放到工具箱里面,我们如果需要获取要先打开工具箱然后把工具箱的工具挂到腰上才能使用。为什么不能从工具箱取出来再放回去呢?其实思考一下如果你需要频繁使用那得多累呀。另外工具箱虽然比腰上的空间大不少,但是也没有大特别多,所以L2 cache 没有比L1 cache大多少。
  • L3 cache:L3相比L1和L2要大非常多,相当于一个仓库,我们获取数据需要自己走到仓库去找工具箱然后放到身边,然后再像是上面那样执行一次,虽然仓库容积很大,但是需要操作的步骤最多,时间开销也最大。

L1 cache下面是L2 cache,L2下面是 L3 cache,根据上面介绍L2和L3都有跟L1 cache一样的问题,要加锁,同步,并且L2比L1慢,L3比L2慢。

这里我们再举例简述高速缓存的内部操作:

假设需要读取缓存块是10个字节,高速缓存为50个字节,R0、R1的寄存器总计为20个字节,当R1需要读取某个地址的数据时,在第一次读取数据的时候将10字节先加载到高速缓存,然后再由高速缓存传输到寄存器,此时R0有10字节的数据,如果下次还需要读取10个字节,同样因为高速缓存发现缓存中有相同数据,则直接从高速缓存读取10个字节到R1中。

那么如果此时R0数据被改写会怎么办?首先CPU会先改写寄存器的值,改写寄存器值之后会同时改写高速缓存的值,此时如果存在从内存进来缓存块数据,在高速缓存中会先标记这些值,然后高速缓存会在某一个时刻把改写的数据同步到内存中。

如果高速缓存不足系统会发生什么情况?

首先高速缓存会根据缓存淘汰机制淘汰末端最少使用的高速缓存,但是如果高速缓存的“变脏”速度很快并且高速缓存的容量总是不足,此时就会发生内存频繁写入高速缓存并且不断变动高速缓存的情况,此时就有可能会出现可感知的系统抖动。

注意⚠️:本部分讨论的内容全部为回写,改写的方式分为直写回写,回写在高速缓存中存在一定的延迟,利用时间积累的方式定时改写的方式进行内存的同步刷新,而直写的方式则会在高速缓存改变的那一刻立刻改写内存的值。

如何衡量访问的局限性呢?

几乎所有的程序都可以分为下面两种情况:

  • 时间局限性:在一定的时间内缓存可能被访问一次,但是可以隔一小段时间再一次访问,常见的情况是一个循环中不断取值。
  • 空间局限性:访问一段数据的同时还需要访问它周边的数据情况,有点类似磁盘的预读机制。

如果进程可以衡量并且把控好上面两个点,那么基本可以认为是一款优秀的程序,但是现实情况往往不是如此。

小结:

  • 高速缓存是利远远大于弊的一个设计。
  • 数据不一致和数据同步高速缓存性能影响的主要问题。
  • 高速缓存一旦被占满,则系统处理速度会出现一定延迟。

寄存器

寄存器包括指令寄存器(IR)和程序计数器(PC),它属于中央处理器的组成部分,寄存器中包含指令寄存器程序计数器以及累加器(数学运算)。

ARM走的是简单指令集,X86走复杂指令集,虽然X86从现在来看是走到了尽头,但是依然占据市场主导地位。

复杂指令集会包含非常多的寄存器完成复杂运算,比如下面一些寄存器:

  • 通用寄存器
  • 标志寄存器
  • 指令寄存器

如果有感兴趣可以将寄存器作为深入X86架构的入口。

内存

内存不仅仅是我们熟知的电脑内存,从广义上来说还包括只读存储,随机存储和高速缓存存储

这里可能会有疑问为什么内存使用的最多却不如寄存器和高速缓存呢?

因为内存不仅仅需要和CPU通信还需要和其他的控制器和硬件打交道,管的事情越多效率自然越低,同时如果内存吃紧CPU还需要等待内存传输,当然这也可以反过来解释为什么需要高速缓存和寄存器。

除了上面提到的原因之外,还有比较关键的原因是主板的总线带宽是有上限的并且需要共享给各路使用,比如南桥和其他的一些外接设备等等,同时总线也是需要抢占的,并不是分片使用。

其他补充

转译后备缓冲区

下面的内容来自百科的解释:

转译后备缓冲器(英语:Translation Lookaside Buffer,首字母缩略字:TLB),通常也被称为页表缓存转址旁路缓存,为CPU的一种缓存,由内存管理单元用于改进虚拟地址到物理地址的转译速度。

目前所有的桌面型及服务器型处理器(如 x86)皆使用TLB。TLB具有固定数目的空间槽,用于存放将虚拟地址映射至物理地址标签页表条目。为典型的结合存储(content-addressable memory,首字母缩略字:CAM)。

其搜索关键字为虚拟内存地址,其搜索结果为物理地址。如果请求的虚拟地址在TLB中存在,CAM 将给出一个非常快速的匹配结果,之后就可以使用得到的物理地址访问存储器。如果请求的虚拟地址不在 TLB 中,就会使用标签页表进行虚实地址转换,而标签页表的访问速度比TLB慢很多。

有些系统允许标签页表被交换到次级存储器,那么虚实地址转换可能要花非常长的时间。

进程如果想要访问特殊的数据,可以通过下面提到的方式访问逻辑地址:

  • 对照物理页表通过查表的方式把虚拟地址转物理地址。
  • 通过访问对应的物理地址寻找实际的物理地址。
注意⚠️:这里的操作类似二级指针的访问操作,如果想要高速缓存发挥作用必须是一级指针的查找才有意义,但是二级指针的查找是没有太大意义的。

转译后备缓冲器说白了就是用于加速虚拟地址到物理地址转化的一块特殊空间,目的是为了提高多级嵌套映射查找的速度。

页面缓存

注意上面提到的内容是页表缓存,这里是页面缓存

页面缓存的作用是什么呢?我们都知道外部的硬件存储速度是最为缓慢的,通常应用程序操作硬盘中的数据都是预先把数据加载到内存再进行操作,然而数据并不是直接从磁盘拷贝到内存的,而是在内存和外部存储设备之间多了一层页面缓存。

页面缓存的读取步骤如下:

  • 进程读取磁盘文本数据,寻找到相关数据之后将内容加载到页面缓存。
  • 把页面缓存的内容复制到内存中,此时物理数据和内存以及页面缓存数据一致。
  • 如果需要改写文件文本数据,首先会通知页面缓存标记自己为“脏页”。
  • 如果内存不足则空出空闲的页面缓存给内存使用。
  • 如果页面缓存和内存都不足就需要刷新“脏页”空出空间给内存继续使用。
  • 通常情况下页面缓存会定期刷新缓存回写到磁盘中保持数据同步。

另外需要注意如果页面缓存一直没有进程访问,页面缓存会一直“膨胀”,如果页面缓存和内存一直不够用,就会不断的回写脏页并且产生性能抖动问题。

缓冲区缓存

缓冲区缓存很容易和页面缓存搞混,我们只需要简单理解是对原始磁盘块的临时存储,也就是用来缓存磁盘的数据,比如设备文件直连外部的存储设备,U盘读写和外接磁盘的读写等等,这些读写通过缓存区缓存进行管理。

需要注意缓冲区缓存通常不会特别大(20MB 左右),这样内核就可以把分散的写集中起来,统一优化磁盘的写入,比如可以把多次小的写合并成单次大的写等等。

Linux中调优参数

了解上面各个组件的内容和细节之后,我们来看几个简单的Linux调优参数。

回写周期

回写周期可以通过sysctl 的vm.dirty_writeback_centisecs 参数调整,但是注意这个值的单位比较特殊,厘秒,这个参数默认设置为500,也就是5秒进行一次回写。

厘秒(英文:centisecond,符号cs),1厘秒 = 100分之1

当然除非为了实验了解否则不要把这个值设置为0。

除了这个参数之外,还有个百分比的参数,当脏页的数量超过百分比之后就会触发脏页回写的操作防止性能剧烈抖动,下面案例的10代表了10%。

下面是这个参数的内容:

vm.dirty_backgroud_ratio = 10

如果想要使用字节的形式控制这个阈值,可以通过参数vm.dirty_background_bytes指定,如果这个参数为0则代表不开启这个配置。

脏页不允许一直存在,如果脏页积攒到一定的量的时候会内核会触发回写操作,可以通过vm.dirty_ratio 控制,当到达此百分比内核会阻塞用户进程并且把所有的脏页回写。

vm.dirty_ratio除了设置百分比参数也可以通过字节限制,参数是vm.dirty_bytes

除了这些不太常用的参数之外,还有一些更为特殊的调优参数,
比如配置用于清空所有的页面缓存的操作方法是向/proc/sys/vm/drop_caches 写入3,为什么是写入3,这里留给读者自己寻找答案。

超线程

超线程(HT, Hyper-Threading)是英特尔研发的一种技术,于2002年发布。超线程的技术可以把一个核心伪装成两个核心看待,同时对于单核心的CPU,也可以享受模拟双核心的优惠,当然超线程技术不只是有好处,还有一个明显的缺点是多线程抢占以及线程上下文带来的开销,同时哪怕在最理想的情况下超线程的技术最多也只能提升 20% -30%的,但是这个优化对于当年技术实力有限的情况下的技术优化和性能提升效果是非常显著的。

<s>从此牙膏厂走向了挤牙膏的不归路</s>

小结

这部分内容更像是对于家用电脑常见的几个核心组建进行稍加深入的介绍,学习这些内容不仅对于计算机的深入理解是必要的,对于我们日常选配电脑一些商家说明也能有更深理解。

在其他的补充部分介绍了三个缓存,分别是转译后备缓冲,页面缓冲,缓冲区缓冲,这三者虽然名字相近但是内部负责的工作差别还是比较大,介绍三者之后介绍了一些关于Linux的调优参数。

对于深入X86架构来说,理解各个寄存器的核心工作机制比较关键,而对于战未来的ARM使用的精简指令集对于整个生态发展更为合适。

Linux内存管理

简单介绍

下面我们就来简单介绍Linux内存管理的,在Linux中内存管理可以大致理解为三个部分:

  • 内核使用的内存
  • 进程使用的内存
  • 可用内存(空闲内存)

其中除开内核使用的内存维持系统正常运行不能被释放之外,其他均可以由操作系统自由支配。在Linux中拥有free命令来专门查看内存的使用情况,执行的效果类似如下:

/opt/app/tdev1$free
             total       used       free     shared    buffers     cached
Mem:       8175320    6159248    2016072          0     310208    5243680
-/+ buffers/cache:     605360    7569960
Swap:      6881272      16196    6865076

各个列的含义如下:

  • total:系统搭载物理内存总量,比如上面为8G。
  • free:表面可用内存。
  • buff/cache:缓冲区缓存和页面缓存,在计算机存储层次简析中提到了当内存不够可以使用释放缓存腾出空间给内存使用。
  • availiable:实际可以使用的内存,计算公式很简单即内核之外的可用总内存 - (free + buff/cache 最大可以释放的内存)

除了列数据之外还有一个swap的行,这个参数的含义将在后文进行介绍。

Linux除了free命令之外,还有sar -r命令,可以通过这个参数指定采集周期,比如-r 1就是1秒采集一次。

个人目前使用的电脑为Mac,虽然是类Unix系统但是没有free相关的命令,为此可以使用下面的命令进行简单的替代,但是不如free强大。

在Mac中使用top -l 1 | head -n 10查看整体系统运行情况。

MacBook-Pro ~ % top -l 1 | head -n 10

Processes: 604 total, 2 running, 602 sleeping, 3387 threads 

2022/04/15 17:29:57

Load Avg: 2.84, 3.27, 5.68 

CPU usage: 6.8% user, 14.18% sys, 79.72% idle 

SharedLibs: 491M resident, 96M data, 48M linkedit.

MemRegions: 168374 total, 5515M resident, 235M private, 2390M shared.

PhysMem: 15G used (1852M wired), 246M unused.

VM: 221T vsize, 3823M framework vsize, 0(0) swapins, 0(0) swapouts.

Networks: packets: 312659/297M in, 230345/153M out.

Disks: 788193/14G read, 161767/3167M written.

除此之外,可以在Mac中使用使用diskutil list

~ > diskutil list

/dev/disk0 (internal):

   #:                       TYPE NAME                    SIZE       IDENTIFIER

   0:      GUID_partition_scheme                         500.3 GB   disk0

   1:             Apple_APFS_ISC ⁨⁩                        524.3 MB   disk0s1

   2:                 Apple_APFS ⁨Container disk3⁩         494.4 GB   disk0s2

   3:        Apple_APFS_Recovery ⁨⁩                        5.4 GB     disk0s3

/dev/disk3 (synthesized):

   #:                       TYPE NAME                    SIZE       IDENTIFIER

   0:      APFS Container Scheme -                      +494.4 GB   disk3

                                 Physical Store disk0s2

   1:                APFS Volume ⁨mysystem⁩                15.2 GB    disk3s1

   2:              APFS Snapshot ⁨com.apple.os.update-...⁩ 15.2 GB    disk3s1s1

   3:                APFS Volume ⁨Preboot⁩                 529.6 MB   disk3s2

   4:                APFS Volume ⁨Recovery⁩                798.6 MB   disk3s3

   5:                APFS Volume ⁨Data⁩                    455.3 GB   disk3s5

   6:                APFS Volume ⁨VM⁩                      24.6 KB    disk3s6

/dev/disk6 (external, physical):

   #:                       TYPE NAME                    SIZE       IDENTIFIER

   0:      GUID_partition_scheme                        *512.1 GB   disk6

   1:       Microsoft Basic Data ⁨⁩                        512.1 GB   disk6s1

/dev/disk7 (external, physical):

   #:                       TYPE NAME                    SIZE       IDENTIFIER

   0:      GUID_partition_scheme                        *1.0 TB     disk7

   1:       Microsoft Basic Data ⁨Extreme SSD⁩             1.0 TB     disk7s1

下面是freesar这两个命令的输出结果对应关系:

  • total : 无对应
  • free :kbememfree
  • buff/cache :kbbufferrs + kbcached
  • available:无对应

如果内存使用过多,系统为了空出内存可能出现强制 kill 某个进程的操作,此操作是随机的并且无法被监控,商用机器上执行这种操作是十分危险的,所以有部分的商用机器会开启一旦OOM直接把整个系统强制关闭的操作。

内存分配方式及问题

内核分配内存的时机大致有下面两种:

  1. 创建进程。
  2. 创建进程之后进行动态内存分配。

在进程创建之后如果进程还需要内核提供更多的内存,则可以向内核发出内存的请求申请,内核收到指令之后,则划分可用内存并且把起始结束的地址给进程进行使用。

但是这种要一点给一点的方式有下面几个常见的问题:

  • 难以执行多个任务。
  • 访问其他用途的内存区域。
  • 内存的碎片化。
注意⚠️:内存不仅仅需要和CPU通信还需要和其他的控制器和硬件打交道,分配内存给进程只是诸多任务的项目之一。

难以执行多任务

可以理解为进程频繁的需要申请内存的情况,这时候内核需要不断的操作分配内存给进程,整个任务相当于被单个进程给拖累了。

另外如果多个任务出现分配内存的区域刚好相同,此时需要要完成内存分配给那个进程任务,则另一个进程等待也是可以理解的。

内存碎片化

原因是进程每次获取内存都需要了解这部分内容要涵盖多少区域否则就不能获取这些内存。

内存碎片化的另一个重大问题是明明有很多富裕的内存但是却拿不出一块完整连续的空间给进程使用,导致不断的回收和分配操作。

访问其他用途的内存区域

这种情况进程访问被叫做缺页访问中断,在后续的内容会进行介绍。

虚拟地址和物理地址

为了解决上面的问题,操作系统使用了虚拟内容和物理内存的方式进行内存管理。

我们需要了解三个概念:地址空间、虚拟地址、物理地址

地址空间:指的是可以通过地址访问的范围都统称为地址空间。

虚拟地址:虚拟地址指的是进程无法直接访问到真实的物理内存地址,而是访问和实际内存地址映射的虚拟内存地址,目的是为了保护系统硬件安全。

物理地址:也就是我们实际内存对应的实际的物理地址。

这里举一个简单的例子:如果内核给进程分配100地址的虚拟内存地址,那么这个虚拟内存地址实上可能会指向实际的600物理地址。

页表

完成虚拟地址到物理地址的映射依靠的是页表,在虚拟内存当中所有的内存都被划分为页,一个页对应的数据条目叫做页表项,页表项记录物理地址到虚拟地址的映射关系。

在x86-64的架构中一个页的大小为4KB,进程在内存是有固定的起止地址的,那么如果出现超出地址的页访问,也就是访问了没有虚拟地址和物理地址映射的空间会出现什么情况呢?

如果出现越界访问,那么此时CPU会出现缺页中断,并且终止在缺页中进行操作的进程指令,同时启动内核的中断处理机构处理。

注意⚠️:对应访问其他用途的内存区域这个问题。

虚拟内存分配操作

虚拟内存的分配操作步骤我们可以理解为几个核心的步骤:

  • 内核寻找物理地址并且把需要的物理地址空间计算。
  • 创建进程的页表把物理地址映射到虚拟地址。
  • 如果进程需要动态内存管理,内核会分配新页表以及新的可用内存给进程使用,当然同时提供对应的物理内存空间。
物理分页使用的是请求分页的方式进行处理,这个分配的操作十分复杂。

内存的上层分配

在C语言中分配内存的函数是malloc函数,而Linux操作系统中用于分配内存的函数是mmap函数,这两者最大区别是mmap函数使用的是按页的方式分配,而malloc是按照字节的方式分配。

glibc通过系统调用mmap申请大量的内存空间作为内存池,程序则调用malloc内存池请求分配出具体的内存供进程使用,如果进程需要再次获取内存则需要再次通过mmap获取内存并且再次进行分配操作。

在上层编程语言也是使用了类似的操作,首先通过glibc向内核申请内存执行虚拟内存的分配操作,然后malloc函数再去请求划分具体的内存使用,只不过更上层的语言使用了解析器和脚本进行掩盖而已,实际上通过层层翻译最终的操作依然是上面提到的操作。

虚拟内存是如何解决简单分配的问题的?

这里我们再次把上面三个问题搬出来,再解释虚拟内存是如何处理问题的:

  • 难以执行多个任务:每个进程有独立的虚拟地址空间,所以可以编写专用地址空间程序防止多个任务阻塞等待的情况。
  • 访问其他用途的内存区域:虚拟地址空间是进程独有,页表也是进程独有。页表的另一个作用是限制可以防止当前的进程访问到其他线程的页表和地址空间。
  • 内存的碎片化:内存碎片化使用页表的方式进行分配,因为页表记录了物理地址到虚拟地址的映射,这样就可以很好的知道未使用的空间都干了啥。

虚拟内存的其他作用:

  • 文件映射
  • 请求分页
  • 利用写时复制的方式快速创建进程
  • 多级页表
  • 标准大页

小结

这一部分简要阐述Linux内存管理的入门理解部分,这一部分主要介绍了简要的内存分配方式,以及Linux对此通过页表的方式实现物理地址和虚拟地址的分配,最后阐述了操作系统和编程语言也就是进程之间是如何分配内存的,具体的分配步骤和交互逻辑介绍。

Linux内存管理优化

文件映射

经过之前的内容我们了解到文件映射通过映射虚拟内存的方式实现,进程访问内存对时候实际是文件对应的副本虚拟内存地址,既然访问虚拟内存位置可以完成文件的修改映射,那么直接访问物理内存也就是实际内存修改内容也是可行的。

如果知道文件的具体地址,甚至可以直接定位到内存地址对于内容进行覆盖,在书中有一个C语言写的验证程序比较有意思。

请求分页

进程向内核申请内存的通过请求分页的方式完成,之前提到过通过mmap的方式申请内存的方式虽然很方便但是是有问题的:

通常的内存分配方式有下面两种:

  • 物理内存的直接申请和分配,高效。
  • 句柄分配的方式,也就是页表对于虚拟内存和实际内存映射之后再给进程。

这两种分配方式都存在两个比较明显的问题,那就是分配的时候如果申请了却没有使用会大量浪费,另外一次glibc访问需要超过进程的内存,但是进程此时很可能不会使用甚至可能根本不使用,此时很可能出现很大的进程管理大量被申请未使用内存

请求分页就是用来解决上面提到的问题的。

请求分页理念

为了更好理解请求分页需要先理解分页的三种状态

  • 未分配页表和物理内存给进程。
  • 已分配页表但是未分配物理内存。
  • 已分配页表和物理内存。

为了解决分配浪费的问题,分配进程的内存仅使用一次分配方式,请求分页的核心是利用内核缺页中断的机制,当进程初次访问到已分配但是没有没有分配物理内存的空间,对于此时内核会进行缺页中断处理,同时给进程真正申请物理内存进行分配动作,这样可以保证每次分配内存的动作都是有效的。

这种方式也类似懒加载的方式,即可以保证分配动作运行,进程无感知缺页中断的情况,依然可以正常运行。

如果使用C语言按照请求分页的特点进行实验,可以发现当内存没有使用的时候即使显示已经分配内存,但是实际可用物理内存没有变动

另外分配内存失败分为虚拟内存分配失败,物理内存分配失败,这是因为“懒加载”的设计导致的,另外虚拟内存不足不一定会导致物理内存不足,因为只要可用物理在分配空间小于虚拟内存那就是没法分配并且会分配失败。

写时复制

写时复制是利用fork的函数提高虚拟内存分配效率。

在文件系统的体现是update或者delete操作不会动原数据,而是用副本完成操作,当操作完成再更新引用,如果中间宕机断电,则用日志恢复状态即可。

Linux 系统的内存管理中调用 fork 系统调用创建子进程时,并不会把父进程所有占用的内存页复制一份,而是首先与父进程共用相同的页表,而当子进程或者父进程对内存页进行修改时才会进行复制 —— 这就是著名的 写时复制 机制。

写时复制的流程如下:

  • 在父进程调用fork的时候,并不是把所有内存复制给子进程而是递交自己的页表给子进程,如果子父进程只进行只读操作双方都会共享页表,但是一旦一方要改变数据,就会立马解除共享
  • 在解除共享的时候会有如下操作

    • 由于写入失去权限,所以会出发缺页中断,此时会切断用户态,保存当前进程状态并且进行内核态。
    • 切换至内核态,内核干预,执行缺页中断。
    • 写入方的数据复制到另一处,并且把写入方的页表全部更新为新复制的内存并且赋予写入方写入权限,同时把之前共享的页表更新,并且把另一方刷新之后的页表重新连接即可。(关键)
    • 最后父子进程彻底写入权限和页表独立。但是之前解除共享的页表依然可以自由读写。
注意⚠️:注意解除共享并不是所有的共享都解除,而是解除共享需要独立的部分,这种处理是。

另外还需要强调进行fork调用的时候并不会复制页表和内容,而是真正写入的时候会触发复制动作,这也是写时复制名字由来。

写时复制中如果只进行只读操作双方都会共享页表,但是一旦一方要改变数据,就会立马解除共享。

交换内存

交换内存是Linux内核一种OOM情况下的补偿机制,作用也是为了缓解内存溢出和不足的问题,交换内存的实现依靠的是虚拟内存的机制

简单理解就是在物理内存虽然不够但是虚拟内存可以借用外部存储器也就是硬盘的一部分空间来充当物理内存使用,这一块分区叫做交换区,由于是借物理存储空间,这个操作也叫做换出。

另外如果借用的空间被释放则会立即归还,这种操作叫做换入,由于交换内存以页为单位,部分资料也叫页面调入和调出都是一个意思。

交换内存很容易认为是扩充物理内存的美好方式,但是这里有一个本质的问题,那就是硬盘的访问速度和内存相比差的次方级别的差距,另外如果长期内存不足很容易导致交换内存不断的换入换出出现明显的性能抖动。

硬性页缺失和软性页缺失

另外这类需要外部存储器的缺页中断在术语中被称之为硬性页缺失,相对的不需要外部存储器的页缺失是软性页缺失,虽然本质都是内核在触发和完整操作,但是硬性的缺失总归比软性缺失后果严重很多。

这里也要吐槽一下M1的各种偷硬盘缓存来提高性能的操作.....这也是为什么要把硬盘和CPU集成的原因之一。

多级页表

多级页表的设计核心是:避免把全部页表一直保存在内存中是多级页表的关键所在,特别是那些不需要的页表就不应该保留。

在X86-64架构当中,虚拟地址的空间大小约为128T,一个页的大小为4KB,页表的项目大小为8个字节。

所以一个进程的页表至少需要256GB内存!(8 * 128T / 4KB),但是我们都知道现在的电脑一般都是16GB内存为主。

那么系统应该如何维护页表?这就引入了多层页表来进行管理,多级页表可以从最简单的角度当作一个多级的指针看待。

首先我们可以思考,一个进程是否需要整个页表来管理内存?很显然是不需要的,这是引入多级页表的理由之一。

假设一个进程需要12M空间,顶端需要4M,数据部分占用4M,底部又是一些堆栈内容和记录信息,在数据顶端上方和堆栈之间是大量根本没有使用的空闲区。

多级页表实际上就是大目录套小目录,和我们的一本书一样,小目录负责小的进程,而遇到比较大的进程就放到空闲页比较大的目录中完成分配操作,多级页表既可以高效的利用内存的同时,可以最大限度的减少页表本身的数据结构在内存的占用,同时上面的例子也可以发现绝大多数的进程其实根本不需要太大的页表进行维护和管理。

最后从网上的资料翻阅中发现一张下面的图,对于多级页表的理解有一定帮助:

最后X86_64 使用了4层的页表结构,直当理解就是四级指针。

标准大页

随着进程虚拟内存和页表的使用,进程使用的物理内存也会增加。

根据请求分页和写时复制的概念,调用fork函数的时候会对于父子进程共享的页表进行拷贝,虽然这个拷贝动作不会占用物理内存,但是需要拷贝一份完整的页表,当页表很大的时候也会造成性能浪费。

为了解决这个问题,Linux提供了标准大页的机制,和他名字一样就是比普通的页表更大的页,利用这种页表可以减小进程页表所需的内存使用量,通过多级页表和标准大表可以有效的减少整个页表项的数量。

用法

C语言中使用mmap函数参数赋予 MAP_HUGETLB 标志,表示可以获取大页,但是更加常用的方式是使用程序允许使用使用标准大页而不是这种手动切换的方式。

标准大页对于虚拟机和数据库等需要使用大量内存的应用程序是很有必要的,根据实际情况决定是否使用标准大页,通过这种设置可以减少这一类软件内存占用,还能提高fork效率。

透明大页

透明大页是随着标准大页带来的附带特性,主要的作用是在连续的4KB页面如果符合指定条件就可以通过透明大页的机制转为一个标准大页,以及在不满足标准大页条件的时候拆分为多个4KB页面。

小结

这部分从文件映射的内容引申了Linux两个重要的机制:请求分页写时复制,目的本质上都是尽量减少进程对于内存的浪费,但是需要注意的是这两种方式都是使用了内核模式的系统中断机制来进行处理的,所以对于内核的性能以及稳定性要求非常高。

在之后的内容介绍了交换内存以及多级页表和标准大页几个内容,其中多级页表内部的细节非常的复杂,通常需要对于操作系统底层有比较熟悉的认知才能完全的了解这个页表的细节。

进程调度器

针对Linux进程调度有下面的思考:

  • 每一个CPU同一时间只能调度一个进程。
  • 每一个进程有*近乎相等的执行时间。
  • 对于逻辑CPU而言进程调度使用轮询的方式执行,当轮询完成则回到第一个进程反复。
  • 进程数量消耗时间和进程量成正比。

对于进程调度来说不能保证一个程序是连续完成的,由于CPU调度和进程切换,上下文也会出现切换情况。

进程状态

对于大部分进程来说,当我们不使用的时候多数处于睡眠状态。

除了睡眠状态之外,进程还有下面几种状态:

  • 运行状态:获得CPU调度,执行进程任务。
  • 僵死状态:进程等待结束,等待父进程回收。
  • 就绪状态:具备运行条件,等待CPU分配。
  • 睡眠状态:进程不准备运行除非某种条件触发才会获得CPU调度分配。

处在睡眠态的进程触发运行态的条件如下:

  • 外部存储器访问。
  • 用户键入或者鼠标操作触发事件。
  • 等待指定时间。
  • 等待指定时间。

通过ps ax命令可以查看当前的进程状态,下面的案例以个人的Mac电脑为例:

MacBook-Pro ~ % ps ax

  PID   TT  STAT      TIME COMMAND
32615   ??  Ss     0:00.11 /usr/libexec/nearbyd

32632   ??  Ss     0:00.51 /System/Library/CoreServices/Screen Time.app/Content

32634   ??  Ss     0:00.02 /System/Library/PrivateFrameworks/Categories.framewo

32635   ??  S      0:00.12 /System/Library/CoreServices/iconservicesagent

32636   ??  Ss     0:00.05 /System/Library/CoreServices/iconservicesd

32671   ??  S      0:02.44 /Applications/Microsoft Edge.app/Contents/Frameworks

32673   ??  S      0:02.86 /Applications/Microsoft Edge.app/Contents/Frameworks

32678   ??  Ss     0:00.17 /System/Library/PrivateFrameworks/UIFoundation.frame

32726   ??  S      0:00.07 /System/Library/Frameworks/CoreServices.framework/Fr

32736   ??  S      0:00.08 /System/Library/Frameworks/CoreServices.framework/Fr

32738   ??  S      0:00.75 /System/Applications/Utilities/Terminal.app/Contents

32739   ??  Ss     0:00.02 /System/Library/PrivateFrameworks/Categories.framewo

32746   ??  Ss     0:00.03 /System/Library/Frameworks/Metal.framework/Versions/

32740 s000  Ss     0:00.02 login -pf xxxxxx

32741 s000  S      0:00.03 -zsh

32750 s000  R+     0:00.01 ps ax

s表示sleepd表示此时可能在等待磁盘IO,但是如果长时间处于d状态则可能是磁盘IO等待超时或者内核可能发生故障。

下面是根据上面的介绍绘制的进程状态流转图

如果只执行一个进程同时在进程中间休眠过一次,那么此时休眠的进程在干什么?

进程会进入空进程的模式轮询,但是空进程不是没有事做,而是需要调用一些维持系统运行的线程,为了保证系统正常稳定运行。

因为CPU和空闲进程,所以同样会不断的切换睡眠态和运动态,运动态获取用户输入操作完成动作,睡眠态则执行一些轻量操作。

针对睡眠态的进程会有如下特点:

  • 遵循同一时间CPU只能完成一个进程操作
  • 睡眠态不占用CPU时间。

吞吐量和延迟

  • 吞吐量:处理完成的进程数量 / 耗费时间
  • 延迟:结束处理时间 - 开始处理时间

通过这两点可以总结几点规则:

吞吐量的上限是进程的数量多过逻辑CPU的数量,则再增加进程无法增加吞吐量,另外进程中的延迟总是平均的,也就是说多个进程执行会获得近似平均的延迟,最后进程越多延迟越高。

但是现实系统没有那么多理想情况,多数情况是下面几种:

  • 空闲进程:此时吞吐量很低,因为很多逻辑CPU都在睡眠状态。
  • 进程运行态:此时没有就绪:这种状态比较理想,CPU可以安排到下一次处理,虽然会延后开始执行提高吞吐量,但是可能会出现CPU空闲的情况。
  • 进程运行态,同时就绪:此时就好像赛跑,但是只有一个跑道,每一个进程轮流处理一会儿,所以此时延迟变长。

另外由于很多程序编写都是单线程程序,一核运行,多核围观或许在过去更普遍。

优化吞吐量和延迟的方式是使用 sar 命令找到运行时间和开销最大进程,同时把一些死进程kill掉。

多CPU调度情况

分片时间每一个进程用一个CPU工作,那么分配和调度CPU安排工作又是如何的?

主要有两种方式,第一种是通过轮询负载均衡,另一种是全局分配,把任务分配给空闲进程的逻辑CPU。

负载均衡是CPU遇到进程任务依次安排工作,当最后一个CPU安排完成之后,则再回到第一个CPU进行分配,同时都是对于进程执行一定的时间,也就是说出现CPU-A处理一部分,另一部分可能是CPU-B完成。

全局分配的方式比较简单,就是把任务分配给处于空闲进程的逻辑CPU完成工作。

查看系统逻辑CPU的命令如下:

grep -c processor /proc/cpuinfo

多核cpu通常只有在同时运行多个进程的时候才会发挥作用,但是并不是说有多少核心就有多少倍性能,因为大部分时候进程很少很多CPU都在睡眠态度

如果进程超过逻辑CPU数量,无论怎么增加进程都不会提高处理速度

最后处于睡眠状态的进程其实可以指定睡眠时间,通过sleep函数调用完成进程休眠的操作。

小结

进程调度器的内容远没有上面介绍的简单,但是作为理解进程的切入点是不错的。

计算机程序概览

放到最后是因为个人认为算是这本书相对没有什么价值的部分。

概览

狭义上的计算机结构是:CPU,内存,外部设备,其中外部设备包含输入输出和外部外存储器以及网络适配器。

而从广义上来看,计算机可以用抽象化的概念进行解释,则可以简单讲计算机分为三部分:

  • 第一部分是应用程序,这些程序依托于环境和载体。
  • 第二部分就是运行程序的载体,负责管理系统调用,进程切换和设备驱动这些工作,同时担任重要的硬件抽象的角色,为应用程序掩盖掉复杂的底层维护工作。
  • 最后一部分是硬件,这部分就是我们狭义的理解计算机的部分,这里不多介绍。

在硬件设备重复执行以下步骤:

  • 输入设备或者网络适配器发起请求
  • 读取内存命令,CPU上执行,把结果写入负责保存到内存区域
  • 内存的数据写入HDD、SDD等存储器

而程序大致则分为下面几种:

  • 应用程序:让用户直接使用,为用户提供帮助程序,例如计算机等办公软件,计算机上的办公软件,智能手机和其他应用。
  • 中间件:辅助程序运行等软件,比如WEB服务器和数据库
  • OS:控制硬件,为应用程序和中间件提供运行环境,Linux 叫做OS。

用户模式和内核模式

下面的内容可以认为是把之前的内容回顾一遍:

用户模式

用户进程访问的时候都是用户模式,用户模式是受到保护或者说权限受限的,只能够使用内核分配的内存和CPU进行操作,失去使用权则会处于等待的情况。

用户进程的一大特点是用户的空间只能用户进程使用,所以一旦有用户进程崩溃了,内核可以去把它给清理掉。因此增强系统的鲁棒性。

内核模式

此模式下运行的代码对 CPU 和内存具有无限的使用权限,这个强大的权限使得内核可以轻易腐化和崩溃整个系统,所以内核使用的空间是只能被内核访问的,其他任何用户都无权访问。

特点对比

内核级线程特点用户级线程的特点
1.进程中的一个线程被阻塞,内核能调度同一进程的其他线程(就绪态)占有处理器运行。<br/>2.多处理器环境中,内核能同时调度同一进程的多线程,将这些线程映射到不同的处理器核心上,提高进程的执行效率。<br/>3.应用程序线程在用户态运行,线程调度和管理在内核实现。线程调度时,控制权从一个线程改变到另一线程,需要模式切换,系统开销较大<br/>1.线程切换不需要内核模式,能节省模式切换开销和内核资源。<br/>2.允许进程按照特定的需要选择不同的调度算法来调度线程。调度算法需要自己实现。<br/>3.由于其不需要内核进行支持,所以可以跨OS运行。<br/>4.不能利用多核处理器优点,OS调度进程,每个进程仅有一个ULT能执行<br/>5.一个ULT阻塞,将导致整个进程的阻塞。<br/>

对于用户线程阻塞在后续技术的发展出现了一种叫做Jacketing技术

使用Jacketing技术把阻塞式系统调用改造成非阻塞式的。当线程陷入系统调用时,执行jacketing程序,由jacketing程序来检查资源使用情况,以决定是否执行进程切换或传递控制权给另一个线程。

也就是说Jacketing技术实现了当用户线程阻塞的时候更加灵活的进行切换,防止出现一个线程的阻塞导致多线程阻塞这种情况。

用户模式切换到内核模式

一般是发生了中断或者无法处理的系统异常情况下出现。

此时内核会剥夺用户进程的控制权进行处理, 并且执行一些内核的修复操作,比如缺页中断申请内存并且分配新的页表给进程,这种机制为请求分页的处理方式。

注意⚠️:更多细节可以回顾[进程调度器]查看有关进程调度的内容。

用户模式切换到内核模式一般会经历下面的步骤:

  • CPU模式切换
  • 保存当前的进程状态到核心栈。
  • 中断异常程序调度处理

内核模式切换到用户模式

当中断异常处理调度程序完成之后,内核模式会逐渐转为用户模式运行,此时用户线程回从核心栈找回当前到进程状态,并且CPU运行模式也会执行为用户模式。

模式切换的优劣对比

其实这两个模式用最抽象的概念就是应用程序和系统程序之间的差别,因为对于用户来说模式切换是透明的,基本使用过电脑用过电脑程序,基本都经历过应用程序崩溃引发系统崩溃的情况,此时就是一种用户模式到内核模式的切换。

用户模式的好处在于访问和空间占用都是受到内核管控的,但是有一个很大的问题是一旦出现中断异常就会发生用户模式和内核模式的切换,在通常情况下看起来没什么问题,但是随着进程需要的内存越来越大,每一次切换对于系统带来影响和开销都是非常严重的。

在很多大型进程中,因为模式切换带来的问题也并不罕见,更多情况下是用户编写的低质量或者问题代码出现资源浪费导致的问题。

简单对比Windows

下面我们类比Windows系统的内核模式以及用户模式的切换,这里主要看看微软的官方文档是如何介绍的:

用户模式:进程享受专用的虚拟地址空间,和Linux类似的 在用户模式下运行的处理器无法访问为操作系统保留的虚拟地址。

内核模式:在内核模式下运行的所有代码都共享单个虚拟地址空间

其实简单对比一下就会发现实现机制都是类似的,只不过内部实现代码不同和细节不同而已,基本的特性都是相似的。

C标准库

C标准库在很早就被国际设立为标准规范,哪怕过去这么多年依然通用。

而C标准库中较为核心的组件是glibc,之前介绍过glibc是用户进程像内核申请内存的关键实现函数,使用glibc申请内存再使用mmap函数申请具体的内存,这部分内容可以阅读[内存管理]进行了解。

glibc通过系统调用向mmap申请大量的内存空间作为内存池,程序则调用malloc根据内存池分配出具体的内存使用,如果进程需要再次获取内存则需要再次通过mmap获取内存并且再次进行分配操作。

另外高级的编程语言同样依赖或者基于C标准库,比如Python的ppidloop就是如此,除此之外还有C++对于C标准库的进一步扩展等。

OS提供的程序

下面列举一些OS提供的程序:

  • 初始化系统:init
  • 变更系统运作方式:sysctl、nice、sync
  • 文件操作:touch、mkdir
  • 文本数据处理:grep、sort、uniq
  • 性能测试:sar、iostat
  • 编译:gcc
  • 脚本语言运行环境:perl、python、ruby
  • shell:bash
  • 视窗系统:x

另外系统调用通常也可以分为下面几种:

  • 进程控制]]
  • 内存管理
  • 进程通信
  • 网络管理(本书未涉及)
  • 文件系统操作
  • 文件操作

写在最后

还是挺不错的一本书,虽然没有那些板砖书那么系统,但是对于初学者来说是个不错的切入书,这本书买实体书不是很划算,因为配图讲解工作原理的内容比较多,甚至让我觉得作者应该多一点文字描述......

附录

附录部分

LBA(Logical Block Addressing)逻辑块寻址模式

HDD常见寻址方式

CHS寻址
CHS寻址也被称为NORMAL 普通模式,此寻址模式是最早的 IDE 方式。

在硬盘访问时,BIOS 和 IDE 控制器对参数不做任何转换。该模式支持的最大柱面数为 1024,最大磁头数为 16,最大扇区数为 63,每扇区字节数为 512,因此支持最大硬盘的容量为:

512x63x16x1024=528MB

⚠️注意:最早期的计算机仅仅只需要500多MB就够用,和现在确实是天差地别。

LBA寻址

LBA的寻址特点是地址不再和物理磁盘的位置一一对应,前面CHS寻址使用了三个关键参数:磁头位置,存储柱面位置,扇区位置三个参数利用三维的参数来计算容量,而LBA寻址则使用了一个参数进行寻址,同时由IDE控制计算柱面、磁头、扇区的参数等组成的逻辑地址转为物理地址。
LBA可以设置最大的磁头数为255,而其他参数和CHS寻址的模式类似,所以我们对应上面的结算公式只需要把16改为255,最终结果如下:

512x63x255x1024=8.4G。

另外在早期的LBA寻址中主板上大多数是28位的LBA寻址,而前面讨论了LBA的三维参数和物理地址不是一一对应的,而是通过IDE计算逻辑地址寻找最终的物理地址。

根据计算机的位操作我们可以计算出逻辑块为2的28次方= 137,438,953,472个字节也就是137G,意味着最早期的磁盘寻址极限为137G,当然这也是因为当时的主板技术跟不上硬盘技术导致的,并且当时的计算机使用人员用不到137G的容量。

当然要突破这个限制也非常简单,把28位的寻址提高就可以直接支持更大容量的硬盘,经过发展,目前使用的都是48位LBA寻址方式

而48位LBA寻址方式的理论容量极限是144,115,188,075,855,872字节=144,000,000 GB!上亿的磁盘容量基本够目前使用。

由于LARGE、LBA寻址模式采用了逻辑变换算法看上去比CHS复杂不少,但是不少的资料、磁盘工具类软件中采用的硬盘参数介绍和计算方法却还是按照相对而言比较简单的CHS寻址模式

而LBA寻址模式说白了也是在CHS寻址模式上的改进,也需要向前兼容,因此CHS寻址模式是硬盘寻址模式的基础,理解CHS寻址模式HDD硬盘使用和维护还是很有用的。

LARGE寻址

LARGE 大硬盘模式,在硬盘的柱面超过 1024 而又不为 LBA 支持时采用。LARGE 模式采用的方法非常简单粗暴,就是把直接把柱面数除以 2,把磁头数乘以 2,其结果总容量不变方式寻址。

⚠️注意:LBA寻址到现在依旧是主流。

容量和大小对比

  • CHS(或称为Normal)模式: 适应容量≤504MB的硬盘。
  • LARGE(或称LRG)模式: 适应504MB≤容量≤8.4GB的硬盘 。
  • LBA(Logical Block Addressing)模式: 适应容量≥504MB的硬盘,但BIOS需支持扩展INT13H,否则也只能适应≤8.4GB的硬盘

小结

LARGE寻址模式把柱面数除以整数倍、磁头数乘以整数倍而得到的逻辑磁头/柱面/扇区参数进行寻址,所以表示的已不是硬盘中的物理位置,而是逻辑位置,但是这种计算方式显然比较粗糙使用比较少

LBA寻址模式是直接以扇区为单位进行寻址的,不再用磁头/柱面/扇区三种单位来进行寻址。

但为了保持与之前的CHS模式的兼容,通过逻辑变换算法,可以转换为磁头/柱面/扇区三种参数来表示,但表示的也和LARGE寻址模式一样已不是硬盘中的物理位置而是逻辑位置了。

参考资料

  1. 磁盘I/O那些事
  2. 为什么硬盘转速是5400或者7200这么奇怪的数字?7200转的硬盘一定比5400快吗?
  3. ## 求硬盘的3种寻址模式 NORMAL LBA LARGE 详细介绍
  4. # 硬盘基本知识(磁头、磁道、扇区、柱面)
  5. linux内存管理---虚拟地址、逻辑地址、线性地址、物理地址的区别
  6. linux - glibc 中的 mmap 实现 - 带有符号 mmap 的动态库 - 堆栈溢出 (stackoverflow.com)

Xander
198 声望51 粉丝