线程互斥
进程线程间的互斥相关背景概念
- 临界资源:多线程执⾏流共享的资源就叫做临界资源
- 临界区:每个线程内部,访问临界资源的代码,就叫做临界区
- 互斥:任何时刻,互斥保证有且只有⼀个执⾏流进⼊临界区,访问临界资源,通常对临界资源起
- 保护作⽤
- 原⼦性(后⾯讨论如何实现):不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
⼤部分情况,线程使⽤的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程⽆法获得这种变量。
但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。多个线程并发的操作共享变量,会带来⼀些问题。
cpp
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
int ticket = 100;
void *route(void *arg)
{
char *id = (char *)arg;
while (1)
{
if (ticket > 0)
{
usleep(1000);
printf("%s sells ticket:%d\n", id, ticket);
ticket--;
}
else
{
break;
}
}
return nullptr;
}
int main(void)
{
pthread_t t1, t2, t3, t4;
pthread_create(&t1, NULL, route, (void*)"thread 1");
pthread_create(&t2, NULL, route, (void*)"thread 2");
pthread_create(&t3, NULL, route, (void*)"thread 3");
pthread_create(&t4, NULL, route, (void*)"thread 4");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
}

现象却不是很如意,最终得出的余票数量会存在负数甚至不变的情况,这是错误的,那为什么会出现这样错误的情况?
- if 语句判断条件为真以后,代码可以并发的切换到其他线程(线程切换会保留自己的上下文数据当假设我们的ticket从内存加载到寄存器中进行减1操作并没有写回内存此时进程切换再次加载ticket到新进程的寄存器此时的值还是100这就造成了数据不一致问题)
- usleep 这个模拟漫⻓业务的过程,在这个漫⻓的业务过程中,可能有很多个线程会进⼊该代码段
- --ticket 操作本⾝就不是⼀个原⼦操作我们可以看一下--ticket的反汇编代码(原子性:其本质是保证一个操作或一组操作在多线程环境下 "不可分割"------ 要么完全执行,要么完全不执行)
cpp
取出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:静态分配:
cpp
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
- ⽅法2,动态分配:
cpp
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const
pthread_mutexattr_t *restrict attr);
参数:
mutex:要初始化的互斥量
attr:NULL
销毁互斥量:
销毁互斥量组要注意的是:
- 使⽤ PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
- 不要销毁⼀个已经加锁的互斥量
- 已经销毁的互斥量,要确保后⾯不会有线程再尝试加锁
cpp
int pthread_mutex_destroy(pthread_mutex_t *mutex);
互斥量加锁和解锁
cpp
int pthread_mutex_lock(pthread_mutex_t *mutex);//加锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);//解锁
返回值:成功返回0,失败返回错误号
加锁和解锁我们需要知道的是:
- 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
- 发起函数调⽤时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调⽤会陷⼊阻塞(执⾏流被挂起),等待互斥量解锁。
改进抢票系统:
cpp
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
int ticket = 100;
pthread_mutex_t mutex;
void *route(void *arg)
{
char *id = (char *)arg;
while (1)
{
pthread_mutex_lock(&mutex);
if (ticket > 0)
{
usleep(1000);
printf("%s sells ticket:%d\n", id, ticket);
ticket--;
pthread_mutex_unlock(&mutex);
}
else
{
pthread_mutex_unlock(&mutex);
break;
}
}
return nullptr;
}
int main(void)
{
pthread_mutex_init(&mutex, NULL);
pthread_t t1, t2, t3, t4;
pthread_create(&t1, NULL, route, (void *)"thread 1");
pthread_create(&t2, NULL, route, (void *)"thread 2");
pthread_create(&t3, NULL, route, (void *)"thread 3");
pthread_create(&t4, NULL, route, (void *)"thread 4");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
pthread_mutex_destroy(&mutex);
}
锁概念
死锁
死锁是指在⼀组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占⽤不会释放的资源⽽处于的⼀种永久等待状态。
两个线程A和B,对于线程A来说,它现在持有一把锁lock1,对于线程B来说它持有一把锁lock2,但此时这两个线程都无法进入对应的代码块中,此时线程A要访问这块资源,发现条件不足,少一把锁,于是被挂起,线程B要访问这块资源,发现条件不足,少一把锁,于是被挂起,基于这样的原因,这两个线程在之后的每一次被调度都会被阻塞,这就是一个最基本的死锁问题
死锁四个必要条件
- 互斥条件:⼀个资源每次只能被⼀个执⾏流使⽤
- 请求与保持条件:⼀个执⾏流因请求资源⽽阻塞时,对已获得的资源保持不放
- 不剥夺条件:⼀个执⾏流已获得的资源,在末使⽤完之前,不能强⾏剥夺
- 循环等待条件:若⼲执⾏流之间形成⼀种头尾相接的循环等待资源的关系
对比上述的四个条件,再次回到刚才的这个死锁场景
互斥条件:代码中间的这部分资源只能被线程A和B当中的一个进行访问,构成了互斥条件
请求和保持:线程A和B发生阻塞的时候,不会对于当前它们所持有的锁进行释放,而是占用了现有的这个锁,直到下次被调度
不剥夺条件:线程A不能去把线程B当前所持有的锁剥夺下来供自己使用
循环等待:线程A和B交替对于锁的申请判断的逻辑构成了一个环的概念
线程同步
条件变量
- 当⼀个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。
- 例如⼀个线程访问队列时,发现队列为空,它只能等待,只到其它线程将⼀个节点添加到队列中。这种情况就需要⽤到条件变量。
同步概念与竞态条件
- 同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从⽽有效避免饥饿问题,叫做同步(饥饿问题:某个线程 / 进程长期无法获得所需的资源)
- 竞态条件:**多个线程对共享资源的访问顺序不可控,导致程序输出结果依赖于线程的执行时序,**在线程场景下,这种问题也不难理解
条件变量函数:
初始化:
cpp
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t
*restrict attr);
参数:
cond:要初始化的条件变量
attr:NULL
销毁:
cpp
int pthread_cond_destroy(pthread_cond_t *cond)
等待条件满⾜
cpp
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict
mutex);
参数:
cond:要在这个条件变量上等待
mutex:互斥量,后⾯详细解释
唤醒等待
cpp
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
如上四个条件变量函数我们发现:等待条件满⾜pthread_cond_wait还需要传参互斥量
- 解决 "条件检查 + 进入等待" 的竞态条件,避免信号丢失;
- 保护条件变量关联的共享数据,防止数据竞争;
- 保证条件变量等待队列的线程安全。
⽣产者消费者模型
为何要使⽤⽣产者消费者模型
⽣产者消费者模式就是通过⼀个容器来解决⽣产者和消费者的强耦合问题。⽣产者和消费者彼此之间不直接通讯,⽽通过阻塞队列来进⾏通讯,所以⽣产者⽣产完数据之后不⽤等待消费者处理,直接扔给阻塞队列,消费者不找⽣产者要数据,⽽是直接从阻塞队列⾥取,阻塞队列就相当于⼀个缓冲区,平衡了⽣产者和消费者的处理能⼒。这个阻塞队列就是⽤来给⽣产者和消费者解耦的。
要实现生产者消费者模式。首先要保证:
- 三个关系:生产者和生产者(竞争关系,互斥关系)、消费者和消费者(竞争关系,互斥关系)、生产者和消费者(互斥关系(保证数据的正确性),同步关系(保证多线程协调))。
- 两种角色:生产者和消费者(特定的进程或线程)。
- 一个交易场所:通常指内存的一段缓冲区。
C++ queue模拟阻塞队列的⽣产消费模型
POSIX信号量
POSIX信号量和SystemV信号量作⽤相同,都是⽤于同步操作,达到⽆冲突的访问共享资源⽬的。但POSIX可以⽤于线程间同步。
cpp
初始化信号量
#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);
参数:
pshared:0表⽰线程间共享,⾮零表⽰进程间共享
value:信号量初始值
销毁信号量
int sem_destroy(sem_t *sem);
等待信号量
功能:等待信号量,会将信号量的值减1
int sem_wait(sem_t *sem); //P()
发布信号量
功能:发布信号量,表⽰资源使⽤完毕,可以归还资源了。将信号量值加1。
int sem_post(sem_t *sem);//V()
上⼀节⽣产者-消费者的例⼦是基于queue的,其空间可以动态分配,现在基于固定⼤⼩的环形队列重写这个程序(POSIX信号量)
基于环形队列的生产者消费者模式
线程安全和重⼊问题
线程安全:就是多个线程在访问共享资源时,能够正确地执⾏,不会相互⼲扰或破坏彼此的执⾏结果。 ⼀般⽽⾔,多个线程并发同⼀段只有局部变量的代码时,不会出现不同的结果。但是对全局变量或者静态变量进⾏操作,并且没有锁保护的情况下,容易出现该问题。
重⼊: 同⼀个函数被不同的执⾏流调⽤,当前⼀个流程还没有执⾏完,就有其他的执⾏流再次进⼊,我们称之为重⼊。⼀个函数在重⼊的情况下,运⾏结果不会出现任何不同或者任何问题,则该函数被称为可重⼊函数,否则,是不可重⼊函数。
可重⼊与线程安全联系
- 函数是可重⼊的,那就是线程安全的(其实知道这⼀句话就够了)
- 函数是不可重⼊的,那就不能由多个线程使⽤,有可能引发线程安全问题
- 如果⼀个函数中有全局变量,那么这个函数既不是线程安全也不是可重⼊的。
可重⼊与线程安全区别
- 可重⼊函数是线程安全函数的⼀种
- 线程安全不⼀定是可重⼊的,⽽可重⼊函数则⼀定是线程安全的。
- 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重⼊函数若锁还未释放则会产⽣死锁,因此是不可重⼊的。
注意:
- 如果不考虑 信号导致⼀个执⾏流重复进⼊函数 这种重⼊情况,线程安全和重⼊在安全⻆度不做区分
- 但是线程安全侧重说明线程访问公共资源的安全情况,表现的是并发线程的特点
- 可重⼊描述的是⼀个函数是否能被重复进⼊,表⽰的是函数的特点
