【操作系统】操作系统概念

用户态/内核态
动态链接/静态连接

1 进程管理
1.1 进程状态:
新的:进程正在被创建
运行:指令正在被执行
等待:进程等待某个事件的发生(如I/O完成或收到信号)
就绪:进程等待分配处理器
终止:进程完成执行
1.2 进程控制块(PCB):
每个进程在操作系统中用进程控制块来表示,PCB一般包括:进程状态、程序计数器、CPU寄存器、CPU调度信息、内存管理信息、记账信息、I/O状态
1.3 进程调度
调度队列
就绪队列:通常用链表实现,其中头结点指向链表的第一个和最后一个PCB块的指针。每个PCB包括执行下一个PCB的指针域。
上下文切换:进程的上下文用进程的PCB表示
1.4 进程操作
PID:进程标识符
进程创建
进程终止
父进程/子进程
1.5 进程间通信
进程间通信机制 IPC interprocess communication
进程间通信有两种基本模式:共享内存、消息传递
1.6 线程
线程是CPU使用的基本单元,由线程ID、程序计数器、寄存器集合和栈组成。它与属于同一进程的其他线程共享代码段、数据段、和其他操作系统资源。
多线程
Linux并不区分进程和线程,而是将两者同样对待,讲一个任务视为进程或线程,这取决于传递给clone()系统调用的标志集。
1.7 CPU调度
抢占式/非抢占式
调度算法
2 同步
2.1 进程同步
竞争条件:多个进程并发访问和操作同一数据且执行结果与访问发生特定的顺序有关,称为竞争条件。
临界区问题:
假设某个系统有n个进程{P0, P1, …, Pn-1},每个进程有一个代码段称为临界区,在该区中进程可能改变共同变量、更新一个表、写一个文件等。这种系统的重要特征是当一个进程进入临界区,没有其他进程可被允许在临界区内执行,即没有两个进程可同时在临界区内执行。临界区问题是设计一个以便进程协作的协议。每个进程必须请求进入临界区。实现这一请求的代码段称为进入区,临界区之后可有退出区,其他代码称为剩余区。
do {
进入区
临界区(critical section)
退出区
剩余区(reminder section)
} while(true);

Peterson算法

2.2 信号量
信号量S是个整数变量,除了初始化外,它只能通过两个标准院子操作:wait()和signal()来访问。这些操作原来被称为P(荷兰语proberen,测试)和V(荷兰语verhogen,增加)。
信号量的关键之处是它们原子的执行。
wait()的定义可表示为:(用前要P,减)
wait(S) {
while (S <= 0) {
; // no-op
}
S–;
}

signal的定义可以表示为:(用完要V,增加)
signal(S) {
S++;
}

互斥锁:二进制信号量的值只能为0或者1,将二进制信号量称为互斥锁。

自旋锁 忙等待

2.2.1 生产者消费者问题
也叫有限缓冲问题,一组生产者进程和消费者进程共享一个初始为空,大小为n的缓冲区。只有当缓冲区没满的时候,生产者才能将消息放进去。同理,只有当缓冲区不空的时候,消费者才能从中取消息,否则必须等待。由于缓冲区是临界资源,它只允许一个生产者放入消息,也只允许一个消费者拿出消息。这里我再解释一下,意思是,同一个时刻只能是一个生产者或者一个消费者操作缓冲区,禁止以下情况:多个生产者或者多个消费者操作缓冲区,同样,一个生产者和一个消费者同时操作也是禁止的。

semaphore mutex = 1; //临界区互斥信号量
semaphore empty = n; //空闲缓冲区为n
semaphore full = 0; //缓冲区初始化为空

生产者进程:
do {

//produce an item in nextp

wait(empty);
wait(mutex);

//add nextp to buffer

signal(mutex);
signal(full);
} while(TRUE);

消费者进程:
do {
wait(full);
wait(mutex);

//remove an item from buffer to nextc

signal(mutex);
signal(empty);

//consume the item in nextc

} while(TRUE);
2.2.2 读者写者问题
有读者和写者两组并发进程,共享一个文件,当两个或以上的读进程同时访问共享数据时不会产生副作用,但若某个写进程和其他进程(读进程或写进程)同时访问共享数据时则可能导致数据不一致的错误。因此要求:①允许多个读者可以同时对文件执行读操作;②只允许一个写者往文件中写信息;③任一写者在完成写操作之前不允许其他读者或写者工作;④写者执行写操作前,应让已有的读者和写者全部退出。
读者优先(第一读者-写者问题),要求没有读者需要等待除非已有一个写着已获得允许以使用共享数据库/文件。这种情况写者可能饥饿。
写者优先(第二读者-写者问题),一旦写者就绪,那么写着会尽可能快得执行其写操作。这种情况读者可能饥饿。
读者优先代码:
semaphore mutex = 1; // 用于确保更新变量readcount时的互斥
semaphore wrt = 1; //信号量wrt为读者和写者进程公用
int readcount = 0; //表示有多少个进程正在读对象

