文章目录
- 前言
- 一、Linux线程互斥
- 1.mutex的理解
- 锁
- 原子性
- 互斥锁实现原子性的原理
- 2.mutex的封装——Mutex.hpp
- 3.可重入和线程安全
- 可重入
- 线程安全
- 线程安全不一定是可重入的,而可重入函数一定是线程安全的。
- 4.死锁
- 概念
- 造成死锁的四个必要条件
- 如何避免死锁
- 二、Linux线程同步
- 1.引入
- 2.条件变量
- 3.条件变量接口
- 4.理解条件变量
- 条件变量的使用
- 一次唤醒一个线程
- 一次唤醒一批线程
- 总结
前言
本文承接上一篇文章的内容,继续介绍Linux中的线程安全问题及解决方法。
一、Linux线程互斥
1.mutex的理解
锁
- 锁本身也是一个共享资源。
共享资源需要被锁保护,但是锁本身也是共享资源,谁来保护锁呢?
pthread_mutex_lock、pthread_mutex_unlock:加锁和解锁的过程必须是线程安全的,因此加锁的过程是原子的(未来解锁的一定是一个执行流)。 - 谁持有锁谁就能进入临界区。如果申请锁成功,就继续向后续代码执行。如果申请锁失败,执行流会怎么办?如果已经加了一次锁,然后再加一次锁,结果又会怎样?
这时,程序不再执行,执行流会阻塞。
原子性
如果线程1,申请锁成功,进入临界区,正在访问临界资源。此时其它进程真正阻塞等待。那么问题来了,这时该线程是否可以被切换?答案是肯定的,可以被切换。
当持有锁的线程被切换走时,它是抱着锁一起被切走的。即使该线程被切换掉,其它线程此时也无法申请锁,只能等待该线程将锁释放掉。
因此,对于其它线程而言,有意义的锁的状态只有两种:1.锁被申请前、2.锁被释放后。
在其它线程眼中,当前线程持有锁的过程就是原子的(要么持有,要么不持有)。
注意:
- 我们在使用锁的时候一定要尽量保证临界区的粒度尽可能小(粒度是加锁和解锁之间的代码的多少,即锁保护的代码的多少)。
- 加锁是程序员行为,必须做到要加就都加(公共资源,要么加锁,要么不加锁,这是程序员决定的,尽量避免因为锁而写bug)。
互斥锁实现原子性的原理
从汇编指令谈加锁:为了实现互斥锁操作,大多数体系结构提供了swap和exchange指令,它们的作用是把寄存器和内存单元的数据直接进行交换。由于该操作只用了一条指令,因此可以保证原子性。
汇编指令:
lock:movb $0, %alxchgb %al, mutexif(al寄存器的内容 > 0){return 0;}else挂起等待;goto lock;
unlock:movb $1, mutex唤醒等待Mutex的线程return 0;
加锁:将内存里的数据与寄存器中的数据进行交换,就加锁了。
xchgb指令将CPU中寄存器里的数据与内存中对应的数据进行直接交换(原子操作)
解锁:申请锁成功的线程将寄存器内的内容(上下文信息)与内存里的数据交换,然后就成功解锁了。
2.mutex的封装——Mutex.hpp
文件Mutex.hpp
1 #pragma once2 #include<iostream>3 #include<pthread.h>4 using namespace std;5 class Mutex6 {7 public:8 Mutex(pthread_mutex_t* lock_p = nullptr)9 :lock_p_(lock_p)10 {}11 void lock()12 {13 if(lock_p_)14 {15 pthread_mutex_lock(lock_p_);16 }17 }18 void unlock()19 {20 if(lock_p_)21 {22 pthread_mutex_unlock(lock_p_);23 }24 }25 ~Mutex()26 {}27 private:28 pthread_mutex_t* lock_p;29 };30 31 class LockGuard32 {33 public:34 LockGroud(pthread_mutex_t* mutex)35 :mutex_(mutex)36 {37 mutex_.lock();//在构造函数里加锁38 }39 ~LockGroud()40 {41 mutex_.unlock();//在析构函数里解锁42 }43 private:44 Mutex mutex_;45 };
测试代码:
文件main.cc
1 #include"Mutex.hpp"2 int tickets = 1000;3 pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;4 void* get_ticket(void* args)5 {6 string name = static_cast<const char*>(args);7 while(1)8 {9 LockGuard lockguard(&lock);10 if(tickets > 0)11 {12 usleep(1234);13 cout<<name<<"正在抢票, 剩余票数:"<<tickets<<endl;14 tickets--;15 }16 else17 {18 break;19 }20 }21 return nullptr;22 }23 int main()24 {25 pthread_t t1, t2, t3, t4;26 pthread_create(&t1, nullptr, get_ticket, (void*)"thread 1");27 pthread_create(&t2, nullptr, get_ticket, (void*)"thread 2");28 pthread_create(&t3, nullptr, get_ticket, (void*)"thread 3");29 pthread_create(&t4, nullptr, get_ticket, (void*)"thread 4");30 31 pthread_join(t1, nullptr);32 pthread_join(t2, nullptr);33 pthread_join(t3, nullptr);34 pthread_join(t4, nullptr);35 return 0;36 }
运行:
3.可重入和线程安全
可重入
同一个函数被不同的执行流调用,当前一个执行流还没有执行完,给予有其它执行流再次进入该函数,我们称为重入。
一个函数在重入的状态下,运行结果不会出现任何不同或者没有出现任何问题,该函数被称为可重入函数。否则,该函数是不可重入函数。
线程安全
线程安全:多个线程并发执行同一段代码,多次测试不会出现不同的结果(即,没有问题),常见的多线程对全局变量或静态变量进行操作,在没有锁保护的情况下会出现问题,例如:抢票。
线程安全不一定是可重入的,而可重入函数一定是线程安全的。
如果对临界资源的访问加锁,则该函数是线程安全的。但是如果重入这个函数时,函数的锁还未释放,则会产生死锁问题,因此该函数是不可重入的。
常见的可重入的情况
1.每个线程对全局变量或静态变量只有读取的权限,没有修改(写入)的权限,一般来说,这些线程是安全的;
2.类或者接口对于线程来说都是原子操作,多个线程之间的切换不会导致该接口的执行结果存在二义性。
常见的不可重入的情况
1.调用了malloc/free函数:因为malloc函数是用全局链表来管理堆的(链表的插入等操作是不可重入的);
2.调用标准I/O库函数:标准I/O库函的很多实现都是以不可重入的方式使用全局数据结构;
3.可重入函数体使用了静态的数据结构。
4.死锁
概念
一组执行流(进程/线程)持有自己锁资源的同时,还想申请对方的锁(但是,锁是不可抢占的,只能等持锁的线程主动归还),这会使得多个执行流互相等待对方持有的资源,导致代码无法推进。这就是死锁。
特殊的,一把锁也会导致死锁问题,在已经申请锁的情况下,又去申请一把锁,就会导致死锁问题。
为什么会导致死锁?
前提是使用了锁——锁可以保护临界资源的安全;为啥要保护临界资源——多线程并发访问临界资源会导致数据不一致的问题——多线程的大部分资源是临界资源(共享资源)——多线程的特性决定的。
为了解决一个问题,带来了新的问题:死锁。任何技术都有自己的边界,在解决一个问题的同时,一定会导致另一个新的问题。
造成死锁的四个必要条件
- 互斥:一个共享资源每次仅被一个执行流使用;
- 请求和保持:一个执行流因请求其它资源而阻塞,同时也不释放已有资源;
- 不剥夺:一个执行流获得的资源在未(使用完毕)主动释放之前,不能被强行剥夺;
- 环路等待:执行流之间形成环路问题,循环等待资源。
如何避免死锁
- 破坏死锁的四个必要条件(破坏其中一个及以上即可)。
- 加锁顺序保持一致;
- 避免锁未释放的场景(防止出现锁一直被占有,无法申请);
- 资源一次性分配(一个执行流需要的资源,一次性全部分配给它)。
二、Linux线程同步
1.引入
举一些生活中的例子:
游乐园的热门项目,先到先玩;打印机打印东西,先到的人先打印;上厕所时将门反锁,其他人无法进入……
这些例子中,离资源越近的人竞争力越强,就导致一直是同一个人在拿到资源、释放资源,造成其他人饥饿状态。我们本节内容和上节内容所举的例子:抢票系统就是这样,我们发现很长一段时间一直是同一个线程在抢票,造成其它线程的饥饿问题。
为了解决这个问题,我们在数据安全的情况下让这些线程按照一定的顺序申请资源,这就是线程同步。
饥饿状态:得不到锁资源,而无法访问公共资源的线程,处于饥饿状态。它并没有错,但是不合理。
竞态条件:因为时序问题导致程序异常,我们称为竞态条件。
线程同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步。
2.条件变量
当一个线程互斥的访问某个资源时,它有可能发现在其它线程改变状态之前,它不能对该资源进行操作。
例如:一个线程访问一个队列时,发现队列为空,它只能等待其它线程往该队列里添加节点,这种情况就需要用到条件变量。
条件变量通常是配合互斥锁一起使用的。
条件变量的使用:一个线程等待条件变量的条件成立而被挂起;另一个线程使条件成立后唤醒等待的线程。
3.条件变量接口
//初始化
int pthread_cont_init(pthread_cont_t* restrict cont, const pthread_condarr_t* restrict attr);
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
//销毁
int pthread_cond_destroy(pthread_cond_t* cond);
//特定时间阻塞等待
int pthread_cond_timedwait(pthread_cond_t* restrict cond, pthread_mutex_t* restrict mutex, const struct timespec* restrict abstime);
//等待
int pthread_cond_wait(pthread_cond_t* restrict cond, pthread_mutex_t* restrict mutex);
//唤醒一批线程
int pthread_cond_broadcast(pthread_cond_t* cond);
//唤醒一个线程
int pthread_cond_signal(pthread_condt_t* cond);
4.理解条件变量
举例:公司进行招聘,很多应聘者来面试,只有一个面试官,一次只能有一个应聘者进入面试间进行面试。由于没有组织者进行组织,导致没有规则:上一个人面试完之后,所有人都拥挤到面试官面前申请面试,面试官只能选择离自己最近的那个人进行面试,这就导致一群人在外面等着,总有人抢不过别人一直没有面试机会,甚至有的人面试完一次再次申请面试的情况,造成其它人的饥饿问题。这种情况下,面试的效率很低下。
之后,面试官对面试的顺序制定了规则,设立了一个等待区,所有人按照到达的时间进行排队,这样一来所有人都有机会面试了。
而这个等待区就是条件变量,如果一个人想进行面试,就要先去等待区等待,未来所有的应聘者都要去条件变量等待。
条件不满足时,线程就必须去某些定义好的条件变量上进行等待。
变量条件(struct cond, 结构体)里面包含状态、队列。条件变量里包含一个队列,不满足条件的线程就链接在这个队列上进行等待。
条件变量的使用
可以通过条件变量来控制线程的执行。由于条件变量本身并不具备互斥的功能,所以条件变量必须配合互斥锁使用:
一次唤醒一个线程
创建2个线程,通过条件变量一秒唤醒一个线程(或者全部唤醒)
文件test.cc
1 #include"Mutex.hpp"2 int tickets = 1000;3 pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;4 pthread_cond_t cond = PTHREAD_COND_INITIALIZER;//条件变量5 void* start_routine(void* args)6 {7 string name = static_cast<const char*>(args);8 while(1)9 {10 LockGuard lockguard(&lock);11 pthread_cond_wait(&cond, &lock);12 if(tickets > 0)13 {14 cout<<name<<"正在抢票, 剩余票数:"<<tickets<<endl;15 tickets--;16 }17 else18 {19 break;20 }21 }22 return nullptr;23 }24 int main()25 {26 pthread_t t1, t2;27 pthread_create(&t1, nullptr, start_routine, (void*)"thread 1");28 pthread_create(&t2, nullptr, start_routine, (void*)"thread 2");29 while(1)30 {31 sleep(1);32 pthread_cond_signal(&cond);33 cout<<"main thread wakeup one thread..."<<endl;34 }35 pthread_join(t1, nullptr);36 pthread_join(t2, nullptr);37 return 0;38 }
主线程一个一个去叫,按照一定顺序输出打印。
一次唤醒一批线程
文件test1.cc
1 #include"Mutex.hpp"2 int tickets = 1000;3 pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;4 pthread_cond_t cond = PTHREAD_COND_INITIALIZER;//条件变量5 void* start_routine(void* args)6 {7 string name = static_cast<const char*>(args);8 while(1)9 {10 LockGuard lockguard(&lock);11 pthread_cond_wait(&cond, &lock);12 if(tickets > 0)13 {14 cout<<name<<"正在抢票, 剩余票数:"<<tickets<<endl;15 tickets--;16 }17 else18 {19 break;20 }21 }22 return nullptr;23 }24 int main()25 {26 pthread_t t1, t2, t3, t4, t5;27 pthread_create(&t1, nullptr, start_routine, (void*)"thread 1");28 pthread_create(&t2, nullptr, start_routine, (void*)"thread 2");29 pthread_create(&t3, nullptr, start_routine, (void*)"thread 3");30 pthread_create(&t4, nullptr, start_routine, (void*)"thread 4");31 pthread_create(&t5, nullptr, start_routine, (void*)"thread 5");32 while(1)33 {34 sleep(1);35 pthread_cond_broadcast(&cond);36 cout<<"main thread wakeup one thread..."<<endl;37 }38 pthread_join(t1, nullptr);39 pthread_join(t2, nullptr);40 return 0;41 }
运行:
总结
以上就是今天要讲的内容,本文继上一篇文章继续介绍了线程安全的相关内容,主要介绍了锁以及条件变量等相关概念。作者目前也是正在学习Linux相关的知识,如果文章中的内容有错误或者不严谨的部分,欢迎大家在评论区指出,也欢迎大家在评论区提问、交流。
最后,如果本篇文章对你有所启发的话,希望可以多多支持作者,谢谢大家!