目录
引入
概念
首先,我们先回顾一下今天会用到的概念:
临界资源
多线程执行流共享的资源临界区
每个线程内部,访问临界资源的代码互斥
- 任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源
- 通常对临界资源起保护作用
原子性
- 不会被任何调度机制打断的操作
- 该操作只有两态,要么完成,要么未完成
重入函数
同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入可重入函数
- 一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题
- 否则,是不可重入函数
访问未加保护的临界资源 示例
- 有时候我们的代码看似没有问题
- 但如果放在多线程共同执行的情况下,就可能因为未加保护的临界资源 + 线程间的时序问题,而导致未知的问题
- 比如下面这份类似抢票的代码:
int count = 1000; void *func5(void *args) { while (true) { if (count >= 0) { usleep(1000); //模拟可能花费的时间 cout << count << endl; --count; } } return nullptr; } void test5() { pthread_t tid; vector<pthread_t> tids; for (size_t i = 0; i < 3; ++i) { pthread_create(&tid, nullptr, func5, nullptr); tids.push_back(tid); } for (auto it : tids) { pthread_join(it, nullptr); } }
看到执行结果,我们会发现,票数怎么会被抢到负数呢?
我们的代码明明只有当count>=0时才会执行-1啊
原因
- 原因就在于count是一个全局变量,可以被所有线程共享,而使用这个临界资源的函数是重入函数
- 但我们并没有对其进行访问控制:
- 我们的多个线程同时运行时,会受到调度器的调度,不断被切换
- 这可能就会因时序问题而导致不受约束的临界资源产生数据紊乱
比如说这里的代码,我们会有多种可能导致出现问题
- 首先我们要明确,判断也是一种运算
- 其次,运算操作一般有三个步骤: 将内存内的数据load进cpu的寄存器,在寄存器内运算(分为逻辑运算和数值运算),将结果写回内存
- 也就是说,判断和运算都不具有原子性
以下是可能会出现的问题:
if判断
- 如果每个线程的时间片很短,就可能让线程在[进行判断的三个步骤的过程中] / [改变变量的数值前]被替换
- 也就是说,会让多个线程拿着同样的值,同时进入if为true的语句中
- 那么就会让多个线程对count直接进行修改,而不需要判断
- 自然也就会出现count为负数的情况
--操作
- 如果A进程在运算,他原本应该完成图中三个操作:
- 但在cpu完成运算时,时间片到了,那么count此时的值(比如999)就会作为该线程的上下文被保存起来
- 然后新的B线程来执行一系列操作 (比如已经将count减为400)
- B线程的时间片到了后,又将A线程重新换入
- 它会从原先执行位置继续执行,这样就会让原本已经修改的count值,重新从999开始
- 这样就会出现数据紊乱的问题
当然,这些问题都只是可能出现
总之,我们如果不对临界资源进行保护,就可能出现各种未知问题
解决
如何进行保护呢?
- 就是让临界区代码在有线程执行时,不允许其他线程继续进入
如何实现这一点呢?
- 可以让执行临界区代码的线程,在执行前为它加上一把"锁",让其他线程进不来
- 只有当自己完成任务后,解开锁,其他线程才能执行
在linux下,"锁"这个概念被称为"互斥量"
互斥量
介绍
是一种实现互斥的机制,通过[对共享资源的访问进行控制]来实现互斥
基本思想
- 每当一个线程希望访问共享资源时,它必须先获得互斥量的锁
- 如果某个线程已经获得了锁,其他线程就无法同时获得锁,而必须等待当前拥有锁的线程释放锁
- 这样可以确保在任何时刻只有一个线程可以访问共享资源,从而防止数据的不一致性
其中,pthread库中,就为我们提供了互斥量的相关接口
申请互斥量
介绍
我们有两种方式来申请互斥量:
- 动态申请(局部) -- 使用函数接口,定义一个局部锁
- 静态申请(全局) -- 使用宏定义,定义一个全局锁
pthread_mutex_init()
用于初始化互斥量(也就是我们申请的锁)
函数原型
pthread_mutex_t类型
它是互斥量的类型
实质上就是一个联合体,里面定义了互斥量相关的数据
mutex
一个要被初始化的互斥量指针
attr
互斥量属性
一般我们设置为nullptr即可,表示使用默认属性
pthread_mutex_destroy()
注意,使用了init的同时,我们必须要及时销毁互斥量
示例
这里我们将锁在主线程申请并释放:
void *func2(void *args) { pthread_mutex_t mux = *((pthread_mutex_t *)args); while (true) { if (count >= 0) { usleep(1000); // 模拟可能花费的时间 --count; cout << count << endl; } } return nullptr; } void test1() { pthread_mutex_t mux; pthread_mutex_init(&mux, nullptr); pthread_t tid; vector<pthread_t> tids; for (size_t i = 0; i < 3; ++i) { pthread_create(&tid, nullptr, func1, (void *)&mux); tids.push_back(tid); } for (auto it : tids) { pthread_join(it, nullptr); } pthread_mutex_destroy(&mux); }
PTHREAD_MUTEX_INITIALIZER
介绍
这是一个宏定义:
它会创建一个静态初始化的互斥量,避免了显式调用函数接口的需要,同时也不需要手动销毁
示例
pthread_mutex_t g_mux = PTHREAD_MUTEX_INITIALIZER;
加锁和解锁
介绍
锁的实际操作就两种,加锁和解锁
返回值
- 这三个接口都是成功,返回0
- 失败,返回错误码
pthread_mutex_lock()
介绍
用于上锁
如果互斥量已被其他线程锁定,则当前线程将被阻塞,直到互斥量可用
pthread_mutex_unlock()
介绍
用于解锁,释放互斥量,使其它线程可以获得锁
介绍完上锁和解锁,就可以修改上面的抢票代码了
示例
当我们为上面的抢票代码加上互斥量之后:
int count = 1000; struct data //为了将自定义的线程名和互斥量都传入线程 { data(pthread_mutex_t *px, const string &n) : pmux(px), name(n) { } pthread_mutex_t *pmux; string name; }; pthread_mutex_t g_mux = PTHREAD_MUTEX_INITIALIZER; void *func2(void *args) { data *info = (data *)args; while (true) { int n = pthread_mutex_lock(info->pmux); // 从if就开始访问count了 assert(n == 0); if (count > 0) { usleep(1000); // 模拟可能花费的时间 cout << info->name << " : " << count << endl; --count; n = pthread_mutex_unlock(info->pmux); assert(n == 0); } else { n = pthread_mutex_unlock(info->pmux); // 注意,一定要保证上锁后会解锁 assert(n == 0); break; } usleep(1000); //模拟抢票成功后的花费时间(为了尽量让不同线程去抢票) } return nullptr; } void test1() { pthread_mutex_t mux; pthread_mutex_init(&mux, nullptr); pthread_t tid; vector<pthread_t> tids; for (size_t i = 0; i < 3; ++i) { string t = "thread"; t += to_string(i + 1); data *pd = new data(&mux, t); pthread_create(&tid, nullptr, func2, (void *)pd); tids.push_back(tid); } for (auto it : tids) { pthread_join(it, nullptr); } pthread_mutex_destroy(&mux); }
- 这是结果:
- 可以看到,我们成功让最后的结果以1结束,而不是0或者负数了
- 说明,我们对其增加的保护机制是有效的
代码中的注意点
临界区的粒度 -- 串行执行
当我们加锁后,会形成下面的情况:
- 也就是说,在加锁区域内,线程是串行执行的
- 只有一个线程可以执行代码,其他线程申请锁失败后,会阻塞等待
- 所以,为了效率,我们一定要保证加锁区域足够小(也就是临界区的粒度)
线程调度 -- 同一个线程不断执行
- 注意,这里我们增加了usleep来模拟[让抢票成功后的线程去执行代码]所耗费的时间
- 否则,就会出现下面这种情况:
- (同一线程会在抢票成功后,会马上循环进来,继续竞争到锁)
线程数量和效率的关系
我们可以通过计算执行任务所用时间,来判断效率
- time() -- 粗略计算(以s为单位)
- gettimeofday() -- 更加精确(以微秒或纳秒为单位)
示例
int count = 10000; //我们这里使用10000的数据量,可以更好的观察时间的差异 struct data { data(pthread_mutex_t *px, const string &n) : pmux(px), name(n) { } pthread_mutex_t *pmux; string name; }; pthread_mutex_t g_mux = PTHREAD_MUTEX_INITIALIZER; void *func2(void *args) { data *info = (data *)args; while (true) { int n = pthread_mutex_lock(info->pmux); // 从if就开始访问count了 assert(n == 0); if (count > 0) { usleep(1000); // 模拟可能花费的时间 cout << info->name << " : " << count << endl; --count; n = pthread_mutex_unlock(info->pmux); assert(n == 0); } else { n = pthread_mutex_unlock(info->pmux); // 注意,一定要保证上锁后可以解锁 assert(n == 0); break; } usleep(1000); // 模拟抢票成功后的花费时间(为了尽量让不同线程去抢票) } return nullptr; } #define thread_num 2 void test1() { time_t start, end; start = time(nullptr); pthread_mutex_t mux; pthread_mutex_init(&mux, nullptr); pthread_t tid; vector<pthread_t> tids; for (size_t i = 0; i < thread_num; ++i) { string t = "thread"; t += to_string(i + 1); data *pd = new data(&mux, t); pthread_create(&tid, nullptr, func2, (void *)pd); tids.push_back(tid); } for (auto it : tids) { pthread_join(it, nullptr); } pthread_mutex_destroy(&mux); end = time(nullptr); cout << "time : " << (end - start) << "s" << endl; }
2个线程:
3个线程:
6个线程:
- 所以,我们可以得出结论 -- 线程确实可以提高程序效率
- 但并不是线程数越多越多,过多的线程数会使线程切换的成本增大
除此之外,我们还有一个接口,可以实现不阻塞等待锁的释放:
pthread_mutex_trylock()
- 和lock类似,都可以加锁
- 不同的是,如果互斥量已被其他线程锁定,则立即返回错误码,而不是阻塞
- 这样可以更灵活的让线程执行任务
- 在临界区上锁时,不需要阻塞等待,而是可以去先完成其他代码
互斥量保护临界区的原理 -- 原子性
- 通过上面的代码,我们确实看到加了锁之后,打出来的结果是正确的
为什么加了锁就正确了?
- 因为每个线程进来,在进入临界区之前,都需要先申请锁
- 但是,锁只能被一个线程拥有,所以其他没有竞争到锁的线程就只能等待
- 那么对于其他线程来说,它被调度的情况就是 -- 没有线程持有锁时,他们可以自由被调度 / 有线程持有锁,它们只能等待那个线程执行完成 / 线程释放了锁,说明任务被执行完了,它又可以被调度了
- 所以在有线程持有锁时,只能等待那一个线程完成任务后再执行,也就不会出现前面说的那一大堆可能出现的问题了
- 也就不会出现数据紊乱的情况
为什么在持有锁期间,其他进程只能等待?
- 难道调度器不切换线程了吗?难道时间片不起作用了吗?
- 当然不是!当然可能会出现线程切换
- 但是,切换也是那个线程在持有锁的情况下切换的
- 那个锁,是作为线程的上下文被带走的
- 其他线程要进入临界区,必须要申请锁,但是锁已经被带走了
- 所以他们会被阻塞
- 阻塞了自然也就被调度器切换了
- 所以最终,还是会让那个持有锁的线程,顺畅的完成它的任务
只针对[其他线程看到的临界资源的完成情况]是怎样的呢?
- 没有线程持有锁,资源没有被访问 / 有线程持有锁,资源正在被访问,但是它无法去访问,自然也就与它无关 / 锁被释放,资源已经访问完毕
- 所以,可以认为,被锁保护的区域,其执行是具有原子性的
- 因为它无法被打扰,也就没有中间状态,它要么没有执行,要么已经执行完了!
- (当然,前提是我们正确的使用了互斥量)
lock和unlock的底层汇编
引入
- 我们上面虽然讨论出,临界区代码的执行是具有原子性的
- 那么,上锁和解锁的那两个函数呢?
- 如果他们不具有原子性,是不是会出现多个线程都持有锁的情况呢?
- 确实是这样的
- 所以,互斥量被设计时,就考虑到了这个问题,它的核心指令必然是原子的
swap/exchange指令
- 为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令
- 该指令的作用是把寄存器和内存单元的数据相交换
- 由于只有一条指令,保证了其原子性
lock介绍
下面是两个函数的伪汇编:
mutex
- 注意,这里的mutex可以认为是互斥量在内存中开辟的空间
- 是一个整型,初始值为1
过程
- move为al寄存器填充0
- xchge和上面介绍的exchange有异曲同工之妙,它只用了一句汇编,就让al寄存器和内存中的mutex交换了值 (也就是将自己的私有数据和内存数据交换)
- 如果此时寄存器中的值不是1,则说明该线程持有锁,成功返回
- 是0,说明已经有线程持有锁了,该线程被挂起等待
- 当线程等待完成后,会回到lock的开头,从move重新执行
图解
这就是第一句指令执行后的样子:
第二句执行后:
如果中间被切换
- 即使被切换,寄存器中的数据也是作为线程的上下文(线程的私有数据)被带走了
- 无论在哪个地方被切换,都不会影响持有锁的操作,因为它是原子的
- 从图中,我们可以知道,第二句汇编就是lock的核心指令,谁拿到了mutex的初始值1,谁就拿到了锁
当某个线程已经持有锁
- 那么mutex中的值,就是和某个线程交换后的0
- 当执行xchge时,就是0和0交换,al寄存器中的值是0,会被挂起等待
unlock介绍
过程
- move将mutex中的值直接赋为1
- 唤醒其他线程
- 当其他线程被唤醒后,会从lock的第一句开始执行
- 那么就会将寄存器中的0和mutex中新的1交换
- 这样就得到了新锁
那原先线程的旧锁呢?
- 原先线程中的al寄存器,应该还是1吧
会不会有影响呢?
- 不会的
- 因为如果该线程还需要访问那块临界区的话,是从lock的第一句开始执行
- 也就是将al寄存器的值赋为0
- 如果不被切换,就以0继续执行
- 如果中间被切换,这个0会替换掉原来的1
可重入函数和线程安全
概念
线程安全
- 一个线程安全的系统可以由多个线程同时访问,而不会导致数据损坏或不一致
- 一般的,我们对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题
可重入函数
- 重入和可重入的概念在开头已经介绍过了
- 简单来说,可重入函数就是 -- 一个函数被不同的执行流调用时,不会出现任何问题
关系
- 线程安全针对于一个线程,可重入针对一个函数
- 可重入函数一定是线程安全的,可以认为可重入函数是线程安全函数的一种
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
- 可重入的目标,是确保单个函数的安全执行,而不受外部影响
- 线程安全的目标,是确保整个系统或库在多线程环境下的安全运行,包括多个函数的相互协作和对共享资源的访问
- 如果对临界资源的访问加上锁,则这个函数是线程安全的,但这个重入函数不一定是可重入的(比如出现死锁问题)