Geonode logo
Geonode Team

Geonode Team

更新于:2026年10月7日

发布于:2026年9月2日

并发与并行:有什么区别?

并发是指同时处理多项任务。并行是指同时执行多项任务。这是教科书上的标准说法,但仅凭这一点并不能说明任何问题。 当你提出一个实际问题时,这种区分才变得有意义:为什么增加线程会让某个程序运行得更快,而另一个却变慢了? 本文将解答这一问题,具体说明 Python、Go 和 Node 之间的差异,以及如何判断你的工作负载实际上需要哪一种。

先简单介绍一下本文的作者及其写作动机。我们是 Geonode,主要销售代理服务,因此始终与这一话题密切相关——网页抓取是典型的I/O受限工作负载,而“我的抓取工具太慢了”也是用户向我们反映最频繁的问题之一。 因此,在进入理论探讨之前,先说个实话:如果你的爬虫因为每次只抓取一页而变慢,购买代理并不能加快它的速度。 并发性取决于你的代码。即使有上百个代理端点,如果使用顺序循环,你得到的也只是一个顺序爬虫,而那上百个端点将处于闲置状态。 请先解决并发问题。代理解决的是另一个问题——即在并发机制正常运作之后,当目标网站开始对突然每秒发出五十次请求的IP地址进行速率限制时出现的问题。这两个问题都真实存在。它们并非同一个问题,解决方案也各不相同。

话虽如此,下面来区分这两者。

一句话的区别

罗布·派克(Rob Pike)在关于该主题的演讲中给出的定义依然最为清晰,而Go博客也直截了当地阐述了这一点:

并发是“独立执行的进程的组合”。

并行性是“(可能相关的)计算的同步执行”。

请仔细读两遍,因为两者的区别在于侧重点的不同。并发关注的是结构——如何将问题分解为能够独立进行的部分。 并行性则关乎执行——这些子任务中有多少个在同一瞬间实际运行。

人们常忽略的一个关键点是:并发是你编写的,并行是机器实现的。你可以编写一个并发程序并在单核上运行它,尽管此时没有任何任务是同时执行的,但它仍然是并发的。 你定义了独立的任务;运行时会将它们交错执行。而一个在八个核心上运行的并发程序,如果运行时和工作负载允许,也可能变成并行程序。

正是这种不对称性,使得这两个词无法互换使用。并发性使并行性成为可能,但并不保证并行性。而没有并发结构的并行性,则根本无法实现。

为何混淆依然存在

有三个原因,说清楚这些原因会有所帮助。

可观察到的行为往往完全相同。 无论是在单核上运行的并发程序,还是在四核上运行的并行程序,看起来都像是“同时发生多件事”。从外部来看,你无法分辨两者有何不同。只有当你增加核心数却未见性能提升时,才会发现其中的区别。

每种编程语言的术语体系都不一致。 Python 的 threading 模块提供了并发功能,但历史上并不支持并行计算。Python 的 multiprocessing 则同时支持两者。JavaScript 的 async/await 提供了并发功能,但你的自有代码永远无法实现并行计算。Go 语言的 goroutines 提供了并发和并行计算,具体取决于 GOMAXPROCS。 在不同的生态系统中,文档中相同的术语却有着不同的含义。

**大多数时候你无需在意,直到某天你不得不面对这个问题。**对于 I/O 密集型工作负载,这种区别几乎只是理论上的——仅靠并发就能带来全部收益。 对于 CPU 密集型工作负载,情况则截然不同,因为仅靠并发根本无法带来任何收益。问题在于,人们往往会将解决 I/O 密集型问题时行之有效的模式,直接套用到 CPU 密集型问题上。

每种语言的实际实现方式

运行时并发机制代码的实际并行性存在的问题
Python(默认构建)threading, asyncio仅通过 multiprocessing 实现GIL 导致字节码执行串行化
Python(自由线程构建)threading, asyncio是,线程并行运行单线程开销,生态系统成熟度
Gogoroutines + 通道是,最多 GOMAXPROCS共享状态仍需同步
Node.js事件循环,async/await仅通过 worker_threads 或子进程实现一个 CPU 密集型回调会阻塞所有操作
Java / C#线程、线程池是共享可变状态的复杂性
Rustasync + 线程是编译器强制要求代码正确性

Node.js 是最清晰地诠释“无并行性的并发”的示例。 官方文档对此给出了精辟的阐述:事件循环“允许 Node.js 执行非阻塞 I/O 操作——尽管默认情况下仅使用单个 JavaScript 线程——通过在可能的情况下将操作卸载给系统内核来实现。” 内核是多线程的;而你的 JavaScript 代码则不是。该循环会依次经历六个阶段——定时器、待处理回调、空闲/准备、轮询、检查、关闭回调——当某项操作完成时,“内核会通知 Node.js,以便将相应的回调添加到轮询队列中,最终予以执行。”

其实际影响不言而喻。在 Node 中处理一万个并发 HTTP 请求轻而易举,因为等待过程发生在内核中。而一个运行两秒的 CPU 密集型函数却会冻结整个进程,因为只有一个线程可以运行它,且没有任何东西能抢占其执行权。

Go 则采取了截然相反的策略:goroutine 的创建成本极低,可以成千上万地创建,而调度器会将它们分配到操作系统线程上,数量上限为 GOMAXPROCS,该值默认等于可用核心数。 因此,Go 通过同一种构造同时提供了并发性和并行性。这也是 Pike 发表这场演讲的原因——在一种既能同时获得这两者、又容易将它们混淆的语言中,这种区分尤为重要。

关键问题:您的工作负载是 I/O 受限还是 CPU 受限

以上内容最终归结为一个关于您工作负载的问题,对此应进行实际测量,而非凭空假设。

I/O 受限意味着您的程序大部分时间都在等待:等待网络响应、磁盘读取或数据库查询。 等待期间,CPU处于空闲状态。并发是正确且充分的解决方案,因为它允许你在当前等待尚未完成时就启动下一次等待。并行处理实际上毫无意义——八个核心等待网络响应,其速度并不比一个核心等待网络响应更快。

CPU 受限意味着你的程序大部分时间都在进行计算:解析、压缩、哈希、转换。CPU 处于满负荷状态。仅靠并发无法改变现状——在一个核心上交替执行两项计算,其总耗时与顺序执行相同,还会增加切换开销。 只有并行处理能起到帮助,且其效果仅受限于物理核心的数量。

要确定你面临的是哪种情况,应通过测量而非推测。在 Linux 系统中,运行 time 命令可立即得到答案:将实际耗时与用户时间加系统时间的 CPU 时间进行比较。 如果实际时间远大于 CPU 时间,说明你在等待——即受 I/O 限制;如果两者相差无几,说明你在进行计算——即受 CPU 限制。

网页抓取是一个很好的例子,因为它同时包含这两种情况,且是顺序进行的。获取页面主要受 I/O 限制;随后的 HTML 解析则主要受 CPU 限制。 正确的架构应利用并发处理页面抓取,并采用并行处理进行解析,而常见的错误是将同一策略应用于这两个阶段。一个拥有 200 个并发抓取任务、却仅由单线程解析器处理的爬虫,并不是一个高效的爬虫——它只是一个抓取速度快的工具,后面却积压着长长的队列。

Python 的 GIL 以及“自由线程”带来的变化

Python 值得单独一节来讨论,因为其现状确实发生了变化,而你将读到的关于它的许多内容如今已过时。

历史背景:CPython 的全局解释器锁(GIL)只允许一个线程同时执行 Python 字节码。因此,线程虽然提供了并发性,却无法实现并行性。GIL 会在 I/O 操作期间释放,因此基于线程的 I/O 运行良好;但 CPU 密集型线程操作则不然,而 multiprocessing 提供了相应的解决方法。

变化之处:从 3.13 版本开始,CPython 提供了一个可选的构建版本,其中禁用了 GIL。自由线程文档 对此有明确说明——“自由线程执行允许通过在可用 CPU 核心上并行运行线程,从而充分利用可用的处理能力。”

PEP 779 于 2025 年 6 月 16 日被指导委员会以“最终”状态通过,该提案制定了将自由线程从实验性功能转为官方支持的标准,并计划在 Python 3.14 版本中实现这一转变。

在您尝试使用之前,请注意以下四点:

这不是默认构建版本。 您必须主动获取或编译该版本——若从源代码编译,则需使用 --disable-gil 配置选项。可通过 python -VV 检查当前运行状态(若显示“free-threading build”即为启用),或使用 sys._is_gil_enabled() 进行验证(当 GIL 关闭时,该命令会返回 False)。

单线程代码会变慢。 文档指出,在 pyperformance 测试套件中,“平均开销范围从 macOS aarch64 平台上的约 1% 到 x86-64 Linux 系统上的 8% 不等”。 PEP 779 记录了指导委员会的预期:支持自由线程的 Python “速度将变慢约 10-15%”,其中 15% 是第二阶段的硬性目标,并接受内存使用量增加 20%(几何平均值)作为“实现高效、安全的自由线程所付出的代价”。 如果您的程序是单线程的,此构建版本将直接导致性能退化。

您可以在运行时重新启用 GIL。 自由线程构建支持通过PYTHON_GIL环境变量或-X gil选项在启用 GIL 的情况下运行——当依赖项出现异常行为时,此功能非常有用。

