《Modern Operating System》、《Operating Systems:Three easy pieces》阅读笔记
1. 程序和进程的区别
首先来看一个故事:
A平时因为工作忙,都是在公司食堂吃或者吃外卖。但是突然有一天下班突然心血来潮想自己做饭,于是A来到超市购物,他买了一些食材准备做一份西红柿炒鸡蛋。回到家后,A拿了出放在书架最深处的一本《家常菜食谱大全》,照着上面的方法,他就开始准备做菜了:首先先把西红柿洗干净切块,鸡蛋搅拌成蛋液,开锅热油…终于在A的不屑努力之下,一份色香味俱全的西红柿炒鸡蛋就完成了。
在上述故事中,我们可以抽象出一个简单的计算处理模型:
- 【程序】就是那本食谱(存储在书架上,一种物理存在,用特定的描述形式阐述做菜的过程和方法)
注:著名计算机科学家Niklaus Wirth提出了:程序 = 数据结构 + 算法
将食谱看成程序,它就是用图文并茂地形式(数据结构)生动地向读者展示各个菜的做法(算法)
- 【进程】就是做菜的这一系列活动(动态进行的),包括了洗菜、切菜、炒菜等等
- 【CPU】就是A本人,他是所有操作的执行者
- 【数据】就是西红柿炒鸡蛋的各种原材料,包括了西红柿、鸡蛋…(将一系列数据经由CPU处理变成不同的数据)
2. 进程
2.1 同一个程序有无可能拥有多个进程?
例如一个文字处理软件,当我们打开两个word文件时,这时候系统就会创建两个不同的进程
2.2 在哪几种情况下进程会被创建?
- 系统启动后
- 通过一个运行的进程执行调用进程创建系统
- 用户需求
- 批处理作业的启动
2.3 进程创建的过程?父进程和子进程的关系?
在UNIX系统中,创建进程的函数叫做 fork
在fork调用后,会有父、子两个进程,它们拥有相同的内存镜像、环境字符串和打开文件
The reason for this two-step process is to allow the child to manipulate its file descriptors after the fork but before the execve in order to accomplish redirection of standard input, standard output, and standard error.
两步进程的原因时使得子进程在 fork之后,execve之前处理它的文件描述符,这是为了完成标准输入输出和错误的重定位
2.4 进程结束的几种方式?
- 正常终止
- 错误终止【ERROR EXIT】
- 致命错误(被动地)
- 被其他进程结束(被动地)
2.5 进程的状态
进程就是程序关于某个数据集上的一系列活动。
进程有三个状态,分别是:
- 运行态【running】(actually using the CPU at that instant)
- 就绪态【ready】(runnable; temporarily stopped to let another process run)
- 阻塞态【blocked】(unable to run until some external event happens)
A running thread curently has the CPU and is activeA blocked thread is waiting for some event to unblock itA ready thread is scheduled to run and will as soon as its turn comes up
从上图中,我们可以很清楚地看到三种状态之间的转化过程:
- 当发生IO操作时,当前进程就会被阻塞直到IO操作完成
- 当调度器中断当前进程转而执行另一进程,原进程就会处于就绪状态
- 当调度器选择该进程,便会从就绪态转为运行态,享用CPU资源
- 当IO完成后,被阻塞的进程就会转为就绪态,等待操作系统的调度
2.6 进程上下文切换
当进程与进程间因中断(interrupt)或系统调用(system call)时会发生上下文切换
- 正在执行的进程P0由于终端或者系统调用将cpu的使用权交还给os
- os保存P0的PCB信息,并重新从内存中载入P1进程的pcb内容,并执行P1
- P1执行后,由中断或者系统调用由交还使用权给os(调度器scheduler / 调度者scheduler)
- 再回过头来执行P0(载入P0的PCB信息)
上下文切换的代价其实是很高的,需要大量的处理器时间(因此通常使用汇编完成),有时候1秒内可能执行上千次的上下文切换【CONTEXT SWITCH】
即便现代计算机正在逐渐降低上下文切换所占用的CPU时间,但那也仅是在CPU时钟周期降低,处理速度加快的情况下,而不是提升了上下文切换的效率
2.7 进程控制块【PCB】的概念?
为了实现进程模型,操作系统保存了一个表【table】(一系列结构数组),叫做进程控制块【PCB】
存放于内存中
PCB包括了:
- PID
- 程序计数器
- 栈指针、代码段指针、数据段指针
- 内存分配
- 打开文件状态
- 调度等信息
- 等等
当进程从运行状态变换为就绪或阻塞态,PCB信息就会被暂存,等到再次运行时就会调出(这就好像从来没有中断过进程一样)
3. 为什么要引入线程的概念?
既然我们已经有了进程,那么为什么计算机科学家还要搞出一个线程的概念呢?
- 在系统中多应用将同时进行,经常会不断切换状态,通过将一个进程切分成多个线程,达到准并行【quasi-parallel】,程序模型将更简单(这些线程共享地址空间和数据资源,这些能力正是多进程无法做到的(地址空间不同))
- 更加轻量级。比起进程,更加容易(快速)创建和销毁(在一些系统中,比进程创建快10-100倍)
- 性能表现更好。当有大量计算和I/O处理时,线程有能力让这些活动交叠,提升程序的速度
- 对多核处理器更有用。
【注】
由于进程是资源的拥有者,所以在创建、撤销、切换操作中需要较大的时空开销,限制了并发程度的进一步提高。
为减少进程切换的开销,把进程作为资源分配单位和调度单位这两个属性分开处理,即进程还是作为资源分配的基本单位,但是不作为调度的基本单位(很少调度或切换),把调度执行与切换的责任交给“线程”。
这样做的好处不但可以提高系统的并发度,还能适应新的对称多处理机(SMP)环境的运行,充分发挥其性能
应用程序的需要:
某些应用程序需要同时发生多种活动,比如字符处理软件,当输入文字时,排版也在同时进行,自动保存也在进行。如果用线程来描述这样的活动的话,编程模型就会变得更简单,因为同一进程的所有线程都处于同一地址空间,拥有相同的资源
如图2-7所示,一个word处理软件,假设我们不拆分成多个线程:
当我们删除了文档中的某一行后,我们想要去搜索一个单词,进程首先会接受搜索请求,然后再进行文档排版处理(有内容被删),最后才能进行搜索处理,这样一来速度非常慢
但我们有了线程之后,其中一个线程负责监听搜索请求,一个线程在后台负责文档排版,另一个线程负责文件的自动备份工作。每个线程之间分工明确,同时进行,大大提高了速度
3.1 线程的一些缺陷
线程虽然运行速度很快,但并不是十分完美的
主要是在安全性方面的问题:我们知道,进程内的各个线程之间是共享数据和地址空间的,如果其中一个线程出现了错误(比如错误改写了某个变量),其他线程也会跟着出现问题,导致最终整个进程的错误
因此,我们说现在对于进程和线程的使用也是分场合的,不是一味的追求线程:
- 在高性能计算领域(如天气预报、水利、空气动力学等等),这一类需要追求高性能计算,同时程序并不容易出错,就适合使用线程
- 而例如浏览器这种应用,由于浏览器页面的操作由用户执行,有时候会考量到数据安全,因此推荐使用进程,一个进程打开一个网页
3.2 线程的实现
3.2.1 用户空间实现
在用户空间建立线程库,提供一组管理线程的过程由
运行时系统【run-time system】来完成线程的管理工作
内核管理的还是进程,内核不知道线程的存在
线程切换不需要陷入内核
如Unix、Linux
优点 | 缺点 |
---|---|
切换速度快 | 一进程只有一个线程运行在处理器上 |
调度算法可以有应用程序设定 | 若一个进程的某个线程调用了阻塞的系统调用,那么该进程的所有线程也将会被阻塞,页面失效也会有同样的问题 |
用户级线程可以运行在任何操作系统,包括不支持线程操作系统 |
3.2.2 内核空间实现
内核管理所有的线程管理,创建,撤销与调度**,并向应用程序提供API
内核维护进程和线程上下文
线程的切换需要内核支持
以线程为基础进行调度
优点 | 缺点 |
---|---|
由内核调度,当有多个处理器时,一个进程的多个线程可以在多个处理器上同时执行 | 由内核进行创建,撤销,调度,系统开销更大 |
一个进程的某个线程阻塞不会引起其他线程的阻塞,页面失效同理 |
3.3.3 混合实现
线程创建在——用户空间
线程调度在——内核空间
3.4 进程和线程之间拥有资源的关系?
【进程】是资源分配的单位——————【线程】是CPU调度的单位
线程 又称为轻量级进程【lightweight processes】
它(同一进程的所有线程)共享进程的地址空间,同时还有自己特有的一些信息:
每个线程共享进程的信息如上图左侧所示
但,每个线程也有自己专属的 PC、寄存器、栈、状态信息
(因为每个线程可能去调用不同的步骤,
因此线程之间都有各自的栈、PC、寄存器,这样防止执行时发生异常)
【注】
- 进程内的多个线程是共存的
- 相互将信息共享,甚至一个线程创建删除另一个线程都是可能的
- 虽然进程之间有上下级关系,但线程间没有(所有线程是平等的)
4. 通信
4.1 原语、原子操作?
【原语】:系统提供的完成某种特定功能的一段程序,由机器指令编写,执行过程不可中断
【原子操作】:在多线程操作系统中不能被其他线程打断的操作叫做原子操作。当该次操作无法完成时,必须回到操作之前的状态,原子操作不可分割
4.2 进程间通信 IPC?
进程间通信]是指在不同进程之间传播或交换信息,在Linux环境下,进程地址空间相互独立,每个进程各自有不同的用户地址空间,进程之间不能相互访问。必须通过内核才能进行数据交换
(13条消息) 几种常见进程间通信(IPC)方式之共享存储_Lyn&Xx-CSDN博客_ipc进程间通信
常见的通信方式有以下几种:
- 管道pipe
- 有名管道FIFO
- 消息队列MessageQueue
- 共享存储
- 信号量Semaphore
- 信号Signal
- 套接字Socket
【竞争条件】【race conditions】
两个或多个进程读写某些共享数据,其最终的运行结果取决于进程间的精确运行时序
我们如何才能避免竞争条件发生?————【临界区】【critical rigions】
共享内存进行访问的片段,禁止两个或多个进程同时访问
尽管如此,我们还需要制定一些规则:
- 两个进程不能同时访问临界区
- 不对CPU的速度和数量做任何假设
- 在临界区外的进程不能阻塞其他进程
- 没有进程会永久等待进入临界区
进程的互斥【mutual exclusion】
4.3 信号量【semaphore 】?
一个特殊的变量,用于进程间传递信息的一个整数值
主要用于临界区互斥与进程间同步(进程的运行保持某种特定的时序)
mutex有时也称为二元信号量
对信号量可以实施的操作:
wait()、signal()或者P、V(PV操作均为原语操作 )
//信号量
struct semaphore{
int value;
struct process * list; // 当value<0时,将进程阻塞至此队列
};
//P操作
wait(semaphore s){
s->value--; // 将value的值减一
if (s->value < 0) //若value的值小于0 {
add this process to s->list; //则将此进程的信息加入list队列
block(); // 并将此进程阻塞
}
}
//V操作
signal(semaphore s){
s->value++; // 将value值加一
if (s->value <= 0) // 如果value的值小于等于0时,说明在value值加一之前,value的值是负数,即有进程阻塞在list中,此时应该将其中一个进程唤醒
{ remove a process p from s->list; // 将进程p从list移除
wakeup(p); // 并将进程p唤醒
}
// 如果value的值是正数的话,就说明之前的值至少是大于等于0的,即在list中没有进程阻塞,所以只需将value的值加一,表示可用的资源又多了一个,而不需要其他操作
}
4.4 生产者、消费者问题?
一个生产者的进程与一个消费者的进程,两个进程共享一段缓存区
生产者生产了一个产品,就往缓存区里放,当缓存区满了,则进入睡眠,当消费者取出了一个产品后再将生产者唤醒
消费者消费了一个产品,就从缓存区中取出,当缓存区空了,即进入睡眠,当生产者生产了一个产品后,再将其唤醒
// 存在严重竞争条件的生产者消费者模型
#define N 100; // 缓存区的最大值
int count = 0; // 定义的共享变量,用于记录缓存区中产品的个数
int item;
void producer(){
item = produce_iter(); // 生产一个产品
if (count == N) sleep(); // 如果缓存区满了,则生产者sleep
count++; // 如果没满或者被唤醒了, 则继续执行,先count加一
insert_item(item); // 将产品放入缓存区
if (count == 1)
wakeup(consumer); // 如果此时count等于1,就说明之前缓存区空的,即此时消费者应该在sleep,因为此时缓存区有产品了,应该将其唤醒
}
void consumer(){
if (count == 0) sleep(); // 如果缓存区为空,则消费者进入sleep
count--; // 如果缓存区不为空或者被唤醒了,则继续执行,先count减一
item = remove_item(); // 然后再从缓存区中移除一个产品
if (count == N-1)
wakeup(producer); // 如果此时count 等于N-1,说明刚才count 等于N,即生产者此时正在睡眠,因为此时缓存区空了一个位置了,故应该将其唤醒
consume_item(item); // 消费这个产品
}
这个模型存在的严重问题是:
若此时缓存区为N,当生产者执行判断count是否为N的语句,如果CPU将count值复制进寄存器,但是还没来得及判断,就被中断了,CPU调度消费者上去,此时count仍然为N
而消费者消费了一个产品,此时count为N-1,消费者向生产者发送了一个wakeup()信号,但是此时生产者并未进入sleep,故此信号丢失
当CPU再次切换到生产者,操作系统恢复中断之前的状态,此时寄存器中count的值为N,继续往下执行,执行sleep,而wakeup信号丢失了,再也没有进程将他唤醒
而消费者随着一个一个的产品消费完,缓存区为空,也进入sleep,两个进程就一直睡眠下去了 (产生了死锁【dead lock】)
用信号量机制解决互斥与同步问题
#define N 100typedef int semaphore // 将semaphore定义为int类型,可正可负
semaphore mutex = 1; // mutex为二元信号量,用于互斥对缓存区的操作
semaphore full = 0; // 信号量full, empty用于同步生产者消费者进行
semaphore empty = N;
int item;
void producer(){
item = produce_item();
wait(&empty); // 先判断缓存区是否为满,若为满,empty为0,对其执行wait操作,会把empty减为小于0,则系统会将生产者阻塞
wait(&mutex); // mutex用于控制进程对缓存区的操作,保证某个进程在临界区操作时,其他进程无法进入
insert_item(item);
signal(&mutex); // 将临界区解锁,两个signal操作其实可以交换位置,原则上不会有错误,但是临界区越小越好
signal(&full); // 对full执行signal操作,表示空的位置有增加了一个
}
void consumer(){
wait(&full); // 对full执行wait操作,如果此时缓存区为空,则消费者应该被阻塞full当缓存区为空时等于0,对其wait操作,会把full降为小于0,则系统会将其阻塞
wait(&mutex);//对缓存区上锁,每次只能一个进程访问资源
item = remove_item();
signal(&mutex);
signal(&empty); // 对empty执行signal操作,即把empty的值加一,表示缓存区空的位置又多了一个
consume_item(item);
}
当同一个信号量由同一个进程来执行wait, signal操作时,一般是临界区互斥(比如这里的mutex)
当同一个信号量由不同的进程来执行wait, signal操作时,一般是用于两个进程的同步问题 (比如这里的empty和full)
4.5 条件变量?
pthread提供了另一种同步机制,称为条件变量
- 条件变量允许线程由于未达到某个条件而阻塞,直到另一个线程向他发信号
- 条件变量允许这种阻塞和等待原子性的进行
- 当有多个线程被阻塞了,可以使用broadcast广播全部唤醒
- 条件变量经常与mutex一起使用
条件变量与信号量不同的地方:
- 信号量是可以保存状态的,wait和signal操作一般不会丢失,可以体现在值上
- 条件变量是不保存状态的,即条件变量不会存在内存中,如果在wait之前signal到了,那么没有线程在等待这个signal信号,也就丢失了,而执行wait的线程需要等到下一个signal
4.6 管程【monitor】?
管程是一种特殊的抽象数据类型,内部定义有数据与特定的操作的集合,是另一种实现进程同步的方式
并且只能通过管程内部的操作才能操纵该数据类型的实例
只有通过管程的某个过程才能访问资源
管程是互斥【mutual exclusion】的,即管程保证在某个时刻只能有一个进程或线程调用管程中的过程
管程应用数据类型本身的机制提供互斥操作
如果还需要同步,可再定义条件变量
例如生产者、消费者问题:
4.7 进程间通信的原语操作
两个原语操作:【send】、【receive】
send(destination, &message);
receive(source, &message);
【补充】如何理解伪并行【pseudo-parallel】的概念?
虽然从宏观上看,进程同时进行实现了并行,但在微观时间上,每个时刻,仅有一个进程在运行(对于单核CPU来说)
【注】 并发【Concurrent】 和 并行【Parallel】
你吃饭吃到一半,电话来了,你一直到吃完了以后才去接,这就说明你不支持并发也不支持并行。你吃饭吃到一半,电话来了,你停了下来接了电话,接完后继续吃饭,这说明你支持并发。你吃饭吃到一半,电话来了,你一边打电话一边吃饭,这说明你支持并行。
-
并发的关键是你,不一定要同时。(有处理多个任务的能力)
-
并行的关键是你有同时处理多个任务的能力。
所以最关键的点就是:是否是『同时』