💓博主CSDN主页:杭电码农-NEO💓
⏩专栏分类:Linux从入门到精通⏪
🚚代码仓库:NEO的学习日记🚚
🌹关注我🫵带你学更多操作系统知识
🔝🔝
Linux线程
- 1. 前言
- 2. 多线程互斥相关背景概念
- 3. 多线程互斥详解
- 4. 互斥锁的接口使用
- 5. 死锁相关概念
- 6. 线程安全和可重入的关系
- 7. 线程同步基本概念
- 8. 线程同步的接口使用
- 9. 总结以及拓展
1. 前言
如果你不了解线程的基本概念,请你先
移步上一篇文章: 线程基本概念
本章重点:
本篇文章着重讲解线程互斥以及线程
同步的相关概念,以及如何实现它们.
周边概念包括临界资源,原子性,互斥量
等也会在本文当中提及
2. 多线程互斥相关背景概念
在学习互斥前,需要先补充一些相关概念:
临界资源:
多线程执行流共享的资源就叫做临界资源临界区:
每个线程内部,访问有临界资源的代码,就叫做临界区互斥:
任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用原子性:
不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
。大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互,多个线程并发的操作共享变量,会带来一些问题
比如说我们最常见的高铁售票系统,可以把买票操作分为三步: 第一步: 判断现在还有无车票.第二步: 乘客付款后,钱包金额减少. 第三步: 乘客获得一张车票,高铁的总票数减一.多个执行流执行这三步时可能会出现问题,如下图:
可以写一段代码来验证上面的情况:
// 操作共享变量会有问题的售票系统代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
int ticket = 100;
void *route(void *arg)
{char *id = (char*)arg;while ( 1 ) {if ( ticket > 0 ) {usleep(1000);printf("%s sells ticket:%d\n", id, ticket);ticket--;} else {break;}}
}
int main( void )
{pthread_t t1, t2, t3, t4;pthread_create(&t1, NULL, route, "thread 1");pthread_create(&t2, NULL, route, "thread 2");pthread_create(&t3, NULL, route, "thread 3");pthread_create(&t4, NULL, route, "thread 4");//等待线程结束pthread_join(t1, NULL);pthread_join(t2, NULL);pthread_join(t3, NULL);pthread_join(t4, NULL);
}
发现多次执行这段代码得到的结果可能不同
为什么会出现不同的结果?
3. 多线程互斥详解
为啥上面可能会出现多种结果?
是有多种原因在里面的:
- if 语句判断条件为真以后,代码可以并发的切换到其他线程
- usleep这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段
- - -ticket操作本身就不是一个原子操作
这里有一个问题,为什么- -ticket操作不是原子的?其实我们鉴定一个操作是不是原子性的可以查看这个操作的汇编代码,若汇编代码只有一条,则我们认为这个操作是原子的,反之则这个操作不是原子性的,可以来看看减减的汇编代码是有三条:
要解决上面的问题,需要满足以下条件:
- 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
本质上就是需要一把锁, 互斥锁
任何一个时间,只允许一个线程获得这把锁并且继续向后执行,没拿到锁的线程默认只能在加锁处阻塞等待其他线程释放掉锁才能继续往后走,多个线程来竞争一把锁,它们的关系就是互斥
4. 互斥锁的接口使用
互斥锁的使用一般分为四个步骤:
- 初始化互斥锁
- 在到达临界区前加锁
- 在跑完临界区代码后解锁
- 用完互斥锁后进行销毁
第一步: 初始化互斥锁
方法一, 静态分配
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
方法二, 动态分配
第二步和第三步: 加解锁
调用pthread_ lock 时,可能会遇到以下情况:
- 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
- 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,
那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁
第四步: 销毁互斥锁
所以现在可以更改一下售票系统:
int ticket = 100;
pthread_mutex_t mutex;//全局
void *route(void *arg)
{char *id = (char*)arg;while ( 1 ) {pthread_mutex_lock(&mutex);if ( ticket > 0 ) {usleep(1000);printf("%s sells ticket:%d\n", id, ticket);ticket--;pthread_mutex_unlock(&mutex);// sched_yield(); 放弃CPU} else {pthread_mutex_unlock(&mutex);break;}}
}
int main( void )
{pthread_t t1, t2, t3, t4;pthread_mutex_init(&mutex, NULL);pthread_create(&t1, NULL, route, "thread 1");pthread_create(&t2, NULL, route, "thread 2");pthread_create(&t3, NULL, route, "thread 3");pthread_create(&t4, NULL, route, "thread 4");pthread_join(t1, NULL);pthread_join(t2, NULL);pthread_join(t3, NULL);pthread_join(t4, NULL);pthread_mutex_destroy(&mutex);
}
5. 死锁相关概念
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。这样干说可能有点抽象,举个例子:
形成死锁的必要条件:
- 互斥条件:一个资源每次只能被一个执行流使用
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
- 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
所以大家在写代码时,要避免写出死锁
6. 线程安全和可重入的关系
线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
所以说,重入实际上比线程安全更加严格
下面是常见的不可重入的情况:
可重入和线程安全的联系和区别:
- 函数是可重入的,那就是线程安全的
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
- 可重入函数是线程安全函数的一种
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
- 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。
7. 线程同步基本概念
在多线程下并发的跑一段有加锁的代码,确实不会出现由于原子性而导致的不可预估的问题,但是也不代表这种方案就没有缺点了,想象一下以下的场景: 一共有6个线程在并发的执行一段代码,假如不做人为的干涉,当一个线程释放锁之后,下一个拿到锁的线程完全是不可预测的,也就是随机的,并且在加解锁这里,有一个可以称为就近原则的东西,就是说1号线程释放锁后,它此时距离锁最近,下一次获取锁可能还是1号线程,这就会导致其他线程虽然被创建出来了,但是并没有被调用,浪费的资源
不仅如此,当一个线程持有锁来到临界区后,可能临界区资源并没有就绪,比如一个线程访问队列时,发现队列为空,它只能等待,直到其它线程将一个节点添加到队列中.有没有一种方法可以让线程在资源不就绪时不要频繁的检测,一旦资源就绪就通知线程来访问资源?
要解决上面的问题,本质就是要线程同步
而线程同步坚持使用条件变量来实现
8. 线程同步的接口使用
使用条件变量通常分为四步:
- 初始化条件变量
- 利用条件变量等待资源就绪
- 资源就绪后唤醒线程来访问
- 使用完后销毁条件变量
第一步:
pthread_cond_t cond;//定义变量后再初始化
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict attr);
第二步:
进行资源等待的前提是要拿到锁进入到临界区,所以wait函数既要你的条件变量,也要你的互斥锁,因为在后期资源就绪时,你这个线程是持有锁醒来的,继续该你执行
第三步:
broadcast函数是唤醒所有的线程,而signal是唤醒一个线程
第四步:
写一个简单的案例:
pthread_cond_t cond;
pthread_mutex_t mutex;
void *r1( void *arg )
{while ( 1 ){pthread_cond_wait(&cond, &mutex);printf("线程被成功唤醒\n");}
}
void *r2(void *arg )
{while ( 1 ) {sleep(5);//等待一会儿再唤醒线程pthread_cond_signal(&cond);sleep(1);}
}
int main( void )
{pthread_t t1, t2;pthread_cond_init(&cond, NULL);pthread_mutex_init(&mutex, NULL);pthread_create(&t1, NULL, r1, NULL);pthread_create(&t2, NULL, r2, NULL);pthread_join(t1, NULL);pthread_join(t2, NULL);pthread_mutex_destroy(&mutex);pthread_cond_destroy(&cond);
}
9. 总结以及拓展
总的来说,多线程场景下还是比较容易出现各种错误的,所以在编写多线程的代码时,一定要对底层足够熟悉,对互斥锁以及条件变量要有一定的理解才能解决问题
一定要注意条件变量是在加解锁之间使用的