你的依赖项才是限制因素。 C 扩展必须在构建时声明支持自由线程。虽然生态系统已发生显著变化,但“在我的机器上纯 Python 能运行”并不等同于“我的科学计算栈能正常工作”。

对于在数据抓取和 API 操作中占主导地位的 I/O 密集型场景,上述内容不会改变您的决策:asyncio 或标准构建中的线程池,已经为您提供了并发能带来的全部优势。只有当解析阶段(而非获取阶段)成为瓶颈时,自由线程才显得重要。

一个示例:获取 10,000 个 URL

具体的数字能让区别一目了然。假设在四核机器上,每次请求耗时 200 毫秒,解析每个响应需占用 50 毫秒的 CPU 时间。

顺序处理。 10,000 × 250 毫秒 = 2,500 秒,约 42 分钟。在此期间,CPU 有 80% 的时间处于空闲状态。

并行获取,顺序解析。 通过 100 个并发请求,获取时间缩短至大约 20 秒(实际时间)。解析时间保持不变:10,000 × 50 毫秒 = 500 秒。 总计约 520 秒,即大约 9 分钟。性能提升了 4.8 倍——请注意时间消耗的变化:数据获取原本占总运行时间的 80%,现在仅占新运行时间的 4%。而你未作改动的解析环节,现在占总时间的 96%。

在四个核心上进行并发数据读取和并行解析。 解析时间降至约 125 秒。总计约 145 秒,大约 2.5 分钟。与顺序执行相比,效率提高了 17 倍。

这些数字中蕴含着三个教训。

首先,最大的收益来自通过并发解决 I/O 问题,而且几乎不耗费额外资源——无需额外核心,没有共享状态问题,只需改写一个循环。

其次,一旦解决了主要的瓶颈,下一个瓶颈就会立即成为主导。 这就是阿姆达尔定律最直观的体现:优化占运行时间 20% 的阶段,无论你将其消除得多彻底,性能提升都不会超过 25%。优化前务必进行测量,优化后也要再次测量,因为结果会发生变化。

第三——这也是我们的商业利益开始发挥作用的地方,请据此权衡——当你从每次处理一个请求转变为处理一百个请求时,你的存在就会变得显而易见。一个每秒向同一主机发送500次请求的IP地址,将会被限速,随后被封锁。 这并非并发问题,无论使用多少asyncio都无法解决;这是一个流量分布问题,而代理服务器正是为此而存在的。我们的住宅网络流量起价为0.79美元/GB,数据中心流量起价为0.14美元/GB,此价格依据2026年9月我们的定价页面核对。 但请注意实施顺序:首先解决并发问题,只有当并发问题超出自身解决能力时,才启用代理。若本末倒置,则意味着为无法利用的带宽付费。

并发何时不再奏效

增加并发性会带来收益递减,最终甚至产生负面影响,而这一转折点的到来比大多数人预期的要早。

连接限制。 操作系统对打开的文件描述符设有上限。服务器对每个客户端的并发连接数也设有上限。来自同一台机器的 1 万个并发请求,在触及 CPU 限制之前,就会先撞上其中一个上限,而这种故障通常表现为令人困惑的错误,而非明确的错误信息。

内存。 每个正在处理的请求都会占用缓冲区、已解析的头部信息和待处理的响应数据。一万个并发请求,每个占用 100 KB 内存,就意味着有 1 GB 的内存仅用于等待而没有任何实际工作。

上下文切换开销。 操作系统线程并非免费的——每个线程都携带一个栈,并产生调度开销。这正是 goroutine 和协程存在的理由:它们的开销足够低,因此同时运行数千个是合理的,而数千个操作系统线程则不然。

目标端的容忍度。 连接的另一端也有自己的“意见”。一旦超过某个速率阈值,额外的并发不仅无法返回数据,反而会引发 429 和 503 错误,而随着并发量的增加,你的实际吞吐量反而会下降。 这是现实世界中最常见的上限,却也是最少被测量的指标,因为这些请求表面上仍然“有效”——它们只是返回了错误,而重试循环会乖乖地重复这些请求。

实际的做法并不光鲜:从适度的并发限制开始,测量“每秒完成请求数”而非“每秒尝试请求数”,并逐步增加直至吞吐量停止提升。吞吐量会先趋于平稳,随后开始下降。最优值就在这个平稳点上,而且通常比直觉预期的要小得多——往往是数十而非数百。

当你两者都不需要时

这一点值得强调,因为“让它并发起来”已经成了下意识的反应。

当任务量确实很小的时候。 一百个请求,每个耗时 200 毫秒,顺序执行需要 20 秒。如果这是夜间通过 cron 任务运行的,20 秒完全可以接受,而且当代码在凌晨 3 点出错时,并发代码反而更难调试。

