目录
一.几个概念
- 临界资源:任何一个时刻,只允许一个执行流访问的共享资源
- 临界区:访问(read/write)临界资源的代码
- 互斥:保护临界资源的一种访问方式,即任何时刻,保证只有一个执行流进入临界区,访问临界资源
- 原子性:一个操作的性质,该操作不会被调度机制打断,只有未完成和已完成两种状态
C++语言中的--操作会转换为三条汇编语句:
- 将内存中的数据mov到CPU的寄存器
- 对寄存器中的数据dec(减1)
- 将寄存器中的数据写入到内存
一条汇编语句才是原子的,因此a--这句代码不是原子的!!!
举例:两个线程并发对int a = 10进行++操作。线程1将10从内存拷贝到寄存器,进行+1操作,正要准备写2回到内存时,线程1被切换了,它会将寄存器内的值保存到CPU中。线程2从内存中读取到的还是10,进行若干次++操作,加到了100,并将100写回了内存。此时线程1再次被调度,从PCB中恢复硬件上下文,接着上一次未做完的工作,将寄存器中的11写回到内存。线程2好不容易把a加到100,现在一把回到解放前。出现以上问题的原因是,++操作不是原子的,数据从内存到寄存器,从寄存器到内存的过程没有一次性完成,被线程调度打断了。
内存中的数据是共享的,寄存器中的上下文是线程私有的,数据不一致就是指,上下文数据和内存中的共享数据不一致。
二.线程互斥
数据不一致问题,是因为多线程并发访问临界区,访问临界资源。所以只需保证,任一时刻,只有一个线程访问临界区,即可实现对临界资源的保护,以上的资源保护方式叫做互斥,互斥可以通过对临界区加互斥锁的技术手段来完成。
每个线程访问临界区,都必须先加锁,只有加锁成功,才能访问临界区,否则会阻塞等待。这是大家(线程或者编写代码的用户)都应该遵守的规则,如果有的线程遵守,有的不遵守,那锁便失去了意义。
1.定义并初始化锁
定义并初始化全局的锁:
定义并初始化局部的锁:
2.加锁
- 尽可能给少的代码加锁
- 一般给临界区加锁
- 访问临界区的线程都必须加锁,大家都应遵守这套规则
- 任何时刻,只会有一个线程申请加锁成功
- 锁也是临界资源,但申请加锁的操作本身就是原子的,所以申请加锁不会出现数据不一致问题,而导致多个线程申请锁成功。
- 一个线程加锁成功后,访问临界区,也会发生线程切换。但其它申请锁的线程会在pthread_mutex_lock上阻塞等待,无法访问临界区。加锁间接实现了线程访问临界区的原子性。
- 加锁的一般原则:谁加锁,谁解锁
3.解锁
4.销毁锁
全局锁无需销毁,局部锁要及时销毁:
三.互斥锁的本质
1.xchg的原子性
计算机通常会提供xchg汇编指令(硬件支持对应机器码),其作用是将寄存器内容和内存单元的内容进行交换。如果机器不支持exchange指令,那我们用户想要完成同样的工作,只能先将寄存器内容备份到另一个寄存器,再将内存拷贝到寄存器,最后将备份的内容拷贝到内存,分三步完成。但机器支持xchg指令,就只需要一步,这意味这,它是原子的。
2.加锁的过程
可以简单认为互斥锁就是一个值为变量,值为1表示可以被申请,值为0表示被占用
1.将某个寄存器的内容置0
2.使用xchg交换寄存器和mutex的内容->寄存器内容变为mutex的值,mutex变为0
3.判断寄存器内容是否大于0,如果大于0,则成功返回,否则挂起等待
只有第二步涉及到访问内存中的共享资源,而第二步又是原子的,所以整个加锁过程就是原子的。
内存中的mutex是线程共享的,寄存器中的上下文是线程私有的。xchg的本质是将共享资源mutex的数据交换到上下文中,属于线程自己。互斥锁的“1”就像一个令牌,谁拥有了“1”,谁就拥有了锁,即使线程被切换,它也会将上下文保存到PCB中打包带走。
3.解锁的过程
1.将1mov到内存的mutex中
2.唤醒等待mutex的线程(只不过从是从阻塞状态唤醒,可以被CPU调度,不是直接把锁分配给它),并成功返回
可以看出,解锁也是原子的
四.可重入VS线程安全
- 一个函数被多个执行流进入,称该函数被重入了。如果因为函数被重入而使程序行为不符合预期(崩溃或者数据不一致),称为该函数不可重入;否则,则称该函数可重入
- 多线程并发访问共享资源时,程序的行为符合预期,则称该线程是安全的。
- 线程安全描述的是线程的特征,可重入描述的是函数的特征。
- 多线程调用不可重入函数会导致线程不安全,多线程调用可重入函数不会导致线程不安全
五.死锁
1.死锁的概念
一组进程中的各个进程均占有不会释放的资源,但因为相互申请被其他进程所占用不会释放的资源,而处于的一种永久等待的状态。简而言之,死锁就是线程等待一个永远都得不到的锁
2.具体实例
实例1:现有某个共享资源,规定线程访问它需要持有锁1和锁2。线程A欲要访问该资源,已经持有锁1,正在申请锁2;线程B也想访问该资源,已经持有锁2,正在申请锁1。线程A和线程B都无法集齐两把锁,也都不放弃手中的锁,导致线程A和线程B都被阻塞挂起,永远不会被调度。
以上是两把锁造成的死锁,实际上一把锁也能造成死锁
实例2:由于用户的粗心,将解锁代码写成加锁,导致线程等待自己手中的锁,但它永远等不到
3.死锁产生的四个必要条件
- 互斥条件:使用锁以达到访问共享资源互斥
- 请求与保持条件:线程在申请锁的同时,对已获得的锁保持不放
- 不剥夺条件:一个线程手中的锁在使用完之前,不会被其它线程强行释放
- 循环等待条件:A找B要,B找A要
4.解决或避免死锁
破坏产生死锁的4个条件之一即可:
- 破坏互斥条件。使用锁是因为要保护共享资源,在条件允许的情况下,让每个线程私有一份资源,这样就不必使用锁来保证互斥了
- 破坏请求与保持条件。如果一个线程申请锁不成功,就把持有的锁都释放掉
- 破坏不剥夺条件。线程A想申请线程线程B手中的,管理线程检测到死锁,强行将线程B的锁释放
- 破坏循环等待条件。如果多线程要申请相同的锁,建议按照同样的次序申请
用户编码建议:
- 能不用锁就不用锁
- 如果要线程需要申请多个锁,尽量按照顺序一次性申请到位,不要分批申请
六.线程同步
同步:在临界资源使用安全的前提下,让多线程执行具有一定的顺序性,以保证合理使用资源,解决线程饥饿问题。线程同步使用条件变量完成。
互斥能保证资源的安全,同步让资源使用更加高效。
七.生产者消费者模型
1.概念
生产者消费者模型是多线程并发的一种经典设计模式。分为两种角色:
生产者:产生数据的线程,将产生的数据放置到共享的内存空间
消费者:从共享的内存空间取数据进行处理
生产者和消费者之间的关系如下:
生产者和生产者:互斥。同时向一块内存写入,会出现数据不一致问题(覆盖或混乱)
消费者和消费者:互斥。同时从一块内存读数据,会出现数据不一致问题(混乱)
生产者和消费者:互斥和同步。同时写和读,会导致消费者得到的数据残缺不全,所以需要互斥;生产者生产完成后再通知消费者来取,避免消费者做一些浪费资源的行为,所以需要同步。
注意:生产是指将数据放到交易场所,消费是指从交易场所拿走数据
2.意义
我们调用函数的目的,就是让函数作为消费者,处理传递给它的数据。但是如果函数的处理过程没有完成,调用处的代码就无法向下执行。
- 实现生产过程和消费过程执行解耦。共享的内存空间相当于一块缓存,只要缓存没有打满或者缓存不为空,消费和生产过程就不会相互影响,支持生产消费忙闲不均
- 提高数据处理的效率。生产者生产数据,前提是要获取数据,消费者取走数据后,还要进行处理。消费者的数据一般从具体场景中得来,例如从网络读取,也就是说,获取数据本身也要花费时间,消费者处理数据也要花时间。多生产和多消费模型在任何一个时刻只允许一个线程进入,高效并不体现在同步和互斥,而是使得获取数据和处理数据更好地并发。
八.条件变量
1.定义并初始化条件变量
2.等待条件变量
3.唤醒等待条件变量的一个或多个线程
前者是唤醒所有的线程,或者是唤醒队头的线程
九.条件变量的细节问题
- 在加锁和解锁之间要往往是要访问临界资源,但资源不一定是就绪的,所以需要有if语句这样的判断。如果判断条件不满足,什么也不做,线程会再次执行加锁,判断,解锁这样的无效工作。为了避免资源浪费,在条件不满足时,让线程在条件变量上阻塞等待。
- 让线程进行等待的时候,会自动释放锁
- 线程被唤醒的时候,是在临界区内唤醒的,当线程被唤醒,线程在pthread_cond_wait返回的时候,要重新申请锁
- 当线程被唤醒的时候,重新申请锁本质是也要参与锁的竞争的