操作系统中的并发
《现代操作系统》第二章,进程间通信。
竞争条件
在一些操作系统中,协作的进程可能共享一些彼此都能读写的公用存储区,这个存储区可能在内存中(也也可能是内核的数据结构),也可能是一个共享文件夹。
这里通过简单的方式来呈现一下一种可能出现竞争条件的情况
/* shard_memory.cpp */
class SharedMemory {
public:
int set_and_get(int i)
{
val = i; // step 1
return val; // step 2
}
/*
当多个线程一起执行这个函数时,对于每个线程来说,确实是先执行了step1,在执行step2,
但是由于当一个线程执行了step1后,另一个线程可能又会执行step1,导致val的值于先前传递进来的值不一样,
return val的值也就自然不同了。
*/
private :
int val;
};
/* main.cpp */
SharedMemory sm; // sharing data struct
// the function which thread excute
auto func = [&] (int i){
int res = sm.set_and_get(i);
std::cout << res << std::endl;
};
// thread 1
std::thread thread1(sm, 1);
// thread 2
std::thread thread2(sm, 2);
thread1.join();
thread2.join();
可以看到上面的代码的输出不一定是正确,但是代码又能够跑起来,就说明这段程序存在竞争条件。
竞争条件:两个或多个进程(线程)读写某些共享数据,而最后的结果取决于进程运行的精确时序,称为竞争条件。
临界区
我们把对共享内存进行访问的程序片段称作临界区域(critical region)或临界区(critical section)。如果我们能够进行适当的安排,使得两个进程不可能同时处于临界区中,就能避免竞争条件。
对于好的解决方案,需要同时满足一下四个条件:
- 任何两个进程不能同时处于其临界区。
- 不应对CPU的速度和数量做任何假设。
- 临界区外运行的进程不能阻塞其他进程。
- 不得使进程无限期等待进入临界区。(死锁)
互斥(mutual exclusion):以某种手段确保当一个进程在使用一个共享变量或文件时,其他进程不能做相同的操作。
忙等待的互斥
屏蔽中断
在单处理器中最简单的方法是使每个进程在刚刚进入临界区后立即屏蔽所有中断,并在就要离开之前在打开中断。屏蔽中断后,时钟中断也会被屏蔽,CPU只有发生时钟中断或其他中断时才会进行进程切换。这样,在屏蔽中断之后,并不会切换到其他进程。
存在的缺点:
- 这个方案将屏蔽中断的权力交给用户进程,是不明智的。若一个中断屏蔽之后没有再次打开,这个系统可能会因此终止。
- 在多核处理器中,对单个CPU进行中断屏蔽,并不会影响其他的CPU对临界区进行操作,并不能阻止竞争条件的发生。
锁变量
设置一个共享(锁)变量,在进入临界区之前,检查该变量是否为0,如果为0说明该锁可以获取,然后将该锁变量更改为1,在离开临界区之后再将所变量的值改回0。
但是这个方案同样存在问题,锁变量的检查和更改并不具有原子性,并不能保证在检查操作执行完成和更改操作发生之前的时间段内,其他的线程不会执行相关操作(检查或更改),这就意味着,如果有两个线程同时检查到该变量为0, 然后同时进入临界区。
严格轮换法
两个进程交替进入临界区。
turn = 0;
// process 1
while (true)
{
while(turn != 0);
critial_region();
turn = 1;
noncritial_region();
}
// process 2
while (true)
{
while(true != 1);
critial_region();
turn = 0;
noncritial_region();
}
连续测试直到某个值出现为止,称为忙等待(’busy waiting)。由于这种方式浪费CPU时间,所以通常应该避免。只有认为等待时间是非常短的情形下,才使用忙等待。
这种方式也存在问题,设想一下,如果进程1执行完了critial_region()
,将turn置为1,此时进程2就能够进入临界区执行任务。而进程2很快执行完了critial_region()
和noncritial_region()
,回到循环开头再次等待,但进程1这个时候还在执行上一个循环的noncritial_region()
,并不在临界区内。这时,一个不在临界区内的进程阻塞其他进程,不符合要求。
Peterson解法
#define FALSE 0
#define TRUE 1
#define N 2 // the number of process
int turn; // which process enter the critial_region
int interested[N]; // initial as 0 FALSE
void enter_region(int process)
{
int other;
other = 1 - process;
interested[process] = TRUE;
// 现在假设有两个进程同时执行到这一步,但是turn的值只能由后一个完成操作的进程决定
// 假设进程0先,进程1后,则此时turn = 1
turn = process; // turn = 1
// 对于进程0 turn == process -> false 进入临界区
// 对于进程1 turn == process -> true 并且 interested[other] = TRUE -> true 一直在循环
// 直到进程0 退出临界区 interested[other] = FALSE,进程1才能进入临界区
while (turn == process && interested[other] == TRUE);
}
void leave_region(int process)
{
interested[process] = FALSE;
}
在使用共享变量之前,各个进程使用其进程号0或1作为参数调用enter_region
,该调用在需要使用时将使进程等待,直到能够安全进入临界区。在完成对共享变量的操作后,进程调用leave_region
离开临界区。
TSL
指令
这是一种需要硬件支持的方案,
TSL RX, LOCK
TSL test and set lock
测试并加锁,跟CAS change and swap
相似,
它将一个内存字lock读到寄存器RX中,然后在该内存地址上存一个非零值。读字和写字操作保证是不可分割的,即该指令结束之前其他处理器均不允许访问该内存字。执行TSL
指令的CPU将锁住总线,以禁止其他cpu在本指令结束之前访问内存。
**锁住总线和屏蔽中断不同。**屏蔽中断,然后在读内存字之后跟着写操作并不能阻止总线上的第二个处理器在读操作和写操作之间访问该内存字。
TSL
指令的使用
enter_region:
TSL REGISTER, LOCK |复制锁到寄存器并将锁设为1
CMP REGISTER, #0 |锁是否等于零
JNE enter_region |若不是零,说明锁已被设置,所以循环
RET |返回调用者,进入临界区
leave_region:
MOVE LOCK,#0
RET
XCHG
指令:该指令可以原子性地交换两个位置的内容。
enter_region:
MOVE REGISTER, #1
XCHG REGISTER, LOCK
CMP REGISTER, #0
JNE enter_region
RET
leave_region:
MOVE LOCK, #0
RET
睡眠与唤醒
**优先级反转问题(priority inversion problem):**通过忙等待实现互斥的方法,可能存在这样的问题–假设一台计算机上有两个进程,进程H的优先级较高,进程L的优先级较低。调度规则规定,只用进程H处于就绪状态,它就可以运行。在某一时刻,进程L处于临界区内,此时进程H变为就绪状态,准备运行。但是由于进程L还在临界区内,进程H开始忙等待,但根据调度规则,只用进程H处于就绪状态,进程L就不会运行,这样进程H就会一直等待下去。
sleep
和weakup
,进程间通信原语。sleep是一个将引起调用进程阻塞的系统调用,即被挂起,直到另一个线程将其唤醒。weadup调用有一个参数,即要被唤醒的进程。
生产者-消费者问题
#define N 100 // the number of the buffer
int count = 0; // sharing buffer
// producer
void producer(void) {
int item;
while (true) {
item = produce_item(); // produce a item
if (count == N) sleep(); // the buffer is full, so the producer will sleep
insert_item(item); // add the item into the buffer
++count;
if (count == 1) wakeup(consumer); // 0 -> 1, weak up the consumer which wating for item
}
}
// consumer
void consumer(void) {
int item;
while (true) {
if (count == 0) sleep();
item = remove_item();
--count();
if (count == N - 1) wakeup(producer);
consumer_item(item);
}
}
// 这个是书上给出的例子,确实写的很奇怪,所以就会出现了信号丢失的问题。
信号丢失问题:由于这里并未对count
的访问进行限制(应该是保证相连操作的原子性):缓冲区为空,消费者刚刚读取count的值发现他为0。此时调度程序决定暂停消费者并启动生产者。生产者向缓冲区中加入一个数据项,count加1。它推断认为count由0变成1,消费者此时一定在睡眠,所以调用weakup来唤醒消费者。但是消费者并没有睡眠(准确来说是将要睡眠),所以信号丢失了。之后消费者开始睡眠,由于先前的信号丢失了,但是生产者已经发出了信号,它认为此时的消费者正在工作,直到缓冲区被填满,生产者也进入了睡眠。
信号量
信号量使用一个整型变量来累计唤醒次数,供以后使用。
一个信号量的取值可以是0(表示没有保存下来的唤醒操作)或者正值(表示有一个或多个唤醒操作)。
信号量提供了两种操作:up
和down
down
:检查其值是否大于0。若该值大于0,则将其值减一并继续;否则,进程将睡眠。检查数值、修改变量值以及可能发生的睡眠操作均作为一个单一的,不可分割的原子操作完成。保证了一旦一个信号量操作开始,则在该操作完成或阻塞之前,其他进程不可以访问该信号量。up
:对信号量执行一次递增操作。如果一个或多个进程在该信号量上睡眠,无法完成一个先前的down操作,则由系统随机选择其中一个并允许该进程完成它的down操作。
互斥量
相当于一个不需要计数能力的信号量,mutex
。
互斥量是一个可以处于两态之一的变量:加锁和解锁。
mutex_lock 和 mutex_unlock 的实现(csa)
mutex_lock:
TSL REGISTER, MUTEX
CMP REGISTER, #0
JZE ok
CALL thread_yield // 让出cpu,没有忙等待
JMP mutex_lock
ok: RET
mutex_unlock:
MOVE MUTEX, #0
RET
-
futex 快速用户空间互斥
funtex
是linux的一个特性,它实现了基本的锁,但是避免陷入内核,除非它真的不得不这样做。futex
包含两个部分:一个内核服务和一个用户库。**内核服务提供一个等待队列:**它允许多个进程在一个锁上等待。它们将不会运行,除非内核明确地对他们解除阻塞。将一个进程放到等待队列需要系统调用,我们应该避免这种情况。所以,在没有竞争时,futex
完全在用户空间工作。特别地,这些进程共享一个锁变量,假设锁初值为1,即假设这意味着锁是释放状态。线程通过执行原子操作“减少并检验”来夺取锁。接下来,这个线程检查结果,看锁是否被释放。如果未处于被锁状态,线程将夺取该锁;但是如果该锁被另一个线程持有,那么线程必须等待,这种情况下,futex
库并不自旋,而是使用一个系统调用把这个线程放在内核的等待队列中。当一个线程使用完该锁,它通过原子操作“增加并检验”来释放锁,并检查结果,看是否有进程阻塞在内核等待队列上。如果有,它会通知内核可以对等待队列里的一个或多个进程解除阻塞。如果没有锁竞争,内核则不需要参与其中。 -
pthread
中的互斥量直接上代码,因为感觉日常比较熟悉
#include <stdio.h> #include <pthread.h> #define MAX 10000000 pthread_mutex_t the_mutex; pthread_cond_t condc, condp; int buffer = 0; void *producer(void *ptr) { int i; for (i = 1; i < MAX; ++i) { pthread_mutex_lock(&the_mutex); while (buffer != 0) pthread_cond_wait(&condp, &the_mutex); buffer = i; pthread_cond_signal(&condc); pthread_mutex_unlock(&the_mutex); } pthread_exit(0); } void *consunmer(void *ptr) { int i; for (i = 1; i <= MAX; ++i) { pthread_mutex_lock(&the_mutex); while (buffer == 0) pthread_cond_wait(&condc, &the_mutex); buffer = 0; pthread_cond_signal(&condp); pthread_mutex_unlock(&the_mutex); } pthread_exit(0); } int main(int argc, char **argv) { pthread t pro, con; pthread_mutex_init(&the_mutex, 0); pthread_cond_init(&condc, 0); pthread_cond_inti(&condp, 0); pthread_create(&con, consumer, 0); pthread_create(&pro, producer, 0); pthread_join(pro, 0); pthread_join(con, 0); pthread_cond_destory(&condp); pthread_cond_destroy(&condc); pthread_mutex_destory(&the_mutex); }