目录
1、Linux线程互斥
进程线程间的互斥相关背景概念
互斥量 mutex
互斥量的接口
互斥量实现原理探究
互斥量接口的使用(多种方式初始化锁)
2、可重入 VS 线程安全
概念
常见的线程不安全的情况
常见的线程安全的情况
常见的不可重入的情况
常见的可重入的情况
可重入与线程安全联系
可重入与线程安全区别
3、常见锁概念
死锁
死锁的四个必要条件
避免死锁
4、Linux线程同步
同步概念与竞态条件
条件变量
条件变量函数
为什么pthread_mutex_wait需要互斥量
条件变量使用规范
- 临界资源: 多线程执行流共享的资源叫做临界资源。
- 临界区: 每个线程内部,访问临界资源的代码,就叫做临界区。
- 互斥: 任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。
- 原子性: 不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。
下面详谈临界资源和临界区:
- 对于如下的代码,被多个线程访问的全局变量即临界资源,而访问此全局变量的代码即临界区:
下面详谈互斥和原子性:
注意:临界资源,可能会因为共同访问,造成数据不一致的问题,示例如下。
- 如下我写了一个票数计数器(全局变量tickets),创建4个线程来模拟多线程抢票的场景:
#include
#include #include #include #include #include using namespace std;// int 票数计数器 int tickets = 10000; // 临界资源,可能会因为共同访问,造成数据不一致的问题void *getTickets(void *args) {const char *name = static_cast (args);while (true){// 临界区if (tickets > 0){cout << name << " 抢到了票, 票的编号: " << tickets << endl;tickets--;}else{cout << name << "] 已经放弃抢票了, 因为没有了..." << endl;break;}}return nullptr; } int main() {pthread_t tid1;pthread_t tid2;pthread_t tid3;pthread_t tid4;pthread_create(&tid1, nullptr, getTickets, (void *)"thread 1");pthread_create(&tid2, nullptr, getTickets, (void *)"thread 2");pthread_create(&tid3, nullptr, getTickets, (void *)"thread 3");pthread_create(&tid4, nullptr, getTickets, (void *)"thread 4");int n = pthread_join(tid1, nullptr);cout << n << ":" << strerror(n) << endl;n = pthread_join(tid2, nullptr);cout << n << ":" << strerror(n) << endl;n = pthread_join(tid3, nullptr);cout << n << ":" << strerror(n) << endl;n = pthread_join(tid4, nullptr);cout << n << ":" << strerror(n) << endl;return 0; }
上述代码其实是有问题的,抢票即使对票判断,又是对票--,而对于tickets--,它不是由一条语句完成的,而是3条:
- 从内存中取数据到CPU的寄存器中(load tickets to reg)
- 在内部对其进行--操作(reg--)
- 将计算完成的结果写回内存中(write reg to tickets)
线程执行代码时根据这3条依次从上往下执行,而在这三条语句的任何地方,线程都有可能被切换走。CPU内的寄存器是被所有的执行流共享的,但是寄存器里面的数据是属于当前执行流的上下文数据。
- 线程被切换的时候,需要保存上下文
- 线程被换回的时候,需要恢复上下文
我们假设线程A在执行tickets--的任务,且tickets为10000。当tickets在寄存器已经计算一次完毕(tickets = 9999),准备将结果写回内存的时候,此时发生了线程切换(由线程A切至线程B),当前线程被拿下来了,此时寄存器里的值(9999)被放在了自己线程A的上下文里头,此时线程B也要执行tckets--的任务,且是不断循环此tickets--任务(读到寄存器,计算,返回结果),当tickets--到50的时候,再次--,读取寄存器,计算,-到49,准备写回的时候,线程B被切走了,保存自己的上下文数据,注意此时内存中tickets的数据已经是50了。线程A被切回来了,需要恢复上下文,把原先保存在线程A的值9999重新读回寄存器里,执行第三条语句:将计算完成的结果写回内存。此时内存中tickets由50变成了9999。我好不容易-到50的数据一瞬间回到解放前了!!!
上述就是典型的数据不一致问题!因为线程切换,多线程之间并发访问临界资源就会出现数据不一致的问题。上面的不只有--会出现数据不一致的问题,在判断tickets > 0时也同样会出现数据不一致:
- 我们假设tickets为1,此时线程A执行if判断,此步骤同样需要在cpu内的寄存器执行的,tickets = 1 > 0,判断后整准备返回结果时发生线程切换(由线程A切至线程B)
- 线程B也要执行if判断,把1从内存读到cpu里判断,发现tickets = 1 > 0,判断后返回结果到内存,随后执行tickets--语句,这里也要把tickets=1读到内存计算,计算后把结果0返回至内存。此时线程切换回至线程A,线程A继续执行未完成的tickets--语句,照样是把tickets = 0读到内存计算,计算后把结果-1返回至内存。票数只有1张,怎么可能出现这种情况呢。归根结底在于此判断发生了数据不一致问题!
能够出现数据不一致的问题本质还是线程切换过于频繁。而线程切换的场景如下:
- 时间片到了;线程会在内核返回到用户态做检测,创造更多的让线程阻塞的场景
现在来修改我们的代码使其发生数据不一致的问题:(加上usleep(1000),让线程按1000微秒进行休眠,那么此时线程的状态一定是阻塞或挂起的,那么就容易发生线程切换,从而导致数据不一致的问题)
#include
#include #include #include #include #include using namespace std;// int 票数计数器 int tickets = 10000; // 临界资源,可能会因为共同访问,造成数据不一致的问题void *getTickets(void *args) {const char *name = static_cast (args);while (true){// 临界区if (tickets > 0){usleep(1000);cout << name << " 抢到了票, 票的编号: " << tickets << endl;tickets--;}else{cout << name << "] 已经放弃抢票了, 因为没有了..." << endl;break;}}return nullptr; } int main() {pthread_t tid1;pthread_t tid2;pthread_t tid3;pthread_t tid4;pthread_create(&tid1, nullptr, getTickets, (void *)"thread 1");pthread_create(&tid2, nullptr, getTickets, (void *)"thread 2");pthread_create(&tid3, nullptr, getTickets, (void *)"thread 3");pthread_create(&tid4, nullptr, getTickets, (void *)"thread 4");int n = pthread_join(tid1, nullptr);cout << n << ":" << strerror(n) << endl;n = pthread_join(tid2, nullptr);cout << n << ":" << strerror(n) << endl;n = pthread_join(tid3, nullptr);cout << n << ":" << strerror(n) << endl;n = pthread_join(tid4, nullptr);cout << n << ":" << strerror(n) << endl;return 0; }
可以看到这里抢到的票数竟然出现了负数,这就是典型的数据不一致问题。解决方案见下文
- 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况变量归属单个线程,其他线程无法获得这种变量。
- 但有时候,很多变量都需要在线程间共享,这样的变量成为共享变量,可以通过数据的共享,完成线程之间的交互。
- 多个线程并发的操作共享变量,就会带来一些问题。
要解决上述抢票系统的问题,就要做到以下几点:
- 保证--的行为是原子的,一件事要么做了,要么没做
- 代码必须有互斥行为:当代码进入临界区执行时,不允许其它线程进入该临界区,只允许一个执行流在进行访问
- 如果多个线程同时要求执行临界区的代码,并且此时临界区没有线程在执行,那么只能允许一个线程进入该临界区
- 如果线程不在临界区执行,那么该线程不能阻止其它线程进入临界区
要做到上述几点,本质上就是需要一把锁,Linux上提供的这把锁叫做互斥量
初始化互斥量(动态分配)
初始化互斥量的函数叫做pthread_mutex_init,该函数的函数原型如下:
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
参数说明:
- mutex:需要初始化的互斥量。
- attr:初始化互斥量的属性,一般设置为NULL即可。
返回值说明:
- 互斥量初始化成功返回0,失败返回错误码。
初始化互斥量(静态分配)
调用pthread_mutex_init函数初始化互斥量叫做动态分配,除此之外,我们还可以用下面这种方式初始化互斥量,该方式叫做静态分配:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
销毁互斥量
销毁互斥量的函数叫做pthread_mutex_destroy,该函数的函数原型如下:
int pthread_mutex_destroy(pthread_mutex_t *mutex);
参数说明:
- mutex:需要销毁的互斥量。
返回值说明:
- 互斥量销毁成功返回0,失败返回错误码。
销毁互斥量需要注意:
- 使用PTHREAD_MUTEX_INITIALIZER初始化的互斥量不需要销毁。
- 不要销毁一个已经加锁的互斥量。
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁。
互斥量加锁(阻塞式)
互斥量加锁的函数叫做pthread_mutex_lock,该函数的函数原型如下:
int pthread_mutex_lock(pthread_mutex_t *mutex);
参数说明:
- mutex:需要加锁的互斥量。
返回值说明:
- 互斥量加锁成功返回0,失败返回错误码。
调用pthread_mutex_lock时,可能会遇到以下情况:
- 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功。
- 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_mutex_lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
- pthread_mutex_lock是阻塞式加锁,当其他线程正在使用,那么这个线程将会被阻塞住,放入等待队列,带其它线程用完。
互斥量加锁(非阻塞式)
int pthread_mutex_trylock(pthread_mutex_t *mutex);
- 非阻塞式加锁,如果其它线程正在使用,那么这个线程就会返回。
互斥量解锁
互斥量解锁的函数叫做pthread_mutex_unlock,该函数的函数原型如下:
int pthread_mutex_unlock(pthread_mutex_t *mutex);
参数说明:
- mutex:需要解锁的互斥量。
返回值说明:
- 互斥量解锁成功返回0,失败返回错误码。
示例
我们在上述的抢票系统中引入互斥量,每一个线程要进入临界区之前都必须先申请锁,加锁的本质是让线程执行临界区代码串行化,只要对临界区加锁,而且加锁的粒度越细越好。只有申请到锁的线程才可以进入临界区对临界资源进行访问,并且当线程出临界区的时候需要释放锁,这样才能让其余要进入临界区的线程继续竞争锁。
#include
#include #include #include #include #include using namespace std;// int 票数计数器 int tickets = 10000; // 临界资源,可能会因为共同访问,造成数据不一致的问题pthread_mutex_t mutex;void *getTickets(void *args) {const char *name = static_cast (args);while (true){// 临界区,只要对临界区加锁,而且加锁的粒度越细越好// 加锁的本质是让线程执行临界区代码串行化// 加锁pthread_mutex_lock(&mutex);if (tickets > 0){usleep(1000);cout << name << " 抢到了票, 票的编号: " << tickets << endl;tickets--;// 解锁pthread_mutex_unlock(&mutex);// other codeusleep(100); // 模拟其它业务逻辑的执行}else{cout << name << "] 已经放弃抢票了, 因为没有了..." << endl;// 解锁pthread_mutex_unlock(&mutex);break;}}return nullptr; } int main() {// 初始化锁pthread_mutex_init(&mutex, nullptr);pthread_t tid1;pthread_t tid2;pthread_t tid3;pthread_t tid4;pthread_create(&tid1, nullptr, getTickets, (void *)"thread 1");pthread_create(&tid2, nullptr, getTickets, (void *)"thread 2");pthread_create(&tid3, nullptr, getTickets, (void *)"thread 3");pthread_create(&tid4, nullptr, getTickets, (void *)"thread 4");int n = pthread_join(tid1, nullptr);cout << n << ":" << strerror(n) << endl;n = pthread_join(tid2, nullptr);cout << n << ":" << strerror(n) << endl;n = pthread_join(tid3, nullptr);cout << n << ":" << strerror(n) << endl;n = pthread_join(tid4, nullptr);cout << n << ":" << strerror(n) << endl;// 释放锁pthread_mutex_destroy(&mutex);return 0; }
注意:
- 加锁的本质式让线程执行临界区代码串行化
- 只需要对临界区加锁,而且加锁的粒度越细越好
- 加锁是一套规范,通过临界区对临界资源进行访问的时候,要加锁就都要加
- 锁保护的是临界区,任何线程执行临界区代码访问临界资源,都必须先申请锁,前提是都必须先看到锁
问1:这把锁,本身不久也是临界资源吗?它保护临界区,那谁来保护它呢?
- pthread_mutex_lock: 竞争和申请锁的过程,就是原子的,不会被影响。
问2:对临界区加锁后,还可以发生线程切换吗?
- 是可以切换的,因为线程执行的加锁解锁等对应的也是代码。线程在任意代码处都可以被切换,但是线程加锁是原子的(要么拿了锁,要么没有),不存在拿了一半锁的情况,所以加锁的过程是安全的。
问3:若加锁后在某一行代码发生线程切换了,是否意味着其它线程可以访问此临界区了呢?加锁后的原子性体现在哪里?
- 在我被切走的时候,绝对不会有其它线程进入临界区!!!因为每个线程进入临界区都必须先申请锁,当前的锁,假设被线程A申请走了,即便当前的线程A没有被调度,因为它是被切走了,带着锁走的,还没释放,锁在线程A的上下文里,新线程再来的时候申请锁会被阻塞住,因为持有锁的线程还没有回来。有点类似于“爷不在江湖,但江湖依旧有爷的传说”。
- 总结:一旦一个线程持有了锁,该线程根本就不担心任何的切换问题。对于其它线程而言,线程A访问临界区是具有原子性的,只有没有进入和使用完毕两种状态,才对其它线程有意义!只有线程A使用完毕释放了,其它线程才有机会申请锁,然后再访问。注意,尽量不要在临界区内做耗时的事情。
问4:线程加锁和解锁具有原子性,原子性是如何实现的呢?
- 上面我们已经说明了
--
和++
操作不是原子操作,根部原因是这条语句是由多条语句构成,继而发生线程切换,从而可能会导致数据不一致问题。- 只要我们的代码能够用一条语句(二进制级别的一条语句)执行完,就可以称之为原子性
- 为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用就是把寄存器和内存单元的数据相交换。
- 由于只有一条指令,保证了原子性(要么执行,要么不执行),即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时,另一个处理器的交换指令只能等待总线周期。
下面我们来看加锁lock和解锁unlock的伪代码:
以加锁示例,这是由多态汇编语句执行的,上述%al是寄存器,mutex就是内存中的一个变量。每个线程申请锁时都要执行上述语句,执行步骤如下:
- (movb $0,%al)先将al寄存器中的值清0。该动作可以被多个线程同时执行,因为每个线程都有自己的一组寄存器(上下文信息),执行该动作本质上是将自己的al寄存器清0。注意:凡是在寄存器中的数据,全部都是线程的内部上下文!多个线程看起来同时在访问寄存器,但是互不影响。
- (xchgb %al,mutex)然后用此一条指令交换al寄存器和内存中mutex的值,xchgb是体系结构提供的交换指令,该指令可以完成寄存器和内存单元之间数据的交换。
- 最后判断al寄存器中的值是否大于0。若大于0则申请锁成功,此时就可以进入临界区访问对应的临界资源;否则申请锁失败需要被挂起等待,直到锁被释放后再次竞争申请锁。
示例:假设内存中有一个变量mutex为1,cpu内部有%al寄存器,我们有threadA和threadB俩线程,
- 现在线程A要开始加锁,执行上述语句。首先(movb $0,%al),线程A把0读进al寄存器(清0寄存器),然后执行第二条语句(xchgb %al,mutex),将al寄存器中的值与内存中mutex的值进行交换。
- 交换完成后,寄存器al的值为1,内存中mutex的值为0。此时这个过程就是加锁
- 当线程A争议执行第三条语句if判断时,发生了线程切换(切至线程B),但是线程A要把自己的上下文(1)带走。线程B也要执行加锁动作,同样是第一条语句把0加载到寄存器,清0寄存器。
- 随后线程B执行第二条语句交换动作,可是mutex的数据先前已经被线程A交换至寄存器,然后保存到线程A的上下文了,现在的mutex为0,而线程B执行交换动作,拿寄存器al的0去换内存中mutex的0。
- 随后线程B执行第三条语句if判断,可是我现在寄存器的值为0,判断失败,线程B挂起等待。此时线程B就叫做申请锁失败。
即使我线程A在执行第一条语句把寄存器清0后就发生了线程切换(切至线程B),线程A保存上下文数据(0),此时线程B执行第一条语句把0写进寄存器,随后线程B执行第二条语句xchgb交换:
当线程B好不容易拿到1将要进行if判断时,又发生了线程切换(切至线程A),线程B保留自己的上下文数据(1),线程A恢复上下文数据(0)到寄存器。随后线程A继续执行第二条xchgb交换语句,可是现在mutex为0啊,交换后寄存器的值依旧为0,线程B这老嫂子竟然偷家了!
此时线程A执行第三条语句if判断失败,只能被挂起等待,线程A只能把自己的上下文数据保存,重新切换至线程B,也就是说我线程B只要不运行,你们其它所有线程都无法申请成功。线程B恢复上下文数据(1)到内存,然后执行第三条语句if成功,返回结果
注意:上述xchgb就是申请锁的过程。申请锁是将数据从内存交换到寄存器,本质就是将数据从共享内存变成线程私有。
- mutex就是内存里的全局变量,被所有线程共享,但是一旦用一条汇编语句将内存的mutex值交换到寄存器,寄存器内部是哪个线程使用,那么此mutex就是哪个线程的上下文数据,那么就意味着交换成功后,其它任何一个线程都不可能再申请锁成功了,因为mutex已经独属于某线程私有了。
- 这个mutex = 1就如同令牌一般,哪个线程先交换拿到1,那么哪个线程就能申请锁成功,所以加锁是原子的
当线程释放锁时,需要执行以下步骤:
- 将内存中的mutex置回1。使得下一个申请锁的线程在执行交换指令后能够得到1,形象地说就是“将锁的钥匙放回去”。
- 唤醒等待Mutex的线程。唤醒这些因为申请锁失败而被挂起的线程,让它们继续竞争申请锁。
总结:
- 在申请锁时本质上就是哪一个线程先执行了交换指令,那么该线程就申请锁成功,因为此时该线程的al寄存器中的值就是1了。而交换指令就只是一条汇编指令,一个线程要么执行了交换指令,要么没有执行交换指令,所以申请锁的过程是原子的。
- 在线程释放锁时没有将当前线程al寄存器中的值清0,这不会造成影响,因为每次线程在申请锁时都会先将自己al寄存器中的值清0,再执行交换指令。
- CPU内的寄存器不是被所有的线程共享的,每个线程都有自己的一组寄存器,但内存中的数据是各个线程共享的。申请锁实际就是,把内存中的mutex通过交换指令,原子性的交换到自己的al寄存器中。
上文说到抢票的例子,我们需要对其加锁来防止出现数据不一致的问题,而初始化锁有多种方式:
1、全局变量静态分配:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; int main() { ... }
代码如下:
#include
#include #include using namespace std;int tickets = 1000; //全局变量静态分配 pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;void* startRoutine(void* args) {const char* name = static_cast (args);while (true){//加锁(如果申请不到, 就会阻塞线程)pthread_mutex_lock(&mutex);if (tickets > 0){usleep(10021);cout << name << " get a ticket: " << tickets << endl;tickets--;//解锁pthread_mutex_unlock(&mutex);}else{//解锁pthread_mutex_unlock(&mutex);break;}}return nullptr; } int main() {pthread_t t1, t2, t3, t4;pthread_create(&t1, nullptr, startRoutine, (void*)"thread 1");pthread_create(&t2, nullptr, startRoutine, (void*)"thread 2");pthread_create(&t3, nullptr, startRoutine, (void*)"thread 3");pthread_create(&t4, nullptr, startRoutine, (void*)"thread 4");pthread_join(t1, nullptr);pthread_join(t2, nullptr);pthread_join(t3, nullptr);pthread_join(t4, nullptr); } 注意如果加锁时申请不到,那么就会阻塞线程,所以当if (tickets > 0)判断不成立时,也要在else语句那进行unlock解锁,否则会阻塞线程。测试结果如下:
通过我们的测试可以看到,怎么只有线程2在进行抢票,其它线程呢?其实这是很正常的现象,正常情况下你把票抢完了,还要做其它的事情(把信息同步给客户,让他确认……),我们这里的代码没有体现出它要做其它的事情,所以出现上述现象很正常,为了更真实模拟抢票场景,我们可以让抢票成功后加上usleep(500)来模拟线程抢票后做其它事情的场景。其实前面的抢票代码我默认加上了,这里没加,特地说明的。加上后效果如下:
2、局部变量静态分配:
int main() {// 局部变量静态分配static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;... }
因为这里的锁是静态分配的,为了让线程看到这把锁,我们可以在创建线程的函数pthread_create的最后一个参数中传入进去这把锁,这样就能让线程拿到了。
#include
#include #include using namespace std;int tickets = 1000;void *startRoutine(void *args) {pthread_mutex_t *mutex_p = static_cast (args);while (true){// 加锁(如果申请不到, 就会阻塞线程)pthread_mutex_lock(mutex_p);if (tickets > 0){usleep(10021);cout << "thread: "<< pthread_self() << " get a ticket: " << tickets << endl;tickets--;pthread_mutex_unlock(mutex_p);// 做其它事情usleep(500);}else{pthread_mutex_unlock(mutex_p);break;}}return nullptr; } int main() {// 局部变量静态分配static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;pthread_t t1, t2, t3, t4;pthread_create(&t1, nullptr, startRoutine, (void *)&mutex);pthread_create(&t2, nullptr, startRoutine, (void *)&mutex);pthread_create(&t3, nullptr, startRoutine, (void *)&mutex);pthread_create(&t4, nullptr, startRoutine, (void *)&mutex);pthread_join(t1, nullptr);pthread_join(t2, nullptr);pthread_join(t3, nullptr);pthread_join(t4, nullptr); }
3、多种参数初始化:
- 上述方法的传参导致线程名称只能用地址显示,如果我传参数的时候,想要传多种参数(名字...),那么就可以定义一个线程数据threadData结构体
#include
#include #include #include #include using namespace std;int tickets = 1000; #define NAMESIZE 64typedef struct threadData {char name[NAMESIZE];pthread_mutex_t *mutexp; } threadData;void *startRoutine(void *args) {threadData *td = static_cast (args);while (true){// 加锁(如果申请不到, 就会阻塞线程)pthread_mutex_lock(td->mutexp);if (tickets > 0){usleep(10021);cout << td->name << " get a ticket: " << tickets << endl;tickets--;pthread_mutex_unlock(td->mutexp);// 做其它事情usleep(500);}else{pthread_mutex_unlock(td->mutexp);break;}}return nullptr; } int main() {// 局部变量静态分配static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;pthread_t t1, t2, t3, t4;threadData *td1 = new threadData();strcpy(td1->name, "thread1");td1->mutexp = &mutex;threadData *td2 = new threadData();strcpy(td2->name, "thread2");td2->mutexp = &mutex;threadData *td3 = new threadData();strcpy(td3->name, "thread3");td3->mutexp = &mutex;threadData *td4 = new threadData();strcpy(td4->name, "thread4");td4->mutexp = &mutex;pthread_create(&t1, nullptr, startRoutine, td1);pthread_create(&t2, nullptr, startRoutine, td2);pthread_create(&t3, nullptr, startRoutine, td3);pthread_create(&t4, nullptr, startRoutine, td4);pthread_join(t1, nullptr);pthread_join(t2, nullptr);pthread_join(t3, nullptr);pthread_join(t4, nullptr);delete td1;delete td2;delete td3;delete td4; }
4、动态分配锁:
- 这里我们可以使用动态分配锁的方式,此法就是我们上文一开始抢票的代码,动态申请后需要用到destroy释放锁。
int main() {// 初始化锁pthread_mutex_init(&mutex, nullptr);...// 释放锁pthread_mutex_destroy(&mutex); }
代码如下:
#include
#include #include #include #include #include using namespace std;// int 票数计数器 int tickets = 1000; // 临界资源,可能会因为共同访问,造成数据不一致的问题pthread_mutex_t mutex;void *getTickets(void *args) {const char *name = static_cast (args);while (true){// 临界区,只要对临界区加锁,而且加锁的粒度越细越好// 加锁的本质是让线程执行临界区代码串行化// 加锁pthread_mutex_lock(&mutex);if (tickets > 0){usleep(1000);cout << name << " 抢到了票, 票的编号: " << tickets << endl;tickets--;// 解锁pthread_mutex_unlock(&mutex);// other codeusleep(100); // 模拟其它业务逻辑的执行}else{cout << name << "] 已经放弃抢票了, 因为没有了..." << endl;// 解锁pthread_mutex_unlock(&mutex);break;}}return nullptr; } int main() {// 初始化锁pthread_mutex_init(&mutex, nullptr);pthread_t tid1;pthread_t tid2;pthread_t tid3;pthread_t tid4;pthread_create(&tid1, nullptr, getTickets, (void *)"thread 1");pthread_create(&tid2, nullptr, getTickets, (void *)"thread 2");pthread_create(&tid3, nullptr, getTickets, (void *)"thread 3");pthread_create(&tid4, nullptr, getTickets, (void *)"thread 4");int n = pthread_join(tid1, nullptr);cout << n << ":" << strerror(n) << endl;n = pthread_join(tid2, nullptr);cout << n << ":" << strerror(n) << endl;n = pthread_join(tid3, nullptr);cout << n << ":" << strerror(n) << endl;n = pthread_join(tid4, nullptr);cout << n << ":" << strerror(n) << endl;// 释放锁pthread_mutex_destroy(&mutex);return 0; }
5、封装锁来实现买票(c++的风格):
我们可以对锁进行一个简单的封装,使其实现出一种RAII风格的加锁方式
- 我们把创建锁,加锁,解锁,释放锁封装成一个Mutex类,再封装一个LockGuard类,里面的构造函数调用加锁功能,析构函数调用解锁功能。后续对临界区加锁时,只需要定义一个LockGuard的对象即可,因为此局部对象的生命周期是随代码块的,开始时调用构造函数完成加锁功能,等要退出此代码块时自动调用析构函数完成解锁功能。
Lock.hpp文件:
#pragma once #include
#include using namespace std;class Mutex { public:Mutex(){pthread_mutex_init(&lock_, nullptr);}void lock(){pthread_mutex_lock(&lock_);}void unlock(){pthread_mutex_unlock(&lock_);}~Mutex(){pthread_mutex_destroy(&lock_);} private:pthread_mutex_t lock_; };class LockGuard { public:LockGuard(Mutex *mutex): mutex_(mutex){mutex_->lock();cout << "加锁成功..." << endl;}~LockGuard(){mutex_->unlock();cout << "解锁成功..." << endl;} private:Mutex *mutex_; }; mythread.cc文件:
#include
#include #include #include #include #include #include "Lock.hpp" using namespace std;// int 票数计数器 int tickets = 7000; Mutex mutex;// 抢票 bool getTickets() {bool ret = false;LockGuard lockGuard(&mutex);//局部对象的生命周期是随代码块的//构造函数默认调用加锁if (tickets > 0){usleep(1000);cout << "thread: " << pthread_self() << " get a ticket: " << tickets << endl;tickets--;ret = true;}//退出代码块时,自动调用析构函数解锁return ret; }// 线程的函数 void *startRoutine(void *args) {const char *name = static_cast (args);while (true){if (!getTickets())break;cout << name << "get tickets success" << endl;// 其它事情要做sleep(1);} } int main() {pthread_t t1, t2, t3, t4;pthread_create(&t1, nullptr, startRoutine, (void *)"thread 1");pthread_create(&t2, nullptr, startRoutine, (void *)"thread 2");pthread_create(&t3, nullptr, startRoutine, (void *)"thread 3");pthread_create(&t4, nullptr, startRoutine, (void *)"thread 4");pthread_join(t1, nullptr);pthread_join(t2, nullptr);pthread_join(t3, nullptr);pthread_join(t4, nullptr);return 0; }
有了此Mutex类和LockGuard完成加锁解锁功能的类,如果后续我们想要对一些临界资源如int cnt = 10000;进行加锁,只需执行此代码即可:
Mutex mutex;{ //临界资源LockGuard lockGuard(&mutex);cnt++;... }
- 线程安全: 多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现线程安全问题。
- 重入: 同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则是不可重入函数。
注意: 线程安全讨论的是线程执行代码时是否安全,重入讨论的是函数被重入进入。
不保护共享变量的函数。
函数状态随着被调用,状态发生变化的函数。
返回指向静态变量指针的函数。
调用线程不安全函数的函数。
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。
- 类或者接口对于线程来说都是原子操作。
- 多个线程之间的切换不会导致该接口的执行结果存在二义性。
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的。
- 调用了标准I/O库函数,标准I/O可以的很多实现都是以不可重入的方式使用全局数据结构。
- 可重入函数体内使用了静态的数据结构。
- 不使用全局变量或静态变量。
- 不使用malloc或者new开辟出的空间。
- 不调用不可重入函数。
- 不返回静态或全局数据,所有数据都由函数的调用者提供。
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据。
- 函数是可重入的,那就是线程安全的。
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
- 可重入函数是线程安全函数的一种。
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
- 如果对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数的锁还未释放则会产生死锁,因此是不可重入的。
- 死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于的一种永久等待状态。
下面我们来模拟实现一个死锁的场景:
- 如下我创建了两个线程,两把锁。线程1先申请A锁,线程2先申请B锁,申请完后,线程1又开始申请B锁,而线程2又开始申请A锁,此时就出现了线程1拿着A锁,线程2拿着B锁,他俩还互相想要对方的锁,但是他们要的锁已经被对方所拿走,此时就出现,线程1在申请B锁的时候申请不到,线程1抱着A锁挂起等待,线程2也不可能申请到A锁,线程2抱着B锁挂起等待。这就是典型的死锁问题!
#include
#include #include #include #include #include #include "Lock.hpp" using namespace std;pthread_mutex_t mutexA = PTHREAD_MUTEX_INITIALIZER; pthread_mutex_t mutexB = PTHREAD_MUTEX_INITIALIZER;void *startRoutine1(void *args) {while (true){pthread_mutex_lock(&mutexA);sleep(1);pthread_mutex_lock(&mutexB);cout << "我是线程1, 我的tid: " << pthread_self() << endl;pthread_mutex_lock(&mutexA);pthread_mutex_lock(&mutexB);} } void *startRoutine2(void *args) {while (true){pthread_mutex_lock(&mutexB);sleep(1);pthread_mutex_lock(&mutexA);cout << "我是线程2, 我的tid: " << pthread_self() << endl;pthread_mutex_lock(&mutexB);pthread_mutex_lock(&mutexA);} } int main() {pthread_t t1, t2;pthread_create(&t1, nullptr, startRoutine1, nullptr);pthread_create(&t2, nullptr, startRoutine2, nullptr);pthread_join(t1, nullptr);pthread_join(t2, nullptr);return 0; }
此时俩线程都挂起等待了,自然也打印不出东西,也验证了死锁的问题。
问:单执行流可能产生死锁吗?
- 会的,如果某一执行流连续申请了两次锁,那么此时该执行流就会被挂起。因为该执行流第一次申请锁的时候是申请成功的,但第二次申请锁时因为该锁已经被申请过了,于是申请失败导致被挂起直到该锁被释放时才会被唤醒,但是这个锁本来就在自己手上,自己现在处于被挂起的状态根本没有机会释放锁,所以该执行流将永远不会被唤醒,此时该执行流也就处于一种死锁的状态。
如下我让创建的新线程连续申请了两次锁就产生了上述的死锁问题:
#include
#include #include #include using namespace std;//一把锁也会产生死锁的问题 pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; int cnt = 100;void *startRoutine(void *args) {const char *name = static_cast (args);while (true){//加锁pthread_mutex_lock(&mutex);pthread_mutex_lock(&mutex);cout << name << "count : " << cnt-- << endl;//解锁pthread_mutex_unlock(&mutex);sleep(1);} } int main() {pthread_t t1;pthread_create(&t1, nullptr, startRoutine, (void*)"thread 1");pthread_join(t1, nullptr);return 0; }
运行代码,此时该程序实际就处于一种被挂起的状态。
- 互斥条件: 一个资源每次只能被一个执行流使用。
- 请求与保持条件: 一个执行流因请求资源而阻塞时,对已获得的资源保持不放。(我拿着自己的锁还申请对方的锁)
- 不剥夺条件: 一个执行流已获得的资源,在未使用完之前,不能强行剥夺。(如线程1拿A锁,线程2拿B锁,可还要强行剥夺对方的锁,这是不允许的,只能挂起)
- 循环等待条件: 若干执行流之间形成一种头尾相接的循环等待资源的关系。(双方线程各占用1各锁,还互相申请对方的锁,形成环路等待)
注意: 这是死锁的四个必要条件,也就是说只有同时满足了这四个条件才可能产生死锁。
- 破坏死锁的四个必要条件。
- 加锁顺序一致。
- 避免锁未释放的场景。
- 资源一次性分配。
问:线程互斥,它是对的,但是它在任何地方都合理吗?
- 我们假设如下的场景:食堂打饭时,每个人都是竞争式的打饭,谁块头大,声望高,谁就有肉吃,这就导致了很大一部分人挣不到一口饭吃。打饭的规则没有错,资源有限,只能给一个人打饭。从而导致很多人饥饿的问题。如上也就是是互斥的规则(一个资源每次只能被一个执行流使用)。
- 我们再假设一个自习室一次只能允许一个人进入学习,钥匙挂在门口,谁先拿到,谁就能进去,假设你占据先天优势,每次都是你先进去了,其它人只能干等,假设你故意搞人心态,一会出去,一会进来,钥匙始终在你身上。其它人也没办法。上述过程你没有错,因为自习室的规则导致的。
如上两个场景都证明了互斥它是对的,但是它不一定合理,因为互斥有可能导致饥饿问题,一个执行流,长时间得不到某种资源。
- 我们现在改变一下自习室的规则,任何一个人进入自习室后,一旦出去,就不能再进入了,也就是不能再申请这把锁了,再申请,则必须得排队。这种规则的改变是基于互斥特性的基础上增加了让多个人访问某种资源具备一定的顺序性,即同步。
上述场景对于线程的体现如下:
- 单纯的加锁是会存在某些问题的,如果个别线程的竞争力特别强,每次都能够申请到锁,但申请到锁之后什么也不做,所以在我们看来这个线程就一直在申请锁和释放锁,这就可能导致其他线程长时间竞争不到锁,引起饥饿问题。单纯的加锁是没有错的,它能够保证在同一时间只有一个线程进入临界区,但它没有高效的让每一个线程使用这份临界资源。
- 现在我们增加一个规则,当一个线程释放锁后,这个线程不能立马再次申请锁,该线程必须排到这个锁的资源等待队列的最后。增加这个规则之后,下一个获取到锁的资源的线程就一定是在资源等待队列首部的线程,如果有十个线程,此时我们就能够让这十个线程按照某种次序进行临界资源的访问。
- 同步:在保证临界资源安全的前提下(互斥等),让线程访问某种资源,具有一定的顺序性,称之为同步。从而达到防止饥饿和线程协同的目的。
- 竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。
我们抛出了同步的概念,具体实现同步要用到条件变量,见下文
条件变量:是利用线程间共享的全局变量进行同步的一种机制,条件变量是用来描述某种资源是否就绪的一种数据化描述。
- 当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。例如一个线程访问队列时,发现队列为空,它只能等待,只到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量。条件变量使唤醒线程由系统唤醒 -》让程序员自己唤醒线程。
条件变量主要包括两个动作:
- 一个线程等待条件变量的条件成立而被挂起。
- 另一个线程使条件成立后唤醒等待的线程。
条件变量通常需要配合互斥锁一起使用。
初始化条件变量(pthread_cond_init)
#include
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr); 参数说明:
- cond:需要初始化的条件变量。
- attr:初始化条件变量的属性,一般设置为NULL即可。
返回值说明:
- 条件变量初始化成功返回0,失败返回错误码。
调用pthread_cond_init函数初始化条件变量叫做动态分配,除此之外,我们还可以用下面这种方式初始化条件变量,该方式叫做静态分配:
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
销毁条件变量(pthread_cond_destroy)
int pthread_cond_destroy(pthread_cond_t *cond);
参数说明:
- cond:需要销毁的条件变量。
返回值说明:
- 条件变量销毁成功返回0,失败返回错误码。
注意:
- 使用pthread_cond_destroy初始化的条件变量不需要销毁。
等待条件变量满足(pthread_cond_wait)
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
参数说明:
- cond:需要等待的条件变量。
- mutex:当前线程所处临界区对应的互斥锁。
返回值说明:
- 函数调用成功返回0,失败返回错误码。
可以设置等待时间(pthread_cond_timedwait):
int pthread_cond_timedwait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex, const struct timespec *restrict abstime);
唤醒等待
- 唤醒所有等待的线程(pthread_cond_broadcast):
int pthread_cond_broadcast(pthread_cond_t *cond);
- 唤醒一个等待的线程(pthread_cond_signal):
int pthread_cond_signal(pthread_cond_t *cond);
参数说明:
- cond:唤醒在cond条件变量下等待的线程。
返回值说明:
- 函数调用成功返回0,失败返回错误码。
示例
如下我们创建3个线程,让主线程控制着3个线程活动。这3个线程创建后都在条件变量下进行等待,直到主线程检测到键盘有输入指定字符n时才唤醒一个等待线程,输入指定字符q才终止线程。如此执行下去:
#include
#include #include #include using namespace std;// 定义一个条件变量 pthread_cond_t cond; // 定义一个互斥锁 pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; // 当前不用,但是接口需要void *waitCommand(void *args) {const char *name = static_cast (args);pthread_detach(pthread_self());while (true){// 只要执行了下面的代码, 证明某一种条件不就绪(现在还没有场景),要我这个线程等待// 三个线程,都会在条件变量下进行排队pthread_cond_wait(&cond, &mutex); // 让对应的线程进行等待被唤醒cout << name << " run..." << endl;} } int main() {// 初始化条件变量pthread_cond_init(&cond, nullptr);pthread_t t1, t2, t3;pthread_create(&t1, nullptr, waitCommand, (void *)"thread 1");pthread_create(&t2, nullptr, waitCommand, (void *)"thread 2");pthread_create(&t3, nullptr, waitCommand, (void *)"thread 3");// 让主线程控制着三个线程while (true){char n = 'a';cout << "请输入你的command(n/q): ";cin >> n;if (n == 'n'){pthread_cond_signal(&cond);//唤醒一个线程}elsebreak;sleep(1);}cout << "main thread quit" << endl;pthread_join(t1, nullptr);pthread_join(t2, nullptr);pthread_join(t3, nullptr);// 销毁条件变量pthread_cond_destroy(&cond);return 0; } 此时我们会发现唤醒这三个线程时具有明显的顺序性,根本原因是当这若干个线程启动时默认都会在该条件变量下去等待,而我们每次都唤醒的是在当前条件变量下等待的头部线程,当该线程执行完打印操作后会继续排到等待队列的尾部进行wait,所以我们能够看到一个轮询周转的现象:
如上我们使用的是 pthread_cond_signal 一次唤醒一个线程,如果我们想让一次唤醒多个线程,可以使用 pthread_cond_broadcast 函数,此时我们每一次唤醒都会将所有在该条件变量下等待的线程进行唤醒,也就是每次都将这三个线程唤醒:
我们也可以对上述代码进行加工,使线程执行某种具体的方法,示例如下:
#include
#include #include #include #include #include using namespace std;// 定义一个条件变量 pthread_cond_t cond; // 定义一个互斥锁 pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; // 当前不用,但是接口需要 // 定义全局的容器 vector > funcs;void show() {cout << "hello world" << endl; } void print() {cout << "hello print" << endl; }void *waitCommand(void *args) {const char *name = static_cast (args);pthread_detach(pthread_self());while (true){// 只要执行了下面的代码, 证明某一种条件不就绪(现在还没有场景),要我这个线程等待// 三个线程,都会在条件变量下进行排队pthread_cond_wait(&cond, &mutex); // 让对应的线程进行等待被唤醒for (auto &f : funcs){cout << name << " 执行: ";f();}}cout << name << " end..." << endl;return nullptr; } int main() {funcs.push_back(show);funcs.push_back(print);funcs.push_back([](){ cout << "hello world" << endl; });// 初始化条件变量pthread_cond_init(&cond, nullptr);pthread_t t1, t2, t3;pthread_create(&t1, nullptr, waitCommand, (void *)"thread 1");pthread_create(&t2, nullptr, waitCommand, (void *)"thread 2");pthread_create(&t3, nullptr, waitCommand, (void *)"thread 3");// 让主线程控制着三个线程while (true){char n = 'a';cout << "请输入你的command(n/q): ";cin >> n;if (n == 'n'){pthread_cond_signal(&cond); // 唤醒一个线程// pthread_cond_broadcast(&cond); // 唤醒所有线程}elsebreak;sleep(1);}cout << "main thread quit" << endl;// 销毁条件变量pthread_cond_destroy(&cond);return 0; }
- 条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程。
- 条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化,所以一定要用互斥锁来保护,没有互斥锁就无法安全的获取和修改共享数据。
- 当线程进入临界区时需要先加锁,然后判断内部资源的情况,若不满足当前线程的执行条件,则需要在该条件变量下进行等待,但此时该线程是拿着锁被挂起的,也就意味着这个锁再也不会被释放了,此时就会发生死锁问题。
- 所以在调用pthread_cond_wait函数时,还需要将对应的互斥锁传入,此时当线程因为某些条件不满足需要在该条件变量下进行等待时,就会自动释放该互斥锁。
- 当该线程被唤醒时,该线程会接着执行临界区内的代码,此时便要求该线程必须立马获得对应的互斥锁,因此当某一个线程被唤醒时,实际会自动获得对应的互斥锁。
总结:
- 等待的时候往往是在临界区内等待的,当该线程进入等待的时候,互斥锁会自动释放,而当该线程被唤醒时,又会自动获得对应的互斥锁。
- 条件变量需要配合互斥锁使用,其中条件变量是用来完成同步的,而互斥锁是用来完成互斥的。
- pthread_cond_wait函数有两个功能,一就是让线程在特定的条件变量下等待,二就是让线程释放对应的互斥锁。
错误的设计:
- 按照上面的说法,我们设计出如下的代码:先上锁,发现条件不满足,解锁,然后等待在条件变量上不就行了,如下代码:
//错误的设计 pthread_mutex_lock(&mutex); while (condition_is_false){pthread_mutex_unlock(&mutex);//解锁之后,等待之前,条件可能已经满足,信号已经发出,但是该信号可能被错过pthread_cond_wait(&cond);pthread_mutex_lock(&mutex); } pthread_mutex_unlock(&mutex);
- 但这是不可行的,因为解锁和等待不是原子操作。调用解锁之后, pthread_cond_wait 之前,如果已经有其他线程获取到互斥量,摒弃条件满足,发送了信号,那么 pthread_cond_wait 将错过这个信号,可能会导致线程永远阻塞在这个 pthread_cond_wait 。所以解锁和等待必须是一个原子操作。
- 而实际进入pthread_cond_wait函数后,会先去判断条件量是否等于0。若等于0则说明不满足,此时会先将对应的互斥锁解锁,就把互斥量变成1,直到pthread_cond_wait函数返回,并把条件量改成1,把互斥量恢复成原样。
等待条件变量的代码:
pthread_mutex_lock(&mutex); while (条件为假)pthread_cond_wait(&cond, &mutex); 修改条件 pthread_mutex_unlock(&mutex);
唤醒等待线程的代码:
pthread_mutex_lock(&mutex); 设置条件为真 pthread_cond_signal(&cond); pthread_mutex_unlock(&mutex);