并发效率与多线程安全


why you need to learn 并发与多线程?

当你需要让程序快速处理大量数据或高频率的任务时,并发和多线程能帮你充分发挥多核处理器的优势。这就像是给你的应用装上了多台引擎,使得任务处理更加迅速高效。掌握并发和多线程,可以让你更高效地使用系统资源,比如CPU和内存。这就像是你在分配资源时,能做到心中有数,不让系统资源闲置浪费。
避免常见陷阱:竞争条件、死锁和资源匮乏等线程问题在并发编程中很常见。对这些问题的深入理解有助于开发人员编写避免此类陷阱的代码,从而开发出更强大、更可靠的软件。

并发性(concurrency)是指程序、算法或问题的不同部分或单元可以在同一时间段内无序地在CPU上执行,但不会影响最终结果的能力。换句话说,并发性允许程序的不同部分同时进行,但它们的执行顺序可以是灵活的,只要最终的结果不受影响。

并发Concurrent:无论上一个开始执行的任务是否完成,当前任务都可以开始执行 (也就是说,A B 顺序执行的话,A 一定会比 B 先完成,而并发执行则不一定。) 与可以一起执行的并行(parallel)相对的是不可以一起执行的串行(serial)

从宏观方面来说,并发就是同时进行多种时间,实际上,这几种时间,并不是同时进行的,而是交替进行的,而由于CPU的运算速度非常的快,同一时间只有一个线程运行

并行:则是真正意义上的同时进行多种事情。这种只可以在多核CPU的基础上完成。

综上,并发与并行并不是互斥的概念,只是前者关注的是任务的抽象调度、后者关注的是任务的实际执行。而它们又是相关的

在并发编程中,可见性、原子性和有序性是指导并发程序正确性的重要概念,与上述三大问题(竞态条件、死锁和内存同步)密切相关。

可见性(Visibility):

可见性指的是一个线程对共享变量的修改是否能够被其他线程立即观察到。在多线程环境中,由于CPU缓存、编译器优化等因素,一个线程对共享数据的修改可能不会立即被其他线程所感知,造成不一致的结果。通过合适的同步机制(如互斥锁、原子操作、内存屏障等)可以确保对共享变量的修改对其他线程是可见的,从而保证了程序的正确性。

原子性(Atomicity):

原子性是指一个操作要么完全执行,要么完全不执行,不存在中间状态。在并发环境中,多个线程可能同时访问同一个变量,当一个线程正在修改某个共享变量时,如果没有适当的同步机制,其他线程可能会读取到不一致的状态。使用原子操作或锁可以确保某个操作是原子的,即在执行过程中不会被中断或影响。

原子性操作在并发编程中至关重要,它确保了一组操作的不可分割性和中间状态的不可见性。通过使用基本类型的原子操作、锁机制、原子变量和数据库事务等技术,我们可以有效地避免竞态条件,确保数据的一致性和正确性。

有序性(Ordering):

有序性指的是程序中的操作在执行时保持其指令顺序,不会出现乱序执行的情况。在现代CPU中,由于指令重排优化,CPU 可能会重新排序指令以提高性能。对于并发编程来说,这可能会导致线程之间操作的执行顺序与代码编写的顺序不一致。使用内存屏障(Memory Barriers)或同步机制可以确保指令不会被重排,从而保持操作的有序性。

关系:

  • 竞态条件通常与可见性和原子性有关。竞态条件可能发生在多个线程对共享资源的读写操作上,如果没有适当的同步机制来确保可见性和原子性,就可能导致不确定的结果。
  • 死锁可能与有序性有关,当多个线程按照不同的顺序获取锁或资源时,可能会产生死锁。合理地规划资源获取顺序可以避免死锁的发生。
  • 内存同步问题与可见性、原子性和有序性密切相关。解决内存同步问题需要确保对共享变量的修改对其他线程是可见的(可见性)、原子操作能够保证操作的完整性(原子性),以及指令执行的有序性。

同步问题

线程同步(Thread Synchronization)是指在多线程编程中,为了避免多个线程之间对共享资源的并发访问引发的问题(如竞争条件、死锁等),需要采取措施来协调线程的执行,以确保线程之间的操作按照期望的顺序进行。线程同步的目的是保证多个线程能够有序地访问共享资源,避免数据的不一致和错误。

