目录
Linux线程互斥
1. 进程线程间的互斥相关背景概念
2. 互斥量mutex
3. 互斥量的接口
3.1 初始化互斥量
3.2 销毁互斥量
3.3 互斥量加锁和解锁
4. 互斥量实现原理探究
可重入VS线程安全
1. 概念
2. 常见的线程不安全的情况
3. 常见的线程安全的情况
4. 常见不可重入的情况
5. 常见可重入的情况
6. 可重入与线程安全联系
7. 可重入与线程安全区别
常见锁概念
1. 死锁
2. 死锁四个必要条件
3. 避免死锁
Linux线程同步
1. 条件变量
2. 同步概念与竞态条件
3. 条件变量
3.1 条件变量函数
3.2 条件变量使用规范
我们来看下面这个场景:
假如现在有1000张票,我们创建了4个线程来抢票,下面我们来看一下代码和运行结果:
#include
#include
#include
#include
using namespace std;
#define NUM 4
class threadData
{
public:
threadData(int number)
{
threadName = "thread-" + to_string(number);
}
public:
string threadName;
};
int tickets = 1000;// 用多线程,模拟一轮抢票
void* getTickets(void* args)
{
threadData* td = static_cast(args);
const char* name = td->threadName.c_str();
while (true)
{
if(tickets > 0)
{
usleep(1000);
printf("who=%s, get a ticket: %d\n", name, tickets); // ?
tickets--;
}
else
{
break;
}
}
printf("%s ... quit\n", name);
return nullptr;
}
int main()
{
vector tids;
vector thread_datas;
for (int i = 1; i < NUM; i++)
{
pthread_t tid;
threadData* td = new threadData(i);
thread_datas.push_back(td);
pthread_create(&tid,nullptr,getTickets,thread_datas[i-1]);
tids.push_back(tid);
}
for(auto thread:tids)
{
pthread_join(thread,nullptr);
}
for (auto td : thread_datas)
{
delete td;
}
return 0;
}
运行结果:
我们发现这里只有总共1000张票,getTickets函数中循环我们判断的是tickets > 0才进行抢票,按理说抢到1,应该就停止抢票了,但是现在票变成了负数,线程却还在抢。 这种情况会导致在同一张票卖出去了几次,在我们实际购票的时候是绝对不允许这种情况出现的。
那么为什么可能无法获得正确结果?原因有以下几点:
为什么说tickets-- 操作不是一个原子操作呢?我们取出ticket--部分的汇编代码:
objdump -d a.out > test.objdump
152 40064b: 8b 05 e3 04 20 00 mov 0x2004e3(%rip),%eax # 600b34
153 400651: 83 e8 01 sub $0x1,%eax
154 400654: 89 05 da 04 20 00 mov %eax,0x2004da(%rip) # 600b34
我们看到操作并不是原子操作,而是对应三条汇编指令:
因为我们的操作需要三个步骤才能完成,那么就有可能出现这种情况:
tickets
的值到内存中,然后它被切换出去。由于线程切走时保存了上下文信息,当第二个线程进入并执行操作时,它看到的tickets
值仍然是1000。第二个线程抢了5张票后,tickets
值变为995。tickets
值998写回到内存。tickets
值从995修改为998,这就导致了票卖出次数的问题。原本应该剩余995张票,但因为两个线程的并发操作,现在票的数量从995变为了998,可能导致一张票被卖出多次。这个问题称为竞态条件,它是由于多个线程对共享资源的并发访问和修改所引起的。为了解决这个问题,我们就需要采取一些办法来确保在任何时候只有一个线程可以访问和修改共享资源。
注意:
那么如何解决上面的问题呢?要解决以上问题,需要做到三点:
要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量。
初始化互斥量有两种方法:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict
attr);
参数:
返回值:
销毁互斥量需要注意:
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 时,可能会遇到以下情况:
下面我们使用这些互斥量的这些接口来改进上面的售票系统:
#include
#include
#include
#include
using namespace std;
#define NUM 4
int tickets = 1000;
//定义一把互斥锁
pthread_mutex_t lock;
class threadData
{
public:
threadData(int number)
{
threadName = "thread-" + to_string(number);
}
public:
string threadName;
};
void* getTickets(void* args)
{
threadData* td = static_cast(args);
const char* name = td->threadName.c_str();
while (true)
{
pthread_mutex_lock(&lock);// 申请锁成功,才能往后执行,不成功,阻塞等待。
if(tickets > 0)
{
usleep(1000);
printf("who=%s, get a ticket: %d\n", name, tickets); // ?
tickets--;
pthread_mutex_unlock(&lock);
}
else
{
pthread_mutex_unlock(&lock);
break;
}
usleep(13); // 防止同一个线程抢到很多票。我们抢到了票,我们会立马抢下一张吗?其实多线程还要执行得到票之后的后续动作。usleep模拟
}
printf("%s ... quit\n", name);
return nullptr;
}
int main()
{
pthread_mutex_init(&lock, NULL);
vector tids;
vector thread_datas;
for (int i = 1; i < NUM; i++)
{
pthread_t tid;
threadData* td = new threadData(i);
thread_datas.push_back(td);
pthread_create(&tid,nullptr,getTickets,thread_datas[i-1]);
tids.push_back(tid);
}
for(auto thread:tids)
{
pthread_join(thread,nullptr);
}
for (auto td : thread_datas)
{
delete td;
}
pthread_mutex_destroy(&lock);
return 0;
}
运行结果:
可以看到使用了互斥量之后,这里的抢票抢到1就不会再抢了,达到了我们的预期效果。 我们发现代码中每次释放锁之后,我们都要进行sleep,这是为了防止同一个线程抢到很多票。我们抢到一张票并不会马上抢下一张票,而是会执行其他的动作。
下面我们用一个故事来加深对上面例子中每次抢完票为什么要sleep的理解:
假如现在有一个vip自习室(锁),里面只有一个座位,先到先得,你拿着门上的钥匙进去学习了。由于只有一个座位,后面的人(其它线程)只能排队等着。当你学习完,把钥匙挂回门上。挂回去之后你怕自习室被人占了,又马上把钥匙拿下来,由于这时候你离门最近,其它人竞争不过你,所以你又申请到了自习室。但是你占着自习室不干活,其他人又等着自习室。这就造成了线程饥饿问题。
为了解决这个问题。自习室观察员提出来两条规则:1.外面来的人,必须排队 2.出来的人,不能立马申请锁,必须排队到队列的尾部。上面代码中我们释放锁完用一个sleep来模拟出来的线程,不能立马申请锁,必须排队到队列的尾部。
注意:我们判断tickets是否大于0的时候,判断是访问临界资源吗?必须是的,也就是判断tickets是否大于0必须在加锁之后!!! (你怎么知道临界资源是就绪还是不就绪的?你判断出来的!判断是访问临界资源吗?必须是的,也就是判断必须在加锁之后!!! )
我们的线程在访问临界区的代码之前都必须要先申请锁,那也就意味着所有的线程必须看到同一把锁,那锁是不是临界资源呢?
注意:
加锁的本质:用时间来换取安全!
- 我们加锁之后,代码的执行效率一般会降低,这是因为在大部分情况下,加锁本身都是有损于性能的事,它会让多执行流由并行执行变为了串行执行,这几乎是不可避免的。
加锁的表现:线程对于临界区代码串行执行
加锁原则:尽量的要保证临界区代码,越少越好!
代码是程序员写的!为了保证临界区的安全,必须保证每个线程都遵守相同的编码规范(A线程申请锁,其他线程的代码也必须申请锁)
我们来看一下lock和unlock的伪代码:
我们假设这里的mutex的初始值为1,然后al是一个寄存器,当线程去申请锁时,需要执行以下几步:
下面我们来展示一下多个线程申请锁的具体过程:
前面我们说过,我们要注意:寄存器内容属于线程的上下文,每个线程都有自己的寄存器内容,当线程被调度时,会把自己的内容加载到寄存器。线程被切换时会把自己的上下文带走! !
现在有两个线程,线程1、线程2,线程1与线程2刚开始都将al寄存器的值置为0
线程1先将a1寄存器里面的值与mutex值交换,线程2再将a1寄存器里面的值与mutex值进行交换,因为1先将寄存器里面的值与mutex值交换了,所以等2进行交换的时候内存里面mutex里面的值已经变成0了,因此2交换后a1寄存器里面的值和内存里面mutex的值都是0。
假如此时线程1的时间片到了,然后线程1会被切走,但是线程1还没有释放锁(所以被切走的时候线程1的上下文信息会被保存起来)。这时候线程2去申请锁,因为此时线程2它al寄存器里面的值是0,因此它申请不到锁所以他要挂起等待。
CPU重新开始调度线程1继续执行,此时线程1申请锁,因为此时线程1它al寄存器上下文的值是大于0的,因此它能够申请到锁。
当线程A快执行完自己的任务时,就会释放刚刚申请到的锁,此时内存中的mutex值会被置成1,刚刚由于竞争锁失败而挂起等待的线程B此时会被唤醒然后去重新竞争锁。
以上就是互斥量的实现原理了。这里要注意:为什么当一个线程它申请到锁之后,即使它被切走了,但是其他的线程还是申请不到锁,这是因为即使它被切走了但是因为它的寄存器里面的值为1。所以也就相当于它是拿着钥匙走的,别人是拿不到这把锁的。
下面我来给大家讲一个小故事帮助大家记忆:
张三和李四都各自有20块钱,但是买麦当劳套餐却要40块钱,这个时候张三对李四说:李四啊,你把你手上的20钱给我吧,我去买一包烟。李四对张三说:张三啊,你把你手上的20块钱给我吧,我去买肯德基套餐吃。这个时候张三向李四要她20块钱,李四向张三要她的20块钱,但是两个人都不肯把自己的20块钱给对方。因为法治社会所以不会出现直接抢夺对方钱的情况,因此这就形成了死锁。
上面的这个小故事就体现了死锁的四个必要条件:
张三和李四各自有20块钱(互斥条件),两个人都向对方要对方的20块钱,但是不肯将自己的20块钱给对方(请求与保持条件),因为两个人是好朋友所以不会出现直接抢夺对方20块钱的情况(不剥夺条件),于是两个人就形成一种头尾相接的循环等待资源的关系(循环等待条件)
注意:以上四个必要条件必须同时满足!
我们来看一段死锁的代码:
#include
#include
#include
using namespace std;
pthread_mutex_t lock1 = PTHREAD_MUTEX_INITIALIZER;//线程1的锁
pthread_mutex_t lock2 = PTHREAD_MUTEX_INITIALIZER;//线程2的锁
void* threadRoutine1(void* arg)
{
const char* name1 = (char*)arg;
//加锁
pthread_mutex_lock(&lock1);
while(true)
{
cout<<"threadname: "<
运行结果:
避免死锁算法:死锁检测算法(了解)、银行家算法(了解)
前面抢票的例子里面,我们说过为了避免同一个线程抢到很多票,我们使用usleep函数进行模拟其它动作,因为实际上抢完票我们不可能马上抢下一张,还需要做一些其他的事情(绑定信息等)。同样的,我们只进行单纯的加锁,如果一个线程竞争力很强,每次都是申请到锁,但是不干活有可能会导致其它线程长时间竞争不到锁,从而引起饥饿问题。
单纯的加锁可以保证在同一时间只有一个线程进入临界区访问临界资源,但是它没有高效的让每一个线程使用这份临界资源。
概念:条件变量是用于多线程编程的一种同步机制,它允许线程等待某个特定条件成立或满足某些条件后再执行。它是用来描述某种临界资源是否就绪的一种数据化描述。
条件变量常常与互斥锁(mutex)一起使用。
初始化条件变量函数:
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict
attr);
参数:
- cond:要初始化的条件变量
- attr:NULL
返回值:
- 初始化成功返回0,失败返回错误码
销毁条件变量函数:
int pthread_cond_destroy(pthread_cond_t *cond)
参数说明:
- cond: 要销毁的条件变量
返回值:
- 条件变量销毁成功返回0,失败返回错误码
等待条件满足:
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);
参数说明:
- cond: 唤醒在cond条件变量下等待的线程
区别:
- pthread_cond_signal函数用于唤醒等待队列中首个线程。
- pthread_cond_broadcast函数用于唤醒等待队列中的全部线程。
返回值:
- 调用成功返回0,失败返回错误码
了解了条件变量的函数之后,下面我们来看一段线程同步的简单案例:
#include
#include
#include
using namespace std;
int cnt = 0;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
void* Count(void*args)
{
pthread_detach(pthread_self());
uint64_t num = (uint64_t)args;//这里用int强转的话是4个字节,而64位下指针是8个字节,所以我们用8字节的无符号长整型的uint64_t来强转和接收
cout << "pthread:" << num << " create sucess" << endl;
while (true)
{
pthread_mutex_lock(&mutex);
// 我们怎么知道我们要让一个线程去休眠了那?一定是临界资源不就绪,没错,临界资源也是有状态的!!
// 你怎么知道临界资源是就绪还是不就绪的?你判断出来的!判断是访问临界资源吗?必须是的,也就是判断必须在加锁之后!!!
//每个线程进来都到等待队列里面去等待唤醒
pthread_cond_wait(&cond,&mutex); //? 为什么在这里? 1. pthread_cond_wait让线程等待的时候,会自动释放锁!
// 不管临界资源的状态情况
cout << "pthread:" << num << " ,cnt:" << cnt++ << endl;
pthread_mutex_unlock(&mutex);
}
}
int main()
{
for (int i = 0; i < 5; i++)
{
pthread_t tid;
pthread_create(&tid, nullptr, Count, (void*)i);//i不能取地址,如果取地址新线程和主线程用的就是同一个i,如果主线程i++的动作比新线程快执行,那么新线程取到的i就变了
usleep(1000);
}
sleep(3);
cout << "main thread ctrl begin: " << endl;
while (true)
{
sleep(1);
pthread_cond_signal(&cond);//唤醒在cond的等待队列中等待的一个线程,默认都是第一个
std::cout << "signal one thread..." << std::endl;
}
return 0;
}
运行结果:
通过运行结果我们可以看到,我们唤醒的这五个线程是有顺序性的,主要是因为这几个线程启动时默认都会在该条件变量下去等待,而我们通过pthread_cond_signal函数每次都唤醒的是在当前条件变量下等待的首个线程,当该线程执行完打印操作后会继续排到等待队列的尾部进行wait,所以我们能够唤醒的线程是有顺序性的。
我们也可以通过pthread_cond_broadcast函数每次唤醒在该条件变量下等待的所有线程:
运行结果:
可以看到我们这一次的唤醒是一次唤醒了所有在cond条件变量下等待的线程。
我们来看下面这幅图加深对上面这个例子的理解:
为什么phread_cond_wait需要互斥量?
pthread_mutex_lock(&mutex);
while (条件为假)
pthread_cond_wait(cond, mutex);
修改条件
pthread_mutex_unlock(&mutex);
为什么pthread_cond_wait要在lock和unlock之间?
pthread_cond_wait必须在lock和unlock之间,因为它是用来处理线程间的同步的。具体来说,pthread_cond_wait的作用是根据某个条件来等待,当条件满足时,线程会被唤醒并继续执行。而这个条件通常与某个共享变量的状态有关。为了确保线程安全地访问共享变量,需要使用互斥锁(mutex)来保护共享变量。
在调用pthread_cond_wait之前,需要先对互斥锁进行加锁操作,以防止其他线程同时修改共享变量。在调用pthread_cond_wait之后,需要再对互斥锁进行解锁操作,以便其他等待该互斥锁的线程可以获得执行机会。
如果不在lock和unlock之间使用pthread_cond_wait,那么可能会出现竞态条件(race condition),即多个线程同时访问和修改共享变量,导致数据不一致或不可预期的行为。使用互斥锁可以避免这种情况,确保只有一个线程可以访问共享变量,从而保证数据的一致性和正确性。
因此,pthread_cond_wait必须在lock和unlock之间使用,以确保线程安全地等待某个条件成立或满足某些条件。
pthread_mutex_lock(&mutex);
设置条件为真
pthread_cond_signal(cond);
pthread_mutex_unlock(&mutex);
注意: