线程互斥
进程线程间的互斥相关概念
- 临界资源:
被多个执行流同时访问的公共资源叫做临界资源
- 临界区:
每个线程内部,访问临界资源的代码,就叫做临界区
- 互斥:
任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
- 原子性:
不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
理解一下临界区和临界资源
进程之间要通信需要先创建第三方资源,然后让2个进程看到同1份资源。进程间通信中的第三方资源就叫做临界资源,访问第三方资源的代码就叫做临界区。多线程是共享资源的,不需要像进程那么麻烦。
举个例子: 定义1个全局变量,让新线程每隔1秒++一次,然后2个线程一起访问。
1 #include<stdio.h>
2 #include<unistd.h>
3 #include<pthread.h>
4 int d = 0;
5 void* route(void* arg)
6 {
7 while(1)
8 {
9 d++;
10 sleep(1);
11 printf("newthread: %d\n",d);
12 }
13 pthread_exit((void*)0);
14 }
15
16 int main()
17 {
18 pthread_t tid;
19 pthread_create(&tid,NULL,route,NULL);
20
21 while(1)
22 {
23 printf("n:%d\n",d);
24 sleep(1);
25 }
26 pthread_join(tid,NULL);
27 return 0;
28 }
运行结果如下:
d是共同访问的资源就是临界资源,访问临界资源的代码叫临界区。
下面来理解互斥和原子性例如:我们实现1个售票系统看看会出现怎么样的情况?
创建4个线程来买票,票卖完线程退出。
1 #include<stdio.h>
2 #include<unistd.h>
3 #include<stdlib.h>
4 #include<pthread.h>
5 int ticket = 1000;
6 void* route(void* arg){
7 int id = (int)arg;
8 while(1){
9 if(ticket > 0){
10 usleep(1000);
11 printf("thread:%d sells ticket:%d\n",id,ticket);
12 ticket--;
13 }
14 else {
15 break;
16 }
17 }
18 }
19
20
21 int main()
22 {
23
24 pthread_t tid[4];
25
26 int i = 0;
27 for(;i < 4;++i){
28 pthread_create(tid+i,NULL,route,(void*)i);
29 }
30
31 for(i = 0;i < 4;++i){
32 pthread_join(tid[i],NULL);
33 }
34 return 0;
35 }
运行的情况如下:
票出现了负数的情况。为什么呢?
1.if 语句判断条件为真以后,代码可以并发的切换到其他线程
2.usleep 这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段
3.--ticket 操作本身就不是一个原子操作
--ticket为什么不是原子的?
互斥量mutex
- 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。
- 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。
- 多个线程并发的操作共享变量,会带来一些问题。
解决上面的问题需要一下几点:
1.代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
2.如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
3.如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量。
如下图:
互斥量接口
初始化互斥量:
初始化互斥量有2种方法:
1.静态分配:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
2.动态分配:
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
参数说明:
mutex:要初始化的互斥量
attr:NULL
互斥量加锁:
int pthread_mutex_lock(pthread_mutex_t *mutex);
参数说明:
mutex:需要加锁的互斥量
返回值:
成功返回0,失败返回错误码
注意:调用 pthread_ lock
时,可能会遇到以下情况:
1.互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
2.发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
互斥量解锁:
int pthread_mutex_unlock(pthread_mutex_t *mutex);
参数说明:
mutex:需要解锁的互斥量
返回值:
成功返回0,失败返回错误码
销毁互斥量:
int pthread_mutex_destroy(pthread_mutex_t *mutex);
注意:
- 使用
PTHREAD_ MUTEX_ INITIALIZER
初始化的互斥量不需要销毁 - 不要销毁一个
已经加锁
的互斥量 - 已经销毁的互斥量,要确保后面
不会有线程再尝试加锁
那么我们用锁来改进上面的卖票。
加锁的步骤如下:
1.现将临界区加锁
2.1次由1个线程访问临界区
3.这个线程在解锁,让别的线程来执行
1 #include<stdio.h>
2 #include<unistd.h>
3 #include<stdlib.h>
4 #include<pthread.h>
5 int ticket = 1000;
6 pthread_mutex_t mutex;
7 void* route(void* arg){
8 int id = (int)arg;
9 while(1){
10 pthread_mutex_lock(&mutex);
11 if(ticket > 0){
12 usleep(1000);
13 printf("thread:%d sells ticket:%d\n",id,ticket);
14 ticket--;
15 pthread_mutex_unlock(&mutex);
16 }
17 else {
18 pthread_mutex_unlock(&mutex);
19 break;
20 }
21 }
22 }
23
24
25 int main()
26 {
27
28 pthread_t tid[4];
29 pthread_mutex_init(&mutex,NULL);
30 int i = 0;
31 for(;i < 4;++i){
32 pthread_create(tid+i,NULL,route,(void*)i);
33 }
34
35 for(i = 0;i < 4;++i){
36 pthread_join(tid[i],NULL);
37 }
38 pthread_mutex_destroy(&mutex);
39 return 0;
40 }
运行结果如下:
这次没有出现负数票数的情况。
注意:
1.加锁的粒度要小,加锁的临界区只能有1个线程进来,此时就是串行执行的,效率就会变低
2.对临界区进行保护,所有的执行线程必须要遵守这个规则。
互斥量原理
加锁的原子性:
所有的线程必须要看到同一把锁,所以锁的本身就是临界资源,锁本身得保证自身的安全。申请锁的过程不能有中间的状态,也就是2态,所以加锁是原子的。
临界区的线程进行了切换的情况:
在临界区执行很多的代码,线程的时间片到了,当前的线程被切换了。但是并不影响,因为当前的线程是带着锁走的,并没有释放锁。其他的线程是申请不到锁的,只有等走的线程把锁释放了才能申请到锁访问临界区。
如何保证申请锁是原子的?
- 经过上面,我们知道到单纯的
i++
或者++i
都不是原子的,有可能会有数据一致性问题 - 为了实现互斥锁操作,大多数体系结构都提供了
swap
或exchange指令
,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的 总线周期也有先后一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。
我们来看一下加锁和解锁的伪代码:
我们可以认为mutex的初始值为1,al是CPU中的寄存器。
解锁是只有锁的线程才能解锁,只有1条执行流,所以解锁是原子的。
小结:
exchange一条汇编语句就完成了寄存器和内存中的数据交换,之前都是拷贝是的交换数据有2份。
可重入和线程安全
- 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
- 线程安全::多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
常见的不安全情况:
1.不保护共享变量的函数
2.函数状态随着被调用,状态发生变化的函数
3.返回指向静态变量指针的函数
4.调用线程不安全函数的函数
常见的线程安全情况
1.每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安的
2.类或者接口对于线程来说都是原子操作
3.多个线程之间的切换不会导致该接口的执行结果存在二义性
常见的不可重入情况
1.调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
2.调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
3.可重入函数体内使用了静态的数据结构
常见的可重入情况
1.不使用全局变量或静态变量
2.不使用用malloc或者new开辟出的空间
3.不调用不可重入函数
4.不返回静态或全局数据,所有数据都有函数的调用者提供
5.使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
可重入和线程安全
可重入和线程安全的联系
- 函数是可重入的,那就是线程安全的
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
可重入和线程安全的区别
- 可重入函数是线程安全函数的一种
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
- 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。
死锁
死锁概念:死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。
例:让新线程连续2次申请所锁:
1 #include<stdio.h>
2 #include<pthread.h>
3
4 pthread_mutex_t mutex;
5
6 void* route(void* arg)
7 {
8 pthread_mutex_lock(&mutex);
9 pthread_mutex_lock(&mutex);
10 pthread_mutex_unlock(&mutex);
11 pthread_exit((void*)0);
12 }
13 int main()
14 {
15 pthread_t tid;
16 pthread_mutex_init(&mutex,NULL);
17 pthread_create(&tid,NULL,route,NULL);
18
19
20 pthread_join(tid,NULL);
21 pthread_mutex_destroy(&mutex);
22 return 0;
23 }
该进程处于死锁的状态。
死锁的必要条件:
1.互斥条件:一个资源每次只能被一个执行流使用
2.请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
3.不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
4.循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
避免死锁:
- 破坏死锁的四个必要条件
- 加锁顺序一致
- 避免锁未释放的场景
- 资源一次性分配
线程同步
同步概念
同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步
竞争条件:
竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。
条件变量函数
初始化
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict attr);
参数:
cond:要初始化的条件变量
attr:NULL
销毁
int pthread_cond_destroy(pthread_cond_t *cond)
等待条件满足
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
参数
cond:要在这个条件变量上等待
mutex:当前线程所处临界区对应的互斥锁
唤醒等待的函数有2个
1.int pthread_cond_broadcast(pthread_cond_t *cond);
2.int pthread_cond_signal(pthread_cond_t *cond);
区别:
1.pthread_cond_signal
用于唤醒等待队列中的首个线程
2.pthread_cond_broadcast
用于唤醒等待队列中的全部线程
示例如下:
1 #include<stdio.h>
2 #include<pthread.h>
3 #include<unistd.h>
4 pthread_mutex_t mutex;
5 pthread_cond_t cond;
6 void* route1(void* arg)
7 {
8 char* name=(char*)arg;
9 while(1)
10 {
11 pthread_cond_wait(&cond,&mutex);
12 printf("%s:开始任务\n",name);
13 }
14 }
15 void* route3(void* arg)
16 {
17 char* name=(char*)arg;
18 while(1)
19 {
20 pthread_cond_wait(&cond,&mutex);
21 printf("%s:开始任务\n",name);
22 }
23 }
24 void* route2(void* arg)
25 {
26 while(1)
27 {
28 pthread_cond_signal(&cond);
29 printf("%s:开始去唤醒\n",(char*)arg);
30 sleep(1);
31 }
32 }
33
34 int main()
35 {
36 pthread_t tid1,tid2,tid3;
37
38 pthread_mutex_init(&mutex,NULL);
39 pthread_cond_init(&cond,NULL);
40 pthread_create(&tid1,NULL,route1,(void*)"thread2");
41 pthread_create(&tid2,NULL,route3,(void*)"thread3");
42 pthread_create(&tid2,NULL,route3,(void*)"thread4");
43 pthread_create(&tid3,NULL,route2,(void*)"thread1");
44
45
46 pthread_join(tid1,NULL);
47 pthread_join(tid2,NULL);
48 pthread_join(tid3,NULL);
49
50 pthread_mutex_destroy(&mutex);
51 pthread_cond_destroy(&cond);
52
53 return 0;
54 }
让3个线程开始阻塞,让''thread1"去唤醒其他的线程。
可以看出唤醒的线程具有明显的顺序性,原因是当这若干线程启动是默认都会在该条件变量下去等待,每次唤醒的是当前条件下等待的头部线程,当前线程执行完后会继续排到等待队列的尾部继续等待,所以我们看到1个周转的现象。
如果要唤醒全部的线程用函数pthread_cond_broadcast
2,3,4同时被唤醒了。
为什么 pthread_cond_wait 需要互斥量?
- 当线程进入临界区时需要加锁,然后判断内部资源的情况,若不满足当前线程执行的条件,需要在改变量条件下进行等待,但是改线程拿着锁等待的,这个锁就不会被释放,此时产生死锁的问题。
- 所以在调用
pthread_cond_wait
需要将互斥锁传入,在等待时将锁释放。 - 因为是在临界区等的,该
函数被返回时还是要被返回到临界区内
,该函数会让该线程重新持有锁。
正确的使用:
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);