← 返回文章列表

学习文章 · 多进程、多线程与并发

多进程、多线程与并发:两张图片怎样一起处理

从批量生成缩略图出发,看懂进程与线程的边界、单核并发与多核并行,以及共享数据为什么需要同步。

依据官方文档与教学示意

假设你写了一个生成缩略图的程序。用户选中图片 A 和 B,程序读取图片、缩小尺寸、写出结果,最后显示“已完成 2 张”。

最直接的写法,是先完整处理 A,再完整处理 B。现在你希望两张图片都能往前推进,并且有多个 CPU 核心时能一起计算。多线程、多进程和并发,就在这个需求里相遇了。

先抓住三个不同的问题:进程决定代码和资源处于什么边界,线程承载实际执行,并发描述多项工作怎样在一段时间里共同推进。 它们可以配合使用,但不是三个互相替换的名称。

同时在做两件事,可以有两种执行方式

先只看缩放图片需要的计算,暂时拿掉读取和写文件。把每项计算想象成前半、后半两段;这是为了看清顺序,并不表示真实程序必须在中点切换。

如果程序只能使用一个 CPU 执行位置,它可以先计算 A 的前半,再计算 B 的前半,随后回来继续 A,最后继续 B。A 没有做完时,B 已经开始了;两项工作的执行过程发生了交错。这是并发。

如果有两个可用的执行位置,就可能让一个计算 A,另一个计算 B。在某个相同的时刻,两项计算都真实地占用 CPU。这是并行。并行提供了实现并发的一种方式;并发本身不要求同一时刻执行两条任务。

看下面的图时,重点看同一个时刻有几条轨道在工作。

单核轨道依次执行 A 前半、B 前半、A 后半、B 后半;双核的两条轨道分别同时执行 A 和 B。
图 1:作者制作的教学示意。上方靠交错推进,下方存在同时计算的区间。两组时间轴没有标定统一比例,不能据此计算加速倍数;图中省略了调度、读取和写入的开销。

这里的“一个核心”采用入门简化,指一个可供调度的逻辑处理器。真实机器可能有超线程等机制,物理核心数与逻辑处理器数并不总相同。Windows 处理器说明区分了这些硬件关系。后面讨论的是普通操作系统线程;不会把协程、任务对象都直接当成操作系统线程。

这也解释了为什么“开了两个线程”不能直接推出“快一倍”:只有一个可用执行位置时,两条计算路径仍然需要轮流占用它。即使有两个,任务是否独立、是否被锁挡住,也会影响它们能不能同时前进。Windows 调度说明说明了操作系统如何从可运行线程中选择下一位执行者。

程序启动之后,进程保存什么,线程继续什么

磁盘上的程序文件只是代码和数据的载体。把它启动起来,操作系统会建立运行所需的状态。对这个缩略图程序而言,进程提供地址空间,以及运行时需要使用的资源;图片数据、任务列表和“已完成张数”等状态可以放在其中。

线程则有自己的执行进度。它需要记住正在执行哪段代码、寄存器里的值,以及函数调用走到了哪一层。被暂停后,这些上下文使它以后能够接着执行。一个进程可以有多条线程,操作系统调度的是其中可运行的线程。Windows 进程与线程文档将两者分别描述为资源环境和可被调度的执行实体。

在我们的例子里,可以只启动一个缩略图进程,然后在里面创建两个工作线程。线程 A 执行处理图片 A 的函数,线程 B 执行处理图片 B 的函数。两条线程可以运行同一份函数代码,只是传入不同的图片,并保持各自的调用进度。

本例中的内容同一进程里的两个工作线程怎样使用
图片处理函数的代码可以执行同一份代码
放在共享对象里的任务表与计数可以访问同一份数据,需要安排并发访问
当前执行位置、寄存器上下文每条线程各自保存
函数调用栈每条线程各有自己的栈,用来维护调用过程

“各有自己的栈”不表示栈拥有进程级的内存隔离。线程仍在同一地址空间里;只要代码持有合适的地址或对象引用,就不能靠“这是另一条线程的数据”来保证访问安全。POSIX 线程文档同样区分了共享的全局内存、堆与各自的栈。Linux pthreads 文档

如果改成启动两个工作进程,结构就变了:两个进程各有自己的地址空间,每个进程中仍然至少需要一条线程执行图片处理。多进程并没有跳过线程这一层;它增加了资源与隔离边界。

