线程安全概念
在多个执行流中对一个临界资源进行操作访问,而不会造成数据二义性
如何实现线程安全: 同步与互斥
- 互斥:通过保证同一时间只有一个执行流可以对临界资源进行访问(一个执行流访问期间,其他执行流不能访问),来保证数据访问的安全性
- 同步:通过一些条件判断来实现多个执行流对临界资源访问的合理性(有资源则访问,没有资源则等着,等有资源了再被唤醒)
线程间的互斥
概念
- 临界资源: 多线程执行流共享的资源就叫做临界资源
- 临界区: 每个线程内部,访问临界资源的代码,就叫做临界区
- 互斥: 任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
- 原子性: 不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
互斥量mutex
- 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。
- 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。
- 多个线程并发的操作共享变量,会带来一些问题。
代码示例:
// 操作共享变量会有问题的售票系统代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
int ticket = 100;
void *route(void *arg)
{
while(1){
if (ticket > 0) {
// 如果有票就一直枪
usleep(1000);
printf("sells ticket:%d\n", ticket);
ticket--;
}
else {
pthread_exit(NULL);
}
}
}
int main(){
pthread_t tid[4];
int i, ret;
for(i = 0; i < 4; i++){
ret = pthread_create(&tid[i], NULL, route, NULL);
if(ret != 0){
printf("thread create error");
return -1;
}
}
for(i = 0; i < 4; i++){
pthread_join(tid[i], NULL);
}
return 0;
}
一次执行结果:
sell ticket:100
sell ticket:99
sell ticket:98
sell ticket:97
...
sell ticket:1
sell ticket:0
sell ticket:-1
sell ticket:-2
为什么可能无法获得争取结果?
- if 语句判断条件为真以后,代码可以并发的切换到其他线程
- usleep 这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段
- ticket-- 操作本身就不是一个原子操作
取出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的内存地址
要解决以上问题,需要做到三点:
- 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量。
互斥的实现:互斥锁
原理:
互斥锁本质是一个0/1计数器,对临界资源当前的访问状态进行标记( 0-不可访问;1-可以访问)
所有执行流在访问临界资源之前先尝试加锁(通过计数器判断当前状态是否能够访问临界资源)
- 如果可以访问,则将状态修改为不可访问状态,然后再让执行流访问临界资源
- 如果不允许访问,则让执行流等待,直到持有锁的线程解锁
对临界资源访问完毕之后进行解锁(将临界资源的访问状态置为可访问,唤醒等待的线程,大家重新开始竞争这个资源)
所有的执行流都需要通过同一个互斥锁实现互斥,意味着:
互斥锁本身就是一个临界资源,但是互斥锁自身计数器的操作是原子操作。
互斥锁的操作流程、接口介绍:
- 定义互斥锁变量
pthread_mutex_t
- 初始化互斥锁变量
pthread_mutex_init(pthread_mutex_t *mutex, pthread_mutexattr_t *attr); // 动态分配
参数:
mutex: 要初始化的互斥量
attr: NULL
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER // 静态分配
- 在临界资源访问之前加锁(不能加锁则等待,可以加锁则修改资源状态,然后调用返回,访问临界资源)
pthread_mutex_lock(pthread_mutex_t *mutex); // 阻塞加锁 - 如果当前不能加锁(锁已经被别人加了),则一直等待直到加锁成功调用返回
返回值: 成功返回0,失败返回非0值 - 错误编码
pthread_mutex_trylock(pthread_mutex_t *mutex); // 非阻塞加锁 - 如果当前不能加锁,则立即报错返回 -EBUSY
- 在临界资源访问完毕后解锁(将资源状态置为可访问,将其他执行流唤醒)
pthread_mutex_unlock(pthread_mutex_t *mutex);
- 销毁互斥锁
pthread_mutex_destroy(pthread_mutex_t *mutex);
销毁互斥量需要注意:
- 使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
- 不要销毁一个已经加锁的互斥量
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁
使用注意事项
- 锁尽量只保护对临界资源的访问操作
- 在任意有可能退出的地方退出前都要解锁
简单案例
// 操作售票系统代码
#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)
{
while(1){
// 加锁一定是只保护临界资源的访问
pthread_mutex_lock(&mutex);
if (ticket > 0) {
// 如果有票就一直枪
usleep(1000);
printf("sell ticket:%d\n", ticket);
ticket--;
pthread_mutex_unlock(&mutex);
}
else {
// 加锁后在任意有可能退出线程的地方都要解锁
pthread_mutex_unlock(&mutex);
pthread_exit(NULL);
}
}
}
int main(){
pthread_t tid[4];
int i, ret;
// 互斥锁的初始化一定要放在线程创建之前
pthread_mutex_init(&mutex, NULL);
for(i = 0; i < 4; i++){
ret = pthread_create(&tid[i], NULL, route, NULL);
if(ret != 0){
printf("thread create error");
return -1;
}
}
for(i = 0; i < 4; i++){
pthread_join(tid[i], NULL);
}
// 互斥锁的销毁一定是不再使用这个互斥锁
pthread_mutex_destroy(&mutex);
return 0;
}
运行结果:
sell ticket:100
sell ticket:99
sell ticket:98
sell ticket:97
sell ticket:96
...
sell ticket:5
sell ticket:4
sell ticket:3
sell ticket:2
sell ticket:1
互斥量(mutex)实现原理探究
- 单纯的 i++ 或者 ++i 都不是原子的,有可能会有数据一致性问题
- 为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性。
- 即使是多处理器平台,访问内存的总线周期也有先后, 一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。
不管当前mutex的状态是什么,反正一步交换之后,其他的线程都是不可访问的;这时候当前线程就可以慢慢判断了
- 先将寄存器中的值置为0
- 直接将寄存器的值域内存空间中的数据进行交换 - 这个交换指令是一步可以完成的(这时候内存中mutex的值就是0了,别人访问肯定发现无法加锁)
- if(%eax == 1) // eax 是一个累加寄存器(如果是1,则pthread_mutex_lock直接返回,访问临界资源;如果是0,则让线程等待)
线程间的同步
条件变量:
向外提供了使线程等待的接口和唤醒线程的接口+pcb的等待队列
注意:
条件变量只提供了使线程等待和唤醒的接口,因此什么时候让线程该等待/唤醒就需要程序员在进程中判断。
同步的实现
通过条件判断保证资源访问的合理性 – 条件变量
- 线程满足获取资源的访问条件才能去访问资源
- 没有资源的时候则需要让线程等待,等待被唤醒(其他线程产生一个资源的时候)
- 等待:将pcb状态置为可中断休眠状态 (表示当前休眠)
- 唤醒:将pcb状态置为运行态(则可以开始调度)
- 其他线程/进程促使条件满足之后,可以唤醒pcb等待队列上的pcb
操作接口介绍
- 定义条件变量
pthread_cond_t;
- 初始化条件变量
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int pthread_cond_init(pthread_cont_t* cond,pthread_condattr_t *attr);
- (访问条件不满足时)使线程挂起休眠的接口:条件变量是搭配互斥锁一起使用(判断条件是否满足的条件本身就是一个临界资源,需要被保护)
pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t mutex); // 一直等待被唤醒
pthread_cond_timedwait(pthread_cond_t *cond, pthread_mutex_t mutex, struct timespec); // 等待指定时间内都没有被唤醒则自动醒来
- (访问条件满足时)唤醒线程的接口:
pthread_cond_signal(pthread_cond_t * cond); // 唤醒至少一个等待的线程(并不是唤醒单个)
pthread_cond_broadcast(pthread_cond_t * cond); // 唤醒所有等待的线程
- 销毁条件变量:
pthread_cond_destory(pthread_cond_t * cond);
注意事项:
- 条件变量需要搭配互斥锁一起使用,pthread_cond_wait 集合了解锁/休眠/被唤醒后加锁的三步操作
- 在程序中对访问条件是否满足的判断需要使用while循环进行判断
- 在同步实现中,多种角色线程应该使用多个条件变量,不要让所有的线程等待在同一个条件变量上
简单案例
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
int bowl = 0; // 默认0表示碗中没有饭
pthread_cond_t cook_cond; // 实现线程间对bowl变量访问的同步操作
pthread_cond_t customer_cond; // 实现线程间对bowl变量访问的同步操作
pthread_mutex_t mutex; // 保护bowl变量的访问操作
void *thr_cook(void *arg){
while(1){
// 加锁
pthread_mutex_lock(&mutex);
while(bowl != 0){ // 表示有饭,不满足做饭条件
// 让厨师线程等待,等待之前先解锁,被唤醒之后再加锁
// pthread_cond_wait 接口中就完成了解锁,休眠,被加锁后加锁三步操作
// 并且解锁和休眠操作是一步完成的,保证原子操作
pthread_cond_wait(&cook_cond, &mutex);
}
bowl = 1; // 能够走下来表示没饭,则做一碗饭,将bowl改为1
printf("I made a bowl of rice!\n");
// 唤醒顾客吃饭
pthread_cond_signal(&customer_cond);
// 解锁
pthread_mutex_unlock(&mutex);
}
return NULL;
}
void *thr_customer(void *arg){
// 顾客现场被唤醒加锁成功后重新判断有没有饭,没有就休眠,有则吃饭
while(1){
// 加锁
pthread_mutex_lock(&mutex);
while(bowl != 1){ // 没有饭,不满足吃饭条件,则等待
// 没有饭则等待,等待前先解锁,被唤醒后加锁
pthread_cond_wait(&customer_cond, &mutex);
}
bowl = 0; // 能够走下来表示有饭,吃完饭,将bowl修改为0
printf("I had a bowl of rice.It was delicious~\n");
// 唤醒厨师做饭
pthread_cond_signal(&cook_cond);
// 解锁
pthread_mutex_unlock(&mutex);
}
return NULL;
}
int main(){
pthread_t cook_tid[4], customer_tid[4];
int i, ret;
pthread_mutex_init(&mutex, NULL);
pthread_cond_init(&cook_cond, NULL);
pthread_cond_init(&customer_cond, NULL);
for(i = 0; i < 4; i++){
ret = pthread_create(&cook_tid[i], NULL, thr_cook, NULL);
if(ret != 0){
printf("pthread_create error!\n");
return -1;
}
ret = pthread_create(&customer_tid[i], NULL, thr_customer, NULL);
if(ret != 0){
printf("pthread_create error!\n");
return -1;
}
}
pthread_join(cook_tid[0], NULL);
pthread_join(customer_tid[0], NULL);
pthread_mutex_destroy(&mutex);
pthread_cond_destroy(&cook_cond);
pthread_cond_destroy(&customer_cond);
return 0;
}
运行结果:
I made a bowl of rice!
I had a bowl of rice.It was delicious~
I made a bowl of rice!
I had a bowl of rice.It was delicious~
...
I made a bowl of rice!
I had a bowl of rice.It was delicious~
I made a bowl of rice!
I had a bowl of rice.It was delicious~
^CI made a bowl of rice!
I had a bowl of rice.It was delicious~
STL,智能指针和线程安全
- STL中的容器都是线程安全的吗?
不是
原因是, STL 的设计初衷是将性能挖掘到极致, 而一旦涉及到加锁保证线程安全, 会对性能造成巨大的影响.
而且对于不同的容器, 加锁方式的不同, 性能可能也不同(例如hash表的锁表和锁桶).
因此 STL 默认不是线程安全. 如果需要在多线程环境下使用, 往往需要调用者自行保证线程安全.
- 智能指针是线程安全的吗?
对于 unique_ptr, 由于只是在当前代码块范围内生效, 因此不涉及线程安全问题.
对于 shared_ptr, 多个对象需要共用一个引用计数变量, 所以会存在线程安全问题. 但是标准库实现的时候考虑到了 这个问题, 基于 原子操作(CAS) 的方式保证 shared_ptr 能够高效, 原子的操作引用计数.
如果本篇博文有帮助到您,请留赞激励博主~~