线程不安全:多线程环境下程序的执行结果出现预料之外的值。
举一个例子:假如现在有一个全局变量,两个线程分别执行,对这个数据进行++50000次,执行完之后我们发现输出的结果并不是我们想要的100000,而是一些每次都在改变的值,这就属于线程不安全。
多个线程访问的那个公共的资源就叫做“临界资源”--------对应上面例子中的全局变量
访问临界资源的代码就做“临界区”
在临界区中使用“互斥机制”就能够解决线程不安全的问题
互斥机制的使用:
1.先加🔒
2.执行临界区代码
3.释放🔒 同一时刻只能有一个线程获取到🔒,只有这个获取到🔒的线程才能执行临界区的代码,其他线程只能等待🔒的释放。
互斥锁/互斥量:
互斥锁的主要作用是将并行的程序改成了串行了
加锁需要五个步骤
1.先定义一个🔒
pthread_mutex_t mutex;
2.初始化🔒
int pthread_mutex_init(pthread_mutex_t *restrict mutex,const pthread_mutexattr_t *restrict attr);
pthread_mutex_init(&mutex,NULL);
3.在需要加🔒的位置上🔒
int pthread_mutex_lock(pthread_mutex_t *mutex);
pthread_mutex_lock(&mutex);
4.代码执行完毕解🔒
int pthread_mutex_unlock(pthread_mutex_t *mutex);
pthread_mutex_unlock(&mutex);
5.销毁🔒
int pthread_mutex_destroy(pthread_mutex_t *mutex);
pthread_mutex_destroy(&mutex);
如果当前锁已经被其他线程获取了,当前线程在想获取,就会阻塞到lock()加锁函数这里,直到锁资源被释放,才会继续执行。这个是便于理解的,实际上本质是,下面这段话。
互斥锁pthread_mutex又叫挂起等待锁,一旦线程获取锁失败,就会挂起(进入操作系统的一个等待队列中,这个线程什么时候才能恢复执行,也不是其他线程释放锁,立刻就能执行起来,他还要等待cpu的调度才能执行,调度也是有开销的)
互斥锁能够保证线程安全,但是最后的程序效率会受到影响
除此之外还有可能引起死锁,锁上之后却忘记解锁,就会造成死锁。
死锁的常见场景:
1.一个线程加锁之后,再次加锁就会造成死锁
2.两个线程有两把锁,锁a,锁b,线程1先获取锁a,再去获取锁b,线程2先获取锁b,再去获取锁a,此时两个线程就会进入死锁状态。双方都在锁在了,尝试获取对方线程手中的锁的这个状态。死锁
3.哲学家就餐问题,多个线程,多把锁问题。
实际规避死锁的方法是:
1.短:让临界区的代码尽可能的去短,一目了然,这样就算死锁也会很容易看出来
2.平:在临界区中尽量不要有复杂的函数调用
3.快:临界区代码执行速度尽量快,别做太耗时的操作
真正出现死锁了,那也只有想办法解决了,代价也是比较大的
哲学家就餐问题解决示意图:
可重入函数:一个函数在任意的执行流中调用,都不会出问题
线程安全函数:一个函数在任意的线程中调用,都不会出问题
可重入这个概念要求更高一些,涵盖了线程安全。如果一个函数是可重入的,一定线程安全,反之,如果一个函数线程安全,不一定可重入,可重入的概念在之前已经说过了。
//为了验证上面的理论,看下面这段代码
1 #include<stdio.h>
2 #include<pthread.h>
3 #include<unistd.h>
4 #include<signal.h>
5 pthread_mutex_t mutex;
6 int g_count = 0;
7 void ModifyCount() //这个是线程函数内部的函数,加锁了,所以线程安全
8 {
9 pthread_mutex_lock(&mutex);
10 ++g_count;
11 printf("before sleep\n");
12 sleep(3);
13 printf("after sleep\n");
14 pthread_mutex_unlock(&mutex);
15 }
16 void* func(void* arg) //线程所执行的函数
17 {
18 (void)arg;
19 for(int i = 0;i < 50000;++i)
20 {
21 ModifyCount(); //线程执行的内部函数
22 }
23 return NULL;
24 }
25
26 void MyHandler(int sig)
27 {
28 (void)sig;
29 ModifyCount(); //线程和信号捕捉函数执行的是相同的代码
30 }
31 int main ()
32 {
33 signal(SIGINT,MyHandler); //信号捕捉函数,捕捉二号线好
34 pthread_mutex_init(&mutex,NULL);
35 pthread_t tid[2];
36 for(int i = 0;i < 2; ++i)
37 {
38 pthread_create(&tid[i],NULL,func,NULL);
39 }
40 for(int i = 0;i < 2 ;++i)
41 {
42 pthread_join(tid[i],NULL);
43 }
44 printf("g_count = %d\n",g_count);
45 pthread_mutex_destroy(&mutex);
46 return 0;
47 }
代码运行起来之后就是上图的样子,信号捕捉函数,捕捉ctrl + c 2号信号,捕捉到之后,执行线程函数内部的加法函数,线程函数加锁 了所以是安全的,但是在线程函数加了锁之后,大印before之后我们在,连续输入2号信号,此时我们要清楚信号捕捉函数的执行流程,捕捉到之后,由内核执行信号处理函数,和我们的进程属于不同的执行流,在信号处理函数执行的时候,进程代码全部停止,等待信号处理函数处理完毕之后再继续执行,但是此时外部的线程已经打印before ,还没有打印after 就说明线程还没有释放锁资源,此时信号处理函数再加锁,这样就陷入了死锁。
综上所述,所以说可重入要求更高一些,线程安全了不一定可重入,可重入了一定线程安全。
信号处理函数很少用到,因为一旦进入信号处理函数,原来的逻辑不管有多少个线程都得停下来等,除非极个别的情况下会使用
同步与互斥:同步是同步,互斥是互斥,不要混为一谈
互斥:就是同一时间只有一个线程可执行临界区。
同步:同步控制着线程和线程之间的执行顺序(主要还是抢占式执行惹的祸,有的时候我们就是需要线程和线程之间按照一定的顺序来执行)。
举个例子:一个人在ATM机取钱,后面有一大堆人在排队,第一个取钱的人,锁上门,取钱,完事之后解锁,出来,但是他刚出来,就想到钱没拿够再拿一点这样的话,后面排队的人就没有办法取钱了。
线程饿死:
然后再说线程 ,之前说了当一个线程获取锁之后,先一个线程假如也在等这个锁(死等),等到锁被释放了要等到cpu继续调度这个线程才可以获得锁,但是线程与线程之间是抢占式的执行,大家都想抢到只有一份的资源这样,加入第一个获取锁的线程反复自己一个线程利用资源,因为自己加完锁,释放后,在获取锁比较方便,但是后面的线程没法获取到锁资源了,就在一直等,这就叫线程饿死。
抢占式执行是万恶之源
所以就需要同步,来控制线程的执行顺序,这时候就需要引入条件变量。
同步代码:
1 #include<stdio.h>
2 #include<unistd.h>
3 #include<pthread.h>
4 #include<stdlib.h>
5 pthread_mutex_t mutex;
6 pthread_cond_t cond;
7 void* func1(void* arg)
8 {
9 (void)arg;
10 while(1)
11 {
12 pthread_cond_signal(&cond); //要被等的执行这个函数
13 printf("传球\n");
14 usleep(789789); //一个时间长
15 }
16 return NULL;
17 }
18 void* func2(void* arg)
19 {
20 (void)arg;
21 while(1)
22 {
23 pthread_cond_wait(&cond,&mutex); //需要等其他线程的执行这个函数,阻塞在这里直到条件成立
24 printf("扣篮\n");
25 usleep(123123); //一个时间短
26 }
27 return NULL;
28 }
29 int main()
30 {
31 pthread_t tid[2];
32 pthread_mutex_init(&mutex,NULL); //初始化锁
33 pthread_cond_init(&cond,NULL); //初始化条件变量
34 pthread_create(&tid[0],NULL,func1,NULL); //线程1
35 pthread_create(&tid[1],NULL,func2,NULL); //线程2
36
37 pthread_join(tid[0],NULL); //线程等待
38 pthread_join(tid[1],NULL);
39 pthread_mutex_destroy(&mutex); //释放锁
40 pthread_cond_destroy(&cond); //释放条件变量
41
42
43 return 0;
44 }
//执行结果:
传球
扣篮
传球
扣篮
传球
扣篮
传球
扣篮
传球
扣篮
传球
扣篮
整齐
pthread_cond_wait做了以下几件事情:
1.先释放锁
2.等待条件就绪
3.重新获取锁,准备执行后续的操作 大部分情况下,条件变量都得和互斥锁一起使用。
又有一个问题就是很有可能等待的线程,在条件变量已经满足之后,却没有接收到通知,所以,要求1,2两步必须是原子性的,也就是说两步是不可以拆分的 ,为的就是可以第一时间接收到通知。