Linux——线程互斥与同步
文章目录
一、多人抢票模拟实现
上篇文章我们简单的对原生线程库就行了封装,这里直接引入代码
#pragma once
#include <iostream>
#include <pthread.h>
#include <functional>
using namespace std;
template <class T>
using func_t = function<void(T)>;
template <class T>
class Thread
{
// 传入该线程对应的Thread
static void *ThreadRoutine(void *args)
{
Thread *ts = static_cast<Thread *>(args);
//cout << "threadnamed: " << ts->_threadname << " " << "tid: " << ts->_tid << endl;
ts->_func(ts->_data);
return nullptr;
}
public:
Thread(string threadname, func_t<T> func, T data)
: _threadname(threadname), _func(func), _isrunning(false), _data(data)
{
}
bool Start()
{
int n = pthread_create(&_tid, nullptr, ThreadRoutine, this);
if (n == 0)
{
_isrunning = true;
return true;
}
else
{
return false;
}
}
bool Join()
{
if (!_isrunning)
{
return true;
}
int n = pthread_join(_tid, nullptr);
if (n == 0)
{
_isrunning = false;
return true;
}
else
{
return false;
}
}
string ThreadName()
{
return _threadname;
}
bool IsRunning()
{
return _isrunning;
}
~Thread()
{
}
private:
pthread_t _tid;
string _threadname;
func_t<T> _func;
T _data;
bool _isrunning;
};
模拟实现一段多线程抢票的代码
int tickets = 1000;
string GetThreadName()
{
static int n = 1;
char buffer[64];
snprintf(buffer, sizeof(buffer), "thread-%d", n++);
return buffer;
}
void GetTicket(string thname)
{
while (true)
{
if(tickets > 0)
{
usleep(1000);
cout << thname << " get one ticket rest: " << tickets << endl;
tickets--;
}
else
{
break;
}
}
}
int main()
{
string s1 = GetThreadName();
Thread<string> t1(s1, GetTicket, s1);
string s2 = GetThreadName();
Thread<string> t2(s2, GetTicket, s2);
string s3 = GetThreadName();
Thread<string> t3(s3, GetTicket, s3);
string s4 = GetThreadName();
Thread<string> t4(s4, GetTicket, s4);
string s5 = GetThreadName();
Thread<string> t5(s5, GetTicket, s5);
t1.Start();
t2.Start();
t3.Start();
t4.Start();
t5.Start();
t1.Join();
t2.Join();
t3.Join();
t4.Join();
t5.Join();
return 0;
}
运行以后得到以上结果
可以发现票数被减到了负数,这意味着1000张票被多卖了,这并不符合寻常逻辑
这时我们就要引入线程互斥的概念
二、线程互斥
2.1 互斥概念
临界资源:多线程执行流共享的资源就叫做临界资源
临界区:每个线程内部,访问临界资源的代码,就叫做临界区
互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
关于原子性需要着重描述,根据原子性的定义,想想C/C++中,定义一个变量n,当执行n++这个操作的时候是否是具有原子性的
int n = 0;
n++;
在执行n++操作的时候,其实本质上是做了以上三步操作,也就是说n++并不具有原子性,在执行三步中的任何一步的途中,只要该线程的时间片到期,就会被切换成其他进程
解释抢票代码出现问题的原因:
很明显 上图中代码的部分在访问临界资源,也就是临界区
数据在内存中,本质是被线程共享的
数据被读取到寄存器中,本质变成了线程的上下文,属于线程私有数据
if语句判断也需要两步,也不具有原子性
试想一下,一个线程在值正常的时候通过if判断进入了函数内,只不过刚进入函数
时间片到了,还没进行操作就被切换成了其他进程,另外一个线程将值减为了0后结束
而第一个线程并不知情,已然进入了函数内,继续做着自减操作,此时值就被减成了负数
这种情况还是单核CPU的情况,实际上在多核CPU中这种事件发展的概念会大大增大
也就说明了,如果共享资源并不具有原子性的,就有可能存在线程安全问题
而如何保证我们访问的资源是互斥的,也就是具有原子性呢?
代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区
如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区
如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区
要做到这三点,本质上就是需要一把锁,Linux上提供的这把锁叫互斥量
2.2 互斥量(锁)mutex
互斥量的接口
初始化互斥量
方法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调用会陷入阻塞(执行流被挂起),等待互斥量解锁
我们对上文中的抢票代码进行加锁
int tickets = 1000;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
string GetThreadName()
{
static int n = 1;
char buffer[64];
snprintf(buffer, sizeof(buffer), "thread-%d", n++);
return buffer;
}
void GetTicket(string thname)
{
while (true)
{
pthread_mutex_lock(&mutex);
if(tickets > 0)
{
usleep(1000);
cout << thname << " get one ticket rest: " << tickets << endl;
tickets--;
pthread_mutex_unlock(&mutex);
}
else
{
pthread_mutex_unlock(&mutex);
break;
}
}
}
int main()
{
string s1 = GetThreadName();
Thread<string> t1(s1, GetTicket, s1);
string s2 = GetThreadName();
Thread<string> t2(s2, GetTicket, s2);
string s3 = GetThreadName();
Thread<string> t3(s3, GetTicket, s3);
string s4 = GetThreadName();
Thread<string> t4(s4, GetTicket, s4);
string s5 = GetThreadName();
Thread<string> t5(s5, GetTicket, s5);
t1.Start();
t2.Start();
t3.Start();
t4.Start();
t5.Start();
t1.Join();
t2.Join();
t3.Join();
t4.Join();
t5.Join();
return 0;
}
可以发现抢票逻辑是正确的,不会出现一票多抢的现象,但抢票速度降低了
这是因为加锁后,访问该临界资源同一时间只能有一个执行流,并且实现锁本身也需要开销
2.3 对锁进行简单封装
借助临时变量和C++类的特性,实现自动加锁解锁的过程
#include <pthread.h>
class Mutex
{
public:
Mutex(pthread_mutex_t *lock)
: _lock(lock)
{
}
void Lock()
{
pthread_mutex_lock(_lock);
}
void Unlock()
{
pthread_mutex_unlock(_lock);
}
~Mutex()
{
}
private:
pthread_mutex_t *_lock;
};
class LockGuard
{
public:
LockGuard(pthread_mutex_t *lock)
: _mutex(lock)
{
_mutex.Lock();
}
~LockGuard()
{
_mutex.Unlock();
}
private:
Mutex _mutex;
};
封装后的运用
int tickets = 1000;
//pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
string GetThreadName()
{
static int n = 1;
char buffer[64];
snprintf(buffer, sizeof(buffer), "thread-%d", n++);
return buffer;
}
void GetTicket(pthread_mutex_t *lock)
{
while (true)
{
//pthread_mutex_lock(&mutex);
LockGuard lg(lock);
if(tickets > 0)
{
usleep(1000);
cout << " get one ticket rest: " << tickets << endl;
tickets--;
//pthread_mutex_unlock(&mutex);
}
else
{
//pthread_mutex_unlock(&mutex);
break;
}
}
}
int main()
{
pthread_mutex_t lock;
string s1 = GetThreadName();
Thread<pthread_mutex_t *> t1(s1, GetTicket, &lock);
string s2 = GetThreadName();
Thread<pthread_mutex_t *> t2(s2, GetTicket, &lock);
string s3 = GetThreadName();
Thread<pthread_mutex_t *> t3(s3, GetTicket, &lock);
string s4 = GetThreadName();
Thread<pthread_mutex_t *> t4(s4, GetTicket, &lock);
string s5 = GetThreadName();
Thread<pthread_mutex_t *> t5(s5, GetTicket, &lock);
t1.Start();
t2.Start();
t3.Start();
t4.Start();
t5.Start();
t1.Join();
t2.Join();
t3.Join();
t4.Join();
t5.Join();
return 0;
}
2.4 锁的实现原理
大家已经意识到单纯的 i++ 或者 ++i 都不是原子的,有可能会有数据一致性问题
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期
这部分涉及到汇编语言和计算机组成原理的知识,有兴趣的兄弟可以去了解下
这里只需要知道swap和exchange语句是原子的就行
exchange eax mem_addr
如上,就是将内存中的值与寄存器中的值进行交换,这种交换是单步操作,具有原子性
加锁的的原则:谁加锁,谁解锁
三、可重入与线程安全
线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题
重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入,一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数
常见的线程不安全的情况
1. 不保护共享变量的函数
2. 函数状态随着被调用,状态发生变化的函数
3. 返回指向静态变量指针的函数
4. 调用线程不安全函数的函数
常见的线程安全的情况
1. 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
2. 类或者接口对于线程来说都是原子操作
3. 多个线程之间的切换不会导致该接口的执行结果存在二义性
常见不可重入的情况
1. 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
2. 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
3. 可重入函数体内使用了静态的数据结构
常见可重入的情况
1. 不使用全局变量或静态变量
2. 不使用用malloc或者new开辟出的空间
3. 不调用不可重入函数
4. 不返回静态或全局数据,所有数据都有函数的调用者提供
5. 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
可重入与线程安全联系
1. 函数是可重入的,那就是线程安全的
2. 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
3. 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的
可重入与线程安全区别
1. 可重入函数是线程安全函数的一种
2. 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
3. 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,则是不可重入的
四、死锁现象
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态
死锁的四个必要条件
互斥条件:一个资源每次只能被一个执行流使用
请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
避免死锁的方法
1. 破坏死锁的四个必要条件
2. 加锁顺序一致
3. 避免锁未释放的场景
4. 资源一次性分配
5. 死锁检测算法,银行家算法
五、线程同步的必要性
如果一个线程一直在进行申请锁,访问资源(无效),释放锁,这三个步骤
也就是一直占用临界资源导致其他线程无法访问,并且自己什么事都不做
这样对资源会造成极大的浪费,所以我们需要同步
在临界资源使用安全的前提下,让多线程执行具有一定的顺序性——同步
由于多线程之间的运行顺序由许多因素决定,例如CPU的调度算法,线程之间竞争锁的能力等等
所以决定多线程之间的运行顺序需要用条件变量和POSIX信号量来实现,下篇文章介绍!
总结:
互斥能保证资源的安全
同步能够较为充分高效的使用资源