Logo
Section 1 / 15

简介

本课程介绍并发性,并提供一些动机性示例,以帮助我们更好地理解并发系统。

导入

简介

本课程介绍并发性,并提供一些动机性示例,以帮助我们更好地理解并发系统。

简介

对并发及其使用线程或协程实现模型的理解,体现了候选人的成熟度和技术深度,这在争取更高层级的职位 offer 时可能成为重要区分点。更重要的是,掌握并发机制使我们能够编写高性能且高效的程序。在处理任何规模较大的项目时,你不可避免地会深入到这一主题的核心。

我们将从线程开始,因为它们是并发领域中最广为人知和最熟悉的概念。线程并非物理实体,就像大多数计算机科学概念一样。线程最接近实际可见的表现形式可以在调试器中观察到。下图展示了一个示例程序在调试器中暂停时的线程状态。

image.png
image.png

一个最简单的并发系统示例,是一个单处理器机器上运行你最喜欢的 IDE。假设你编辑某个代码文件并点击保存。这个按钮点击操作将触发一个流程,导致字节被写入底层物理磁盘。然而,I/O 操作代价高昂,CPU 在数据写入磁盘期间将处于空闲状态。

在 I/O 操作进行时,空闲的 CPU 可以执行其他有用的任务。这时就轮到线程登场了——I/O 线程被 切换出去,UI 线程被调度到 CPU 上运行,这样当你在屏幕上点击其他位置时,IDE 仍然保持响应,不会显得卡顿或冻结。

线程可以制造出多任务并行的假象,尽管 CPU 在任意时刻实际上只执行一个线程。每个线程都会获得 CPU 的一段执行时间片,然后被切换出去,原因可能是它启动了一个需要等待的任务而不再使用 CPU,或者已经用完了分配的时间片。虽然线程调度的具体机制有许多细节和复杂性,但上述描述构成了其基本原理。

随着硬件技术的进步,如今多核机器已很常见。应用程序可以利用这些架构,让每个线程由独立的 CPU 核心运行。

如果你使用 Ruby 编程,那么你很可能已经接触过线程,尽管可能并未意识到。如果没有显式创建线程,你的代码将在主线程中运行。下面这段代码展示了如何打印当前执行代码的线程 ID。

1# 打印主线程的 ID 2puts Thread.current.__id__

线程的优势

  1. 更高的吞吐量,尽管在某些极端情况下,线程间上下文切换的开销可能会抵消性能提升,甚至导致性能低于单线程场景。但这种情况较为罕见,属于例外而非普遍现象。此外,多线程程序可以利用机器中的多个核心,而单线程程序即使存在多个可用核心,也仅能使用其中一个。

  2. 响应式应用,在单核机器上营造出多任务并行的假象。

  3. 资源利用更高效。请注意,相比创建全新的进程,线程的创建开销要轻得多。使用线程而非为每个 Web 请求创建新进程的 Web 服务器,消耗的资源要少得多。此外,同一进程内的所有线程共享相同的地址空间,因此无需像进程之间那样依赖共享内存。

其他多线程带来的好处都是上述几点的延伸或间接结果。

多线程带来的性能提升

以一个具体例子说明:下面的代码任务是 计算从 1 到 30,000,000(3000 万)所有整数的总和。第一种情况是单线程完成求和;第二种情况是将范围分为两部分,由两个线程分别求和,待两个线程完成后,将两个半段结果相加得到最终总和。最后,我们改用进程来重复上述过程。我们测量每种方案所花费的时间并输出结果。

1MAX_NUM = 30000000 2 3class SumUpExample 4 5 def initialize(startRange, endRange) 6 @counter = 0 7 @startRange = startRange 8 @endRange = endRange 9 end 10 11 def add() 12 for i in @startRange..@endRange do 13 @counter += i 14 end 15 end 16 17 def getCounter 18 return @counter 19 end 20end 21 22def singleThread() 23 s = SumUpExample.new(1, MAX_NUM) 24 25 thread = Thread.new do 26 s.add() 27 end 28 29 thread.join() 30 return s.getCounter() 31 32end 33 34def runTwoThreads() 35 s1 = SumUpExample.new(1, MAX_NUM / 2) 36 s2 = SumUpExample.new(1 + (MAX_NUM / 2), MAX_NUM) 37 38 thread1 = Thread.new do 39 s1.add() 40 end 41 42 thread2 = Thread.new do 43 s2.add() 44 end 45 46 thread1.join() 47 thread2.join() 48 return s1.getCounter() + s2.getCounter() 49 50end 51 52def runTwoProcesses() 53 54 read1, write1 = IO.pipe 55 read2, write2 = IO.pipe 56 57 pid1 = Process.fork do 58 s = SumUpExample.new(1, MAX_NUM / 2) 59 s.add() 60 write1.puts s.getCounter() 61 end 62 63 pid2 = Process.fork do 64 s = SumUpExample.new(1 + (MAX_NUM / 2), MAX_NUM) 65 s.add() 66 write2.puts s.getCounter() 67 end 68 69 Process.wait pid1 70 Process.wait pid2 71 72 write1.close() 73 write2.close() 74 75 return (read1.read().to_i) + (read2.read().to_i) 76 77end 78 79 80starting = Process.clock_gettime(Process::CLOCK_MONOTONIC) 81sum = singleThread() 82ending = Process.clock_gettime(Process::CLOCK_MONOTONIC) 83puts "单线程耗时:#{ending - starting} 秒,结果为 #{sum}" 84 85starting = Process.clock_gettime(Process::CLOCK_MONOTONIC) 86sum = runTwoThreads() 87ending = Process.clock_gettime(Process::CLOCK_MONOTONIC) 88puts "两个线程耗时:#{ending - starting} 秒,结果为 #{sum}" 89 90starting = Process.clock_gettime(Process::CLOCK_MONOTONIC) 91sum = runTwoProcesses() 92ending = Process.clock_gettime(Process::CLOCK_MONOTONIC) 93puts "两个进程耗时:#{ending - starting} 秒,结果为 #{sum}"