常见的线程同步机制包括:

  1. 互斥锁(Mutex): 互斥锁是一种最基本的线程同步机制,用于保护共享资源免受并发访问。一次只有一个线程能够持有互斥锁,其他线程必须等待直到锁被释放。这样可以防止多个线程同时修改同一资源。

  2. 信号量(Semaphore): 信号量是一种计数器,用于控制多个线程对共享资源的访问。它可以用来限制同时访问资源的线程数量,也可以用于线程间的通信。

  3. 条件变量(Condition Variable): 条件变量用于在线程之间传递信息,以及在某些条件满足时唤醒等待的线程。它通常和互斥锁一起使用,以实现更复杂的同步需求。

  4. 读写锁(Read-Write Lock): 读写锁允许多个线程同时读取共享资源,但只允许一个线程进行写操作。这可以提高读操作的并发性能。多个读线程可以同时获得锁,写线程会阻塞其他写线程和读线程。乐观锁

  5. 原子操作(Atomic Operations): 原子操作是不可中断的操作,可以在单个指令中完成。它们可以用于保护简单的共享数据结构,避免竞争条件。

  6. 自旋锁: 自旋锁与互斥量类似,但它不使线程进入阻塞态;而是在获取锁之前一直占用CPU,处于忙等(自旋)状态。

  7. 屏障(Barrier): 屏障用于等待多个线程都达到某个点,然后再继续执行后续操作,常用于需要多个线程协同完成的任务。

  8. 乐观锁:乐观锁是一种假设在大多数情况下不会发生冲突的锁。它不会在访问共享资源之前获取锁,而是在更新共享资源时进行检查。如果检测到其他线程已经更新了共享资源,那么当前线程可能需要重试或者采取其他措施来处理冲突。乐观锁通常用于多读的场景,可以提高吞吐量。

  9. 悲观锁:悲观锁是一种假设在大多数情况下会发生冲突的锁。它在访问共享资源之前获取锁,并假定其他线程会干扰。悲观锁通常用于写入操作,以确保在写入共享资源时不会发生冲突。

乐观锁的实现方式主要有两种:CAS机制和版本号机制。

对于ABA问题,比较有效的方案是引入版本号,内存中的值每发生一次变化,版本号都+1;在进行CAS操作时,不仅比较内存中的值,也会比较版本号,只有当二者都没有变化时,CAS才能执行成功。Java中的AtomicStampedReference类便是使用版本号来解决ABA问题的。

除了CAS,版本号机制也可以用来实现乐观锁。版本号机制的基本思路是在数据中增加一个字段version,表示该数据的版本号,每当数据被修改,版本号加1。当某个线程查询数据时,将该数据的版本号一起查出来;当该线程更新数据时,判断当前版本号与之前读取的版本号是否一致,如果一致才进行操作。

临界区 对临界资源进行访问的那段代码称为临界区。

为了互斥访问临界资源,每个进程在进入临界区之前,需要先进行检查。

// entry section // critical section; // exit section

  1. 同步与互斥 同步:多个进程因为合作产生的直接制约关系,使得进程有一定的先后执行关系。 互斥:多个进程在同一时刻只有一个进程能进入临界区。
  2. 信号量 信号量(Semaphore)是一个整型变量,可以对其执行 down 和 up 操作,也就是常见的 P 和 V 操作。

down : 如果信号量大于 0 ,执行 -1 操作;如果信号量等于 0,进程睡眠,等待信号量大于 0; up :对信号量执行 +1 操作,唤醒睡眠的进程让其完成 down 操作。 down 和 up 操作需要被设计成原语,不可分割,通常的做法是在执行这些操作的时候屏蔽中断。

如果信号量的取值只能为 0 或者 1,那么就成为了 互斥量(Mutex) ,0 表示临界区已经加锁,1 表示临界区解锁。

互斥锁

1严格轮换法 加入锁变量 2 Peterson算法 进程0出临界区后进程1才会离开忙等