写者进程:
do {
wait(wrt);

//writing is performed

signal(wrt);
} while(TRUE);

读者进程:
do {
wait(mutex);
readcount++;
if (readcount == 1) { // 当第一个读进程读共享文件时, 阻止写进程写
wait(wrt);
}
signal(mutex);

// reading is performed

wait(mutex);
readcount–;
if (readcount == 0) { // 当最后一个读进程读完共享文件, 允许写进程写
signal(wrt);
}
signal(mutex);
} while(TRUE);
2.2.3 哲学家就餐问题
有五个哲学家,他们的生活方式是交替地进行思考和进餐。他们共用一张圆桌,分别坐在五张椅子上。
在圆桌上有五个碗和五支筷子,平时一个哲学家进行思考,饥饿时便试图取用其左、右最靠近他的筷子,只有在他拿到两支筷子时才能进餐。进餐完毕,放下筷子又继续思考。
在这里插入图片描述

需要在多个进程之间分配多个资源且不会出现死锁和饿死的典型例子。
一个简单的解决方法是每只筷子都用一个信号量来表示。一个哲学家通过执行wait()操作试图获取相应的筷子,他会通过执行signal()操作释放相应的筷子。
代码:
Semaphore chopsticks[5];
其中所有chopsticks的元素初始化为1。哲学家i的结构图如下:
do {
wait(chopsticks[i]);
wait(chopsticks[i + 1] % 5);

//eat

signal(chopsticks[i]);
signal(chopsticks[i + 1] % 5);

//think

} while(TRUE);

上述解决方法会导致死锁。
2.3 管程
管程 (英语:Monitors,也称为监视器) 是一种程序结构,结构内的多个子程序(对象或模块)形成的多个工作线程互斥访问共享资源。这些共享资源一般是硬件或一群变量。管程实现了在一个时间点,最多只有一个线程在执行管程的某个子程序。与那些通过修改数据结构实现互斥访问的并发程序设计相比,管程实现很大程度上简化了程序设计。
管程提供了一种机制,线程可以临时放弃互斥访问,等待某些条件得到满足后,重新获得执行权恢复它的互斥访问。
2.3.1 信号量 VS 管程
  上面的介绍可以看到,信号量与管程都能够解决我们再编程中遇到的同步互斥问题,所以,难免需要对二者进行对比。首先,二者本质上时互通的,hoare在论文中也证明了可以用信号量实现管程、也可以用管程实现信号量。下面简单归纳一下二者的区别:
• 信号量本质是可共享的资源的数量; 而管程是一种抽象数据结构用来限制同一时刻只有一个线程进入临界区
• 信号量是可以并发的,并发量取决于S初始值;而管程内部同一时刻最多只能有一个线程执行
• 信号量与管理的资源紧耦合(即信号量S的初始值等同于资源的数目,且通过P V操作修改剩余可用的资源数量);而在管程中需自行判断是否还有可共享的资源。这一点可以参见下面生产者消费者的实现代码
• 信号量的P操作可能阻塞,也可能不阻塞;而管程的wait操作一定会阻塞
• 信号量的V操作如果唤醒了其他线程,当前线程与被唤醒线程并发执行;对于管程的signal操作,要么当前线程继续执行(Hansen),要么被唤醒线程继续执行(Hoare),二者不能并发。

2.4 Linux实现
Pthread API为线程同步提供互斥锁、条件变量、读写锁、。

