Linux之【多线程】线程互斥(锁)&线程同步(条件变量)
- 一、引入:线程安全问题
- 二、浅谈"++"和"- -"非原子性操作
- 三、Linux线程互斥
- 3.1 互斥量-->mutex⚠️
- 3.1.1 互斥锁的理解
- 3.1.2 深入了解锁的原子性⚠️
- 3.2 线程安全与可重入函数
- 四、死锁
- 五、Linux线程同步
- 5.1 初步认识
- 5.2 条件变量⚠️
- 5.3 结合生活理解条件变量
- 5.4 结合代码简单理解条件变量
文章篇幅较长,请耐心阅读😀😀😀😀😀😀
一、引入:线程安全问题
全局变量可以被多个线程同时访问,多个线程对其进行操作,可能会出现数据不一致问题。
下面以一个购票池为例:
int tickets = 1000;//共享资源
void* get_ticket(void* args)
{
std::string name = static_cast<const char *>(args);
while(true)
{
if(tickets>0)
{
usleep(1111);//引起线程阻塞,挂起,切换其他线程
cout<<name<<"正在抢票 : "<<tickets<<endl;
tickets--;
}
else
{
break;
}
}
return nullptr;
}
int main()
{
std::unique_ptr<Thread> thread1(new Thread(get_ticket,(void*)"User1",1));
std::unique_ptr<Thread> thread2(new Thread(get_ticket,(void*)"User2",2));
std::unique_ptr<Thread> thread3(new Thread(get_ticket,(void*)"User3",3));
std::unique_ptr<Thread> thread4(new Thread(get_ticket,(void*)"User4",4));
thread1->join();
thread2->join();
thread3->join();
thread4->join();
return 0;
}
观察图片可以发现,票数有负数!!!
多个线程交叉运行,即让调度器频繁地发生线程调度与切换 ,线程一般在时间片结束、来了更高级别的线程、线程等待的时候发生切换
线程等待:当从内核态返回用户态的实施,线程要对调度状态监测,如果可以,就发生切换;检测工作是由OS来做的,但是线程共享地址空间,执行OS的代码本来就是在线程上下文执行,3–4G是内核代码,当线程检测,只不过执行OS代码,实际上就是OS在检测
上述代码极端情况在tickets==1时,假设所有线程都进去,然后第一个线程在判断:(1.读取内存数据cpu内的寄存器中2.进行判断),为真进入代码块,这个时候发生线程切换并带走上下文数据,其余线程依次进行判断并执行和第一个线程一样的动作,直到第一个线程被唤醒,执行减1并写回内存,这个时候tickets已经为0,但是其余线程还没有结束,也会执行减减并修改数据,导致出现负数的情况
减减的本质就是1.读取数据2.更改数据3.写回数据
二、浅谈"++“和”- -"非原子性操作
对变量进行++或者–,在C、C++上看起来只有一条语句,但是汇编之后至少是三条语句:
1.从内存读取数据到CPU寄存器中
2.在寄存器中让CPU进行对应的算逻运算
3.写回新的结果到内存中变量的位置
- 现在线程A把数据加载到寄存器中,做减减,成为99,到第三步的时候写回到内存的时候被切走了,顺便把寄存器中的上下文也拿走了:
- 此时调度线程B,一直在减减,当tickets变为10的时候,内存中变量的也变为了10,但是当它想继续减减的时候,线程B被切走了,带着自己的上下文走了
- 现在线程A回来了:恢复寄存器上下文,继续之前的第三步,线程B已经把tickets变为10,但是被线程A改为了99!!!
由此可知我们定义的全局变量在没有保护的时候,往往是不安全的,像上面的例子,多个线程交替执行时造成数据安全问题,发生了数据不一致问题。
而解决这种问题的办法就是加锁!
三、Linux线程互斥
临界资源:多个执行流进行安全访问的共享资源就叫临界资源
临界区:多个执行流进行访问临界资源的代码就是临界区
互斥: 任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。
原子性: 不会被任何调度机制打断的操作,该操作只有两态,要么不做,要么做完,这就是原子性。
现在结合上文,先"简单"理解原子性:一个资源进行的操作如果只用一条汇编语句就能完成,就是原子性的,反之不是原子的。(++ --就不是原子性的),文章后面会再详解
3.1 互斥量–>mutex⚠️
3.1.1 互斥锁的理解
#include <pthread.h>
// 局部锁初始化
int pthread_mutex_init(pthread_mutex_t *restrict mutex,const pthread_mutexattr_t *restrict attr);
// 销毁
int pthread_mutex_destroy(pthread_mutex_t *mutex);
// 全局锁初始化
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
//成功返回0,失败返回错误码
#include <pthread.h>
//加锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
//如果加锁成功,直接持有锁,加锁不成功,此时立马出错返回(试着加锁,非阻塞获取方式)
int pthread_mutex_trylock(pthread_mutex_t *mutex);
//解锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);
// 成功返回0,失败返回错误码
加上局部互斥锁的售票池
class ThreadData
{
public:
ThreadData(const std::string threadname, pthread_mutex_t *mutex_p)
: threadname_(threadname), mutex_p_(mutex_p)
{
}
~ThreadData() {}
public:
std::string threadname_;
pthread_mutex_t *mutex_p_;
};
int tickets = 100; // 共享资源--临界资源
void *get_ticket(void *args)
{
ThreadData *td = static_cast<ThreadData *>(args);
while (true)
{
pthread_mutex_lock(td->mutex_p_);
/* */ if (tickets > 0)
/*临*/ {
/* */ usleep(1111); // 引起线程阻塞,挂起,切换其他线程
/*界*/ cout << td->threadname_ << "正在抢票 : " << tickets << endl;
/* */ tickets--;
/*区*/ pthread_mutex_unlock(td->mutex_p_);
}
else
{
pthread_mutex_unlock(td->mutex_p_);
break;
}
//usleep(1000);//休息一会,让别的线程申请锁
}
return nullptr;
}
int main()
{
#define NUM 4
pthread_mutex_t lock;
pthread_mutex_init(&lock, nullptr);
vector<pthread_t> tids(NUM);
for (int i = 0; i < NUM; ++i)
{
char buffer[1024];
snprintf(buffer, sizeof buffer, "thread %d", i + 1);
ThreadData *td = new ThreadData(buffer, &lock);
pthread_create(&tids[i], nullptr, get_ticket, td);
}
for(const auto& tid:tids)
{
pthread_join(tid,nullptr);
}
return 0;
}
此时的运行结果每次都是能够减到1,且不是负数,但是运行的速度也变慢了。这是因为加锁和解锁的过程是多个线程串行执行的,程序变慢了
同时这里看到每次都是只有一个线程在抢票,这是因为锁只规定互斥访问,并没有规定谁来优先执行,所以谁的竞争力强就谁来持有锁
只需要取消//usleep(1000)
注释即可
全局锁的使用
比局部简单,只需要在全局内初始化,不需要init、destroy就可以直接使用
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
小结:锁的概念⚠️
- 锁本身就是一个共享资源!全局的变量是要被保护的,锁是用来保护全局的资源的,锁本身也是全局资源
- pthread_mutex_lock、pthread_mutex_unlock:加锁和解锁的过程必须是安全的!加锁的过程其实是原子的
- 如果申请锁暂时没有成功,执行流暂时阻塞,直到有人释放锁
- 谁先拿到锁,谁先进入临界区
3.1.2 深入了解锁的原子性⚠️
针对锁的原子性概念
锁是原子性的原理
从汇编谈加锁:为了实现互斥锁操作,大多数体系结构提供了swap和exchange指令,作用是把寄存器和内存单元的数据直接做交换,由于只用一条指令,就可以保证原子性
-
线程A申请锁:把0move到寄存器中,然后交换数据,%al里面变成1,内存里面变成0,之后,被切走,需要携带自己的上下文数据一起跑路!!!
-
线程B前来申请锁资源,把0写进%al里面,也是要交换数据,但是执行判断条件的时候为假,需要挂起等待。
-
这个时候,线程A结束阻塞,恢复上下文数据并接着执行上次未执行完的代码,判断为真,return 0,申请锁成功。
解锁:过程很简单,把寄存器的内容1移动到内存中,直接return,解锁完成
3.2 线程安全与可重入函数
线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数
可重入与线程安全区别
- 可重入函数是线程安全函数的一种
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
- 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。
四、死锁
死锁概念:一组执行流(不管进程还是线程)持有自己锁资源的同时,还想要申请对方的锁,锁是不可抢占的(除非自己主动归还),会导致多个执行流互相等待对方的资源,而导致代码无法推进。这就是死锁
注:一把锁可以造成死锁,先申请一把锁,未释放再申请一把锁
死锁四个必要条件:
1.互斥:一个共享资源每次被一个执行流使用
2.请求与保持:一个执行流因请求资源而阻塞,对已有资源保持不放
3.不剥夺:一个执行流获得的资源在未使用完之前,不能强行剥夺
4.环路等待条件:执行流间形成环路问题,循环等待资源
避免死锁
1.破坏死锁的四个必要条件
2.加锁顺序一致
3.避免锁未释放的场景
4.资源一次性分配
五、Linux线程同步
5.1 初步认识
引入情景:上面的抢票系统我们看到一个线程一直连续抢票,造成了其他线程的饥饿,为了解决这个问题:我们在数据安全的情况下让这些线程按照一定的顺序进行访问,这就是线程同步
饥饿状态:得不到锁资源而无法访问公共资源的线程处于饥饿状态。但是并没有错,但是不合理
竞态条件:因为时序问题,而导致程序异常,我们称为竞态条件。
同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步
条件变量通常需要配合互斥锁一起使用。
5.2 条件变量⚠️
- 当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。
- 例如一个线程访问队列时,发现队列为空,它只能等待,只到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量。
函数接口认识
#include <pthread.h>
//初始化
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict attr);
//全局初始化
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
//销毁
int pthread_cond_destroy(pthread_cond_t *cond);
------------------------------------------------------
//阻塞等待
int pthread_cond_wait(pthread_cond_t *restrict cond,
pthread_mutex_t *restrict mutex);
---------------------------------------------------------------
// 唤醒一批线程
int pthread_cond_broadcast(pthread_cond_t *cond);
// 唤醒一个线程
int pthread_cond_signal(pthread_cond_t *cond);
5.3 结合生活理解条件变量
应聘者要面试,不能同时进入房间进行面试,但是没有由于没有组织,上一个人面试完之后,面试官打开门准备面试下一个,一群人在外面等待面试,但是有人抢不过别人,面试官存在记不住谁面试过了,所以有可能一个人面试完之后又去面试了,造成其他人饥饿问题,这时候效率很低
后来重新进行管理:设立一个等待区,所有人都在这里等待并由面试官安排进入,等待区+面试官就组成了条件变量;如果一个人想面试,先得去排队并在等待区等待,未来所有应聘者都要等
条件变量(struct cond)里面包含状态,队列,而我们定义好的条件变量包含一个队列,不满足条件的线程就链接在这个队列上进行等待
5.4 结合代码简单理解条件变量
每次唤醒一个线程
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <vector>
using namespace std;
int tickets = 1000;
//初始化全局锁
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
//初始化全局变量
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
void* start_routine(void* args)
{
string name = static_cast<const char*>(args);
while(true)
{
pthread_mutex_lock(&mutex);
pthread_cond_wait(&cond,&mutex);//线程阻塞挂起
//判断暂时省略
cout<<name<<" -> "<<tickets<<endl;
tickets--;
pthread_mutex_unlock(&mutex);
}
}
int main()
{
pthread_t t1,t2;
pthread_create(&t1,nullptr,start_routine,(void*)"thread 1");
pthread_create(&t1,nullptr,start_routine,(void*)"thread 2");
while(true)
{
sleep(1);
pthread_cond_signal(&cond);//随机唤醒一个等待的线程
cout<<"main thread wakeup one thread..."<<endl;
}
pthread_join(t1,nullptr);
pthread_join(t2,nullptr);
return 0;
}
一次唤醒全部线程
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <vector>
using namespace std;
int tickets = 1000;
//初始化全局锁
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
//初始化全局变量
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
void* start_routine(void* args)
{
string name = static_cast<const char*>(args);
while(true)
{
pthread_mutex_lock(&mutex);
pthread_cond_wait(&cond,&mutex);//线程阻塞挂起
//判断暂时省略
cout<<name<<" -> "<<tickets<<endl;
tickets--;
pthread_mutex_unlock(&mutex);
}
}
int main()
{
#define NUM 5
vector<pthread_t> tids(NUM);
for(int i=0;i<NUM;++i)
{
char* namebuffer=new char[1024];
snprintf(namebuffer,1024,"thread->%d",i+1);
pthread_create(&tids[i],nullptr,start_routine,namebuffer);
}
while(true)
{
sleep(1);
pthread_cond_broadcast(&cond);//唤醒全部等待的线程
cout<<"main thread wakeup all thread..."<<endl;
}
for(const auto& tid:tids)
{
pthread_join(tid,nullptr);
}
return 0;
}
关于线程同步的暂时讲到这里,后面会结合生产者消费者模型细细讲解一番!!!
附:本文Thread.hpp和Mutex.hpp皆在我的码云