前言
线程的数据处理:
和进程相比,线程的最大优点之一是数据的共享性,各个进程共享父进程处沿袭的数据段,可以方便的获得、修改数据。但这也给多线程编程带来了许多问题。我们必须当心有多个不同的进程访问相同的变量
。许多函数是不可重入的,即同时不能运行一个函数的多个拷贝(除非使用不同的数据段)
。在函数中声明的静态变量常常带来问题,函数的返回值也会有问题。因为如果返回的是函数内部静态声明的空间的地址,则在一个线程调用该函数得到地址后使用该地址指向的数据时,别的线程可能调用此函数并修改了这一段数据(造成结果的二义性
)。在进程中共享的变量必须用关键字volatile(只从内存中取数据)来定义,这是为了防止编译器在优化时(如gcc中使用-OX参数)改变它们的使用方式。为了保护变量(临界资源等),我们必须使用信号量(实现同步)
、访问互斥
等方法来保证我们对变量的正确使用(即实现线程安全
)。
之前我们也都讲过 重入函数、竞态条件、多线程的独有和共享、volatile关键字、原子操作等概念,不在赘述。本篇文章主要讲解互斥来实现对临界资源的访问安全性(即线程安全问题)。
再谈可重入与线程安全
两者概念
线程安全:多个线程并发同一段代码时,不会出现不同的结果(无二义性)。常见问题:对全局变量或者静态变量进行操作,并且没有锁保护的情况下,线程就是不安全的。
重入:同一个函数被不同的执行流(线程)调用,当前一个流程还没有执行完(cpu时间片到了,必须保存程序计数器和上下文信息和数据信息等后让出cpu资源),就有其他的执行流(拿到cpu资源)再次进入此函数,我们称之为重入。一个函数在重入的情况下,运行结果(对数据的操作)不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
常见的线程不安全的情况
- 不保护共享变量的函数
- 函数状态随着被调用,状态发生变化的函数
- 返回指向静态变量指针的函数
- 调用不可重入函数的函数
常见的线程安全的情况
- 每个线程对线程共享的全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
- 类或者接口对于线程来说都是原子操作
- 多个线程之间的切换不会导致该接口的执行结果存在二义性
常见不可重入的情况
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
- 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
- 可重入函数体内使用了静态的数据结构
常见可重入的情况
- 不使用全局变量或静态变量
- 不使用用malloc或者new开辟出的空间
- 不调用不可重入函数
- 不返回静态或全局数据,所有数据都有函数的调用者提供
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
可重入与线程安全联系
- 函数是可重入的,那就是线程安全的
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
可重入与线程安全区别
- 可重入函数是线程安全函数的一种
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
- 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。
。
如何保证线程安全
互斥:保证在同一时刻只能有一个执行流访问临界资源,即任何时刻,互斥保证有且只有一个(线程)执行流进入临界区,访问临界资源,实现对临界资源起保护作用。
同步:保证程序对临界资源的合理访问(即一个执行流完成逻辑后必须及时让出cpu资源,让其他执行流也还是他们的任务逻辑)。
互斥
还是在说说进程线程间的互斥相关概念
临界资源:多线程执行流共享的资源(线程共享)就叫做临界资源
临界区:每个线程内部,访问临界资源的代码,就叫做临界区
原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
访问操作:在临界区对临界资源进行非原子操作
互斥:任何时刻,互斥保证有且只有一个(线程)执行流进入临界区,访问临界资源,实现对临界资源起保护作用。
先模拟一个抢票实现逻辑:
全局共享数据当作票数,共有一百张,四个线程模拟黄牛来抢票倒卖,看看会不会出现我们上面提到过的线程安全问题:
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
#define THREADCOUNT 4 //四个线程
int g_tickes = 100; //一百张票
void* ThreadStart(void* arg)
{
(void)arg;
while(1)
{
if(g_tickes > 0)
{
g_tickes--; //减减操作代表被抢
usleep(100000); //模拟阻塞抢
printf("i am thread [%p], i have ticket num is [%d]\n", pthread_self(), g_tickes + 1);
}
else
{
break;
}
}
return NULL;
}
int main()
{
pthread_t tid[THREADCOUNT];
int i = 0;
for(; i < THREADCOUNT; i++)
{
int ret = pthread_create(&tid[i], NULL, ThreadStart, NULL);
if(ret < 0)
{
perror("pthread_create");
return 0;
}
}
for(i = 0; i < THREADCOUNT; i++)
{
pthread_join(tid[i], NULL);//等待线程退出回收资源
}
return 0;
}
果然,分析上面面结果,四个线程对临界资源的访问并没有按我们想的那样有理有序,因为线程是抢占式执行的,而减减操作也是非原子操作,可能票已经被抢了,但是还没打印,时间片到了,或者刚一减减,还没来的急在内存中更新数据,就再次被其他执行流访问,造成多次访问操作,从而造成程序结果的二义性。
从代码逻辑总结原因
- if 语句判断条件为真以后,代码可以并发的切换到其他线程
- usleep 这个模拟等待的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段
- 减减操作本身就不是一个原子操作,可以被打断
-
- 取出减减操作部分的汇编代码来分析为啥可以被打断:
-
-
- 对应三条汇编指令:
mov :将共享变量g_ticket从内存加载到寄存器中
sub : 更新寄存器里面的值,执行-1操作
mov:将新值,从寄存器写回共享变量g_ticket的内存地址
- 对应三条汇编指令:
-
要解决以上问题,需要做到三点:
- 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
要做到这三点,本质上就是需要一把锁来管理。Linux上提供的这把锁叫互斥量。
互斥锁
互斥锁用来保证一段时间内只有一个线程在执行一段代码。
概念:
互斥锁的底层是互斥量,而互斥量本质上是一个计数器,该计数器只有两个取值0或者1
0:代表无法获取互斥锁,进而表示需要访问的临界资源不可以被访问
1:代表可以获取互斥锁,进而表示需要访问的临界资源可以被访问
加锁与解锁
加锁操作:对于互斥锁当中的互斥量保存的计数器进行减1操作
解锁操作:对于互斥锁当中的互斥量保存的计数器进行加1操作
互斥量实现原理探究
经过上面的例子,我们已经知道到单纯的i++ 或者++i 都不是原子操作的,有可能会有数据一致性问题,但是加锁与解锁必会进行加减操作,所以为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据直接相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。
故加锁和减锁的伪代码大致就是这样
加锁时遇到的两种情况,即伪代码里的if判断语句分析:
有了这些概念后我们可以试试使用互斥锁逻辑来实现互斥
int val= 10; //临界资源
执行流A 内部逻辑:
先加锁-》使用互斥锁提供的加锁的接口,用来获取互斥锁资源
两种情况
1.互斥量计数器的值为1 ,表示可以获取互斥锁,获取互斥锁的接口就正常返回,访问临界资源;
2.互斥量计数器的值为0,表示不可以获取互斥锁,当前获取互斥锁的接口进行阻塞等待
g_ _val-- ; //访问临界资源
再解锁
线程退出
互斥量的接口使用
1.定义互斥锁
pthread_ mutex_ t: 互斥锁变量类型
eg:pthread_ mutex_t lock
2.初始化互斥锁
1.调用函数初始化
int pthread_mutex_init(pthread_mutex_ t* mutex, const pthread_ mutexattr_t* attr);
mutex:互斥锁变量,传参的时候传入互斥锁变量的地址
attr:互斥锁的属性,一般情况下,我们不关心,采用默认的属性,传值为NULL就可以了
2.宏定义初始化
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
pthread_ mutexattr_t 本身是一个结构体类型, PTHREAD_MUTEX_INITIALIZER宏定义是一个结构体的值,使用这种初始方式可以直接填充pthread_ mutexttr_t这个结构体
3.加锁
1.---阻塞加锁操作
int pthread_mutex_ lock(pthread_ mutex_ t* mutex)
mutex:传入互斥锁变量的地址,来进行加锁操作
如果使用该接口进行加锁操作的时候:
如果计数器当中的值为1 ,意味着可以加锁,加锁操作之后,对计数器当中的从1改变成为0
如果计数器当中的值为0,意味着不可以加锁,该接口阻塞等待,执行流就不会往下继续执行了(等待其他执行流互斥量解锁)
2.--- 非阻塞加锁操作
int pthread_mutex_trylock(pthread_ mutex_t* mutex)
mutex:传入互斥锁变量的地址,来进行加锁操作
如果使用该接口进行加锁操作的时候:
如果计数器当中的值为1 ,意味着可以加锁,加锁操作之后,对计数器当中的从1改变成为0
如果计数器当中的值为0,意味着不可以加锁,该接口直接返回,不进行阻塞等待,返回为EBUSY (表示没拿到互斥锁)
注意:一般在使用trylock这样的接口进行加锁操作的时候, 一般要操作在循环加锁的方式,防止
由于不到锁资源,而直接返回。从而变成不加锁来直接访问临界资源,从而可能导致程序产生二义性的结果
3.---带有超时时间的加锁接口
int pthread_mutex_timedlock(pthread_mutex_t* mutex, const struct timespec* abs_timeout)
mutex:互斥锁变量
abs_timeout:加锁超时时间,当加锁的时候,超过加锁的超时时间之后,还没有获取的互斥锁,则报错返回,不在进行阻塞等待,返回ETIMEOUT
struct timespce有两个变量,第一个变量代表秒, 第二个变量代表纳秒
4.解锁
int pthread_mutex_unlock(pthread_mutex_t* mutex)
不管是pthread_mutex_ ock或者pthread_mutex_trylock或者pthread_mutex_ timedlock进行的加锁操作,使用pthread_ mutex_unlock都可以进行解锁操作
5.销毁互斥锁
int pthread_mutex_destroy(pthread_mutex_t* mutex)
互斥锁销毁接口,如果使用互斥锁完成之后,不调用销毁接口,就会造成内存泄漏
另外销毁互斥量需要注意:
- 使用PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
- 不要销毁一个已经加锁的互斥量
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁
这样加锁与解锁就可以实现访问临界资源的互斥,解决多个执行流访问资源的不确定性。
这样我们在来加上互斥锁来完善刚才的抢票程序,但是也要像明确下面几点:
1.在代码当中哪里进行定义,哪里进行初始化,哪里进行销毁
定义:定义为全局变量
初始化:在创建线程之前进行初始化
销毁:在线程退出之后进行销毁
2.在哪里进行加锁
在访问临界资源之前进行加锁操作
3.在哪里进行解锁
在所有有可能退出的地方退出时提前需要解锁(不然你退出了,谁也没法解开锁了,程序可能卡死(死锁))
4.如果加锁了但是没有进行解锁,则会造成所有想要加该互斥锁的执行流陷入阻塞等待(死锁)
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
#define THREADCOUNT 4 //模拟四个线程执行流
int g_tickes = 100;//访问全局变量临界资源
pthread_mutex_t lock; //定义互斥量
void* ThreadStart(void* arg)
{
(void)arg;
while(1)
{
// 1 加锁
pthread_mutex_lock(&lock);
if(g_tickes > 0)
{
g_tickes--;
usleep(100000);//模拟等待让线程睡眠一段时间,就是为了防止一个线程始终占据此函数。
printf("i am thread [%p], i have ticket num is [%d]\n", pthread_self(), g_tickes + 1);
}
else
{
//假设有一个执行流判断了g_tickets之后发现,g_tickets的值是小于等于0的
//则会执行else逻辑,直接就被break跳出while循环
//跳出while循环的执行流还加着互斥锁
//所以在所有有可能退出线程的地方都需要进行解锁操作
pthread_mutex_unlock(&lock);
break;
}
pthread_mutex_unlock(&lock); //用完后解锁
}
return NULL;
}
int main()
{
pthread_mutex_init(&lock, NULL);//初始化互斥锁
pthread_t tid[THREADCOUNT]; //声明线程
int i = 0;
for(; i < THREADCOUNT; i++)
{
int ret = pthread_create(&tid[i], NULL, ThreadStart, NULL);//创建线程
if(ret < 0)
{
perror("pthread_create");
return 0;
}
}
for(i = 0; i < THREADCOUNT; i++)
{
pthread_join(tid[i], NULL); //等待线程退出回收资源
}
pthread_mutex_destroy(&lock);//销毁互斥锁
return 0;
}
死锁
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占有用不会释放的资源而处于的一种永久等待状态(吃着碗里看着锅里)。
使用互斥锁的过程中很有可能会出现死锁:两个线程试图同时占用两个资源,并按不同的次序锁定相应的互斥锁,例如两个线程都需要锁定互斥锁1和互斥锁2,A线程先锁定互斥锁1,B线程先锁定互斥锁2,这时就出现了死锁。此时我们可以使用函数 pthread_mutex_trylock,它是函数pthread_mutex_lock的非阻塞版本,当它发现死锁不可避免时,它会返回相应的信息,程序员可以针对死锁做出相应的处理。另外不同的互斥锁类型对死锁的处理不一样,但最主要的还是要自己在程序设计时注意这一点。
介绍几个gdb调试多线程的命令
1.查看所有线程堆栈信息
thread apply all bt
2.切换到某一个执行流
t [执行流编号]
3.在某一个执行流当中使用bt产看具体该执行流的调用堆栈
f [堆栈编号]跳转到某一个具体的堆栈
4. p [变量名] 查看变量信息
5. bt 查看各级函数调用及参数
死锁四个必要条件
- 互斥条件:一个资源每次只能被一个执行流使用
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放,即吃着碗里的:当前执行流已经占用了一个互斥锁;看着锅里的,还想去申请新的互斥锁
- 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺,即只有当前拿着互斥锁的线程可以释放该互斥锁
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系,即若干个执行流在请求锁资源的情况下,形成了一个闭环
避免死锁
- 破坏死锁的四个必要条件
- 加锁顺序一致
- 避免锁未释放的场景
- 资源一次性分配