上一章我们学习了线程的控制,理解了什么是线程id,和线程私有栈等相关的概念,还学习了线程退出的四种方式,并学习了几个相关接口。
本章我们还是继续进行线程方面的学习,接下来我们将学线程的互斥加锁,同步条件变量等待等相关问题的学习,目标已经确定,搬好小板凳准备开讲啦……
在之前【进程通信 — 共享内存】
的学习中,我们已经介绍过临界资源,临界区,原子性等概念了,今天我们再来复盘一下:
进程/线程
(执行流)都能够看到并访问的资源叫做临界资源
。进程/线程
(执行流)在进行访问的时候,就都是乱序的。进程/线程
(执行流)而言,访问临界资源的代码
。进程/线程
代码中,有大量的代码,只有一部分代码,会访问临界资源。进程/线程
对临界资源做读写的代码,我们称之为临界区。进程/线程
,访问临界资源。在线程中,存在着访问临界资源而导致的冲突:
如果我们要对一个变量进行++/- -要做什么工作呢?
补充:
CPU内的寄存器是被所有的执行流共享的,但是寄存器里面的数据是属于当前执行流的上下文数据。线程被切换的时候,需要保存上下文,线程被换回的时候,需要恢复上下文。
线程切换时,需要保存的不是寄存器,而是寄存器里面的数据。
所以谈线程必谈两个概念,一个是线程的上下文,另一个是线程的独立站结构。
问题出现:
100
拷贝到CPU
中进行- -
操作。CPU
,执行其他操作(连续- -
到50
)。100
减到50
,再切回原来的进程。50
的值又干到了99
。为了保证能够正确的控制线程的访问,其就必须维护自身的原子性!不能有中间状态!!
当我们访问某种资源的时候,任何时刻都只有一个执行流在进行访问,这个就叫做:互斥特性。
为了维护互斥性,我们要给线程的临界资区加锁。
线程安全是指在多线程环境下,对共享资源的访问不会导致数据不一致或者出现意料之外的结果。
当多个线程同时访问共享资源时,如果没有适当的同步机制或保护措施,可能会导致以下问题:
为了确保线程安全,需要采取合适的并发控制措施,如加锁机制、原子操作、信号量等。这些机制可以保证在任意时刻只有一个线程能够访问共享资源,避免数据竞争和竞态条件的发生。
面对不可重入函数,这不是问题,这是种特性,一般通过加锁来解决。
加锁本质上是把多线程串行起来,确保同一时间只有一个线程可以进入临界区,让每个线程可以安全的调用这个不可重入的函数。
如果一个库函数明明告知你了是不可重入的,但是还不加保护的在多线程操作中调用它。
那么这段代码如果出现bug,并不是库函数本身有问题,是编码的问题。
注意:
一个线程对全局变量的恶意修改 ,可能会影响其他线程安全。
一个线程有bug导致线程退出,导致其他线程也退出,也叫做线程退出。
绝大多数的系统自带的库(比如C++的STL库)都是不可重入的,并非所有容器都是线程安全的。
定义全局的互斥锁,所有线程能访问:
方法一:静态分配
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
方法二:动态分配
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
// 参数:
// mutex:要初始化的互斥量
// attr:NULL
PTHREAD_MUTEX_INITIALIZER
初始化的互斥量不需要销毁。多线程竞争锁:
因此,线程互斥锁的使用涉及到多个线程之间的竞争获取锁的过程,以确保同一时间只有一个线程能够获得互斥锁,并访问共享资源。
其他线程已经锁定互斥量(互斥锁),或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock
调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
我们用多线程实现一个简单的抢票程序:
#include
#include
#include
#include
#include
using namespace std;
// int 票数计数器
// 临界资源
int tickets = 10000; // 临界资源,可能会因为共同访问,可能会造成数据不一致问题。
// 定义一个全局的锁
pthread_mutex_t mutex;
void* getTickets(void* args)
{
const char* name = static_cast<const char*>(args);
while (true)
{
// 加锁
pthread_mutex_lock(&mutex);
if (tickets > 0)
{
usleep(1000);
cout << name << " 抢到了票, 票的编号: " << tickets << endl;
tickets--;
// 解锁
pthread_mutex_unlock(&mutex);
//other code
usleep(123); //模拟其他业务逻辑的执行
}
else
{
// 票抢到几张,就算没有了呢?0
cout << name << "] 已经放弃抢票了,因为没有了..." << endl;
// 解锁
pthread_mutex_unlock(&mutex);
break;
}
}
return nullptr;
}
int main()
{
// 对锁初始化
pthread_mutex_init(&mutex, nullptr);
pthread_t tid1;
pthread_t tid2;
pthread_t tid3;
pthread_create(&tid1, nullptr, getTickets, (void*)"thread1");
pthread_create(&tid2, nullptr, getTickets, (void*)"thread2");
pthread_create(&tid3, nullptr, getTickets, (void*)"thread3");
int n = pthread_join(tid1, nullptr);
cout << n << ":" << strerror(n) << endl;
pthread_join(tid2, nullptr);
cout << n << ":" << strerror(n) << endl;
pthread_join(tid3, nullptr);
cout << n << ":" << strerror(n) << endl;
// 锁用完了,释放这把锁
pthread_mutex_destroy(&mutex);
return 0;
}
如果不加锁的话,if
条件判断是否也会有线程安全问题:
if
判断的时候也有可能出现问题,因为CPU也需要参与。所以就导致了多线程进行抢票的时候出现了负数票的情况:
加锁的范围:
这把锁,本身不就也是临界资源吗,谁来给它加锁呢?锁的设计者早就想到了~
pthread_mutex_lock
竞争和申请锁的过程,就是原子的!难度在加锁的临界区里面,就没有线程切换了吗??
当加锁的线程被切走的时候,绝不会有其他线程进入临界区!!
所有线程要想进入临界区,就得申请锁,只要有了锁才能进入临界区。
当一个线程有了锁,就不害怕被切换了。
进入临界区前,都必须要申请锁,当一个线程申请锁之后,另一个线程只能等,要么使用临界区使用完了,要么根本就没有进入临界区。
在执行临界区代码时,加锁的那部分代码,会不会切换呢?会的!或者阻塞呢?会的!或者挂起呢?会的!
为了实现互斥锁操作,大多数体系结构都提供了swap
或exchange
指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性。
现在我们看一下pthread_mutex_lock
和pthread_mutex_unlock
的伪代码:
寄存器只有一个,是被所有线程共享的,但是寄存器里的内容是被所有线程私有的。单CPU任何时刻,只能有一个线程在跑。
每个线程要切走时,必须将自己的上下文带走。
过程:
%a寄存器
中的0和内存中mutex的值交换。%a寄存器
中的值是1,然后线程A再被切走。%a寄存器
中的0和内存中mutex的值交换。%a寄存器
的内容不大于0,线程B挂起等待。用一条汇编的方式,把寄存器的值放到mutex,把mutex的值放到了寄存器,这个动作就叫做加锁。
本质:
解锁:解锁,就是把1再写回去mutex,这就完成了解锁。
模拟死锁,出现的情况:
现象:
#include
#include
#include
#include
#include
#include "Lock.hpp"
using namespace std;
// 模拟死锁
// 静态定义锁的方式(可以不用再去destroy,也可以不用对其进行init)
pthread_mutex_t mutexA = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t mutexB = PTHREAD_MUTEX_INITIALIZER;
void *startRoutine1(void *args)
{
while (true)
{
pthread_mutex_lock(&mutexA);
sleep(1);
pthread_mutex_lock(&mutexB);
cout << "我是线程1,我的tid: " << pthread_self() << endl;
pthread_mutex_unlock(&mutexA);
pthread_mutex_unlock(&mutexB);
}
}
void *startRoutine2(void *args)
{
while (true)
{
pthread_mutex_lock(&mutexB);
sleep(1);
pthread_mutex_lock(&mutexA);
cout << "我是线程2,我的tid: " << pthread_self() << endl;
pthread_mutex_unlock(&mutexB);
pthread_mutex_unlock(&mutexA);
}
}
int main()
{
pthread_t t1, t2;
pthread_create(&t1, nullptr, startRoutine1, nullptr);
pthread_create(&t2, nullptr, startRoutine2, nullptr);
pthread_join(t1, nullptr);
pthread_join(t2, nullptr);
return 0;
}
一把锁会不会有死锁问题呢?我们来演示一下:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
int cnt = 100;
using namespace std;
void *startRoutine(void *args)
{
string name = static_cast<char *>(args);
while (true)
{
pthread_mutex_lock(&mutex);
cout << name << " count : " << cnt-- << endl;
// 代码写错了,写了两个锁
// pthread_mutex_lock(&mutex);
pthread_mutex_unlock(&mutex);
// 耗时的操作尽量不要再临界区里面
sleep(1);
}
}
编码错误引起的:
那就是同时申请了一把锁两次,这就导致第二次申请锁的时候一直在等待第一把锁释放,这就导致了死锁问题。
死锁的条件通常被称为"死锁四个必要条件",它们是:
当以上四种条件同时满足时,就可能发生死锁。要解决死锁问题,需要破坏其中至少一个条件。例如,可以采用资源有序分配、避免持有并等待、引入抢占机制以及打破循环等待等方法来预防和解除死锁。
线程互斥,它是对的,难道它任何场景都是合理的吗?很显然并不是。
互斥有可能导致饥饿问题:
在保证临界资源安全的前提下(互斥等),让线程访问某种资源,具有一定的顺序性,称之为同步!
一般在保证互斥前提条件下,多做了一个工作,让多个线程访问某种资源具备了一定的顺序性,这种特性我们称之为,同步。
如何完成同步呢?
Linux中提供了完成同步的重要机制,叫做:条件变量。(最常用,没有之一,最常用的线程同步的策略)
条件变量:
条件变量要和mutex互斥锁,一并使用!
基本的接口和pthread
库的其他接口很相似,用法也几乎一样。其中attr
也是设置条件变量的属性,这里置为nullptr
即可。
timewait
接口可以在条件变量下等,设置等待的时间(超时了就不等了)。mutex
互斥锁来保证条件变量读写的原子性。
broadcast
是给在当前条件变量等待的所有线程发信号唤醒,而signal
是发送信号只唤醒一个线程。
条件变量决定,什么时候叫醒一个线程,以前只要有锁,如果所有线程都被叫醒,大家都去参与竞争,谁抢到了算谁的,这个机制完全是由调度器决定的。
下面的代码可以很好的演示上述的函数接口:
#include
#include
#include
#include
#include
#include
using namespace std;
// 定义一个条件变量
pthread_cond_t cond;
// 定义一个互斥锁
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; // 当前不用,但是接口需要,所以我们需要留下来
// 定义全局退出变量
volatile bool quit = false;
// 三个线程都会调用这个函数
void *waitCommand(void *args)
{
string name = static_cast<char*>(args);
pthread_mutex_lock(&mutex);
// 如果不退出一直去运行
while (!quit)
{
// pthread_cond_wait内部先解锁
pthread_cond_wait(&cond, &mutex);
// 被唤醒出来之后锁已经加上了
cout << "thread id: " << pthread_self() << " running... " << name << endl;
}
pthread_mutex_unlock(&mutex);
cout << "thread quit..." << (char*)args << endl;
return nullptr;
}
int main()
{
// 初始化一个条件变量
pthread_cond_init(&cond, nullptr);
// 创建三个线程
pthread_t t1, t2, t3;
pthread_create(&t1, nullptr, waitCommand, (void*)"thread1");
pthread_create(&t2, nullptr, waitCommand, (void*)"thread2");
pthread_create(&t3, nullptr, waitCommand, (void*)"thread3");
// 主线程控制
while(true)
{
char n;
// cin和cout在交叉使用的时候,缓冲区会做强制刷新
cout << "请输入你的command: ";
cin >> n;
if(n == 'n')
{
// 唤醒在特定条件变量下等的线程
pthread_cond_signal(&cond);
}
else if (n == 'x')
{
pthread_cond_broadcast(&cond);
}
else
{
quit = true;
break;
}
usleep(100);
}
// 唤醒所有等待的条件变量
pthread_cond_broadcast(&cond);
cout << "main thread quit..." << endl;
// 释放条件变量
pthread_cond_destroy(&cond);
pthread_mutex_destroy(&mutex);
// 等待线程
int m = pthread_join(t1, nullptr);
cout << strerror(m) << endl;
m = pthread_join(t2, nullptr);
cout << strerror(m) << endl;
m = pthread_join(t3, nullptr);
cout << strerror(m) << endl;
return 0;
}
运行结果:
现象:
但是我们退出时,将quit改成true,循环条件不满足,三个线程退出,将三个线程全部唤醒,我们却发现,阻塞等待住了,这是什么原因:(重点)
pthread_cond_wait
内部已经帮我们想到了这一点,并做了相应的措施:(重点)
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex)
{
pthread_mutex_unlock(mutex);// 先解锁
// 避免因为该线程拿着锁去休眠了,导致其他线程无法申请该锁
//条件变量相关代码
pthread_mutex_lock(mutex);// 条件满足后,再加锁
}
pthread_cond_wait
内部是先调用pthread_mutex_unlock
解锁,再调用pthread_mutex_lock
加锁。
知道了pthread_cond_wait
内部细节,再来回头看阻塞的原因:(重点)
pthread_cond_wait
内部都要重新加锁,就要同时去竞争一把锁,但是只有一个能竞争成功。pthread_cond_wait
函数最后加锁那里,等着申请锁),而且那个线程退出了也没有解锁,就导致其他线程无法占用这个锁,就一直在阻塞等待锁。解决问题:在退出循环后解锁(重点)
q
之后,false被改成true,但是此时所有线程还是在条件变量中等。pthread_cond_broadcast
之后。为什么主线程输入n
多个线程会挨个执行?
pthread_cond_wait
内部解锁了。pthread_cond_wait
内部解锁了。n
,pthread_cond_signal
唤醒一个线程,然后加锁。pthread_cond_wait
内部解锁了,线程继续等待。pthread_cond_signal
执行完毕,主线程的while循环再次执行。n
,pthread_cond_signal
唤醒另一个线程,具体唤醒哪一个,我也不知道。pthread_mutex_lock(&mutex);
while (condition_is_false) {
pthread_mutex_unlock(&mutex);
pthread_cond_wait(&cond);// 解锁和加锁的操作,该函数会帮我们完成
pthread_mutex_lock(&mutex);// 二次申请同一把锁,出现死锁!
}
pthread_mutex_unlock(&mutex);
当一个线程解锁互斥量之后,另一个线程可能立刻获取到互斥量,并满足条件发送信号,然而此时还未执行pthread_cond_wait函数,因此等待的线程可能会错过这个信号,从而导致永远等待下去,这就是典型的竞态条件问题。
了解一下吧,我也不太明白……