1
2
3
4
typedef int semaphore; semaphore mutex = 1; 
void P1() { down(&mutex);
// 临界区 up(&mutex); }
void P2() { down(&mutex); // 临界区 up(&mutex); } 使用信号量实现生产者-消费者

问题描述:使用一个缓冲区来保存物品,只有缓冲区没有满,生产者才可以放入物品;只有缓冲区不为空,消费者才可以拿走物品。

因为缓冲区属于临界资源,因此需要使用一个互斥量 mutex 来控制对缓冲区的互斥访问。

为了同步生产者和消费者的行为,需要记录缓冲区中物品的数量。数量可以使用信号量来进行统计,这里需要使用两个信号量:empty 记录空缓冲区的数量,full 记录满缓冲区的数量。其中,empty 信号量是在生产者进程中使用,当 empty 不为 0 时,生产者才可以放入物品;full 信号量是在消费者进程中使用,当 full 信号量不为 0 时,消费者才可以取走物品。

注意,不能先对缓冲区进行加锁,再测试信号量。也就是说,不能先执行 down(mutex) 再执行 down(empty)。如果这么做了,那么可能会出现这种情况:生产者对缓冲区加锁后,执行 down(empty) 操作,发现 empty = 0,此时生产者睡眠。消费者不能进入临界区,因为生产者对缓冲区加锁了,消费者就无法执行 up(empty) 操作,empty 永远都为 0,导致生产者永远等待下,不会释放锁,消费者因此也会永远等待下去。

异步编程

异步(Asynchronous):异步编程是一种处理并发操作的方式,其中不需要等待一个操作完成才能执行下一个操作。在异步编程中,一个操作的启动不会阻塞程序的其他部分,而是允许程序继续执行其他操作,之后当异步操作完成时,程序会得到通知或回调。这种方式可以提高程序的响应性和并发性。但由于资源有限,进程的执行不是一贯到底的,这就是进程的异步性。 异步和同步是相对的,同步就是顺序执行,执行完一个再执行下一个,需要等待、协调运行

“异步阻塞” 意味着可能存在某些情况下,虽然使用了异步编程的方式,但在某些时刻程序仍然被阻塞,等待某些操作的完成。这种情况可能发生在以下情形下:

  • 某些异步操作内部使用了同步操作,导致当前线程在等待这些同步操作完成时被阻塞。
  • 异步操作之间存在依赖关系,某个异步操作必须等待另一个异步操作完成后才能执行。
  • 程序中的某些部分仍然采用同步编程方式,而不是全面采用异步编程。

要避免异步操作中的阻塞,通常需要使用异步编程框架和模式,以确保在异步操作中也不会阻塞程序的其他部分。这包括使用回调、Promise、异步/await等机制来管理异步操作的执行顺序和依赖关系,从而实现真正的非阻塞异步编程。

eg. 我们以经典的读取文件的模型举例。(对操作系统而言,所有的输入输出设备都被抽象成文件。)

在发起读取文件的请求时,应用层会调用系统内核的 I/O 接口。

如果应用层调用的是阻塞型 I/O,那么在调用之后,应用层即刻被挂起,一直出于等待数据返回的状态,直到系统内核从磁盘读取完数据并返回给应用层,应用层才用获得的数据进行接下来的其他操作。

如果应用层调用的是非阻塞 I/O,那么调用后,系统内核会立即返回(虽然还没有文件内容的数据),应用层并不会被挂起,它可以做其他任意它想做的操作。(至于文件内容数据如何返回给应用层,这已经超出了阻塞和非阻塞的辨别范畴。)

阻塞和非阻塞解决了应用层等待数据返回时的状态问题,那系统内核获取到的数据到底如何返回给应用层呢?是否是阻塞还是非阻塞,关注的是接口调用(发出请求)后等待数据返回时的状态。

同步还是异步,关注的是任务完成时消息通知的方式。由调用方盲目主动问询的方式是同步调用,由被调用方主动通知调用方任务已完成的方式是异步调用

JAVA中的线程安全

Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。

Java提供了volatile关键字来保证可见性。当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。关闭缓存,(从Load到Store)是不安全的,中间如果其他的CPU修改了值将会丢失。下面的测试代码可以实际测试voaltile的自增没有原子性(原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

编译器层面 和CPU层面都提供了一套内存屏障来禁止重排序的指令,编码人员需要识别存在数据依赖的地方加上一个内存屏障指令,那么此时计算机将不会对其进行指令优化。

在多线程环境下,确保代码正确同步和避免重排序reording是至关重要的。Java 提供了各种机制来帮助程序员在多线程场景下明确表达数据依赖和避免指令重排序:

  1. volatile 关键字: 使用 volatile 关键字可以确保变量的可见性,并防止编译器和处理器对被 volatile 修饰的变量进行重排序。它适用于简单的变量操作,但不能保证原子性。
  2. synchronized 关键字: 使用 synchronized 关键字可以确保代码块在同一时刻只能被一个线程执行,从而保证了线程安全性和避免了重排序。
  3. Lock 接口及其实现类: Java 提供了 Lock 接口及其实现类,如 **ReentrantLock**,允许更灵活地进行锁定和解锁,并提供了比 synchronized 更多的特性。
  4. Atomic 类: Java 提供了一系列原子操作类,如 AtomicIntegerAtomicBoolean 等,用于在不使用锁的情况下执行原子操作。
  • Happens-Before 规则: Java 内存模型定义了 Happens-Before 规则,规定了一系列操作的顺序,以保证多线程环境下的一致性和可预测性。程序员可以利用这个规则来确保代码正确性。

    Java 内存模型下一共有 8 条 happens-before 规则,如果线程间的操作无法从如下几个规则推导出来,那么它们的操作就没有顺序性保障,虚拟机或者操作系统就能随意地进行重排序,从而可能会发生并发安全问题。

    • 程序次序规则(Program Order Rule):在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
    • 管程锁定规则(Monitor Lock Rule):一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。这里必须强调的是同一个锁,而 “后面” 是指时间上的先后顺序。
    • volatile 变量规则(Volatile Variable Rule):对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作,这里的 “后面” 同样是指时间上的先后顺序。
    • 线程启动规则(Thread Start Rule):Thread 对象的 start () 方法先行发生于此线程的每一个动作。
    • 线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过 Thread.join () 方法结束、Thread.isAlive () 的返回值等手段检测到线程已经终止执行。
    • 线程中断规则(Thread Interruption Rule):对线程 interrupt () 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 Thread.interrupted () 方法检测到是否有中断发生。
    • 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize () 方法的开始。
    • 传递性(Transitivity):如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那就可以得出操作 A 先行发生于操作 C 的结论。
  1. 线程安全的集合类: Java 提供了一些线程安全的集合类,如 ConcurrentHashMapCopyOnWriteArrayList 等,可以在多线程环境下安全地进行操作。
  2. volatile 的双重检查锁定模式(Double-Checked Locking): 在需要延迟初始化对象的情况下,使用双重检查锁定模式结合 volatile 关键字,可以确保线程安全性。

ScheduledExecutorService的主要作用就是可以将定时任务与线程池功能结合使用

  • Java中存在两种锁机制:synchronized和Lock,Lock接口及其实现类是JDK5增加的内容

    synchronized: Java的关键字,在jvm层面上 Lock: 是一个接口

    用法

    synchronized: 在需要同步的对象中加入此控制,synchronized可以加在方法上,也可以加在特定代码块中,括号中表示需要锁的对象。

    Lock: 一般使用ReentrantLock类做为锁。在加锁和解锁处需要通过lock()和unlock()显示指出。所以一定要在finally块中写unlock()以防死锁。

    锁的释放

    synchronized: 1、以获取锁的线程执行完同步代码,释放锁 2、线程执行发生异常,jvm会让线程释放锁。在发生异常时候会自动释放占有的锁,因此不会出现死锁

    Lock: 在finally中必须释放锁,不然容易造成线程死锁。必须手动unlock来释放锁,可能引起死锁的发生

    锁的获取

    synchronized: 假设A线程获得锁,B线程等待。如果A线程阻塞,B线程会一直等待

    Lock: 分情况而定,Lock有多个锁获取的方式,大致就是可以尝试获得锁,线程可以不用一直等待(可以通过tryLock判断有没有锁)

    锁的状态 synchronized: 无法判断 Lock: 可以判断

    • Lock可以提高多个线程进行读操作的效率。(可以通过readwritelock实现读写分离)
    • 在资源竞争不是很激烈的情况下,Synchronized的性能要优于ReetrantLock,但是在资源竞争很激烈的情况下,Synchronized的性能会下降几十倍,但是ReetrantLock的性能能维持常态;
    • ReentrantLock提供了多样化的同步,比如有时间限制的同 步,可以被Interrupt的同步(synchronized的同步是不能Interrupt的)等。在资源竞争不激烈的情形下,性能稍微比synchronized差点点。但是当同步非常激烈的时候,synchronized的性能一下子能下降好几十倍。而ReentrantLock确还能维持常态。公平锁机制。什么叫公平锁呢?也就是在锁上等待时间最长的线程将获得锁的使用权。通俗的理解就是谁排队时间最长谁先执行获取锁。

    ② 是否可手动释放
    synchronized 不需要用户去手动释放锁,synchronized 代码执行完后系统会自动让线程释放对锁的占用; ReentrantLock则需要用户去手动释放锁,如果没有手动释放锁,就可能导致死锁现象。一般通过lock()和unlock()方法配合try/finally语句块来完成,使用释放更加灵活。

    ③ 是否可中断
    synchronized是不可中断类型的锁,除非加锁的代码中出现异常或正常执行完成; ReentrantLock则可以中断,可通过trylock(long timeout,TimeUnit unit)设置超时方法或者将lockInterruptibly()放到代码块中,调用interrupt方法进行中断。

    ④ 是否公平锁

    所以公平和非公平的区别就是:线程执行同步代码块时,是否会去尝试获取锁。

    如果会尝试获取锁,那就是非公平的。如果不会尝试获取锁,直接进队列,再等待唤醒,那就是公平的。synchronized为非公平锁 ReentrantLock则即可以选公平锁也可以选非公平锁,通过构造方法new ReentrantLock时传入boolean值进行选择,为空默认false非公平锁,true为公平锁。

    可重入锁是指同一个线程如果首次获得了这把锁,那么因为它是这把锁的拥有者,因此 有权利再次获取这把锁

    公平锁是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。

    非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。

    调度

    synchronized: 使用Object对象本身的wait 、notify、notifyAll调度机制

    Lock: 可以使用Condition进行线程之间的调度

    底层实现

    synchronized: 底层使用指令码方式来控制锁的,映射成字节码指令就是增加来两个指令:monitorenter和monitorexit。当线程执行遇到monitorenter指令时会尝试获取内置锁,如果获取锁则锁计数器+1,如果没有获取锁则阻塞;当遇到monitorexit指令时锁计数器-1,如果计数器为0则释放锁。

    Lock: 底层是CAS乐观锁,依赖AbstractQueuedSynchronizer类,把所有的请求线程构成一个CLH队列。而对该队列的操作均通过Lock-Free(CAS)操作。

    关于ReentrantLock和CAS的关系:ReentrantLock是一个基于CAS的悲观锁,而不是乐观锁。ReentrantLock使用CAS操作来实现锁的获取和释放,但它的行为是悲观的,因为它要求线程在访问共享资源之前获得锁。CAS本身是一种乐观锁的实现方式,但ReentrantLock使用CAS来保证互斥性,因此它被归类为悲观锁。总的来说,乐观锁和悲观锁是两种不同的并发控制策略,它们在多线程环境下解决了竞争问题,但它们的使用场景和实现方式有所不同。 CAS是一种用于实现乐观锁的原子操作,而ReentrantLock是一种基于CAS的悲观锁。

    1. 乐观锁:乐观锁是一种假设在大多数情况下不会发生冲突的锁。它不会在访问共享资源之前获取锁,而是在更新共享资源时进行检查。如果检测到其他线程已经更新了共享资源,那么当前线程可能需要重试或者采取其他措施来处理冲突。乐观锁通常用于多读的场景,可以提高吞吐量。
    2. 悲观锁:悲观锁是一种假设在大多数情况下会发生冲突的锁。它在访问共享资源之前获取锁,并假定其他线程会干扰。悲观锁通常用于写入操作,以确保在写入共享资源时不会发生冲突。

    乐观锁的实现方式主要有两种:CAS机制和版本号机制。

    对于ABA问题,比较有效的方案是引入版本号,内存中的值每发生一次变化,版本号都+1;在进行CAS操作时,不仅比较内存中的值,也会比较版本号,只有当二者都没有变化时,CAS才能执行成功。Java中的AtomicStampedReference类便是使用版本号来解决ABA问题的。

    除了CAS,版本号机制也可以用来实现乐观锁。版本号机制的基本思路是在数据中增加一个字段version,表示该数据的版本号,每当数据被修改,版本号加1。当某个线程查询数据时,将该数据的版本号一起查出来;当该线程更新数据时,判断当前版本号与之前读取的版本号是否一致,如果一致才进行操作。

最经典的分布式锁是可重入的公平锁,可重入锁(Reentrant Lock)是一种特殊类型的锁,允许同一个线程多次获取同一个锁而不会导致死锁。这种锁可以多次被同一线程获取,而不会因为同一线程的重复获取而阻塞自己。


Author: Stan ke
Reprint policy: All articles in this blog are used except for special statements CC BY 4.0 reprint policy. If reproduced, please indicate source Stan ke !
  TOC