谁让两个函数开始运行

把一个普通函数调用写两遍,不会凭空出现两条执行路径。下面的 C# 片段表示由当前线程先后完成两次调用;MakeThumbnail 是省略实现的业务函数。

MakeThumbnail("A.jpg");
MakeThumbnail("B.jpg");

当处理 A 的调用没有返回时,当前线程就还没走到下一行。要让两项计算能够交错或者并行,就要把工作交给能安排独立执行的机制。

这里用显式创建线程来暴露这个动作。代码是教学片段,省略文件读取、异常处理和结果展示,并不是一份完整的图片处理程序。

using System.Threading;

var workerA = new Thread(() => MakeThumbnail("A.jpg"));
var workerB = new Thread(() => MakeThumbnail("B.jpg"));

workerA.Start();
workerB.Start();

workerA.Join();
workerB.Join();

new Thread(...) 指定新线程要执行的入口。Start() 使线程具备被安排运行的条件,调用它的线程继续往下走;它不承诺工作线程在这一刻立刻执行。两个 Start() 的调用顺序,也不能保证 A 一定先处理完成。Thread.Start 文档

最后两个 Join() 让调用者等待工作线程结束。在这个片段中,两条工作线程都先启动,调用者再等待;不会因为先等待 A,就把已经启动的 B 禁止运行。实际图形界面通常需要避免让界面线程长时间阻塞,这里仅用等待动作说明“启动工作”和“等工作完成”是两个阶段。Thread.Join 文档

单核上,调度器可以让 A 运行一段,再换成 B。切换时保存 A 的执行上下文、恢复 B 的上下文;之后切回来,A 能从原来的进度继续。两条线程不是各自私有一颗 CPU,也不必永远绑定某个核心。Windows 上下文切换文档

现在把读取图片放回来。如果 A 因等待文件读取而阻塞,它暂时没有可执行的下一步,调度器便可以运行其他就绪线程,包括 B。设备可能仍在完成 A 请求的 I/O;等待的 A 线程本身并没有持续计算。因此,增加并发有时改善的是等待期间的利用率和响应性,而不是凭空增加计算能力。

两张图片都成功,为什么计数可能只有 1

让两个线程各自处理独立图片很容易理解。麻烦出现在它们都要修改同一个“已完成张数”时。

假设计数最初为 0。图片处理完成后,每条线程都做一次普通的 completed++。从源代码看只有一个递增表达式,但不能据此假定“读取旧值、加一、写回”整体不可打断。

下面是一种可能的交错,用来解释丢失更新;它是教学推演,不是本次执行产生的日志。

执行顺序线程 A线程 B共享计数
1读到 0,准备写入 10
2也读到 0,准备写入 10
3写入 11
4写入 11

两张缩略图都可能已经生成,计数却只增加了一次,因为两个执行者都根据同一个旧值算出了结果。真正需要保护的是这次更新的整体关系。只要允许两次更新交错,单核也能出现这个问题,并不需要两颗核心在同一瞬间写入。

这类结果取决于时序的情况叫竞争条件。针对这个简单计数,C# 可以使用原子递增:

Interlocked.Increment(ref completed);

这里的 completed 是两个线程实际引用的同一个 int 存储位置。Interlocked.Increment 保证递增操作的原子性,不会出现前表那样把两次递增合成一次的结果;但它不会顺便保护其他字段,也不意味着整个图片处理函数都变成不可分割的操作。Interlocked.Increment 文档

如果每次完成还要同时修改“计数”和“结果列表”,就要考虑这组状态的一致性。例如用同一把锁保护这一小段更新,并要求相关读写遵守同一规则。这样另一条线程进入受保护区域时,能看到前一条线程完成后的状态。锁的作用范围应围绕需要一起维护的数据,不能为了方便把整个耗时缩图过程都包进去,否则两条工作线程可能长期排队。Windows 线程同步说明

同步也可以通过减少共享来实现:工作线程只报告结果,由一个执行者统一更新统计。关键是明确谁拥有状态、谁能改它、改动按什么规则排列。

换成多进程,结果怎样汇总

继续使用 A、B 两张图片,不过这次安排三个进程:管理进程分配工作,工作进程 A 和 B 各处理一张。管理进程保留任务表和最终统计。

