你好并发世界

读完能用自己的话说清并发与并行的区别,看懂第一个 std::thread 双线程程序,并解释它的输出为什么没有固定顺序。

学习目标

  • 能用自己的话解释并发并行到底差在哪——一个厨师轮流做几道菜,和几个厨师各占灶台同时做,分别对应哪个
  • 能看懂一个最简单的 std::thread 双线程程序,知道 std::threadjoin 各在干什么
  • 能回答:两个线程同时往屏幕打印,输出顺序固定吗?为什么?

一个人的后厨,忙不过来

想象你一个人开了家小餐厅,后厨只有你一个厨师、一个灶台。来了三桌客人,每桌点一道菜。你只能一道一道做:做完第一道,再做第二道,再做第三道。客人等得越来越急——明明灶台还空着大半时间(比如等水烧开时你只能干站着),却没人帮你。

要是能再雇两个厨师、多搭两个灶台呢?三道菜同时开火,等水烧开的工夫,另一个厨师已经在切菜了。出菜快了三倍,客人也满意了。这就是这一章要解决的问题:怎么让一台电脑像多厨师后厨一样,把活儿同时铺开干,而不是死等一件做完再做下一件。

没有这套本事会怎样?你的程序就永远是「一个厨师一个灶台」——下载文件时界面卡死、多核 CPU 闲着一大半、该秒开的事要转好几秒圈。这一章就带你把第一个「多厨师」程序跑起来。

并发与并行:一个厨师 vs 两个厨师

先把两个最容易混的词分清楚,它们是整本书的地基。

说的是:多个任务都在推进,但不一定真的同时在动手。 一个厨师守着一个灶台,先翻两下 A 菜、转头切两刀 B 菜的料、再回去给 A 菜加盐……每道菜都在往前走,可任意一个瞬间,他的手其实只在一道菜上。切换够快,客人看上去就像「两道菜一起在做」。

则更强:同一时刻真的有多件事在同时发生。 两个厨师、两个灶台,一个炒 A、一个炒 B,同一秒钟两口锅都在响。这只有「灶台够多」——也就是 CPU 有多个核——时才办得到。

一句话记牢:并行一定是并发,但并发不一定并行。 单核机器也能并发(一个厨师轮流切换),但要并行就得有多个核(多个厨师各占灶台)。下面这张动画把两者掰开演给你看:

猜一猜:在只有一个灶台(单核)的机器上,两道菜 A 和 B 有没有可能在某一瞬间「真的同时」在做?先想一下,再点播放看答案。

可交互
单核 · 并发(Concurrency)一个灶台轮流做 A、B 两道菜 — 任一时刻只做一道任务 A任务 B灶台A1B1A2B2A3B3时间 →双核 · 并行(Parallelism)灶台1灶台2A1B1A2B2A3B3

第 1 / 9 步 · 单核·并发:灶台只做 A1——开始做第一道菜 A

先看上区:一个灶台轮流切换两道菜(并发);再看下区:两个灶台同时各做一道(并行)。可暂停、单步、拖进度。

并发 vs 并行:并发是「一个灶台来回切换、看起来同时」,并行是「两个灶台真正同时」。 单核机器上多任务靠飞快切换造出并发的错觉,多核机器才能真正并行。

看懂了上区那条扫描线在 A、B 之间来回跳——这就是并发的真相:宏观上两道菜都在推进,微观上一个灶台任一刻只做一道。下区两条灶台泳道始终并肩亮,才是真正的并行。

为什么要用并发:两个理由

知道了是什么,再说为什么值得费这个劲。用并发主要图两件事。

第一是 性能。它又分两种:一种是把一件大活儿拆成几块、几个厨师分头干,比如把一篮子菜分给三个厨师同时洗——这叫 任务并行;另一种是同一种处理套在一大堆数据上,比如十个厨师每人洗一筐一模一样的菜——这叫 数据并行。两种都是为了「人多力量大,早点出菜」。

第二是 关注点分离:把性质不同的活儿交给不同的「厨师」,让代码更清楚。比如一个厨师专门盯着前台招呼客人(响应界面点击),另一个厨师在后厨埋头炒大菜(跑耗时计算)。这样前台厨师永远不会因为后厨那锅炖了半小时的汤而僵在原地——界面就不会卡死。即使只有一个灶台、并不能真并行,这种「各管一摊」的拆法也让程序更好懂、更好维护。

