目录
一.线程互斥
1.1 相关概念
- 临界资源:多线程执行流共享的资源叫临界资源。
并不一定所有的共享资源是临界资源,是多个线程访问的资源才是临界资源。
- 临界区:每个线程内部,访问临界资源的代码,叫做临界区。
- 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问灵界资源,通常对临界资源起保护作用。一个能进,一个不能进
- 原子性:不会被人后调度机制打断的操作,该操作只有两态,要么完成,要么未完成,没有中间态。可以理解成只有一个汇编代码。比如修改一个变量,一个执行流进来,要修改时,又有一个执行流进来,该值还是原来的值,会导致变量只修改一次,其实修改了两次。
1.2 互斥量mutex
- 大部分情况下,线程使用的数据都是局部变量,该局部变量保存在线程自己的栈空间内,这种情况,变量数据单个线程,其它线程无法获取。
- 但是有的时候,多个线程共享进程的变量,这种变量称为共享变量。可以通过数据共享来完成线程间的交互。
- 当多个线程并发操作共享变量,会有一些问题。
这时因为有4个线程,都访问了ticket这个变量,于是ticket就是临界资源,访问临界资源的代码就是临界区,于是有
原因:
- if判定为真时,代码可以并发的切换到其它线程。
- 由于usleep的等待过程,可能有其它线程也进入if代码段。
- ticket--本身就不是一个原子操作。
当一个线程在进行判断时,另外一个线程可能正好也进行判断,此时两个线程的ticket的值相同,判断为真后,就都进入了if的语句进行减减操作。
出现负数的原因是,当ticket为1时,由于usleep,当一个线程if判断成功,进入代码,由于usleep语句,可能导致其它线程if判断为真,因为此时ticket值还是1,此时都进行减减操作。在进行减减时,有先后顺序,因为进入循环的时间可能不一样。一个减完为0,另外一个线程减为-1,最后一个线程减为-2。
如果同时进入,应是操作多次,只是导致操作了一次。
此时线程执行的函数是一个不可重入函数,因为重入后会出错。
解决上面的办法:
- 代码必须要有互斥行为:当一个代码进入临界区,其它线程的代码不允许进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程执行,只允许一个线程进入临界区。
- 如果线程不在临界区种执行,那么该线程不能阻止其它线程进入临界区。
要做到这三点,本质需要一把锁,Linux上提供的这把锁叫互斥量。
1.3 互斥量的接口
- 初始化互斥量
初始化互斥量有两种方法:
方法1:静态分配
//使用宏来静态分配
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
方法2:动态分配
int pthread_mutex_init(pthread_mutex_t *restrict mutex,const pthread_mutexattr_t *restrict attr);
- 销毁互斥量:
注意:
- 使用静态分配PTHREAD_MUTEX_INITIALIZER初始化的互斥量,不需要销毁
- 不要销毁一个已经加锁了的互斥量
- 已经销毁了的互斥量,要确保后面不会有线程再加锁。
int pthread_mutex_destroy(pthread_mutex_t *mutex);
- 加锁和解锁
调用thread_mutex_lock时可能会遇到下面的情况:
- 互斥量处于未锁状态,该函数将互斥锁锁定。
- 发起加锁函数调用时,其它线程已经锁定互斥量或者存在其它线程同时申请加锁互斥量,但是没有竞争到互斥量,那么pthread_mutex_lock会陷入阻塞,等待互斥量被解锁。
修改上面有问题的代码:
1.4 总结
线程互斥本质就是当一个线程访问临界资源,另外一个线程不能访问临界资源。
锁就是互斥量。一般怎么实现互斥,通过对临界资源加锁。当一个线程占领到锁时,其它线程只能阻塞等待线程解锁。
所有线程看到的时同一把锁,锁也是临界资源,锁得保证自生是原子性的。加锁的过程和解锁的过程都是原子性的。
如何理解POSIXpthread库中的互斥量?
OS种可能有很多线程在申请锁,所以锁需要进行管理。
伪代码:互斥量在库中一定会有两个变量
struct mutex{
......
int val;//0表示上锁,1表示没有上锁
wait_queue *head//等待队列,当锁被占用需要被放入等待队列中
}
一次保证只有一个线程进入临界区,当CPU上调度该线程的时间片到了,要切换到别的线程时,也不会有影响,别的线程还是进入不到临界区,因为还没有解锁。
所以加锁之后一般效率都会比较低,加锁之后只能一个线程运行临界区代码,并行变成串行。
1.5 互斥锁实现原理(锁的原理)
锁是怎么保证自己的原子性的?
为了实现互斥锁的操作,大多数体系结构都提供了swap或者exchange指令,该指令是将寄存器和内存单元的数据想交换,由于只有一条指令,保证了原子性。
二.可重入函数和线程安全
2.1 概念
- 线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见于全局变量或者静态变量进行操作,并且没有锁保护的情况下,会导致线程不安全。
- 可重入函数:同一函数被不同执行流调用,当前线程还没执行完,就有其它进程进入,我们称之为重入。一个函数在重入的情况下,运行结果不会有任何问题,则该函数称为可重入函数,否则就是不可重入函数。
线程安全强调线程,线程执行完是否没有问题,可重入函数强调函数,函数执行完是否没有问题。
- 常见线程不安全情况
- 不保护临界资源
- 返回指向静态变量指针
- 调用不可重入函数
- 函数状态随着被调用,状态发生变化
- 常见线程安全情况
- 每个线程对于全局会在静态变量只有读权限,没有写权限
- 类或者接口对于线程来说时原子的。
- 多线程切换不会导致结果出现二义性。
- 常见不可重入函数情况
- 调用malloc/free函数,因为malloc是用全局链表管理堆的
- 调用标志I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
- 可重入函数体内使用了静态的数据结构
- 常见可重入的情况
- 不使用全局变量或静态变量
- 不使用用malloc或者new开辟出的空间
- 不返回静态或全局数据,所有数据都有函数的调用者提供
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
- 可重入与线程安全联系
- 函数是可重入的,那就是线程安全的
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的
总的来说就是一个函数是可重入的,线程是安全的,一个函数不是可重入的,线程不安全。
- 可重入与线程安全区别
- 可重入函数是线程安全函数的一种
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
- 如果将对临界资源的访问加上锁,则这个线程是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。
三.死锁
3.1 概念
死锁是指在一组进程中,各个线程均占有不释放的资源,但因互相申请被其它线程所占用不会释放的资源而处于一种永久等待状态。
举个例子:线程1有锁a,线程2有锁b,并且线程1,2不会释放锁。但是线程1申请锁b。线程2申请锁a。都申请不到,因为线程1和线程2都不会释放,导致线程1,2都一直阻塞等待锁的释放。
3.2 死锁的必要条件
死锁的必要条件意思是,要产生死锁必须拥有的条件,缺1不可。
- 互斥:一个临界资源每次只能被一个执行流使用。
- 请求与保持:一个执行流因请求资源阻塞时,对以获得的资源保持不放。
- 不剥夺:一个执行流已获得的资源在未使用完前,不能强行剥夺
- 循环等待:各执行流之间形成头尾相连的循环等待资源的关系。
前三个条件是一个正常执行流就有的条件,导致死锁的主要条件就是第四个条件。
3.3避免死锁
- 破坏死锁的其中一个必要条件即可
- 加锁顺序一致。比如线程1和2都要锁a和b,线程1,2加锁顺序都是先加锁a再加锁b。只有一方再等待。
- 避免锁未释放的场景。别的线程就申请不到了。
- 资源一次性分配。
一个线程可以导致死锁: 自己申请自己的锁
四.线程同步
4.1 同步的概念
当只有互斥的情况下,当一个线程访问某个变量时,再其它线程改变状态之前,这个线程什么也做不了,只能等待。
例如:一个线程往队列放数据,一个线程往队列读数据。当读数据的线程发现队列为空时,只能等待,知道另一线程往线程里写数据。
我们发现这样效率时很低下的,我们可以改变一种思路,当线程1发现队列里没数据时,会通知线程2往队列里放数据,当线程2发现队列里的数据满的时候,会通知线程1从队列里读数据。
同步:再保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题。
4.2 为什么需要同步
与互斥相比较,互斥是保证线程的安全。但是会导致饥饿问题,同步有效解决了饥饿问题,使多线程协同高效完成某些事情。
4.3 条件变量
条件变量可以理解成,保存条件的变量。线程可以通过函数来发送或者识别条件变量。来使线程根据条件变量进行不同的动作。
4.4 条件变量函数
- 条件变量的初始化
条件变量的初始化也有两种方法:
- 静态初始化
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
PTHREAD_COND_INITIALIZER是一个宏。
- 动态初始化
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict attr);
- 条件变量的销毁
int pthread_cond_destroy(pthread_cond_t *cond);
作用:销毁条件变量
参数:cond:要销毁的条件变量
返回值:成功返回0,失败返回1
- 等待条件的满足
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
为什么pthread_cond_wait需要互斥量作为参数?
首先pthread_cond_wait肯定使因为条件不满足才会进行等待。
要判断条件一定会在临界区里,临界区因为互斥使上锁的。
等待的时候,需要将锁释放释放,不然别的线程进不了临界区,改变不了条件,就会导致当前线程一直等,导致死锁。
等待完毕被唤醒时还需要将锁锁上。
结论:在调用pthread_cond_wait时,需要别的线程进到临界区来修改条件,会自动释放锁。
当条件满足被唤醒时,该函数会让该线程重新占有锁。
- 唤醒等待
int pthread_cond_signal(pthread_cond_t *cond);
怎么理解条件变量?
OS中线程中可能有很多的条件变量,OS需要对其进行管理。先描述后组织。
根据线程要通过条件变量,一个线程再条件变量上等待,一个将通知信息发送到条件变量上。大概有
struct cond{
......
int val;//条件是否满足
wait_queue *head;//条件不满足需要等待。
......
}
简单案例使用上面的函数:
一个线程等待条件变量的满足,一个线程发通知给条件变量,使其满足条件。
1 #include<stdio.h>
2 #include<pthread.h>
3 #include<unistd.h>
4 //等待条件成立需要互斥量
5 pthread_mutex_t mutex;
6 pthread_cond_t cond;
7 //等待条件变量成立
8 void *rountion1(void *arg){
9 char *str=(char *)arg;
10 while(1){
11 pthread_cond_wait(&cond,&mutex);
12 printf("%s:活动......\n",str);
13 }
14
15 }
16 //唤醒条件变量
17 void *rountion2(void *arg){
18 char *str=(char *)arg;
19 while(1){
20 sleep(2);
21 pthread_cond_signal(&cond);
22 printf("%s meet the condtion\n",str);
23 }
24
25 }
26
27 int main(){
28 pthread_mutex_init(&mutex,NULL);
29 pthread_cond_init(&cond,NULL);
30 pthread_t t1,t2;
31 pthread_create(&t1, NULL, rountion1, (void *)"thread 1");
32 pthread_create(&t2, NULL, rountion2, (void *)"thread 2");
33
34 pthread_join(t1, NULL);
35 pthread_join(t2, NULL);
36 pthread_cond_destroy(&cond);
37 pthread_mutex_destroy(&mutex);
38 return 0;
39 }
4.5 总结
同步就是在保证数据安全的前提下,让线程按照某种特定的顺序来访问临界资源。
同步与互斥的关系:互斥是保证数据的安全,同步在互斥的前提下,来提高线程之间的效率。