LZ水平有限,如果发现有错误之处,欢迎大家指出,或者觉得那块说的不好,欢迎建议。希望和大家一块讨论学习
LZ QQ:1310368322
进程和线程理解起来比较抽象,是因为它本来就是一种抽象,一种操作系统层面的抽象。
我们先来看看什么是进程?
第一章:进程
什么是进程
我觉得《深入理解计算机系统》这本书对进程解释的比较到位
进程:进程是操作系统对一个正在运行的程序的一种抽象
其实抽象是计算机科学中最重要的概念之一,抽象无处不在,比如指令集结构提供了对实际处理器硬件的抽象,下图展示了计算机系统提供的一些抽象
我们可以看到进程其实是由虚拟存储器加上处理器构成的(有关虚拟存储器的内容参考虚拟存储器)
如果大家还是不太理解进程,让我们来看一个比较形象的比喻
我们可以把厨师做蛋糕这一系列动作看做一个进程
厨师在做蛋糕的时候会有一个食谱(假设这个厨师的厨艺不怎么样,做蛋糕还要看食谱),这个食谱中就包含了做蛋糕的步骤[指令]以及所需要的原料[所需要的数据],而做蛋糕的原料就相当于输入数据,厨师就相当于一个CPU厨师阅读食谱,用原料做蛋糕的一系列动作的总和就是一个进程。
假设这么一个场景:厨师的儿子跑进来,说是被蜜蜂蛰了,这时候厨师记录下当前做到了哪一步(记录当前进程的状态),然后拿出急救手册开始对儿子进行处理(开始了另外的一个进程)
说道这里,大家应该能猜到上面的场景就是进程间进行切换
下来我们来看看进程间的切换
进程间切换
我们先来看一看一个进程所拥有的虚拟内存张什么样,如下[大家不要管下图中的指令具体代表着什么含义]:
简单地介绍一下,程序数据里面包含了全局变量的一些信息,程序代码中包含了运行这个程序的所有指令,PC是CPU中的一个寄存器,里面存的是下一条指令的地址,栈中存放的是一些函数调用的信息(比如局部变量),堆中是运行时动态分配的信息(比如new)。好了,接下来我们重点来看进程之间是如何进行切换的。
话不多说,上图
从图中我们可以看出在程序1在和程序2进行切换(准确地来讲是进程1和进程2进行切换)的时候,操作系统(OS)会保存进程1的上下文和设置进程2的上下文,这里提到了一个名词:上下文,简单地理解就是进程运行所雪的所有状态信息,比如在运行的时候,CPU中寄存器的值。简单地说一下切换的过程:
当进程1的时间片用完的时候,操作系统会保存进程1的上下文,然后操作系统会根据一些调度算法来调度下一个将要执行的程序,然后设置进程2的上下文,根据进程2的PC去执行进程2。
注意:
①这里进程的上下文信息是保存在内存中的一个称为PCB的进程控制块中,我们仔细想想就会明白,物理的寄存器只有一套,当前进程时间片用完后当然要保存寄存器中的值和其它信息到内存中,以便下次执行时恢复数据
②进程间的切换是由操作系统来负责的
进程的五种状态
进程有五种状态,分别是:新建、就绪、执行、等待和退出,他们的关系如下:
进程刚一开始被创建还没有创建好的状态就是新建状态,等创建好了就变成了就绪状态,就绪状态就代表该进程具备运行条件,等待系统分配处理器(CPU)以便运行,当就绪状态的进程获得CPU使用权的时候,该进程就进入了运行态,如果正在运行的进程出现了等待事件(如有IO操作,等待键盘输入),那么这个进程就进入了等待状态,当这个等待的事件完成(如键盘输入完成),这个进程又会进入到就绪状态,进程运行完毕或者其他原因进入到退出状态
注意:
①进程处于等待态的时候是不占用CPU的
②操作系统会维护若干个等待列表,会把不同类的等待进程放入列表中,比如进程A等待磁盘,就会被放到磁盘列表,进程B等待键盘输入,就会放到等待键盘列表中去
上面我们提到了一个进程会从就绪状态得到CPU后变为运行状态,那么如果有多个进程会怎么办,是按什么顺序分配CPU给进程的,这就牵涉到进程调度算法,进程调度算法有很多,也比较复杂,这里我们大概地介绍一下,有关具体内容,请大家参考有关资料
进程调度
进程调度根据不同类型的操作系统会分为两大类
* 非抢占式:调度程序把CPU分配给进程后,该进程就一直运行,一直 到该进程运行完毕或者发生某件事情而不能运行,适用于批处理系统,优点是简单、系统开销小
* 抢占式:当一个进程正在运行,操作系统可以基于某种策略剥夺CPU给其他进程,适用于交互式系统
批处理系统中的调度有:先来先服务和最短作业优先策略
交互式系统中的调度策略有:时间片轮转、优先级和多级队列反馈
接下来我们来详细地讨论进程间同步
进程间同步
我们先来看一个很经典的问题:生产者与消费者
现在有一个打印机进程(消费者)和多个进程(生产者)生产待打印的文件,如下图:
每个打印机都有一个缓存,这个缓存里存放的是待打印的队列,生产者生产文档,消费者(打印机)打印文档,这里就牵扯一个问题,就是生产者与消费者之间的同步问题,即:当生产者再生产文档的时候,如果队列中已经填满了文档,就要等待打印机打印文件,以便腾出空位,同样当队列为空的时候,打印机就要等待生产者去生产文件了,解决办法如下(伪代码[注意:这个伪代码是程序员写的伪代码]):
伪码中的Item就是我们上面所说的各种文件,buffer就是那个缓冲队列,in是生产者要放入队列时的下标,out是消费者从队列中所取出的文件的下标,count是队列中文件的总数
我们简单地看一个生产者,进入循环,如果队列满(count==5),那就只能等待消费者消费产品了(啥也不干,继续循环),如果队列不满的话,生产者就把生产出来的东西放到以下标in为下标的缓存队列中,然后下标加一再对五取余[in = (in + 1)%5 ],这里解释一下这个式子,加一的目的是让下标往后移动一个位置,而对五取余是因为这个队列能容纳5个元素,我要构成一个循环队列,比如说,当生产者生产的东西是要往以4为下标存放的时候,就会buffer[4]=item,这个时候如果消费者把下标为0的文件打印了,那么下一个生产出来的东西应该放在下标为0的队列中,就是(4+1)%5=0
当一个或多个生产者进程与消费者并发执行的时候,这时候就会出错,这是因为我们的一些高级语言(比如Java、C)中的一行代码在编译成机器码或者class文件的时候,会变成多行代码【比如说i++】,如下:
如上图所示:队列中有三个文件,counter=3,这时候如果是并发执行,那么会出现上图的情况,我们在代码中写的count++,其实在机器码层次(C语言)是,先将count的值赋值给寄存器(register),然后将寄存器的值加一,最后再将寄存器中的值赋值给count变量,那么多进程的时候就会出错,如上图的并发执行,当一个进程P刚把寄存器中的值加一了,这时候进程P的时间片用完了,发生了进程间切换,进程C去执行,这个时候,counter的值还是3,然后进程C把count的值赋值到寄存器中,这时寄存器的值就变成了3(覆盖了之前进程P中register的值4,但是进程P中所使用的寄存器中的值会保存到进程控制块中),然后寄存器中的值减一变成2,然后这个时候进程P又执行了,把它中寄存器中的值4赋给了count,count变为4,然后又切换到进程C,进程C把它的寄存器中的值[值为2]赋给了counter,最终count为2,很显然这是个错误的值,本来应该是3。说了一大堆,让我们来反思一下,出现这个问题的核心是什么?
①进程间的执行顺序不可控,你根本不知道下一个进程是谁
②在机器层面,count++、count–等操作不是原子操作
那么我们应该如何解决这类问题?
在说解决方法之前,我们先来看一个概念:临界区
临界区:简单地理解就是一个程序中访问公共资源的程序片段,也就是这个程序片段中的指令将要访问一个公共资源,这个公共资源有无法被多线程或者多进程访问的特性[如果访问会出错]
下来我们看一下解决多线程问题的解决方案注意:下面讨论的方案中的代码或伪代码都是操作系统层面的(由操作系统实现的)或者硬件层面的:
方案一:暴力手段,关闭时钟中断
在说这个方案之前我们先来了解一些底层的东西
①中断:中断是指计算机在执行期间CPU对系统某个事件做出的反应,CPU暂时中断当前执行的程序而转去执行相应的事件处理程序,待事件处理完毕之后又返回被中断处理处继续执行或调度新的进程
②硬件时钟:硬件时钟是指主机板上的时钟设备
③软件时钟:软件时钟是建立在硬件时钟的基础上,记录了将来某一时刻要执行的函数,当这一时刻到来,就会去执行这个函数,简单地理解就是软件概念上的时钟
④时钟中断:时钟中断是软件时钟到时而引起的中断
了解了这些概念后,我们看看这个暴力的手段,我们CPU会定期的接收到时钟中断,当接收到时钟中断后,就会通知操作系统去检查当前进程的时间片是否用完,用完则切换,从这里我们可以想到,如果在进程在进入临界区的时候,我们不让进程切换,不就避免了指令错乱执行的问题了吗?是的,我们可以这样做
但是这样把关闭中断交给应用程序是十分危险的,假设一个程序有bug,忘记关中断,那么电脑就会出现死机(单核)。
方案二:用硬件指令来实现锁
上面关闭中断的方案不太可行,我们想着能不能用一个锁去锁住临界区所要访问的资源,也就是说当一个进程在临界区对公共资源进行访问,我就加一把锁,当我时间片用完了,别的进程也想访问这个公共资源的时候,因为有了这把锁,它是访问不了的。这个锁,我们用硬件指令来实现,为了更清楚地展现这个锁,我们用C语言来描述一下这个锁
boolean TestAndSet(boolean * lock){
boolean rv = *lock;
*lock = true;// 将锁锁住
return rv;// 如果传进来的是false返回false,传进来true,返回true
}
注意:这个TestAndSet函数里面的三条语句是原子执行的,也就是说调用这个函数,这三条会全部执行[硬件指令]
接下来我们看一下一个进程是如何调度这个锁的
注意:图中的lock是各个进程公用的,默认为FALSE
当一个进程要进入临界区的时候,他会去做一个while(TestAndSet(&lock))循环,如果跳出循环,就说明传进来的所为false,没人加锁,那在跳出循环的时候,TestAndSet函数就会把lock置为true[TestAndSet函数中的*lock=true
],然后这个进程才进入临界区,当在临界区的操作全部执行完了之后,这个进程就会再次的把锁置为false,这个时候,别的进程才可以用。
对应到我们的生产者和消费者中就是,当生产者在进入到临界区,也就是要进行count++操作的时候,生产者进程就会把lock置为true,然后执行P.register = counter; P.register = P.register+1; 这是后就算生产者进程P的时间片用完了,消费者进程执行,那消费者相对临界区所操作的资源[寄存器]进行操作,因为生产者已经加锁,所以只能一直循环(占着CPU啥也不干,直到CPU时间片用完),这样的话就会避免发生错误
方案三:信号量
前面的方法都比较偏硬件,比较麻烦,荷兰学家迪杰斯特拉发明了一种算法,只用一个整数变量就把并发时的数据出错问题解决了,这就是我们常听到的P/V操作,这被广泛地应用到各个操作系统中。P&V是荷兰语,这里我们用wait和signal来代替它。
信号量简单地来说就是一个变量,我们用大写S(semaphore)表示,程序对其访问都是原子操作
wait函数张什么样?
wait(S){
while(S<=0){
啥也不做;//信号量小于等于0,其绝对值表示等待该资源的进程数
}
S--;
}
进入临界区
signal函数张什么样?
离开临界区
signal(S){
S++;
}
我们看一个实例,看看这个wait函数和signal函数是如何运作的
semaphore mutex = 1;// 互斥信号量
wait(mutex);
进入临界区
signal(mutex);
剩余区
从上面的伪代码可以看出,当一个进程进入到临界区的时候,会调用wait函数,如果互斥信号量为1,S–;[mutex–],mutex变为0;这时候如果其他进程也访问同样的资源,这时候mutex为0,所以一直循环,这和用硬件指令加锁非常的像
注意:wait函数和signal函数是操作系统实现的一个原子性的函数
问题:忙等问题
前面我们看到的几个方案中的方案二和方案三都有忙等问题【进程占着CPU什么事情都不做】,比如用硬件指令实现加锁的while(TestAndSet(&lock))循环,如果条件为真,循环体就什么都不做。那如何解决这个问题呢?
这个时候只需要给信号量定义一个结构体就OK了
typedef struct{
int value;// 信号量的值
struct process * list;// 进程等待列表
}semaphore;
我们再来看看wait函数
wait(semaphore * s){
s->value--;
if(s->value < 0){
把当前进程加到s->list中;
block();// 进程进入等待状态,不占用CPU
}
}
----如果传进来的s->value为0,那么s->value--后变为-1,这时候就把这个进程加入到等待列表中去,然后让这个进程等待
我们再看看signal函数
signal(semaphore * s){
s->value++;
if(s->value <= 0){
从s->list中取出一个进程p;
wakeup(p);// 唤醒这个进程
}
}
s->value++之后如果s->value还是小于等于0,说明等待列表中还有等待进程,比如说s->value=-1,这个时候代表等待列表中有一个进程在等待这个资源,然后有一个进程离开临界区的时候,调用了signal函数,s->value++;这个时候s->value=0,进入if(),取出那个正在等待的进程并唤醒它。
接下来我们来看利用信号量如何解决消费者和生产者的并发执行错误的
用信号量解决打印问题
伪代码如下:
我们可以看到,生产者生产一个东西之前要占据一个空位(wait(empty)),然后设置互斥量(加锁),最后要给消费者消费的东西数量full加一。这样进程之间操作公共资源就安全了(有互斥量)。这种方案有一个很大的问题,就是这样这样会很容易导致死锁:在生产者进程运行的过程中,生产者持有公共资源A的锁,但是在持有A的锁后想获得公共资源B的锁,但是消费者进程运行后持有公共资源B的锁,这时候想获取资源A的锁,双方都持有各自想要的锁,这样一来,就互不相让,导致死锁
第二章:线程
什么是线程?
根据*中的解释是:线程是操作系统能够进行运算调度的最小单元。这句话比较官方,也比较难懂,其实通俗地理解,线程其实就是代码的一个执行而已,只不过它执行代码中的一部分,如果一个进程是单线程的,进程就相当于线程。
我们看一个图来看看线程
从图中我们可以看到,线程中保存着有自己的寄存器和堆栈的值[在内存中],线程共享进程的代码、数据和文件。我们来理一下,一个进程中有栈区、堆区、数据区和代码区等等,当一个进程在执行的时候,CPU会读取代码区的指令,去执行它,如果需要数据会从数据区去拿,如果有函数调用就会在栈上分配空间,如果有动态的分配空间(如new),会在堆中分配内存。线程只不过是执行代码区不同的指令,去共享进程的一些数据,代码,然后在调用函数的时候,因为每个线程调用的函数不同,就会保存自己的堆栈和寄存器的值。这就是线程和进程的关系。下面的图也许更能直观地说明这个问题
从图中我们可以看到,每一个线程执行的指令不一样,那么也许所需要的数据就不一样,如果执行函数调用,在栈中就会分配不同内存,每个线程都必须保存这个数据,但是线程所保存的数据很少,如自己所用的寄存器的状态、调用的函数栈,所以说线程之间的切换开销比进程要小的多【系统保存的东西少】
最后我们总结一下:一个进程最少有一个线程,进程是资源拥有的基本单位,线程是CPU调度和分派的基本单位
注:本文参考刘欣老师讲课以及网络资源,图片大多来自上课的PPT