2.5 死锁
2.5.1 必要条件:
互斥
占有并等待
非抢占
循环等待
2.5.2 死锁处理方法:
(1)可使用协议以预防或避免死锁,确保系统不会进入死锁状态;
(2)可允许系统进入死锁状态,然后检测他,并加以恢复;
(3)可忽视这个问题,认为死锁不存在;
第三种方法为大多数操作系统所采用,包括UNIX和Windows。
3 内存管理
3.1 背景
CPU所能直接访问的存储器只有内存和处理器内部的寄存器。CPU只能从内存和处理器内的寄存器中读取指令或数据,如果数据不在内存或缓存中,那么CPU必须先通过指令将数据从外存中转移到内存中。
首先确保每个进程都有独立的内存空间。需要确定进程可以访问的合法地址的范围,并确保进程只访问其合法地址。
基地址寄存器
界限地址寄存器
使用基地址寄存器保存用户进程合法的最小物理地址,使用界限地址寄存器保存用户进程的地址的范围大小
在这里插入图片描述

3.2 地址绑定
输入队列:进程在执行时可以在磁盘和内存之间移动。在磁盘上等待调入内存以便执行的进程形成输入队列(input queue)。

在进程装入内存时,指令和数据应该装入内存的哪一块地址,应该如何分配,也就是地址绑定(Address binding)的方式。
地址绑定通常在以下几个阶段发生:
编译时(compile time),如果编译时知道进程将要驻留在内存中的绝对地址,那么就可以生成绝对代码(absolute code),但这种方式一旦开始地址发生变化,就需要重新编译。
加载时(load time),如果编译时不清楚绝对地址,那么编译器就必须生成可重定位代码(relocatable code),这样地址绑定就会延迟到加载时进行,这样开始地址如果变化,只需要在加载时引入改变值即可
执行时(execution time),如果一个进程在执行时可以从一个内存段移动到另一个内存段,那么绑定就必须发生在执行时。执行时完成地址绑定是绝大多数通用计算机系统采用的方法。

逻辑地址(logical address):CPU生成的地址称为逻辑地址
物理地址(physical address):加载到内存地址寄存器中的地址称为物理地址。
逻辑地址空间(logical address space):由程序生成的所有逻辑地址的集合。
物理地址空间(physical address space):由这些逻辑地址所有相对应的物理地址的集合。

内存管理单元:当虚拟地址和物理地址不同时,需要通过一个映射关系来完成两者的转换,完成这个操作的设备称为内存管理单元(memory-management unit,MMU)。

3.3 内存分配
3.3.1 连续内存分配
外部碎片
内部碎片

3.3.2 分页
分页是另一种内存管理方案,它允许进程的物理地址空间是非连续的。
• 物理内存分为固定大小的块,称为帧(frame)
• 将逻辑内存也分为同样大小的块,称为页(page)
执行进程时,进程以页面为单位加载到可用的帧中(可以不连续),CPU在生成地址时,生成相应的页号§和页偏移(d)。每个进程都拥有了一个页表,页号是页表的索引,页表能够通过索引找到每页所在的物理内存的基地址,而基地址与页偏移的组合代表了真正的物理地址。
在这里插入图片描述

3.3.3 分段
分段是支持用户视角的内存管理方案。
3.4 虚拟内存
3.4.1 背景
内存管理算法都是基于一个基本要求:执行指令必须在物理内存中,满足这一要求的第一种方法是整个进程放在内存中。动态载入能帮助减轻这一限制,但是它需要程序员特别小心地做一些额外的工作。
指令必须都在物理内存内的这一限制,似乎是必须和合理的,但也是不幸的,因为这使得程序的大小被限制在物理内存的大小内。事实上,研究实际程序会发现,许多情况下并不需要将整个程序放到内存中。即使在需要完整程序的时候,也并不是同时需要所有的程序。
因此运行一个部分在内存中的程序不仅有利于系统,还有利于用户。
虚拟内存(virtual memory)将用户逻辑内存和物理内存分开。这在现有物理内存有限的情况下,为程序员提供了巨大的虚拟内存。
在这里插入图片描述

3.4.2 按需分配
一个执行程序从磁盘载入内存的时候有两种方法。

  1. 选择在程序执行时,将整个程序载入到内存中。不过这种方法的问题是可能开始并不需要整个程序在内存中。如有的程序开始时带有一组用户可选的选项。载入整个程序,也就将所有选项的执行代码都载入到内存中,而不管这些选项是否使用。
  2. 另一种选择是在需要时才调入相应的页。这种技术称为按需调页(demand paging),常为虚拟内存系统所采用。

