线程互斥
线程互斥:
任何时刻,保证只有一个执行流进入临界区访问临界资源,通常对临界资源起到保护作用
相关概念
- 临界资源: 一次仅允许一个进程使用的共享资源
- 临界区: 每个线程内部,访问临界资源的代码,就叫做临界区
- 原子性: 不会被任何调度机制打断的操作,该操作只有两态(无中间态,即使被打断,也不会受影响),要么完成,要么未完成
互斥量mutex
概念:
多个线程对一个共享变量进行操控时,会引发数据不一致的问题。此时就引入了互斥量(也叫互斥锁)的概念,来保证共享数据操作的完整性。在被加锁的任一时刻,临界区的代码只能被一个线程访问。
为了更好的阐述这个概念,这里用一个抢票代码去演示
#include <iostream>
#include <vector>
#include <cstdio>
#include <cstring>
#include <string>
#include <unistd.h>
#include <cassert>
#include <pthread.h>
int ticket=1000;
void* getTicket(void* args)
{
long id=(long) args;
while(1)
{
if(ticket>0)
{
usleep(1000);
--ticket;
printf("thread %ld get a ticket,the number is %d\n",id,ticket);
}
else
{
break;
}
}
}
int main()
{
//创建五个线程
pthread_t t1[5];
for(int i=0;i<5;++i)
{
pthread_create(&t1[i],nullptr,getTicket,(void*)i);
}
//主线程在阻塞等待
for(int i=0;i<5;++i)
{
pthread_join(t1[i],nullptr);
}
return 0;
}
运行结果如下:
我们发现票到负数了还会继续执行
原因如下:
- if 语句判断条件为真以后,代码可以并发的切换到其他线程
- usleep 这个过程中,ticket还没有进行
--
的操作有很多线程会进入if条件 - –-ticket 操作本身就不是一个原子操作ticket有三条汇编指令(如下):
movl ticket(%rip), %eax # 把ticket的值(内存)加载到eax寄存器中
subl $1, %eax # 把eax寄存器中的值减1
movl %eax, ticket(%rip) # 把eax寄存器中的值赋给ticket变量
有可能在你执行到第二条汇编的时候,还没来得及拷贝给内存,就别切换走了,就会导致减到负数,因为别的线程在读取的时候内存中的ticket还是1
如何解决上述问题?
- 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
在临界区内,线程只能串行执行,在临界区外,线程可以并发执行
互斥量的接口
互斥量其实就是一把锁,是一个类型为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调用会陷入阻塞(执行流被挂起),等待互斥量解锁再去竞争锁
加锁:
参数:
mutex:要加的锁
返回值:
成功返回0,失败返回错误码
解锁:
参数:
mutex:要解的锁
返回值:
成功返回0,失败返回错误码
销毁互斥量:
参数:
mutex:要销毁锁
返回值:
成功返回0,失败返回错误码
注意:
- 不要销毁一个已经加锁的互斥量
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁
- 加锁的粒度要够小
用以上的方法再需要的地方进行加锁
#include <iostream>
#include <vector>
#include <cstdio>
#include <cstring>
#include <string>
#include <unistd.h>
#include <cassert>
#include <pthread.h>
using namespace std;
int ticket=1000;
class ThreadData
{
public:
ThreadData(const string& threadname,pthread_mutex_t* mutex)
:thread_name(threadname)
,mutex_p(mutex)
{}
string thread_name;
pthread_mutex_t* mutex_p;
};
//创建并初始化
//全局的锁这样写可以不用初始化和销毁
// pthread_mutex_t mutex=PTHREAD_ MUTEX_ INITIALIZER;
void* getTicket(void* args)
{
ThreadData *td = static_cast<ThreadData *>(args);
// ThreadData* td=(ThreadData*) args;
while(1)
{
//加锁
pthread_mutex_lock(td->mutex_p);
if(ticket>0)
{
usleep(1000);
cout << td->thread_name << " tickets is " << ticket << endl;
--ticket;
//解锁
pthread_mutex_unlock(td->mutex_p);
}
else
{
//解锁
pthread_mutex_unlock(td->mutex_p);
break;
}
//抢完票就完了吗?需要形成订单给用户
//这里如果不休息,会一直是第4个线程在跑,原因是锁只规定互斥访问,没有规定必须让谁优先执行
//锁就是真是的让多个执行流进行竞争的结果
usleep(1000);
}
}
int main()
{
//创建五个线程
pthread_t t1[5];
pthread_mutex_t lock;
pthread_mutex_init(&lock,nullptr);
for(int i=0;i<5;++i)
{
char buffer[64];
snprintf(buffer,sizeof(buffer),"%s""%d","thread ",i+1);
//锁用同一把
ThreadData* td=new ThreadData(buffer,&lock);
pthread_create(&t1[i],nullptr,getTicket,td);
}
for(int i=0;i<5;++i)
{
pthread_join(t1[i],nullptr);
}
pthread_mutex_destroy(&lock);
return 0;
}
运行结果如下:
这里运行会变慢,因为加锁以后是串行执行!
如何看待锁?
锁本身就是一个共享资源,全局变量是要被保护的,锁用来保护全局资源,锁本身也是全局资源,所以加锁的过程必须是安全的!加锁的过程是**原子的,锁如果申请成功,继续向后执行,**如果暂时没有申请成功,执行流会阻塞
如果锁申请成功,进入临界资源,正在访问临界资源,其他线程正在做什么?
阻塞等待
如果锁申请成功,进入临界资源,正在访问临界资源,我可以被切换吗?
可以,当持有线程的锁被切走,其他线程依旧无法申请锁成功,也无法向后执行,直到我释放这个锁
互斥量的原理
大多数体系结构都提供了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;
下图展示了如何实现:
解锁的伪代码步骤(只有有锁的线程才可以执行到这段代码):
- 把mutex的值改为1
- 唤醒等待锁的线程
封装锁
Mutex.hpp
#pragma once
#include <iostream>
#include <pthread.h>
class Mutex
{
public:
Mutex(pthread_mutex_t *lock_p = nullptr): lock_p_(lock_p)
{}
void lock()
{
if(lock_p_) pthread_mutex_lock(lock_p_);
}
void unlock()
{
if(lock_p_) pthread_mutex_unlock(lock_p_);
}
~Mutex()
{}
private:
pthread_mutex_t *lock_p_;
};
class LockGuard
{
public:
LockGuard(pthread_mutex_t *mutex): mutex_(mutex)
{
mutex_.lock(); //在构造函数中进行加锁
}
~LockGuard()
{
mutex_.unlock(); //在析构函数中进行解锁
}
private:
Mutex mutex_;
};
test.cpp
#include <iostream>
#include <vector>
#include <cstdio>
#include <cstring>
#include <string>
#include <unistd.h>
#include <pthread.h>
#include <memory>
#include <cassert>
#include "Mutex.hpp"
// 共享资源, 火车票
int tickets = 10000;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
void *getTicket(void *args)
{
long long username = (long long)args;
while (true)
{
{
//出了作用域会销毁
LockGuard lockguard(&lock); // RAII风格的加锁!
if (tickets > 0)
{
usleep(1254);
std::cout << username << " 正在进行抢票: " << tickets << std::endl;
tickets--;
}
else
{
break;
}
}
usleep(1000);
}
return nullptr;
}
int main()
{
#define NUM 4
pthread_t t1[5];
for(int i=0;i<5;++i)
{
pthread_create(&t1[i],nullptr,getTicket,(void*)i);
}
//主线程保持运行
for(int i=0;i<5;++i)
{
pthread_join(t1[i],nullptr);
}
return 0;
}
线程安全和可重入
概念
线程安全: 多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
重入: 同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
常见的线程安全的情况
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
- 类或者接口对于线程来说都是原子操作
- 多个线程之间的切换不会导致该接口的执行结果存在二义性
常见的线程不安全的情况
- 不保护共享变量的函数
- 函数状态随着被调用,状态发生变化的函数
- 返回指向静态变量指针的函数
- 调用线程不安全函数的函数
常见可重入的情况
- 不使用全局变量或静态变量
- 不使用用malloc或者new开辟出的空间
- 不调用不可重入函数
- 不返回静态或全局数据,所有数据都有函数的调用者提供
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
常见不可重入的情况
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
- 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
- 可重入函数体内使用了静态的数据结构
区别与联系
区别:
- 函数是可重入的,那就是线程安全的
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的
联系:
- 可重入函数是线程安全函数的一种
- 线程安全不一定是可重入的(不一定发生线程安全问题),而可重入函数则一定是线程安全的。
- 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。
死锁
概念
死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。
死锁产生的四个必要条件:
- 互斥条件:一个资源每次只能被一个执行流使用
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
- 不剥夺条件:一个执行流已获得的资源,在未使用完之前,不能强行剥夺
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
所谓的必要条件是都要满足才能形成死锁,只要有一个不满足就不是死锁
避免死锁
- 破坏死锁的四个条件(上面分别对应的是:1.不使用锁 2.让一个执行流放开资源 3. 让一个个执行流剥夺一个执行流的资源 4. 调整申请资源的顺序)
- 假设顺序要一致
- 避免锁未释放的场景
- 资源一次性分配
避免死锁算法:
- 银行家算法:为了防止银行家资金无法周转而倒闭,对每一笔贷款,必须考察其是否能限期归还。在操作系统中研究资源分配策略时也有类似问题,系统中有限的资源要供多个进程使用,必须保证得到的资源的进程能在有限的时间内归还资源,以供其他进程使用资源。
- 死锁检测法