多进程/线程的同步与互斥实现

多线程同步与互斥

上一篇在这
在上一次的学习中,我们了解到了进程间的6种通信机制,其中信号量是主要用于于实现进程间的同步与互斥,它使得共享资源在某一时刻只能被一个进程访问,防止了多进程竞争共享资源而造成资源混乱。

🚀在这一次的学习中,我们再来了解关于共享资源在多线程环境中存在的问题。

竞争与协作(互斥与同步)

我们知道,线程就是进程中的一条执行流程。如果一个进程中只有一个执行流程,那么它是单线程进程;如果一个进程中有多个执行流程那么就是多线程进程。线程是调度的基本单位,进程是资源分配的基本单位。

所以,线程之间是可以共享进程的资源,如代码段、堆空间、数据段、打开的文件等资源,但是每个线程都有自己独立的栈空间。

💣因此,随之出现了一个问题,就是如果多个线程竞争共享资源,不采取一定的措施就会造成共享数据的混乱。

具体例子:创建两个线程,他们分别对共享变量i自增1执行10000次。

#include <iostream> // I/O流对象 std::cout输出一些内容到控制台
#include <thread>	// 多线程编程头文件 std::thread创建一个线程

//共享数据
int i = 0;
//线程函数 对变量i进行自增操作
void test() {
    int num = 10000;
    for(int n = 0; n < num; n++) {
        i += 1; 
    }
}

int main(void) {
    
    //控制台输出 主线程操作
	std::cout << "Start all threads." << std::endl;
    //创建线程
    std::thread thread_test1( test );
    std::thread thread_test2( test );
    //等待线程执行完成
    thread_test1.join();
    thread_test2.join();
    //控制台输出
    std::cout << "All threads joined." << std::endl;
    std::cout << "now i is " << i << std::endl;
    
    return 0;
}
 

输出结果:

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

三次运行的结果是不同的,而且只有最后一次与预期结果20000相符,那么为什么会出现这种情况呢?

原因就是多个线程竞争共享资源,不采取一定的措施就会造成共享数据的混乱。

📔互斥

当多线程相互竞争操作共享变量时,由于运气不好,在执行过程中发生了上下文切换,我们会得到错误的结果,在每次运行后都可能得到不同的结果,输出结果存在不确定性。

❗多线程执行操作共享变量的这段代码可能会导致竞争状态,这段代码我们称之为临界区,是访问共享资源的代码片段,一定不能多线程同时执行。

在这个例子中,我们希望这两个线程是互斥的,即互相执行不造成干扰,保证一个线程在临界区执行时其他线程应该被阻止进入临界区。互斥不仅针对于多线程,对于多进程也一样,都是同一个原理。

在这里插入图片描述

📔同步

互斥解决了并发进程/线程中对临界区的使用问题。只要一个进程或者线程进入了临界区,其他想要进入临界区的的进程或线程都会处于阻塞状态,直至已经进入临界区的那个进程或线程离开临界区。

但我们也知道,在多线程中不是每个线程都是按照顺序执行的,它们基本是以各自独立的顺序速度执行,有的情况下我们希望多个线程能够互相合作共同完成一个任务。

⏲️这就涉及到了进程/线程同步的问题。同步就是并发进程/线程在实现同一个任务时在某一处需要互相等待与互通消息,这种相互制约的等待与互通信息称之为进程/线程同步。

这里的等待就相当于进程的阻塞,进程/线程需要得到某一个信息才能被唤醒继续执行,而互通就是传递信息进而唤醒被阻塞的进程


互斥与同步的实现与使用

❔那么在了解互斥与同步的概念后,我们如何实现它们呢?

为了实现进程/线程之间的互斥和同步,操作系统提供的方法主要有两种:、

  • :对进程/线程进行加锁、解锁操作
  • 信号量 :即之前了解过的P、V操作

在操作系统中,锁是一种同步机制,用于协调对共享资源的访问。它可以用来防止多个进程或线程同时访问关键代码块或共享数据,从而避免并发访问引发的竞态条件和数据不一致的问题。

使用加锁、解锁操作可以解决并发进程/线程的互斥问题。

  • 任何想要进入临界区的线程,必须先执行加锁操作。
  • 若是加锁操作顺利通过则线程可以进入临界区
  • 在完成对临界区资源的访问后执行解锁操作,释放该临界区资源

在这里插入图片描述

根据锁的实现不同,锁可以分为忙等待锁和无忙等待锁。


📔忙等待锁

我们可以通过Test-and-Set指令实现忙等待锁。

Test-and-Set指令(测试和置位指令)是现代CPU体系结构提供的特殊原子操作指令

//该指令C语言语法
int TestAndSet(int *old_ptr, int new) {
	int old = *old_ptr;
    *old_ptr = new;
    return old;
}

这些代码是原子执行的,因为它既可以测试旧值也可以设置新值,所以叫做“ 测试并设置 ”。

原子操作就是要么全部执行,要么都不执行,不能出现执行到一半的中间状态。

Test-and-Set指令实现忙等待锁

//C语言语法

int TestAndSet(int *old_ptr, int new) {
	int old = *old_ptr;
    *old_ptr = new;
    return old;
}

typedef struct lock_t {
	int flag; //表示锁的状态
} lock_t;

void init(lock_t *lock) {
	lock->flag = 0; //初始化锁的状态为解锁
}

void lock(lock_t *lock) {
    //TestAndSet返回值等于1则其他线程在临界区中 当前线程阻塞
    while(TestAndSet(&lock->flag, 1) == 1);
}

void unlock(lock_t *lock) {
	lock->flag = 0;//解锁操作
}
  • 场景一:
    • 假设一个线程在运行,调用lock(),没有其他线程持有锁,所以flag是0。当调用TestAndSet(flag,1)方法时,返回0,则线程跳出while循环,获取锁。同时,将flag设置为1,标志锁已经被占用。当线程离开临界区,调用unlock()flag清理为0。
  • 场景二:
    • 当一个线程已经持有锁(flag为1)。本线程调用lock(),然后调用 TestAndSet(flag,1),此时返回1。只要另一个线程一直持有锁则一直返回1,本线程会一直忙等。flag终于改为0,本线程调用 TestAndSet()返回0并原子地设置为1,从而获得锁进入临界区。

当获取不到锁时,线程就会一直while循环,不做任何事情,所以被称为 “ 忙等待锁 ” ,也被称为 “ 自旋锁 ”

自旋锁是最简单的一种锁,一直自旋,利用CPU周期直到锁可用。在单处理器上,需要抢占式的调度器(即不断通过时钟中断一个线程,运行其他线程)。否则,自旋锁在单CPU上无法使用,因为一个自旋锁不会放弃CPU。


📔无等待锁

传统的互斥锁在资源被占用时会导致请求锁的线程进入阻塞状态,等待资源的释放。 这种阻塞导致其他线程无法继续执行,从而影响了系统的并发性能。为了解决这个问题,无等待锁被引入。

无等待锁就是在上述情况下获取不到锁的时候不用自旋,把当前线程放入到锁的等待队列,然后执行调度程序,把CPU让给其他线程执行。

无等待锁可以通过一些特殊的算法和技术实现,可以确保当多个线程同时请求锁时,至少有一个线程能够成功获取锁并继续执行,而其他线程会继续尝试获取锁,而不是阻塞等待。


信号量

信号量在之前的学习中已经提到过,具体概念在上一篇中。

信号量是操作系统提供的一种协调共享资源访问的方法。

通常信号量表示资源的数量,对应的变量是一个整型变量(sem)。

还有两个原子操作的系统调用函数用来控制信号量:

  • P操作:将sem1,相减后如果sem<0则说明进程/线程进入阻塞等待;否则表明可以继续执行进程,表明P操作后可能会阻塞。
  • V操作:将sem1,相加之后如果sem<=0,则唤醒一个等待过程中的进程/线程,表明V操作不会被阻塞。

P、V操作必须成对出现,P操作是在进入临界区之前,V操作是离开临界区之后。

信号量数据结构和PV操作的算法描述

//信号量数据结构
type struct sem_t {
	int sem;//资源个数
    queue_t *q; //等待队列
} sem_t;

//初始化信号量
void init(sem_t *s, int sem) {
	s->sem = sem;
    queue_init(s->q);
}

//P操作
void P(sem_t *s) {
    s->sem--;
    if(s->sem < 0) {
        // 1.保留调用线程CPU现场
        // 2.将该线程的TCB插入到 s 的等待队列
        // 3.设置该线程为等待状态
        // 4.执行调度程序
    }
}
//V操作
void V(sem_t *s) {
	s->sem++;
    if(s->sem <= 0) {
        // 1.移出 s 等待队列首元素
        // 2.将该线程的TCB插入就绪队列
        // 3.设置该线程为就绪状态
    }
}

算法的具体实现可以自己查找资料,这里做个简单思路了解。

🧿信号量对于多进程/线程互斥的具体实现

  • 为每类共享资源设置一个信号量s,其初始值为1 表示该临界资源未被占用。
  • 将进入临界区的操作置于P(s)V(s)之间,即可实现进程/线程的互斥。
    • 任何想进入临界区的线程必须先在互斥信号量的基础上执行P操作,在完成资源访问后执行V操作。
    • 互斥信号量的初始值为1,所以执行P操作后s值为0,表明临界区资源空闲,可以访问,分配给该线程。
    • 若是这个时候另外一个线程想进入临界区,同样也会进行P操作,这个线程进行P操作后s值变为负值,表明临界区内已经有了线程,所以这个线程变为阻塞状态。
    • 在第一个进入临界区的线程完成资源访问后,进行V操作,s值变回1,唤醒刚才处于阻塞状态的线程并执行。

🧿信号量对于多进程/线程同步的具体实现

信号量对于多进程/线程同步的实现较为抽象,以这个具体例子来看。

在这里插入图片描述

  • 我们先将信号量初始化为0,妈妈在询问是否需要做饭时执行P(s1)操作,此时s1的值变为-1,所以妈妈线程变为阻塞等待状态。
  • 而孩子肚子饿时就会执行V(s1)操作,使得s1的值变为0,唤醒妈妈进程,开始做饭。
  • 然后孩子进程就会执行P(s2)操作,相当于询问妈妈饭做好没有,同样s2的初始值为0,所以s2变为了-1,孩子进程变为了阻塞等待状态。
  • 饭做好后,妈妈线程执行V(s2)操作,信号量变为0,唤醒孩子进程,进行吃饭。

注意:对于线程的同步实现,我们要知道两个线程的目的是一样的,都是通过本线程的执行进而达到最终目的,在这个例子中就是吃饭-做饭的同步。所以,两个线程执行的P、V操作是有关联的。

semaphore s1 = 0; //表示不需要吃饭
semaphore s2 = 0; //表示饭还没做完

//儿子线程
void son() {
    while(TRUE) {
        //肚子饿
        V(s1);//叫妈妈做饭
        P(s2);//等待饭做好
    }
}

//妈妈线程
void mom() {
	while(TRUE) {
        P(s1);//询问是否需要做饭
        //做饭
        V(s2);//做完饭
    }
}

最后的通过信号量实现线程同步的描述还不到位,在我功力精进后再来好好完善一下。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值