本文介绍同步与互斥,主要包括相关背景,竞态条件、原子操作、临界区 & 互斥 & 死锁 & 饥饿等相关概念,临界区的属性以及如何实现临界区的互斥访问等。
第一章到第八章内容,到目前为止:
独立线程:
合作线程:
不确定性和不可重现意味着 BUG 可能是间歇性发生的。
虽然如此,但进程 & 线程、计算机、设备需要合作。
合作的优点:
程序调用 fork()
创建新进程的过程:
fork()
系统调用会运行 new_pid = next_pid++
:
LOAD next_pid Reg1
STORE Reg1 new_pid
——> 假设发生了上下文切换,转去执行另一个程序的 fork()
操作INC Reg1
STORE Reg1 next_pid
next_pid
的值为 100
,那么其中一个进程将获得new_pid=100
的新进程 ID,另一个进程将获得 new_pid=101
的新进程 ID,同时 next_pid
的值将增加到102
。new_pid=100
的新进程 ID,而 next_pid
的值也变成了101
。多线程程序具有不确定性和不可重现的特点,但 无论多个线程的指令序列怎么交替执行,程序都必须正常工作。
因此,我们必须要有一些新的机制来保证能够 达到最终确定的结果,后面会引入同步互斥机制来解决这种不确定性的问题。
竞态条件是指在多个线程或进程并发执行的情况下,对共享资源的访问顺序不确定,从而导致程序的行为出现不可预测的结果。
原子操作是指在并发环境下,不能被中断的一系列操作,要么全部执行成功,要么全部不执行。原子操作能够保证在多线程或多进程同时访问共享资源时的数据一致性。
原子操作的特点包括:
原子操作通常是由硬件提供的特殊指令或者操作系统提供的原子操作函数来实现的。
实际上的操作往往不是原子的:
i++
这样的简单语句,实际上是由三条指令构成的。临界区(Critical Section)是指进程中的一段需要访问共享资源,并且当另一个进程处于相应代码区域时便不会被执行的代码区域。
互斥(Mutual Exclusion)是指当一个进程处于临界区并访问共享资源时,没有其他进程会处于临界区并访问任何相同的共享资源。
死锁(Dead Lock)是指两个或以上进程,在相互等待完成特定任务,而最终没法将自身任务进行下去,形成循环等待。
饥饿(Starvation)是指一个可执行的进程,被调度器持续忽略,以至于虽然处于可执行状态却不能被执行。
要设计实现临界区,临界区必须满足以下属性:
thread
处于入口区,那么在 thread
的请求被接受前,其它线程进入临界区的时间是有限制的(否则线程 thread
可能会饥饿)。实现临界区的互斥访问的方法:
中断是指在程序执行过程中,由硬件或软件触发的一种特殊事件,它会打断当前正在执行的程序,并跳转到预定义的中断处理程序中执行。
上面说,中断会打断当前正在执行的程序,也正是因为这个中断,才导致了进程 / 线程的上下文切换——执行其它进程 / 线程。如果没有这个中断,是不是就可以让一个进程 / 线程顺利地执行完临界区代码呢?是的!
禁用硬件中断,实现临界区的互斥访问:
禁用硬件中断确实可以解决多个进程 / 线程同时访问临界区共享资源,但它有一些缺点:
对于两个线程 的临界区的互斥访问:
1 | /* T_i 的通常结构 */ |
使用一个共享变量 turn
:
1 | /* 声明并初始化共享变量 */ |
代码分析:
turn
的值只能是 0/1
——线程 会在执行完临界区代码后,将 turn
设置为 1/0
;turn
的值不是自己(while (turn != i)
),则会一直忙等待,等待另一个线程将 turn
值设置为它的对方(turn=j
)。存在的问题(是否满足临界区属性):
turn=1
),下次能访问临界区的只能是线程 ,如果 一直不访问临界区(不去设置 turn=0
),那么 便无法再次访问临界区。因此,该设计满足互斥访问,但 只能在两个进程交替的访问临界区 的情况下满足前进。
使用一个共享数组变量 flag[2]
,先检查是否可以进入临界区:
1 | /* 声明并初始化共享变量 */ |
代码分析:
flag[]
数组的每个位置值只能是 0/1
——线程 会在执行临界区代码前(后),将 flag[i]
的标志设置为 1
(0
);存在的问题(是否满足临界区属性):
flag
都被置为 0
;然后向下执行 while
,都会发现对方的 flag
不为 1
,则会进入临界区,无法实现对临界区的互斥访问。使用一个共享数组变量 flag[2]
,先置位标志,后检查是否可以进入临界区:
1 | /* 声明并初始化共享变量 */ |
代码分析:
flag[]
数组的每个位置值只能是 0/1
——线程 会在 enter section 前将 flag[i]
的标志设置为 1
、在执行完 critical section 代码后,将 flag[i]
的标志设置为 0
;存在的问题(是否满足临界区属性):
flag
都被置为 0
,并向下执行。当线程 将其 flag[0]
设置为 1
后,发生了上下文切换;然后,线程 也会将其 flag[1]
设置为 1
;至此,不管哪个线程执行 enter section 代码,都会等待,从而发生死锁。算法思想:在双标志后检查法中,两个进程都争着想进入临界区,但是谁也不让谁,最后谁都无法进入临界区。而 Peterson 算法,在双方都争着想进入临界区时,让进程尝试“孔融让梨”,主动让对方先使用临界区。
Peterson 算法是满足进程 和 之间互斥的经典的基于软件的解决方法(1981 年)。
它使用两个共享数据项(Use two shared data items):
int turn
指示该谁进入临界区bool flag[]
指示进程是否准备好进入临界区1 | /* 声明并初始化共享变量 */ |
代码分析:
flag[i] = true
、并谦让着让对方先进入 turn=j
,然后自己做好忙等待的心理准备——但是会检查两个共享数据项,如果对方想进入临界区(flag[j] == true
),这时自己便忙等待;否则,自己可以成功进入临界区。可以证明(用反证法),Peterson 算法能够满足互斥、前进、有限等待三种特性。
Dekker 算法是第一个针对双线程的基于软件的解决方法(1965 年)。
1 | /* 声明并初始化共享变量 */ |
代码分析:
flag
为真,表示想进入临界区(只是想,不代表能进入);turn
为自己,表示自己可以进入临界区(对方不可以);flag[]
共享变量:那就像「双标志后检查」一样,会发生死锁;turn
的使用,解决了死锁的问题。可以证明,Dekker 算法能够满足互斥、前进、有限等待三种特性。
有机会再补充。
解决 N 个进程的临界区互斥访问:
这些算法有以下特点:
LOAD
和STORE
指令。硬件提供一些原语:
操作系统提供更高级的编程抽象来简化并行编程:
锁是一个抽象数据结构,用于控制并发访问共享资源的机制:
Lock::Acquire()
:锁被释放前一直等待,然后得到锁;Lock::Release()
:锁释放,唤醒任何等待的进程。使用锁来编写临界区:
1 | lock_next_pid->Acquire(); |
前面(背景小节)的例子变得简单起来,lock_next_pid->Acquire()
加锁后,临界区中的代码 next_pid++
,在同一时间(从读、执行加一再到写回内存),只有一个进程对其操作,确保对 next_pid
的操作是原子的。
大多数现代体系结构都提供特殊的原子操作指令:
测试和置位(Test-And-Set, TAS)指令 把给定的内存地址设置为 1,然后返回之前的旧值:
1 | bool TestAndSet(bool *target) { |
还有一个 Compare And Swap (CAS) 指令。
1 | void Exchange(bool *bool *b) { |
Test-And-Set 和 Exchange 在硬件上实现为一个原子操作,执行期间不会被其他处理器打断。
1 | class Lock { |
Lock::Acquire()
代码分析:
上面方法,线程在等待的时候是忙等待,消耗 CPU 时间,自旋锁采用这种设计(因为它适用于持有锁的时间很短的情况下)。
无忙等待锁,使处于忙等的进程睡眠,在临界区执行完的进程会将睡眠的进程唤醒。
如果临界区执行时间短,选择忙等方式(如自旋锁);如果临界区执行时间长,选择无忙等待方式(如互斥锁、信号量、条件变量)。
1 | /* 声明并初始化共享变量 */ |
代码分析:
key==1, lock==1
时,交换两数后,不会影响两数的值,即无法退出循环、无法获取锁;key==1, lock==0
时,交换两数后,有 key==0, lock==1
,即立即获取锁、退出循环。优点:
缺点:
锁是更高等级的编程抽象:
常用的三种实现方法:
可选的实现内容:
参考资料:
1:https://github.com/OXygenMoon/OperatingSystemInDepth
2:https://blog.csdn.net/weixin_53407527/article/details/125088864