按需调页系统看类似于使用交换的分页系统,进程驻留在第二级存储器上(通常为磁盘)。当需要执行进程时,将它换入内存。不过,不是讲整个进程换入内存,而是使用懒惰交换(lazy swapper)。懒惰交换只有在需要页时,才将它调入内存。由于将进程看做是一系列的页,而不是一个大的连续空间,因此使用交换从技术上来讲并不正确。交换程序(swapper)对整个进程进行操作,而调页程序(pager)只是对进程的单个页进行操作。因此, 在讨论有关按需调页时,需要使用调页程序而不是交换程序。
在这里插入图片描述
当换入进程时,调页程序推测在该进程再次换出之前使用到的哪些页,仅仅把需要的页调入内存。从而减少交换时间和所需的物理内存空间。
这种方案需要硬件支持区分哪些页在内存,哪些在磁盘。采用有效/无效位来表示。当页表中,一个条目的该位为有效时,表示该页合法且在内存中;反之,可能非法,也可能合法但不在内存中。
在这里插入图片描述

如果进程从不试图访问标记为无效的页,那么并没有什么影响,因此,如果推测正确且只调入所有真正需要的页,那么进程就可如同所有页都调入内存一样正常运行。
当进程试图访问这些尚未调入内存的页时,会引起页错误陷阱(page-fault trap)。这种情况的处理方式如下:

1)检查进程的内部页表(通常与PCB一起保存)。以确定该引用是的合法还是非法的地址访问。
2)如果非法,则终止进程;如果引用有效但是尚未调入页面,则现在进行调入。
3)找到一个空闲帧(如,从空闲帧表中选取一个)。
4)调度一个磁盘操作,以便将所需页调入刚分配的帧
5)磁盘读操作完成后,修改进程的内部表和页表,表示该页已在内存中。
6)重新开始因陷阱而中断的指令。
在这里插入图片描述

3.4.3 页面置换
操作系统为何要进行页面置换呢?这是由于操作系统给用户态的应用程序提供了一个虚拟的“大容量”内存空间,而实际的物理内存空间又没有那么大。所以操作系统就就“瞒着”应用程序,只把应用程序中“常用”的数据和代码放在物理内存中,而不常用的数据和代码放在了硬盘这样的存储介质上。如果应用程序访问的是“常用”的数据和代码,那么操作系统已经放置在内存中了,不会出现什么问题。但当应用程序访问它认为应该在内存中的的数据或代码时,如果这些数据或代码不在内存中,则根据上文的介绍,会产生缺页异常。这时,操作系统必须能够应对这种缺页异常,即尽快把应用程序当前需要的数据或代码放到内存中来,然后重新执行应用程序产生异常的访存指令。如果在把硬盘中对应的数据或代码调入内存前,操作系统发现物理内存已经没有空闲空间了,这时操作系统必须把它认为“不常用”的页换出到磁盘上去,以腾出内存空闲空间给应用程序所需的数据或代码。

操作系统迟早会碰到没有内存空闲空间而必须要置换出内存中某个“不常用”的页的情况。如何判断内存中哪些是“常用”的页,哪些是“不常用”的页,把“常用”的页保持在内存中,在物理内存空闲空间不够的情况下,把“不常用”的页置换到硬盘上就是页面置换算法着重考虑的问题。容易理解,一个好的页面置换算法会导致缺页异常次数少,也就意味着访问硬盘的次数也少,从而使得应用程序执行的效率就高。

基本页置换:
1)查找需要页在磁盘上的位置。
2)查找一空闲帧:
3)如果有空闲帧,那么就使用它
4)如果没有空闲帧,那么就是用页置换算法选择一个“牺牲”帧(victim frame)
5)将牺牲帧的内容放到磁盘上,改变页表和帧表。
6)将所需页读入(新)空闲帧,改变页表和帧表。
7)重启用户进程。
在这里插入图片描述
页置换是按需调页的基础。为锁骨下班按需调页,必须解决两个主要问题:必须开发帧分配算法(frame-allocation algorithm)和页置换算法(page-replacement algorithm)。如果在内存中有多个进程,那么必须决定为每个进程各分配多少帧。而且,当需要页置换时,必须选择要置换的帧。

页置换算法:
FIFO页置换
最优(Optimal)置换
LRU(Least Recently Used)页置换
近似LRU页置换
基于计数的页置换
3.4.4 帧分配
基本策略:用户进程会分配到任何空闲帧。
分配算法
1)平均分配,每个进程一样多
2)按进程大小使用比例分
3)按进程优先级分
4)大小和优先级组合分

全局分配 局部分配 3.4.5 系统颠簸 4 中断与异常