**当处理顺序是需求的一部分时。**某些处理流程必须按严格顺序处理项目,或者每个步骤都依赖于前一步的结果。此时,并发不仅无济于事,更是导致在高负载下才会显现的错误的根源。

当瓶颈完全在其他地方时。 如果数据库写入是瓶颈,200 个并发读取者只会让同一个锁前的队列变得更长。解决真正的瓶颈才是关键。在串行资源上游引入并发,只会将一个运行缓慢的程序变成一个既有速度问题又有内存问题的程序。

当共享状态较为复杂时。 涉及共享可变状态的并发代码需要同步,一旦处理不当,就会产生最棘手的错误——间歇性、依赖负载、且在你的机器上无法复现。如果性能提升仅为 2 倍,而状态又十分复杂,那么可推导的顺序代码往往是更好的工程决策。

而与我们相关的情况是:如果你每天从一个并不介意的网站上抓取几百个页面,那么既不需要并发,也不需要代理。在循环中加入一次requests调用并配合适当的延迟,就是正确的解决方案,我们宁愿告诉你这一点,也不愿向你推销你根本不需要的方案。

大家还问

并发与并行最简单的区别是什么?

并发是指同时处理多项任务——这是程序编写方式的一种结构属性。并行是指同时执行多项任务——这是程序运行方式的一种物理属性。 并发使并行成为可能,但并不能自动实现并行。

没有并发,能否实现并行?

从本文讨论的意义上讲,无法实现有实际意义的并行。 并行执行需要可分配的独立工作单元,而定义这些单元正是并发所指的内容。硬件级并行(如 SIMD)是一个例外——它将单个指令流并行化处理数据,而无需程序中存在任何并发结构。

Python 的 GIL 还存在吗?

是的,在默认构建中依然存在。 自 3.13 版本起,CPython 还提供了一个可选的自由线程构建版本,其中禁用了 GIL;而 PEP 779 已将该构建版本纳入 3.14 版本的官方支持范围。默认构建版本仍包含 GIL,因此除非你特意安装了自由线程解释器,否则 GIL 依然存在。

异步(async)与多线程是一回事吗?

不是。异步并发使用单个线程,并在显式的 await 点进行协作式切换,因此每次只有一段代码运行,且切换仅发生在您编写的位置。 多线程则使用多个操作系统线程,并采用抢占式切换,这种切换可能发生在任何位置。异步模式更易于理解;而多线程可以在运行时允许的情况下实现真正的并行性。

我应该发起多少个并发请求?

比你想象的要少。从 10 个左右开始,测量每秒完成的请求数,然后逐步增加,直到该数值停止上升。上限通常取决于目标服务器的容忍度,而非你机器的容量;超过该点后,额外的并发只会引发错误,而非提升吞吐量。

并发能让我的代码运行得更快吗?

只有当你正在等待某些操作时才行。对于 I/O 密集型任务,性能提升显著。对于单核上的 CPU 密集型任务,由于切换开销,并发反而会使运行速度略微变慢——你需要真正的并行性,这意味着需要多核处理器以及能够充分利用它们的运行时环境。

多进程和多线程有什么区别?

线程在同一个进程内共享内存,这使得通信成本低廉,但共享状态却很危险。 进程拥有独立的内存,这使得它们更安全,但通信成本较高。在默认构建的 Python 中,进程是实现 CPU 密集型任务真正并行性的方式;而线程则为 I/O 密集型任务提供并发性。

运行并发请求时需要代理吗?

本质上并不需要。 当并发性使你的请求足够显眼,以至于目标服务器对你发起的请求进行速率限制或封禁你的IP地址时,才需要使用代理。对于配额宽松的API,或者你有权限爬取的网站,仅靠并发性就足够了。对于按IP地址限制的网站,流量分发就成了限制因素——而这与修复代码是两码事,需要另行解决。

总结

这一区分值得牢记,因为它将一个模糊的问题——“如何让这个过程更快?”——转化为一个具有可验证答案的具体问题:我是在等待,还是在进行计算?

如果你正在等待,你就需要并发,并且需要利用你所使用的编程语言提供的任何形式的并发。其收益巨大,通常无需额外硬件成本,而且在每个主流运行时环境中都可用。 如果你正在进行计算,仅靠并发是无济于事的,你需要真正的并行处理——进程、工作线程、跨核心的 goroutine,或者在 Python 的情况下,可能是带有自身权衡取舍的自由线程解释器。

大多数实际程序在不同阶段兼具这两种情况,而顺序比选择本身更为重要。 解决主要的瓶颈,重新测量,你会发现结果已经发生了变化。一个原本 80% 受网络限制的管道,一旦你解决了网络问题,就会变成 96% 受解析限制的管道,而第二次优化与第一次相比,完全是另一项工作。