文章目录
- Linux线程互斥
- 一、进程线程间的互斥相关概念
- 1.临界资源和临界区
- 2.互斥和原子性
- 二、互斥量mutex
- 1.抢票程序是否引入互斥量现象观察
- 2.抢票程序原理分析
- 3.互斥量的接口
- 4. 加锁后的程序
- 5.互斥量原理探究
- 可重入VS线程安全
- 一、概念
- 1.线程安全
- 2.重入
- 二、常见的线程不安全的情况
- 三、常见的线程安全的情况
- 四、常见不可重入的情况
- 五、常见可重入的情况
- 六、可重入与线程安全联系
- 七、可重入与线程安全区别
- 常见锁概念
- 一、死锁
- 二、死锁四个必要条件
- 三、避免死锁
- Linux线程同步
- 一、同步概念与竞态条件
- 二、条件变量
- 1. 条件变量函数初始化
- 2.条件变量函数销毁
- 3.条件变量函数等待条件满足
- 4.唤醒等待
- 5.程序示例
- 三、为什么 pthread_cond_wait 需要互斥量?
Linux线程互斥
一、进程线程间的互斥相关概念
临界资源
:多线程执行流共享的资源就叫做临界资源
临界区
:每个线程内部,访问临界资源的代码,就叫做临界区
互斥
:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
原子性
(后面讨论如何实现):不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
1.临界资源和临界区
在前边学习进程的时候,我们实现进程间通信可以使用管道,共享内存等方式,此时就是多执行流在共享资源,所以此时的管道或共享内存就是临界资源,而访问临界资源的代码就被称为临界区。
例如在我们的实际生活中,当我们要看演唱会时,需要进行抢票,如果多个人可以同时买票,并且不进行限制,很有可能有多个人抢到同一张票,所以在以后演出开始之后,很多人只能抢同一个座位,这是不能被允许的,所以就必须加以限制。
2.互斥和原子性
互斥就像是一把锁,当一个人在抢这张票时,其他人就被锁上了,不能再去抢这张票,回到我们线程中,当多个线程进行竞争时,当一个线程拿到这把锁,其他线程就不能再去访问该临界资源了。
原子性就是站在其他线程的角度来看,要么某一线程还没有开始访问临界资源,要么已经访问完成,这是两种对其他线程有利的情况,而并不关心是否在进行访问临界资源。
二、互斥量mutex
通过前边的学习,我们知道了线程之间的通信十分简单,除了线程的栈空间和上下文数据是私有的,其他的数据和内容都是共享的,所以我们定义全局变量就可以直接进行通信,但是在可以进行通信之后,也会带来很多问题,例如可能多个线程在同一时刻发送数据或接收数据,多个线程并发操作,可能会导致数据异常,所以为了解决这个问题,我们就引入互斥量的概念。
1.抢票程序是否引入互斥量现象观察
我们通过演唱会抢票的例子来学习互斥量:
首先来看看不引入互斥量的情况:
#include <iostream>
#include <cstdio>
#include <string>
#include <ctime>
#include <mutex>
#include <cstdlib>
#include <unistd.h>
#include <pthread.h>
using namespace std;
class tickets
{
private:
int ticket;
pthread_mutex_t mx;
public:
tickets()
: ticket(1000)
{
//初始化互斥锁
pthread_mutex_init(&mx, nullptr);
}
~tickets()
{
//销毁互斥锁
pthread_mutex_destroy(&mx);
}
bool buy_tickets()
{
bool ret = true;
if (ticket > 0)
{
usleep(1000);
cout << "我是[" << pthread_self() << "]线程,我正在抢第" << ticket << "张票" << endl;
ticket--;
printf("");
}
else
{
cout << "票已经卖完了" << endl;
ret = false;
}
return ret;
}
};
void *pthread_run(void *args)
{
tickets *t = (tickets *)args;
while (true)
{
if (!t->buy_tickets())
break;
}
}
int main()
{
tickets *t = new tickets();
pthread_t tid[5];
for (int i = 0; i < 5; i++)
{
pthread_create(tid + i, nullptr, pthread_run, (void *)t);
}
for (int i = 0; i < 5; i++)
{
pthread_join(tid[i], nullptr);
}
return 0;
}
在不引入互斥量时,我们会发现惊奇的现象,就是可能会有多个线程抢同一张票,导致最后会出现负数票的情况,这是万万不能接受的。
接下来,我们再来看一下引入互斥量的现象:
#include <iostream>
#include <cstdio>
#include <string>
#include <ctime>
#include <mutex>
#include <cstdlib>
#include <unistd.h>
#include <pthread.h>
using namespace std;
class tickets
{
private:
int ticket;
pthread_mutex_t mx;
public:
tickets()
: ticket(1000)
{
//初始化互斥锁
pthread_mutex_init(&mx, nullptr);
}
~tickets()
{
//销毁互斥锁
pthread_mutex_destroy(&mx);
}
bool buy_tickets()
{
bool ret = true;
//加锁
pthread_mutex_lock(&mx);
if (ticket > 0)
{
usleep(1000);
cout << "我是[" << pthread_self() << "]线程,我正在抢第" << ticket << "张票" << endl;
ticket--;
printf("");
}
else
{
cout << "票已经卖完了" << endl;
ret = false;
}
//解锁
pthread_mutex_unlock(&mx);
return ret;
}
};
void *pthread_run(void *args)
{
tickets *t = (tickets *)args;
while (true)
{
if (!t->buy_tickets())
break;
}
}
int main()
{
tickets *t = new tickets();
//tickets* t;
pthread_t tid[5];
for (int i = 0; i < 5; i++)
{
pthread_create(tid + i, nullptr, pthread_run, (void *)t);
}
for (int i = 0; i < 5; i++)
{
pthread_join(tid[i], nullptr);
}
return 0;
}
在引入互斥量之后,就不会出现有多个线程同时抢一张票而导致最后出现负数的情况。
2.抢票程序原理分析
为什么会出现负票数呢?
1.if 语句判断条件为真以后,代码可以并发的切换到其他线程
2.usleep 这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段
3.–ticket 操作本身就不是一个原子操作
有很多人可能会说,–ticket不就是一条语句吗?为什么不是原子性的呢?
这是因为–ticket在C语言中虽然是一条语句,但是转换成汇编就不是一条语句了,可能是多条语句,而在多条语句执行时,就有可能会有其他线程访问该代码段,我通过下图来详细解释:
先来看–ticket转换为汇编的代码:
此时,我们必须知道,虽然寄存器是被每一个线程共享的,但是寄存器中的数据确实私有的,当某一个线程时间片到了之后被切走,会将寄存器中的数据也带走保存在自己的上下文当中,所以此时在进行–ticket操作时就有可能出现下边的情况:
步骤一:
步骤二:
步骤三:
经过上边的三步,我们就会发现,–ticket并不是原子性的,可能在执行某一行代码时,可能该线程被切走,所以导致数据异常,出现负数票的情况。
要解决以上问题,需要做到三点:
代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量。
3.互斥量的接口
初始化互斥量
初始化互斥量有两种方法:
方法1,
静态分配
:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
方法2,
动态分配
:
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict
attr);
参数
:
mutex:要初始化的互斥量
attr:初始化互斥量的属性,默认写成NULL
销毁互斥量
销毁互斥量需要注意:
使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
不要销毁一个已经加锁的互斥量
已经销毁的互斥量,要确保后面不会有线程再尝试加锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);
互斥量加锁和解锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回0,失败返回错误号
调用 pthread_ lock 时,可能会遇到以下情况:
互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
4. 加锁后的程序
#include <iostream>
#include <cstdio>
#include <string>
#include <ctime>
#include <mutex>
#include <cstdlib>
#include <unistd.h>
#include <pthread.h>
using namespace std;
class tickets
{
private:
int ticket;
pthread_mutex_t mx;
public:
tickets()
: ticket(1000)
{
//初始化互斥锁
pthread_mutex_init(&mx, nullptr);
}
~tickets()
{
//销毁互斥锁
pthread_mutex_destroy(&mx);
}
bool buy_tickets()
{
bool ret = true;
//加锁
pthread_mutex_lock(&mx);
if (ticket > 0)
{
usleep(1000);
cout << "我是[" << pthread_self() << "]线程,我正在抢第" << ticket << "张票" << endl;
ticket--;
printf("");
}
else
{
cout << "票已经卖完了" << endl;
ret = false;
}
//解锁
pthread_mutex_unlock(&mx);
return ret;
}
};
void *pthread_run(void *args)
{
tickets *t = (tickets *)args;
while (true)
{
if (!t->buy_tickets())
break;
}
}
int main()
{
tickets *t = new tickets();
pthread_t tid[5];
for (int i = 0; i < 5; i++)
{
pthread_create(tid + i, nullptr, pthread_run, (void *)t);
}
for (int i = 0; i < 5; i++)
{
pthread_join(tid[i], nullptr);
}
return 0;
}
5.互斥量原理探究
经过上边的证明,我们知道了ticket–或者ticket++都不是原子性的,那么为什么加入一个锁可以保证原子性呢?
这是因为在一批线程来竞争某一资源,当其中一个线程先申请到锁时,此时其他线程再来申请锁,锁告诉线程,不好意思,锁已经属于别人了,此时无论哪个线程再来申请,都不可能申请成功,只有当拥有锁的线程将锁释放,也就是归还之后,其他的线程才可能申请成功,这样就保证了互斥性和原子性。
但是我们又忽视了一个情况,需要访问临界资源,必须先来申请锁,所以锁是肯定要被所有线程看到了,那么问题来了,锁不就是一个临界资源吗?而且处理成汇编也不只是一个语句,那么锁是原子性的吗?
来分析一下汇编代码:
第一条指令:
将寄存器al中的值清0
第二条指令:
交换内存中mutex的值和寄存器中的值
第三步:
判断锁的值,若大于0,则可以访问临界资源,若小于0,则挂起等待。
所以mutex的初始值为1,当一个线程来申请锁时,这个线程的al寄存器会将0换给mutex,而al的值变为1,当第二个线程来申请锁,先将al寄存器的值清零,然后与mutex交换,得到的值还是0
,所以就会挂起等待。哪怕拿到锁的线程暂时被挂起了,也会将al寄存器的值保存在自己的上下文当中,也就是将锁带走了,其他线程也不能申请到锁。
可重入VS线程安全
一、概念
1.线程安全
多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
2.重入
同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
二、常见的线程不安全的情况
不保护共享变量的函数
函数状态随着被调用,状态发生变化的函数
返回指向静态变量指针的函数
调用线程不安全函数的函数
三、常见的线程安全的情况
每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。
类或者接口对于线程来说都是原子操作。
多个线程之间的切换不会导致该接口的执行结果存在二义性。
四、常见不可重入的情况
调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
可重入函数体内使用了静态的数据结构
五、常见可重入的情况
不使用全局变量或静态变量
不使用用malloc或者new开辟出的空间
不调用不可重入函数
不返回静态或全局数据,所有数据都有函数的调用者提供
使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
六、可重入与线程安全联系
函数是可重入的,那就是线程安全的
函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
七、可重入与线程安全区别
可重入函数是线程安全函数的一种
线程安全不一定是可重入的,而可重入函数则一定是线程安全的
。
如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。
常见锁概念
一、死锁
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。
并且,单执行流也可以造成死锁,例如下边的程序:
#include<iostream>
#include<pthread.h>
using namespace std;
pthread_mutex_t mtx;
void* thread_run(void* args)
{
pthread_mutex_lock(&mtx);
Spthread_nutex_lock(&mtx);
pthread_exit((void*)0);
// char* name = (char*)args;
// cour<<i am a <<name<<endl;
}
int main()
{
pthread_t tid;
pthread_create(&tid,nullptr,thread_run,(void*)"new thread");
pthread_mutex_init(&mtx,nullptr);
pthread_join(tid,nullptr);
pthread_mutex_destroy(&mtx);
return 0;
}
二、死锁四个必要条件
互斥条件:一个资源每次只能被一个执行流使用
请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
也就是说如果出现了死锁,就一直达到了以上的条件。
三、避免死锁
破坏死锁的四个必要条件
加锁顺序一致
避免锁未释放的场景
资源一次性分配
Linux线程同步
一、同步概念与竞态条件
同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步。
竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。在线程场景下,这种问题也不难理解。
我们前边提到了,线程可以访问同一份资源,可能会出现数据异常的问题,所以我们引入了互斥的概念,通过加锁来使同一时间只有一个线程访问临界资源,当加锁之后,可能又会因为某一线程的竞争力过强,一直处于加锁和销毁锁的状态,导致其他线程不能去访问临界资源而导致饥饿问题,所以我们就要引入线程同步的概念,当一个线程在销毁锁之后,不能立马去申请锁,而是要到队列的末尾排序,进入条件变量的等待队列,不会造成其他的线程长时间不能访问临界资源的情况。
二、条件变量
条件变量是一种同步机制,用于在多个线程之间进行通信。它允许一个线程等待另一个线程满足特定的条件,然后再继续执行。条件变量通常与互斥锁一起使用,以确保线程安全。条件变量提供了一种高效的方式来实现线程之间的同步和通信。
当临界资源加锁之后,我们不容易知道临界资源的状态,这时我们可以引入临界变量来获得临界资源的状态,下边我来举一个生活中的例子:
例如,我们要去书店买一本《剑指offer》,但是去了书店之后,售货员说书卖完了,但是过两天可能会到货,现在我们回家之后,只能等待,那么我们怎么知道书什么时候才有呢?这是有两种方式,第一种方式就是我们没事就去书店一趟,问一下售货员书来了吗,或者是留下售货员的电话,当书来了之后,让售货员给我们打电话,这种打电话的方式我们就可以简单的认为就是使用条件变量的方式来获得临界资源的状态。
1. 条件变量函数初始化
与互斥锁的初始化类似:
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict
attr);
参数:
cond:要初始化的条件变量
attr:条件变量的相关属性,默认给NULL
2.条件变量函数销毁
int pthread_cond_destroy(pthread_cond_t *cond)
3.条件变量函数等待条件满足
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
参数:
cond:要在这个条件变量上等待
mutex:互斥量,后面详细解释
4.唤醒等待
第一个是唤醒等待一批线程
int pthread_cond_broadcast(pthread_cond_t *cond)
第二个是唤醒等待一个线程
int pthread_cond_signal(pthread_cond_t *cond).
5.程序示例
我们现在实现一个老板控制工人工作的例子,来观察一个线程可以通过条件变量来控制其他的线程。
#include<iostream>
#include<pthread.h>
#include<unistd.h>
using namespace std;
pthread_mutex_t mx;
pthread_cond_t cond;
void* worker_routine(void* args)
{
while(true)
{
int num = *(int*)args;
pthread_cond_wait(&cond,&mx);
cout<<"worker"<<num<<"is working..."<<endl;
}
}
void* boss_routine(void* args)
{
while(true)
{
cout<<"boss say:";
pthread_cond_signal(&cond);
sleep(1);
}
}
int main()
{
pthread_t boss;
pthread_t tid[3];
pthread_mutex_init(&mx,nullptr);
pthread_cond_init(&cond,nullptr);
for(int i=0;i<3;i++)
{
int* num = new int(i);
pthread_create(tid+i,nullptr,worker_routine,(void*)num);
}
pthread_create(&boss,nullptr,boss_routine,(void*)"boss");
for(int i=0;i<3;i++)
{
pthread_join(tid[i],nullptr);
}
pthread_join(boss,nullptr);
return 0;
}
三、为什么 pthread_cond_wait 需要互斥量?
条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程。
条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化。所以一定要用互斥锁来保护。没有互斥锁就无法安全的获取和修改共享数据。