进程是资源分配的基本单位,线程是 CPU 调度的基本单位。进程拥有独立的地址空间,线程是共享内存地址的。进程切换的开销比线程要大。
进程调度的主要目标是让 CPU 始终处于忙碌状态,并为所有程序提供最短的响应时间。为了实现这一点,必须要适当的中断程序的运行以及唤起其他程序运行。当前的调度分为了两大类:
当前的调度算法也有很多,总的可以分为下面几类:
当进程开始运行时,通常会涉及到 5 个状态:
在进程运行期间主要是就绪、运行、阻塞状态这几种状态轮流转换。
fork 出来的子进程在结束释放资源时,会残留着一些状态信息在 PCB 里,只有等到父进程调用 wait 或 waitpid 函数来取走这些信息,才会真正的结束。
当父进程比子进程先结束,那么此时子进程会交给 init 进程管理。当子进程结束时,即使没有原来的父进程去收走那些残留信息也没关系,因为 init 进程会接手管理。
如果子进程先结束,此时父进程还没结束,又没有调用 wait/waitpid 来取走相关信息,导致子进程的进程描述符一直残留在系统里,那么就会一直占用着资源。当父进程也结束后,就会变为僵尸进程,此时的僵尸进程还是得交给 init 进程管理,由 init 进程统一清理。
在内核态下,操作系统可以访问任意数据以及进行任意操作,而用户态下是有限制的,只能访问指定的内存。内核态相当于拥有了全部权利,用户态让用户程序对系统的操作是有边界的,只能使用系统开放的能力。
当发生了程序调度的时候,势必涉及到当前进程状态的保存,以及另一个即将运行的进程的状态加载。这一过程被称之为上下文切换
。进程的上下文信息是保存在 PCB,也就是进程控制块里的,保存的信息包括了 CPU 寄存器的值、进程状态和内存管理信息。
上下文切换是纯粹的性能开销,因为在此过程中,操作系统不做任何有用的工作,仅仅只是为了切换而切换。所以这一块会很容易就变成瓶颈,导致我们会经常 new 一个新的进程来提高执行效率。
现代计算机体系是一个为数据处理而设计的结构(冯·诺依曼体系结构),执行指令和数据会同时存放在存储器里。这里的存储器包括了靠近 CPU 的高速缓存(cache),也包括了我们熟悉的内存条这种主存储器(RAM);当然,还有像磁盘这种廉价但速度较慢的外存储器。通过这些存储器的分工合作,使得程序具备长期记忆、快速运算的特点。
在早期的操作系统里,物理内存都是裸奔在 CPU 面前的,也就是程序可以直接操作物理内存。而内存的分配方式采用的是连续分配管理,也就是用户程序将会得到一段连续的地址空间,主要有单一连续和分区式分配方式:
紧缩技术
合并这些碎片。上面这种直接访问物理内存的方式会让程序变得很脆弱,很容易就出现访问冲突和内存碎片。为此,操作系统提供了一种机制,将程序要访问的地址和真实的物理地址进行了隔离,抽象出了面向程序的虚拟地址空间。
当程序运行起来后,每个程序都有属于自己的虚拟地址空间,这种虚拟地址空间的好处就在于每个程序所看到的内存地址都是对自己负责的,跟其他程序互不干扰,不用担心冲突的问题。而且一开始并不会分配真正的物理内存,只有当 CPU 需要操作这个虚拟地址的时候,才会通过 一个内存管理单元(MMU)来映射真正的物理地址。
上面这种抽象出虚拟地址的方式,使得程序看到的地址空间将不再需要和底层的物理内存地址空间一一对应,也就是说,一个程序的真实物理地址将可以是离散的,不需要一直连续的,而这更有利于物理内存的高效利用,只需要有一个类似映射关系机制即可。而这种设计主要有分段管理和分页管理。
分段管理将程序的虚拟地址空间划分成多个段,这些段的划分依据是根据程序自身的逻辑关系来分配的,例如 main 函数的划分为一个段,库函数的划分一个段,数据划分为一个段。总之,这些段是有逻辑含义,可以由用户自己来指定段名。
为了能建立跟物理内存的映射关系,每当创建出一个段的时候,就会在一个段表里维护当前段的信息,段表里的段信息包括了当前段的索引号:段号;当前段的最大长度:段长以及当前段在物理内存里的起始地址:段基地址。
这样的话,只要虚拟地址是由段号和段内偏移量组成的话,那么就可以推算出物理内存地址了:即根据段号在段表里找到当前段基地址,段基地址加上段内偏移量就可以得到真实的物理地址了:
有上面可以看出,段地址其实是二维的,因为需要我们给出段名(段号)以及段内偏移量来标识一个虚拟地址。
分段管理虽然建立起一套映射的机制,但是它包含了逻辑含义,需要用户去指定段名(段号)和段内偏移量。这种管理方式太过于灵活了,如果分配某一个段的段长很大,那么就很容易产生外部内存碎片了。
为此,操作系统提供了分页管理方式,并且对用户是不可见的。它将虚拟地址空间和物理地址空间切割成了一个个固定大小的页(例如在 Linux 里页的大小为 4k),并且它们的映射关系会交由一张页表来维护。
页表里包含了虚拟地址页号和物理地址页号的关联关系,只要我们的虚拟地址包含了页号和页偏移量,那么就可以通过页表找到物理地址页号,根据物理地址页号找到物理内存地址,再加上页偏移量,就得到物理地址了。
页的大小是固定的,而虚拟地址空间大小也是固定的,比如在 32 位的 Linux 系统里,一个虚拟地址空间大小将会分配到 4G,如果按每页为 4k 计算,那么对于一个程序来讲,操作系统就要管理 100 多万个页了。如果有多个程序同时运行,那对于操作系统来讲,压力将会很大,效率也提不上去。
所以,操作系统进行了多级管理,例如,将这 100 多万个页先拆分到 1024 个页表里,每个页表管理着 1024 个页项。这样就缩小了管理页数,而且很多时候程序可能也就需要 100 多 M 的地址空间,并不会真正的用上所有的地址空间,所以后面的页表也并不需要去创建出具体的页项,可以等到使用的时候再创建。
分段管理让程序的内存分配有了逻辑含义,能更好的满足用户需求,而分页管理提高了内存的利用率,减少了外部内存碎片。因此将两者结合,也就是现在操作系统常用的段页式内存管理了。
段页式管理会先将程序划分为多个有逻辑意义的段,比如代码段、数据段等。然后在这些段里进行了按页管理的方式。段页式管理的虚拟地址是由段号、段内页号和页内位移组成。当要根据这些信息查找物理地址时,将会经历下面三个过程:
尽管操作系统为内存管理进行了很多优秀的设计,但对于物理内存来讲,它的上限就是固定的,比如内存条大小为 4G,那再怎么优化,也只能使用 4G 的上限。所以,一旦操作系统检测到没有足够的空闲内存分配时,此时就需要启动“交换”机制了。将那些近期不再使用或不会再用的内存交换到硬盘上,这样就能暂时的空闲出更多的物理内存来使用了。如果有些物理内存加载进来后一直没有被修改过,那么就会直接删除,等到下次触发缺页中断,重新加载。
因此,怎么合适的去交换这些空闲内存,也是需要用决策的,当前流行操作系统都是采用段页式管理内存的,所以主要有三种“页面置换”算法:
如果页面交换频繁,那么操作系统势必要花更多的时间来执行这些动作,这在操作系统里称之为抖动颠簸,当产生这种现象的时候,CPU 的利用率将会很低,因为内存不能及时反馈获取。
死锁是因为多个进程并发争夺系统资源而互相等待的现象。死锁要满足四个必要条件才会产生,分别是:
处理死锁有四种方法:
常用方法:撤销或挂起一些进程,以便回收一些资源,再将这些资源分配给已处于阻塞状态的进程。
操作硬盘上的文件时,先将文件用一个叫文件描述符来表示,然后将文件中的数据加载到内核的缓冲区,再将缓冲区的数据映射到用户空间,告诉用户可以通过文件描述符来操作某个文件了。
缓冲区的文件数据映射到用户空间这个动作可以让用户进程自己来,即站在用户进程来讲,读写操作都是由用户进程来执行的,这个叫同步 IO。也可以将映射的过程交由操作系统的内核来完成,即所谓的异步 IO。
其中,同步 IO 里,又可以分为阻塞和不阻塞 IO。所谓的阻塞 IO 即用户进程在询问文件数据是否加载到缓冲区时,可以阻塞的等待,直到缓冲区的数据都加载完毕;不阻塞 IO 即用户进程通过不断的询问操作系统,来获取加载结果。
在不阻塞 IO 模式里,如果要监控的文件描述比较多的话。可以使用 select 模型。select 将监控多个文件描述符,再将结果统一的返回给处理程序。
由于 select 能监视的文件描述符有限,一般为 1024 个,为了处理这个瓶颈,后面实现了 poll 这种结构来监控文件描述符的数据到达情况。 即 poll 没有最大数量的限制。
select 和 poll 本质上都需要遍历的去操作文件描述符,效率不高。为此, linux 后面有设计了 epoll 模型,通过在内核里使用一个红黑树的结构来管理注册进来的文件描述符,同时使用了一个就绪链表维护了就绪事件,一旦基于某个文件描述符就绪时,内核会采用类似 callback 的回调机制,将事件通知给用户进程。
创建一个文件时,会有一个 inode 值指向这个文件的具体存储信息,可以理解为这个文件的指针。每当创建硬链接时,就会将文件的引用计数 + 1,直到没有引用时,那么这个文件就会被删除。硬链接不可以在不同文件系统建立链接,而且只有超级用户才可以为目录创建硬链接。
软链接则没有文件系统的限制,它和原来的文件具有不一样的 node 值,并且 inode 里保存了原来文件绝对路径。因此,在删除了原来文件后,软链接会访问无效的。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。