并发技术、进程、线程和锁拾遗
Part1. 多任务
计算机发展起初,CPU 资源十分昂贵,如果让 CPU 只能运行一个程序那么当 CPU 空闲下来(例如等待 I/O 时),CPU 资源就会被浪费,为了使 CPU 资源得到更好的利用,先驱编写了一个监控程序,如果发现某个程序暂时无需使用 CPU 时,监控程序就把另外的正在等待 CPU 资源的程序启动起来,以充分利用 CPU资源。这种方法称为 - 多道程序(Multiprogramming)
对于多道程序,最大的弊端是各程序之间不区分轻重缓急,对于用户交互式的程序来说,对 CPU 计算时间的需求并不多,但是对于响应速度却有比较高的要求。而对于计算类程序来说则相反,对响应速度要求低,但需要长时间的 CPU 计算。想象一个场景:我在同时在浏览网页和听音乐,我们希望浏览器能够快速响应,同时也希望音乐不停,这时候多道程序就没法达到我们的要求了。
于是人们改进了多道程序,使得每个程序运行一段时间之后,都主动让出 CPU 资源,这样每个程序在一段时间内都有机会运行一小段时间。这样像浏览器这样的交互式程序就能够快速地被处理,同时计算类程序也不会受到很大影响。这种程序协作方式被称为 分时系统(Time-Sharing System)。
在分时系统的帮助下,我们可以边用浏览器边听歌了。但是如果某个程序出现了错误,导致了死循环,不仅仅是这个程序会出错,整个系统都会死机,为了避免这种情况,一个更为先进的操作系统模式被发明处理,也就是我们现在熟悉的多任务系统(Multi-tasking System)。
操作系统从最底层接管了所有硬件资源。所有的应用程序在操作系统上以 进程(Process) 的方式运行,每个进程都有自己独立的地址空间,相互隔离。CPU 由操作系统统一统一进行分配。每个进程都有机会得到 CPU,同时在操作系统控制下,如果一个进程运行超过了一定时间,就会被暂停掉,失去 CPU 资源。这样就避免了一个程序的错误导致整个系统死机。如果操作系统分配给各个进程的运行时间都很短,CPU 可以在多个进程间快速切断,就像很多进程都同时在运行的样子。几乎所有现代操作系统都是采用这样的方式支持多任务。
Part2. 进程
进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。它可以申请和拥有系统资源,是一个活动的实体。它不只是程序的代码,还包括当前的活动,通过程序计数器的值和处理递存器的内容来表示。
- 进程是一个实体,每一个进程都有它自己的地址空间,一般情况下,包括文本区域(text region)、数据区域(data region)和堆栈(stack region)。文本区域存储器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储着活动过程调用的指令和本地变量。
- 进程是一个“执行中的程序”。程序是一个没有生命的实体,只有处理器赋予程序生命时,它才能成为一个活动的实体,我们称其为进程。
1. 进程的基本状态
- 等待态:等待某个事件的完成;
- 就绪态:等待系统分配处理器以便运行;
- 运行态:占有处理器正在运行;
几种状态的切换:
- 运行态 -> 等待态:往往是由于等待外设,等待主存等资源分配或等待人工干预而引起的。
- 等待态 -> 就绪态:则是等待的条件已满足,只需分配到处理器后就能运行。
- 运行态 -> 就绪态:不是由于自身原因,而是由外界原因使运行状态的进程让出处理器,这时就变成就绪态。例如:时间片用完,或有更高优先级的进程来抢占处理器等
- 就绪态 -> 运行态:系统按某种策略选中就绪队列中的一个进程占用处理器,此时就变成了运行态。
2. 进程调度
调度种类
高级、中级和低级调度作业从提交开始直到完成,往往要经历下述三级调度:
- 高级调度(High-Level Scheduling):又称为作业调度,它决定把后备作业调入内存运行;
- 中级调度(Intermediate-Level Scheduling):又称为在虚拟存储器中引入,在内、外存对换区进行进程对换。
- 低级调度(Low-Level Scheduling):又称为进程调度,它决定把就绪队列的某进程获得 CPU。
非抢占式调度与抢占式调度
- 非抢占式
分派程序一旦把处理机分配给某进程后便让它一直运行下去,直到进程完成或者发生进程调度某事件而阻塞时,才把处理机分配给另一个进程。 - 抢占式
操作系统将正在运行的进程强行暂停,由调度程序将 CPU 分配给其他就绪进程的调度方式。
调度策略的设计
- 响应时间:从用户输入到产生反应的时间
- 周转时间:从任务开始到任务结束的时间
CPU 任务可以分为交互式任务和批处理任务,调度最终的目标是合理的使用 CPU,使得交互式任务的响应时间尽可能短,用户不至于感到延迟,同时使得批处理任务的周转时间尽可能短,减少用户等待的时间。
调度算法
FIFO 或 First Come,First Served(FCFS)
调度的顺序就是任务到达就绪队列的顺序。
公平、简单(FIFO 队列)、非抢占、不适合交互式。未考虑任务特性,平均等待时间可以缩短。Shortest Job First(SJF)
最短的作业(CPU 区间长度最小)优先调度
可以证明,SJF 可以保证最小的平均等待时间
Shortest Job First (SRJF): SJF 的可抢占版本,比 SJF 更有优势
SJF、SRJF 如何知道下一 CPU 区间大小?根据历史进行预测:指数平均法。优先权调度
每个任务关联一个优先权、调度优先权最高的任务。
注意:优先权太低的任务一直就绪,得不到运行,出现“饥饿”现象。
FCFS 是 RR 的特例,SJF 是优先权调度的特例,这些调度算法都不适合于交互式系统。Round-Robin(RR)
设置一个时间片,按时间片来轮转调度(“轮叫”算法)
优点:定时有响应,等待时间较短;缺点:上下文切换次数较多;
如何确定时间片?时间片太大,响应时间太长;吞吐量变小,周转时间变长;当时间片过长时,退化为 FCFS。多级队列调度
按照一定的规则建立多个进程队列
不同的队列有固定的优先级(高优先级有抢占权)
不同的队列可以给不同的时间片和采用不同的调度方法
存在问题 1:没法区分 I/O bound 和 CPU bound;
存在问题 2:也存在一定程度的“饥饿”现象多级反馈队列
在多级队列的基础上,任务可以在队列之间移动,更细致的区分任务。
可以根据“享用”CPU 时间多少来移动队列,阻止“饥饿”。
最通用的调度算法,多数 OS 都使用该方法或其变形,如 UNIX、Windows 等。
3. 进程同步
临界资源与临界区
在操作系统中,进程是占有资源的最小单位(线程可以访问其所在进程内的所有资源,但线程本身并不占有资源或仅仅占有一点必须资源)。但对于某些资源来说,其在同一时间只能被一个进程所占用。这些一次只能被一个进程所占用的资源就是所谓的临界资源。
典型的临界资源比如物理上的打印机,或是存在硬盘或内存中被多个进程所共享的一些变量和数据等(如果这类资源不被看成临界资源加以保护,那么很有可能造成丢数据的问题)。
对于临界资源的访问,必须是互斥进行。也就是当临界资源被占用时,另一个申请临界资源的进程会被阻塞,直到其所申请的临界资源被释放。而进程内访问临界资源的代码被称为临界区。
解决临界区问题可能的方法:
- 一般软件方法
- 关中断方法
- 硬件原子指令方法
- 信号量方法
信号量
信号量是一个确定的二元组(s,q),其中 s 是一个具有非负初值的整型变量,q 是一个初始状态为空的队列,整型变量 s 表示系统中某类资源的数目:
- 当 s ≥ 0 时,表示系统中当前可用资源的数目
- 当 s < 0 时,其绝对值表示系统中因请求该类资源而被阻塞的进程数目
除信号量的初值外,信号量的值仅能由 P 操作和 V 操作更改,操作系统利用它的状态对进程和资源进行管理。
P 操作:P 操作记为 P(s),其中 s 为一信号量,它执行时主要完成以下动作:
// 可理解为占用一个资源,若原来就没有则记账“欠”1 个
s.value = s.value - 1;
若 s.value ≥ 0
,则进程继续执行,否则(即s.value < 0
),则进程被阻塞,并将该进程插入到信号量 s 的等待队列 s.queue 中。
实际上,P 操作可以理解为分配资源的计数器,或是使进程处于等待状态的控制指令
V 操作:V 操作记为 V(s),其中 s 为一信号量,它执行时,主要完成以下动作:
// 可理解为归还一个资源,若原来就没有则意义是用此资源还 1 个欠账
s.value = s.value + 1;
若 s.value > 0
,则进程继续执行,否则(即 s.value ≤ 0
),则从信号量 s 的等待队列 s.queue 中移出第一个进程,使其变为就绪状态,然后返回原进程继续执行。
实际上,V 操作可以理解为归还资源的计数器,或是唤醒进程使其处于就绪状态的控制指令
信号量方法实现:生产者 - 消费者互斥与同步控制
semaphore fullBuffers = 0;//仓库中已填满的货架个数
semaphore emptyBuffers = BUFFER_SIZE;//仓库货架空闲个数
semaphore mutex = 1;//生产 - 消费互斥信号
Producer()
{
while(True)
{
/*生产产品item*/
emptyBuffers.P();
mutex.P();
/*item存入仓库buffer*/
mutex.V();
fullBuffers.V();
}
}
Consumer()
{
while(True)
{
fullBuffers.P();
mutex.P();
/*从仓库buffer中取产品item*/
mutex.V();
emptyBuffers.V();
/*消费产品item*/
}
}
死锁
死锁:多个进程因循环等待而造成的无法执行的现象
死锁会造成进程无法执行,同时会造成系统资源的极大浪费(资源无法释放)。
死锁产生的 4 个必要条件:
- 互斥使用(Mutual exclusion)
- 指进程对所有分配到的资源进行排他性使用,即在一段时间内某资源只由一个进程占用。如果此时还有其他进程请求资源,则请求者只能等待,直至占有资源的进程用毕释放。
- 不可抢占(No preemption)
- 指进程已获得的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放。
- 请求和保持(Hold and wait)
- 指进程已经保持至少一个资源,但又提出了新的资源请求,而该资源已被其他进程占有,此时请求进程阻塞,但又对自己已获得其他资源保持不放。
- 循环等待(Circular wait)
- 指在发生死锁时,必然存在一个进程-资源的环形链,即进程集合{P0, P1, P2, P3, P4, ..., Pn} 中的 P0 正在等待一个 P1 占用的资源;P1 正在等待 P2 占用的资源,...,Pn 正在等待已被 P0 占用的资源。
死锁的避免:银行家算法
思想:判断此次请求是否造成死锁,若会造成死锁,则拒绝该请求。
4. 进程间通信
本地进程间通信的方式有很多,可以总结为下面四类:
- 消息传递(管道、FIFO、消息队列)
- 同步(互斥量、条件变量、读写锁、文件和写记录锁、信号量)
- 共享内存(匿名的和具名的)
- 远程过程调用(Solaris门 和 Sun RPC)
Part3. 线程
多线程解决了前面提到的多任务问题。然而很多时候不同的程序需要共享同样的资源(文件,信号量等),如果全都使用进程的话会导致切换的成本很高,造成 CPU 资源的浪费。于是出现了线程的概念。
线程,有时被称为轻量级进程(Lightweight Process,LWP),是程序执行流的最小单元。一个标准的线程由线程 ID,当前指令指针(PC),寄存器集合和堆栈组成。
线程具有以下属性:
轻型实体
线程中的实体基本上不拥有系统资源,只是有一点必不可少的、能保证独立运行的资源。线程的实体包括:程序、数据和 TCB(Thread Control Block)。线程是动态概念,它的动态特性由线程控制块 TCB 描述。独立调度和分派的基本单位
在多线程 OS 中,线程是能独立运行的基本单位,因而也是独立调度和分派的基本的单位。由于线程很“轻”,故线程的切换非常迅速且开销小(在同一进程中的)可并发执行
在一个进程中的多个线程之间,可以并发执行,甚至允许在一个进程中所有线程都能并发执行;共享进程资源
在同一进程中的各个线程,都可以共享该进程所拥有的资源,这首先表现在:所有线程都具有相同的地址空间(进程的地址空间),这意味着,线程可以访问改地址空间的每一个虚拟地址;此外,还可以访问进程所拥有的已打开文件、定时器、信号量等。由于同一个进程内的线程共享内存和文件,所以线程之间互相通信不必调用内核。
线程共享的环境包括:进程代码段、进程的公有数据(利用这些共享的数据,线程很容易的实现相互之前的通讯)、进程打开的文件描述符、信号的处理器、进程的当前目录和进程用户 ID 与进程组 ID。
Part4. 锁
锁要解决的是线程之间争夺资源的问题:
- 资源是否是独占(独占锁 - 共享锁)
- 抢占不到资源怎么办(互斥锁 - 自旋锁)
- 自己能不能重复抢(重入锁 - 不可重入锁)
- 竞争读的情况比较多,读可不可以不加锁(读写锁)
上面几个角度并非相互独立,在实际场景中需要将他们集合起来才能构造出一个合适的锁。
独占锁 - 共享锁
当一个共享资源只有一份的时候,通常我们使用独占锁,常见的即各个语言中的 Mutex
。当共享资源有多份时,可以使用信号量(Semaphere)。
互斥锁 - 自旋锁
对于互斥锁来说,如果一个线程已经锁定了一个互斥锁,第二个线程又试图去获取这个互斥锁,则第二个线程将会被挂起(即休眠、不占用 CPU 资源)。
在计算机系统中,频繁的挂起和切换线程,也是有成本的。自旋锁就是解决这个问题的。
自旋锁:指当一个线程在获取锁的时候,如果锁已经被其他线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。
容易看出,当资源等待的时间较长,用互斥锁让线程休眠,会消耗更少的资源,当资源等待的时间较短时,使用自旋锁将减少线程的切换,获得更高的性能。
Java 中的 synchornized
和 .NET 中的 lock
(Monitor
)的实现,是结合了两种锁的特点。简单说,它们在发现资源被抢占之后,会先试着自旋等待一段时间,如果等待时间太长,则会进入挂起状态。通过这样的实现,可以较大程度上挖掘出锁的性能。
重入锁 - 不可重入锁
可重入锁(ReetrantLock),也叫作递归锁,指的是同一线程内,外层函数获得锁之后,内层递归函数仍然可以获取到该锁。
换而言之:同一线程再次进入同步代码时,可以使用自己已获取到的锁。
使用可重入锁时,在同一线程中多次获取锁,不会导致死锁。使用不可重入锁,则会导致死锁发生。
Java 中的 synchornized
和 .NET 中的 lock
(Monitor
) 都是可重入的。
读写锁
有些情况下,对于共享资源读竞争的情况远远多于写竞争,这种情况下,对读操作每次都进行加锁,是得不偿失的。读写锁就是为了解决这个问题。
读写锁允许同一时刻被多个读线程访问,但是在写线程访问时,所有读线程和其他的写线程都会被阻塞。简单可以总结为,读读不互斥,读写互斥,谢谢互斥。
对读写锁来说,有一个升级和降级的概念,即当前获得了读锁,想把当前的锁变成写锁,成为升级,反之称为降级。锁的升降级本身也是为了提升性能,通过改变当前锁的性质,避免重复获取锁。
Part.5 协程
协程,又称为微线程,纤程。英文名: Coroutine
协程可以理解为用户级线程,协程和线程的区别是:线程是抢占式的调度,而协程是协同式的调度,协程避免了无意义的调度,由此可以提高性能,但也因此,程序员必须自己承担调度的责任,同时,协程也失去了标准线程使用多 CPU 的能力。
Part.6 IO多路复用
基本概念
IO 多路复用是指内核一旦发现进程指定的一个或者多个 IO 条件准备读取,他就通知该进程。IO 多路复用适用于如下场景:
- 当客户处理多个描述字时(一般是交互式输入和网络套接口),必须使用 I/O 复用。
- 当一个客户同时处理多个套接口时,而这种情况是可能的,但很少出现。
- 如果一个 TCP 服务器既要处理监听套接口,又要处理已连接套接口,一般也要用到 I/O 复用。
- 如果一个度武器既要处理 TCP,又要处理 UDP,一般要使用 I/O 复用。
- 如果一个服务器要处理多个服务或多个协议,一般要使用 I/O 复用。
与多进程和多线程技术相比,I/O 多路复用技术的最大优势是系统开销小,系统不必创建 进程/线程,也不必维护这些 进程/线程,从而大大减小了系统的开销。
常见的 IO 复用实现
- select (Linux/Windows/BSD)
- epoll (Linux)
- kqueue (BSD/Mac OS)
更多干货文章
博客:www.qiuxuewei.com
微信公众号:@开发者成长之路
一个没有鸡汤只有干货的公众号
****************************************************