世界上充满了各种计算机系统,比如大家身边的个人计算机、智能手机、平板电脑,以及平时不怎么接触的商用服务器等。虽然这些计算机系统上的硬件结构存在各种各样的差异,但大体上为如图所示的结构。
在计算机系统运行时,在硬件设备上会重复执行以下步骤。
【1】通过输入设备或网络适配器,向计算机发起请求。
【2】读取内存中的命令,并在CPU上执行,把结果写入负责保存数据的内存区域中。
【3】将内存中的数据写入HDD(Hard Disk Drive,硬盘驱动器)、SDD(Solid State Disk,固态硬盘)等存储器,或者通过网络发送给其他计算机,或者通过输出设备提供给用户。
【4】回到步骤【1】。
由这些重复执行的步骤整合而成的对用户有意义的处理,就称为程序。程序大体上分为以下几种。
●应用程序:能让用户直接使用,为用户提供帮助的程序,例如计算机上的办公软件、智能手机和平板电脑上的应用
●中间件:将对大部分应用程序通用的功能分离出来,以辅助应用程序运行的程序,例如Web服务器、数据库系统
●OS:直接控制硬件设备,同时为应用程序与中间件提供运行环境的程序,例如Linux
调用外部设备(以下简称“设备”)是Linux的一个重要功能。如果没有Linux这样的OS,就不得不为每个进程单独编写调用设备的代码。
在这种情况下,会存在以下缺点。
●应用程序开发人员必须精通调用各种设备的方法
●开发成本高
●当多个进程同时调用设备时,会引起各种预料之外的问题为了解决上述问题,Linux把设备调用处理整合成了一个叫作设备驱动程序的程序,使进程通过设备驱动程序访问设备。
虽然世界上存在各种设备,但对于同一类型的设备,Linux可以通过同一个接口进行调用。
在某个进程因为Bug或者程序员的恶意操作而违背了“通过设备驱动程序访问设备”这一规则的情况下,依然会出现多个进程同时调用设备的情况。为了避免这种情况,Linux借助硬件,使进程无法直接访问设备。具体来说,CPU存在内核模式和用户模式两种模式,只有处于内核模式时才允许访问设备。另外,使设备驱动程序在内核模式下运行,使进程在用户模式下运行。
除此之外,还有许多不应被普通进程调用的处理程序,如下所示。
●进程管理系统
●进程调度器
●内存管理系统
这些程序也全都在内核模式下运行。把这些在内核模式下运行的OS的核心处理整合在一起的程序就叫作内核。如果进程想要使用设备驱动程序等由内核提供的功能,就需要通过被称为系统调用的特殊处理来向内核发出请求。需要指出的是,OS并不单指内核,它是由内核与许多在用户模式下运行的程序构成的。
OS并非仅由内核构成,还包含许多在用户模式下运行的程序。这些程序有的以库的形式存在,有的作为单独的进程运行。
一般来说,由在用户模式下运行的进程通过系统调用向内核发送相应的请求,其中存在进程独有的代码直接向内核发起请求的情况,也存在进程所依赖的库向内核发起请求的情况。另外,库分为 OS提供的库与第三方库两种类型。
进程在执行创建进程、操控硬件等依赖于内核的处理时,必须通过系统调用向内核发起请求。系统调用的种类如下。●进程控制(创建和删除)●内存管理(分配和释放)●进程间通信●网络管理●文件系统操作●文件操作(访问设备)
CPU的模式切换
系统调用需要通过执行特殊的CPU命令来发起。通常进程运行在用户模式下,当通过系统调用向内核发送请求时,CPU会发生名为中断的事件。这时,CPU将从用户模式切换到内核模式,然后根据请求内容进行相应的处理。当内核处理完所有系统调用后,将重新回到用户模式,继续运行进程。
内核在开始进行处理时验证来自进程的请求是否合理(例如,请求的内存量是否大于系统所拥有的内存量等)。如果请求不合理,系统调用将执行失败。需要注意的是,并不存在用户进程绕过系统调用而直接切换CPU运行模式的方法(假如有,内核就失去存在的意义了)。
strace命令
strace命令来自于英文单词“跟踪”,其功能是用于跟踪系统调用信息。strace命令是一个集诊断、调试、统计于一体的工具,可以对系统调用和信号传递的跟踪结果进行分析,从而解决问题。 Linux系统中进程不能直接访问硬件设备,当进程需要读取磁盘文件或接收网络数据时,必须由用户态模式切换至内核态模式,通过系统调用访问硬件设备。而strace命令则可以跟踪到一个进程产生的系统调用数据——包括参数,返回值,执行消耗时间等信息。
Linux提供了所有或者说绝大多数进程所依赖的库函数,以为编写程序提供方便。需要注意的是,与常规的函数调用不同,系统调用并不能被C语言之类的高级编程语言直接发起,只能通过与系统架构紧密相连的汇编语言代码来发起。例如,在x86_64架构中,是如下发起getppid() 这个系统调用的.
如果没有OS的帮助,程序员就不得不根据系统架构为每一个系统调用编写相应的汇编语言代码,然后再从高级编程语言中调用这些代码。
为了解决这样的问题,OS提供了一系列被称为系统调用的包装函数的函数,用于在系统内部发起系统调用。各种架构上都存在着对应的包装函数。因此,使用高级编程语言编写的用户程序,只需调用由高级编程语言提供的包装函数即可。
在Linux中,创建进程有如下两个目的。
【1】将同一个程序分成多个进程进行处理(例如,使用Web服务器接收多个请求)
【2】创建另一个程序(例如,从bash启动一个新的程序)为了达成这两个目的,Linux提供了fork() 函数与execve() 函数(其底层分别请求名为clone() 与execve() 的系统调用)
要想将同一个程序分成多个进程进行处理,只需使用fork() 函数即可。在调用fork() 函数后,就会基于发起调用的进程,创建一个新的进程。发出请求的进程称为父进程,新创建的进程称为子进程。
【创建新进程的流程如下】
【1】为子进程申请内存空间,并复制父进程的内存到子进程的内存空间。
【2】父进程与子进程分裂成两个进程,以执行不同的代码。这一点的实现依赖于fork() 函数分别返回不同的值给父进程与子进程。
在打算启动另一个程序时,需要调用execve() 函数。首先,我们来看一下内核在运行进程时的流程。
② 用新进程的数据覆盖当前进程的内存。
③ 从最初的命令开始运行新的进程。
也就是说,在启动另一个程序时,并非新增一个进程,而是替换了当前进程。
Linux内核具有进程调度器的功能,它使得多个进程能够同时运行(准确来说,是看起来在同时运行)。在使用Linux系统时通常是意识不到调度器的存在的。
一个CPU同时只运行一个进程。在同时运行多个进程时,每个进程都会获得适当的时长(时间片),轮流在CPU上执行处理。需要指出的是,Linux会将多核CPU(现在几乎都是这样的CPU)上的每一个核心都识别为一个CPU。
不管同时运行多少个进程,在任意时间点上,只能有一个进程运行在逻辑CPU上。在逻辑CPU上运行多个进程时,它们将按轮询调度的方式循环运行,即所有进程按顺序逐个运行,一轮过后重新从第一个进程开始轮流运行。每个进程被分配到的时间片的长度大体上相等。全部进程运行结束所消耗的时间,随着进程数量的增加而等比例地增加。
上下文切换是指切换正在逻辑CPU上运行的进程。所示为当存在进程0和进程1时,在消耗完一个时间片后进行上下文切换的情况。
当一个时间片被消耗完后,不管进程正在执行什么代码,都一定会发生上下文切换。
使用ps ax命令可以按照一行一个进程的格式列举出系统中当前正在运行的所有进程。然后,根据执行该命令而输出的行数,就能直接得知正在运行的进程总数。
进程的一部分状态如下所示。
疑问:在某些时间点cpu没有在运行,在此期间,逻辑cpu上会发生什么呢?
实际上,在此期间,逻辑CPU会运行一个被称为空闲进程的不执行任何处理的特殊进程。空闲进程最简单的实现方式就是创建一个新进程,或者在唤醒处于睡眠态的进程之前执行无意义的循环。但因为这样会浪费电,所以通常并不会这样处理,而是使用特殊的CPU指令使逻辑CPU进入休眠状态,直到出现就绪态的进程。大家的计算机和智能手机之所以在不运行程序时能够待机更长时间,主要就得益于逻辑CPU能够进入空闲状态。
利用sar命令就可以确认单位时间内逻辑CPU处于空闲状态的时间占比,以及有多少空余的计算资源。
吞吐量:单位时间内的总工作量,越大越好
延迟:各种处理从开始到完成所耗费的时间,越短越好
吞吐量 = 处理完成的进程数量 / 耗费的时间
延迟 = 结束处理的时间 – 开始处理的时间
对于吞吐量,基本上可以简单地理解为,CPU的计算资源消耗得越多,或者说空闲状态的时间占比越低,吞吐量就越大。
当存在多个逻辑CPU时,如何进行调度呢?为了能够利用各个逻辑CPU,调度器会运行一个被称为负载均衡或全局调度的功能。简单来说,负载均衡负责公平地把进程分配给多个逻辑CPU。与只有单个逻辑CPU时的情况相同,在各个逻辑CPU内,调度器为在逻辑CPU上运行的各个进程分配均等的CPU时间。
一个CPU同时只运行一个进程。
在同时运行多个进程时,每个进程都会获得适当的时长,轮流在CPU上执行处理。
对于多核CPU的计算机来说,只有同时运行多个进程才能提高吞吐量。另外,“有 n 个核心就有 n 倍性能”这种说法,说到底也只存在于理想状态中。
与只有单个逻辑CPU 时一样,当进程数量多于逻辑CPU 数量时,吞吐量就不会再提高。
到目前为止,我们说的都是系统会均等地分配CPU时间给所有可以运行的进程。但是,为特定的进程指定优先级也是可以的。nice() 就是为了实现这一点而提供的系统调用。
nice() 能通过 -19和20之间的数来设定进程的运行优先级(默认值为0),其中,-19的优先级最高,20的优先级最低。优先级高的进程可以比普通进程获得更多的CPU时间。与此相反,优先级低的进程会获得更少的CPU时间。需要注意的是,虽然谁都可以降低进程优先级,但是只有拥有root权限的用户才能进行提高优先级的操作。
Linux通过内核中名为内存管理系统的功能来管理系统上搭载的所有内存。除了各种进程以外,内核本身也需要使用内存。
可以通过free命令获取系统搭载的内存总量和已消耗的内存量。
这里对Mem: 这一行中的重要字段进行说明。需要注意的是,上面的所有数值的单位都为千字节(KB)。
total字段:系统搭载的物理内存总量。在上面的例子中约为17GB
free字段:表面上的可用内存量
buff/cache字段:缓冲区缓存与页面缓存占用的内存。当系统的可用内存量(free字段的值)减少时,可通过内核将它们释放出来
available字段:实际的可用内存量。本字段的值为free字段的值加上当内存不足时内核中可释放的内存量。“可释放的内存”指缓冲区缓存与页面缓存中的大部分内存,以及内核中除此以外的用于其他地方的部分内存.
随着内存使用量增加,可用内存变得越来越少。当内存不足时,内存管理系统将回收内核中可释放的内存。如果内存使用量继续增加,系统就会陷入做什么都缺乏足够的内存,以至于无法运行的内存不足(Out Of Memory,OOM)状态。在进入OOM状态后,内存管理系统会运行被称为 OOM killer 的可怕功能,该功能会选出合适的进程并将其强制终止(kill掉),以释放出更多内存。
如果是个人计算机,这可能并非什么大问题;但如果是商用服务器,则完全不知道结束的是哪一个进程,这种状态非常令人困扰。虽然有办法令特定进程排除在OOM killer的选择范围之外,但是要在业务用的进程中筛选出允许强制结束的进程是非常困难的。因此,也有将服务器上的sysctl的vm.panic_on_oom参数从默认的0(在发生OOM时运行OOM killer)变更为1(在发生OOM时强制关闭系统)这样的做法。
简而言之,虚拟内存使进程无法直接访问系统上搭载的内存,取而代之的是通过虚拟地址间接访问。进程可以看见的是虚拟地址,系统上搭载的内存的实际地址称为物理地址。此外,可以通过地址访问的范围称为地址空间。进程无法直接访问真实的内存,也就是说不存在直接访问物理地址的方法。
通过保存在内核使用的内存中的页表,可以完成从虚拟地址到物理地址的转换。在虚拟内存中,所有内存以页为单位划分并进行管理,地址转换也以页为单位进行。在页表中,一个页面对应的数据条目称为页表项。页表项记录着虚拟地址与物理地址的对应关系。页面大小取决于CPU架构。在x86_64架构中,页面大小为4KB。
如果进程访问0 ~ 300的虚拟地址,CPU将自动参考页表的内容,将其转换为对相应的物理地址的访问,而无须经过内核的处理。
如果进程访问地址300 ~ 500,即页表不存在的地址映射,则在CPU上会发生缺页中断。缺页中断可以中止正在执行的命令,并启动内核中的缺页中断机构的处理。内核的缺页中断机构检测到非法访问,向进程发送SIGSEGV信号。接收到该信号的进程通常会被强制结束运行。
【1】首先读取程序的可执行文件,计算运行程序所需的内存大小为,假设计算结果为300
【2】在物理内存上划分出大小为300的区域,将其分配给进程,并把代码和数据复制过去。Linux的物理内存分配使用的是更复杂的请求分页方法。
【4】在复制完成后,创建进程的页表,并把虚拟地址映射到物理地址。
【5】最后,从指定的地址开始运行即可。
【6】如果进程请求更多内存,内核将为其分配新的内存,创建相应的页表,然后把与新分配的内存(的物理地址)对应的虚拟地址返回给进程。mmap() 函数会通过系统调用向Linux内核请求新的内存。
C语言标准库中存在一个名为malloc() 的函数,用于获取内存。在Linux中,这个函数的底层调用了mmap() 函数。
mmap() 函数是以页为单位获取内存的,而malloc() 函数是以字节为单位获取内存的。为了以字节为单位获取内存,glibc事先通过系统调用mmap() 向内核请求一大块内存区域作为内存池,当程序调用malloc() 函数时,从内存池中根据申请的内存量划分出相应大小(以字节为单位)的内存并返回给程序。在内存池中的内存消耗完后,glibc会再次调用mmap() 以申请新的内存区域。
这是运行在用户模式下的OS功能(glibc的malloc() 函数)为普通程序提供的一个典型功能。
内存碎片化
如图所示,假如能巧妙地设定进程的页表,就能将物理内存上的碎片整合成虚拟地址空间上的一片连续的内存区域。这样一来,碎片化的问题也就解决了。
访问用于其他用途的内存区域
虚拟地址空间是每个进程独有的。相应地,页表也是每个进程独有的。如图所示,进程A和进程B各自拥有独立的虚拟地址空间。得益于虚拟内存,进程根本无法访问其他进程的内存。
●文件映射●请求分页●利用写时复制快速创建进程●Swap●多级页表●标准大页
进程在访问文件时,通常会在打开文件后使用read()、write() 以及lseek() 等系统调用。此外,Linux还提供了将文件区域映射到虚拟地址空间的功能。按照指定方式调用mmap() 函数,即可将文件的内容读取到内存中,然后把这个内存区域映射到虚拟地址空间。
【场景问题分析】
对于创建进程时的内存分配,或者在创建进程后通过mmap() 系统调用进行的动态内存分配,我们是这样描述它们的流程的。
【1】内核直接从物理内存中获取需要的区域。
【2】内核设置页表,并关联虚拟地址空间与物理地址空间。但是,这种分配方式会导致内存的浪费。因为在获取的内存中,有一部分内存在获取后,甚至直到进程运行结束都不会使用
例如:●用于大规模程序中的、程序运行时未使用的功能的代码段和数据段●由glibc保留的内存池中未被用户利用的部分
为了解决这个问题,Linux利用请求分页机制来为进程分配内存。在请求分页机制中,对于虚拟地址空间内的各个页面,只有在进程初次访问页面时,才会为这个页面分配物理内存。页面的状态除了前面提到过的“未分配给进程”与“已分配给进程且已分配物理内存”这两种以外,还存在“已分配给进程但尚未分配物理内存”这种状态。
我们都知道,在进程运行时,如果获取内存失败,进程就会异常终止。但不知大家是否知道,获取内存失败也分为虚拟内存不足与物理内存不足两种情况。
当进程把虚拟地址空间的范围内的虚拟内存全部获取完毕后,就会导致虚拟内存不足。举个例子,在虚拟地址空间的大小只有500字节的情况下,图中的情况就会引发虚拟内存不足。由于已经使用完了全部虚拟地址空间,所以即使尚有300字节的可用物理内存,也会引发虚拟内存不足。
虚拟内存不足与剩余多少物理内存无关。如果不清楚虚拟内存的机制,可能难以想象这到底是一种什么样的情景。在x86架构上,虚拟地址空间仅有4GB,因此数据库之类的大型程序经常会引发虚拟内存不足;但是在x86_64架构上,由于虚拟地址空间扩充到了128TB,所以虚拟内存不足变得非常罕见。但是,随着程序对虚拟内存的需求不断增加,我们可能会再次迎来容易引发虚拟内存不足的一天。与虚拟内存不足相对的物理内存不足指的是系统上搭载的物理内存被耗尽的状态。
物理内存不足与进程的虚拟内存剩余多少无关。与虚拟内存不足相比,物理内存不足的情形应该更容易想象。
用于创建进程的fork() 系统调用,利用虚拟内存机制,可以提高fork() 的执行速度。
在发起fork() 系统调用时,并非把父进程的所有内存数据复制给子进程,而是仅复制父进程的页表。虽然在父进程和子进程双方的页表项内都存在表示写入权限的字段,但此时双方的写入权限都将失效(即变得无法进行写入)。在这之后,假如只进行读取操作,那么父进程和子进程双方都能访问共享的物理页面。但是,当其中一方打算更改任意页面的数据时,则将按照下述流程解除共享。
【1】 由于没有写入权限,所以在尝试写入时,CPU将引发缺页中断。
【2】 CPU转换到内核模式,缺页中断机构开始运行。
【3】对于被访问的页面,缺页中断机构将复制一份放到别的地方,然后将其分配给尝试写入的进程,并根据请求更新其中的内容。
【4】为父进程和子进程双方更新与已解除共享的页面对应的页表项。
●对于执行写入操作的一方,将其页表项重新连接到新分配的物理页面,并赋予写入权限
●对于另一方,也只需对其页表项重新赋予写入权限即可
当物理内存耗尽时,系统就会进入OOM状态。但实际上,Linux提供了针对OOM状态的补救措施,即Swap这一利用了虚拟内存机制的功能。通过这个功能,我们可以将外部存储器的一部分容量暂时当作内存使用。具体来说,在系统物理内存不足的情况下,当出现获取物理内存的申请时,物理内存中的一部分页面将被保存到外部存储器中,从而空出充足的可用内存。这里用于保存页面的区域称为交换分区(Swap分区)。交换分区由系统管理员在构建系统时进行设置。
假设系统处于物理内存不足的状态下,且这时需要使用更多的物理内存。在图中,进程B向尚未关联物理内存的虚拟地址100发起访问,这引发了缺页中断。
此时,由于已经没有空闲的物理内存了,所以内核会将正在使用的物理内存中的一部分页面保存到交换分区。这个处理称为换出。在下图中,与进程A的虚拟地址100 ~ 200对应的物理地址600 ~ 700的区域会被换出到交换分区。
虽然在图中,被换出的页面在交换分区上的地址信息记录在页表项中,但实际上是记录在内核中专门用于管理交换分区的区域上的。在通过换出处理空出一块可用内存后,内核将这部分内存分配给进程B。
假设在经过一段时间后,系统得以空出部分可用内存。在这样的状态下,如果进程A对先前保存到交换分区的页面发起访问,此时,内核会从交换分区中将先前换出的页面重新拿回到物理内存,这个处理称为换入。
换出与换入这两个处理统称为交换。在Linux中,由于交换是以页为单位进行的,所以也称为分页。同时,换入与换出也分别称为页面调入与页面调出。
Swap乍看之下是一个能够使可用内存量扩充为“实际搭载的内存量 + 交换分区的容量”的美好机制,但这里其实存在一个非常大的陷阱。那就是,相比对内存的访问速度,对普通的外部存储器的访问速度慢了几个数量级。当系统长期处于内存不足时,访问内存的操作将导致页面不断地被换入和换出,从而导致系统陷入系统抖动(颠簸)状态。大家或许经历过这样的情形:在使用笔记本时,明明没有进行读写文件的操作,但外部存储器的访问指示灯却亮着。这种情形的原因大多在于系统抖动。一旦发生了抖动,系统就会暂时无法响应,然后一直保持那样的状态,最后宕机或者引发OOM。如果系统频繁发生交换处理,就必须重新审视一下其设计是否存在问题。会引发系统抖动的系统不应当部署到服务器上。要调整设计,可以考虑降低系统负载以降低系统内存使用量,或者单纯地为系统增添内存等。
可以通过swapon --show命令查看系统交换分区的信息。
如上所示,当前计算机/dev/dm-1被用作交换分区,大小为2g,交换分区的大小可以通过free命令查看。
类似于交换这类需要访问外部存储器的缺页中断称为硬性页缺失。与此相对,无须访问外部存储器的缺页中断称为软性页缺失。虽然无论发生硬性页缺失还是软性页缺失,都需要内核进行处理,进而影响性能,但硬性页缺失所产生的影响要更大。
在x86_64架构上,虚拟地址空间大小为128TB,页面大小为4KB,页表项的大小为8字节。通过上面的信息可以算出,一个进程的页表就需要占用256GB的内存(= 8B×128TB / 4KB)。以笔者的计算机为例,由于只有32GB的内存,所以一个进程也无法创建。
为了避免这样的情况,x86_64架构的页表采用多级结构,而非上面描述的单层结构。这样就能节约大量的内存。在现实中,x86_64架构的页表结构非常复杂,因此在比较单层页表与多级页表的不同时,我们将利用比实际结构简单的模型。假设一个页面大小为100字节,虚拟地址空间的大小为1600字节。当虚拟内存使用量增加到一定程度时,多级页表的内存使用量就会超过单层页表。但这种情况非常罕见,所以从系统整体来看,也是多级页表的内存使用量更少。
随着进程的虚拟内存使用量增加,进程页表使用的物理内存量也会增加。此时,除了内存使用量增加的问题之外,还存在fork() 系统调用的执行速度变慢的问题,这是因为fork() 是通过写时复制创建进程的,这虽然不需要复制物理内存的数据,但是需要为子进程复制一份与父进程同样大小的页表。为了解决这个问题,Linux提供了标准大页机制。顾名思义,标准大页是比普通的页面更大的页。利用这种页面,能有效减少进程页表所需的内存量。
上一篇:前端基础(一)
下一篇:java 每日一练 (9)