何时不使用并发:收益必须覆盖协调成本

并发会引入线程启动与调度、同步、缓存一致性、额外内存和测试状态空间。工作只有几微秒、任务彼此强依赖、共享状态远多于独立计算,或顺序版本已经满足延迟与吞吐目标时,增加线程通常只会让程序更慢、更难证明正确。

决策前先测顺序基线,再问四件事:有没有可重叠的等待或计算,任务粒度能否摊薄开销,目标机器有多少可用并行资源,失败与停止能否清晰传播。并发不是默认优化开关;没有指标收益时保留顺序实现,是合理的工程选择。

C++11、C++14 与 C++17 的并发支持

C++11 首次在语言内存模型和标准库层面建立可移植并发基础,包括 std::thread、mutex、condition_variable、future/promise 与 atomic。C++14 增补了 std::shared_timed_mutex 等设施;C++17 增加 std::shared_mutexstd::scoped_lock 和带执行策略的并行算法。后续标准还有新能力,但本书第二版以 C++17 边界为主,不能把 std::jthread 等 C++20 API 混进示例后仍声称只需 C++17。

标准库抽象并不消除平台成本差异。native_handle 等接口可接入平台特性,但会牺牲可移植性;只有性能测量或功能需求证明必要时才越过标准接口,并把平台分支隔离起来。

进程与线程:两家餐厅 vs 一家餐厅的两个厨师

那「多个厨师」在电脑里到底是什么?这就要分清

一个进程像一家独立餐厅:默认拥有隔离的虚拟地址空间。不同进程可以用管道、套接字或显式共享内存通信,成本和隔离强度取决于机制,不能一概断言“进程通信一定慢”。

而同一进程里的线程能直接访问同一批对象,传递共享数据很方便;代价是对象生存期、同步和可见性都必须由程序正确约束。下面这张图采用常见实现模型帮助理解,但不把“每线程必须有某种固定栈布局”当成 C++ 标准保证:

两个进程:内存互相隔离各有一整套独立地址空间,谁也看不见谁进程 1代码段全局数据堆 heap栈 stack进程 2代码段全局数据堆 heap栈 stack🧱 内存互不可见一个进程 · 两个线程共享同一地址空间,但各有各的栈进程(一个地址空间)代码段全局数据堆 heap(共享)↑ 共享区:两线程都能读写线程1 栈线程2 栈↑ 各自独立:互不干扰
进程之间内存彼此隔离(要通信得专门「打电话」);同一进程内的多个线程共享代码、全局数据和堆, 但各有独立的栈。线程间共享数据又快又方便,也正是后续「数据竞争」麻烦的根源。

每个线程都有独立执行状态与自动对象实例,常见 ABI 用独立调用栈实现。线程仍能通过指针或引用访问同一共享对象;若结果依赖不可控时序,这是 。若两个线程无同步地访问同一内存位置、至少一个访问是写且操作不都是合适原子操作,则形成 。后续章节会分别用互斥、消息和原子操作建立同步关系。

动手:两个线程一起喊话,谁先谁后?

理论说完,来真切感受一次「线程之间顺序不可控」。下面这个小演示模拟一个最常见的场景:主线程和一个 worker 线程同时往屏幕打印各自的几行字。每点一次「运行」,两组字就按一个随机的交错顺序蹦出来。

反复运行会看到两条执行流之间没有固定先后。每个线程内部的表达式仍按 C++ 规则排序,但这不保证多个线程向同一输出流写入的整条消息不可分割;真实日志可能发生字符或片段交错。需要完整记录时要用同步日志策略,而不能把“这次刚好整行输出”当成调度保证。

代码:你的第一个双线程程序

先看一个单线程的「Hello World」作对照——一个厨师,顺着做:

#include <iostream>
 
int main() {
    std::cout << "Hello, World!\n";
    return 0;
}

这就是普通程序:从 main 进门,一行接一行跑到底。现在请来「第二个厨师」——用 std::thread 另起一个线程,让它去跑一个函数:

#include <iostream>
#include <thread> // 线程库,std::thread 在这里
 
void hello() { // 交给新线程去跑的「一道菜」
    std::cout << "你好,来自新线程!\n";
}
 
int main() {
    std::thread t(hello); // 起一个新线程,立刻开始跑 hello()
    t.join();             // 等这个线程跑完,再继续往下
    return 0;
}

