并发(Concurrency)和并行(Parallelism)是系统设计中最容易被误解的两个概念。
虽然它们听起来很相似,但实际上指的是处理任务的两种截然不同的方法。
简单来说,一个是关于同时管理(manage)多个任务,而另一个是关于同时执行(execute)多个任务。
在本文中,我们将剖析这两个概念之间的差异,探讨它们的工作原理,并通过示例和代码来说明它们在现实世界中的应用。
1. 什么是并发?
并发意味着一个应用程序同时在多个任务上取得进展。
Concurrency means an application is making progress on more than one task at the same time.
在计算机中,任务是通过中央处理器(CPU)来执行的。
虽然单个CPU一次只能处理一个任务,但它通过在任务之间快速切换来实现并发。
例如,在编写代码的同时播放音乐。CPU在这些任务之间快速交替,以至于对用户来说,感觉这两个任务是同时进行的。
这种由现代CPU设计实现的无缝切换,创造了多任务处理(illusion of multitasking)的假象,给人一种任务并行运行的错觉。
然而,需要注意的是,这并不是并行(parallel),而是并发(concurrent)。
并发(concurrency)主要是通过线程来实现的,线程是进程中最小的执行单元。CPU在线程之间切换,以并发地处理多个任务,确保系统保持响应性。
并发的主要目标是通过最小化空闲时间来最大化CPU利用率。
例如:
- 当一个线程或进程在等待输入输出操作、数据库事务或外部程序启动时,CPU可以将资源分配给另一个线程。
这确保了即使个别任务被暂停,CPU仍然能够保持高效运行。
并发是如何工作的?
CPU中的并发是通过上下文切换来实现的。
其工作原理如下:
- 上下文保存:当CPU从一个任务切换到另一个任务时,它会将当前任务的状态(例如,程序计数器、寄存器)保存在内存中。
- 上下文加载:然后CPU加载下一个任务的上下文,并继续执行该任务。
- 快速切换:CPU重复这个过程,在任务之间快速切换,使得这些任务看起来像是在同时运行。
上下文切换的代价
虽然上下文切换实现了并发,但它也引入了额外的开销:
- 每次切换都需要保存和恢复任务状态,这会消耗时间和资源。
- 过多的上下文切换会增加CPU的开销,从而降低性能。
并发在现实世界中的例子
- 网络浏览器
现代网络浏览器会并发地执行多个任务:
- 渲染网页(HTML/CSS)。
- 获取外部资源,如图像和脚本。
- 响应用户操作,如点击和滚动。
这些任务中的每一个都由单独的线程管理,确保浏览器在加载和显示内容时保持响应性。
- Web服务器
像Apache或Nginx这样的Web服务器会并发地处理多个客户端请求:
- 每个请求都使用线程或异步输入输出独立处理。
- 例如,服务器可以同时处理多个用户加载不同页面的请求,而不会出现阻塞。
- 聊天应用程序
聊天应用程序会并发地执行几个操作:
- 处理传入的消息。
- 用新消息更新用户界面。
- 发送传出的消息。
这确保了实时通信的流畅性,不会出现延迟或卡顿。
- 视频游戏
视频游戏在很大程度上依赖并发来提供沉浸式体验:
- 渲染图形。
- 处理用户输入(例如,角色移动)。
- 模拟物理效果。
- 播放背景音乐。
例如,当玩家移动角色时,游戏会同时更新环境并播放音乐,确保游戏运行流畅。
代码示例
大多数流行的编程语言都内置了对创建和管理线程的支持。
下面是一个用Java编写的并发程序示例:
输出(交错执行):
Task A - Step 1
Task B - Step 1
Task C - Step 1
Task A - Step 2
Task B - Step 2
Task C - Step 2
...
2. 什么是并行?
并行意味着多个任务同时被执行。
Parallelism means multiple tasks are executed simultaneously.
为了实现并行,一个应用程序会将其任务分成更小的、独立的子任务。这些子任务被分配到多个CPU、CPU核心、GPU核心或类似的处理单元上,从而使它们能够并行处理。
为了实现真正的并行,你的应用程序必须:
- 使用多个线程。
- 确保每个线程被分配到一个单独的CPU核心或处理单元上。
并行是如何工作的?
现代CPU由多个核心组成。每个核心都可以独立地执行一个任务。并行是将一个问题分解成更小的部分,并将每个部分分配给一个单独的核心进行同时处理。
- 任务划分:将问题分解为更小的独立子任务。
- 任务分配:将子任务分配到多个CPU核心上。
- 执行:每个核心同时处理其分配的任务。
- 结果聚合:将所有核心的结果组合起来,形成最终输出。
并行在现实世界中的例子
-
机器学习训练
训练深度学习模型涉及将数据集分成更小的批次。
每个批次同时在多个GPU或CPU核心上进行处理,显著加快了训练过程。 -
视频渲染
视频帧是独立渲染的,这使得可以同时处理多个帧。
例如,在使用多个核心并行处理不同帧时,渲染3D动画会快得多。 -
网络爬虫
像谷歌爬虫(Googlebot)这样的网络爬虫会将URL列表分成更小的块,并并行处理它们。
这使得爬虫可以同时从多个网站获取数据,减少了收集信息的时间。 -
数据处理
像Apache Spark这样的大数据框架利用并行性来处理海量数据集。
诸如分析数百万用户的日志等任务被分配到一个集群中,实现了同时处理并能更快地获得洞察。 -
科学模拟
像天气建模或分子相互作用这样的模拟需要大量的计算。
这些计算被分配到多个核心上,实现了同时执行并能更快地得到结果。
代码示例
下面是一个用Java编写的简单并行示例,使用ForkJoinPool框架来并行计算数组的总和:
4. 并发与并行的组合
- 应用程序看起来同时在多个任务上取得进展(并发)。
- 然而,它是通过快速在任务之间切换来实现的,而不是同时运行这些任务。
- 例如:一个单核CPU在任务之间交替,给人一种多任务处理的错觉。
- 一个单一任务被分成子任务,并且这些子任务在单独的核心上同时执行。
- 任务之间没有重叠;一个任务(及其子任务)在另一个任务开始之前完成。
- 例如:视频渲染,其中一个单一视频被分成帧,并且每一帧都并行处理。
- 任务是按顺序依次执行的,一次执行一个,没有任何重叠或并行执行。
- 例如:一个单核CPU,其中只有一个任务被处理,并且在处理下一个任务之前,该任务会完全完成。
一个应用程序可以既是并发的又是并行的,结合了两种执行模型的优点。
在这种方法中:
在上述示例中,一个单一任务被分成4个子任务,这些子任务被分配到2个CPU核心上进行并行执行。这些子任务由多个线程执行。一些线程在同一个CPU核心上并发运行,而其他线程在不同的CPU核心上并行运行。
如果每个子任务都由其自己的线程在专用CPU上执行(例如,4个线程在4个CPU上),任务执行就会完全并行,不涉及并发。
通常很难将一个任务精确地分成与CPU数量相同的子任务。相反,任务通常被分成与问题的结构和可用的CPU核心数量自然匹配的若干子任务。