不能直接认为工作进程里的 completed++ 会改变管理进程的计数。即使三个程序里都有一个名为 completed 的普通变量,它们通常也是不同地址空间中的不同数据。

一种具体做法是使用管道通信。管理进程发送任务编号、输入路径与输出路径;工作进程收到数据后,在自己的线程上调用图片处理函数。写好结果后,它再发送一条完成消息,例如:

{
  "taskId": "A",
  "status": "done",
  "outputPath": "thumbnails/A.jpg"
}

管理进程收到这条消息,把任务 A 从“处理中”改为“完成”,再由自己更新计数。收到 B 的完成消息后,计数成为 2。这个方案中跨越进程边界的是按约定编码的数据;工作进程没有拿着管理进程里的函数指针,直接跳进去替它修改普通变量。

管道只是 IPC,也就是进程间通信的一种实现。Socket 和共享内存也能承担不同需求。共享内存尤其容易被误解:它允许进程显式映射同一块共享存储,但仍然需要同步规则;使用它不会自动消除前面的竞争。Windows IPC 文档

进程边界使私有内存更容易分开,也有助于管理独立生命周期。但工作进程异常退出后,管理进程仍需发现失败、处理未完成任务。两个进程若写同一个结果文件,也依然可能冲突。隔离不会自动替应用设计结果汇总、错误恢复和资源归属。

等待很多,还是计算很多,会改变安排方式

缩略图程序里,读取和写入涉及 I/O,缩放图片涉及 CPU 计算。两者的比例改变后,适合的并发安排也可能改变。

如果大量时间在等 I/O,目标是让一次等待不拖住所有工作。可以用多个工作线程,也可以使用异步 I/O:当前操作尚未完成时,调用方先把执行机会交还,完成后再继续后续逻辑。async、await 描述这类控制流时,并不自动意味着为每个操作创建一条新线程;实际执行依赖具体 API 和运行时。C# 异步编程文档

如果大部分时间用于 CPU 计算,要缩短总时间,就要能把独立计算分配到多个可用执行位置。多线程或多进程都可能参与这种安排,实际并行能力还受语言运行时、库内部实现与资源限制影响。仅给计算函数套上异步语法,不会使算术本身多出计算资源。

任务从两张增加到一万张时,也没有必要为每张图片都创建一个线程或进程。可以保留数量受控的工作者,让它们从任务队列持续取下一张图片。线程池、进程池就是管理和复用执行者的常见方式;具体数量需要围绕 CPU、内存和 I/O 容量确定。Windows 线程池文档展示了工作线程与排队任务的关系。

回到这个程序,选择可以落到具体职责上:同一进程内共享数据、成本可控的独立工作,可以交给工作线程;需要独立地址空间、独立运行环境或生命周期的部分,可以拆成进程并设计通信;只需要处理等待,则先检查是否有合适的异步 API。它们也可以组合,比如一个管理进程配合若干工作进程,每个工作进程内部再使用有限数量的线程。

放回软件体系:资源边界、执行单元与协作方式

这些知识位于软件体系中的“运行与操作系统”部分。业务代码和依赖被装入进程后,由线程承载执行;操作系统为可运行线程安排 CPU。代码需要文件、网络或其他系统服务时,再通过相应接口使用这些能力。

同一进程内,线程之间可以通过共享数据协作,因而要安排状态所有权与同步。跨进程后,私有地址空间形成边界,协作需要 IPC 等明确通道。至于事件驱动、任务队列和异步控制流,则描述工作如何组织与交付;必须继续看它们由哪些线程执行、跨越哪些边界,才能判断有没有并行。

下面是体系白板第 06 区的局部视图。先看两个进程的边界,再看里面的线程与下方的调度关系。

软件体系白板第 06 区:进程 P 包含共享数据及线程 A、B,进程 Q 包含自己的数据与线程 C;进程通过 IPC 协作,操作系统调度就绪线程在 CPU 上执行。
图 2:从可编辑 Excalidraw 体系白板导出的局部视图。矩形表示资源或系统边界,箭头注明访问、通信与调度关系;它不是一次任务的时间顺序。

这一局部连接回第 03 区的系统执行视角与第 04 区的组件协作视角。你可以在软件知识白板中缩放查看总览。进程包围资源与线程,调度器为线程安排 CPU,IPC 连接不同进程;“多进程”“多线程”“并发”各自回答的那个问题就有了明确位置。

本页目录