文章目录
- 前言
- 1.线程相关问题
- 2.加锁操作
- 1.相关接口
- 2.加锁原理
- 3.线程安全
- 4.线程同步
前言
本文主要围绕Linux下线程互斥问题进行相关讲解,同时也会线程同步相关问题。
1.线程相关问题
我们知道进程地址空间很多资源是被线程共享的。线程在并发访问这些共享资源的时候,如果不加以保护就可能会出现问题。我们看到如下的代码:
#include<iostream>
#include<unistd.h>
#include<string>
#include<pthread.h>
using namespace std;
int tickets=1000;
void *thread_run(void* name)
{
string tname=static_cast<const char*>(name);
while(1)
{
if(tickets>0)
{
//模拟抢票花费时间
usleep(2000);
cout<<tname<<"get a ticket: "<<tickets--<<endl;
}
else
{
break;
}
}
return nullptr;
}
int main()
{
pthread_t t[4];
int n=sizeof(t)/sizeof(t[0]);
for(int i=0;i<n;i++)
{
char *data=new char[64];
snprintf(data,64,"thread -%d",i+1);
pthread_create(t+i,nullptr,thread_run,data);
}
for(int i=0;i<n;i++)
{
pthread_join(t[i],nullptr);
}
return 0;
}
上述代码是模拟抢票逻辑,票数是是全局变量,对于线程来说就是共享资源。在各个线程执行抢票逻辑的时候都是会去访问这个资源,在没有保护的措施的前提下执行上述代码就出现了问题。这就是多线程的并发访问带来的问题。
为啥会出现这种问题呢?因为这个ticket–操作不是原子性的。当cpu调度执行上述代码时,ticket实际上是有3个步骤的。
ticket减减操作不是原子性的,线程每次对ticket操作完后被cpu切走的时候都会有相应的寄存器保留当前执行状态的上下文。直到被cpu重新调度后,接着原来的线程上下文继续执行。这样一来如果有个线程执行的时候没有把寄存器的值重新写会内存中就被切走了,下一个线程看到这个ticket变量的时候还是原来的值,减到最后就可能出现上述代码的问题。
这里我们就看到了如果不加保护的去访问公共资源,各个线程就会产生数据不一致问题,从而造成代码有问题。这个时候我们就需要加锁来访问临界资源。加锁之后我们可以保证线程对ticket访问是互斥且原子的。
2.加锁操作
1.相关接口
加锁操作可以让线程访问临界资源是原子的。让线程串行化执行相关代码,线程库中提供了这种互斥锁变量。pthread_mutex_t mutex,这里的pthread_mutex_t 是线程库中自定义的类型,表示互斥锁。mutex是我随便起的变量名。
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)
这个函数是用来摧毁锁的,当确保锁不再使用后就要及时的摧毁锁。mutex:需要摧毁的互斥量
int pthread_mutex_lock(pthread_mutex_t *mutex);
加锁函数,参数mutex:需要加锁的互斥量,调用 pthread_ lock 时,可能会遇到以下情况:互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功,发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
int pthread_mutex_unlock(pthread_mutex_t *mutex);
解锁函数,参数mutex:需要加锁的互斥量。一般加锁和解锁是成对存在的,加锁之后需要及时解锁。
代码示例
#include<iostream>
#include<unistd.h>
#include<string>
#include<pthread.h>
using namespace std;
int ticktets=1000;
class TData
{
public:
TData(const string &name,pthread_mutex_t* mutex)
:_name(name),_pmutex(mutex){}
~TData(){}
public:
string _name;
pthread_mutex_t *_pmutex;
};
void *runnig(void *arg)
{
TData* td=static_cast<TData*>(arg);
while(1)
{ pthread_mutex_lock(td->_pmutex);
if(ticktets>0)
{ //模拟抢票花费时间
usleep(2000);
cout<<td->_name<<"get a ticket: "<<ticktets--<<endl;
pthread_mutex_unlock(td->_pmutex);
}
else
{
pthread_mutex_unlock(td->_pmutex);
break;
}
}
return nullptr;
}
int main()
{
pthread_mutex_t mutex;
pthread_mutex_init(&mutex,nullptr);
pthread_t t[4];
int n=sizeof(t)/sizeof(t[0]);
for(int i=0;i<n;i++)
{
char name [64];
snprintf(name,64,"thread -%d",i+1);
TData* td=new TData(name,&mutex);
pthread_create(t+i,nullptr,runnig,td);
}
for(int i=0;i<n;i++)
{
pthread_join(t[i],nullptr);
}
pthread_mutex_destroy(&mutex);
return 0;
}
我们加锁之后就不用担心会出现问题了,
锁本身就是属于公共资源,因此加锁和解锁的操作本身就是原子的。这个锁是用来保护临界资源的,锁本身就得先保证自己没问题才能去保护临界资源。
2.加锁原理
对应线程来说,只有加锁和没上锁两种状态,这就保证了原子性。当持有的锁的线程被切走了,只要改线程没有归还锁也就是解锁,其他线程来访问临界资源的时候依旧被拒之门外。这样就保证了线程串行化的访问临界资源。
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的 总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。
上图就是加锁解锁的伪代码,其实锁可以看作变量1,当某个线程申请到锁之后,在没有规划锁的情况下就被切走了,这个时候其他线程来申请锁的时候,由于mutex这个1被拿走了,这个时候mutex存放只是0.其他线程以为申请到了1但是拿到的只是0而已。这样底层汇编在判断的时候对于没有拿到1的线程就会将其挂起。这个1就相当于是一把钥匙,这也就是加锁解锁的原理。
3.线程安全
线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
这个函数是否可重入只是函数的特性而已,不能作为判断函数好坏的依据。
常见不可重入的情况
调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
可重入函数体内使用了静态的数据结构
常见可重入的情况
不使用全局变量或静态变量
不使用用malloc或者new开辟出的空间
不调用不可重入函数
不返回静态或全局数据,所有数据都有函数的调用者提供
使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
常见的线程安全的情况
每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的,或者接口对于线程来说都是原子操作多个线程之间的切换不会导致该接口的执行结果存在二义性。
可重入与线程安全联系
函数是可重入的,那就是线程安全的函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
可重入与线程安全区别
可重入函数是线程安全函数的一种.线程安全不一定是可重入的,而可重入函数则一定是线程安全的。如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。
死锁现象
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。
锁死就像是小红和小明各有5毛钱,他们都想要彼此的5毛钱去买辣条,从而陷入等待状态。造成锁死的必要的条件有4个:互斥条件
:一个资源每次只能被一个执行流使用;请求与保持条件
:一个执行流因请求资源而阻塞时,对已获得的资源保持不放;不剥夺条件:
一个执行流已获得的资源,在末使用完之前,不能强行剥夺;循环等待条件:
若干执行流之间形成一种头尾相接的循环等待资源的关系.
消除死锁的方法
避免死锁:破坏死锁的四个必要条件,加锁顺序一致,避免锁未释放的场景,资源一次性分配。
4.线程同步
之前抢票代码为例,假如票数为0后,过段时间后重新放票,线程不断加锁解锁去访问临界资源,当票数为0时,众多线程还是这样去做无用功,线程只能等待放票,从而造成饥饿现象。这样无疑是很影响效率的。由此就提出的线程同步的概念,当访问资源的条件不满足的时候,可以让相关线程进行休眠,等道条件满足时在将线程唤醒,去重新竞争申请资源。这样就可以提到效率。
线程同步的主要通过条件变量来实现的,在保证线程安全的前提下,线程有序的访问资源,提高执行效率。
条件变量
概念: 用来描述某种临界资源是否就绪的一种数据化描述,条件变量通常需要配合mutex互斥锁一起使用。它的动作主要有两个:一个线程等待条件变量的条件成立而被挂起,另一个线程使条件成立后唤醒等待的线程。
相关接口
pthread_cond_t cond;
定义一个条件变量cond,它的类型是pthread_cond_t.
int pthread_cond_init(pthread_cond_t *restrict cond,
const pthread_condattr_t *restrict attr);
初始化条件变量的函数,参数说明cond:需要初始化的条件变量attr:初始化条件变量的属性 一般设置为NULL即可。
另外定义全局或者静态条件变量可以宏PTHREAD_COND_INITIALIZER初始化,这样初始化不用调用相关函数销毁不用的条件变量。
int pthread_cond_destroy(pthread_cond_t *cond);
销毁条件变量的函数,参数说明:cond:需要销毁的条件变量
int pthread_cond_wait(pthread_cond_t *restrict cond,
pthread_mutex_t *restrict mutex);
等待条件变量函数,参数说明:cond:需要等待的条件变量,mutex:当前线程所处临界区对应的互斥锁。
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
唤醒等待的函数,pthread_cond_signal函数用于唤醒等待队列中首个线程,pthread_cond_broadcast函数用于唤醒等待队列中的全部线程。参数说明:cond:唤醒在cond条件变量下等待的线程。
代码示例
#include<iostream>
#include<pthread.h>
#include<string>
#include<unistd.h>
using namespace std;
const int num=5;
pthread_cond_t cond=PTHREAD_COND_INITIALIZER;
pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER;
void *active(void*arg)
{
string name=static_cast<const char*>(arg);
while(1)
{
pthread_mutex_lock(&mutex);
pthread_cond_wait(&cond,&mutex);
cout<<name<<"启动"<<endl;
pthread_mutex_unlock(&mutex);
}
}
int main()
{
pthread_t tids[num];
for(int i=0;i<num;i++)
{
char *name=new char[32];
snprintf(name,32,"thread -%d",i);
pthread_create(tids+i,nullptr,active,name);
}
sleep(3);
while(1)
{
cout<<"main thread wakeup thread..."<<endl;
pthread_cond_signal(&cond);//每次唤醒一个线程
//pthread_cond_broadcast(&cond);//唤醒全部线程
sleep(1);
}
for(int i=0;i<num;i++)
{
pthread_join(tids[i],nullptr);
}
return 0;
}
每次唤醒一个线程
唤醒全部线程
为什么pthread_cond_wait需要互斥锁
简单来说,条件等待是线程间同步的一种手段,使用条件变量的场景肯定是不止一个线程,必须会有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程将其唤醒,条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化,所以一定要用互斥锁来保护,没有互斥锁就无法安全的获取和修改共享数据,当线程进入等待的时候这个锁也必须要释放,不然其他满足条件的线程无法进入临界区访问临界资源。
等待的时候往往是在临界区内等待的,当该线程进入等待的时候,互斥锁会自动释放,而当该线程被唤醒时,又会自动获得对应的互斥锁,条件变量需要配合互斥锁使用,其中条件变量是用来完成同步的,而互斥锁是用来完成互斥的。
pthread_cond_wait函数有两个功能,一是让线程在特定的条件变量下进行等待,二是让线程释放掉自己申请到的互斥锁。当该线程被唤醒后,该线程会立马获得之前释放的互斥锁,然后继续向下执行。