操作系统中的锁:自旋锁、互斥锁、条件变量、信号量、死锁

一直对这些概念学了又忘忘了又学,究其根本是因为始终没搞清楚这几个概念之间的关系,每次都是钻进细节里面,所以就很容易忘了。所以这里整理一下,以后回忆起来也方便。

首先锁是什么?操作系统中的锁是用于协调多个进程或线程对共享资源的访问(即多个线程同时只有一个线程可以进入临界区代码),以防止竞争条件和数据不一致的问题,以及实现进程之间的同步(本来两个不同的进程是各干各的,互不影响,这叫异步,如果我想要让进程A运行完某行代码之后进程B再运行,这叫作同步),比如读写进程之间就需要同步,写好了之后才能读。下面是实现进程互斥和同步的伪代码。

//进程互斥
mutex;
Thread1(){
	mutex.lock();
	Print();
	mutex.unlock();
}
Thread2(){
	mutex.lock();
	Print();
	mutex.unlock();
}
//进程同步
mutex.lock();//先锁,再运行子线程
Thread1(){
	write();
	mutex.unlock();
}
Thread1(){
	mutex.lock();
	read();//只有当写完之后才能读
}

那么锁有哪些呢?常见的有互斥锁(Mutex)、自旋锁(Spinlock)、读写锁(Read-Write Lock)、条件变量(Condition Variable)、信号量(Semaphore)。

互斥锁和自旋锁

互斥锁和自旋锁的目标都是:确保在同一时刻,只有一个线程能够持有锁,并访问共享资源,其他尝试获取锁的线程必须等待,直到锁被释放。它们的区别在于,当一个线程访问其他线程持有的互斥锁时,会被 OS 调度为阻塞状态(休眠),直到锁被释放后,再唤醒一个休眠的线程,而在无法获得自旋锁时,线程不会进入休眠状态,而是会持续循环检查锁的状态。因此自旋锁适用于锁持有时间非常短的场景,因为它避免了线程切换的开销,互斥锁适合线程持有锁时间比较长的场景,否则线程在等待过程中会让CPU一直空转,浪费CPU资源。

这两个锁的实现方式都要通过硬件原子指令,比如TAS(Test And Set)、CAS(Compare-and-Swap)等,不过这些指令都有可能某个线程一直得不到锁(即饥饿情况),用Fetch And Add可以解决这个问题,具体看这篇文章

当然也有不通过硬件指令,纯靠软件实现的方式,即著名的Peterson算法,可以实现两个线程的互斥。思路是定义两个变量:flag[2]turn,flag用来表示希望进入临界区的意图,turn用来决定到底谁可以进入临界区

// 初始化
bool flag[2] = {false, false};
int turn;

// 线程 0 的代码
void thread0() {
    while (true) {
        flag[0] = true;      // 表示线程 0 想要进入临界区
        turn = 1;            // 让线程 1 先来

        // 等待线程 1 放弃它的权利
        while (flag[1] && turn == 1) {
            // 忙等待
        }

        // 临界区代码段
        // ...

        // 离开临界区
        flag[0] = false;
    }
}

// 线程 1 的代码
void thread1() {
    while (true) {
        flag[1] = true;      // 表示线程 1 想要进入临界区
        turn = 0;            // 让线程 0 先来

        // 等待线程 0 放弃它的权利
        while (flag[0] && turn == 0) {
            // 忙等待
        }

        // 临界区代码段
        // ...

        // 离开临界区
        flag[1] = false;
    }
}

真的非常巧妙。试想一下,如果没有turn,就有可能线程0看线程1想进入临界区,1看0想,互相等待,就死锁。如果没有flag,就和普通的互斥锁实现方式一样,但是由于无法保证test和set是原子的,因此可能同时有两个线程进入临界区。而且必须是线程0让turn=1,线程1让turn=0,这样的话,当某个线程进入临界区时,说明要么另一个线程没有进入临界区的意图,要么就是有意图并且已经执行完了给turn赋值的操作,也就是说在本线程离开临界区之前,turn不可能再变了,这就保证了另一个线程不会进入临界区。而如果反过来,线程0让turn=0,线程1让turn=1,就有可能线程0进入临界区之后,线程1再改变turn,然后也进入临界区,就不互斥了。

不过由于性能原因,现代系统更倾向于使用硬件支持的原子操作来实现锁定机制,软件实现的锁主要用于教育和特定硬件环境下的研究。

条件变量

条件变是一种高级的同步原语,目标是实现当某个条件不满足时,线程进入等待状态,直到该条件被满足并由其他线程发出通知后,再继续执行。通常与互斥锁(Mutex)结合使用,来协调线程之间的合作。条件变量在需要等待某个条件的场景下非常有用,例如生产者-消费者问题。

