目录
1.问题
2.互斥相关概念
3.互斥量
4.互斥量接口
5.修改买票代码
6.互斥量原理
7.锁的封装
8.可重入和线程安全
1. 问题
用一个模拟抢票过程的程序引出本节话题,如果有1000张票,设为全局变量,生成3个线程每隔一秒抢一张票,如果票数大于0就继续,小于0就退出,看看结果:
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
#include <cstring>
#include <thread>
#include <vector>
#include <errno.h>
#include <cstring>using namespace std;
int ticket = 1000; //票数量
void *buyticket(void *num)
{int n = (long)num;while (true){if (ticket > 0){usleep(1000);ticket--;printf("%d 线程买到票,剩余 %d 张\n", n, ticket);}else{break;}}
}int main()
{vector<pthread_t> v;for (int i = 0; i < 3; i++){pthread_t tid;pthread_create(&tid, nullptr, buyticket, (void*)(long)i);v.push_back(tid);}for(auto ch : v){pthread_join(ch, nullptr);}return 0;
}
票没有了,剩余负数张,这是有问题的
原因
因为ticket是全局变量,属于共享资源,每个线程都可以访问。当if判断和票数减减时有可能一个线程刚判断票数还有,准备往下执行或–时,其他的线程也来判断,这时,当票数只剩1张时,三个线程都会判断为有票,执行到下面–3次,票数就成了负数
这就是多线程访问共享数据引起的数据不一致问题,线程会在内核返回用户的时候检查切换
–是否安全
对一个全局变量进行多线程并发的–或者++是不是安全的?
cpu在对一个数据减减时需要三步操作,所以是不安全的
1.先将ticket读入到寄存器中
2.cpu内部计算
3.将计算结果写回内存
上面每一步都对应一条汇编操作,只有一条汇编指令才是原子的,上面的三步操作中都有可能切换线程。线程1先读入数据。if判断属于逻辑运算,需要读入数据,进行判断,跳转执行,也不是原子的。如果票数只剩1张,几条线程都判断大于1进入内部,当1条线程–后将数据改为0写回内存,其他线程进来执行,别忘了,–操作需要重新从内存中读入数据,所以会在0的基础上继续–,就变成了-1
怎么解决
对共享收据的任何访问,保证在任何时候只有一个执行流访问,就是互斥
2. 互斥相关概念
临界资源:多线程执行流共享的资源叫临界资源
临界区:每个线程内部,访问临界资源的代码,叫临界区
互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
3. 互斥量
大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量
但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互
多个线程并发的操作共享变量,会带来一些问题
上面的抢票由下面三个地方可能被切换:
1.if语句判断为真以后,代码可以并发的切换到其他线程
2.usleep这个模拟漫长业务的过程,可能会有多个线程进入该代码片段
3.–ticket操作本身不是原子的
下面是–的部分汇编代码
取出ticket–部分的汇编代码
objdump -d a.out > test.objdump
152 40064b: 8b 05 e3 04 20 00 mov 0x2004e3(%rip),%eax # 600b34 <ticket>
153 400651: 83 e8 01 sub $0x1,%eax
154 400654: 89 05 da 04 20 00 mov %eax,0x2004da(%rip) # 600b34 <ticket>
操作不是原子的,对应了三条汇编指令:
load:将共享变量ticket从内存加载到寄存器中
update:更新寄存器里面的值,执行-1操作
store:将新值,从寄存器写回共享变量ticket的内存地址
要解决上面的问题,需要做到三点:
1.代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入临界区
2.如果多个线程同时要执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区
3.如果线程不在临界区执行,那么该线程不能阻止其他线程进入临界区
要做到这三点,需要一把锁,linux上提供的这把锁叫互斥量
4. 互斥量接口
初始化互斥量
初始化互斥量由两种方法:
方法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_INTIALIZER初始化的互斥量不需要销毁
不要销毁一个已经加锁的互斥量
已经销毁的互斥量,要确保后面不会有线程再尝试加锁
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调用会陷入阻塞(执行流被挂起),等待互斥量解锁
加锁总结
加锁的本质:用时间换取安全
加锁的表现:线程对于临界区代码串行执行
加锁原则:尽量的保证临界区代码越少越好,串行执行的代码就越少,其他线程等的时间就越少
5. 修改买票代码
局部变量锁
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
#include <cstring>
#include <thread>
#include <vector>
#include <errno.h>
#include <cstring>using namespace std;
int ticket = 1000; //票数量struct threadData
{threadData(int num, pthread_mutex_t *_lock){i = num;lock = _lock;}int i;pthread_mutex_t *lock;
};
void *buyticket(void *num)
{threadData* td = static_cast<threadData*>(num);while (true){//加锁pthread_mutex_lock(td->lock);if (ticket > 0){usleep(1000);ticket--;printf("%d 线程买到票,剩余 %d 张\n", td->i, ticket);//解锁pthread_mutex_unlock(td->lock);}else{pthread_mutex_unlock(td->lock);break;}usleep(13); //买完票不会去买下一张,延时模拟过程}
}int main()
{vector<pthread_t> v;vector<threadData*> td;// 初始化互斥量pthread_mutex_t lock;pthread_mutex_init(&lock, nullptr);for (int i = 0; i < 3; i++){td.push_back(new threadData(i, &lock));pthread_t tid;pthread_create(&tid, nullptr, buyticket, td[i]);v.push_back(tid);}for(auto ch : v){pthread_join(ch, nullptr);}return 0;
}
这次运行正常了,买票之后加上usleep,省略买成功后的动作,如果没有,会造成加锁的线程刚买完又立马继续加锁,导致其他线程难以得到锁的使用权,无法进入判断。线程对锁的竞争能力是不同的
纯互斥环境,如果锁分配不合理,容易导致其他线程的饥饿问题。不是说只要有互斥,必有饥饿。适合纯互斥的场景,就用互斥
让所有的线程获取锁,按照一定顺序获取资源,就是同步
线程申请锁成功,才能向后执行,不成功则阻塞等待。在临界区,线程可以切换,但线程切换出去是持有锁被切走,不在期间,照样没有人能进入临界区访问资源。对于其他线程来讲,一个线程要么没有锁,要么释放锁,当前线程访问临界区的过程,对于其他线程都是原子的。不释放锁,其他线程一直申请不到锁,就阻塞着
全局变量锁
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
#include <cstring>
#include <thread>
#include <vector>
#include <errno.h>
#include <cstring>using namespace std;
int ticket = 1000; //票数量
//全局变量锁,自动解锁
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;struct threadData
{threadData(int num/*, pthread_mutex_t *_lock*/){i = num;//lock = _lock;}int i;//pthread_mutex_t *lock;
};
void *buyticket(void *num)
{threadData* td = static_cast<threadData*>(num);while (true){//加锁pthread_mutex_lock(&lock);if (ticket > 0){usleep(1000);ticket--;printf("%d 线程买到票,剩余 %d 张\n", td->i, ticket);//解锁pthread_mutex_unlock(&lock);}else{pthread_mutex_unlock(&lock);break;}usleep(13); //买完票不会去买下一张,延时模拟过程}
}int main()
{vector<pthread_t> v;vector<threadData*> td;// 初始化互斥量pthread_mutex_t lock;//pthread_mutex_init(&lock, nullptr);for (int i = 0; i < 3; i++){td.push_back(new threadData(i/*, &lock*/));pthread_t tid;pthread_create(&tid, nullptr, buyticket, td[i]);v.push_back(tid);}for(auto ch : v){pthread_join(ch, nullptr);}for(auto ch : td){delete ch;}//pthread_mutex_destroy(&lock);return 0;
}
全局变量不需要初始化和销毁
6. 互斥量原理
为了实现互斥锁,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的,总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期,现在把lock和unlock的伪代码改一下
将互斥量简单看做上述样子。初始化互斥量就会在内存里申请一个整数1,每一个线程上锁的过程就是上面的代码,al是eax寄存器的低16位,将这个寄存器值设为0,线程进来后寄存器和互斥量1进行交换,寄存器中变为1,把共享的锁以汇编的方式交换到了自己的上下文。内存中变为0,判断寄存器里值大于0就返回上锁成功。这时其他线程进来执行时,寄存器和内存中都为0,判断后就不满足条件而挂起等待。所谓的“锁”只有一个,只能被一个线程拥有,保证了加锁过程的原子性
解锁的过程将内存中互斥值改为1,然后唤醒等待的线程就可以像上面一样继续有一个线程得到锁。这里为什么不也用交换的方式,而是直接赋值?这样可以让一个线程上锁,另一个线程可以解锁,对解锁的一方没有要求。如果上锁的线程卡死了,不解锁的话其他线程也无法执行,所以可以解决这种情况
7. 锁的封装
一个锁类,有加锁和去锁的功能,再用一个类封装这个锁类成员,构造自动上锁,析构去锁。修改买票功能,线程判断票数的时候上锁,–完后去锁,避免延迟模式的功能到临界区,可以加一个域括号
锁类
#pragma once
#include <pthread.h>class Mutex
{
public:Mutex(pthread_mutex_t *_lock){m_lock = _lock;}void Lock(){pthread_mutex_lock(m_lock);}void unLock(){pthread_mutex_unlock(m_lock);}private:pthread_mutex_t *m_lock;
};class LockGuard
{
public:LockGuard(pthread_mutex_t *_lock):m(_lock){m.Lock();}~LockGuard(){m.unLock();}private:Mutex m;
};
买票
#include <stdio.h>
#include <unistd.h>
#include <cstring>
#include <thread>
#include <vector>
#include <errno.h>
#include <cstring>
#include "LockGuard.hpp"using namespace std;
int ticket = 1000; //票数量
//全局变量锁,自动解锁
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;struct threadData
{threadData(int num/*, pthread_mutex_t *_lock*/){i = num;//lock = _lock;}int i;//pthread_mutex_t *lock;
};
void *buyticket(void *num)
{threadData* td = static_cast<threadData*>(num);while (true){//加锁//pthread_mutex_lock(&lock);{LockGuard mutex(&lock);if (ticket > 0){usleep(1000);ticket--;printf("%d 线程买到票,剩余 %d 张\n", td->i, ticket);// 解锁// pthread_mutex_unlock(&lock);}else{// pthread_mutex_unlock(&lock);break;}}usleep(13); //买完票不会去买下一张,延时模拟过程}
}int main()
{vector<pthread_t> v;vector<threadData*> td;// 初始化互斥量//pthread_mutex_t lock;//pthread_mutex_init(&lock, nullptr);for (int i = 0; i < 3; i++){td.push_back(new threadData(i/*, &lock*/));pthread_t tid;pthread_create(&tid, nullptr, buyticket, td[i]);v.push_back(tid);}for(auto ch : v){pthread_join(ch, nullptr);}for(auto ch : td){delete ch;}//pthread_mutex_destroy(&lock);return 0;
}
8. 可重入和线程安全
概念
线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况会出现该问题
重入:同一个函数被不同的执行流调用,当前一个线程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况相爱,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数
常见的线程不安全的情况
- 不保护共享变量的函数
- 函数状态随着被调用,状态发生变化的函数
- 返回指向静态变量指针的函数
- 调用线程不安全函数的函数
常见的线程安全情况
每个线程对全局变量或者静态变量只有读取的权限,没有写入的权限,一般来说这些线程是安全的
类或者接口对于线程来说都是原子操作
多个线程之间的切换不会导致该接口的执行结果存在二义性
常见的不可重入
调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
调用了标准I/O库函数,很多实现都以不可重入的方式使用全局数据结构
可重入函数体内使用了静态的数据结构
常见可重入
不适用全局变量或静态变量
不适用malloc或者new开辟空间
不调用不可重入函数
不返回静态或全局数据,所有数据都由函数的调用者提供
使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
可重入和线程安全联系
函数是可重入的,就是线程安全的
函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的
可重入和线程安全的区别
可重入函数是线程安全函数的一种
线程安全不一定是可重入的,而可重入函数一定是线程安全的
如果将对临界资源的访问加上锁,这个函数是线程安全的,但如果这个重入函数锁还未释放则会产生死锁,是不可重入的
线程安全描述的是并发的情况,重入描述的是函数的特性。线程安全不一定是可重入的,可重入一定是线程安全的