Lecture 9: Concurrency 2
上次幻灯片从进程(processes)的角度定义了死锁和临界区,这是打字错误吗
答:这并没有太大的区别。对于所讨论的定义,您需要两个关键的东西:
- 并发执行多个指令序列——线程和进程都满足此属性。
- 共享资源——线程更容易共享资源。有一些机制可以在进程之间共享资源,包括满足互斥的资源。一个标准的例子是被命名为信号量。
回顾:
- 并发性问题的示例(例如,counter++)
- 并发性问题的根本原因——不可预测的执行顺序。
- 潜在的解决方案-关键部分和强制互斥
- 进一步的问题——死锁。
互斥方法
基于软件:Peterson的解决方案
基于硬件:test_and_set(), swap_and_compare()
基于操作系统:内核阻塞。
基于软件:Peterson的解决方案
Peterson方案是一种基于软件的解决方案,在旧机器上运行良好
使用了两个共享变量:
- turn:表示下一个进入临界区的进程是哪个;
- bool flag [2]:表示一个进程准备进入临界区
能推广到多个进程
两个进程的Peterson解满足所有“临界区要求”(互斥、进度、公平性)
过程i的Peterson解:
do {flag[i] = true; // i wants to enter critical sectionturn = j; // allow j to access firstwhile (flag[j] && turn == j);// whilst j wants to access critical section// and its j’s turn, apply busy waiting// CRITICAL SECTION, e.g. counter++flag[i] = false;// remainder section
} while (...);
过程j的Peterson解:
do {flag[j] = true; // j wants to enter critical sectionturn = i; // allow i to access firstwhile (flag[i] && turn == i);// whilst i wants to access critical section// and its i’s turn, apply busy waiting// CRITICAL SECTION, e.g. counter++flag[j] = false;// remainder section
} while (...);
互斥要求
变量turn一次最多只能有一个值
- flag [i]和flag [j]在进入临界区时都为true
- turn是一个只能存储一个值的奇异变量
- 因此,while(flag[i] && turn == i)或while(flag[j] && turn == j)中最多有一个为真,并且最多有一个进程可以进入其临界区(互斥)。
进程要求
任何进程都必须能够在某个时间点进入其临界区域
⇒flag[j] == false
⇒while(flag[j] && turn == j)将终止进程 i
⇒i 进入临界区
公平/有限等待要求
公平分配的等待时间/进程不能无限期地等待
如果Pi和Pj都想进入临界区域:
⇒flag[i] == flag[j] = true
⇒turn是 i 或 j ⇒假设turn == i ⇒while(flag[j] && turn == j)终止,i 进入临界区
⇒i完成临界区⇒flag[i] = false ⇒while(flag[i] && turn == i)终止,j进入临界区。
即使它再次循环,它也会设置turn = j,让另一个线程先进入。
基于硬件:原子操作指令
将test_and_set()和swap_and_compare()指令实现为一组原子(=不可中断)指令
它们可以与锁变量结合使用,如果锁正在使用,则假定为true(或1)
test_and_set()
- “剩余代码”中的进程/线程不会影响对临界区的访问
- 如果进程 j 不想进入它的临界区
- 读取和设置变量显示为单个指令
- 如果同时调用test_and_set() / compare_and_swap(),它们将依次执行
// Test and set method
bool test_and_set(bool* bIsLocked) {bool rv = *bIsLocked;*bIsLocked = true;return rv;
}// Example of using test and set method
do {// WHILE the lock is in use, apply busy waitingwhile (test_and_set(&bIsLocked));// Lock was false, now true// CRITICAL SECTION...bIsLocked = false;...// remainder section
} while (...)
test_and_set必须是原子的/不可中断的
compare_and_swap()
// Compare and swap methodint compare_and_swap(int* iIsLocked, int expected, int new_value) {int const old_value = *iIsLocked;if(old_value == expected)*iIsLocked = new_value;return old_value;}do {// While the lock is in use (i.e. == 1), apply busy waitingwhile (compare_and_swap(&iIsLocked, 0, 1));// Lock was false, now true// CRITICAL SECTION...iIsLocked = 0;...// remainder section
} while (...);
基于操作系统
test_and_set和compare_and_swap级别比较低,需要忙等。
操作系统可以使用这些硬件指令来实现更高级别的互斥机制,即互斥体(mutexes)和信号量(semaphores)
互斥体
互斥体是提供互斥的抽象。(Mutexes = mutual exclusion)
互斥锁提供了两个函数的接口:
- acquire(&mutex):在进入临界区之前调用,当临界区没有其他人时返回。
- release (&mutex):在退出临界区后调用,允许其他线程获取互斥锁。应该只在匹配获取之后调用。
此接口的细节可能会有所不同——名称或使用面向对象语言中的方法。
如何准确地提供这个接口是一个实现细节:
- 在朴素的假设下,我们可以使用Peterson算法。
- 我们可以使用原子硬件操作和忙等。
- 操作系统可能会阻塞试图获取不可用互斥锁的线程。
- 根据设计假设,可以选择结合多种策略的混合解决方案来优化性能。
例子:Pthreads互斥锁
int counter = 0;
pthread_mutex_t lock;
void* calc(void* param) {int const iterations = 50000000;for(int i = 0; i < iterations; i++) {pthread_mutex_lock(&lock); // acquirecounter++;pthread_mutex_unlock(&lock); // release}return 0;
}int main() {pthread_t tid1 = 0, tid2 = 0;pthread_mutex_init(&lock,NULL);pthread_create(&tid1, NULL, calc, 0);pthread_create(&tid2, NULL, calc, 0);pthread_join(tid1,NULL);pthread_join(tid2,NULL);printf("The value of counter is: %d\n", counter);
}
理解
能否“纯粹软件”地在并发硬件上实现互斥,完全不依赖操作系统或硬件特殊指令?
• 在 单处理器、关中断 的模型里可以:只要把时钟中断关掉,当前线程独占 CPU,直到主动开中断,自然互斥。
• 在 多核/乱序/缓存一致性 的现代硬件上,不行。任何纯软件算法(Dekker、Peterson、Lamport Bakery 等)都隐含要求“读/写按某种顺序对所有 CPU 立即可见”,而现代 CPU 的重排序、Store Buffer、Invalidate Queue 会打破这些顺序,导致算法失效。因此必须至少依赖一条“有同步语义”的硬件原语(Test-And-Set、Load-Link/Store-Conditional、CAS、SWAP、fence 等)。→ 结论:现代并发硬件下无法仅靠软件实现可靠互斥。Peterson 算法在现代机器上能用吗?
• 不能直接使用。它隐含“写后读”必须立即全局可见的假设;现代 CPU 的乱序执行和缓存延迟会违反这一假设,出现丢失更新或死锁。
• 若 在每条关键读写前后插入全屏障(memory fence),则可恢复正确性,但这就等于借用了硬件提供的同步支持,不再是“纯软件”了。
→ 作为教学实验,可在 x86 上用volatile
+_mm_mfence()
验证,但生产代码不会用它。只读共享变量还需要加锁吗?
• 读本身不需要互斥,但有两个陷阱:
a) “读-改-写”的组合(即使你觉得只是读)仍要保护;
b) 如果变量可能被并发写入,读线程可能看到撕裂值(例如跨缓存行的 64-bit 整数在 32 位总线上)。
• 因此:
– 若变量初始化后永不改变,或已用const
/static const
修饰,则无需锁;
– 若变量仍可能被写,即使当前代码段只读,也应使用原子读、读锁或 RCU 等机制,以保证可见性和原子性。