⭐️ 本篇博客开始要继续给大家介绍线程同步和互斥相关的知识。多线程是如何进行同步与互斥操作的,下面我来和大家一起聊一聊~
目录
🌏线程互斥
🌲概念
线程互斥: 任何时刻,保证只有一个执行流进入临界区访问临界资源,通常对临界资源起到保护作用
相关概念
- 临界资源: 多线程执行流共享的资源就叫做临界资源
- 临界区: 每个线程内部,访问临界资源的代码,就叫做临界区
- 原子性: 不会被任何调度机制打断的操作,该操作只有两态(无中间态,即使被打断,也不会受影响),要么完成,要么未完成
🌲互斥量mutex
概念: 多个线程对一个共享变量进行操控时,会引发数据不一致的问题。此时就引入了互斥量(也叫互斥锁)的概念,来保证共享数据操作的完整性。在被加锁的任一时刻,临界区的代码只能被一个线程访问。
为了让大家更好地了解不加互斥量的情况下,多个线程同时操作共享变量会带来哪些的问题,我这里写了一个抢票的小程序,用全局变量ticket
代表现有票数,五个线程分别执行抢票的操作,也就是对ticket
进行减减的操作,直到票数为0就停止抢票
具体代码如下:
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
int ticket = 100;
void* get_tickets(void* arg)
{
long id = (long)arg;
while (1){
if (ticket > 0){
// 有票
usleep(1000);
ticket--;
printf("thread %ld get a ticket, the number of remaining is %d\n", id , ticket);
}else{
// 无票,退出
break;
}
}
}
int main()
{
pthread_t t[5];
// 创建5个线程
long i = 0;
for (; i < 5; ++i)
{
pthread_create(t+i, NULL, get_tickets, (void*)i);
}
// 释放5个线程
for (i = 0; i < 5; ++i)
{
pthread_join(t[i], NULL);
}
return 0;
}
一次代码运行结果如下:
可以发现的是,票居然抢成了负数,显然不符合实际情况。
分析以上结果有以下几点原因:
- if 语句判断条件为真以后,代码可以并发的切换到其他线程
- usleep 这个过程中,ticket还没有进行
--
的操作有很多线程会进入if条件 - –ticket 操作本身就不是一个原子操作
ticket有三条汇编指令(如下):
当一个线程正准备执行第三条指令时,两一个线程恰好执行了第二条指令,此时寄存器中的值又减了一次,当第一个线程执行完第三条指令时,ticket其实已经减了两次。所以这个操作不是一个原子操作movl ticket(%rip), %eax # 把ticket的值加载到eax寄存器中 subl $1, %eax # 把eax寄存器中的值减1 movl %eax, ticket(%rip) # 把eax寄存器中的值赋给ticket变量
如何解决上述问题呢?
- 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
🌲互斥量的接口
互斥量其实就是一把锁,是一个类型为pthread_mutex_t
的变量,使用前需要进行初始化操作,使用完之后需要对锁资源进行释放
- 初始化互斥量:
静态分配:pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
动态分配
函数原型:
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
参数:
- restrict mutex:要初始化的锁
- restrict attr:不关心,置空
返回值: 成功返回0,失败返回错误码
注意:
- 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
- 函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁再去竞争锁
- 加锁:
函数原型:
int pthread_mutex_lock(pthread_mutex_t *mutex);
参数:
- mutex:要加的锁
返回值: 成功返回0,失败返回错误码
- 解锁:
函数原型:
int pthread_mutex_unlock(pthread_mutex_t *mutex);
参数:
- mutex:要解的锁
返回值: 成功返回0,失败返回错误码
- 销毁互斥量:
函数原型:
int pthread_mutex_destroy(pthread_mutex_t *mutex);
参数:
- mutex:要销毁锁
返回值: 成功返回0,失败返回错误码
注意:
- 使用
PTHREAD_ MUTEX_ INITIALIZER
初始化的互斥量不需要销毁 - 不要销毁一个已经加锁的互斥量
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁
- 加锁的粒度要够小
改进上面的抢票小程序代码如下:
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
pthread_mutex_t mutex;// 创建锁变量
int ticket = 100;
void* get_tickets(void* arg)
{
long id = (long)arg;
while (1){
usleep(1000);// 放这模拟效果更好一些,不然会看到一个线程抢光所所有的票的现象
// 加锁
pthread_mutex_lock(&mutex);
if (ticket > 0){
// 有票
--ticket;
printf("thread %ld get a ticket, the number of remaining is %d\n", id , ticket);
// 解锁
pthread_mutex_unlock(&mutex);
}else{
// 无票,退出
// 解锁
pthread_mutex_unlock(&mutex);
break;
}
}
}
int main()
{
pthread_t t[5];
// 初始化锁
pthread_mutex_init(&mutex, NULL);
// 创建5个线程
long i = 0;
for (; i < 5; ++i)
{
pthread_create(t+i, NULL, get_tickets, (void*)(i+1));
}
// 释放5个线程
for (i = 0; i < 5; ++i)
{
pthread_join(t[i], NULL);
}
// 销毁锁
pthread_mutex_destroy(&mutex);
return 0;
}
代码运行结果如下:
可以看到的是,这样模拟的抢票过程是一个正常的,不会把票抢成负数,这就是因为临界区得到了保护。
总结几点并回答几个问题:
- 锁的作用: 对临界区进行保护,所有的执行流线程都必须遵守这个规则:lock——>访问临界区——>unlock
- 需要注意的点:
- 所有的线程必须看到同一把锁,锁本身就是临界资源,所以锁本身需要先保证自身安全申请锁的过程不能出现中间态,必须保证原子性
- 任一线程持有锁之后,其它线程如果还想申请锁时申请不到的,保证互斥性
- 线程申请不到锁此时会做什么?
进入等待队列进行等待,从运行队列转移到等待队列,状态由R变成S,持有锁的线程unlock之后,需要唤醒等待队列中的第一个线程
struct mutex { int lock;// 0 1 // ... sturct wait_queue;//锁下的等待队列 }
- 加锁之后,代码执行效率一般会下降,这是为什么?
原本并发或并行的执行流变成串行了,在很多线程的情况下,OS的压力会变大
🌲互斥量的原理
大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。
下面是lock和unlock的伪代码
lock:
movb $0, %a1 # 把0值放进寄存器a1里
xchgb %a1, mutex # 交换a1寄存器的内容和锁的值(无线程使用锁时,metux的值为1)
if (%a1 > 0)
return 0; # 得到锁
else
挂起等待;
goto lock;
unlock:
movb $1 mutex #把1赋给锁
唤醒等待的线程;
return 0;
在上述加锁的伪代码中演示了上步骤:
- 对寄存器的内容进行清0
- 把mutex的值(被使用值为0,未被使用值为1)和寄存器的内容进行交换
- 寄存器的内容为1代表得到了锁,为0代表未得到锁,要挂起等待
解锁的伪代码步骤(只有有锁的线程才可以执行到这段代码):
- 把mutex的值改为1
- 唤醒等待锁的线程
🌏线程安全和可重入
🌲概念
线程安全: 多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
重入: 同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
🌲常见的线程安全的情况
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
- 类或者接口对于线程来说都是原子操作
- 多个线程之间的切换不会导致该接口的执行结果存在二义性
🌲常见的线程不安全的情况
- 不保护共享变量的函数
- 函数状态随着被调用,状态发生变化的函数
- 返回指向静态变量指针的函数
- 调用线程不安全函数的函数
🌲常见可重入的情况
- 不使用全局变量或静态变量
- 不使用用malloc或者new开辟出的空间
- 不调用不可重入函数
- 不返回静态或全局数据,所有数据都有函数的调用者提供
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
🌲常见不可重入的情况
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
- 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
- 可重入函数体内使用了静态的数据结构
🌲区别与联系
区别:
- 函数是可重入的,那就是线程安全的
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的
联系:
- 可重入函数是线程安全函数的一种
- 线程安全不一定是可重入的(不一定发生线程安全问题),而可重入函数则一定是线程安全的。
- 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。
🌏死锁
概念: 死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。
死锁产生的四个必要条件:
- 互斥条件:一个资源每次只能被一个执行流使用
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
- 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
避免死锁:
- 破坏死锁的四个条件(上面分别对应的是:1.不使用锁 2.让一个执行流放开资源 3. 让两个执行流剥夺两一个执行流的资源 4. 调整申请资源的顺序)
- 假设顺序要一致
- 避免锁未释放的场景
- 资源一次性分配
避免死锁算法:
- 银行家算法:为了防止银行家资金无法周转而倒闭,对每一笔贷款,必须考察其是否能限期归还。在操作系统中研究资源分配策略时也有类似问题,系统中有限的资源要供多个进程使用,必须保证得到的资源的进程能在有限的时间内归还资源,以供其他进程使用资源。
- 死锁检测法
实例演示: 线程1拿着锁1运行5秒后申请锁2,线程2拿着锁2运行5秒后申请锁1,观察现象
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
pthread_mutex_t mutex1;// 创建锁变量1
pthread_mutex_t mutex2;// 创建锁变量2
int ticket = 100;
void* thread_run1(void* arg)
{
char* name = (char*)arg;
pthread_mutex_lock(&mutex1);
int count = 0;
while (1){
if (count++ == 5){
printf("%s is requesting a lock...\n", name);
pthread_mutex_lock(&mutex2);
}
printf("%s is running...\n", name);
sleep(1);
}
pthread_mutex_unlock(&mutex1);
}
void* thread_run2(void* arg)
{
char* name = (char*)arg;
pthread_mutex_lock(&mutex2);
int count = 0;
while (1){
if (count++ == 5){
printf("%s is requesting a lock...\n", name);
pthread_mutex_lock(&mutex1);
}
printf("%s is running...\n", name);
sleep(1);
}
pthread_mutex_unlock(&mutex2);
}
int main()
{
pthread_t t1, t2;
// 初始化锁
pthread_mutex_init(&mutex1, NULL);
pthread_mutex_init(&mutex2, NULL);
// 创建2个线程
pthread_create(&t1, NULL, thread_run1, (void*)"thread 1");
pthread_create(&t2, NULL, thread_run2, (void*)"thread 2");
// 释放2个线程
pthread_join(t1, NULL);
pthread_join(t2, NULL);
// 销毁锁
pthread_mutex_destroy(&mutex1);
pthread_mutex_destroy(&mutex2);
return 0;
}
代码运行结果如下: 两个线程都想申请对方资源,但各自都不放手自己的资源,造成了死锁
🌏条件变量
🌲概念
概念: 利用线程间共享的全局变量进行同步的一种机制,主要包括两个动作:一个线程等待"条件变量的条件成立"而挂起;另一个线程使“条件成立”(给出条件成立信号)。为了防止竞争,条件变量的使用总是和一个互斥锁结合在一起。
同步: 在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而避免饥饿问题,叫做同步
竞态条件: 因为时序问题,而导致程序异常,我们称之为竞态条件,旨在描述一个系统或者进程的输出依赖于不受控制的事件出现顺序或者出现时机。
为什么存在线程同步?
线程同步使得每个线程都能够访问临界资源,多个线程协同高效完成某些任务。
条件变量如何与互斥锁结合使用?
条件变量是包含一个等待队列的。多个线程可以去竞争一把锁,没有得到锁资源的线程会在锁上继续挂起等待,当拥有锁的线程条件变量满足时,会先释放锁资源,然后进入到条件变量的等待队列去等待(等待其他线程唤醒),这样其他线程就可以获得锁资源,如果此时唤醒的条件变量满足,该线程可以去唤醒等待队列中的第一个线程,自己释放锁资源,然后让第一个线程重新拥有锁资源,依次如此,多个线程就是顺序地执行工作。这样就可以实现线程同步的操作
🌲条件变量的接口
条件变量是一个类型为pthread_cond_t
的条件变量,课通过定义变量的方式来定义一个条件变量
条件变量初始化:
- 静态创建:使用字段
PTHREAD_COND_INITIALIZER
进行初始化 - 动态创建:
函数原型:
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);
参数:
- restrict cond:要初始化的条件变量
- restrict attr:不关心,置空
条件变量的销毁:
函数原型:
int pthread_cond_destroy(pthread_cond_t *cond);
参数:
- restrict cond:要销毁的条件变量
等待条件变量满足:
函数原型:
pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
参数:
- restrict cond:在这个条件条件变量下等待
- restrict mutex:互斥量
为什么pthread_cond_wait需要互斥量?
条件变量是实现线程同步的一种手段,如果一个线程进入等待队列还不释放锁资源,这样其他线程也不能够得到锁资源,这样唤醒线程的条件变量永远不可能满足,那么这个线程也将一直等待下去。所以一个线程进入等待队列需要释放自己手中的锁资源来实现真正地同步
唤醒条件变量满足:
函数原型:
int pthread_cond_broadcast(pthread_cond_t *cond); int pthread_cond_signal(pthread_cond_t *cond);
参数:
- cond:第一个函数是唤醒在这个条件变量的等待队列中的所有线程;第二个条件变量是唤醒在这个条件变量的等待队列中的第一个线程
pthread_cond_broadcast和pthread_cond_signal
前者是唤醒等待队列中所以的线程,而后者只唤醒等待队列中的第一个线程。前者会带来一个很不好的效应——惊群效应。多个线程同时被唤醒,但是最终只有一个线程能够获得“控制权”,其他获得控制权失败的线程可能重新进入休眠状态。等待获得控制权的线程释放锁资源后去通知下一个线程,这样就容易引起OS和CPU的管理调度负担,所以不建议使用。
实例演示: 创建五个线程,四个线程执行run1,上来就在条件变量下等待,另一个线程执行run2,然后无脑唤醒等待队列下的线程
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
pthread_cond_t cond;// 条件变量
pthread_mutex_t mutex;// 锁
void* threadrun1(void* arg)
{
char* name = (char*)arg;
while (1){
pthread_mutex_lock(&mutex);
pthread_cond_wait(&cond, &mutex);// 挂起,释放锁,当该函数返回时,进入到临界区,重新持有锁
printf("%s is waked up...\n", name);
sleep(1);
pthread_mutex_unlock(&mutex);
}
}
void* threadrun2(void* arg)
{
char* name = (char*)arg;
while (1){
sleep(1);
// 唤醒一个等待队列中的线程
pthread_cond_signal(&cond);
//pthread_cond_broadcast(&cond);
printf("%s is wakeing up a thread...\n", name);
}
}
int main()
{
pthread_t pthread1, pthread2, pthread3, pthread4, pthread5;
// 初始化条件变量
pthread_cond_init(&cond, NULL);
pthread_mutex_init(&mutex, NULL);
pthread_create(&pthread1, NULL, threadrun1, (void*)"pthread 1");
pthread_create(&pthread2, NULL, threadrun1, (void*)"pthread 2");
pthread_create(&pthread3, NULL, threadrun1, (void*)"pthread 3");
pthread_create(&pthread4, NULL, threadrun1, (void*)"pthread 4");
pthread_create(&pthread5, NULL, threadrun2, (void*)"pthread 5");
pthread_join(pthread1, NULL);
pthread_join(pthread2, NULL);
pthread_join(pthread3, NULL);
pthread_join(pthread4, NULL);
pthread_join(pthread5, NULL);
pthread_mutex_destroy(&mutex);
pthread_cond_destroy(&cond);
return 0;
}
代码运行结果如下: 可以看出的是,1-4号线程都是顺序得被唤醒,这足以说明cond是包括等待队列的
我们把唤醒的动作改成广播,下盖部分代码如下:
pthread_cond_broadcast(&cond);
printf("%s is wakeing up all threads...\n", name);
sleep(2);
代码运行结果如下: 可以看出的是,等待队列的顺序被破坏了,因为是异常唤醒一群,竞争能力弱的可能一直得不到锁资源
🌐总结
多线程的同步与互斥的内容就先介绍到这里。下一篇博客我会给大家介绍生产者消费者模型和信号量相关内容,喜欢的话,欢迎点赞、收藏和关注~