0%

线程同步

一个进程是一个独立的程序运行单位【如运行的QQ程序等】:包括了运行是的一切资源如内存,编译好的二进制代码,寄存器,堆践等等。在计算机中,我们把一个任务称为一个进程,浏览器就是一个进程,视频播放器是另一个进程,类似的,音乐播放器和Word都是进程。某些进程内部还需要同时执行多个子任务。例如,我们在使用Word时,Word可以让我们一边打字,一边进行拼写检查,同时还可以在后台进行打印,我们把子任务称为线程。

进程和线程的关系就是:一个进程可以包含一个或多个线程,但至少会有一个线程。

操作系统调度的最小任务单位其实不是进程,而是线程。常用的Windows、Linux等操作系统都采用抢占式多任务,如何调度线程完全由操作系统决定,程序自己不能决定什么时候执行,以及执行多长时间。

因为同一个应用程序,既可以有多个进程,也可以有多个线程。

线程是最小的逻辑执行单元,只有在运行的时候才会有自身的寄存器【CPU缓存区】等等。一个进程可以拥有多个线程。这些线程调度有系统调度执行,所以访问共享资源的时候都要加锁。

  • 读:当一个线程修改变量的时候,另外一个变量读取变量就可能会造成数据不一致。因为在写操作需要两个存储器周期时间,需要先把数据写到寄存器,然后刷新到内存区域。如果另外的线程在写操作区间就会出现数据不一致【一个新值,一个老的值】。

  • 写:当多个线程在同一时间修改同一个变量的时候也是需要进行同步。

​ 一个变量的增值操作:

​ 1:从内存读取到寄存器

​ 2:在寄存器进行变量的增值操作

​ 3:把变量写入到内存区域

而线程同步的本质是保证一个线程顺序执行,不被中断。线程的同步需要

线程同步的一些方法:

1: Pthread提供的互斥量:互斥量本质来说是一把锁,保证在同一时间内容只有以线程可以访问数据。在访问共享资源之前加锁,访问之后解锁。加锁后,任何其他试图再次加锁的线程会被阻塞。直到当前线程解锁:

​ 使用时会造成死锁。【死锁是指一个资源被多次调用,而多次调用方都未能释放该资源就会造成一种互相等待的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁。

​ 1.1:同一个线程对于同一个互斥量不能加锁两次【必须是加锁解锁操作,不能是加锁加锁操作】,否则会造成死锁。

​ 1.2:多互斥量下的互相等待:既线程A锁住了互斥量A此时要锁住互斥量B,线程B锁住了互斥量B,想要锁住互斥量A,造成线程间的互相等待。

​ 1.3:Java 的 synchronized 重量级锁依赖于操作系统的互斥量(mutex) 实现

​ Java死锁的例子:

            
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void add(int m) {
synchronized(lockA) { // 获得lockA的锁
this.value += m;
synchronized(lockB) { // 获得lockB的锁
this.another += m;
} // 释放lockB的锁
} // 释放lockA的锁
}

public void dec(int m) {
synchronized(lockB) { // 获得lockB的锁
this.another -= m;
synchronized(lockA) { // 获得lockA的锁
this.value -= m;
} // 释放lockA的锁
} // 释放lockB的锁
}

​ 在获取多个锁的时候,不同线程获取多个不同对象的锁可能导致死锁。对于上述代码,线程1和线程2如果分别执行add()和dec()方法时:

  • 线程1:进入add(),获得lockA;
  • 线程2:进入dec(),获得lockB。

随后:

  • 线程1:准备获得lockB,失败,等待中;
  • 线程2:准备获得lockA,失败,等待中。

此时,两个线程各自持有不同的锁,然后各自试图获取对方手里的锁,造成了双方无限等待下去,这就是死锁。

死锁发生后,没有任何机制能解除死锁,只能强制结束JVM进程。

因此,在编写多线程应用时,要特别注意防止死锁。因为死锁一旦形成,就只能强制结束进程。

那么我们应该如何避免死锁呢?答案是:线程获取锁的顺序要一致。即严格按照先获取lockA,再获取lockB的顺序,改写dec()方法如下:

           
1
2
3
4
5
6
7
8
public void dec(int m) {
synchronized(lockA) { // 获得lockA的锁
this.value -= m;
synchronized(lockB) { // 获得lockB的锁
this.another -= m;
} // 释放lockB的锁
} // 释放lockA的锁
}

2: 自旋锁

自旋锁的主要特征是使用者在想要获得临界区执行权限时,如果临界区已经被加锁,那么自旋锁并不会阻塞睡眠【互斥量会阻塞】,等待系统来主动唤醒,而是原地忙轮询资源是否被释放加锁,自旋就是自我旋转,这个名字还是很形象的。自旋锁有它的优点就是避免了系统的唤醒,自己来执行轮询,如果在临界区的资源代码非常短且是原子的,那么使用起来是非常方便的,避免了各种上下文切换,开销非常小,因此在内核的一些数据结构中自旋锁被广泛的使用。

​ 1:自旋锁本身是有缺点的,它不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX:PreBlockSpin来更改)没有成功获得锁,就应当挂起线程。

3:读写锁rwlock

读写锁也叫共享互斥锁:读模式共享和写模式互斥,本质上这种非常合理,因为在数据没有被写的前提下,多个使用者读取时完全不需要加锁的。读写锁有读加锁状态、写加锁状态和不加锁状态三种状态,当读写锁在写加锁模式下,任何试图对这个锁进行加锁的线程都会被阻塞,直到写进程对其解锁。

读优先的读写锁:读写锁rwlock默认的也是读优先,也就是:当读写锁在读加锁模式先,任何线程都可以对其进行读加锁操作,但是所有试图进行写加锁操作的线程都会被阻塞,直到所有的读线程都解锁,因此读写锁很适合读次数远远大于写的情况。这种情况需要考虑写饥饿问题,也就是大量的读一直轮不到写,因此需要设置公平的读写策略。在一次面试中曾经问到实现一个写优先级的读写锁,感兴趣的可以想想如何实现。

4:可重入锁和不可重入锁

​ Mutex可以分为递归锁(recursive mutex)【可重入锁】和非递归锁(non-recursive mutex)【不可重入锁】。 递归锁也叫可重入锁(reentrant mutex),非递归锁也叫不可重入锁(non-reentrant mutex)。

二者唯一的区别是:

​ 同一个线程可以多次获取同一个递归锁,不会产生死锁。

​ 如果一个线程多次获取同一个非递归锁,则会产生死锁。

Windows下的Mutex和Critical Section是可递归的。

Linux下的pthread_mutex_t锁是默认是非递归的。可以通过设置PTHREAD_MUTEX_RECURSIVE属性,将pthread_mutex_t锁设置为递归锁。

关于锁常见的名词的概念解锁:

​ 1:悲观锁和乐观锁

乐观锁与悲观锁是一种广义上的概念,体现了看待线程同步的不同角度。在Java和数据库中都有此概念对应的实际应用。

先说概念。对于同一个数据的并发操作,悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。Java中,synchronized关键字和Lock的实现类都是悲观锁。

而乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试)。

乐观锁在Java中是通过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的

2:

3:参考

https://zhuanlan.zhihu.com/p/88241719 锁概述

https://www.xuebuyuan.com/3255731.html Linux互斥锁 概述

https://www.cnblogs.com/bigberg/p/7910024.html

https://tech.meituan.com/2018/11/15/java-lock.html