下表展示了在上述代码运行的一次实验中,不同方案从 1 到 3000 万整数求和所需的时间(单位:秒)。

配置时间
(秒)

单线程

3.97

多线程

3.98

多进程

3.97

结果令人意外且反直觉。我们本预期多线程方案应优于单线程,因为如果系统至少有两个 CPU,两个线程可以并行工作。然而,与单线程相比表现较差,这可以用(MRI)Ruby 的致命弱点——全局解释器锁(GIL) 来解释。GIL 是 Ruby 解释器内部的一个机制,即使存在多个空闲 CPU,也只允许一个线程执行。我们将在后续章节中进一步讨论这一点。对于像本例中这样的 CPU 密集型任务,多线程方案可能无法获得性能提升,因为线程并不能真正并行执行,反而增加了管理成本以及合并部分结果的开销。

使用多进程时,我们期望每个进程被调度到不同的 CPU 上并行运行。但创建和销毁进程的额外开销高于线程。此外,进程之间的通信比线程间通信更昂贵,因为线程共享同一地址空间,而进程则不共享。综合这些因素,解释了为何多进程方案的性能并不优于单线程。

尽管这些数值并非绝对,会因硬件配置和 Ruby 版本的不同而有所变化。例如,在我的 MacBook 上,三种方案的运行时间均约为 1 秒,而多进程方案明显领先。关键在于,影响程序运行时间的因素众多,不能仅因程序是多线程或多进程就断定其性能一定更好。

不过,总体而言,涉及阻塞操作(如网络 I/O、磁盘 I/O)的任务,在迁移到多线程或多进程架构后,通常能获得显著的性能提升。

为了演示,我们在下方的代码框中提供了相同求和程序的 Java 实现。Java 是真正的多线程语言,没有 GIL 限制,可以利用多个核心并行运行多个线程。你无需理解语法或程序逻辑。如果你运行该代码,会发现当求和到 30 亿时,两个线程的运行速度优于单线程。如果你将第 12 行的数字去掉两个零,并将变量 MAX_NUM 改为 3000 万,此时单线程反而更快!这个例子说明,存在一个临界点:在此之后,多线程能更快解决计算密集型问题;但在达到该临界点之前,单线程更快,因为它避免了上下文切换、共享内存访问以及其他多线程或多进程带来的管理开销。

1class Demonstration { 2 public static void main( String args[] ) throws InterruptedException { 3 SumUpExample.runTest(); 4 } 5} 6 7class SumUpExample { 8 9 long startRange; 10 long endRange; 11 long counter = 0; 12 static long MAX_NUM = 3000000000L; 13 14 public SumUpExample(long startRange, long endRange) { 15 this.startRange = startRange; 16 this.endRange = endRange; 17 } 18 19 public void add() { 20 21 for (long i = startRange; i <= endRange; i++) { 22 23 counter += i; 24 } 25 } 26 27 static public void twoThreads() throws InterruptedException { 28 29 long start = System.currentTimeMillis(); 30 SumUpExample s1 = new SumUpExample(1, MAX_NUM / 2); 31 SumUpExample s2 = new SumUpExample(1 + (MAX_NUM / 2), MAX_NUM); 32 33 Thread t1 = new Thread(() -> { 34 s1.add(); 35 }); 36 37 Thread t2 = new Thread(() -> { 38 s2.add(); 39 }); 40 41 t1.start(); 42 t2.start(); 43 44 t1.join(); 45 t2.join(); 46 47 long finalCount = s1.counter + s2.counter; 48 49 System.out.println("两个线程最终结果 = " + finalCount + ",耗时 " + (System.currentTimeMillis() - start) / 1000.0); 50 } 51 52 static public void oneThread() { 53 54 long start = System.currentTimeMillis(); 55 SumUpExample s = new SumUpExample(1, MAX_NUM ); 56 s.add(); 57 System.out.println("单线程最终结果 = " + s.counter + ",耗时 " + (System.currentTimeMillis() - start) / 1000.0); 58 } 59 60 public static void runTest() throws InterruptedException { 61 62 oneThread(); 63 twoThreads(); 64 65 } 66}

线程的问题

世上没有免费的午餐。使用线程的代价体现在以下几个方面:

  1. 调试困难,有些问题可能仅在生产环境中才会暴露。
  2. 代码维护成本更高,因为代码本身变得更难理解和推理。
  3. 系统资源消耗增加。每个线程的创建都会占用额外内存,消耗 CPU 周期用于管理,并造成上下文切换的时间浪费。对于像本例中的 CPU 密集型任务,多线程可能无法带来性能提升,因为线程无法真正并行执行,反而增加了管理成本和合并中间结果的开销。
  4. 程序可能出现性能下降,线程间的协调是有代价的。获取和释放锁会增加程序执行时间,线程争抢锁会导致锁竞争。

在上述背景下,让我们深入探讨并发编程的更多细节,这些内容很可能是你在面试中会被考察的重点。

程序、进程与线程