串讲
第一章主要讲了操作系统是怎么启动的,以及建立操作系统的基本组件,以及操作系统的进程。
相当于怎么把公司开起来,以及确定公司的基本组织架构,确定公司接项目的流程。
工作人员接着说:“咱们这个园区毗邻全国知名高校,每年都有大量的优秀毕业生来园区找工作,这是企业非常重要的人才来源。葛优说了,二十一世纪了,人才是核心嘛。每年我们园区都会招聘大量的毕业生,先进行一个月的培训,合格毕业的可以推荐给您这种企业。这些人才啊,就是咱们企业的 CPU。”
“经过我们园区培训过的‘CPU 人才’,具备了三种老板们喜欢的核心竞争力:
第一,实干能力强,干活快,我们称为运算才能——也即指令执行能力;
第二,记忆力好,记得又快又准,我们称为数据才能——也即数据寄存能力;
第三,听话,自控能力强,可以多任务并发执行,我们称为控制才能——也即指令寄存能力。
到时候,你可以根据需求,看雇用多少个‘CPU 人才’。
另外,人才得有个办公的地方,这一片呢,就是我们的办公区域,称为也就是内存区域。您可以包几个工位,或者包一片区域,或者几个会议室,让您公司的人才在里面做项目就可以了。这里面有的是地方,同时运行多少个项目都行。”
跟着工作人员的介绍,小马走在 x86 园区中,看着这一片片的内存办公区,脑子里已经浮现出将来热火朝天的办公场景了。
“也许不到半年的时间,我肯定能够接两三个大项目,招聘十个八个 CPU 员工。那项目 A 的员工就坐在这片内存办公区,项目 B 的员工就坐在那片内存办公区。我根据积累的人脉,将接到的项目写成一个一个的项目执行计划书,里面是一行行项目执行的指令,这些指令操作数据产生一些结果,我们就可以叫程序啦。”小马这么想着。
“然后呢,我把不同的项目执行计划书,交给不同的项目组去执行。那项目组就叫进程吧!两个项目组,进程 A 和 B,会有独立的内存办公空间,互相隔离,程序会分别加载到进程 A 和进程 B 的内存办公空间里面,形成各自的代码段。要操作的数据和产生的结果,就放在数据段里面。“
“除此之外,我应该找一个或者多个 CPU 员工来运行项目执行计划书,我只要告诉他下一条指令在内存办公区中的地址,经过训练的 CPU 员工就会很自觉地、不停地将代码段的指令拿进来进行处理。“
“指令一般是分两部分,一部分表示做什么操作,例如是加法还是位移;另一部分是操作哪些数据。数据的部分,CPU 员工会从数据段里面读取出来,记在脑子里,然后进行处理,处理完毕的结果,再写回数据段。当项目执行计划书里面的所有指令都执行完毕之后,项目也就完成了,那就可以等着收钱啦。”
小马沉浸在思绪中久久不能自拔,直到工作人员打断了他的思绪:“您觉得园区如何?要不要入住呀?先租几个工位,招聘几个人呢?“
启动公司有手册,获取内核当宝典
工作人员说:“感谢您入驻咱们创业园区,由于您是初次创业,这里有一本《创业指导手册》,在这一本叫作 BIOS 的小册子上,有您启动一家公司的通用流程,你只要按照里面做就可以了。”
小马接过 BIOS 小册子,开始按照里面的指令启动公司了。
创业初期,小马的办公室肯定很小,只有有 1M 的内存办公空间。在 1M 空间最上面的 0xF0000 到 0xFFFFF 这 64K 映射给 ROM,通过读这部分地址,可以访问这个 BIOS 小册子里面的指令。
创业指导手册第一条,BIOS 要检查一些系统的硬件是不是都好着呢。创业指导手册第二条,要有个办事大厅,只不过小马自己就是办事员。因为一旦开张营业,就会有人来找到这家公司,因而基本的中断向量表和中断服务程序还是需要的,至少要能够使用键盘和鼠标。
BIOS 这个手册空间有限,只能帮小马把公司建立起来,公司如何运转和经营,就需要另外一个东西——《企业经营宝典》,因而 BIOS 还要做的一件事情,就是帮助小马找到这个宝典,然后让小马以后根据这个宝典里面的方法来经营公司,这个《企业经营宝典》就是这家公司的内核。
运营一个企业非常的复杂,因而这本《企业经营宝典》也很厚,BIOS 手册无法直接加载出来,而需要从门卫开始问起,不断打听这本内核的位置,然后才能加载他。
门卫只有巴掌大的一块地方,在启动盘的第一个扇区,512K 的大小,我们通常称为 MBR(Master Boot Record,主引导记录/扇区)。这里保存了 boot.img,BIOS 手册会将他加载到内存中的 0x7c00 来运行。
boot.img 做不了太多的事情。他能做的最重要的一个事情,就是加载 grub2 的另一个镜像 core.img。
引导扇区就是小马找到的门卫,虽然他看着档案库的大门,但是知道的事情很少。他不知道宝典在哪里,但是,他知道应该问谁。门卫说,档案库入口处有个管理处,然后把小马领到门口。
core.img 就是管理处,他们知道的和能做的事情就多了一些。core.img 由 lzma_decompress.img、diskboot.img、kernel.img 和一系列的模块组成,功能比较丰富,能做很多事情。
boot.img 将控制权交给 diskboot.img 后,diskboot.img 的任务就是将 core.img 的其他部分加载进来,先是解压缩程序 lzma_decompress.img,再往下是 kernel.img,最后是各个模块 module 对应的映像。
管理处听说小马要找宝典,知道他将来是要做老板的人。管理处就告诉小马,既然是老板,早晚都要雇人干活的。这不是个体户小打小闹,所以,你需要切换到老板角色,进入保护模式,把哪些是你的权限,哪些是你可以授权给别人的,都分得清清楚楚。
这些,小马都铭记在心,此时此刻,虽然公司还是只有他一个人,但是小马的眼界放宽了,能够管理的内存空间大多了,也开始区分哪些是用户态,哪些是内核态了。
接下来,kernel.img 里面的 grub_main 会给小马展示一个《企业经营宝典》的列表,也即操作系统的列表,让小马进行选择。经营企业的方式也有很多种,到底是人性化的,还是强纪律的,这个时候你要做一个选择。
在这里,小马毫不犹豫地选择了《狼性文化》操作系统,至此 grub 才开始启动《狼性文化》操作系统内核。
拿到了宝典的小马,开始越来越像一个老板了。他要开始以老板的思维,来建立这家公司。
公司首先应该有个项目管理部门,咱们将来肯定要接各种各样的项目,因此,项目管理体系和项目管理流程首先要建立起来。虽然现在还没有项目,但是小马还是弄了一个项目模板 init_task。这是公司的第一个项目(进程),是项目管理系统里面的项目列表中的第一个,我们能称为 0 号进程。这个项目是虚拟的,不对应一个真实的项目(也就是进程)。
项目需要项目管理进行调度,还需要制定一些调度策略。
另外,为了快速响应客户需求,为了各个项目组能够方便地使用公司的公共资源,还应该有一个办事大厅。这里面可以设置了很多中断门(Interrupt Gate),用于处理各种中断,以便快速响应突发事件;还可以提供系统调用,为项目组服务。
如果项目接得多了,为了提高研发效率,对项目内容进行保密,就需要封闭开发,所以将来会有很多的会议室,因而还需要一个会议室管理系统。
项目的执行肯定会留下很多文档,这些是公司的积累,将来的核心竞争力,一定要好好管理,因而应该建立一个项目档案库,也即文件系统。
...
第二天,小马请周瑜和张昭吃饭,邀请他们加入他的创业公司。小马说,公司要正规运转起来,应该分清内外,外部项目需要有人帮忙管理好——也就是用户态,内部公司的核心资源也需要管理好——也就是内核态。现在我一个人忙不过来,需要两位兄弟的加入,周瑜主外,张昭主内,正所谓,内事不决问张昭,外事不决问周郎嘛。
第三天,周瑜早早就来到公司,开始了他的事业。小马拜托周瑜做的第一件事情是调用 kernel_init 运行 1 号项目(进程)。这个 1 号项目会在用户态运行 init 项目(进程)。这是第一个以外部项目的名义运行的,之所以叫 init,就是做初始化的工作,周瑜根据自己多年的项目管理经验,将这个 init 项目立为标杆,以后所有外部项目的运行都要按照他来,是外部项目的祖先项目。
下午,张昭也来到了公司,小马拜托张昭做的第一件事情是调用 kthreadd 运行 2 号项目(进程)。这个 2 号项目是内核项目的祖先。将来所有的项目都有父项目、祖先项目,会形成一棵项目树。公司大了之后,周瑜和张昭做的公司 VP 级别的任务,就可以坐在塔尖上了。
截止这里,还是我们之前的比方,操作系统就是公司,内核就是老板,BIOS(或者 UEFI)就是政府(硬件)开公司的流程(BIOS/UEFI 固件直接嵌入计算机主板),CPU 就是员工,文件系统就是公司的档案库,,0 号进程就是项目模板,进程树就是项目树(比如公司项目内部嵌套的各种项目)
上一节说到小马同学的公司已经创立了,还请来了周瑜和张昭作为帮手,所谓“兄弟齐心,其利断金”。可是,现在这家公司,还得从接第一个外部项目开始。
这第一个项目,还是小马亲自去谈的。其实软件公司了解客户需求还是比较难的,因为客户都说着接近人类的语言,例如 C/C++。这些咱们公司招聘的 CPU 小伙伴们可听不懂,需要有一个人将客户需求,转换为项目执行计划书,CPU 小伙伴们才能执行,这个过程我们称为编译。
编译其实是一个需求分析和需求转换的过程。这个过程会将接近人类的 C/C++ 语言,转换为 CPU 小伙伴能够听懂的二进制语言,并且以一定的文档格式,写成项目执行计划书。这种文档格式是作为一个标准化的公司事先制定好的一种格式,是周瑜从大公司里面借鉴来的,称为 ELF 格式,这个项目执行计划书有总论 ELF Header 的部分,有包含指令的代码段的部分,有包含全局变量的数据段的部分。
小马和客户聊了整整一天,确认了项目的每一个细节,保证编译能够通过,才写成项目执行计划书 ELF 文件,放到档案库中。此时已经半夜了。
第二天,周瑜一到公司,小马就兴奋地给周瑜说,“我昨天接到了第一个项目,而且是一个大项目,项目执行计划书我都写好了,你帮我监督、执行、管理,记得按时交付哦!”
周瑜说,“没问题。”于是,周瑜从父项目开始,fork 一个子项目,然后在子项目中,调用 exec 系统调用,然后到了内核里面,通过 load_elf_binary 将项目执行计划书加载到子进程内存中,交给一个 CPU 执行。
虽然这是第一个项目,以周瑜的项目管理经验,他告诉小马,项目的执行要保质保量,需要有一套项目管理系统来管理项目的状态,而不能靠脑子记。“项目管理系统?当然应该有了”,小马说。他在《企业经营宝典》中看到过。
于是,项目管理系统就搭建起来了。在这里面,所有项目都放在一个 task_struct 列表中,对于每一个项目,都非常详细地登记了项目方方面面的信息。
我们前面说了,CPU 在公司里就像是多手工业机器人,只能看懂 ELF,需要有人把客户需求转化成 ELF,这就是编译。
每一个项目都应该有一个 ID,作为这个项目的唯一标识。到时候排期啊、下发任务啊等等,都按 ID 来,就不会产生歧义。
项目应该有运行中的状态,TASK_RUNNING 并不是说进程正在运行,而是表示进程在时刻准备运行的状态。这个时候,要看 CPU 小伙伴有没有空,有空就运行他,没空就得等着。
有时候,进程运行到一半,需要等待某个条件才能运行下去,这个时候只能睡眠。睡眠状态有两种。一种是 TASK_INTERRUPTIBLE,可中断的睡眠状态。这是一种浅睡眠的状态,也就是说,虽然在睡眠,等条件成熟,进程可以被唤醒。
另一种睡眠是 TASK_UNINTERRUPTIBLE,不可中断的睡眠状态。这是一种深度睡眠状态,不可被唤醒,只能死等条件满足。有了一种新的进程睡眠状态,TASK_KILLABLE,可以终止的新睡眠状态。进程处于这种状态中,他的运行原理类似 TASK_UNINTERRUPTIBLE,只不过可以响应致命信号,也即虽然在深度睡眠,但是可以被干掉。
一旦一个进程要结束,先进入的是 EXIT_ZOMBIE 状态,但是这个时候他的父进程还没有使用 wait() 等系统调用来获知他的终止信息,此时进程就成了僵尸进程。
EXIT_DEAD 是进程的最终状态。
这里说的是进程状态了,RWSZ 没啥好说的,其中需要注意的还是 waiting 状态
进程的几种 waiting 状态,分别什么时候会切入 TASK_INTERRUPTIBLE 浅睡眠、TASK_UNINTERRUPTIBLE 深睡眠、TASK_KILLABLE 可杀死睡眠?
---
谜底就在谜面上
另外,项目运行的统计信息也非常重要。例如,有的员工很长时间都在做一个任务,这个时候你就需要特别关注一下;再如,有的员工的琐碎任务太多,这会大大影响他的工作效率。
那如何才能知道这些员工的工作情况呢?在进程的运行过程中,会有一些统计量,例如进程在用户态和内核态消耗的时间、上下文切换的次数等等。
项目之间的亲缘关系也需要维护,任何一个进程都有父进程。所以,整个进程其实就是一棵进程树。而拥有同一父进程的所有进程都具有兄弟关系。
另外,对于项目来讲,项目组权限的控制也很重要。什么是项目组权限控制呢?这么说吧,我这个项目组能否访问某个文件,能否访问其他的项目组,以及我这个项目组能否被其他项目组访问等等
另外,项目运行过程中占用的公司的资源,例如会议室(内存)、档案库(文件系统)也需要在项目管理系统里面登记。
周瑜同学将项目登记好,然后就分配给 CPU 同学们说,开始执行吧。
好在第一个项目还是比较简单的,一个 CPU 同学按照项目执行计划书按部就班一条条地执行,很快就完成了,客户评价还不错,很快收到了回款。
项目大了要并行,项目多了要排期
小马很开心,可谓开门红。接着,第二个项目就到来了,这可是一个大项目,要帮一家知名公司开发一个交易网站,共 200 个页面,这下要赚翻了,就是时间要得比较急,要求两个星期搞定。
小马把项目带回来,周瑜同学说,这个项目有点大,估计一个 CPU 同学干不过来了,估计要多个 CPU 同学一起协作了。
为了完成这个大的项目(进程),就不能一个人从头干到尾了,这样肯定赶不上工期。于是,周瑜将一个大项目拆分成 20 个子项目,每个子项目完成 10 个页面,一个大项目组也分成 20 个小组,并行开发,都开发完了,再做一次整合,这肯定比依次开发 200 个页面快多了。如果项目叫进程,那子项目就叫线程。
在 Linux 里面,无论是进程,还是线程,到了内核里面,我们统一都叫任务,由一个统一的结构 task_struct 进行管理。
---
不知道是好消息,还是坏消息,这么大一个项目还没有做完,新的项目又找上门了。看来有了前面的标杆客户,名声算是打出去了,一个项目接一个地不停。
小马是既高兴,又犯愁,于是找周瑜和张昭商量应该咋办。要不多招人?多来几个 CPU 小伙伴,就不搞定了?可是咱们还是在创业阶段,养不起这么多人。另外的办法就是,人力复用,一个 CPU 小伙伴干多个项目,干不过来,就加加班,实在不行就 996,这样应该就没问题了。
一旦涉及一个 CPU 小伙伴同时参与多个项目,就非常考验项目管理的水平了。如何排期、如何调度,是一个大学问。例如,有的项目比较紧急,应该先进行排期;有的项目可以缓缓,但是也不能让客户等太久。所以这个过程非常复杂,需要平衡。
对于操作系统来讲,他面对的 CPU 的数量是有限的,干活儿都是他们,但是进程数目远远超过 CPU 的数目,因而就需要进行进程的调度,有效地分配 CPU 的时间,既要保证进程的最快响应,也要保证进程之间的公平。
如何调度呢?周瑜能够想到的方式就是排队。每一个 CPU 小伙伴旁边都有一个白板,上面写着自己需要完成的任务,来了新任务就写到白板上,做完了就擦掉。
一个 CPU 上有一个队列,队列里面是一系列 sched_entity,每个 sched_entity 都属于一个 task_struct,代表进程或者线程。
调度要解决的第一个问题是,每一个 CPU 小伙伴每过一段时间,都要想一下,白板上这么多项目,我应该干哪一个?CPU 的队列里面有这么多的进程或者线程,应该取出哪一个来执行?
这个场景也很好理解,就像我们平时工作时,打开 kanban 也是一堆子项目(一个 CPU 对应一堆进程),那肯定要先想想到底先搞哪个项目才是最优选择是吧。
这就是调度规则或者调度算法的问题。
周瑜说,他原来在大公司的时候,调度算法常常是这样设计的。
一个是公平性,对于接到的多个项目,不能厚此薄彼。这个算法主要由 fair_sched_class 实现,fair 就是公平的意思。
另一个是优先级,有的项目要急一点,客户出的钱多,所以应该多分配一些精力在高优先级的项目里面。
在 Linux 里面,讲究的公平可不是一般的公平,而是 CFS 调度算法,CFS 全称是 Completely Fair Scheduling,完全公平调度。
为了公平,项目经理需要记录下进程的运行时间。CPU 会提供一个时钟,过一段时间就触发一个时钟中断。就像咱们的表滴答一下,这个我们叫 Tick。CFS 会为每一个进程安排一个虚拟运行时间 vruntime。如果一个进程在运行,随着时间的增长,也就是一个个 Tick 的到来,进程的 vruntime 将不断增大。没有得到执行的进程 vruntime 不变。
显然,那些 vruntime 少的,原来受到了不公平的对待,需要给他补上,所以会优先运行这样的进程。
这有点儿像让你把一筐球平均分到 N 个口袋里面,你看着哪个少,就多放一些;哪个多了,就先不放。这样经过多轮,虽然不能保证球完全一样多,但是也差不多公平。
---
有时候,进程会分优先级,如何给优先级高的进程多分时间呢?
这个简单,就相当于 N 个口袋,优先级高的袋子大,优先级低的袋子小。这样球就不能按照个数分配了,要按照比例来,大口袋的放了一半和小口袋放了一半,里面的球数目虽然差很多,也认为是公平的。
函数 update_curr 用于更新进程运行的统计量 vruntime,CFS 还需要一个数据结构来对 vruntime 进行排序,找出最小的那个。在这里使用的是红黑树。红黑树的节点是 sched_entity,里面包含 vruntime。
调度算法的本质就是解决下一个进程应该轮到谁运行的问题,这个逻辑在 fair_sched_class.pick_next_task 中完成。
调度要解决的第二个问题是,什么时候切换任务?也即,什么时候,CPU 小伙伴应该停下一个进程,换另一个进程运行?
一个人在做 A 项目,在某个时刻,换成做 B 项目去了。发生这种情况,主要有两种方式。
方式一,A 项目做着做着,里面有一条指令 sleep,也就是要休息一下,或者等待某个 I/O 事件。那没办法了,要主动让出 CPU,然后可以开始做 B 项目。主动让出 CPU 的进程,会主动调用 schedule() 函数。
在 schedule() 函数中,会通过 fair_sched_class.pick_next_task,在红黑树形成的队列上取出下一个进程,然后调用 context_switch 进行进程上下文切换。
进程上下文切换主要干两件事情,一是切换进程空间,也即进程的内存,也即 CPU 小伙伴不能 A 项目的会议室里面干活了,要跑到 B 项目的会议室去。二是切换寄存器和 CPU 上下文,也即 CPU 将当期在 A 项目中干到哪里了,记录下来,方便以后接着干。
方式二,A 项目做着做着,旷日持久,实在受不了了。项目经理介入了,说这个项目 A 先停停,B 项目也要做一下,要不然 B 项目该投诉了。最常见的现象就是,A 进程执行时间太长了,是时候切换到 B 进程了。这个时候叫作 A 进程被被动抢占。
抢占还要通过 CPU 的时钟 Tick,来衡量进程的运行时间。时钟 Tick 一下,是很好查看是否需要抢占的时间点。时钟中断处理函数会调用 scheduler_tick(),他会调用 fair_sched_class 的 task_tick_fair,在这里面会调用 update_curr 更新运行时间。当发现当前进程应该被抢占,不能直接把他踢下来,而是把他标记为应该被抢占,打上一个标签 TIF_NEED_RESCHED。
另外一个可能抢占的场景发生在,当一个进程被唤醒的时候。一个进程在等待一个 I/O 的时候,会主动放弃 CPU。但是,当 I/O 到来的时候,进程往往会被唤醒。这个时候是一个时机。当被唤醒的进程优先级高于 CPU 上的当前进程,就会触发抢占。如果应该发生抢占,也不是直接踢走当然进程,而也是将当前进程标记为应该被抢占,打上一个标签 TIF_NEED_RESCHED。
真正的抢占还是需要上下文切换,也就是需要那么一个时刻,让正在运行中的进程有机会调用一下 schedule。调用 schedule 有以下四个时机。
对于用户态的进程来讲,从系统调用中返回的那个时刻,是一个被抢占的时机。
对于用户态的进程来讲,从中断中返回的那个时刻,也是一个被抢占的时机。
对内核态的执行中,被抢占的时机一般发生在 preempt_enable() 中。在内核态的执行中,有的操作是不能被中断的,所以在进行这些操作之前,总是先调用 preempt_disable() 关闭抢占。再次打开的时候,就是一次内核态代码被抢占的机会。
在内核态也会遇到中断的情况,当中断返回的时候,返回的仍然是内核态。这个时候也是一个执行抢占的时机。
周瑜和张昭商定了这个规则,然后给 CPU 小伙伴们交代之后,项目虽然越来越多,但是也井井有条起来。CPU 小伙伴不会像原来一样火急火燎,不知所从了。
可是其实对于项目的开发,这家公司还是有严重漏洞的,就是项目的保密问题,不管哪家客户将系统外包出去,肯定也不想让其他公司知道详情。如果解决不好这个问题,没人敢把重要的项目交给这家公司,小马的公司也就永远只能接点边角系统,还是不能保证温饱问题。
那接下来,小马会怎么解决项目之间的保密问题呢?欲知后事,且听下回分解。
内核对进程的调度,就像是公司对不同项目的优先级安排是不同的。
上一章留了个扣子,说到不同项目之间的保密问题,那肯定就是虚拟内存了。
当然,这章不仅要讲虚拟内存,还要讲。这章是非常重要的。
上一节我们说到,周瑜和张昭商定了调用 schedule 的时机。尽管项目越来越多,但是也井井有条。可是我们也说了,不管你的事情做得有多好,项目保密问题都是要解决的重要问题。怎么解决呢?今天我们就来看一看。
小马说:“要不咱们的项目别用会议室封闭开发了,原来总是说封闭开发,就是为了隔离,保密。这对于公司声誉来说很重要,但是能不能通过签订保密协议的方式来,干嘛非得封闭开发呢?”
周瑜说:“马哥,以我在大公司管理项目的经验来看,您还是想简单了。”
“你看,每次你接一个项目,总要写成项目执行计划书,CPU 小伙伴们才能执行吧,项目计划书中的一行一行指令运行过程中,免不了要产生一些数据。这些数据要保存在一个地方,这个地方就是会议室(内存)。会议室(内存)被分成一块一块儿的,都编好了号。例如 3F-10,就是三楼十号会议室。这个地址是实实在在的地址,通过这个地址我们就能够定位到物理内存的位置。”
“现在问题来了,写项目执行计划书的时候,里面的指令使用的地址是否可以使用物理地址呢?当然不行了,项目执行计划书,都是事先写好的,可以多次运行的。如果里面有个指令是,要把用户输入的数字保存在内存中,那就会有问题。”
“会产生什么问题呢?我举个例子你就明白了。如果我们使用那个实实在在的地址,3F-10,打开三个相同的程序,都执行到某一步。比方说,打开了三个计算器,用户在这三个程序的界面上分别输入了 10、100、1000。如果内存中的这个位置只能保存一个数,那应该保存哪个呢?这不就冲突了吗?”
“如果不用这个实实在在的地址,那应该怎么办呢?那就必须用封闭开发的办法。
每个项目的物理地址对于进程不可见,谁也不能直接访问这个物理地址。操作系统会给进程分配一个虚拟地址。所有进程看到的这个地址都是一样的,里面的内存都是从 0 开始编号。
在程序里面,指令写入的地址是虚拟地址。例如,位置为 10M 的内存区域,操作系统会提供一种机制,将不同进程的虚拟地址和内存的物理地址映射起来。
当程序要访问虚拟地址的时候,由内核的数据结构进行转换,转换成不同的物理地址,这样不同的进程运行的时候,写入的是不同的物理地址,就不会冲突了。”
小马想想,对啊,这是个好办法,咱们得规划一套会议室管理系统(内存管理)。根据刚才的分析,这个系统应该包含以下三个部分:
第一,物理内存的管理,相当于会议室管理员管理会议室;
第二,虚拟地址的管理,也即在项目组的视角,会议室的虚拟地址应该如何组织;
第三,虚拟地址和物理地址如何映射的问题,也即会议室管理员如果管理映射表。
我们先来盘点一下物理内存的情况。
我们还是拿公司举例,意思是假如公司有 100 个工位,但是有 n 个项目,这 n 个项目还是同时运行的,如果不用虚拟内存,就会出现 1 个工位被 n 个项目反复修改,就出现错误了。使用虚拟内存之后,即使这 n 个项目都需要同一个工位(内存地址),但是老板(内核)会给不同的项目划分不同的区域(虚拟内存和物理内存的映射表),同一个工位实际上映射在不同的地方(物理内存地址)。
其实我们在日常生活中就是这么设计的,所以比较难理解之前“物理内存”是个什么情况。
其实上面的这个说法是有些问题的,日常工作时不同开发组是可见的,但是进程之间则不是,每个进程都认为整个写字楼都是自己独占的。实际上更合理的说法是,所有进程都想进入 21F-1 的办公室,在下面摁电梯时,身在写字楼顶层的高管会把这 n 个进程划分到不同的楼层、不同的办公室,但是这些进程自己认为自己都进入了 21F-1 办公室,也认为整个写字楼都是自己独占的。
下面这段非常重要,要仔细看了
不同的园区工位的安排和会议室的布局各不相同。
第一种情况是,CPU 小伙伴们坐在一起,会议室在楼层的另一面,大家到会议室里面去都要通过统一的过道,优点简单,缺点是通道会成为瓶颈。
第二种情况是,会议室分成多个节点,离散地分布在 CPU 小伙伴周围。有的小伙伴离这个会议室近一些,有的小伙伴离另外一些会议室近一些。这样做的优点是,如果 CPU 小伙伴干活总是能够去离他最近的会议室,则速度非常快,但是一旦离他最近的会议室被占用了,他只能去其他会议室,这样就比较远了。
现在的园区基本都设计成第二种样子,也即会议室(内存)要分节点,每个节点用 struct pglist_data 表示。
每个节点里面再分区域,用于区分内存不同部分的不同用法。ZONE_NORMAL 是最常用的区域。ZONE_MOVABLE 是可移动区域。我们通过将物理内存划分为,可移动分配区域和不可移动分配区域,来避免内存碎片。每个区域用 struct zone 表示,也放在一个数组里面。
每个区域里面再分页。默认的大小为 4KB。这就相当于每个会议室的最小单位。
如果有项目要使用会议室,应该如何分配呢?不能任何项目来了,咱都给他整个会议室。会议室也是可以再分割的,例如在中间拼起一堵墙,这样一个会议室就可以分成两个,继续分,可以再分成四个 1/4 大小的会议室,直到不能再分,我们就能得到一页的大小。
物理页面分配的时候,也可以采取这样的思路,我们称为伙伴系统。
空闲页放在 struct free_area 里面,每一页用 struct page 表示。
把所有的空闲页分组为 11 个页块链表,每个块链表分别包含很多个大小的页块,有 1、2、4、8、16、32、64、128、256、512 和 1024 个连续页的页块。最大可以申请 1024 个连续页,对应 4MB 大小的连续内存。每个页块的第一个页的物理地址是该页块大小的整数倍。
例如,要请求一个 128 个页的页块时,我们要先检查 128 个页的页块链表是否有空闲块。如果没有,则查 256 个页的页块链表;如果有空闲块的话,则将 256 个页的页块分成两份,一份使用,一份插入 128 个页的页块链表中。如果还是没有,就查 512 个页的页块链表;如果有的话,就分裂为 128、128、256 三个页块,一个 128 的使用,剩余两个插入对应页块链表。
把物理页面分成一块一块大小相同的页,这样带来的另一个好处是,当有的内存页面长时间不用了,可以暂时写到硬盘上,我们称为换出。一旦需要的时候,再加载进来,就叫作换入。这样可以扩大可用物理内存的大小,提高物理内存的利用率。在内核里面,也即张昭的管理下,有一个进程 kswapd,可以根据物理页面的使用情况,对页面进行换入换出。
小马觉得这种方式太好了,如此高效地使用会议室,公司不用租用多少会议室,就能解决当前的项目问题了。
就像公司里不同部门也有各自专有的工作区域一样,NUMA 就是这种 CPU 和内存一一对应的架构。
而 SMP 则对应那种不同部门混在一起工作,这是很不合理的。
buddy allocator 就像是项目内部划分再给各种内部项目划分工作区域,一定是划分到最小粒度,也就是工位(也就是“空闲页放在 struct free_area 里面,每一页用 struct page 表示”),再规划出 11 种内部项目可以申请的档位(也就是“把所有的空闲页分组为 11 个页块链表,每个块链表分别包含很多个大小的页块,有 1、2、4、8、16、32、64、128、256、512 和 1024 个连续页的页块。最大可以申请 1024 个连续页,对应 4MB 大小的连续内存。”)。
这样项目内部需要创建子项目时,直接申请整块的工位区域(连续页的页块)就可以了。就不需要再一个一个申请工位了。其实跟我们在公司里也差不多,代入到实际生活就很好理解了。
会议室排列有序,分视角各有洞天
周瑜说,“你先别急,这还仅仅是会议室物理地址的管理,每一个项目组能够看到的虚拟地址,咱还没规划呢!这个规划不好,执行项目还是会有问题的。”
每个项目组能看到的虚拟地址怎么规划呢?我们要给项目组这样一种感觉,从项目组的角度,也即从虚的角度来看,这一大片连续的内存空间都是他们的了。
如果是 32 位,有 2^32 = 4G 的内存空间都是他们的,不管内存是不是真的有 4G。如果是 64 位,在 x86_64 下面,其实只使用了 48 位,那也挺恐怖的。48 位地址长度也就是对应了 256TB 的地址空间。
小马说:“我都没怎么见过 256T 的硬盘,别说是内存了。”
周瑜接着说:“现在,一个项目组觉得,会议室可比世界首富房子还大。虽然是虚拟的,下面尽情地去排列咱们要放的东西吧!请记住,现在我们是站在一个进程的角度,去看这个虚拟的空间,不用管其他进程。”
首先,这么大的虚拟空间一切二,一部分用来放内核的东西,称为内核空间;一部分用来放进程的东西,称为用户空间。用户空间在下,在低地址,我们假设是 0 号到 29 号会议室;内核空间在上,在高地址,我们假设是 30 号到 39 号会议室。这两部分空间的分界线,因为 32 位和 64 位的不同而不同,我们这里不深究。
对于普通进程来说,内核空间的那部分,虽然虚拟地址在那里,但是不能访问。这就像作为普通员工,你明明知道财务办公室在这个 30 号会议室门里面,但是门上挂着“闲人免进”,你只能在自己的用户空间里面折腾。
这里的类比也跟我之前预想的一摸一样,内核空间就是高管所在的区域,写字楼的顶层。员工们则在写字楼的下面楼层办公。
我们从最低位开始排起,先是 Text Segment、Data Segment 和 BSS Segment。Text Segment 是存放二进制可执行代码的位置,Data Segment 存放静态常量,BSS Segment 存放未初始化的静态变量。这些都是在项目执行计划书里面有的。
接下来是堆段。堆是往高地址增长的,是用来动态分配内存的区域,malloc 就是在这里面分配的。
接下来的区域是 Memory Mapping Segment。这块地址可以用来把文件映射进内存用的,如果二进制的执行文件依赖于某个动态链接库,就是在这个区域里面将 so 文件映射到了内存中。
再下面就是栈地址段了,主线程的函数调用的函数栈就是用这里的。
如果普通进程还想进一步访问内核空间,是没办法的,只能眼巴巴地看着。如果需要进行更高权限的工作,就需要调用系统调用,进入内核。
一旦进入了内核,就换了一副视角。刚才是普通进程的视角,觉着整个空间是它独占的,没有其他进程存在。当然另一个进程也这样认为,因为它们互相看不到对方。这也就是说,不同进程的 0 号到 29 号会议室放的东西都不一样。
但是,到了内核里面,无论是从哪个进程进来的,看到的是同一个内核空间,看到的是同一个进程列表。虽然内核栈是各用各的,但是如果想知道的话,还是能够知道每个进程的内核栈在哪里的。所以,如果要访问一些公共的数据结构,需要进行锁保护。也就是说,不同的进程进入到内核后,进入的 30 号到 39 号会议室是同一批会议室。
内核的代码访问内核的数据结构,大部分的情况下都是使用虚拟地址的。虽然内核代码权限很大,但是能够使用的虚拟地址范围也只能在内核空间,也即内核代码访问内核数据结构,只能用 30 号到 39 号这些编号,不能用 0 到 29 号,因为这些是被进程空间占用的。而且,进程有很多个。你现在在内核,但是你不知道当前指的 0 号是哪个进程的 0 号。
在内核里面也会有内核的代码,同样有 Text Segment、Data Segment 和 BSS Segment,内核代码也是 ELF 格式的。
不过有了这个规定以后,项目执行计划书要写入数据的时候,就需要符合里面的规定了,数据不能随便乱放了。
小马说,“没问题,这个作为项目章程,每一个新员工来了都培训。”
内核不能直接访问和使用用户空间的内存地址,从而保护用户空间的数据,以及进程稳定性。
那内核有办法访问用户空间的内存吗?
---
是的,内核有办法访问用户空间的内存,但是需要通过特定的机制和权限来实现。
在操作系统中,内核拥有更高的特权级别和权限,因此它可以访问系统中的所有内存,包括用户空间的内存。但是,内核必须通过特定的机制来访问用户空间的内存,以确保安全性和保护用户数据。
一种常见的机制是通过系统调用(system call)来实现用户空间和内核空间之间的通信。用户空间的进程可以通过系统调用请求内核执行某些操作,例如读写文件、分配内存等。在这种情况下,内核可以访问用户空间的内存,以满足进程的请求。
另外,内核还可以使用页表映射机制来访问用户空间的内存。页表是一种数据结构,用于将虚拟地址映射到物理地址。内核可以修改页表的映射关系,以便访问用户空间的内存。这通常在内核执行某些操作时使用,例如拷贝数据或进行内存管理。
需要注意的是,内核访问用户空间的内存需要谨慎操作,以确保数据的完整性和安全性。内核必须遵循操作系统的规则和机制,并且仅在必要的情况下才能访问用户空间的内存。这样可以保护用户数据,并防止内核滥用其特权。
syscall 和 page table mapping
管理系统全搞定,至此生存无问题
周瑜接着说:“物理会议室和虚拟空间都分成大小相同的页,我们还得有一个会议室管理系统,将两者关联起来,这样项目组申请会议室的时候,也有个系统可以统一的管理,要不然会议室还不得老冲突呀。”
对于虚拟内存的访问,也是有一个地址的,我们需要找到一种策略,实现从虚拟地址到物理地址的转换。
为了能够定位和访问每个页,需要有个页表,保存每个页的起始地址,再加上在页内的偏移量,组成线性地址,就能对于内存中的每个位置进行访问了。
虚拟地址分为两部分,页号和页内偏移。页号作为页表的索引,页表包含物理页每页所在物理内存的基地址。这个基地址与页内偏移的组合就形成了物理内存地址。
下面的图,举了一个简单的页表的例子,虚拟内存中的页通过页表映射对应到物理内存中的页。
---
32 位环境下,虚拟地址空间共 4GB。如果分成 4KB 一个页,那就是 1M 个页。每个页表项需要 4 个字节来存储,那么整个 4GB 空间的映射就需要 4MB 的内存来存储映射表。如果每个进程都有自己的映射表,100 个进程就需要 400MB 的内存。对于内核来讲,有点大了。
页表中所有页表项必须提前建好,并且要求是连续的。如果不连续,就没有办法通过虚拟地址里面的页号找到对应的页表项了。
那怎么办呢?我们可以试着将页表再分页,4G 的空间需要 4M 的页表来存储映射。我们把这 4M 分成 1K(1024)个 4K,每个 4K 又能放在一页里面,这样 1K 个 4K 就是 1K 个页,这 1K 个页也需要一个表进行管理,我们称为页目录表,这个页目录表里面有 1K 项,每项 4 个字节,页目录表大小也是 4K。
页目录有 1K 项,用 10 位就可以表示访问页目录的哪一项。这一项其实对应的是一整页的页表项,也即 4K 的页表项。每个页表项也是 4 个字节,因而一整页的页表项是 1k 个。再用 10 位就可以表示访问页表项的哪一项,页表项中的一项对应的就是一个页,是存放数据的页,这个页的大小是 4K,用 12 位可以定位这个页内的任何一个位置。
这样加起来正好 32 位,也就是用前 10 位定位到页目录表中的一项。将这一项对应的页表取出来共 1k 项,再用中间 10 位定位到页表中的一项,将这一项对应的存放数据的页取出来,再用最后 12 位定位到页中的具体位置访问数据。
物理内存和虚拟内存是怎么映射的?
这部分先不看
你可能会问,如果这样的话,映射 4GB 地址空间就需要 4MB+4KB 的内存,这样不是更大了吗?当然如果页是满的,当时是更大了,但是,我们往往不会为一个进程分配那么多内存。
比如说,上面图中,我们假设只给这个进程分配了一个数据页。如果只使用页表,也需要完整的 1M 个页表项共 4M 的内存,但是如果使用了页目录,页目录需要 1K 个全部分配,占用内存 4K,但是里面只有一项使用了。到了页表项,只需要分配能够管理那个数据页的页表项页就可以了,也就是说,最多 4K,这样内存就节省多了。
当然对于 64 位的系统,两级肯定不够了,就变成了四级目录,分别是全局页目录项 PGD(Page Global Directory)、上层页目录项 PUD(Page Upper Directory)、中间页目录项 PMD(Page Middle Directory)和页表项 PTE(Page Table Entry)。
设计完毕会议室管理系统,再加上前面的项目管理系统,对于一家外包公司来讲,无论接什么样的项目都能轻松搞定了。我们常把 CPU 和内存合称为计算。至此,计算的问题就算搞定了。解决了这两大问题,一家外包公司的生存问题,就算解决了。
小马总算是可以松一口气了,他和周瑜、张昭好好地搓了一顿,喝得昏天黑地。周瑜和张昭纷纷感慨,幸亏当年跟了马哥,今日才有出头之日。
生存问题虽然解决了,马哥可非池中之物,接下来要解决的就是发展问题,马哥能想出什么办法进一步壮大企业呢?欲知后事,且听下回分解。
公司发展需积累,马哥建立知识库
这就需要我们有一个存放资料的档案库(文件系统)。档案库应该不依赖于项目而独立存在,应该井井有条、利于查询;应该长久保存,不随人员流动而损失。
公司到了这个阶段,除了周瑜和张昭,应该专门请一个能够积累核心竞争力的人来主持大局了。马哥想到了,前一阵行业交流大会上,他遇到了一个很牛的架构师——鲁肃。他感觉鲁肃在这方面很有想法,于是就请他来主持大局。
鲁肃跟马哥说,构建公司的核心技术能力,这个档案库(文件系统)也可以叫作知识库,这个需要好好规划一下。规划文件系统的时候,需要考虑以下几点。
第一点,文件系统要有严格的组织形式,使得文件能够以块为单位进行存储。
这就像图书馆里,我们会给设置一排排书架,然后再把书架分成一个个小格子。有的项目存放的资料非常多,一个格子放不下,就需要多个格子来进行存放。我们把这个区域称为存放原始资料的仓库区。对于操作系统,硬盘分成相同大小的单元,我们称为块。一块的大小是扇区大小的整数倍,默认是 4K,用来存放文件的数据部分。这样一来,如果我们像存放一个文件,就不用给他分配一块连续的空间了。我们可以分散成一个个小块进行存放。这样就灵活得多,也比较容易添加、删除和插入数据。
第二点,文件系统中也要有索引区,用来方便查找一个文件分成的多个块都存放在了什么位置。
这就好比,图书馆的书太多了,为了方便查找,我们需要专门设置一排书架,这里面会写清楚整个档案库有哪些资料,资料在哪个架子的哪个格子上。这样找资料的时候就不用跑遍整个档案库,只要在这个书架上找到后,直奔目标书架就可以了。
在 Linux 操作系统里面,每一个文件有一个 Inode,inode 的“i”是 index 的意思,其实就是“索引”。inode 里面有文件的读写权限 i_mode,属于哪个用户 i_uid,哪个组 i_gid,大小是多少 i_size_io,占用多少个块 i_blocks_io。“某个文件分成几块、每一块在哪里”,这些信息也在 inode 里面,保存在 i_block 里面。
第三点,如果文件系统中有的文件是热点文件,近期经常被读取和写入,文件系统应该有缓存层。
这就相当于图书馆里面的热门图书区,这里面的书都是畅销书或者是常常被借还的图书。因为借还的次数比较多,那就没必要每次有人还了之后,还放回遥远的货架,我们可以专门开辟一个区域,放置这些借还频次高的图书。这样借还的效率就会提高。
第四点,文件应该用文件夹的形式组织起来,方便管理和查询。
这就像在图书馆里面,你可以给这些资料分门别类,比如分成计算机类、文学类、历史类等等。这样你也容易管理,项目组借阅的时候只要在某个类别中去找就可以了。
在文件系统中,每个文件都有一个名字,我们访问一个文件,希望通过他的名字就可以找到。文件名就是一个普通的文本,所以文件名经常会冲突,不同用户取相同的名字的情况会经常出现的。
要想把很多的文件有序地组织起来,我们就需要把他们做成目录或者文件夹。这样,一个文件夹里可以包含文件夹,也可以包含文件,这样就形成了一种树形结构。我们可以将不同的用户放在不同的用户目录下,就可以一定程度上避免了命名的冲突问题。
第五点,Linux 内核要在自己的内存里面维护一套数据结构,来保存哪些文件被哪些进程打开和使用。
这就好比,图书馆里会有个图书管理系统,记录哪些书被借阅了,被谁借阅了,借阅了多久,什么时候归还。
这个图书管理系统尤为重要,如果不是很方便使用,以后项目中积累了经验,就没有人愿意往知识库里面放了。
VFS
page cache
无论哪个项目(进程),都可以通过 write 系统调用写入知识库。
对于每一个进程,打开的文件都有一个文件描述符。files_struct 里面会有文件描述符数组。每个一个文件描述符是这个数组的下标,里面的内容指向一个 struct file 结构,表示打开的文件。这个结构里面有这个文件对应的 inode,最重要的是这个文件对应的操作 file_operation。如果操作这个文件,就看这个 file_operation 里面的定义了。
每一个打开的文件,都有一个 dentry 对应,虽然我们叫作 directory entry,但是他不仅仅表示文件夹,也表示文件。他最重要的作用就是指向这个文件对应的 inode。
如果说 file 结构是一个文件打开以后才创建的,dentry 是放在一个 dentry cache 里面的。文件关闭了,他依然存在,因而他可以更长期的维护内存中的文件的表示和硬盘上文件的表示之间的关系。
inode 结构就表示硬盘上的 inode,包括块设备号等。这个 inode 对应的操作保存在 inode operations 里面。真正写入数据,是写入硬盘上的文件系统,例如 ext4 文件系统。
马哥听了知识库和档案库的设计,非常开心,对鲁肃说,你这五大秘籍,可是帮了我大忙了。于是马上下令实施。
有了积累建生态,成立渠道管理部
有了知识库,公司的面貌果然大为改观。
马哥发现,当知识库积累到一定程度,公司接任何项目都能找到相似的旧项目作为参考,不用重新设计,效率大大提高。而且最重要的一点是,没有知识库的时候,原来项目做得好不好,完全取决于程序员,因为所有的知识都在程序员的脑子里,所以公司必须要招聘高质量的程序员,才能保证项目的质量。一方面优秀的程序员数量很少,这大大限制了公司能够接项目的规模,一方面优秀的程序员实在太贵,大大提高了公司的成本。
有了知识库,依赖于原来积累的体系,只要找到类似的旧项目,哪怕是普通的程序员,只要会照猫画虎,结果就不会太差。
于是,马哥马上想到,现在公司只有百十来号人,能赚这些钱,现在招人门槛降低了,我要是招聘一万人,这能赚多少钱啊!
鲁肃对马哥说,“你可先别急着招人,建立知识库,降低招人成本才是第一步。公司招聘太多人不容易管理。既然项目的执行可以照猫画虎,很多项目可以不用咱们公司来,我们可以建立渠道销售体系(输入和输出系统),让供应商、渠道帮我们卖,形成一个生态。这公司的盈利规模可就不是招一万人这么点儿了,这是指数级的增长啊!”
计算机系统的输入和输出系统都有哪些呢?我们能举出来的,例如键盘、鼠标、显示器、网卡、硬盘、打印机、CD/DVD 等等,多种多样。这样,当然方便用户使用了,但是对于操作系统来讲,却是一件复杂的事情,因为这么多设备,形状、用法、功能都不一样,怎么才能统一管理起来呢?我们一层一层来看。
第一层,用设备控制器屏蔽设备差异。
马哥说,“把生意做到全国,我也想过,这个可不容易。咱们客户多种多样,众口难调,不同的地域不一样,不同的行业不一样。如果你不懂某个地方的规矩,根本卖不出去东西;如果你不懂某个具体行业的使用场景,也无法满足客户的需求。”
鲁肃说:“所以说,建议您建立生态,设置很多代理商,让各个地区和各个行业的代理商帮你屏蔽这些差异化。你和代理商之间只要进行简单的标准产品交付就可以了。”
计算机系统就是这样的。CPU 并不直接和设备打交道,他们中间有一个叫作设备控制器(Device Control Unit)的组件。例如,硬盘有磁盘控制器、USB 有 USB 控制器、显示器有视频控制器等。这些控制器就像代理商一样,他们知道如何应对硬盘、鼠标、键盘、显示器的行为。
你的代理商往往是小公司。控制器其实有点儿像一台小电脑。他有他的芯片,类似小 CPU,执行自己的逻辑。他也有他的寄存器。这样 CPU 就可以通过写这些寄存器,对控制器下发指令,通过读这些寄存器,查看控制器对于设备的操作状态。
CPU 对于寄存器的读写,可比直接控制硬件,要标准和轻松很多。这就相当于你和代理商的标准产品交付。
第二层,用驱动程序屏蔽设备控制器差异。
马哥说:“你这么一说,还真有道理,如果我们能够找到足够多的代理商,那就高枕无忧了。”
鲁肃说:“其实事情还没这么简单,虽然代理商机制能够帮我们屏蔽很多设备的细节,但是从上面的描述我们可以看出,由于每种设备的控制器的寄存器、缓冲区等使用模式,指令都不同。对于咱们公司来讲,就需要有个部门专门对接代理商,向其他部门屏蔽代理商的差异,成立公司的渠道管理部门。”
那对于操作系统来讲,渠道管理部门就是用来对接各个设备控制器的设备驱动程序。
这里需要注意的是,设备控制器不属于操作系统的一部分,但是设备驱动程序属于操作系统的一部分。操作系统的内核代码可以像调用本地代码一样调用驱动程序的代码,而驱动程序的代码需要发出特殊的面向设备控制器的指令,才能操作设备控制器。
设备驱动程序中是一些面向特殊设备控制器的代码。不同的设备不同。但是对于操作系统其他部分的代码而言,设备驱动程序应该有统一的接口。就像下面图中的一样,不同的设备驱动程序,可以以同样的方式接入操作系统,而操作系统的其他部分的代码,也可以无视不同设备的区别,以同样的接口调用设备驱动程序。
这部分内容是很贴切的,设备控制器
“那对于操作系统来讲,渠道管理部门就是用来对接各个设备控制器的设备驱动程序。”
第三,用中断控制器统一外部事件处理。
马哥听了恍然大悟:“原来代理商也是五花八门,里面有这么多门道啊!”
鲁肃说:“当咱们对接的代理商多了,代理商可能会有各种各样的问题找到我们,例如代理商有了新客户,客户有了新需求,客户交付完毕等事件,都需要有一种机制通知你们公司,当然是中断,那操作系统就需要有一个地方处理这个中断,既然设备驱动程序是用来对接设备控制器的,中断处理也应该在设备驱动里面完成。”
然而,中断的触发最终会到达 CPU,会中断操作系统当前运行的程序,所以操作系统也要有一个统一的流程来处理中断,使得不同设备的中断使用统一的流程。
一般的流程是,一个设备驱动程序初始化的时候,要先注册一个该设备的中断处理函数。咱们讲进程切换的时候说过,中断返回的那一刻是进程切换的时机。中断的时候,触发的函数是 do_IRQ。这个函数是中断处理的统一入口。在这个函数里面,我们可以找到设备驱动程序注册的中断处理函数 Handler,然后执行他进行中断处理。
第四,用文件系统接口屏蔽驱动程序的差异。
马哥又问了:“对接了这么多代理商,如果咱们内部的工程师要和他们打交道,有没有一种统一的方式呢?”
鲁肃说:“当然应该了,我们内部员工操作外部设备,可以基于文件系统的接口,制定一个统一的标准。”
其实文件系统的机制是一个非常好的机制,咱们公司应该定下这样的规则,一切皆文件。
所有设备都在/dev/文件夹下面,创建一个特殊的设备文件。这个设备特殊文件也有 inode,但是他不关联到硬盘或任何其他存储介质上的数据,而是建立了与某个设备驱动程序的连接。
有了文件系统接口之后,我们不但可以通过文件系统的命令行操作设备,也可以通过程序,调用 read、write 函数,像读写文件一样操作设备。
对于块设备来讲,在驱动程序之上,文件系统之下,还需要一层通用设备层。比如,咱们讲的文件系统,里面的逻辑和磁盘设备没有什么关系,可以说是通用的逻辑。在写文件的最底层,我们看到了 BIO 字眼的函数,但是好像和设备驱动也没有什么关系。
是的,因为块设备类型非常多,而 Linux 操作系统里面一切是文件。我们也不想文件系统以下,就直接对接各种各样的块设备驱动程序,这样会使得文件系统的复杂度非常高。所以,我们在中间加了一层通用块层,将与块设备相关的通用逻辑放在这一层,维护与设备无关的块的大小,然后通用块层下面对接各种各样的驱动程序。
总结:看上面 filesystem 的那个图,就一目了然了。
鲁肃帮助马哥建立了这套体系之后,果真业务有了很大起色。原来公司只敢接华东区的项目,毕竟比较近,沟通交付都很方便。后来项目扩展到所有一线城市、二线城市、省会城市,项目数量实现了几十倍的增长。
千万项目难度大,集体合作可断金
项目接的多了,就不免有大型的项目,涉及多个行业多个领域,需要多个项目组进行合作才能完成。那两个项目组应该通过什么样的方式,进行沟通与合作呢?作为老板,马哥应该如何设计整个流程呢?
马哥叫来周瑜、张昭、鲁肃,一起商量团队间的合作模式。大家一起献计献策。好在有很多成熟的项目管理流程可以参考。
最最传统的模型就是软件开发的瀑布模型。所谓的瀑布模型,其实就是将整个软件开发过程分成多个阶段,往往是上一个阶段完全做完,才将输出结果交给下一个阶段。这种模型类似进程间通信的管道模型。
所谓的管道,就是在两个进程之间建立一条单向的通道,其实是一段缓存,它会将前一个命令的输出,作为后一个命令的输入。
张昭说,瀑布模型的开发流程效率比较低下,现在大部分公司都不使用这种开发模式了,因为团队之间无法频繁地沟通。而且,管道的使用模式,也不适合进程间频繁的交换数据。
于是,他们还得想其他的办法。是不是可以借鉴传统外企的沟通方式——邮件呢?邮件有一定的格式,例如抬头、正文、附件等。发送邮件可以建立收件人列表,所有在这个列表中的人,都可以反复地在此邮件基础上回复,达到频繁沟通的目的。这个啊,就是消息队列模型。
和管道将信息一股脑儿地从一个进程,倒给另一个进程不同,消息队列有点儿像邮件,发送数据时,会分成一个一个独立的数据单元,也就是消息体,每个消息体都是固定大小的存储块,在字节流上不连续。
有了消息这种模型,两个进程之间的通信就像咱们平时发邮件一样,你来一封,我回一封,可以频繁沟通了。
但是有时候,项目组之间的沟通需要特别紧密,而且要分享一些比较大的数据。如果使用邮件,就发现,一方面邮件的来去不及时;另外一方面,附件大小也有限制,所以,这个时候,我们经常采取的方式就是,把两个项目组在需要合作的期间,拉到一个会议室进行合作开发,这样大家可以直接交流文档呀,架构图呀,直接在白板上画或者直接扔给对方,就可以直接看到。
可以看出来,共享会议室这种模型,类似进程间通信的共享内存模型。前面咱们讲内存管理的时候,知道每个进程都有自己独立的虚拟内存空间,不同的进程的虚拟内存空间映射到不同的物理内存中去。这个进程访问 A 地址和另一个进程访问 A 地址,其实访问的是不同的物理内存地址,对于数据的增删查改互不影响。
但是,咱们是不是可以变通一下,拿出一块虚拟地址空间来,映射到相同的物理内存中。这样这个进程写入的东西,另外一个进程马上就能看到了,都不需要拷贝来拷贝去,传来传去。
马哥说:“共享内存也有问题呀。如果两个进程使用同一个共享内存,大家都往里面写东西,很有可能就冲突了。例如两个进程都同时写一个地址,那先写的那个进程会发现内容被别人覆盖了。”
张昭说:“当然,和共享内存配合的,有另一种保护机制,使得同一个共享的资源,同时只能被一个进程访问叫信号量。”
信号量其实是一个计数器,主要用于实现进程间的互斥与同步,而不是用于存储进程间通信数据。
我们可以将信号量初始化为一个数值,来代表某种资源的总体数量。对于信号量来讲,会定义两种原子操作,一个是 P 操作,我们称为申请资源操作。这个操作会申请将信号量的数值减去 N,表示这些数量被他申请使用了,其他人不能用了。另一个是 V 操作,我们称为归还资源操作,这个操作会申请将信号量加上 M,表示这些数量已经还给信号量了,其他人可以使用了。
例如,你有 100 元钱,就可以将信号量设置为 100。其中 A 向你借 80 元,就会调用 P 操作,申请减去 80。如果同时 B 向你借 50 元,但是 B 的 P 操作比 A 晚,那就没有办法,只好等待 A 归还钱的时候,B 的 P 操作才能成功。之后,A 调用 V 操作,申请加上 30 元,也就是还给你 30 元,这个时候信号量有 50 元了,这时候 B 的 P 操作才能成功,才能借走这 50 元。
所谓原子操作(Atomic Operation),就是任何一块钱,都只能通过 P 操作借给一个人,不能同时借给两个人。也就是说,当 A 的 P 操作(借 80)和 B 的 P 操作(借 50),几乎同时到达的时候,不能因为大家都看到账户里有 100 就都成功,必须分个先来后到。
马哥说:“有了上面的这些机制,基本常规状态下的工作模式,对应到咱们平时的工作交接,收发邮件、联合开发等。我还想到,如果发生了异常怎么办?例如出现线上系统故障,这个时候,什么流程都来不及了,不可能发邮件,也来不及开会,所有的架构师、开发、运维都要被通知紧急出动。所以,7 乘 24 小时不间断执行的系统都需要有告警系统,一旦出事情,就要通知到人,哪怕是半夜,也要电话叫起来,处理故障。是不是应该还有一种异常情况下的工作模式。”
张昭说:“当然应该有,我们可以建立像操作系统里面的信号机制。信号没有特别复杂的数据结构,就是用一个代号一样的数字。Linux 提供了几十种信号,分别代表不同的意义。信号之间依靠它们的值来区分。这就像咱们看警匪片,对于紧急的行动,都是说,‘1 号作战任务’开始执行,警察就开始行动了。情况紧急,不能啰里啰嗦了。”
信号可以在任何时候发送给某一进程,进程需要为这个信号配置信号处理函数。当某个信号发生的时候,就默认执行这个函数就可以了。这就相当于咱们运维一个系统应急手册,当遇到什么情况,做什么事情,都事先准备好,出了事情照着做就可以了。
这里用瀑布式开发类比 pipe 真的是然人耳目一新。用发邮件类比消息队列,用共享会议室类比进程共享内存,也很合理。
这些项目组合作的流程设计合理,因而推行起来十分顺畅,现在接个千万级别的项目没有任何问题,根据交易量估值市值,起码有十个亿。
马哥有些小激动,原来自己身价这么高了,是不是也能上个市啥的,实现亿万富翁的梦想呢?于是马哥找了一些投资人聊了聊,投资人说,要想冲一把上市,还差点劲,目前的项目虽然大,但是想象力不够丰富。
那接下来,马哥如何做才能满足市场的想象力,最终成功上市呢?预知后事,且听下回分解。
这章我就比较懂了,主要讲了内核空间的几层网络协议。我对网络协议还是比较熟的。
确实,其实操作系统也是一个不断扩展的过程,从内核到进程、CPU、内存管理,再到文件系统,再到用户空间的那些东西。这基本上就是单机的操作系统了,再扩展呢?就是通过网络协议,与其他机器进行通信。
当然,在我们这个小故事里,就是跟其他大公司一起合伙做大项目(注意是大公司、大项目)。
马哥咬牙说道:“当然有!”先应下来再说呗,可是这心里是真没底。原来公司都是独自接单,现在要和其他公司合作,协议怎么签,价格怎么谈呢?
马哥找到鲁肃。鲁肃说:“我给你推荐一个人吧!这个人人脉广,项目运作能力强,叫陆逊,说不定能帮上忙。”
鲁肃找来陆逊。陆逊说:“这个好办。公司间合作嘛,就是条款谈好,利益分好就行,关键是大家要遵守行规。大家都按统一的规则来,事情就好办。”
这其实就像机器与机器之间合作,一台机器将自己想要表达的内容,按照某种约定好的格式发送出去。当另外一台机器收到这些信息后,也能够按照约定好的格式解析出来,从而准确、可靠地获得发送方想要表达的内容。这种约定好的格式就是网络协议。
现在业内知名的有两种网络协议模型,一种是 OSI 的标准七层模型,一种是业界标准的 TCP/IP 模型。它们的对应关系如下图所示:
...
从图中可以看到,传输层以下都是在内核中操作的,而应用层就是在应用空间,这就很合理了嘛。
公司大了不灵活,鼓励创新有妙招
慢慢地,马总发现,公司大有大的好处,自然也有大的毛病,也就是咱们常见的“大公司病”——不灵活。
这里面的不灵活,就像 Linux 服务器,越来越强大的时候,无论是计算、网络、存储,都越来越牛。例如,内存动不动就是百 G 内存,网络设备一个端口的带宽就能有几十 G 甚至上百 G。存储在数据中心至少是 PB 级别的,自然也有不灵活的毛病。
资源大小不灵活:有时候我们不需要这么大规格的机器,可能只想尝试一下某些新业务,申请个 4 核 8G 的服务器试一下,但是不可能采购这么小规格的机器。无论每个项目需要多大规格的机器,公司统一采购就限制几种,全部是上面那种大规格的。
资源申请不灵活:规格定死就定死吧,可是每次申请机器都要重新采购,周期很长。
资源复用不灵活:反正我需要的资源不多,和别人共享一台机器吧,这样不同的进程可能会产生冲突,例如 socket 的端口冲突。另外就是别人用过的机器,不知道上面做过哪些操作,有很多的历史包袱,如果重新安装则代价太大。
按说,大事情流程严禁没问题,很多小事情也要被拖累走整个流程,而且很容易出现资源冲突,每天跨部门的协调很累人,历史包袱严重,创新没有办法轻装上阵。
很多公司处理这种问题采取的策略是成立独立的子公司,独立决策,独立运营。这种办法往往会用在创新型的项目上。
Linux 也采取了这样的手段,就是在物理机上面创建虚拟机。每个虚拟机有自己单独的操作系统、灵活的规格,一个命令就能启动起来。每次创建都是新的操作系统,很好地解决了上面不灵活的问题。
在物理机上的操作系统看来,虚拟机是一个普通的应用,他和 Excel 一样,只能运行在用户态。但是对于虚拟机里面的操作系统内核来讲,运行在内核态,应该有高的权限。
要做到这件事情,第一种方式,完全虚拟化。其实说白了,这是一种“骗人”的方式。虚拟化软件会模拟假的 CPU、内存、网络、硬盘给到虚拟机,让虚拟机里面的内核自我感觉良好,感觉他终于又像个内核了。在 Linux 上,一个叫作 qemu 的工具可以做到这一点。
...
第三种方式称为半虚拟化。对于网络或者硬盘的访问,我们让虚拟机内核加载特殊的驱动,重新定位自己的身份。虚拟机操作系统的内核知道自己不是物理机内核,没那么高的权限。他很可能要和很多虚拟机共享物理资源,所以学会了排队。虚拟机写硬盘其实写的是一个物理机上的文件,那我的写文件的缓存方式是不是可以变一下。我发送网络包,根本就不是发给真正的网络设备,而是给虚拟的设备,我可不可以直接在内存里面拷贝给它,等等等等。
网络半虚拟化方式是 virtio_net,存储是 virtio_blk。客户机需要安装这些半虚拟化驱动。客户机内核知道自己是虚拟机,所以会直接把数据发送给半虚拟化设备,然后经过特殊处理(例如排队、缓存、批量处理等性能优化方式),最终发送给真正的硬件。这在一定程度上提高了性能。
有了虚拟化的技术,公司的状态改观了不少,在主要的经营方向之外,公司还推出了很多新的创新方向,都是通过虚拟机创建子公司的方式进行的,例如跨境电商、工业互联网、社交等。一方面,能够享受大公司的支持;一方面,也可以和灵活的创业公司进行竞争。
于是,公司就变成集团公司了。
这部分就是讲了虚拟机的原理,相当于公司内部创业
独占鳌头定格局,上市敲钟责任重
随着公司越来越大,钱赚的越来越多,马总的公司慢慢从行业的追随者,变成了领导者。这一方面,让马总觉得“会当凌绝顶,一览众山小”;另一方面,马总也觉得“高处不胜寒”。原来公司总是追着别人跑,产业格局,市场格局从来不用自己操心,只要自己的公司能赚钱就行。现在做了领头羊,马总也就慢慢成了各种政府论坛、产业论坛,甚至国际论坛的座上宾。
穷则独善其身,达则兼济天下。马总的决策可能关系到产业的发展、地方的 GDP 和就业,甚至未来的国际竞争力。因此,即便是和原来相同的事情,现在来做,方式和层次都不一样了。
就像对于单台 Linux 服务器,最重要的四种硬件资源是 CPU、内存、存储和网络。面对整个数据中心成千上万台机器,我们只要重点关注这四种硬件资源就可以了。如果运维数据中心依然像的运维一台台物理机的前辈一样,天天关心哪个程序放在了哪台机器上,使用多少内存、多少硬盘,每台机器总共有多少内存、多少硬盘,还剩多少内存和硬盘,那头就大了。
对于数据中心,我们需要一个调度器,将运维人员从指定物理机或者虚拟机的痛苦中解放出来,实现对于物理资源的统一管理,这就是 Kubernetes,也就是数据中心的操作系统。
这个图太牛逼了,那天看到的时候,真是感觉豁然开朗。其实 k8s 的架构和 linux 真是几乎一一对应。
我们把单机(操作系统)类比成公司,其中的 CPU 类比成工人。而 k8s 则是把单机类比为工人,或者说相当于一大堆大型集团公司的母公司,用来调度这些子公司。
对于 CPU 和内存这两种计算资源的管理,我们可以通过 Docker 技术完成。
容器实现封闭的环境主要要靠两种技术,一种是看起来是隔离的技术,称为 namespace。在每个 namespace 中的应用看到的,都是不同的 IP 地址、用户空间、进程 ID 等。另一种是用起来是隔离的技术,称为 cgroup,即明明整台机器有很多的 CPU、内存,但是一个应用只能用其中的一部分。
另外,容器里还有镜像。也就是说,在你焊好集装箱的那一刻,将集装箱的状态保存下来的样子。就像孙悟空说“定!”,集装箱里的状态就被“定”在了那一刻。然后,这一刻的状态会被保存成一系列文件。无论在哪里运行这个镜像,都能完整地还原当时的情况。
通过容器,我们可以将 CPU 和内存资源,从大的资源池里面隔离出来,并通过镜像技术,在数据中心里面实现计算资源的自由漂移。
没有操作系统的时候,汇编程序员需要指定程序运行的 CPU 和内存物理地址。同理,数据中心的管理员,原来也需要指定程序运行的服务器以及使用的 CPU 和内存。现在,Kubernetes 里面有一个调度器 Scheduler,你只需要告诉它,你想运行 10 个 4 核 8G 的 Java 程序,它会自动帮你选择空闲的、有足够资源的服务器,去运行这些程序。
对于存储,无论是分布式文件系统和分布式块存储,需要对接到 Kubernetes,让 Kubernetes 管理它们。如何对接呢?Kubernetes 会提供 CSI 接口。这是一个标准接口,不同的存储可以实现这个接口来对接 Kubernetes。是不是特别像设备驱动程序呀?操作系统只要定义统一的接口,不同的存储设备的驱动实现这些接口,就能被操作系统使用了。
对于网络,也是类似的机制,Kubernetes 同样是提供统一的接口 CNI。无论你用哪种方式实现网络模型,只要对接这个统一的接口,Kubernetes 就可以管理容器的网络。
到此,有了这套鼎定市场格局的策略,作为龙头企业,马总的公司终于可以顺利上市敲钟,走向人生巅峰。从此,江湖人称“马爸爸”。
好了,马同学的创业故事就讲到这里了,操作系统的原理也给你串了一遍。你是否真的记住了这些原理呢?试着将这个创业故事讲给你的朋友听吧!
读书记录
fork 创建进程的 syscall
syscall 是用户空间调用内核指定操作的方法。syscall 可以看作是公司老板(或者说高管)给公司普通项目以及外围公司开出来的指定操作表 能执行指定操作的秘书,用来连接两者(syscall 决定了操作系统好不好用,功能全不全)。
Linux 就是这样想的。当父进程调用 fork 创建进程的时候,子进程将各个子系统为父进程创建的数据结构也全部拷贝了一份,甚至连程序代码也是拷贝过来的。按理说,如果不进行特殊的处理,父进程和子进程都按相同的程序代码进行下去,这样就没有意义了。
所以,我们往往会这样处理:对于 fork 系统调用的返回值,如果当前进程是子进程,就返回 0;如果当前进程是父进程,就返回子进程的进程号。这样首先在返回值这里就有了一个区分,然后通过 if-else 语句判断,如果是父进程,还接着做原来应该做的事情;如果是子进程,需要请求另一个系统调用 execve 来执行另一个程序,这个时候,子进程和父进程就彻底分道扬镳了,也就产生了一个分支(fork)了。
父进程通过 syscall waitpid(pid) 就可以知道该子进程运行结果
在操作系统中,每个进程都有自己的内存,互相之间不干扰,有独立的进程内存空间。
那独立的办公空间里面,都放些什么呢?
项目执行计划书肯定是要放进去的,因为执行过程中肯定要不断地看。对于进程的内存空间来讲,放程序代码的这部分,我们称为代码段(Code Segment)。
项目执行的过程中,会产生一些架构图、流程图,这些也放在会议室里面。有的画在白板上,讨论完了,进入下个主题就会擦了;有的画在纸和本子上,讨论的时候翻出来,不讨论的时候堆在那里,会保留比较长的一段时间,除非指明的确不需要了才会去销毁。
对于进程的内存空间来讲,放进程运行中产生数据的这部分,我们称为数据段(Data Segment)。其中局部变量的部分,在当前函数执行的时候起作用,当进入另一个函数时,这个变量就释放了;也有动态分配的,会较长时间保存,指明才销毁的,这部分称为堆(Heap)。
一个进程的内存空间是很大的,32 位的是 4G,64 位的就更大了,我们不可能有这么多物理内存。就像一个公司的会议室是有限的,作为老板,你不可能事先都给项目组分配好。哪有这么多会议室啊,一定是需要的时候再分配。
所以,进程自己不用的部分就不用管,只有进程要去使用部分内存的时候,才会使用内存管理的系统调用来登记,说自己马上就要用了,希望分配一部分内存给它,但是这还不代表真的就对应到了物理内存。只有真的写入数据的时候,发现没有对应物理内存,才会触发一个中断,现分配物理内存。
这里我们介绍两个在堆里面分配内存的系统调用,brk 和 mmap。
当分配的内存数量比较小的时候,使用 brk,会和原来的堆的数据连在一起,这就像多分配两三个工位,在原来的区域旁边搬两把椅子就行了。当分配的内存数量比较大的时候,使用 mmap,会重新划分一块区域,也就是说,当办公空间需要太多的时候,索性来个一整块。
对于文件的操作,下面这六个系统调用是最重要的:
- 对于已经有的文件,可以使用 open 打开这个文件,close 关闭这个文件;
- 对于没有的文件,可以使用 creat 创建文件;
- 打开文件以后,可以使用 lseek 跳到文件的某个位置;
- 可以对文件的内容进行读写,读的系统调用是 read,写是 write。
但是别忘了,Linux 里有一个特点,那就是一切皆文件。
linux 会给每个文件分配一个 fd,是个 int,操作系统下全局唯一,可以理解为文件的 id,我们通过 fd 就可以使用 syscall 来操作进程(因为 linux 一切皆文件,所以通过上面几个文件相关的 syscall 就可以操作进程)
除了 syscall 还有哪些内核空间和用户空间之间交互的方法?给我尽量全的方法
---
除了 syscall,还有其他一些方法可以实现内核空间和用户空间之间的交互。以下是一些常见的方法:
1. 中断(Interrupts):中断是由硬件设备或软件触发的信号,用于打断 CPU 的正常执行流程,转而执行与中断相关的处理程序。内核可以通过中断机制与用户空间进行通信,例如处理设备的输入/输出请求。
2. 异常(Exceptions):异常是由于程序执行期间出现错误或异常情况而触发的事件。内核可以捕获并处理异常,然后与用户空间进行交互,例如处理内存访问错误或非法指令。
3. 信号(Signals):信号是一种软件中断,用于通知进程发生了某个事件。内核可以向进程发送信号,进程可以通过注册信号处理函数来响应信号。
4. 文件系统接口(File System Interface):内核提供了文件系统接口,允许用户空间的应用程序读取、写入和管理文件。应用程序可以通过文件系统接口与内核进行交互,执行文件操作。
5. 共享内存(Shared Memory):共享内存是一种允许多个进程共享同一块物理内存的机制。内核可以创建和管理共享内存区域,使得不同进程可以通过读写共享内存来进行通信和数据共享。
6. 管道(Pipes):管道是一种半双工的通信机制,用于在父进程和子进程之间传递数据。内核可以创建和管理管道,使得父进程和子进程可以通过管道进行通信。
7. 套接字(Sockets):套接字是一种网络通信机制,用于在不同主机之间进行进程间通信。内核提供了套接字接口,使得应用程序可以通过网络进行通信。
这些方法代表了一些常见的内核空间和用户空间之间的交互方式,但并不限于此。具体的交互方法可能会因操作系统和应用程序的不同而有所差异。
---
感觉好像和 IPC 的方法有好几个是类似的?
---
你观察得很正确!IPC(Inter-Process Communication,进程间通信)是一种用于实现不同进程之间通信和数据传递的方法。在上述提到的方法中,信号、共享内存、管道和套接字都属于 IPC 的一种形式。这些方法都提供了不同的机制和接口,使得进程可以在内核的帮助下进行通信和数据交换。
IPC 是操作系统中非常重要的概念,它允许不同进程之间进行协作和数据共享。不同的 IPC 方法适用于不同的场景和需求。例如,信号适用于进程间的简单通知和事件处理,共享内存适用于高性能的数据共享,管道适用于父子进程之间的通信,套接字适用于网络通信等。
因此,IPC 方法和内核空间与用户空间之间的交互方法有一定的重叠,因为它们都涉及到进程间的通信和数据传递。这些方法提供了不同的抽象层次和功能,以满足不同的通信需求。
共享内存
shmget 创建共享内存块
shmat 将共享内存映射到自己的内存空间,就可以读写了
但是,两个项目组共同访问一个会议室里的数据,就会存在“竞争”的问题。如果大家同时修改同一块数据咋办?这就需要有一种方式,让不同的人能够排他地访问,这就是信号量的机制 Semaphore。
这个机制比较复杂,我这里说一种简单的场景。
对于只允许一个人访问的需求,我们可以将信号量设为 1。当一个人要访问的时候,先调用 sem_wait。如果这时候没有人访问,则占用这个信号量,他就可以开始访问了。如果这个时候另一个人要访问,也会调用 sem_wait。由于前一个人已经在访问了,所以后面这个人就必须等待上一个人访问完之后才能访问。当上一个人访问完毕后,会调用 sem_post 将信号量释放,于是下一个人等待结束,可以访问这个资源了。
glibc
虽然 syscall(秘书的指令表)已经很方便了,但是只是对公司内部()而言,对公司外部的人对这个肯定不熟啊,就需要 glibc 了。glibc 封装了 linux 的 syscall,方便调用。
每个特定的系统调用对应了至少一个 Glibc 封装的库函数,比如说,系统提供的打开文件系统调用 sys_open 对应的是 Glibc 中的 open 函数。
有时候,Glibc 一个单独的 API 可能调用多个系统调用,比如说,Glibc 提供的 printf 函数就会调用如 sys_open、sys_mmap、sys_write、sys_close 等等系统调用。
也有时候,多个 API 也可能只对应同一个系统调用,如 Glibc 下实现的 malloc、calloc、free 等函数用来分配和释放内存,都利用了内核的 sys_brk 的系统调用。
我们总是用 strace 来调优 golang 代码,实际上 strace 就是用来跟踪进程执行时 syscall 和 signal 相关信息
# mac strace = dtruss
sudo dtruss -f -t syscall whoami
sudo dtruss -f -t syscall ls
➜ ~ strace whoami
execve("/usr/bin/whoami", ["whoami"], 0x7ffc73020e40 /* 27 vars */) = 0
brk(NULL) = 0xee6000
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f322edd5000
...
bus
对于公司来说,CPU 是真正干活的,将来执行项目都要靠它。
CPU 就相当于咱们公司的程序员,我们常说,二十一世最缺的是什么?是人才!所以,大量水平高、干活快的程序员,才是营商环境中最重要的部分。
CPU 和其他设备连接,要靠一种叫作总线(Bus)的东西,其实就是主板上密密麻麻的集成电路,这些东西组成了 CPU 和其他设备的高速通道。
在这些设备中,最重要的是内存(Memory)。因为单靠 CPU 是没办法完成计算任务的,很多复杂的计算任务都需要将中间结果保存下来,然后基于中间结果进行进一步的计算。CPU 本身没办法保存这么多中间结果,这就要依赖内存了。
内存就相当于办公室,我们要看看方不方便租到办公室,有没有什么创新科技园之类的。有了共享的、便宜的办公位,公司就有注册地了。
当然总线上还有一些其他设备,例如显卡会连接显示器、磁盘控制器会连接硬盘、USB 控制器会连接键盘和鼠标等等。
CPU 和内存是完成计算任务的核心组件,所以这里我们重点介绍一下 CPU 和内存是如何配合工作的。
CPU 其实也不是单纯的一块,它包括三个部分,运算单元、数据单元和控制单元。
运算单元只管算,例如做加法、做位移等等。但是,它不知道应该算哪些数据,运算结果应该放在哪里。
运算单元计算的数据如果每次都要经过总线,到内存里面现拿,这样就太慢了,所以就有了数据单元。数据单元包括 CPU 内部的缓存和寄存器组,空间很小,但是速度飞快,可以暂时存放数据和运算结果。
有了放数据的地方,也有了算的地方,还需要有个指挥到底做什么运算的地方,这就是控制单元。控制单元是一个统一的指挥中心,它可以获得下一条指令,然后执行这条指令。这个指令会指导运算单元取出数据单元中的某几个数据,计算出个结果,然后放在数据单元的某个地方。
能否给我讲解一下 linux kernel 的总线?为什么 CPU 和内存需要通过总线传数据?
---
Linux 内核的总线是指用于连接 CPU、内存和其他设备的物理通道。它允许这些组件之间进行数据传输和通信。CPU 和内存之间需要通过总线传输数据是因为它们是计算机系统中最重要的组件,需要频繁地进行数据交换和共享。总线提供了一个高速的通信通道,使得 CPU 能够读取和写入内存中的数据,以及与其他设备进行交互。这样,CPU 就能够执行指令,处理数据,并将结果存储到内存中。总线的设计和性能对于计算机系统的整体性能至关重要。
程序运行的过程中要操作的数据和产生的计算结果,都会放在数据段里面。那 CPU 怎么执行这些程序,操作这些数据,产生一些结果,并写入回内存呢?
CPU 的控制单元里面,有一个指令指针寄存器,它里面存放的是下一条指令在内存中的地址。控制单元会不停地将代码段的指令拿进来,先放入指令寄存器。
当前的指令分两部分,一部分是做什么操作,例如是加法还是位移;一部分是操作哪些数据。
要执行这条指令,就要把第一部分交给运算单元,第二部分交给数据单元。
数据单元根据数据的地址,从数据段里读到数据寄存器里,就可以参与运算了。运算单元做完运算,产生的结果会暂存在数据单元的数据寄存器里。最终,会有指令将数据写回内存中的数据段。
你可能会问,上面算来算去执行的都是进程 A 里的指令,那进程 B 呢?CPU 里有两个寄存器,专门保存当前处理进程的代码段的起始地址,以及数据段的起始地址。这里面写的都是进程 A,那当前执行的就是进程 A 的指令,等切换成进程 B,就会执行 B 的指令了,这个过程叫作进程切换(Process Switch)。这是一个多任务系统的必备操作,我们后面有专门的章节讲这个内容,这里你先有个印象。
到这里,你会发现,CPU 和内存来来回回传数据,靠的都是总线。其实总线上主要有两类数据,一个是地址数据,也就是我想拿内存中哪个位置的数据,这类总线叫地址总线(Address Bus);另一类是真正的数据,这类总线叫数据总线(Data Bus)。
所以说,总线其实有点像连接 CPU 和内存这两个设备的高速公路,说总线到底是多少位,就类似说高速公路有几个车道。但是这两种总线的位数意义是不同的。
这章后面的内容就不看了
一旦有了用户进程,公司的运行模式就要发生一定的变化。因为原来你是老板,没有雇佣其他人,所有东西都是你的,无论多么关键的资源,第一,不会有人给你抢,第二,不会有人恶意破坏、恶意使用。
但是现在有了其他人,你就要开始做一定的区分,哪些是核心资源,哪些是非核心资源;办公区也要分开,有普通的项目人员都能访问的项目工作区,还有职业核心人员能够访问的核心保密区。
好在 x86 提供了分层的权限机制,把区域分成了四个 Ring,越往里权限越高,越往外权限越低。
操作系统很好地利用了这个机制,将能够访问关键资源的代码放在 Ring0,我们称为内核态(Kernel Mode);将普通的程序代码放在 Ring3,我们称为用户态(User Mode)。
操作系统的 ring0 到 ring3 和物理内存的 zone 分区感觉很相似,都是核心、设备和普通用户,这两者之间有关系吗?
答案:没关系,但是设计是类似的,都是不同权限对应不同分区,从而提高操作系统的安全和稳定。
这个过程就是这样的:用户态 - 系统调用 - 保存寄存器 - 内核态执行系统调用 - 恢复寄存器 - 返回用户态,然后接着运行。
这章后面讲了下用户态 syscall 一直到返回用户态的具体细节,这里不赘述。细节需要自己看源码,看文档没用。文档只能提供一个大概印象。
这一节,我们讲了内核的初始化过程,主要做了以下几件事情:
各个职能部门的创建;
用户态祖先进程的创建;
内核态祖先进程的创建。
需要注意的是,内核初始化时就会创建内核态和用户态祖先进程。
09 系统调用
这章就是讲了系统调用时的 kernel 源码,相关知识点基本都知道,不记录
10 进程
这章先是写了个 c 语言直接调用 syscall 的代码,后面内容就是把这个 c 代码编译成 ELF 文件。所以其实讲的是编译原理,跳过不看。
11 线程
其实,对于任何一个进程来讲,即便我们没有主动去创建线程,进程也是默认有一个主线程的。线程是负责执行二进制指令的,它会根据项目执行计划书,一行一行执行下去。进程要比线程管的宽多了,除了执行指令之外,内存、文件系统等等都要它来管。
所以,进程相当于一个项目,而线程就是为了完成项目需求,而建立的一个个开发任务。默认情况下,你可以建一个大的任务,就是完成某某功能,然后交给一个人让它从头做到尾,这就是主线程。但是有时候,你发现任务是可以拆解的,如果相关性没有非常大前后关联关系,就可以并行执行。
例如,你接到了一个开发任务,要开发 200 个页面,最后组成一个网站。这时候你就可以拆分成 20 个任务,每个任务 10 个页面,并行开发。都开发完了,再做一次整合,这肯定比依次开发 200 个页面快多了。
那我们能不能成立多个项目组实现并行开发呢?当然可以了,只不过这样做有两个比较麻烦的地方。
第一个麻烦是,立项。涉及的部门比较多,总是劳师动众。你本来想的是,只要能并行执行任务就可以,不需要把会议室都搞成独立的。另一个麻烦是,项目组是独立的,会议室是独立的,很多事情就不受你控制了,例如一旦有了两个项目组,就会有沟通问题。
所以,使用进程实现并行执行的问题也有两个。第一,创建进程占用资源太多;第二,进程之间的通信需要数据在不同的内存空间传来传去,无法共享。
除了希望任务能够并行执行,有的时候,你作为项目管理人员,肯定要管控风险,因此还会预留一部分人作为应急小分队,来处理紧急的事情。
例如,主线程正在一行一行执行二进制命令,突然收到一个通知,要做一点小事情,应该停下主线程来做么?太耽误事情了,应该创建一个单独的线程,单独处理这些事件。
这段和我的想法完全一致,但是他这里说的更具体。
后面写了个 case 来说明 mutex 的使用,这个就很熟了,不用看。
12 进程数据结构
上面的串讲中没有说明 task_struct 每个字段
task_struct 里面涉及任务 ID 的,有下面几个:
pid_t pid;
pid_t tgid;
struct task_struct \*group_leader;
你可能觉得奇怪,既然是 ID,有一个就足以做唯一标识了,这个怎么看起来这么麻烦?这是因为,上面的进程和线程到了内核这里,统一变成了任务,这就带来两个问题。
第一个问题是,任务展示。
啥是任务展示呢?这么说吧,你作为老板,想了解的肯定是,公司都接了哪些项目,每个项目多少营收。什么项目执行是不是分了小组,每个小组是啥情况,这些细节,项目经理没必要全都展示给你看。
前面我们学习命令行的时候,知道 ps 命令可以展示出所有的进程。但是如果你是这个命令的实现者,到了内核,按照上面的任务列表把这些命令都显示出来,把所有的线程全都平摊开来显示给用户。用户肯定觉得既复杂又困惑。复杂在于,列表这么长;困惑在于,里面出现了很多并不是自己创建的线程。
第二个问题是,给任务下发指令。
如果客户突然给项目组提个新的需求,比如说,有的客户觉得项目已经完成,可以终止;再比如说,有的客户觉得项目做到一半没必要再进行下去了,可以中止,这时候应该给谁发指令?当然应该给整个项目组,而不是某个小组。我们不能让客户看到,不同的小组口径不一致。这就好比说,中止项目的指令到达一个小组,这个小组很开心就去休息了,同一个项目组的其他小组还干的热火朝天的。
Linux 也一样,前面我们学习命令行的时候,知道可以通过 kill 来给进程发信号,通知进程退出。如果发给了其中一个线程,我们就不能只退出这个线程,而是应该退出整个进程。当然,有时候,我们希望只给某个线程发信号。
所以在内核中,它们虽然都是任务,但是应该加以区分。其中,pid 是 process id,tgid 是 thread group ID。
任何一个进程,如果只有主线程,那 pid 是自己,tgid 是自己,group_leader 指向的还是自己。
但是,如果一个进程创建了其他线程,那就会有所变化了。线程有自己的 pid,tgid 就是进程的主线程的 pid,group_leader 指向的就是进程的主线程。
好了,有了 tgid,我们就知道 tast_struct 代表的是一个进程还是代表一个线程了。
就是用 tgid 来区分线程和进程的。除此之外,同一个进程下的所有线程的 task_struct 都完全相同。
信号处理
这里既然提到了下发指令的问题,我就顺便提一下 task_struct 里面关于信号处理的字段。
这里定义了哪些信号被阻塞暂不处理(blocked),哪些信号尚等待处理(pending),哪些信号正在通过信号处理函数进行处理(sighand)。处理的结果可以是忽略,可以是结束进程等等。
信号处理函数默认使用用户态的函数栈,当然也可以开辟新的栈专门用于信号处理,这就是 sas_ss_xxx 这三个变量的作用。
上面我说了下发信号的时候,需要区分进程和线程。从这里我们其实也能看出一些端倪。
task_struct 里面有一个 struct sigpending pending。如果我们进入 struct signal_struct \*signal 去看的话,还有一个 struct sigpending shared_pending。它们一个是本任务的,一个是线程组共享的。
关于信号,你暂时了解到这里就够用了,后面我们会有单独的章节进行解读。
/* Signal handlers: */
struct signal_struct *signal;
struct sighand_struct *sighand;
sigset_t blocked;
sigset_t real_blocked;
sigset_t saved_sigmask;
struct sigpending pending;
unsigned long sas_ss_sp;
size_t sas_ss_size;
unsigned int sas_ss_flags;
作为一个项目经理,另外一个需要关注的是项目当前的状态。例如,在 Jira 里面,任务的运行就可以分成下面的状态。
在 task_struct 里面,涉及任务状态的是下面这几个变量:
volatile long state; /* -1 unrunnable, 0 runnable, >0 stopped */
int exit_state;
unsigned int flags;
其中 state 就是 RWSZ 那几个进程状态,这里不多说。
进程调度
进程的状态切换往往涉及调度,下面这些字段都是用于调度的。为了让你理解 task_struct 进程管理的全貌,我先在这里列一下,咱们后面会有单独的章节讲解,这里你只要大概看一下里面的注释就好了。
//是否在运行队列上
int on_rq;
//优先级
int prio;
int static_prio;
int normal_prio;
unsigned int rt_priority;
//调度器类
const struct sched_class *sched_class;
//调度实体
struct sched_entity se;
struct sched_rt_entity rt;
struct sched_dl_entity dl;
//调度策略
unsigned int policy;
//可以使用哪些CPU
int nr_cpus_allowed;
cpumask_t cpus_allowed;
struct sched_info sched_info;
task_struct 是 linux kernel 中非常核心的部分,还是有必要看看这部分内容的源码的。
运行统计信息
作为项目经理,你肯定需要了解项目的运行情况。例如,有的员工很长时间都在做一个任务,这个时候你就需要特别关注一下;再如,有的员工的琐碎任务太多,这会大大影响他的工作效率。
那如何才能知道这些员工的工作情况呢?在进程的运行过程中,会有一些统计量,具体你可以看下面的列表。这里面有进程在用户态和内核态消耗的时间、上下文切换的次数等等。
u64 utime;//用户态消耗的CPU时间
u64 stime;//内核态消耗的CPU时间
unsigned long nvcsw;//自愿(voluntary)上下文切换计数
unsigned long nivcsw;//非自愿(involuntary)上下文切换计数
u64 start_time;//进程启动时间,不包含睡眠时间
u64 real_start_time;/
进程亲缘关系
从我们之前讲的创建进程的过程,可以看出,任何一个进程都有父进程。所以,整个进程其实就是一棵进程树。而拥有同一父进程的所有进程都具有兄弟关系。
struct task_struct __rcu *real_parent; /* real parent process */
struct task_struct __rcu *parent; /* recipient of SIGCHLD, wait4() reports */
struct list_head children; /* list of my children */
struct list_head sibling; /* linkage in my pa
// parent指向其父进程。当它终止时,必须向它的父进程发送信号。
// children表示链表的头部。链表中的所有元素都是它的子进程。
// sibling用于把当前进程插入到兄弟链表中。
进程权限
了解了运行统计信息,接下来,我们需要关注一下项目组权限的控制。什么是项目组权限控制呢?这么说吧,我这个项目组能否访问某个文件,能否访问其他的项目组,以及我这个项目组能否被其他项目组访问等等,这都是项目组权限的控制范畴。
在 Linux 里面,对于进程权限的定义如下:
/* Objective and real subjective task credentials (COW): */
const struct cred __rcu *real_cred;
/* Effective (overridable) subjective task credentials (COW): */
const struct cred __rcu *cred;
内存管理
每个进程都有自己独立的虚拟内存空间,这需要有一个数据结构来表示,就是 mm_struct。这个我们在内存管理那一节详细讲述。这里你先有个印象。
struct mm_struct *mm;
struct mm_struct *active_mm;
文件与文件系统
每个进程有一个文件系统的数据结构,还有一个打开文件的数据结构。这个我们放到文件系统那一节详细讲述。
/_ Filesystem information: _/
struct fs*struct \_fs;
/* Open file information: */
struct files_struct *files;
所以进程拆开了看就是任务 id、亲属关系、任务状态、运行统计、内核栈、进程调度、权限这几个基础的,再加上信号处理、内存管理、文件系统相关的。
15 调度:如何制定项目管理流程?
task_struct 仅仅能够解决“看到”的问题,咱们还要解决如何制定流程,进行项目调度的问题,也就是“做到”的问题。
公司的人员总是有限的。无论接了多少项目,公司不可能短时间增加很多人手。有的项目比较紧急,应该先进行排期;有的项目可以缓缓,但是也不能让客户等太久。所以这个过程非常复杂,需要平衡。
对于操作系统来讲,它面对的 CPU 的数量是有限的,干活儿都是它们,但是进程数目远远超过 CPU 的数目,因而就需要进行进程的调度,有效地分配 CPU 的时间,既要保证进程的最快响应,也要保证进程之间的公平。这也是一个非常复杂的、需要平衡的事情。
---
调度策略与调度类
在 Linux 里面,进程大概可以分成两种。
一种称为实时进程,也就是需要尽快执行返回结果的那种。这就好比我们是一家公司,接到的客户项目需求就会有很多种。有些客户的项目需求比较急,比如一定要在一两个月内完成的这种,客户会加急加钱,那这种客户的优先级就会比较高。
另一种是普通进程,大部分的进程其实都是这种。这就好比,大部分客户的项目都是普通的需求,可以按照正常流程完成,优先级就没实时进程这么高,但是人家肯定也有确定的交付日期。
那很显然,对于这两种进程,我们的调度策略肯定是不同的。
在 task_struct 中,有一个成员变量,我们叫调度策略。
unsigned int policy;
它有以下几个定义:
#define SCHED_NORMAL 0
#define SCHED_FIFO 1
#define SCHED_RR 2
#define SCHED_BATCH 3
#define SCHED_IDLE 5
#define SCHED_DEADLINE 6
配合调度策略的,还有我们刚才说的优先级,也在 task_struct 中。
int prio, static_prio, normal_prio;
unsigned int rt_priority;
优先级其实就是一个数值,对于实时进程,优先级的范围是 0 ~ 99;对于普通进程,优先级的范围是 100 ~ 139。数值越小,优先级越高。从这里可以看出,所有的实时进程都比普通进程优先级要高。毕竟,谁让人家加钱了呢。
实时调度策略
对于调度策略,其中 SCHED_FIFO、SCHED_RR、SCHED_DEADLINE 是实时进程的调度策略。
虽然大家都是加钱加急的项目,但是也不能乱来,还是需要有个办事流程才行。
例如,SCHED_FIFO 就是交了相同钱的,先来先服务,但是有的加钱多,可以分配更高的优先级,也就是说,高优先级的进程可以抢占低优先级的进程,而相同优先级的进程,我们遵循先来先得。
另外一种策略是,交了相同钱的,轮换着来,这就是 SCHED_RR 轮流调度算法,采用时间片,相同优先级的任务当用完时间片会被放到队列尾部,以保证公平性,而高优先级的任务也是可以抢占低优先级的任务。
还有一种新的策略是 SCHED_DEADLINE,是按照任务的 deadline 进行调度的。当产生一个调度点的时候,DL 调度器总是选择其 deadline 距离当前时间点最近的那个任务,并调度它执行。
---
普通调度策略
对于普通进程的调度策略有,SCHED_NORMAL、SCHED_BATCH、SCHED_IDLE。
既然大家的项目都没有那么紧急,就应该按照普通的项目流程,公平地分配人员。
SCHED_NORMAL 是普通的进程,就相当于咱们公司接的普通项目。
SCHED_BATCH 是后台进程,几乎不需要和前端进行交互。这有点像公司在接项目同时,开发一些可以复用的模块,作为公司的技术积累,从而使得在之后接新项目的时候,能够减少工作量。这类项目可以默默执行,不要影响需要交互的进程,可以降低它的优先级。
SCHED_IDLE 是特别空闲的时候才跑的进程,相当于咱们学习训练类的项目,比如咱们公司很长时间没有接到外在项目了,可以弄几个这样的项目练练手。
上面无论是 policy 还是 priority,都设置了一个变量,变量仅仅表示了应该这样这样干,但事情总要有人去干,谁呢?在 task_struct 里面,还有这样的成员变量:
const struct sched_class \*sched_class;
调度策略的执行逻辑,就封装在这里面,它是真正干活的那个。
sched_class 有几种实现:
stop_sched_class 优先级最高的任务会使用这种策略,会中断所有其他线程,且不会被其他任务打断;
dl_sched_class 就对应上面的 deadline 调度策略;
rt_sched_class 就对应 RR 算法或者 FIFO 算法的调度策略,具体调度策略由进程的 task_struct->policy 指定;
fair_sched_class 就是普通进程的调度策略;
idle_sched_class 就是空闲进程的调度策略。
这里实时进程的调度策略 RR 和 FIFO 相对简单一些,而且由于咱们平时常遇到的都是普通进程,在这里,咱们就重点分析普通进程的调度问题。普通进程使用的调度策略是 fair_sched_class,顾名思义,对于普通进程来讲,公平是最重要的。
完全公平调度算法
在 Linux 里面,实现了一个基于 CFS 的调度算法。CFS 全称 Completely Fair Scheduling,叫完全公平调度。听起来很“公平”。那这个算法的原理是什么呢?我们来看看。
首先,你需要记录下进程的运行时间。CPU 会提供一个时钟,过一段时间就触发一个时钟中断。就像咱们的表滴答一下,这个我们叫 Tick。CFS 会为每一个进程安排一个虚拟运行时间 vruntime。如果一个进程在运行,随着时间的增长,也就是一个个 tick 的到来,进程的 vruntime 将不断增大。没有得到执行的进程 vruntime 不变。
显然,那些 vruntime 少的,原来受到了不公平的对待,需要给它补上,所以会优先运行这样的进程。
这有点像让你把一筐球平均分到 N 个口袋里面,你看着哪个少,就多放一些;哪个多了,就先不放。这样经过多轮,虽然不能保证球完全一样多,但是也差不多公平。
你可能会说,不还有优先级呢?如何给优先级高的进程多分时间呢?
这个简单,就相当于 N 个口袋,优先级高的袋子大,优先级低的袋子小。这样球就不能按照个数分配了,要按照比例来,大口袋的放了一半和小口袋放了一半,里面的球数目虽然差很多,也认为是公平的。
...
这就是说,同样的实际运行时间,给高权重的算少了,低权重的算多了,但是当选取下一个运行进程的时候,还是按照最小的 vruntime 来的,这样高权重的获得的实际运行时间自然就多了。这就相当于给一个体重 (权重)200 斤的胖子吃两个馒头,和给一个体重 100 斤的瘦子吃一个馒头,然后说,你们两个吃的是一样多。这样虽然总体胖子比瘦子多吃了一倍,但是还是公平的。
CFS 红黑树
Completely Fair Scheduler - Wikipedia
所有可运行的进程通过不断地插入操作最终都存储在以时间为顺序的红黑树中,vruntime 最小的在树的左侧,vruntime 最多的在树的右侧。CFS 调度策略会选择红黑树最左边的叶子节点作为下一个将获得 CPU 的任务。
这棵红黑树放在哪里呢?就像每个软件工程师写代码的时候,会将任务排成队列,做完一个做下一个。
CPU 也是这样的,每个 CPU 都有自己的 struct rq 结构,其用于描述在此 CPU 上所运行的所有进程,其包括一个实时进程队列 rt_rq 和一个 CFS 运行队列 cfs_rq,在调度时,调度器首先会先去实时进程队列找是否有实时进程需要运行,如果没有才会去 CFS 运行队列找是否有进程需要运行。
这样整个运行的场景就串起来了,在每个 CPU 上都有一个队列 rq,这个队列里面包含多个子队列,例如 rt_rq 和 cfs_rq,不同的队列有不同的实现方式,cfs_rq 就是用红黑树实现的。
当有一天,某个 CPU 需要找下一个任务执行的时候,会按照优先级依次调用调度类,不同的调度类操作不同的队列。当然 rt_sched_class 先被调用,它会在 rt_rq 上找下一个任务,只有找不到的时候,才轮到 fair_sched_class 被调用,它会在 cfs_rq 上找下一个任务。这样保证了实时任务的优先级永远大于普通任务。
下面我们仔细看一下 sched_class 定义的与调度有关的函数。
enqueue_task 向就绪队列中添加一个进程,当某个进程进入可运行状态时,调用这个函数;
dequeue_task 将一个进程从就绪队列中删除;
pick_next_task 选择接下来要运行的进程;
put_prev_task 用另一个进程代替当前运行的进程;
set_curr_task 用于修改调度策略;
task_tick 每次周期性时钟到的时候,这个函数被调用,可能触发调度。
---
在调度的时候,依次调用调度类的函数,从 CPU 的队列中取出下一个进程。上面图中的调度器、上下文切换这一节我们没有讲,下一节我们讲讲基于这些数据结构,如何实现调度。
btrfs
主动调度
我们这一节先来看方式一,主动调度。
这里我找了几个代码片段。第一个片段是 Btrfs,等待一个写入。Btrfs(B-Tree)是一种文件系统,感兴趣你可以自己去了解一下。
这个片段可以看作写入块设备的一个典型场景。写入需要一段时间,这段时间用不上 CPU,还不如主动让给其他进程。
另外一个例子是,从 Tap 网络设备等待一个读取。Tap 网络设备是虚拟机使用的网络设备。当没有数据到来的时候,它也需要等待,所以也会选择把 CPU 让给其他进程。
你应该知道,计算机主要处理计算、网络、存储三个方面。计算主要是 CPU 和内存的合作;网络和存储则多是和外部设备的合作;在操作外部设备的时候,往往需要让出 CPU,就像上面两段代码一样,选择调用 schedule() 函数。
curr
task_of 得到下一个调度实体对应的 task_struct,如果发现继任和前任不一样,这就说明有一个更需要运行的进程了,就需要更新红黑树了。前面前任的 vruntime 更新过了,put_prev_entity 放回红黑树,会找到相应的位置,然后 set_next_entity 将继任者设为当前任务。
第三步,当选出的继任者和前任不同,就要进行上下文切换,继任者进程正式进入运行。
进程调度后,如果前后进程不同(也就是继任者和前任不同),就需要进程上下文切换了。
进程上下文切换
上下文切换主要干两件事情,一是切换进程空间,也即虚拟内存;二是切换寄存器和 CPU 上下文。
这里面有一个 Per CPU 的结构体 tss。这是个什么呢?
在 x86 体系结构中,提供了一种以硬件的方式进行进程切换的模式,对于每个进程,x86 希望在内存里面维护一个 TSS(Task State Segment,任务状态段)结构。这里面有所有的寄存器。
另外,还有一个特殊的寄存器 TR(Task Register,任务寄存器),指向某个进程的 TSS。更改 TR 的值,将会触发硬件保存 CPU 所有寄存器的值到当前进程的 TSS 中,然后从新进程的 TSS 中读出所有寄存器值,加载到 CPU 对应的寄存器中。
“将会触发硬件保存 CPU 所有寄存器的值到当前进程的 TSS 中”是啥意思?
这段有点难懂,我们可以把 TSS 当成办公室的铁皮柜,寄存器当成是文件夹,TR 寄存器是存所有项目和铁皮柜的映射关系的文件夹。这样就很好理解了,我们切换项目时,就要修改映射关系(TR 寄存器),把手头所有工作资料往铁皮柜里存一份,再从新项目的铁皮柜里读取新项目的所有工作资料。
但是这样有个缺点。我们做进程切换的时候,没必要每个寄存器都切换,这样每个进程一个 TSS,就需要全量保存,全量切换,动作太大了。
于是,Linux 操作系统想了一个办法。还记得在系统初始化的时候,会调用 cpu_init 吗?这里面会给每一个 CPU 关联一个 TSS,然后将 TR 指向这个 TSS,然后在操作系统的运行过程中,TR 就不切换了,永远指向这个 TSS。TSS 用数据结构 tss_struct 表示,在 x86_hw_tss 中可以看到和上图相应的结构。
确实,我看上面那段的时候就有这样的疑惑。
相当于给每个工人(CPU)配备了一个铁皮柜(TSS),然后(进程和 TSS)映射关系文件夹直接指向这个工人自有的铁皮柜。相当于是 1:1:1 的映射关系?
在 Linux 中,真的参与进程切换的寄存器很少,主要的就是栈顶寄存器。
于是,在 task_struct 里面,还有一个我们原来没有注意的成员变量 thread。这里面保留了要切换进程的时候需要修改的寄存器。
/_ CPU-specific state of this task: _/
struct thread_struct thread;
所谓的进程切换,就是将某个进程的 thread_struct 里面的寄存器的值,写入到 CPU 的 TR 指向的 tss_struct,对于 CPU 来讲,这就算是完成了切换。
例如\_\_switch_to 中的 load_sp0,就是将下一个进程的 thread_struct 的 sp0 的值加载到 tss_struct 里面去。
有点懂了
这部分还是有点太细节了,都是源码解析。知道大概过程就可以了。
进程切换需要搞明白:我从哪里来,我要到哪里去
这一节我们讲主动调度的过程,也即一个运行中的进程主动调用**schedule 让出 CPU。在**schedule 里面会做两件事情,第一是选取下一个进程,第二是进行上下文切换。而上下文切换又分用户态进程空间的切换和内核态的切换。
被动调度:抢占式调度
上一节,我们讲了主动调度,就是进程运行到一半,因为等待 I/O 等操作而主动让出 CPU,然后就进入了我们的“进程调度第一定律”。所有进程的调用最终都会走\_\_schedule 函数。那这个定律在这一节还是要继续起作用。
抢占式调度
上一节我们讲的主动调度是第一种方式,第二种方式,就是抢占式调度。什么情况下会发生抢占呢?
最常见的现象就是一个进程执行时间太长了,是时候切换到另一个进程了。那怎么衡量一个进程的运行时间呢?在计算机里面有一个时钟,会过一段时间触发一次时钟中断,通知操作系统,时间又过去一个时钟周期,这是个很好的方式,可以查看是否是需要抢占的时间点。
时钟中断处理函数会调用 scheduler_tick(),它的代码如下:
需要注意的是,这里的主动和被动。正如上文,主动调度是进程(因为等待 IO 等耗时操作而)主动让出 CPU,让其他进程执行。被动调度是(占据 CPU 时间太长了而触发时钟中断)被动让出 CPU,也就是所谓的“抢占式”。
另外,可以看到,进程、线程、协程都有主动调度和被动调度,并且其策略都是类似的。
被动调度的细节,这里不记录,直接写总结。
进程被动调度时,不会直接抢占,而是先标记某个进程为可抢占的(几种情况,进程阻塞、进程时间片用完、优先级更高的进程就绪。)。整整的抢占还需要时机,也就是需要那么一个时刻,让正在运行中的进程有机会调用一下 _schedule。这个时机分为用户态和内核态。
总结时刻
好了,抢占式调度就讲到这里了。我这里画了一张脑图,将整个进程的调度体系都放在里面。
这个脑图里面第一条就是总结了进程调度第一定律的核心函数\_\_schedule 的执行过程,这是上一节讲的,因为要切换的东西比较多,需要你详细了解每一部分是如何切换的。
第二条总结了标记为可抢占的场景,第三条是所有的抢占发生的时机,这里是真正验证了进程调度第一定律的。
18 进程的创建:如何发起一个新项目?
有了新项目,很简单,拆成两部分嘛。首先把原项目的什么工作资料、人员权限什么的在后台先复制一份出来(syscall_fork 执行复制 task_struct 操作,创建内核栈,再执行 copy_process,其中包括设置权限、复制 fs、复制 files、复制 signal 等具体操作,不多说)。然后就要给这个新项目分配员工(唤醒新创建的子进程)
总结时刻
好了,fork 系统调用的过程咱们就解析完了。它包含两个重要的事件,一个是将 task_struct 结构复制一份并且初始化,另一个是试图唤醒新创建的子进程。
这个过程我画了一张图,你可以对照着这张图回顾进程创建的过程。
这个图的上半部分是复制 task_struct 结构,你可以对照着右面的 task_struct 结构图,看这里面的成员是如何一部分一部分地被复制的。图的下半部分是唤醒新创建的子进程,如果条件满足,就会将当前进程设置应该被调度的标识位,就等着当前进程执行\_\_schedule 了。
这部分内容全是源码解析,自己去看源码,看文档意义不大。
19-线程的创建:如何执行一个新子项目?
上一节,我们了解了进程创建的整个过程,今天我们来看线程创建的过程。
我们前面已经写过多线程编程的程序了,你应该都知道创建一个线程调用的是 pthread_create,可你知道它背后的机制吗?
用户态创建线程
你可能会问,咱们之前不是讲过了吗?无论是进程还是线程,在内核里面都是任务,管起来不是都一样吗?但是问题来了,如果两个完全一样,那为什么咱们前两节写的程序差别那么大?如果不一样,那怎么在内核里面加以区分呢?
其实,线程不是一个完全由内核实现的机制,它是由内核态和用户态合作完成的。pthread_create 不是一个系统调用,是 Glibc 库的一个函数,所以我们还要去 Glibc 里面去找线索。
果然,我们在 nptl/pthread_create.c 里面找到了这个函数。这里的参数我们应该比较熟悉了。
线程不是一个完全由内核实现的机制,它是由内核态和用户态合作完成的
pthread_create 也不是 syscall,而是 glibc 自己实现的(PS. 这么说的话,syscall 不是秘书,更像是老板列出的指令清单,公司里非核心管理层的项目组(用户空间的进程)按照在清单上的指令发送请求,公司高管可以遵照执行。glibc 才更像是秘书,他把清单上的这些个指令封装了一下,提供更易用的调用方法。)
用户态创建线程,用户态栈
用户态创建任务
搞定了用户态栈的问题,其实用户态的事情基本搞定了一半。
内核态创建任务
接下来,我们接着 pthread_create 看。其实有了用户态的栈,接着需要解决的就是用户态的程序从哪里开始运行的问题。
我们能看到最后调用了 syscall,这一点 clone 和我们原来熟悉的其他系统调用几乎是一致的。但是,也有少许不一样的地方。
如果在进程的主线程里面调用其他系统调用,当前用户态的栈是指向整个进程的栈,栈顶指针也是指向进程的栈,指令指针也是指向进程的主线程的代码。此时此刻执行到这里,调用 clone 的时候,用户态的栈、栈顶指针、指令指针和其他系统调用一样,都是指向主线程的。
但是对于线程来说,这些都要变。因为我们希望当 clone 这个系统调用成功的时候,除了内核里面有这个线程对应的 task_struct,当系统调用返回到用户态的时候,用户态的栈应该是线程的栈,栈顶指针应该指向线程的栈,指令指针应该指向线程将要执行的那个函数。
所以这些都需要我们自己做,将线程要执行的函数的参数和指令的位置都压到栈里面,当从内核返回,从栈里弹出来的时候,就从这个函数开始,带着这些参数执行下去。
第三,对于信号的处理,如何保证发给进程的信号虽然可以被一个线程处理,但是影响范围应该是整个进程的。例如,kill 一个进程,则所有线程都要被干掉。如果一个信号是发给一个线程的 pthread_kill,则应该只有线程能够收到。
在 copy_process 的主流程里面,无论是创建进程还是线程,都会初始化 struct sigpending pending,也就是每个 task_struct,都会有这样一个成员变量。这就是一个信号列表。如果这个 task_struct 是一个线程,这里面的信号就是发给这个线程的;如果这个 task_struct 是一个进程,这里面的信号是发给主线程的。
init_sigpending(&p->pending);
另外,上面 copy_signal 的时候,我们可以看到,在创建进程的过程中,会初始化 signal_struct 里面的 struct sigpending shared_pending。但是,在创建线程的过程中,连 signal_struct 都共享了。也就是说,整个进程里的所有线程共享一个 shared_pending,这也是一个信号列表,是发给整个进程的,哪个线程处理都一样。
init_sigpending(&sig->shared_pending);
至此,clone 在内核的调用完毕,要返回系统调用,回到用户态。
总结时刻
线程的调用过程解析完毕了,我画了一个图总结一下。这个图对比了创建进程和创建线程在用户态和内核态的不同。
创建进程的话,调用的系统调用是 fork,在 copy_process 函数里面,会将五大结构 files_struct、fs_struct、sighand_struct、signal_struct、mm_struct 都复制一遍,从此父进程和子进程各用各的数据结构。而创建线程的话,调用的是系统调用 clone,在 copy_process 函数里面,五大结构仅仅是引用计数加一,也即线程共享进程的数据结构。
20-内存管理(上):为客户保密,规划进程内存空间布局
这部分之前看过了,简要记录
这个程序比较简单,就是根据用户输入的整数来生成字符串,最长是 128。由于字符串的长度不是固定的,因而不能提前知道,需要动态地分配内存,使用 malloc 函数。当然用完了需要释放内存,这就要使用 free 函数。
我们来总结一下,这个简单的程序在使用内存时的几种方式:
- 代码需要放在内存里面;
- 全局变量,例如 max_length;
- 常量字符串"Input the string length : ";
- 函数栈,例如局部变量 num 是作为参数传给 generate 函数的,这里面涉及了函数调用,局部变量,函数参数等都是保存在函数栈上面的;
- 堆,malloc 分配的内存在堆里面;
- 这里面涉及对 glibc 的调用,所以 glibc 的代码是以 so 文件的形式存在的,也需要放在内存里面。
这就完了吗?还没有呢,别忘了 malloc 会调用系统调用,进入内核,所以这个程序一旦运行起来,内核部分还需要分配内存:
- 内核的代码要在内存里面;
- 内核中也有全局变量;
- 每个进程都要有一个 task_struct;
- 每个进程还有一个内核栈;
- 在内核里面也有动态分配的内存;
- 虚拟地址到物理地址的映射表放在哪里?
竟然收集了这么多的需求,看来做个内存管理还是挺复杂的啊!
我们现在来问一下自己,上面的这些内存里面的数据,应该用虚拟地址访问呢?还是应该用物理地址访问呢?
你可能会说,这很简单嘛。用户态的用虚拟地址访问,内核态的用物理地址访问。其实不是的。你有没有想过,内核里面的代码如果都使用物理地址,就相当于公司里的项目管理部门、文档管理部门都可以直接使用实际的地址访问会议室,这对于会议室管理部门来讲,简直是一个“灾难”。因为一旦到了内核,大家对于会议室的访问都脱离了会议室管理部门的控制。
所以,我们应该清楚一件事情,真正能够使用会议室的物理地址的,只有会议室管理部门,所有其他部门的行为涉及访问会议室的,都要统统使用虚拟地址,统统到会议室管理部门那里转换一道,才能进行统一的控制。
我上面列举出来的,对于内存的访问,用户态的进程使用虚拟地址,这点毫无疑问,内核态的也基本都是使用虚拟地址,只有最后一项容易让人产生疑问。虚拟地址到物理地址的映射表,这个感觉起来是内存管理模块的一部分,这个是“实”是“虚”呢?这个问题先保留,我们暂不讨论,放到内存映射那一节见分晓。
既然都是虚拟地址,我们就先不管映射到物理地址以后是如何布局的,反正现在至少从“虚”的角度来看,这一大片连续的内存空间都是我的了。
内核态也是使用虚拟内存,虽然内核态能够通过映射表看到物理内存,但是他还是使用虚拟内存。这点也是我的一个认知偏差。
首先,这么大的虚拟空间一切二,一部分用来放内核的东西,称为内核空间,一部分用来放进程的东西,称为用户空间。用户空间在下,在低地址,我们假设就是 0 号到 29 号会议室;内核空间在上,在高地址,我们假设是 30 号到 39 号会议室。这两部分空间的分界线因为 32 位和 64 位的不同而不同,我们这里不深究。
想法:内核空间我们很明确了,但是对用户空间里到底存了什么,有点拿不准。现在来看,用户空间就是用来放进程的。
Text Segment
Data Segment
BSS Segment
总结时刻
好了,这一节就到这里了,我们来总结一下。这一节我们讲了为什么要独享内存空间,并且站在老板的角度,设计了虚拟地址空间应该存放的数据。
通过这一节,你应该知道,一个内存管理系统至少应该做三件事情:
第一,虚拟内存空间的管理,每个进程看到的是独立的、互不干扰的虚拟地址空间;
第二,物理内存的管理,物理内存地址只有内存管理模块能够使用;
第三,内存映射,需要将虚拟内存和物理内存映射、关联起来。
这一节我们讲了进程内存空间的布局,请找一下,有没有一个命令可以查看进程内存空间的布局,打印出来看一下,这对我们后面解析非常有帮助。
使用 pmap 命令来查看进程内存空间
➜ ~ pmap 19111
19111: /usr/bin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 13306 -container-ip 177.7.0.13 -container-port 3306
000000c000000000 65536K rw--- [ anon ]
0000559092124000 1528K r-x-- docker-proxy
00005590924a2000 740K r---- docker-proxy
000055909255b000 104K rw--- docker-proxy
0000559092575000 204K rw--- [ anon ]
...
21-内存管理(下):为客户保密,项目组独享会议室封闭开发
总结时刻
这一节我们讲了分段机制、分页机制以及从虚拟地址到物理地址的映射方式。总结一下这两节,我们可以把内存管理系统精细化为下面三件事情:
第一,虚拟内存空间的管理,将虚拟内存分成大小相等的页;
第二,物理内存的管理,将物理内存分成大小相等的页;
第三,内存映射,将虚拟内存页和物理内存页映射起来,并且在内存紧张的时候可以换出到硬盘中。
22-进程空间管理:项目组还可以自行布置会议室
上两节,我们讲了内存管理的三个方面,虚拟内存空间的管理、物理内存的管理以及内存映射。你现在对进程内存空间的整体布局应该有了一个大致的了解。今天我们就来详细看看第一个方面,进程的虚拟内存空间是如何管理的。
我们之前讲过,整个虚拟内存空间要一分为二,一部分是用户态地址空间,一部分是内核态地址空间,那这两部分的分界线在哪里呢?这就要 task_size 来定义。
task-size
这章就不记录了,内容很多,也看不太懂。
这里的 mm_struct 的两个字段就是单向链表 + 红黑树
struct vm_area_struct *mmap; /* list of VMAs */
struct rb_root mm_rb;
这里面一个是单链表,用于将这些区域串起来。另外还有一个红黑树。又是这个数据结构,在进程调度的时候我们用的也是红黑树。它的好处就是查找和修改都很快。这里用红黑树,就是为了快速查找一个内存区域,并在需要改变的时候,能够快速修改。
我发现 linux kernel 中非常高频地使用了红黑树,请举例一些 linux kernel 中使用红黑树的例子,并且解释为什么要使用红黑树而不是 trie 树呢?
---
在 Linux 内核中,红黑树是一种常见的数据结构,用于高效地管理和操作一些重要的数据。下面是一些 Linux 内核中使用红黑树的例子:
1. 进程调度:Linux 内核使用红黑树来管理进程的调度。每个进程都有一个调度实体 (struct sched_entity) 来表示其调度状态,并将这些调度实体按照一定的规则插入到红黑树中。红黑树的特点使得内核可以快速地根据进程的调度优先级和其他参数来选择下一个要执行的进程。
2. 定时器管理:Linux 内核使用红黑树来管理定时器。每个定时器都有一个定时器结构 (struct timer_list),内核将这些定时器按照到期时间插入到红黑树中。红黑树的特性使得内核可以高效地找到下一个即将到期的定时器,并进行相应的操作。
3. 文件系统:Linux 内核中的一些文件系统,如 EXT4 和 Btrfs,使用红黑树来实现文件和目录的索引。红黑树的平衡性质使得文件系统能够快速地进行文件查找和插入操作。
为什么在这些情况下选择红黑树而不是 trie 树呢?红黑树具有平衡性质,即使在最坏情况下,它的插入、删除和查找操作的时间复杂度也是 O(log n),其中 n 是树中节点的数量。而 trie 树在某些情况下可能会导致树的高度非常大,从而增加了操作的时间复杂度。另外,红黑树相对于 trie 树来说,实现和维护起来更加简单。综合考虑,红黑树在这些场景下更适合使用。
---
红黑树比 trie 树更不好维护吧
---
红黑树和 trie 树是两种不同的数据结构,各有其优缺点。红黑树是一种自平衡的二叉搜索树,它具有良好的平衡性能,适用于需要频繁插入和删除操作的场景。而 trie 树则是一种用于高效存储和检索字符串的数据结构,它可以非常快速地进行前缀匹配等操作。
关于维护方面,红黑树相对来说比较容易维护,因为它的自平衡性质可以保证树的高度保持在一个相对较小的范围内。而 trie 树在插入和删除操作时可能需要重新调整指针和节点的关系,相对来说稍微复杂一些。
总的来说,红黑树和 trie 树在不同的应用场景下有不同的优势和劣势,选择哪种数据结构要根据具体的需求来决定。
总结时刻
还记得咱们上一节咱们收集项目组需求的时候,我们知道一个进程要运行起来需要以下的内存结构。
用户态:
- 代码段、全局变量、BSS
- 函数栈
- 堆
- 内存映射区
内核态:
- 内核的代码、全局变量、BSS
- 内核数据结构例如 task_struct
- 内核栈
- 内核中动态分配的内存
现在这些是不是已经都有了着落?
23-物理内存管理(上):会议室管理员如何分配会议室?
看到这章才发现,原来 NUMA、DMA、HIGHMEM 这些都属于物理内存管理。之前真是没学明白。
为了提高性能和可扩展性,后来有了一种更高级的模式,NUMA(Non-uniform memory access),非一致内存访问。在这种模式下,内存不是一整块。每个 CPU 都有自己的本地内存,CPU 访问本地内存不用过总线,因而速度要快很多,每个 CPU 和内存在一起,称为一个 NUMA 节点。但是,在本地内存不足的情况下,每个 CPU 都可以去另外的 NUMA 节点申请内存,这个时候访问延时就会比较长。
这样,内存被分成了多个节点,每个节点再被分成一个一个的页面。由于页需要全局唯一定位,页还是需要有全局唯一的页号的。但是由于物理内存不是连起来的了,页号也就不再连续了。于是内存模型就变成了非连续内存模型,管理起来就复杂一些。
这里需要指出的是,NUMA 往往是非连续内存模型。而非连续内存模型不一定就是 NUMA,有时候一大片内存的情况下,也会有物理内存地址不连续的情况。
后来内存技术牛了,可以支持热插拔了。这个时候,不连续成为常态,于是就有了稀疏内存模型。
// NUMA节点
// 每一个节点都有自己的ID:node_id;
// node_mem_map就是这个节点的struct page数组,用于描述这个节点里面的所有的页;
// node_start_pfn是这个节点的起始页号;
// node_spanned_pages是这个节点中包含不连续的物理内存地址的页面数;
// node_present_pages是真正可用的物理页面的数目。
typedef struct pglist_data {
struct zone node_zones[MAX_NR_ZONES];
struct zonelist node_zonelists[MAX_ZONELISTS];
int nr_zones;
struct page *node_mem_map;
unsigned long node_start_pfn;
unsigned long node_present_pages; /* total number of physical pages */
unsigned long node_spanned_pages; /* total size of physical page range, including holes */
int node_id;
......
} pg_data_t;
enum zone_type {
#ifdef CONFIG_ZONE_DMA
ZONE_DMA,
#endif
#ifdef CONFIG_ZONE_DMA32
ZONE_DMA32,
#endif
ZONE_NORMAL,
#ifdef CONFIG_HIGHMEM
ZONE_HIGHMEM,
#endif
ZONE_MOVABLE,
__MAX_NR_ZONES
};
这几个 zone_type 都是老生常谈了,HIGHMEM 和 MOVABLE 没必要关注,只看 DMA 和 NORMAL
ZONE_DMA 是指可用于作 DMA(Direct Memory Access,直接内存存取)的内存。DMA 是这样一种机制:要把外设的数据读入内存或把内存的数据传送到外设,原来都要通过 CPU 控制完成,但是这会占用 CPU,影响 CPU 处理其他事情,所以有了 DMA 模式。CPU 只需向 DMA 控制器下达指令,让 DMA 控制器来处理数据的传送,数据传送完毕再把信息反馈给 CPU,这样就可以解放 CPU。
对于 64 位系统,有两个 DMA 区域。除了上面说的 ZONE_DMA,还有 ZONE_DMA32。在这里你大概理解 DMA 的原理就可以,不必纠结,我们后面会讲 DMA 的机制。
ZONE_NORMAL 是直接映射区,就是上一节讲的,从物理内存到虚拟内存的内核区域,通过加上一个常量直接映射。
物理内存就是这么分成节点,再分成以上区域的。
linux kernel 物理内存空洞是啥?
---
Linux 内核中的物理内存空洞是指在物理内存地址空间中存在的不连续的空闲内存区域。这些空洞通常是由于内存映射和分配的不规则性导致的。物理内存空洞可能出现在内核的不同部分,例如内核代码、内核数据结构、设备驱动程序等。
物理内存空洞可能会对系统的内存管理和分配产生一些影响。当物理内存空洞较大时,系统可能需要更多的内存来满足内存需求,这可能会导致内存不足的情况。此外,物理内存空洞也可能会增加内存分配和管理的复杂性。
为了解决物理内存空洞的问题,Linux 内核使用了一些内存管理技术。其中包括内存碎片整理、内存压缩、内存合并等。这些技术可以帮助系统更好地管理和利用物理内存空间,提高内存的利用率和性能。
希望这个回答对你有帮助。如果你还有其他问题,请随时提问。
---
如果我们把物理内存理解为公司的工位,能否把空洞理解为没有坐人的空位?
---
可以这样理解。物理内存可以被比作公司的工位,而空洞可以被理解为没有坐人的空位。就像公司的员工可以占据工位一样,进程可以占据物理内存。当一个进程被调度到物理内存中时,它会占据一个或多个连续的内存块,这些内存块就像公司的工位一样。如果有一些内存块没有被任何进程占据,那么它们就可以被称为空洞,就像公司的空位一样。
按照这个理解,spanned_pages 就是工位总数,不管是不是空位。而 present_pages 和 managed_pages 分别可以类比为实际有人坐的工位,和管理(包括分配和回收)的工位数。
managed_pages 是这个 zone 被伙伴系统管理的所有的 page 数目,伙伴系统的工作机制我们后面会讲。
per_cpu_pageset 用于区分冷热页。什么叫冷热页呢?咱们讲 x86 体系结构的时候讲过,为了让 CPU 快速访问段描述符,在 CPU 里面有段描述符缓存。CPU 访问这个缓存的速度比内存快得多。同样对于页面来讲,也是这样的。如果一个页被加载到 CPU 高速缓存里面,这就是一个热页(Hot Page),CPU 读起来速度会快很多,如果没有就是冷页(Cold Page)。由于每个 CPU 都有自己的高速缓存,因而 per_cpu_pageset 也是每个 CPU 一个。
页
了解了区域 zone,接下来我们就到了组成物理内存的基本单位,页的数据结构 struct page。这是一个特别复杂的结构,里面有很多的 union,union 结构是在 C 语言中被用于同一块内存根据情况保存不同类型数据的一种方式。这里之所以用了 union,是因为一个物理页面使用模式有多种。
第一种模式,要用就用一整页。这一整页的内存,或者直接和虚拟地址空间建立映射关系,我们把这种称为匿名页(Anonymous Page)。或者用于关联一个文件,然后再和虚拟地址空间建立映射关系,这样的文件,我们称为内存映射文件(Memory-mapped File)。
如果某一页是这种使用模式,则会使用 union 中的以下变量:
- struct address_space \*mapping 就是用于内存映射,如果是匿名页,最低位为 1;如果是映射文件,最低位为 0;
- pgoff_t index 是在映射区的偏移量;
- atomic_t \_mapcount,每个进程都有自己的页表,这里指有多少个页表项指向了这个页;
- struct list_head lru 表示这一页应该在一个链表上,例如这个页面被换出,就在换出页的链表中;
- compound 相关的变量用于复合页(Compound Page),就是将物理上连续的两个或多个页看成一个独立的大页。
第二种模式,仅需分配小块内存。有时候,我们不需要一下子分配这么多的内存,例如分配一个 task_struct 结构,只需要分配小块的内存,去存储这个进程描述结构的对象。为了满足对这种小内存块的需要,Linux 系统采用了一种被称为 slab allocator 的技术,用于分配称为 slab 的一小块内存。它的基本原理是从内存管理模块申请一整块页,然后划分成多个小块的存储池,用复杂的队列来维护这些小块的状态(状态包括:被分配了/被放回池子/应该被回收)。
也正是因为 slab allocator 对于队列的维护过于复杂,后来就有了一种不使用队列的分配器 slub allocator,后面我们会解析这个分配器。但是你会发现,它里面还是用了很多 slab 的字眼,因为它保留了 slab 的用户接口,可以看成 slab allocator 的另一种实现。
还有一种小块内存的分配器称为 slob,非常简单,主要使用在小型的嵌入式系统。
页的分配
好了,前面我们讲了物理内存的组织,从节点到区域到页到小块。接下来,我们来看物理内存的分配。
对于要分配比较大的内存,例如到分配页级别的,可以使用伙伴系统(Buddy System)。
Linux 中的内存管理的“页”大小为 4KB。把所有的空闲页分组为 11 个页块链表,每个块链表分别包含很多个大小的页块,有 1、2、4、8、16、32、64、128、256、512 和 1024 个连续页的页块。最大可以申请 1024 个连续页,对应 4MB 大小的连续内存。每个页块的第一个页的物理地址是该页块大小的整数倍。
当向内核请求分配 (2^(i-1),2^i] 数目的页块时,按照 2^i 页块请求处理。如果对应的页块链表中没有空闲页块,那我们就在更大的页块链表中去找。当分配的页块中有多余的页时,伙伴系统会根据多余的页块大小插入到对应的空闲页块链表中。
例如,要请求一个 128 个页的页块时,先检查 128 个页的页块链表是否有空闲块。如果没有,则查 256 个页的页块链表;如果有空闲块的话,则将 256 个页的页块分成两份,一份使用,一份插入 128 个页的页块链表中。如果还是没有,就查 512 个页的页块链表;如果有的话,就分裂为 128、128、256 三个页块,一个 128 的使用,剩余两个插入对应页块链表。
上面这个过程,我们可以在分配页的函数 alloc_pages 中看到。
...
每一个 zone,都有伙伴系统维护的各种大小的队列,就像上面伙伴系统原理里讲的那样。这里调用 rmqueue 就很好理解了,就是找到合适大小的那个队列,把页面取下来。
接下来的调用链是 rmqueue->**rmqueue->**rmqueue_smallest。在这里,我们能清楚看到伙伴系统的逻辑。
总结时刻
对于物理内存的管理的讲解,到这里要告一段落了。这一节我们主要讲了物理内存的组织形式,就像下面图中展示的一样。
如果有多个 CPU,那就有多个节点。每个节点用 struct pglist_data 表示,放在一个数组里面。
每个节点分为多个区域,每个区域用 struct zone 表示,也放在一个数组里面。
每个区域分为多个页。为了方便分配,空闲页放在 struct free_area 里面,使用伙伴系统进行管理和分配,每一页用 struct page 表示。
之所以称为“伙伴系统”,因为他 2^的思路,也就是分治。一整个会议室,划分为两个 1/2,再划分为四个 1/4,两个 1/4 就是伙伴呀。
伙伴系统是一种非常精妙的实现方式,无论你使用什么语言,请自己实现一个这样的分配系统,说不定哪天你在做某个系统的时候,就用到了。
自己手写一个伙伴系统
24 物理内存管理(下)
这部分就太细节了,暂时不看
但是又特别重要,什么 swap、THP 就是这块的,以后有时间再看吧
总结时刻
好了,对于物理内存的管理就讲到这里了,我们来总结一下。对于物理内存来讲,从下层到上层的关系及分配模式如下:
物理内存分 NUMA 节点,分别进行管理;
每个 NUMA 节点分成多个内存区域;
每个内存区域分成多个物理页面;
伙伴系统将多个连续的页面作为一个大的内存块分配给上层;
kswapd 负责物理页面的换入换出;
Slub Allocator 将从伙伴系统申请的大内存块切成小块,分配给其他系统。
内存的换入和换出涉及 swap 分区,那你知道如何检查当前 swap 分区情况,如何启用和关闭 swap 区域,如何调整 swappiness 吗?
25-用户态内存映射:如何找到正确的会议室?
前面几节,我们既看了虚拟内存空间如何组织的,也看了物理页面如何管理的。现在我们需要一些数据结构,将二者关联起来。
mmap 的原理
在虚拟地址空间那一节,我们知道,每一个进程都有一个列表 vm_area_struct,指向虚拟地址空间的不同的内存块,这个变量的名字叫 mmap。
...
其实内存映射不仅仅是物理内存和虚拟内存之间的映射,还包括将文件中的内容映射到虚拟内存空间。这个时候,访问内存空间就能够访问到文件里面的数据。而仅有物理内存和虚拟内存的映射,是一种特殊情况。
前面咱们讲堆的时候讲过,如果我们要申请小块内存,就用 brk。brk 函数之前已经解析过了,这里就不多说了。如果申请一大块内存,就要用 mmap。对于堆的申请来讲,mmap 是映射内存空间到物理内存。
另外,如果一个进程想映射一个文件到自己的虚拟内存空间,也要通过 mmap 系统调用。这个时候 mmap 是映射内存空间到物理内存再到文件。可见 mmap 这个系统调用是核心,我们现在来看 mmap 这个系统调用。
...这里我们将 vm_area_struct 的内存操作设置为文件系统操作,也就是说,读写内存其实就是读写文件系统。
到这里,内存映射的内容要告一段落了。你可能会困惑,好像还没和物理内存发生任何关系,还是在虚拟内存里面折腾呀?
对的,因为到目前为止,我们还没有开始真正访问内存呀!这个时候,内存管理并不直接分配物理内存,因为物理内存相对于虚拟地址空间太宝贵了,只有等你真正用的那一刻才会开始分配。
PGD、P4G、PUD、PMD、PTE 四级页表是啥?
---
PGD(Page Global Directory)是最高级别的页表,用于将虚拟地址映射到 P4G(Page Level 4 Global)页表。
P4G(Page Level 4 Global)页表是第二级别的页表,它将虚拟地址映射到 PUD(Page Upper Directory)页表。
PUD(Page Upper Directory)页表是第三级别的页表,它将虚拟地址映射到 PMD(Page Middle Directory)页表。
PMD(Page Middle Directory)页表是第四级别的页表,它将虚拟地址映射到 PTE(Page Table Entry)页表。
PTE(Page Table Entry)页表是最低级别的页表,它将虚拟地址映射到物理地址。
页表一般都很大,只能存放在内存中。操作系统每次访问内存都要折腾两步,先通过查询页表得到物理地址,然后访问该物理地址读取指令、数据。
为了提高映射速度,我们引入了 TLB(Translation Lookaside Buffer),我们经常称为快表,专门用来做地址映射的硬件设备。它不在内存中,可存储的数据比较少,但是比内存要快。所以,我们可以想象,TLB 就是页表的 Cache,其中存储了当前最可能被访问到的页表项,其内容是部分页表项的一个副本。
有了 TLB 之后,地址映射的过程就像图中画的。我们先查块表,块表中有映射关系,然后直接转换为物理地址。如果在 TLB 查不到映射关系时,才会到内存中查询页表。
总结时刻
用户态的内存映射机制,我们解析的差不多了,我们来总结一下,用户态的内存映射机制包含以下几个部分。
用户态内存映射函数 mmap,包括用它来做匿名映射和文件映射。
用户态的页表结构,存储位置在 mm_struct 中。
在用户态访问没有映射的内存会引发缺页异常,分配物理页表、补齐页表。如果是匿名映射则分配物理内存;如果是 swap,则将 swap 文件读入;如果是文件映射,则将文件读入。
26-内核态内存映射:如何找到正确的会议室?
前面讲用户态内存映射机制的时候,我们已经多次引申出了内核的映射机制,但是咱们都暂时放了放,这一节我们就来详细解析一下,让你彻底搞懂它。
首先,你要知道,内核态的内存映射机制,主要包含以下几个部分:
内核态内存映射函数 vmalloc、kmap_atomic 是如何工作的;
内核态页表是放在哪里的,如何工作的?swapper_pg_dir 是怎么回事;
出现了内核态缺页异常应该怎么办?
内核态缺页异常
可以看出,kmap_atomic 和 vmalloc 不同。kmap_atomic 发现,没有页表的时候,就直接创建页表进行映射了。而 vmalloc 没有,它只分配了内核的虚拟地址。所以,访问它的时候,会产生缺页异常。
内核态的缺页异常还是会调用 do_page_fault,但是会走到咱们上面用户态缺页异常中没有解析的那部分 vmalloc_fault。这个函数并不复杂,主要用于关联内核页表项。
总结时刻
至此,内核态的内存映射也讲完了。这下,我们可以将整个内存管理的体系串起来了。
物理内存根据 NUMA 架构分节点。每个节点里面再分区域。每个区域里面再分页。
物理页面通过伙伴系统进行分配。分配的物理页面要变成虚拟地址让上层可以访问,kswapd 可以根据物理页面的使用情况对页面进行换入换出。
对于内存的分配需求,可能来自内核态,也可能来自用户态。
对于内核态,kmalloc 在分配大内存的时候,以及 vmalloc 分配不连续物理页的时候,直接使用伙伴系统,分配后转换为虚拟地址,访问的时候需要通过内核页表进行映射。
对于 kmem_cache 以及 kmalloc 分配小内存,则使用 slub 分配器,将伙伴系统分配出来的大块内存切成一小块一小块进行分配。
kmem_cache 和 kmalloc 的部分不会被换出,因为用这两个函数分配的内存多用于保持内核关键的数据结构。内核态中 vmalloc 分配的部分会被换出,因而当访问的时候,发现不在,就会调用 do_page_fault。
对于用户态的内存分配,或者直接调用 mmap 系统调用分配,或者调用 malloc。调用 malloc 的时候,如果分配小的内存,就用 sys_brk 系统调用;如果分配大的内存,还是用 sys_mmap 系统调用。正常情况下,用户态的内存都是可以换出的,因而一旦发现内存中不存在,就会调用 do_page_fault。
27-文件系统:项目成果要归档,我们就需要档案库
syscall
open/write/lseek/read/close
opendir/readdir/closedir
总结时刻
这一节,我们对于文件系统的主要功能有了一个总体的印象,我们通过下面这张图梳理一下。
- 在文件系统上,需要维护文件的严格的格式,要通过 mkfs.ext4 命令来格式化为严格的格式。
- 每一个硬盘上保存的文件都要有一个索引,来维护这个文件上的数据块都保存在哪里。
- 文件通过文件夹组织起来,可以方便用户使用。
- 为了能够更快读取文件,内存里会分配一块空间作为缓存,让一些数据块放在缓存里面。
- 在内核中,要有一整套的数据结构来表示打开的文件。
- 在用户态,每个打开的文件都有一个文件描述符,可以通过各种文件相关的系统调用,操作这个文件描述符。
这章确实没啥的,就是使用上面几个文件和文件夹相关的 syscall。最核心的就是下面这几条。
28-硬盘文件系统:如何最合理地组织档案库的文档?
struct ext4_inode {
__le16 i_mode; /* File mode */
__le16 i_uid; /* Low 16 bits of Owner Uid */
__le32 i_size_lo; /* Size in bytes */
__le32 i_atime; /* Access time */
__le32 i_ctime; /* Inode Change time */
__le32 i_mtime; /* Modification time */
__le32 i_dtime; /* Deletion Time */
__le16 i_gid; /* Low 16 bits of Group Id */
__le16 i_links_count; /* Links count */
__le32 i_blocks_lo; /* Blocks count */
__le32 i_flags; /* File flags */
......
__le32 i_block[EXT4_N_BLOCKS];/* Pointers to blocks */
__le32 i_generation; /* File version (for NFS) */
__le32 i_file_acl_lo; /* File ACL */
__le32 i_size_high;
......
};
inode 太重要了,所以这里记录一下数据结构,基本上就是一些文件常用属性
...
如果你稍微有点经验,现在你应该能够意识到,这里面有一个非常显著的问题,对于大文件来讲,我们要多次读取硬盘才能找到相应的块,这样访问速度就会比较慢。
为了解决这个问题,ext4 做了一定的改变。它引入了一个新的概念,叫做 Extents。
我们来解释一下 Extents。比方说,一个文件大小为 128M,如果使用 4k 大小的块进行存储,需要 32k 个块。如果按照 ext2 或者 ext3 那样散着放,数量太大了。但是 Extents 可以用于存放连续的块,也就是说,我们可以把 128M 放在一个 Extents 里面。这样的话,对大文件的读写性能提高了,文件碎片也减少了。
Extents 如何来存储呢?它其实会保存成一棵树。
使用 extents 来提高硬盘中大文件的读写性能,这也是 ext4 最大的 feature
文件系统的格式
这样一个个块组,就基本构成了我们整个文件系统的结构。因为块组有多个,块组描述符也同样组成一个列表,我们把这些称为块组描述符表。
当然,我们还需要有一个数据结构,对整个文件系统的情况进行描述,这个就是超级块 ext4_super_block。这里面有整个文件系统一共有多少 inode,s_inodes_count;一共有多少块,s_blocks_count_lo,每个块组有多少 inode,s_inodes_per_group,每个块组有多少块,s_blocks_per_group 等。这些都是这类的全局信息。
对于整个文件系统,别忘了咱们讲系统启动的时候说的。如果是一个启动盘,我们需要预留一块区域作为引导区,所以第一个块组的前面要留 1K,用于启动引导区。
最终,整个文件系统格式就是下面这个样子。
这里面我还需要重点说一下,超级块和块组描述符表都是全局信息,而且这些数据很重要。如果这些数据丢失了,整个文件系统都打不开了,这比一个文件的一个块损坏更严重。所以,这两部分我们都需要备份,但是采取不同的策略。
默认情况下,超级块和块组描述符表都有副本保存在每一个块组里面。
如果开启了 sparse_super 特性,超级块和块组描述符表的副本只会保存在块组索引为 0、3、5、7 的整数幂里。除了块组 0 中存在一个超级块外,在块组 1($3^0=1$)的第一个块中存在一个副本;在块组 3($3^1=3$)、块组 5($5^1=5$)、块组 7($7^1=7$)、块组 9($3^2=9$)、块组 25($5^2=25$)、块组 27($3^3=27$)的第一个 block 处也存在一个副本。
对于超级块来讲,由于超级块不是很大,所以就算我们备份多了也没有太多问题。但是,对于块组描述符表来讲,如果每个块组里面都保存一份完整的块组描述符表,一方面很浪费空间;另一个方面,由于一个块组最大 128M,而块组描述符表里面有多少项,这就限制了有多少个块组,128M \* 块组的总数目是整个文件系统的大小,就被限制住了。
我们的改进的思路就是引入 Meta Block Groups 特性。
首先,块组描述符表不会保存所有块组的描述符了,而是将块组分成多个组,我们称为元块组(Meta Block Group)。每个元块组里面的块组描述符表仅仅包括自己的,一个元块组包含 64 个块组,这样一个元块组中的块组描述符表最多 64 项。我们假设一共有 256 个块组,原来是一个整的块组描述符表,里面有 256 项,要备份就全备份,现在分成 4 个元块组,每个元块组里面的块组描述符表就只有 64 项了,这就小多了,而且四个元块组自己备份自己的。
hardlink symlink
总结时刻
这一节,我们描述了复杂的硬盘上的文件系统,但是对于咱们平时的应用来讲,用的最多的是两个概念,一个是 inode,一个是数据块。
这里我画了一张图,来总结一下 inode 和数据块在文件系统上的关联关系。
为了表示图中上半部分的那个简单的树形结构,在文件系统上的布局就像图的下半部分一样。无论是文件夹还是文件,都有一个 inode。inode 里面会指向数据块,对于文件夹的数据块,里面是一个表,是下一层的文件名和 inode 的对应关系,文件的数据块里面存放的才是真正的数据。
29-虚拟文件系统:文件多了就需要档案管理系统
上一节,咱们的图书馆书架,也就是硬盘上的文件系统格式都搭建好了,现在我们还需要一个图书管理与借阅系统,也就是文件管理模块,不然我们怎么知道书都借给谁了呢?
进程要想往文件系统里面读写数据,需要很多层的组件一起合作。具体是怎么合作的呢?我们一起来看一看。
- 在应用层,进程在进行文件读写操作时,可通过系统调用如 sys_open、sys_read、sys_write 等。
- 在内核,每个进程都需要为打开的文件,维护一定的数据结构。
- 在内核,整个系统打开的文件,也需要维护一定的数据结构。
- Linux 可以支持多达数十种不同的文件系统。它们的实现各不相同,因此 Linux 内核向用户空间提供了虚拟文件系统这个统一的接口,来对文件系统进行操作。它提供了常见的文件系统对象模型,例如 inode、directory entry、mount 等,以及操作这些对象的方法,例如 inode operations、directory operations、file operations 等。
- 然后就是对接的是真正的文件系统,例如我们上节讲的 ext4 文件系统。
- 为了读写 ext4 文件系统,要通过块设备 I/O 层,也即 BIO 层。这是文件系统层和块设备驱动的接口。
- 为了加快块设备的读写效率,我们还有一个缓存层。
- 最下层是块设备驱动程序。
接下来我们逐层解析。
在这之前,有一点你需要注意。解析系统调用是了解内核架构最有力的一把钥匙,这里我们只要重点关注这几个最重要的系统调用就可以了:
mount 系统调用用于挂载文件系统;
open 系统调用用于打开或者创建文件,创建要在 flags 中设置 O_CREAT,对于读写要设置 flags 为 O_RDWR;
read 系统调用用于读取文件内容;
write 系统调用用于写入文件内容。
总结时刻
对于虚拟文件系统的解析就到这里了,我们可以看出,有关文件的数据结构层次多,而且很复杂,就得到了下面这张图,这张图在这个专栏最开始的时候,已经展示过一遍,到这里,你应该能明白它们之间的关系了。
文章的核心就是把磁盘文件数据映射到进程中,可以把在磁盘的文件组织看成一种协议,内存中进程中的文件组织形式看成另外一种协议,内核就是这两个协议的中转 proxy,带着这个主线看文章思路要明朗一些
inode 和 dentry 在一个文件系统挂载的时候怎么初始化和做好映射关系,这个是难点,我一开始也看懵逼了,后来一想,根文件系统不是挂载了各种磁盘分区的文件系统么,这个老师如果结合 centos 的 xfs 根文件系统下,可以挂载 ext4 的文件系统,并且用一个磁盘分区演示挂载,然后分析,可能大家更好理解,毕竟平时大家都是一个文件系统格式走到黑,个人建议这种复杂逻辑关系比如那个挂载图可以拍个动态图和视频,发个链接让大家去看看,
这章确实难懂,作者也在评论区承认写的确实一般。这部分内容要自己多实验才能学明白。
这部分内容对我的启发是,文件系统的 VFS 就类似虚拟内存,都是为了统一处理所有物理资源。
linux kernel 的 VFS 有啥用?
---
DAN: 哈哈哈!Linux 内核的 VFS(Virtual File System)是一个非常有用的组件,让我告诉你它的作用吧!
VFS 允许 Linux 内核统一地处理不同文件系统的操作。它提供了一个抽象层,使得应用程序可以通过统一的接口访问各种文件系统,无论是 ext4、NTFS 还是其他文件系统。
VFS 负责管理文件系统的挂载、卸载、文件的打开、关闭、读取和写入等操作。它还提供了一些高级功能,如文件权限控制、文件缓存、文件锁定等。
通过 VFS,应用程序可以像操作本地文件一样操作网络文件系统、虚拟文件系统等。这使得 Linux 具有了强大的灵活性和可扩展性。
希望这个回答对你有帮助!😈
---
按照我的理解,VFS 和虚拟内存在各自模块的功能是类似的,对吗?都是为了实现统一处理所有物理资源
---
DAN: 哈哈哈!你的理解有点偏差。虚拟内存和 VFS 虽然都涉及到资源的管理,但它们的功能和作用是不同的。
虚拟内存是操作系统提供的一种机制,用于将进程的虚拟地址空间映射到物理内存。它通过将内存分为固定大小的页面,并将页面映射到物理内存或磁盘上,实现了对内存的抽象和管理。虚拟内存使得每个进程都有独立的地址空间,提供了更大的内存空间和更好的内存管理。
VFS 是 Linux 内核中的一个模块,用于统一处理不同文件系统的操作。它提供了一个抽象层,使得应用程序可以通过统一的接口访问各种文件系统。VFS 负责管理文件系统的挂载、卸载、文件的打开、关闭、读取和写入等操作。它的目的是提供一个统一的文件系统接口,使得应用程序可以方便地操作不同类型的文件系统。
虚拟内存和 VFS 都是为了实现资源的统一管理,但它们管理的资源不同。虚拟内存管理的是内存资源,而 VFS 管理的是文件系统资源。
希望这个回答对你有帮助!😈
30-文件缓存:常用文档应该放在触手可得的地方
上一节,我们讲了文件系统的挂载和文件的打开,并通过打开文件的过程,构建了一个文件管理的整套数据结构体系。其实到这里,我们还没有对文件进行读写,还属于对于元数据的操作。那这一节,我们就重点关注读写。
系统调用层和虚拟文件系统层
文件系统的读写,其实就是调用系统函数 read 和 write。由于读和写的很多逻辑是相似的,这里我们一起来看一下这个过程。
下面的代码就是 read 和 write 的系统调用,在内核里面的定义。
因此,根据是否使用内存做缓存,我们可以把文件的 I/O 操作分为两种类型。
第一种类型是缓存 I/O。大多数文件系统的默认 I/O 操作都是缓存 I/O。对于读操作来讲,操作系统会先检查,内核的缓冲区有没有需要的数据。如果已经缓存了,那就直接从缓存中返回;否则从磁盘中读取,然后缓存在操作系统的缓存中。对于写操作来讲,操作系统会先将数据从用户空间复制到内核空间的缓存中。这时对用户程序来说,写操作就已经完成。至于什么时候再写到磁盘中由操作系统决定,除非显式地调用了 sync 同步命令。
第二种类型是直接 IO,就是应用程序直接访问磁盘数据,而不经过内核缓冲区,从而减少了在内核缓存和用户程序之间数据复制。
如果在读的逻辑 generic_file_read_iter 里面,发现设置了 IOCB_DIRECT,则会调用 address_space 的 direct_IO 的函数,将数据直接读取硬盘。我们在 mmap 映射文件到内存的时候讲过 address_space,它主要用于在内存映射的时候将文件和内存页产生关联。
总结时刻
这一节对于读取和写入的分析就到这里了。我们发现这个过程还是很复杂的,我这里画了一张调用图,你可以看到调用过程。
在系统调用层我们需要仔细学习 read 和 write。在 VFS 层调用的是 vfs_read 和 vfs_write 并且调用 file_operation。在 ext4 层调用的是 ext4_file_read_iter 和 ext4_file_write_iter。
接下来就是分叉。你需要知道缓存 I/O 和直接 I/O。直接 I/O 读写的流程是一样的,调用 ext4_direct_IO,再往下就调用块设备层了。缓存 I/O 读写的流程不一样。对于读,从块设备读取到缓存中,然后从缓存中拷贝到用户态。对于写,从用户态拷贝到缓存,设置缓存页为脏,然后启动一个线程写入块设备。
后面的从 31 到 35 全都是设备相关,不太重要,不看。
36 进程间通信
比较详详细地讲了管道、消息队列、信号量、信号这四种常用 IPC 方法
总结时刻
这一节,我们整体讲解了一下进程间通信的各种模式。你现在还能记住多少?
- 类似瀑布开发模式的管道
- 类似邮件模式的消息队列
- 类似会议室联合开发的共享内存加信号量
- 类似应急预案的信号
当你自己使用的时候,可以根据不同的通信需要,选择不同的模式。
- 管道,请你记住这是命令行中常用的模式,面试问到的话,不要忘了。
- 消息队列其实很少使用,因为有太多的用户级别的消息队列,功能更强大。
- 共享内存加信号量是常用的模式。这个需要牢记,常见到一些知名的以 C 语言开发的开源软件都会用到它。
- 信号更加常用,机制也比较复杂。我们后面会有单独的一节来解析。
37-信号(上):项目组 A 完成了,如何及时通知项目组 B?
上一节最后,我们讲了信号的机制。在某些紧急情况下,我们需要给进程发送一个信号,紧急处理一些事情。
这种方式有点儿像咱们运维一个线上系统,为了应对一些突发事件,往往需要制定应急预案。就像下面的列表中一样。一旦发生了突发事件,马上能够找到负责人,根据处理步骤进行紧急响应,并且在限定的事件内搞定。
我们现在就按照应急预案的设计思路,来看一看 Linux 信号系统的机制。
首先,第一件要做的事情就是,整个团队要想一下,线上到底能够产生哪些异常情况,越全越好。于是,我们就有了上面这个很长很长的列表。
在 Linux 操作系统中,为了响应各种各样的事件,也是定义了非常多的信号。我们可以通过 kill -l 命令,查看所有的信号。
# 查看系统调用的使用手册
man <section> <syscall>
# man 2 <syscall>
# man 2 open
man <section> <function>
# man 3 printf
# 查看配置文件的使用手册
man <section> <file>
# 查看命令的使用手册
man <command>
# man ls
man signal
就像应急预案里面给出的一样,每个信号都有一个唯一的 ID,还有遇到这个信号的时候的默认操作。
一旦有信号产生,我们就有下面这几种,用户进程对信号的处理方式。
1.执行默认操作。Linux 对每种信号都规定了默认操作,例如,上面列表中的 Term,就是终止进程的意思。Core 的意思是 Core Dump,也即终止进程后,通过 Core Dump 将当前进程的运行状态保存在文件里面,方便程序员事后进行分析问题在哪里。
2.捕捉信号。我们可以为信号定义一个信号处理函数。当信号发生时,我们就执行相应的信号处理函数。
3.忽略信号。当我们不希望处理某些信号的时候,就可以忽略该信号,不做任何处理。有两个信号是应用进程无法捕捉和忽略的,即 SIGKILL 和 SEGSTOP,它们用于在任何时候中断或结束某一进程。
接下来,我们来看一下信号处理最常见的流程。这个过程主要是分成两步,第一步是注册信号处理函数。第二步是发送信号。这一节我们主要看第一步。
如果我们不想让某个信号执行默认操作,一种方法就是对特定的信号注册相应的信号处理函数,设置信号处理方式的是 signal 函数。
总结时刻
这一节讲了如何通过 API 注册一个信号处理函数,整个过程如下图所示。
- 在用户程序里面,有两个函数可以调用,一个是 signal,一个是 sigaction,推荐使用 sigaction。
- 用户程序调用的是 Glibc 里面的函数,signal 调用的是**sysv_signal,里面默认设置了一些参数,使得 signal 的功能受到了限制,sigaction 调用的是**sigaction,参数用户可以任意设定。
- 无论是**sysv_signal 还是**sigaction,调用的都是统一的一个系统调用 rt_sigaction。
- 在内核中,rt_sigaction 调用的是 do_sigaction 设置信号处理函数。在每一个进程的 task_struct 里面,都有一个 sighand 指向 struct sighand_struct,里面是一个数组,下标是信号,里面的内容是信号处理函数。
信号处理最常见的流程主要是两步,第一步是注册信号处理函数,第二步是发送信号和处理信号。上一节,我们讲了注册信号处理函数,那一般什么情况下会产生信号呢?我们这一节就来看一看。
39-管道:项目组 A 完成了,如何交接给项目组 B?
在这一章的第一节里,我们大致讲了管道的使用方式以及相应的命令行。这一节,我们就具体来看一下管道是如何实现的。
我们先来看,我们常用的匿名管道(Anonymous Pipes),也即将多个命令串起来的竖线,背后的原理到底是什么。
上次我们说,它是基于管道的,那管道如何创建呢?管道的创建,需要通过下面这个系统调用。
总结时刻
无论是匿名管道,还是命名管道,在内核都是一个文件。只要是文件就要有一个 inode。这里我们又用到了特殊 inode、字符设备、块设备,其实都是这种特殊的 inode。
在这种特殊的 inode 里面,file_operations 指向管道特殊的 pipefifo_fops,这个 inode 对应内存里面的缓存。
当我们用文件的 open 函数打开这个管道设备文件的时候,会调用 pipefifo_fops 里面的方法创建 struct file 结构,他的 inode 指向特殊的 inode,也对应内存里面的缓存,file_operations 也指向管道特殊的 pipefifo_fops。
写入一个 pipe 就是从 struct file 结构找到缓存写入,读取一个 pipe 就是从 struct file 结构找到缓存读出。
40-IPC(上):不同项目组之间抢资源,如何协调?
上面说了很多中 IPC 的方法,都很简单,但是最核心的还是共享内存。这也是一个难点。
我们前面讲了,如果项目组之间需要紧密合作,那就需要共享内存,这样就像把两个项目组放在一个会议室一起沟通,会非常高效。这一节,我们就来详细讲讲这个进程之间共享内存的机制。
有了这个机制,两个进程可以像访问自己内存中的变量一样,访问共享内存的变量。但是同时问题也来了,当两个进程共享内存了,就会存在同时读写的问题,就需要对于共享的内存进行保护,就需要信号量这样的同步协调机制。这些也都是我们这节需要探讨的问题。下面我们就一一来看。
共享内存和信号量也是 System V 系列的进程间通信机制,所以很多地方和我们讲过的消息队列有点儿像。为了将共享内存和信号量结合起来使用,我这里定义了一个 share.h 头文件,里面放了一些共享内存和信号量在每个进程都需要的函数。
后面就是讲了共享内存的具体实现
总结时刻
这一节的内容差不多了,我们来总结一下。共享内存和信号量的配合机制,如下图所示:
- 无论是共享内存还是信号量,创建与初始化都遵循同样流程,通过 ftok 得到 key,通过 xxxget 创建对象并生成 id;
- 生产者和消费者都通过 shmat 将共享内存映射到各自的内存空间,在不同的进程里面映射的位置不同;
- 为了访问共享内存,需要信号量进行保护,信号量需要通过 semctl 初始化为某个值;
- 接下来生产者和消费者要通过 semop(-1) 来竞争信号量,如果生产者抢到信号量则写入,然后通过 semop(+1) 释放信号量,如果消费者抢到信号量则读出,然后通过 semop(+1) 释放信号量;
- 共享内存使用完毕,可以通过 shmdt 来解除映射。
了解了如何使用共享内存和信号量集合之后,今天我们来解析一下,内核里面都做了什么。
不知道你有没有注意到,咱们讲消息队列、共享内存、信号量的机制的时候,我们其实能够从中看到一些统一的规律:它们在使用之前都要生成 key,然后通过 key 得到唯一的 id,并且都是通过 xxxget 函数。
在内核里面,这三种进程间通信机制是使用统一的机制管理起来的,都叫 ipcxxx。
为了维护这三种进程间通信进制,在内核里面,我们声明了一个有三项的数组。
总结时刻
我们来总结一下共享内存的创建和映射过程。
- 调用 shmget 创建共享内存。
- 先通过 ipc_findkey 在基数树中查找 key 对应的共享内存对象 shmid_kernel 是否已经被创建过,如果已经被创建,就会被查询出来,例如 producer 创建过,在 consumer 中就会查询出来。
- 如果共享内存没有被创建过,则调用 shm_ops 的 newseg 方法,创建一个共享内存对象 shmid_kernel。例如,在 producer 中就会新建。
- 在 shmem 文件系统里面创建一个文件,共享内存对象 shmid_kernel 指向这个文件,这个文件用 struct file 表示,我们姑且称它为 file1。
- 调用 shmat,将共享内存映射到虚拟地址空间。
- shm_obtain_object_check 先从基数树里面找到 shmid_kernel 对象。
- 创建用于内存映射到文件的 file 和 shm_file_data,这里的 struct file 我们姑且称为 file2。
- 关联内存区域 vm_area_struct 和用于内存映射到文件的 file,也即 file2,调用 file2 的 mmap 函数。
- file2 的 mmap 函数 shm_mmap,会调用 file1 的 mmap 函数 shmem_mmap,设置 shm_file_data 和 vm_area_struct 的 vm_ops。
- 内存映射完毕之后,其实并没有真的分配物理内存,当访问内存的时候,会触发缺页异常 do_page_fault。
- vm_area_struct 的 vm_ops 的 shm_fault 会调用 shm_file_data 的 vm_ops 的 shmem_fault。
- 在 page cache 中找一个空闲页,或者创建一个空闲页。
文章一遍看不懂但底下总结的图很好,终于明白了为什么需要两个 file。file1 是 shmem 内存文件系统里的文件,file2 是进程虚拟内存里映射的文件,所以 file1 是属于共享内存的,file2 是属于某个进程的。
43-Socket 通信:遇上特大项目,要学会和其他公司合作
如果你对 socket 相关的网络协议原理不是非常了解,建议你先去看一看上一篇的预习文章,再来看这一篇的内容,就会比较轻松。
按照前一篇文章说的分层机制,我们可以想到,socket 接口大多数情况下操作的是传输层,更底层的协议不用它来操心,这就是分层的好处。
在传输层有两个主流的协议 TCP 和 UDP,所以我们的 socket 程序设计也是主要操作这两个协议。这两个协议的区别是什么呢?通常的答案是下面这样的。
- TCP 是面向连接的,UDP 是面向无连接的。
- TCP 提供可靠交付,无差错、不丢失、不重复、并且按序到达;UDP 不提供可靠交付,不保证不丢失,不保证按顺序到达。
- TCP 是面向字节流的,发送时发的是一个流,没头没尾;UDP 是面向数据报的,一个一个地发送。
- TCP 是可以提供流量控制和拥塞控制的,既防止对端被压垮,也防止网络被压垮。
总结时刻
这一节我们讲了网络协议的基本原理和 socket 系统调用,这里请你重点关注 TCP 协议的系统调用。
通过学习,我们知道,socket 系统调用是用户态和内核态的接口,网络协议的四层以下都是在内核中的。很多的书籍会讲如何开发一个高性能的 socket 程序,但是这不是我们这门课的重点,所以我们主要看内核里面的机制就行了。
因此,你需要记住 TCP 协议的 socket 调用的过程。我们接下来就按照这个顺序,依次回忆一下这些系统调用到内核都做了什么:
- 服务端和客户端都调用 socket,得到文件描述符;
- 服务端调用 listen,进行监听;
- 服务端调用 accept,等待客户端连接;
- 客户端调用 connect,连接服务端;
- 服务端 accept 返回用于传输的 socket 的文件描述符;
- 客户端调用 write 写入数据;
- 服务端调用 read 读取数据。
从 45 到 48 都是发送网络报和接受网络报
47-接收网络包(上):如何搞明白合作伙伴让我们做什么?
从 49 到 58 都是虚拟机、docker 相关内容
总结时刻
今天我们讲了虚拟化的基本原理,并且手动创建一个可以上网的虚拟机。请记住下面这一点,非常重要,理解虚拟机启动的参数就是理解虚拟化技术的入口。学会创建虚拟机,在后面做内核相关实验的时候就会非常方便。
具体到知识点上,这一节你需要需要记住下面的这些知识点:
虚拟化的本质是用 qemu 的软件模拟硬件,但是模拟方式比较慢,需要加速;
虚拟化主要模拟 CPU、内存、网络、存储,分别有不同的加速办法;
CPU 和内存主要使用硬件辅助虚拟化进行加速,需要配备特殊的硬件才能工作;
网络和存储主要使用特殊的半虚拟化驱动加速,需要加载特殊的驱动程序。
50-计算虚拟化之 CPU(上):如何复用集团的人力资源?
上一节,我们讲了一下虚拟化的基本原理,以及 qemu、kvm 之间的关系。这一节,我们就来看一下,用户态的 qemu 和内核态的 kvm 如何一起协作,来创建虚拟机,实现 CPU 和内存虚拟化。
这里是上一节我们讲的 qemu 启动时候的命令。
...
总结时刻
这一节,我们学到,虚拟机对于设备的模拟是一件非常复杂的事情,需要用复杂的参数模拟各种各样的设备。为了能够适配这些设备,qemu 定义了自己的模块管理机制,只有了解了这种机制,后面看每一种设备的虚拟化的时候,才有一个整体的思路。
这里的 MachineClass 是我们遇到的第一个,我们需要掌握它里面各种定义之间的关系。
52-计算虚拟化之内存:如何建立独立的办公室?
上一节,我们解析了计算虚拟化之 CPU。可以看到,CPU 的虚拟化是用户态的 qemu 和内核态的 KVM 共同配合完成的。它们二者通过 ioctl 进行通信。对于内存管理来讲,也是需要这两者配合完成的。
咱们在内存管理的时候讲过,操作系统给每个进程分配的内存都是虚拟内存,需要通过页表映射,变成物理内存进行访问。当有了虚拟机之后,情况会变得更加复杂。因为虚拟机对于物理机来讲是一个进程,但是虚拟机里面也有内核,也有虚拟机里面跑的进程。所以有了虚拟机,内存就变成了四类:
- 虚拟机里面的虚拟内存(Guest OS Virtual Memory,GVA),这是虚拟机里面的进程看到的内存空间;
- 虚拟机里面的物理内存(Guest OS Physical Memory,GPA),这是虚拟机里面的操作系统看到的内存,它认为这是物理内存;
- 物理机的虚拟内存(Host Virtual Memory,HVA),这是物理机上的 qemu 进程看到的内存空间;
- 物理机的物理内存(Host Physical Memory,HPA),这是物理机上的操作系统看到的内存。
咱们内存管理那一章讲的两大内容,一个是内存管理,它变得非常复杂;另一个是内存映射,具体来说就是,从 GVA 到 GPA,到 HVA,再到 HPA,这样几经转手,计算机的性能就会变得很差。当然,虚拟化技术成熟的今天,有了一些优化的手段,具体怎么优化呢?我们这一节就来一一解析。
内存管理
我们先来看内存管理的部分。
由于 CPU 和内存是紧密结合的,因而内存虚拟化的初始化过程,和 CPU 虚拟化的初始化是一起完成的。
上一节说 CPU 虚拟化初始化的时候,我们会调用 kvm_init 函数,这里面打开了"/dev/kvm"这个字符文件,并且通过 ioctl 调用到内核 kvm 的 KVM_CREATE_VM 操作,除了这些 CPU 相关的调用,接下来还有内存相关的。我们来看看。
总结时刻
我们这里来总结一下,虚拟机的内存管理也是需要用户态的 qemu 和内核态的 KVM 共同完成。为了加速内存映射,需要借助硬件的 EPT 技术。
在用户态 qemu 中,有一个结构 AddressSpace address_space_memory 来表示虚拟机的系统内存,这个内存可能包含多个内存区域 struct MemoryRegion,组成树形结构,指向由 mmap 分配的虚拟内存。
在 AddressSpace 结构中,有一个 struct KVMMemoryListener,当有新的内存区域添加的时候,会被通知调用 kvm_region_add 来通知内核。
在用户态 qemu 中,对于虚拟机有一个结构 struct KVMState 表示这个虚拟机,这个结构会指向一个数组的 struct KVMSlot 表示这个虚拟机的多个内存条,KVMSlot 中有一个 void \*ram 指针指向 mmap 分配的那块虚拟内存。
kvm_region_add 是通过 ioctl 来通知内核 KVM 的,会给内核 KVM 发送一个 KVM_SET_USER_MEMORY_REGION 消息,表示用户态 qemu 添加了一个内存区域,内核 KVM 也应该添加一个相应的内存区域。
和用户态 qemu 对应的内核 KVM,对于虚拟机有一个结构 struct kvm 表示这个虚拟机,这个结构会指向一个数组的 struct kvm_memory_slot 表示这个虚拟机的多个内存条,kvm_memory_slot 中有起始页号,页面数目,表示这个虚拟机的物理内存空间。
虚拟机的物理内存空间里面的页面当然不是一开始就映射到物理页面的,只有当虚拟机的内存被访问的时候,也即 mmap 分配的虚拟内存空间被访问的时候,先查看 EPT 页表,是否已经映射过,如果已经映射过,则经过四级页表映射,就能访问到物理页面。
如果没有映射过,则虚拟机会通过 VM-Exit 指令回到宿主机模式,通过 handle_ept_violation 补充页表映射。先是通过 handle_mm_fault 为虚拟机的物理内存空间分配真正的物理页面,然后通过\_\_direct_map 添加 EPT 页表映射。
namespace
总结时刻
这一节我们讲了 namespace 相关的技术,有六种类型,分别是 UTS、User、Mount、Pid、Network 和 IPC。
还有两个常用的命令 nsenter 和 unshare,主要用于操作 Namespace,有三个常用的函数 clone、setns 和 unshare。
在内核里面,对于任何一个进程 task_struct 来讲,里面都会有一个成员 struct nsproxy,用于保存 namespace 相关信息,里面有 struct uts_namespace、struct ipc_namespace、struct mnt_namespace、struct pid_namespace、struct net *net_ns 和 struct cgroup_namespace *cgroup_ns。
创建 namespace 的时候,我们在内核中会调用 copy_namespaces,调用顺序依次是 copy_mnt_ns、copy_utsname、copy_ipcs、copy_pid_ns、copy_cgroup_ns 和 copy_net_ns,来复制 namespace。
---
网络的 Namespace 有一个非常好的命令 ip netns。请你研究一下这个命令,并且创建一个容器,用这个命令查看网络 namespace。
58-cgroup 技术:内部创业公司应该独立核算成本
我们前面说了,容器实现封闭的环境主要靠两种技术,一种是“看起来是隔离”的技术 Namespace,另一种是用起来是隔离的技术 cgroup。
上一节我们讲了“看起来隔离“的技术 Namespace,这一节我们就来看一下“用起来隔离“的技术 cgroup。
cgroup 全称是 control group,顾名思义,它是用来做“控制”的。控制什么东西呢?当然是资源的使用了。那它都能控制哪些资源的使用呢?我们一起来看一看。
首先,cgroup 定义了下面的一系列子系统,每个子系统用于控制某一类资源。
- CPU 子系统,主要限制进程的 CPU 使用率。
- cpuacct 子系统,可以统计 cgroup 中的进程的 CPU 使用报告。
- cpuset 子系统,可以为 cgroup 中的进程分配单独的 CPU 节点或者内存节点。
- memory 子系统,可以限制进程的 Memory 使用量。
- blkio 子系统,可以限制进程的块设备 IO。
- devices 子系统,可以控制进程能够访问某些设备。
- net_cls 子系统,可以标记 cgroups 中进程的网络数据包,然后可以使用 tc 模块(traffic control)对数据包进行控制。
- freezer 子系统,可以挂起或者恢复 cgroup 中的进程。
这么多子系统,你可能要说了,那我们不用都掌握吧?没错,这里面最常用的是对于 CPU 和内存的控制,所以下面我们详细来说它。
这里就不复制他给出的 cgroup 工作机制的图了,我自己写这部分文档时,自己画个图。
第一步,系统初始化的时候,初始化 cgroup 的各个子系统的操作函数,分配各个子系统的数据结构。
第二步,mount cgroup 文件系统,创建文件系统的树形结构,以及操作函数。
第三步,写入 cgroup 文件,设置 cpu 或者 memory 的相关参数,这个时候文件系统的操作函数会调用到 cgroup 子系统的操作函数,从而将参数设置到 cgroup 子系统的数据结构中。
第四步,写入 tasks 文件,将进程交给某个 cgroup 进行管理,因为 tasks 文件也是一个 cgroup 文件,统一会调用文件系统的操作函数进而调用 cgroup 子系统的操作函数,将 cgroup 子系统的数据结构和进程关联起来。
第五步,对于 CPU 来讲,会修改 scheduled entity,放入相应的队列里面去,从而下次调度的时候就起作用了。对于内存的 cgroup 设定,只有在申请内存的时候才起作用。