操作系统也是软件,区别于应用软件的最大特点具有进程管理、内存管理等功能。
一 进程
1.1 什么是进程(process)
进程指的就是正在运行中的程序。进程也是有生命周期,当程序运行结束,则进程结束。如果程序没有运行呢?那就是代码。所以我们判断是不是进程的最主要区别就是看程序是否正在运行。
1.2 进程分类
1.2.1 按照运行在不同的态分为用户进程和系统进程
第一:运行在用户态的进程就属于用户进程,一般是没有权限操作系统资源的
第二:运行在内核态的进程就属于系统进程,一般是有权限操作系统资源的
1.2.2 按照对CPU的依赖程度
第一:对于CPU依赖较重,则属于计算型的进程,侧重于计算,比较消耗CPU
第二:对于CPU依赖较弱,则属于偏I/O型进程,侧重于IO读写,比较消耗I/O
1.4 进程的状态(5种)
新建态:刚刚创建的进程就处于新建状态
就绪态:处于就绪队列,等待被CPU执行的进程的状态就是就绪态。比如新创建的进程放入就绪队列或者时钟到期CPU将未执行完的进程放入就绪队列
运行态:如果被CPU调度执行,就是运行状态
阻塞态:运行中被阻塞,则处于阻塞态,比如I/O读,或者等待键盘输入等
终止态:进程被终止,则处于中止态
1.5 进程控制块(PCB)
进程控制快(PCB): 是process control block的缩写,用于管理进程各种信息,Linux中,PCB就是一个结构体,有几百个字段,用于记录该进程运行时各种信息,比如进程ID,进程的名字,运行状态,创建时间,消耗时间,优先级,内存信息,程序计数器等等。
1.6进程创建和撤销
1.6.1 操作系统是创建一个进程
第一:为新进程分配一个唯一的进程标识符
第二:为进程分配地址空间
第三:初始化进程控制块(PCB),PCB的进程状态为New等等
第四:将进程的PCB放入到一个就绪队列中
在Unix下创建进程的主要操作是fork和exec; windows下主要创建进程的操作是CreateProcess
1.6.2 操作系统撤销进程
第一:回收进程占用的资源。比如关闭打开的文件、断开网络连接、回收分配的内存等等
第二:撤销该进程的PCB(比如Unix下exit或者Windows下调用TerminateProcess)
1.7 进程的调度和分派
#1 进程的调度肯定是由调度器实现的,根据调度优先算法来决定哪一个进程来占用CPU,这一行为肯定发生在内核态(调度都是发生在内核态的)
非剥夺调度:只有当进程主动放弃才重新调度,比如阻塞或者进程结束,CPU在之前的进程无法继续运行了,然后去调用调度程序,运行其他程序
剥夺调度:原来的进程本来可以继续运行,可以被操作系统以某种原因剥夺器其被调度的权利。
#2 所有的进程在运行时,都包含就绪态、运行态、阻塞态和终止态,每一个进程参与调度的时候,都会在这些进程状态之间来回切换
#3 只有当进程获取CPU的执行权时,进程才会进入运行态
#4 进程运行的时间片到后就会调度其他进程运行,每一个时间片大概在10-20毫秒左右
1.8 进程切换
1.8.1 进程切换的时间
第一:时钟中断。即执行的时间,超过了允许的最大时间片
第二:I/O中断。比如进程发起系统读文件调用,那么该进程就会处于阻塞等到状态,操作系统把阻塞态的进程放进就绪队列
第三:进程正常结束,则要调度其他进程
1.8.2 进程切换流程图
1.9 进程通信(进程之间信息交换或者传递)
我们知道进程是分配系统资源的单位(包括内存地址空间),因此各个进程拥有的内存地址空间相互独立。
为了保证安全,一个进程是不能直接访问另外一个地址空间。但是有的时候,进程之间信息交换又是必须的,为了保证进程间的通信安全,操作系统提供了一些方式。共享存储、消息传递、管道通信三种方式实现。
1.9.1 共享存储
操作系统为进程分配共享空间,两个进程之间可以通过共享空间来实现通信,但是需要注意的是,进程在访问共享空间的时候应该是互斥的,不然会出现并发问题,互斥是通过操作系统提供的工具实现的,比如P,V操作。
共享存可以基于数据结构或者存储共享。
第一:基于数据结构
比如提供一个16位长度的数组放在共享空间,然后每次进程只能通过这个数组进行通信,速度慢,限制多,是一种低级的通信方式
第二:基于存储区共享
在内存中获取一块位置,数据的形式,存放位置由进程控制,而不是操作系统,这种方式速度快更快,是一种高级的通信方式。
1.9.2 管道通信
管道是指用于连接读写进程的一个共享文件,其实就是在内存开辟的一个大小固定的缓冲区。
管道只能采用半双工通信,某一时间段内只能实现单向的传输。如果要实现双向的同时通信,则需要设置两个管道。
1.9.3 消息传递
进程之间会以格式化的消息进行传递,进程通过操作系统的提供的发送消息和接收消息2个原语来进行数据交换。
二 线程
2.1 线程出现的背景
第一:进程创建、切换资源消耗大
我们知道,进程是资源分配的基本单位,进程的切换需要保存进程状态,那么对资源的消耗很大。
第二:并发能力不强
我们知道,一个应用程序或者说一个进程,是从上往下顺序执行的,如果要执行某一个操作需要耗时很长,比如QQ中上传文件,我们不可能等待文件传输完了再继续视频聊天或者文字聊天吧,所以这种方式并发能力不强
所以,为了提高并发度,减小系统的开销,引入了线程,进程还是资源分配的基本单位,但是不再是CPU调度的基本单位了。线程现在才是调度的基本单位,共享进程的资源。
2.2 用户线程和内核线程
2.2.1 用户级线程(user-level thread)
顾名思义,在用户态或者用户空间建立的线程库,提供一组管理线程的过程
由一个运行时系统完成线程的管理操作。操作系统内核管理的是进程,并不知道线程的存在。
当然,线程的切换也就不需要在内核态完成,在用户态就可以完成线程切换
比如Unix操作系统,就是这种,实现了POSIX线程库编程接口(Portable Operating System Interface),以线程库的方式提供给用户,也就是提供若干个函数来支持多线程。比如:
PThread_create: 创建新线程
PThread_exit: 终止线程
PThread_join: 等待一个线程死亡
PThread_yield:释放CPU执行权,让其他线程执行(主动释放)
优点:
#1 线程切换快
#2 调度算法可以按照应用程序的需求来进行调度
#3 可以运行在任何操作系统上(只需要实现线程库)
缺点:
#1 内核只是将CPU分配给进程,同一进程的2个线程不能同时运行在2个处理器上
#2 大多数系统调用时阻塞的,因为内核阻塞进程,即使是某一个线程系统调用,但是内核是不知道线程,只知道进程,故进程中所有线程也被阻塞
2.2.2 内核级线程(kernel-level thread)
#1 内核管理所有线程,并向应用程序提供API接口
#2 内核线程才是CPU调度的基本单位,而不是用户级线程。
#3 内核维护同时维护进程和线程的上下文
#4 线程的切换需要内核的支持
#5 一个线程发起系统调用,不会阻塞其他线程运行,时间片分配给线程,所以多线程的进程获取更多的CPU时间
比如WINDOWS实现了内核级线程
2.2.3 混合模型
线程的创建的在用户空间;但是线程的调度在内核空间,比如Solaris操作系统,即多个用户级线程通过多路复用技术,复用多个内核级线程。
三 同步和互斥
我们知道,在多任务的系统中,是可以同时运行多个进程的,如果进程之间需要通信,可以通过共享存储或者数据的方式实现通信,即共享资源,而这些资源具有排他性,各进程之间竞争使用这些资源,这种行为叫做进程互斥。
3.1 概念
3.1.1什么是同步
同步主要是用来解决多进程或者多线程的情况下的进程或者线程的协作关系,一个进程或者线程的执行需要依赖另外的线程的消息或者信号,如果没有收到来自其他进程或者线程的信号,则该进程或者线程则一直处于等待或者阻塞状态。比如常见的通知和唤醒操作就是为了协调线程或者进程的执行
3.1.2什么是互斥
互斥主要是为了解决多进程或者多线程并发的时候对临界资源的竞争问题。所以为了避免多个线程同时操作临界资源(共享变量)带来的问题,需要保证一个临界资源只能被单个进程或者线程访问,在访问期间,其余的进程或者线程必须等到当前正在操作临界资源的进程或者线程结束以后才可以访问。
3.1.3 临界资源(互斥资源) (Critical Resource)
系统中某一个资源一次只允许一个进程使用,这样的资源就被称为临界资源或者共享变量。
3.1.4 临界区(critical section)
各个进程对某个邻接资源的操作的代码片段或者程序片段就是临界区或者是互斥区
3.1.5 进入区(entry section)
负责检查是否可以进入临界区,锐可以进入,则应该设置正确的访问临界资源的标志
3.1.6 退出区(exit section)
比如释放锁,表示退出临界区
3.1.7 剩余区(reminder section)
退出临界区之后还有要执行的代码
3.2 进程互斥解决方案
3.2.1软件方案
3.2.1.1单标志法
两个进程在访问完临界资源之后,会把使用临界区的权限交给另外一个进程,每一个进程想要进入临界区只能被另外一个进程赋予权限
3.2.1.2双标志先检查法
设置两个变量pturn = false; qturn = fasle; 表示p q 进程想进入临界区的意愿,比如pturn = true; 表示p进程想进入临界区。
每一个进程进入临界区之前,都会先检查当前有没有其他进程想进入临界区,如果没有则自己进入,然后自己的turn变量设置为true,开始访问临界区,访问结束。自己的turn置为false,表示自己不想进入了。
但是存在的问题就是,如果当p进程先检查q不想进入临界区,然后自己进入,但是还没有来得及将pturn置为true,则时钟中断,然后CPU运行q进程,q进程判断p进程没有意愿进入临界区,则q进程自己进入,则现在两个进程或者线程都进入了临界区。
3.2.2 硬件方案或者硬件指令
因为软件方案的弊端,经常被中断,所以我们如果在处理同步互斥的的时候关闭中断就好了。但是关中又有一个问题,就是多CPU问题,因为你只能关闭当前一个CPU的中断,没办法全部关闭,所以在多处理器的架构中,还是不能解决问题。
3.2.2.1 单处理器系统中可以使用屏蔽中断,多处理器不行
屏蔽所有中断,包括IO中断或者时钟中断,disableInterrupt和enableInterrupt之间的代码段不会被打断。
3.2.2.2 TAS(Test and Set)
单处理器可以使用关闭中断的方式,但是多处理器是不行的,比如两个进程一个存钱一个取钱,同时操作金额,取钱的那个进程关闭中断了,但是另外一个没有,则还是可能给临界变量金额带来错误。
测试并设置(TAS),是为了防止多处理器并发引入的一种锁,也叫作自旋锁,设计的初衷是忙则等待。有的地方也叫作TSL即Test and Set Lock,其实表达的东西是一样的。
用Java语言来描述:
使用TAS指令:
第一:创建全局的共享变量lock ,初始值是lock = false
第二:进程1调用原子指令TestAndSet(lock),由于lock = false, 所以更新全局的lock = true,并且返回旧的值false,对于自己来说
优点:实现简单,把上锁和检查用用硬件的方式封装成了原子操作,适合于多处理器机器
缺点:如果要求进程有限等待,比如等待M毫秒,就超时不等待,TLS无法实现;而且如果大量进程或者线程自旋,浪费CPU资源
3.2.2.3 Swap(XCHG)指令
Swap用Java语言描述:
用Swap实现互斥:
优点:实现简单,把上锁和检查用用硬件的方式封装成了原子操作,适合于多处理器机器
缺点:如果要求进程有限等待,比如等待M毫秒,就超时不等待,没有实现;而且如果大量进程或者线程自旋,浪费CPU资源
3.3 同步机制
3.3.1 信号量机制
信号量(Semaphore)和PV操作
信号量是一种特殊的变量,用于进程之间传递信息的一个整数值,他可以解决同步和互斥问题,比如生产者和消费者问题、读写问题
对信号量可以实施的操作:
init:
P: 给信号量的值减1,比如count–,如果信号量的值小于0,则处于阻塞等待状态;然后将该进程插入到等待队列末尾
V:给信号量的值加1,比如count++,如果信号量小于或者等于0,则唤醒相应等待队列中的一个进程,改变其状态为就绪态,插入到就绪队列
注意:P V 操作是原子操作,执行过程中不允许被中断
缺点:程序编写技巧要求高,易出错
3.3.2 管程(monitor)
管程是一种高级同步机制,由关于共享资源的数据机构及在其上操作的一组过程组成。即它是管理共享资源的,在管理过程中提供了各种的各样操作。
进程只能够调用管程中的操作来间接访问管程中的共享数据结构。
管程要解决两个问题:
第一:互斥
管程是互斥进入的,只能有一个进程调用管程操作,编译器负责保证管程互斥性
第二:同步
管程中设置条件变量以及等待/唤醒操作
可以让一个进程或者线程在条件变量上等待,也可以发送信号将等待在条件变量上的进程或者线程唤醒
遇到的问题:
第一:进程A进入管程,但是需要阻塞等待其他进程的数据
第二:进程A进入等待队列,释放CPU
第三:进程B进入等待队列,但是进程B需要唤醒A,唤醒之后那就是两个进程同时存在
一般多线程是没有这个问题,因为临界区的代码都一样
3.3.3 PThread同步机制
PThread_mutex_lock 有则获取锁,没有阻塞等待
PThread_mutex_tryLock 要么获取锁,要么获取锁失败
PThread_mutex_unlock 释放一个锁
PThread_mutex_wait 阻塞等待,直到被唤醒
PThread_mutex_signal 被其他先程唤醒
PThread_mutex_broadcast 同时唤醒其他所有的线程
3.4 死锁和饥饿
3.4.1 什么是死锁
并发环境下,各进程或者线程因为竞争资源而导致都在等待获取对方手里的资源,导致各进程或者线程阻塞,都没办法向前推进,所以这就是死锁。造成这种死锁的情况,比如因为抛出异常,导致锁或者资源没有被释放,又或者是因为获取资源的顺序。举个例子:
例子1:锁嵌套导致的死锁
如以上代码所示:
第一:当进程A或者线程A执行leftRight的时候,先获取临界资源left,正准备获取right临界资源的时候,CPU切换到进程B或者线程B上,执行righteft的时候,获取 right临界资源
第二:当进程B或者线程B获取left资源的时候,发现left资源已经被占用,所以需要等待
第三:当CPU又切回到进程A或者线程A的时候,发现right资源已经被占用,这样也会等待right被释放
第四:这样两个进程或者线程,都处于等待状态,等待对方释放资源,但是都有没人释放,就造成死锁
例子2:转账问题
比如我们转账的时候,为了防止并发带来的问题,我们对账号加锁独占,进行操作,即转出账户和转入账户都要加锁。
当用户A给用户B转账的时候,首先对自己账号from加锁,然后对转入账号加锁,一般情况下没什么问题,但是如果在A给B转账的同时 B也在给A转账,比如:
第一:A转账,对A账户加锁,此时正准备给B账户加锁的时候,时钟中断,CPU切换到了B给A转账的线程
第二:B开始给自己账户加锁,然后给A账户加锁,发现A账户已经被别的线程持有锁,所以就等待
第三:时钟中断,CPU切换到A,A获取B账户的锁,发现B账户被别的线程加了锁,然后就等待
第四:这样也是互相等待释放锁,从而产生了死锁
3.4.2 进程死锁、饥饿和死循环有什么区别
饥饿:指的是长期得不到想要资源,某进程或者线程无法向前推进,比如调度算法中的优先级调度,优先级低的可能很久都不会被调度,从而发生进程或者线程饥饿
死循环:某进程或者线程执行过程中,一直跳不出某个循环的现象,这个和并发没有关系,更多的是逻辑上的错误;而死锁更多是因为并发带来问题
3.4.3 死锁和活锁有啥区别
进程或者线程执行的时候,没有被阻塞,但是无法满足某种条件,导致一直重复的进行操作或者尝试,但是程序就是无法前进。这种就是活锁。
活锁和死锁的区别:
#1 死锁是必须阻塞等待对方线程释放锁资源;活锁则不是阻塞等待,而是重复运行或者重试
#2 死锁是进程或者线程必须持有一个资源,然后去获取另外一个资源;但是活锁没有这个限制
比如ZK中,在阶段1的时候,提案者1提出了M1的方案,然后提交给接收者,返回过半票数;然后提案者2提出了M2的方案,也提交了,因为M2>M1,所以也返回过半票数
当在第二阶段的时候,提案者1提交的时候,发现自己的提案不是最新的,则重新发起新的一阶段请求,提交提案M3,,M3大于M2,同样M3批准
然后提案者在二阶段的时候,发现自己的提案也不是最新的,则重新发起新的一阶段请求,提交提案M4,M4大于M3
如此这样,一直反复。这就是活锁。
3.4.4 死锁产生的必要条件
#1 进程必须是互斥的,即存在竞争临界资源
#2 进程获取资源之后,不能在未结束之前,强行被别的进程夺走,只可以主动释放
#3 进程持有多个资源。即 这个进程在持有某个资源不放的同时,还希望持有别的资源,这时候才会被阻塞
#4 存在资源循环等待
3.4.5 死锁的处理策略
3.4.5.1预防死锁(静态策略)
第一:破坏互斥条件
将互斥资源改造成可以共享的资源,比如使用SPOOLing技术
第二:破坏不剥夺条件
方案1: 当某个进程请求新的资源得不到满足的时候,他必须理科释放掉所保持的资源,待以后重新申请,也就是说先申请的资源即使没有使用,也需要主动释放
方案2:可以借助操作系统强行剥夺,比如优先级调度
第三:破坏请求和保持条件
进程一次性申请完他所需要的全部资源,在资源没有满足的时候,不让其运行;一旦获取资源这个进程,该进程就不会再请求别的任何资源
第四:破坏循环等待条件