首先想想,为什么需要条件变量?它能提供什么互斥锁提供不了的功能?设想一个生产者-消费者场景,消费者线程需要等生产者线程生产完之后才能消费资源,或许有人说可以像上面的伪代码一样实现,但是这就有一个问题,如果有多个读进程,那样实现同时只能有一个进程在读,一般来说读都是很慢的,实际上只要写完之后,多个读进程可以并行,读进程A阻塞在IO操作的时候可以切换到读进程B,以提高效率。所以需要一个新的东西来实现这个更多要求的锁,那为什么还需要和mutex结合使用呢?因为你得保证对条件的操作是互斥的呀!不然的话,设想这样一个场景,线程A先检查条件是否满足,不满足的话就休眠,但是在这两个操作期间线程B改了条件变量,使得条件变为TRUE了,但是这个时候线程A已经检查完了,然后就进入休眠状态,这还是小问题,再被唤醒就行了,但是一般来说线程B一旦使得条件满足之后,会唤醒当前被该条件阻塞的线程,但是因为线程A此时没有进入休眠状态,所以不会收到信号,等到CPU再切换回线程A,它进入休眠状态,就无法再被唤醒了。所以必须有一个锁,保证线程A的从检查条件是否满足到进入休眠状态期间成为一个临界区,以及线程B的从使得条件满足到唤醒阻塞的线程成为临界区。把这些功能封装起来,就是现在库中提供的条件变量了:

//初始化条件变量
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
//等待条件变量的线程
Thread_Consume(){
	pthread_mutex_lock(&mutex);    // 锁定互斥锁
	while (!condition_met) {       // 检查条件
    	pthread_cond_wait(&cond, &mutex); // 等待条件变量,自动释放互斥锁并挂起。当被唤醒时重新锁定互斥锁。
    	//为什么要用while循环?因为在被唤醒时,条件可能仍然不满足,或者可能由于“假唤醒”而导致不满足。
	}
	// 这里的代码在条件满足后继续执行
	pthread_mutex_unlock(&mutex);  // 解锁互斥锁
	//消费代码。。。
}
Thread_Product(){
	// 生产代码。。。
	pthread_mutex_lock(&mutex);    // 锁定互斥锁
	condition_met = true;          // 修改条件
	pthread_cond_signal(&cond);    // 唤醒等待线程
	// 或者使用 pthread_cond_broadcast(&cond); // 唤醒所有等待的线程
	pthread_mutex_unlock(&mutex);  // 解锁互斥锁
}

信号量

信号量其实比较好理解,就是mutex的升级版,mutex规定资源只能被一个线程访问,但是信号量可以用于控制对多个相同资源的访问。信号量有两个主要操作:P(等待)和V(信号),它们分别对应于以下概念:

P(Proberen, Test)操作:
P操作通常称为"等待"(Wait)或"减"(Down)操作。它将信号量的值减1,如果结果为负,表示资源不可用,当前线程将被阻塞,直到信号量值为非负时被唤醒。

V(Verhogen, Increment)操作:
V操作通常称为"信号"(Signal)或"加"(Up)操作。它将信号量的值加1,如果有线程因为P操作被阻塞且信号量的值变为非负,该线程会被唤醒。

信号量一个比价经典的应用是哲学家问题:五位哲学家坐在一张圆桌旁,每位哲学家前面有一个盘子,他们会交替地进行两项活动:思考和进餐。桌子中央有一碗意大利面条,每位哲学家都需要两根筷子才能吃饭。筷子放在相邻的哲学家之间,每位哲学家需要拿起左手边和右手边的筷子才能开始进餐。问题在于如何设计一个算法,确保每位哲学家都能公平地进餐,同时避免死锁和资源竞争。

Semaphore chopsticks[5] = {1, 1, 1, 1, 1};
void philosopher(int i) {
    while (true) {
        think();  // 哲学家正在思考

        // 获取筷子 (可能会引起死锁的简单实现)
        P(&chopsticks[i]);              // 拿起左边的筷子
        P(&chopsticks[(i + 1) % 5]);    // 拿起右边的筷子

        eat();  // 哲学家正在吃饭

        // 释放筷子
        V(&chopsticks[i]);              // 放下左边的筷子
        V(&chopsticks[(i + 1) % 5]);    // 放下右边的筷子
    }
}

在上述简单实现中,如果所有哲学家同时拿起左边的筷子,那么他们都会等待右边的筷子,导致死锁。下面介绍一下死锁,也是一个很重要的概念

死锁

死锁是指多个进程在运行过程中,因争夺资源而造成的一种僵局,若无外力作用,所有进程都无法向前推进。产生死锁有四个必要条件:

  1. 互斥:进程对所分配到的资源进行排他性使用,当一个进程获得了别的进程相用就必须等
  2. 请求和保持:进程已经获得了至少一个资源,但是又提出了新的资源请求,该资源被其他进程占有因此阻塞(同时自己不释放已经获得的资源)
  3. 不可抢占:一个进程获得了某个资源后,除非它自己释放,否则别人无法抢占
  4. 循环等待:进程之间的阻塞形成了一个循环链,即P0等P1,P1等P2……Pn等P0.

只需破坏其中一个条件即可解决死锁。所以可以这样:
奇偶编号策略:让编号为奇数的哲学家先拿左边的筷子,再拿右边的筷子;编号为偶数的哲学家先拿右边的筷子,再拿左边的筷子。这种策略破坏了死锁的循环等待条件。或者允许最多四个哲学家同时取筷子:通过限制最多有四位哲学家能够尝试拿起筷子,可以确保至少有一位哲学家能够进餐,从而避免死锁。即再加一个semaphore初始化为4,这样也是破坏了死锁的循环等待条件。

然后还有一恶搞避免死锁很经典的算法是银行家算法,思路也很简单,这里就不详细介绍了。

  • 30
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值