初涉并发编程 1——基本概念

从这篇文章起,都是在 openSUSE 环境下,使用 vscode 编写的了~

主要是按照《Java 并发编程实战(Java concurrency in practice)》来学习的,或许也会参考其他书籍。我目前仅有对多线程编程的一点非常粗糙的理解,需要大量的实践和理论学习来补足。

一个必须要明确的问题是,看这本书,并非是要学习 java 中的并发编程,而是通过 Java 来学习并发编程的思想。在这里,思想才是真正的内容,语言只不过是这思想的表现形式而已。将来去学设计模式,也应该坚持这一点。

基础概念

进程和线程

简单来说,进程就是一个程序(存放在硬盘里的,静态的代码/机器码)的一次执行,或者说是一个程序的生命周期。而线程,则是进程所创建的“轻量级进程”。对于 Java 程序来说,JVM 就是 Java 程序的进程,它维护数个线程,如执行 main 方法的主线程,GC 线程等。

就如之前所学习到的,各个进程之间一般上来说是相互隔离的,它们只能通过操作系统提供的一些方法来相互通信,如信号量,socket,共享内存,管道等。而进程下的各线程可以共享线程的资源。对于 Java 程序来说,各个线程都共享进程的方法区和堆,各个线程都私有栈和 PC。

线程是 CPU 调度的最小单位。进程间的上下文切换消耗很大,而线程间的切换则消耗相对较小(这里减少的消耗似乎主要来源于线程间共享的资源,即堆的资源,因为是共享的,所以内存缺页的情况会较少,更容易命中……之类的),所以线程也叫轻量级进程。

串行和并行

所谓串行,就是指一连串的操作依次进行,它们是有严格的顺序关系的,没有两个操作是同时进行的。

而并行,则是说其中有部分(或全部)操作是(严格上来说)同时执行的。并行只有在多核 CPU 的计算机中才能进行。

同步和异步

TODO: 摸了

什么是并发

并发是指在宏观上来说,两个(或多个)操作是同时执行的。这时,计算机底层可能是串行的(所有操作可以都在一个所谓的主事件循环里进行,就如以前的 GUI 程序),也可能是并行的,可能是单线程的,也可能是多线程的。所以并发并不等于并行。

为什么要多线程?

首先一个原因是,多线程让程序更容易维护(把原本互相并行的操作,分割成一个个串行的操作),降低开发的成本(好家伙,有什么比这一条更重要?)。同时,多线程也能够增加资源利用率,在进行某些需要等待的操作时(比如等待外界的输入输出),能够空出手来干其他事(即使对单核处理器也如此);对于现代的多用户系统,多线程能够保证用户和程序的公平性

还有硬件上的原因。随着摩尔定律的失效,CPU 的频率遇到瓶颈,那些硬件厂商迫不得已,只能往 CPU 里塞更多的核心,为了利用好这些核心,多线程是必要的。比如说,单线程的程序跑在双核的计算机上,只能利用 50%性能,在 128 核的计算机上则只能利用不到 1%的性能。

不过无论如何,多线程最香的一点在于它让建模简单了,它将复杂,异步的工作流分解为一组简单,同步的工作流——每个工作流在一个单独的线程中执行,并在特定的同步位置进行交互。它能够简化异步事件,让每一个组件都像是一个单线程程序。学习多线程,实际上就是学习如何让各线程间保持同步。

这可能会让你以为,在单核处理器的情况下,编写并发代码是没有意义的。然而,有些情况下,并发模型会产生更简单的代码,光是为了这个目的就值得舍弃一些性能。——《On Java 8》

多线程带来的问题

多线程要求开发者考虑三个问题——安全性,活跃性,以及性能问题。

安全性

如果线程间没有采取适当的同步措施,多个线程之间不可预测的执行顺序可能会让程序出现错误。并且这种错误是难以发现的,间歇性出现的。

竞态条件(Race Condition)

考虑这样的代码——

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Counter {
private Counter() {}
private static int count = 0;
public static int count() {
return count++;
}
public static void main(String[] args) throws InterruptedException {
Thread a = new Thread(()->{
for (int i = 0; i < 1000; i++)
Counter.count();
});
Thread b = new Thread(()->{
for (int i = 0; i < 1000; i++)
Counter.count();
});
a.start();
b.start();
a.join();
b.join();
System.out.println(Counter.count()); // 结果是不可预测的,可能是 1000 到 2000 的任何值
System.exit(0);
}
}

问题发生在 count 方法里,count++并非是原子操作,查看 count 的字节码,可以看到,其字节码有 6 步,这里简化它有三步——get 值(到栈顶),计算,put 值。它是读取——修改——写入操作。

原子操作指不可被打断的操作,也就是说,当一个线程在执行原子操作的时候,该操作(块)保证不会被其他线程执行,即使该线程被切换出去。通过一定的加锁机制能够给与一串操作以原子性(不知道如何正确定义原子性…这里被删掉的定义的外延似乎不能包括那种真正不可再分的原子操作,比如下面所示的字节码的单行,因为这样的操作在多处理器环境下完全可以是并行的,而锁将不允许并行。或许是我脑子里没货导致的……但是这里其实并不需要纠结,在实践中是能够分辨出来的)

1
2
3
4
5
6
7
8
public static int count();
Code:
0: getstatic #2 // Field count:I
3: dup
4: iconst_1
5: iadd
6: putstatic #2 // Field count:I
9: ireturn

要知道,线程可以在进行到任意一行时进行线程调度。考虑下面的情况——

经历了两次 count 操作,它们正常的情况应该是分别返回 9,10,而这里全部都返回了 9。count 在两次递增后值应当为 11, 但结果却为 10。可以说,有一次递增操作丢失了。这里显然,不恰当的运行时线程交替执行的顺序导致了错误的结果。这是没有充分同步造成的。

上面描述的是一种常见的并发安全问题——竞态条件(Race Condition)中的“先检查后执行(Check-Then-Act)”。它是指结果的正确性取决于多个线程的交替执行时序这样的情况。或者说,结果的正确性取决于运气。(老实说,我觉得 condition 或许翻译成“情况”更好)

大多数竞态条件的本质是“观察结果的失效”。比如这里的 count 方法,当一个线程获取了 count 的值后,它实际上已经无法再知道它所持有的 count 的值是否还是原来的 count 的值,而它却要拿这种不确定的结果来进行运算。如果该结果自始至终都是未改变的,则不会出现问题。但若是有改变,这里就失效了。(该方法属于读取——修改——写入操作,有一定的共通性,但也有其个性)。

(单例模式的?)延迟初始化也是先检查后执行的一个常见例子。

1
2
3
if (ins == null) 
return ins = new instance();
else return ins;

这里由于不一致的调用顺序,可能会导致多个线程执行该方法后返回不一样的实例。

活跃性

安全性是指,“永远不发生糟糕的事情”,而活跃性则是指,“正确的事情终究会发生”。在这里,死锁,饥饿,活锁等会导致活跃性问题。(或许死锁和饥饿的区别在于,死锁和活锁时所有线程都无法执行,饥饿时只有一部分线程无法执行……但无论如何,这都是我们不想看到的)

性能问题

我们不仅要求正确的事情终究要发生,也要求正确的事情尽快发生。多线程程序相较于单线程程序,引入了多余的运行时开销——线程间的上下文切换和对临界区操作时同步机制带来的开销。前者如处理不当,会导致 CPU 时间主要用于线程调度而非运行,后者往往会抑制编译器的性能优化,使内存缓冲区中数据无效。


本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 协议 ,转载请注明出处!