本文章转载自:https://cyc2018.github.io/CS-Notes/#/notes/计算机操作系统
并发是指宏观上在一段时间内能够同时运行多个程序,而并行则指同一时刻能运行多个指令。
并行需要硬件支持,如多流水线或者多处理器。
操作系统通过引入进程和线程,使得程序能够并发运行。
共享是指系统中的资源可以被多个并发进程共同使用。
有两种共享方式:互斥共享和同时共享。
互斥共享的资源称为临界资源,例如打印机等,在同一时间只允许一个进程访问,需要用同步机制来实现对临界资源的访问。
虚拟技术把一个物理实体转换为多个逻辑实体。
主要有两种虚拟技术:时分复用技术和空分复用技术。
多个进程能在同一个处理器上并发执行使用了时分复用技术,让每个进程轮流占有处理器,每次只执行一小个时间片并快速切换。
虚拟内存使用了空分复用技术,它将物理内存抽象为地址空间,每个进程都有各自的地址空间。地址空间和物理内存使用页进行交换,地址空间的页并不需要全部在物理内存中,当使用到你一个没有在物理内存的页时,执行页面置换算法,将该页置换到内存中。
异步指进程不是一次性执行完毕,而是走走停停,以不可知的速度向前推进。
进程控制,进程同步,进程通信,死锁处理,处理机调度等。
内存分配,地址映射,内存保护与共享,虚拟内存等。
文件存储空间的管理,目录管理,文件读写管理和保护等。
完成用户的I/O请求,方便用户使用各种设备,并提高设备的利用率。
主要包括缓冲管理,设备分配,设备处理,虚拟设备等。
如果一个进程在用户态需要使用内核态的功能,就进行系统调用从而陷入内核,由操作系统代为完成。
Linux的系统调用主要有以下这些:
Task | Commands |
---|---|
进程控制 | fork();exit();wait(); |
进程通信 | pipe();shmget();mmap(); |
文件操作 | open();read();write(); |
设备操作 | ioctl();read();write(); |
信息维护 | getpid();alarm();sleep(); |
安全 | chmod();umask();chown(); |
大内核是将操作系统功能作为一个紧密结合的整体放到内核。
由于各模块共享信息,因此有很高的性能。
由于操作系统不断复杂,因此将一部分操作系统功能移除内核,从而降低内核的复杂性。移出的部分根据分层的原则划分成若干服务,相互独立。
在微内核结构下,操作系统被划分成小的,定义良好的模块,只有微内核这以模块运行在内核态,其余模块运行在用户态。
因为需要频繁地在用户态和核心态之间进行切换,所以会有一定的性能损失。
由CPU执行指令以外的事件引起,如I/O完成中断,表示设备输入\输出处理已经完成,处理器能够发送下一个输入\输出请求。此外还有时钟中断,控制台中断等。
由CPU执行指令的内部事件引起,如非法操作码,地址越界,算术溢出等。
在用户程序中使用系统调用。
进程是资源分配的基本单位。
进程控制块(PCB)描述进程的基本信息和运行状态,所谓的创建进程和撤销进程,都是指对PCB的操作。
下图显示了4个程序创建了4个进程,这4个进程可以并发地执行。
线程是独立调度的基本单位。
一个进程中可以有多个线程,它们共享进程资源。
QQ和浏览器是两个进程,浏览器进程里面有很多线程,例如HTTP请求线程,事件响应线程,渲染线程等等,线程的并发执行使得在浏览器中点击一个新链接从而发起HTTP请求时,浏览器还可以响应用户的其他事件。
Ⅰ 拥有资源
进程是资源分配的基本单位,但线程不拥有资源,线程可以访问隶属进程的资源。
Ⅱ 调度
线程是独立调度的基本单位,在同一进程中,线程的切换不会引起进程切换,从一个进程中的线程切换到另一个进程中的线程时,会引起进程切换。
Ⅲ 系统开销
由于创建或撤销进程时,系统都要为之分配或回收资源,如内存空间,I/O设备等,所付出的开销远大于创建或撤销线程时的开销。类似地,在进行进程切换时,涉及当前执行进程CPU环境的保存及新调度进程CPU环境的设置,而线程切换时只需保存和设置少量寄存器内容,开销很小。
Ⅳ 通信方面
线程间可以通过读写同一进程中的数据进行通信,但是进程通信需要借助IPC.
不同环境的调度算法目标不同,因此需要针对不同环境来讨论调度算法。
批处理系统没有太多的用户操作,在该系统中,调度算法目标是保证吞吐量和周转时间(从提交到终止的时间)。
按照请求的顺序进行调度。
有利于长作业,但不利于短作业,因为短作业必须一直等待前面的长作业执行完毕才能执行,而长作业又需要执行很长时间,造成了段作业等待时间过长。
按估计运行时间最短的顺序进行调度。
长作业有可能会饿死,处于一直等待短作业执行完毕的状态。因为如果一直有短作业到来,那么长作业永远得不到调度。
按估计剩余时间最短的顺序进行调度。
交互式系统有大量的用户交互操作,在该系统中调度算法的目标是快速地进行响应。
将所有就绪进程按 FCFS 的原则排成一个队列,每次调度时,把CPU时间分配给队首进程,该进程可以执行一个时间片。当时间片用完时,由计时器发出时钟中断,调度程序便停止该进程的执行,并将它送往就绪队列的末尾,同时继续把CPU时间分配给队首的进程。
时间片轮转算法的效率和时间片的大小有很大关系:
为每个进程分配一个优先级,按优先级进行调度。
为了防止低优先级永远得不到调度,可以随着时间的推移增加等待进程的优先级。
一个进程需要执行100个时间片,如果采用时间片轮转调度算法,那么需要交换100次。
多级队列是为这种需要连续执行多个时间片的进程考虑,它设置了多个队列,每个队列的时间片大小都不同,例如1,2,3,8,…。进程在第一个队列没执行完,就会被移到下一个队列。这种方式下,之前的进程只需要交换7次。
每个队列优先权也不同,最上面的优先权最高。因此只有上一个队列没有进程在排队,才能调度当前队列上的进程。
可以将这种调度算法看成是时间片轮转调度算法和优先级调度算法的结合。
实时系统要求一个请求在一个确定时间内得到响应。
分为硬实时和软实时,前者必须满足绝对的截止时间,后者可以容忍一定的超时。
对临界资源进行访问的那段代码称为临界区。
为了互斥访问临界资源,每个进程在进入临界区之前,需要先进行检查。
// entry section
// critical section;
// exit section
信号量(Semaphore)是一个整型变量,可以对其执行down和up操作,也就是常见的P和V操作。
typedef int semaphore;
semaphore mutex = 1;
void P1() {
down(&mutex);
// 临界区
up(&mutex);
}
void P2() {
down(&mutex);
// 临界区
up(&mutex);
}
使用信号量实现生产者-消费者问题
问题描述:使用一个缓冲区来保存物品,只有缓冲区没有满,生产者才可以放入物品;只有缓冲区不为空,消费者才可以拿走物品。
因为缓冲区属于临界资源,因此需要使用一个互斥量 mutex 来控制对缓冲区的互斥访问。
为了同步生产者和消费者的行为,需要记录缓冲区中物品的数量。数量可以使用信号量来进行统计,这里需要使用两个信号量:empty 记录空缓冲区的数量,full记录满缓冲区的数量。其中,empty 信号量是在生产者进程中使用,当 empty 不为0时,生产者才可以放入物品;full 信号量是在消费者进程中使用,当 full 信号量不为0时,消费者才可以取走物品。
注意,不能先对缓冲区进行加锁,再测试信号量。也就是说,不能先执行 down(mutex)再执行 down(empty)。如果这么做了,那么可能会出现这种情况:生产者对缓冲区加锁后,执行down(empty)操作,发现empty = 0,此时生产者睡眠。消费者不能进入临界区,因为生产者对缓冲区加锁了,消费者就无法执行up(empty)操作,empty永远都为0,导致生产者永远等待下,不会释放锁,消费者因此也会永远等待下去。
#define N 100
typedef int semaphore;
semaphore mutex = 1;
semaphore empty = N;
semaphore full = 0;
void producer() {
while(TRUE) {
int item = produce_item();
down(&empty);
down(&mutex);
insert_item(item);
up(&mutex);
up(&full);
}
}
void consumer() {
while(TRUE) {
down(&full);
down(&mutex);
int item = remove_item();
consume_item(item);
up(&mutex);
up(&empty);
}
}
使用信号量机制实现的生产者消费者问题需要客户端代码做很多控制,而管程把控制的代码独立出来,不仅不容易出错,也使得客户端代码调用更容易。
c语言不支持管程,下面的示例代码使用了类Pascal语言描述管程。示例代码的管程提供了insert()和remove()方法,客户端代码通过调用这两个方法来解决生产者-消费者问题。
monitor ProducerConsumer
integer i;
condition c;
procedure insert();
begin
// ...
end;
procedure remove();
begin
// ...
end;
end monitor;
管程有一个重要特性:在一个时刻只能有一个进程使用管程。进程在无法继续执行的时候不能一直占用管程,否则其他进程永远不能使用管程。
管程引入了条件变量以及相关的操作:wait()
和signal()
来实现同步操作。对条件变量执行wait()
操作会导致调用进程阻塞,把管程让出来给另一个进程持有。signal()
操作用于唤醒被阻塞的进程。
使用管程实现生产者-消费者问题
//管程
monitor ProducerConsumer
condition full, empty;
integer count := 0;
condition c;
procedure insert(item: integer);
begin
if count = N then wait(full);
insert_item(item);
count := count + 1;
if count = 1 then signal(empty);
end;
function remove: integer;
begin
if count = 0 then wait(empty);
remove = remove_item;
count := count - 1;
if count = N - 1 then signal(full);
end;
end monitor;
//生产者客户端
procedure producer
begin
while true do
begin
item =produce_item;
ProducerConsumer.insert(item);
end
end;
// 消费者客户端
procedure consumer
begin
while true do
begin
item = ProducerConsumer.remove;
consume_item(item);
end
end;
生产者和消费者问题前面已经讨论过了。
允许多个进程同时对数据进行读操作,但是不允许读和写以及写和写操作同时发送。
一个整型变量count记录在对数据进行读操作的进程数量,一个互斥量 count_mutex 用于对 count 加锁,一个互斥量 data_mutex 用于对读写的数据加锁。
typedef int semaphore;
semaphore count_mutex = 1;
semaphore data_mutex = 1;
int count = 0;
void reader() {
while(TRUE) {
down(&conut_mutex);
count++;
if(count = 1)down(&data_mutex);//第一个读者需要对数据进行加锁,防止写进程访问
up(&count_mutex);
read();
down(&count_mutex);
count--;
if(count == 0) up(&data_mutex);
up(&count_mutex);
}
}
void write() {
while(TRUE) {
down(&data_mutex);
write();
up(&data_mutex);
}
}
五个哲学家围着一张圆桌,每个哲学家面前放着食物。哲学家的生活有两种交替活动:吃饭以及思考。当一个哲学家吃饭时,需要先拿起自己左右两边的两根筷子,并且一次只能拿起一根筷子。
下面是一种错误的解法,考虑到如果所有哲学家同时拿起左手边的筷子,那么就无法拿起右手边的筷子,造成死锁。
#define N 5
void philosopher(int i) {
while(TRUE) {
think();
take(i); //拿起左边的筷子
take((i+1)%N);//拿起右边的筷子
eat();
put(i);
put((i+1)%N);
}
}
为了防止死锁的发送,可以设置两个条件:
#define N 5
#define LEFT (i + N - 1) % N //左邻居
#define RIGHT (i + 1) % N //右邻居
#define THINKING 0
#define HUNGRY 1
#define EATING 2
typedef int semaphore;
int state[N]; // 跟踪每个哲学家的状态
semaphore mutex = 1; // 临界区的互斥
semaphore s[N]; //每个哲学家一个信号量
void philosopher(int i) {
while(TRUE) {
think();
take_two(i);
eat();
put_two(i);
}
}
void take_two(int i) {
down(&mutex);
state[i] = HUNGRY;
test(i);
up(&mutex);
down(&s[i]);
}
void put_two(int i) {
down(&mutex);
state[i] = THINKING;
test(LEFT);
test(RIGHT);
up(&mutex);
}
void test(int i) { // 尝试拿起两把筷子
if(state[i] == HUNGARY && state[LEFT] != EATING && state[RIGHT] != EATING) {
state[i] = EATING;
up(&s[i]);
}
}
进程同步与进程通信很容易混淆,它们的区别在于:
管道是通过调用pipe
函数创建的,fd[0]用于读,fd[1]用于写。
#include
int pipe(int fd[2]);
它具有以下限制:
也成为命名管道,去除了管道只能在父子进程中使用的限制。
#include
int mkfifo(const char *path, mode_t mode);
int mkfifoat(int fd, const char *path, mode_t mode);
FIFO 常用于客户-服务器应用程序中,FIFO用作汇聚点,在客户进程和服务器进程之间传递数据。
相比于FIFO,消息队列具有以下优点:
它是一个计数器,用于为多个进程提供对共享数据对象的访问。
允许多个进程共享一个给定的存储区。因为数据不需要在进程之间复制,所以这是最快的一种IPC。
需要使用信号量用来同步对共享存储的访问。
多个进程可以将同一个文件映射到它们的地址空间从而实现共享内存。另外XSI共享内存不是使用文件,而是使用内存的匿名段。
与其他通信机制不同的是,它可用于不同机器间的进程通信。
主要有以下四种方法:
把头埋在沙子里,假装根本没发生问题。
因为解决死锁问题的代价很高,因此鸵鸟策略这种不采取任务措施的方案会获得更高的性能。
当发生死锁时不会对用户造成多大影响,或发生死锁的概率很低,可以采用鸵鸟策略。
大多数操作系统,包括Unix,Linux和Windows,处理死锁问题的方法仅仅是忽略它。
不试图阻止死锁,而是当检测到死锁发生时,采取措施进行恢复。
上图为资源非配图,其中方框表示资源,圆圈表示进程。资源指向进程表示该资源已经分配给该进程,进程指向资源表示进程请求获得该资源。
图a可以抽取出环,如图b,它满足了环路等待条件,因此会发生死锁。
每种类型一个资源的死锁检测算法是通过检测有向图是否存在环来实现,从一个节点出发进行深度优先搜索,对访问过的节点进行标记,如果访问了已经标记的节点,表示有向图存在环,也就是检测到死锁的发生。
在程序运行之前预防发生死锁
例如假脱机打印机技术允许若干个进程同时输出,唯一真正请求物理打印机的进程是打印机的守护进程。
一种实现方式是规定所有进程在开始执行强请求所需要的全部资源。
给资源统一编号,进程只能按编号顺序来请求资源。
在程序运行时避免发生死锁。
图a的第二列 Has 表示已拥有的资源数,第三列 Max 表示总共需要的资源数,Free 表示还有可以使用的资源数。从图 a 开始出发,先让 B 拥有所需的所有资源(图 b ),运行结束后释放B,此时 Free 变为 5(图 c );接着以同样的方式运行 C 和 A ,使得所有进程都能成功运行,因此可以称图 a 所示的状态是安全的。
定义:如果没有死锁发生,并且即使所有进程突然请求对资源的最大需求,也仍然存在某种调度次序能够使得每一个进程运行完毕,则称该状态是安全的。
安全状态的检测与死锁的检测类似,因为安全状态必须要求不能发生死锁。下面的银行家算法与死锁检测算法非常类似,可以结合着做参考对比。
一个小城镇的银行家,他向一群客户分别承诺了一定的贷款额度,算法要做的是判断对请求的满足是否会进入不安全状态,如果是,就拒绝请求;否则予以分配。
上图 c 为不安全状态,因此算法会拒绝之前的请求,从而避免进入图 c 中的状态。
上图中有五个进程,四个资源。左边的图表示已经分配的资源,右边的图表示还需要分配的资源。最右边的E,P以及A分别表示:总资源,已分配资源以及可用资源,注意这三个为向量,而不是具体数值,例如 A={1020},表示4个资源分别还剩 1/0/2/0。
检查一个状态是否安全的算法如下:
如果一个状态不是安全的,需要拒绝进入这个状态。
虚拟内存的目的是为了让物理内存扩充成更大的逻辑内存,从而让程序获得更多的可用内存。
为了更改好的管理内存,操作系统将内存抽象成地址空间。每个程序拥有自己的地址空间,这个地址空间被分割陈多个块,每一块称为一页。这些页被映射到物理内存,但不需要映射到连续的物理内存,也不需要所有页都必须在物理内存中。但程序应用到不再物理内存中的页时,由硬件执行必要的映射,将确实的部分转入物理内存并重新执行失败的指令。
从上面的描述中可以看出,虚拟内存允许程序不用将地址孔径啊的每一页都映射到物理内存,也就是说一个程序不需要全部调入内存就可以允许,这使得有限的内存允许大程序成为可能。例如有一台计算机可以产生16位地址,那么一个程序的地址空间范围是0~64K。该计算机只有32KB的物理内存,虚拟内存技术允许该计算机运行一个64K大小的程序。
内存管理单元(MMU)管理着地址空间和物理内存的转换,其中的页表(Page table)存储着页(程序地址空间)和页框(物理内存空间)的映射表。
一个虚拟地址分成两个部分,一部分存储页面号,一部分存储偏移量。
下图的页表存放着16个页,这16个页需要4个比特位来进行索引定位。例如对于虚拟地址(0010000000000100),前4为是存储页面号2,读取表项内容为(110 1),页表项最后一位表示是否存在于内存中,1表示存在。后12位存储偏移量。这个页对应的页框的地址为(110 000000000100)。
在程序运行过程中,如果要访问的页面不再内存中,就发生缺页中断从而将该页调入内存中。此时如果内存已无空闲空间,系统必须从内存中调出一个页面到磁盘对换区中来腾出空间。
页面置换算法和缓存淘汰策略类似,可以将内存看成磁盘的缓存。在缓存系统中,缓冲的大小有限,当有新的缓存到达时,需要淘汰一部分已经存在的缓存,这样才有空间存放新的缓存数据。
页面置换算法的主要目标是使页面置换频率最低(也可以说缺页率最低)。
OPT,Optimal replacement algorithm
所选择的被换出的页面将是最长时间内不再被访问,通常可以保证获得最低的缺页率。
是一种理论上的算法,因为无法直到一个页面多长时间不再被访问。
举例:一个系统为某进程分配了三个物理快,并有如下页面引用序列:
70120304230321201701 70120304230321201701 70120304230321201701
开始运行时,先将7,0,1三个页面装入内存。当进程要访问页面2时,产生缺页中断,会将页面7换出,因为页面7再次被访问的时间最长。
LRU,Least Recently Used
虽然无法知道将来要使用的页面情况,当是可以知道过去使用页面的情况。LRU将最近最久未使用的页面换出。
为了实现LRU,需要在内存中维护一个所有页面的量表。当一个页面被访问时,将这个页面移到链表表头。这样就能保证链表表尾的页面是最近最久未访问的。
因为每次访问都需要更新链表,因此这种方式实现的 LRU 代价很高。
47071012126 47071012126 47071012126
NRU,Not Recently Used
每个页面都有两个状态页:R 与 M,当页面被访问时设置页面的 R=1,当页面被修改时设置 M=1.其中R位会顶死被清零。可以将页面分成以下四类:
FIFO,First In First Out
选择换出的页面是最先进入的页面。
该算法会将哪些经常被访问的页面也被换出,从而使缺页率升高。
FIFO 算法可能会将经常使用的页面置换出去,为了避免这一问题,对该算法做了一个简单的修改:
当页面被访问(读或写)时设置该页面的R位为1.需要替换的时候,检查最先进入的页面的R位。如果R位是0,那么这个页面既老又没有被使用,可以立刻置换掉;如果是1,就将R位清0,并把该页面放到链表的尾端,修改它的转入时间使它就像刚装入的一样,然后继续从链表的头部开始搜索。
Clock
第二次机会算法需要在链表中移动页面,降低了效率。时钟算法使用环形链表将页面连接起来,再使用一个指针指向最老的页面。
虚拟内存采用的是分页技术,也就是将地址空间划分成固定大小的页,每一页再与内存进行映射。
下图为一个编译器在编译过程中建立的多个表,有4个表是动态增长的,如果使用分页系统的一维地址空间。动态征战的特点会导致覆盖问题的出现。
分段的做法是把每个表分成段,一个段构成一个独立的地址空间。每个段的长度可以不同,并且可以动态增长。
程序的地址空间划分成多个拥有独立地址空间的段,每个段上的地址空间划分成大小相同的页。这样既拥有分段系统的共享和保护,又拥有分页系统的虚拟内存功能。
读写一个磁盘块的时间的影响因素有:
FCFS,First Come First Served
按照磁盘请求的顺序进行调度。
优点是公平和简单。缺点也很明显,因为未对寻道做任何优化,使平均寻道时间可能较长。
SSTF,Shortest Seek Time First
优先调度与当前磁头所在磁道距离最近的磁道。
虽然平均寻道时间比较低,但是不够公平。如果新到达的磁道请求总是比一个在等待的磁道请求近,那么在等待的磁道请求会一直等待下去,也就是出现饥饿现象。具体来说,两端的磁道请求更容易出现饥饿现象。
SCAN
电梯总是保持一个方向运行,直到该方向没有请求为之,然后改变运行方向。
电梯算法(扫描算法)和电梯的运行过程类似,总是按一个方向来进行磁盘调度,直到该方向上没有未完成的磁盘请求,然后改变方向。
因为考虑了移动方向,因此所有的磁盘请求都会被满足,解决了SSTF的饥饿问题。
以下是一个 hello.c程序:
#include
int main()
{
printf("hello, world\n");
return 0;
}
在 Unix 系统上,由编译器把源文件转换为目标文件。
gcc -o hello hello.c
静态链接器以一组可重定向目标文件为输入,生成一个完全链接的可执行目标文件作为使出。链接器主要完成以下两个任务:
静态库有以下两个问题:
共享库是为了解决静态库这两个问题而设计的,在 Linux 系统中通常用 .so 后缀来表示,Windows 系统上它们被称为 DLL。它具有以下特点: