多线程同步与互斥
上一篇在这
在上一次的学习中,我们了解到了进程间的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操作:将
sem
减1
,相减后如果sem<0
则说明进程/线程进入阻塞等待;否则表明可以继续执行进程,表明P操作后可能会阻塞。 - V操作:将
sem
加1
,相加之后如果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);//做完饭
}
}
最后的通过信号量实现线程同步的描述还不到位,在我功力精进后再来好好完善一下。