一、线程间互斥
1 .进程线程间的互斥相关概念
-
临界资源:多线程执行流共享的资源就叫做临界资源
-
临界区:每个线程内部,访问临界资源的代码,就叫做临界区
-
互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
-
原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
临界资源和临界区
- 多个线程之间能够看到的同一份资源被称作临界资源,而专门用来访问临界资源的代码段被称为临界区。保护临界资源本质就是想办法把访问临界资源的代码保护起来。
- 因为多线程的大部分资源都是共享的,因此线程之间进行通信不需再去创建第三方资源。
互斥和原子性
- 如果多个线程同时对临界资源进行操作,就可能导致数据不一致的问题,这些问题可以通过互斥解决。互斥能够保证在任何时刻都只能有一个线程(执行流)进入临界区对临界资源进行访问。
- 原子性指的是不会被任何调度机制打断的操作,对于原子性来说,只有 完成 / 未完成 两种状态,要么不做,要么就做完,没有中间状态。要么执行要么就不执行。
为什么需要互斥,看下面一段代码:
模拟抢票软件系统:
int tickets = 1000;
void* get_ticket(void* args)
{
std::string name = static_cast<const char*>(args);
while (true)
{
if (tickets > 0)
{
usleep(1000); // 模拟抢票花费的时间
std::cout<<name<<" get a tickets :"<<tickets<<std::endl;
--tickets;
}
else
{
break;
}
}
return nullptr;
}
int NUM=4;
int main()
{
pthread_t threads[NUM];
for(int i=0;i<NUM;i++)
{
char* name=new char[128];
snprintf(name,128,"thread - %d",i+1);
pthread_create(threads+i,nullptr,get_ticket,(void*)name);
}
for(auto tid:threads)
{
pthread_join(tid,nullptr);
}
return 0;
}
执行结果:
可以看见,总共10000张票,最后却抢到了负号票。
因为线程是OS调度的基本单位。
- if 语句在判断条件为真 (即 tickets > 0)时,此时代码可以并发的切换到其他线程执行该任务。
- usleep 用于模拟抢票的过程,在这个过程中,可能有很多个线程会进入该代码段。
- --tickets和 if (tickets > 0)本身不是一个原子的操作。
假设此时 tickets 的值为 1,线程 A 刚把 g_tickets 从内存拷贝到寄存器中,正准备判断时,线程 A 时间片到了。必须得切换到其他线程,此时线程 A 就只好带着自己得寄存器中存着的 tickets 离开,此时线程 A 的寄存器中存着的 tickets 的值是 1。
切换到线程 B 之后,线程 B 也要把 g_tickets 从内存拷贝到寄存器中,线程 B 通过 if 判断出 tickets 的值 > 0,然后线程 B 就会将 tickets 在内存中的的值减 1,此时 tickets 的值已经变成 0 了。
等到线程 A 切换回来后,线程 A 的寄存器中 g_tickets 的值还是 1,线程 A 以为 tickets 的值依旧是 1 ,能够通过 if 判断,但不知道线程 B 已经将 tickets 减到 0 了,线程 A 再对 tickets 在内存中的值减 1 时就会将 g_tickets 减到 - 1 去。
然而对 g_tickets 执行判断和自减操作都需要再次将 g_tickets 从内存读取到寄存器中。
在多线程访问时,这种将票数干到负数的情况被称为数据不一致。
OS在对变量进行 -- 操作时,从汇编层面上看,其实有 3 条指令:
- load :将共享变量ticket从内存加载到寄存器中
- update : 更新寄存器里面的值,执行-1操作
- store :将新值,从寄存器写回共享变量ticket的内存地址
解决以上问题,需要做到三点:
- 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临 界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
2 .互斥量
为了搞定这个问题,就需要一把锁,在有线程访问临界区时,用这把锁将临界区锁起来,访问完之后再解锁,这把锁被称为互斥量 mutex
3 . 互斥量的接口
初始化互斥量
初始化互斥量有两种方法:
- 方法1,静态分配:
- 方法2,动态分配:
#include <pthread.h>
int pthread_mutex_init( /* 初始化成功时返回 0,失败时返回错误码 */
pthread_mutex_t *restrict mutex, /* 需要初始化的互斥量 (锁) */
const pthread_mutexattr_t *restrict attr); /* 互斥量 (锁) 的属性,一般设置为 空 即可 */
销毁互斥量
#include <pthread.h>
int pthread_mutex_destroy( /* 销毁成功时返回 0,失败时返回错误码 */
pthread_mutex_t *mutex); /* 要销毁的互斥量 */
销毁互斥量需要注意:
- 使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
- 不要销毁一个已经加锁的互斥量
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁
互斥量加锁和解锁
#include <pthread.h>
int pthread_mutex_lock( /* 上锁成功时返回 0,失败时返回错误码 */
pthread_mutex_t *mutex); /* 需要上锁的互斥量 */
注意:
- 加锁的范围,颗粒的一定要小。尽可能的给少的代码块加锁,如果给一大段的代码加锁,线程之间就变成串行执行了,多线程就没意义了。
- 一般来说,都是给临界区加锁,只需要保护会访问到临界资源的那部分代码即可。
- 谁加的锁,就让谁解锁,最好不要出现线程 A 加锁却让线程 B 解锁的情况。
为互斥量上锁会遇到的情况情况
-
互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
-
发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
互斥量的解锁
#include <pthread.h>
int pthread_mutex_unlock( /* 解锁成功时返回 0,失败时返回错误码 */
pthread_mutex_t *mutex); /* 需要解锁的互斥量 (锁) */
改进上面抢票系统:我们加一把全局互斥锁进去
pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER;//全局互斥量
int tickets = 1000;
void* get_ticket(void* args)
{
std::string name = static_cast<const char*>(args);
while (true)
{
pthread_mutex_lock(&mutex);//锁上
if (tickets > 0)
{
usleep(1000); // 模拟抢票花费的时间
std::cout<<name<<" get a tickets :"<<tickets<<std::endl;
--tickets;
pthread_mutex_unlock(&mutex);//解锁
}
else
{
pthread_mutex_unlock(&mutex);//解锁
break;
}
}
return nullptr;
}
int NUM=4;
int main()
{
pthread_t threads[NUM];
for(int i=0;i<NUM;i++)
{
char* name=new char[128];
snprintf(name,128,"thread - %d",i+1);
pthread_create(threads+i,nullptr,get_ticket,(void*)name);
}
for(auto tid:threads)
{
pthread_join(tid,nullptr);
}
return 0;
}
执行结果:
可以发现,抢票不会抢到负号票了。但是出现了新的问题,就是一个线程抢非常多票,这显然也是不合理的。
4 . 互斥量的实现原理
临界区中的线程也能被线程切换走
锁也是需要被保护的共享资源
如何保证申请锁的过程是原子的
查看一下 lock 和 unlock 的伪代码:
线程 lock 争锁的本质
内存中设 mutex 的初始值为 1,al 是线程独立拥有的一组寄存器中的的一个,每个线程都有这样的一组寄存器(寄存器硬件只有一组,这里指的是数据),当线程申请锁时,需要执行以下步骤:
- 使用 movb 指令将 al 寄存器中的值清零,多个线程可同时执行该动作。
- 使用 xchgb 指令将 al 寄存器和 mutex 中的值互换。
- 判断 al 寄存器中的值是否 > 0,若 > 0 则申请锁成功,此时就能进入临界区访问临界资源了。申请锁失败的线程会被挂起等待,直到锁被释放后再次去竞争申请锁。
线程 unlock 解锁的本质
- 使用 movb 指令将内存中的 mutex 的值重置回 1,让下一个申请锁的线程在执行 xchgb 交换指令后能够得到这个 1。
- 唤醒所有因为申请锁失败而被挂起等待 mutex 的线程,让它们继续去争锁。
二、可重入VS线程安全
1 .概念
- 线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作, 并且没有锁保护的情况下,会出现该问题。
- 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们 称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重 入函数,否则,是不可重入函数。
2 .常见的线程不安全的情况
- 不保护共享变量的函数
- 函数状态随着被调用,状态发生变化的函数
- 返回指向静态变量指针的函数
- 调用线程不安全函数的函数
3 . 常见的线程安全的情况
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
- 类或者接口对于线程来说都是原子操作
- 多个线程之间的切换不会导致该接口的执行结果存在二义性
4 . 常见不可重入的情况
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
- 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
- 可重入函数体内使用了静态的数据结构
5 .常见可重入的情况
- 不使用全局变量或静态变量
- 不使用用malloc或者new开辟出的空间
- 不调用不可重入函数
- 不返回静态或全局数据,所有数据都有函数的调用者提供
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
6 .可重入与线程安全联系
- 函数是可重入的,那就是线程安全的
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
7 .可重入与线程安全区别
- 可重入函数是线程安全函数的一种
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
- 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生
- 死锁,因此是不可重入的。
三、常见锁概念
1 .死锁
- 死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。
当然死锁并不是多线程才能产生,当单线程已经获得锁并且没有释放的时候再次去申请该锁,也会形成等待锁的释放而阻塞的状态。进而形成死锁。
2 .死锁四个必要条件
- 互斥条件:一个资源每次只能被一个执行流使用
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
- 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
3 . 避免死锁
- 破坏死锁的四个必要条件
- 加锁顺序一致
- 避免锁未释放的场景
- 资源一次性分配
四、线程同步
1 .同步概念与竞态条件
- 同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步
- 竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。在线程场景下,这种问题也不难理解
解决:
条件变量
条件变量(Condition Variable)是一种用于线程同步的机制,通常与互斥锁(Mutex)一起使用。条件变量提供了一种线程间的通信机制,允许一个线程等待另一个线程满足某个条件后再继续执行。
- 当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。
- 例如一个线程访问队列时,发现队列为空,它只能等待,只到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量。
故事说明
为什么需要使用条件变量
注意:在使用条件变量时,必须确保与互斥锁一起使用,以避免竞态条件的发生。
条件变量就是一种数据类型
struct cond
{
int flag; // 1. 判断条件变量是否就绪
tcb_queue; // 2. 维护一个线程队列,线程就是在这个队列里排队
};
2 .条件变量函数
pthread_cond_t
pthread_cond_t
是 POSIX 线程库(Pthreads)中用于表示条件变量的数据类型。-
可以使用
pthread_cond_t
类型来定义一个条件变量。 -
所有的条件变量函数的返回值都是调用函数成功时返回 0,失败时返回错误码。
初始化条件变量
- 同初始化互斥量一样,初始化条件变量也有静态初始化和动态初始化两种方式。
静态方式:
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
动态方式:
- 全局的条件变量可以使用 静态 / 动态 的方式初始化。
- 局部的条件变量必须使用 动态 的方式初始化。
#include <pthread.h>
int pthread_cond_init(
pthread_cond_t *restrict cond, /* 需要初始化的条件变量 */
const pthread_condattr_t *restrict attr); /* 条件变量的属性,一般都设置为空 */
销毁条件变量
#include <pthread.h>
int pthread_cond_destroy(pthread_cond_t *cond); // 销毁指定的 cond 条件变量
等待条件满足
#include <pthread.h>
int pthread_cond_wait(
pthread_cond_t *restrict cond, /* 条件变量,指定线程需要去 cond 条件变量处等待 */
pthread_mutex_t *restrict mutex); /* 互斥锁,需要释放当前线程所持有的互斥锁 */
- 哪个线程调用的该函数,就让哪个线程去指定的条件变量处等待,还要将这个线程持有的锁释放,让其他线程能够争夺这把锁。
- 线程在哪调用的这个函数,被唤醒之后就要从这个地方继续向下执行后续代码。
- 当线程被唤醒之后,线程是在临界区被唤醒的,线程要重新参与对 mutex 锁的竞争,线程被唤醒 + 重新持有锁两者加起来线程才真正被唤醒。
唤醒在条件变量处等待的线程
- 唤醒条件变量的方式有 2 种,分别是唤醒全部线程以及唤醒首个线程。
#include <pthread.h>
int pthread_cond_broadcast(pthread_cond_t *cond); // 唤醒在 cond 条件变量队列处等待的 所有 线程
int pthread_cond_signal(pthread_cond_t *cond); // 唤醒在 cond 条件变量队列处等待的 首个 线程
- 虽然该函数说是唤醒了线程,但是其实只是一种伪唤醒,只有当线程被唤醒 + 重新持有锁才是真唤醒。
- 只有被真唤醒的线程才会继续去执行后续代码。
如果在调用pthread_cond_wait之前线程已经收到了条件变量的唤醒通知(通过pthread_cond_signal或pthread_cond_broadcast),那么该通知实际上会被“记住”,直到线程真正进入pthread_cond_wait并准备返回。这是因为条件变量的实现通常包含一个等待队列,用于存储那些正在等待条件变量的线程。当调用pthread_cond_signal或pthread_cond_broadcast时,会唤醒等待队列中的一个或多个线程,但如果没有线程实际在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 。所以解锁和等待必须是一个原子操作。
- int pthread_cond_wait(pthread_cond_ t *cond,pthread_mutex_ t * mutex); 进入该函数后,会去看条件量等于0不?等于,就把互斥量变成1,直到cond_ wait返回,把条件量改成1,把互斥量恢复成原样。
3 .条件变量使用规范
- 等待条件代码
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);