逐行看:#include <thread> 引入线程库——std::thread 这个类型就声明在这里。std::thread t(hello); 是关键一句:它创建并启动一个新线程,新线程一诞生就去执行 hello 这个函数。此刻程序里有了两个执行流——main(主线程)和 t(新线程)在并发地跑。

最后那句 t.join(); 不能漏:它让主线程停下来等 t 跑完。join 的字面意思是「会合」——主厨师在这里等新厨师把那道菜做完,两人「会合」后再一起往下走。如果 main 不等就先跑到了结尾,会出大乱子(见下一节第一个坑)。

如果想看到「输出顺序不确定」的真效果,让两个线程都打印就行:

std::thread t(hello);             // 新线程打印「你好,来自新线程!」
std::cout << "你好,来自主线程!\n"; // 主线程同时也在打印
t.join();

这两次输出操作谁先发生没有保证;在共享输出上,消息是否保持整行也不能作为通用同步契约。这个例子只用于观察调度不确定性,业务逻辑不能依赖显示顺序。

容易踩的坑

小结

  • 并发是多个任务都在推进(单核靠飞快切换造出「同时」的错觉),并行是多核真正同时动手——并行一定并发,并发不一定并行
  • 用并发图两件事:性能(任务并行 + 数据并行)和关注点分离(各管一摊、界面不卡)
  • 工作太小、依赖太强或协调成本超过收益时,应保留顺序实现
  • C++11 建立线程库与内存模型,C++14/17 继续补充共享锁、组合锁和并行算法
  • 进程默认地址空间隔离;同进程线程可访问共享对象,但独立执行状态的物理栈布局由实现决定
  • std::thread t(f); 起一个新线程去跑 ft.join() 让主线程等它跑完再继续
  • 竞态条件是时序依赖的逻辑问题;无同步冲突访问形成数据竞争并导致未定义行为

练习

问题 1(改代码型) 把下面这个单线程程序改成双线程:让一个新线程去打印 "我是新厨师",主线程打印 "我是主厨师",并保证程序正常退出。跑几次,观察两次输出操作的先后是否固定。

#include <iostream>
 
int main() {
    std::cout << "我是主厨师\n";
    return 0;
}

问题 2(问答型) 同事说:「我的电脑是单核的,所以根本没法用并发,写多线程没意义。」这句话对吗?请用「厨师 / 灶台」的话纠正他。

问题 3(问答型) 「两家餐厅」和「一家餐厅的两个厨师」分别对应进程还是线程?两者如何通信,为什么共享对象的线程更容易出现同步 bug?

名词解释

本章出现的专业名词,用大白话再讲一遍。

并发(concurrency)

多个任务在同一段时间里都在「往前推进」,但不一定真的同时在动手。在只有一个 CPU 核的机器上,它靠在多个任务之间飞快地来回切换,造出「同时进行」的错觉。像一个厨师守着一个灶台、轮流照看好几道菜。详见本章「并发与并行」一节。

并行(parallelism)

同一时刻真的有多件事在同时发生,靠多个 CPU 核(或多台机器)各干各的实现。像两个厨师各占一个灶台、同一秒钟两口锅都在炒。并行一定是并发,但并发不一定并行——单核做不到并行。详见本章「并发与并行」一节。

进程(process)

由操作系统提供独立虚拟地址空间和资源上下文的执行实例。进程默认隔离,可通过管道、套接字或显式共享内存通信;成本取决于具体机制。

线程(thread)

进程内可并发执行的控制流。同一进程线程可访问共享对象,每次调用与自动对象属于各自执行状态;常见实现使用独立调用栈,但布局不是 C++ 标准接口。

std::thread

C++ 标准库里代表「一个线程」的类型,声明在头文件 <thread> 里。写 std::thread t(f); 就会创建并启动一个新线程去执行函数 f。用完前必须 join()(等它跑完)或 detach();joinable 的 thread 对象析构会调用 std::terminate。detach 后仍须保证线程引用对象的生存期。

竞态条件(race condition)

程序结果或正确性依赖并发操作相对时序的逻辑问题。它比数据竞争更宽:即使每次访问都加锁,错误的检查与操作接口组合仍可能形成竞态条件。

数据竞争(data race)

对同一内存位置的冲突并发访问缺少 happens-before 关系,至少一个为写且没有合适原子语义。C++ 规定存在数据竞争的程序行为未定义。

资料与写作方式声明

本章以C++ 并发编程实战(第2版)权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

讨论

评论区加载中…