除夕夜,一家小饭店的后厨忙得团团转。大厨老张原本只有一位帮厨,洗菜、切菜、掌勺全靠一个人,勉强应付得来。可今年年夜饭订单翻了三倍,老张咬咬牙,又雇了两位厨师:小明和小刚。 两位厨师共用一张案板、一口大锅、一个盐罐。案板是共享内存,盐罐上的数字记录着“已经加了几次盐”。菜谱就是代码,每位厨师按自己的菜谱干活。看起来井然有序,可问题恰恰出在那个共享的盐罐上——小明和小刚同时伸手去抓盐罐,一个还没放回去,另一个又伸手了。盐罐上的数字彻底乱了,一锅汤要么咸得要命,要么淡而无味。 这个故事,就是并发编程的缩影。在C++里,多线程给了我们“同时干活”的能力,但共享数据一旦被多个线程同时读写,就会像两位厨师抢盐罐一样发生数据竞争。幸运的是,C++为我们准备了三种武器:原子操作(特制盐罐,取放一气呵成)、互斥锁(厨房钥匙,谁拿到谁进)、条件变量(厨房铃铛,菜好了喊一声)。本篇就用这个厨房故事,带你迈出C++多线程与原子操作的第一步。

岗位关联

多核时代,并发是榨取CPU性能的必由之路。理解线程、原子操作、内存序与同步原语,你就能写出既快又稳的系统级代码——这正是高性能服务端、游戏引擎、AI推理框架共同的技术底座。

目录
  1. 可视化演示
  2. 线程创建:雇来两位帮厨
  3. 数据竞争:盐罐上的糊涂账
  4. 原子操作:多功能盐罐
  5. 内存序:纸条的可见顺序
  6. 互斥锁:厨房钥匙
  7. 条件变量:厨房铃铛
  8. 常见陷阱:灶台上的坑
  9. 并发实践:多核厨房的功力
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

展示线程生命周期状态转换、CAS原子操作循环及竞态条件与互斥锁保护对比。

本节配有交互动效(进入视口后自动加载,约 3KB)。

线程生命周期 std::thread创建后进入就绪态 调度器分配CPU进入运行态 join等待线程结束后回收资源

线程创建:雇来两位帮厨

大厨老张决定雇两位帮厨。在C++的世界里,“雇人”不是填张表那么简单——你得用std::thread向操作系统正式下聘书:我要创建一条新的执行流。线程创建后,它和主线程并行跑,主线程不用等它也能继续干自己的活。

创建线程的语法简洁得出奇,一行代码就够:std::thread t(make_dish, "红烧肉")。这行代码执行后,系统立刻启动一个全新的执行路径,像店里多了一位手脚麻利的帮厨,马上开始按菜谱干活。

线程跑完之后怎么办?两个选择。t.join()是“原地等他回来交差”,主线程会阻塞,直到子线程结束。t.detach()则是“干完活不用报告,各回各家”。注意detach之后,主线程失去了与这个线程的关联句柄,再也无法等待它。

厨师终于上岗。但厨房只有一张案板、一个盐罐,麻烦也随之而来——他们开始抢盐罐了。

std::thread t(worker_fn, args...);\text{std::thread}\ t(\text{worker\_fn},\ \text{args...});
一行代码雇来一个帮厨,join是等他交差,detach是放养。
  1. std:thread:C++标准库的线程类名,像“招聘合同”的官方抬头,声明这是一条独立执行流。
    std::thread\text{std::thread}
  2. t(……)t是线程对象,括号里第一个参数是任务函数(厨师要执行的菜谱),后面的参数是要传给它的材料。
    t(worker_fn, args...)t(\text{worker\_fn},\ \text{args...})
  3. join()主线程调用t.join(),站在厨房门口等这位帮厨干完活再走。
    t.join()t.\text{join}()

数据竞争:盐罐上的糊涂账

小明要往汤里加盐,小刚也要加。共享的盐罐上,标签写着“已加5次”。问题在于,“加盐”不是一个动作,而是三个:看一眼数字(读)、心算加一(改)、把新标签贴回去(写)。

多线程的世界里,这三个步骤之间随时可能被另一条线程插一脚。小明看到5,刚想写上6,小刚也凑过来,看到的还是5。小刚贴上6,小明也贴上6——两人各加了一次盐,标签却只多了1。这就是经典的“丢失更新”。

这种多个线程同时读写同一块内存、且至少一方在写的现象,就是数据竞争(data race)。在C++标准里,数据竞争属于未定义行为:程序可能崩溃、死循环,或者得出匪夷所思的结果。

盐罐要防抢,就得想个办法让“取、算、放”一气呵成——这正是原子操作要干的事。

count=count+1\text{count} = \text{count} + 1
一次“加盐”分三步,两步之间有人插队,账就乱了。
  1. 右侧 count先从共享内存把当前值读到寄存器,好比看一眼盐罐标签。
    count (read)\text{count} \ (\text{read})
  2. count + 1在寄存器里完成加法,好比心算“再加一次盐”。
    count+1\text{count} + 1
  3. 左侧 count把新值写回共享内存,好比贴上写有6的新标签。
    count (write)\text{count} \ (\text{write})

原子操作:多功能盐罐

C++准备的特制盐罐叫std::atomic<T>。普通int变量没有防抢能力,但std::atomic<int>的每个操作,底层都映射成CPU提供的原子指令:要么不做,要么瞬间做完,中间不可能被插一脚。

最常用的是fetch_add:原子地完成“读-加-写”。count.fetch_add(1)就是count = count + 1的原子版,不会丢失更新。它还支持load()读取和store()写入——分别是安全的“看标签”和“贴标签”。

更强大的是CAS(compare_exchange):只有当v等于expected时,才把v更新为desired,否则不更新并返回失败。它像“先核对标签是不是我记的数,是就改,不是就拉倒”,是构建无锁算法的基础。

原子操作守护了单个变量,但复杂的场景远不止保一个数——如果两件事必须同时完成,原子操作就不够用了。这时得动用厨房钥匙。

count.fetch_add(1)\text{count}.\text{fetch\_add}(1)
if v==expected then v←desired\textbf{if}\ v == \text{expected}\ \textbf{then}\ v \leftarrow \text{desired}
fetch_add是原子版加盐,CAS是“核对标签再改”。
  1. count被声明为std::atomic<int>的原子变量,就是那只特制盐罐。
    count∈atomic\text{count} \in \text{atomic}
  2. fetch_add(1)原子地完成读、加、写三步,中间不可打断。
    read→add→write\text{read} \to \text{add} \to \text{write}
  3. 比较 v == expected把罐子当前值和预期值对照,像核对标签是不是自己记的数。
    v==expectedv == \text{expected}
  4. 交换 v ← desired相等才写入新值,不等就什么都不做,返回是否成功。
    v←desiredv \leftarrow \text{desired}

内存序:纸条的可见顺序

盐罐保住了,新问题浮出水面:纸条的可见性。厨师A明明把盐放好、写了张纸条贴在门口,厨师B却半天看不到。多核CPU里,每个核心有自己的缓存,写操作先落到缓存,什么时候刷进共享内存是另一回事。

内存序(memory_order)管的就是“这张纸条何时对其他核心可见”。宽松序(relaxed)只保证原子变量本身不撕裂,不保证顺序;获取-释放(acquire/release)保证:线程A在release写之前的所有操作,对随后acquire读到该变量的线程B全部可见;顺序一致(seq_cst)则给所有线程一个统一的“全局快照顺序”,最安全也最贵。

一个经典片段:线程A做x.store(1, release),线程B做y.load(acquire)。B一旦看到x的值,就知道A在发布之前写的所有数据都准备好了——像极了“张老板贴出'已消毒'告示,客人看到告示就知道可以进店”。

内存序是把双刃剑:调对了性能飞升,调错了bug诡异。新手请从顺序一致起步,熟练后再考虑宽松优化。

x.store(1, release);y.load(acquire)x.\text{store}(1,\ \text{release}); \qquad y.\text{load}(\text{acquire})
内存序决定“纸条何时被看见”,release贴告示、acquire等告示。
  1. store(1, release)线程A以release语义写入x,相当于贴出“已消毒”告示,之前的写操作不许乱序越过它。
    x=1, releasex = 1,\ \text{release}
  2. load(acquire)线程B以acquire语义读取x,一旦读到1,就能看见A之前的所有写操作。
    y=x, acquirey = x,\ \text{acquire}
  3. 成对出现release和acquire必须作用在同一个原子变量上,才能形成跨线程的同步。
    same x\text{same}\ x

互斥锁:厨房钥匙

有时候,我们要保护的是一段“复合动作”:比如先看账户余额再扣款,两步必须连着做完,不许别人插进来。原子操作管不了这么宽的场面,于是C++派出了互斥锁(mutex)——厨房钥匙。

规则很简单:进厨房前先lock()拿钥匙,干完活unlock()还钥匙,没拿到钥匙的线程只能在门口排队。被mutex保护的那段代码叫临界区(critical section),同一时刻只允许一个线程进入。

手动lock/unlock容易忘。std::lock_guard是智能钥匙扣,离开作用域自动归还;std::unique_lock更灵活,可以手动解锁,也是条件变量需要的搭档。死锁则像两个厨师各拿一把钥匙、都在等对方先进门——互相谦让造成的僵局。

互斥锁解决了“完整执行”,但它太重:一个厨师进厨房,其他人都得晾着。能不能让等待的人去睡一觉,被通知了再干活?条件变量登场。

lock_guard lk(m) ⇒ lock()到unlock()的临界区\text{lock\_guard}\ \text{lk}(m) \ \Rightarrow\ \text{lock()到unlock()的临界区}
lock_guard像智能钥匙扣,人走锁自动弹开。
  1. lock_guard lk(m)构造时自动调用m.lock(),拿到厨房钥匙。
    lk(m): m.lock()\text{lk}(m):\ m.\text{lock}()
  2. 临界区从这行开始到所在花括号块结束,都是受保护的区域。
    {… }\{ \dots \}
  3. 析构自动解锁离开作用域,lk析构调用m.unlock(),钥匙自动归还。
    lk∼: m.unlock()\text{lk}\sim:\ m.\text{unlock}()

条件变量:厨房铃铛

煲汤要等水烧开。厨师B不想空转锅铲,他想在旁边眯一会儿,水开了有人摇铃喊他。条件变量(condition_variable)就是那口铃铛。

使用模式:一个线程持锁后调用cv.wait(lk, pred)——它会原子地释放锁并进入睡眠。另一个线程在条件准备好后调用cv.notify_one()或cv.notify_all(),把沉睡者摇醒。醒来后线程重新拿锁,然后检查谓词是否成立。

虚假唤醒是著名的坑:线程可能没接到铃铛也自己醒了,或醒后发现条件其实没满足。所以wait的谓词必须用循环重新检查——这也是wait第二个参数存在的意义。C++标准明确允许虚假唤醒,所以必须while循环防御。

生产者-消费者模型就是靠铃铛完成的:生产者做好菜摇铃,消费者睡醒取菜。多线程协作的世界终于顺畅了。

cv.wait(lk, predicate)\text{cv.wait}(\text{lk},\ \text{predicate})
wait是“睡觉+听铃铛”,predicate是“醒来看一眼汤开了没”。
  1. cv条件变量对象,厨房里的铃铛。
    cv: condition_variable\text{cv}:\ \text{condition\_variable}
  2. wait(lk, pred)原子地释放锁lk并进入睡眠,直到被通知且pred返回true才重新锁上并继续。
    unlock→sleep→wake→relock\text{unlock} \to \text{sleep} \to \text{wake} \to \text{relock}
  3. predicate一个返回bool的谓词,是“确认汤真的开了”的检查,防御虚假唤醒。
    pred: bool\text{pred}:\ \text{bool}

常见陷阱:灶台上的坑

灶台上最滑的地板有几块:死锁、悬垂引用、volatile误区。死锁我们已经见过——两个线程互相持锁等待,像两位厨师各拿一把钥匙,互不相让。破解之道:所有线程按相同顺序给多个锁排队,或用std::lock同时锁多个。

悬垂引用往往藏在detach里。一个被分离的线程还在执行,但它引用的局部变量早已被主线程销毁,就像被解雇的帮厨还回去用已经回收的旧案板——未定义行为。永远不要detach一个引用了栈变量的线程。

volatile更是经典误区:它只告诉编译器“别优化我”,却不保证CPU缓存一致性和原子性,用它防并发等于刻舟求剑。

知道了坑的边界,就能画出安全地图。最后看看真正的高手怎么用多线程。

R1→R2且R2→R1 ⇒ 死锁R_1 \to R_2 \quad \text{且} \quad R_2 \to R_1 \ \Rightarrow\ \text{死锁}
两个线程各持一锁互相等待,形成环就死锁。
  1. R1 → R2线程1拿着资源R1,等待R2。
    hold R1, wait R2\text{hold}\ R_1,\ \text{wait}\ R_2
  2. R2 → R1线程2拿着R2,等待R1,形成环状等待。
    hold R2, wait R1\text{hold}\ R_2,\ \text{wait}\ R_1
  3. 死锁两人都不撒手,谁也无法推进。
    deadlock\text{deadlock}

并发实践:多核厨房的功力

多线程的最终目标是榨干多核CPU。但线程是昂贵的资源,每创建一个线程,操作系统都要分配独立的栈空间。一百个厨师挤在一个厨房里,除了比手肘长,效率反而更差。

线程池就是“固定雇佣量”的方案:一次性创建N个线程,它们常态待命,任务来了按需分配,避免了反复“雇人/解雇”的开销。无锁编程则是用原子操作+CAS实现高并发数据结构,在极热点数据上免去锁竞争。

衡量并发收益的金标准是Amdahl定律:假设程序可并行部分比例为p,有n个核,理想加速比是1 / ((1-p) + p/n)。哪怕核数无穷大,加速比上限也只有1 / (1-p)。再精巧的多线程设计,也胜不过串行瓶颈。

故事来到尾声:小明和小刚用原子操作管好盐罐,用互斥锁守住厨房门,用条件变量协调上菜顺序……一桌年夜饭在井然有序的并发中准时出锅。你,也迈出了并发编程的第一步。

S(n)=1(1−p)+pnS(n) = \frac{1}{(1-p) + \dfrac{p}{n}}
可并行部分占p,串行部分占1-p,后者决定了加速比天花板。
  1. p程序里可并行部分的比例,取值0到1。
    0≤p≤10 \le p \le 1
  2. 1 - p必须串行执行的比例,谁也躲不开。
    1−p1 - p
  3. p / n可并行部分被n个核平分后的耗时。
    pn\frac{p}{n}
  4. S(n)n个核下的理论加速比,串行比例决定天花板。
    S(n)≤11−pS(n) \le \frac{1}{1-p}

AI 应用场景

推理引擎的并发批处理

阶段:模型推理与部署优化

解决的问题:AI服务收到大量推理请求,传统串行处理吞吐低、响应慢。多线程能并行处理多个请求,但共享模型参数和结果队列会引发数据竞争。

具体案例:某推荐系统在线推理服务,用C++线程池接收用户请求,每个worker线程独立执行推理,用条件变量管理任务队列,用std::atomic计数器记录已完成请求数。改造后吞吐量提升数倍,且无数据竞争。

训练数据流水线

阶段:数据预处理与加载

解决的问题:GPU训练时经常“等饭吃”——CPU单线程预处理图片、做增强太慢,数据喂不饱GPU。

具体案例:一个生产线程负责读图和做数据增强,多个消费线程通过锁和条件变量从有界队列取数据,用std::atomic维护队列大小,避免重复消费。数据吞吐成功匹配GPU训练速度,训练迭代时间缩短约40%。

参数服务器的乐观并发更新

阶段:分布式训练

解决的问题:多个训练worker同时更新共享模型参数,直接写会互相覆盖,加重锁又会让吞吐骤降。

具体案例:参数服务器用compare_exchange实现乐观并发:读参数、算梯度、CAS写入,冲突则重试。由于极少冲突,无锁路径让更新吞吐翻了近一倍。

Python 演示

python52 行
import threadingimport time  class BankAccount:    def __init__(self, balance=2000):        self.balance = balance        self._lock = threading.Lock()     def withdraw(self, amount):        tmp = self.balance        time.sleep(0.0005)        tmp -= amount        self.balance = tmp     def safe_withdraw(self, amount):        with self._lock:            tmp = self.balance            time.sleep(0.0005)            tmp -= amount            self.balance = tmp  def run_unsafe(num_workers):    acc = BankAccount(2000)    threads = [threading.Thread(target=acc.withdraw, args=(50,))               for _ in range(num_workers)]    for t in threads:        t.start()    for t in threads:        t.join()    return acc.balance  def run_safe(num_workers):    acc = BankAccount(2000)    threads = [threading.Thread(target=acc.safe_withdraw, args=(50,))               for _ in range(num_workers)]    for t in threads:        t.start()    for t in threads:        t.join()    return acc.balance  if __name__ == "__main__":    n = 10    expect = 2000 - n * 50    got = run_unsafe(n)    print(f"无锁: 期望余额 {expect}, 实际 {got}")    got_safe = run_safe(n)    print(f"有锁: 期望余额 {expect}, 实际 {got_safe}")
目的:用Python的threading模拟C++多线程的“读-改-写”数据竞争,再演示互斥锁如何修复。
  1. tmp = self.balance先把余额读到本地,模拟C++里count从共享内存读进寄存器。
  2. time.sleep(0.0005)人为放大时间窗,让线程在此刻被切换,制造竞争。
  3. self.balance = tmp写回共享变量,如果多个线程同时写,就会互相覆盖。
  4. with self._lock:拿到互斥锁进入临界区,等价于C++的lock_guard。
  5. threading.Thread(target=acc.withdraw, args=(50,))像std::thread一样创建线程,指明执行函数和参数。
  6. t.start()启动线程。
  7. t.join()主线程等待所有子线程结束。
  8. balance_no_lock = run_unsafe(n)对比无锁与有锁的最终余额差异。

常见误区

误区“我在所有共享变量操作外面都加了mutex,数据安全肯定没问题了吧?”

正解锁诚实,但锁是局部的。如果代码里任何一处访问同一个共享变量时忘了加锁,数据竞争依然存在;锁的顺序不当还会死锁。好比厨房钥匙只锁了储藏室大门,可窗户还敞着,小偷照样进来。

为何常见:直觉上“加了锁=安全”,但锁只保护它包裹的临界区,保护不了其他入口。

误区“volatile告诉编译器不要优化,那多线程共享变量用它就安全了吧?”

正解volatile只防止编译器把变量缓存进寄存器,既不保证原子性,也不保证多核缓存一致性。CPU可能乱序执行,各核缓存各不相同。C++里多线程正确的可见性保障来自atomic和mutex,而非volatile。

为何常见:volatile名字听着就像“易变”,很容易和并发共享变量产生联想,加上编译器优化和CPU乱序执行的知识门槛高,误解就这样扎根了。

误区“atomic底层有特殊指令,肯定比mutex锁慢,所以锁是更好的选择?”

正解恰恰相反:无竞争时atomic的CAS只比普通加减慢一点点,而mutex的lock/unlock涉及系统调用或复杂的用户态队列,开销大得多。只有竞争激烈时atomic可能自旋消耗CPU。多数场景下,atomic是比锁更轻的选择。

为何常见:直觉上“额外的保证=额外的开销”,却忽略了mutex自己那套重量级机制。

误区“detach后就不需要管线程了,它自己会释放资源。”

正解detach只是让线程不阻塞主进程,线程本身仍会继续运行。如果它捕获了局部变量的引用或指针,而主线程已经销毁了这些对象,就会形成悬垂引用,属于未定义行为。正确做法是:用join保证生命周期,或让线程使用自己独立的数据。

为何常见:detach语义看起来像“甩手不管”,恰好掩盖了生命周期这个最需要管的点。

误区“条件变量的线程被notify叫醒,那条件一定成立,直接继续执行吧。”

正解虚假唤醒可能让线程在条件不满足时醒来;即使真的被notify,也可能有多个等待线程同时被唤醒抢资源。必须用while循环(或wait的谓词重载)重新检查条件,不满足就继续睡。

为何常见:notify给人一种“事件发生”的错觉,但wait的返回原因未必是notify,也可能是系统级别的偶发唤醒。

面试题

Q1. 以下哪个方法会让主线程阻塞等待子线程执行完毕?

单选题 · easy

  • A.join等待子线程← 正确答案
  • B.detach分离线程
  • C.swap与另线程交换
  • D.sleep让线程休眠

答案:A。join会阻塞主线程直到子线程结束,相当于老张站在厨房门口等厨师交差。detach是放养不等待,swap只交换线程句柄,sleep只是让当前线程睡觉。

Q2. 下列哪个类型能保证对共享变量的操作是原子的?

单选题 · easy

  • A.原始int变量
  • B.volatile int
  • C.std::atomic类型← 正确答案
  • D.静态int变量

答案:C。只有std::atomic的底层指令保证读取-修改-写入不被中断。volatile只防编译器优化,不防CPU乱序和缓存不一致,原始int和静态int都没有原子保证。

Q3. 在C++标准中,数据竞争(data race)属于哪一类行为?

单选题 · mid

  • A.编译器能查出
  • B.未定义行为← 正确答案
  • C.运行时警告
  • D.确定结果错误

答案:B。数据竞争在C++标准里是未定义行为,不像Java会做内存模型约束。好比两个厨师抢盐罐,结果是咸是淡只能听天由命,可能是任意结果,编译器也不负责提前报警。

Q4. std::atomic<int> counter; counter.fetch_add(1) 的含义是?

单选题 · mid

  • A.counter++ 非原子
  • B.原子自增操作← 正确答案
  • C.自增但可被打断
  • D.需锁保护才行

答案:B。fetch_add在硬件层面把“读-加-写”做成一瞬间完成,等价于原子版的counter = counter + 1。它不可被打断,也不需要互斥锁保护,是特制盐罐的取放动作。

Q5. 下列关于C++内存序由弱到强的排序,正确的是哪项?

单选题 · mid

  • A.宽松、释放、顺序一致← 正确答案
  • B.顺序一致、释放、宽松
  • C.宽松、顺序一致、释放
  • D.释放、宽松、顺序一致

答案:A。宽松序(relaxed)只保证原子性;释放/获取(release/acquire)增加因果同步;顺序一致(seq_cst)给全局统一顺序,约束最强。好比告示从“贴了不一定有人看见”到“全店同步广播”。

Q6. 关于memory_order_release与memory_order_acquire,下列说法正确的有?

多选题 · extreme

  • A.写前操作对读方成为可见← 正确答案
  • B.不能跨多个变量传播
  • C.保证全局顺序一致
  • D.必须配对同一原子变量← 正确答案

答案:AD。release写之前的操作,会被同一原子变量上acquire读到的线程看到,A正确;同步关系可以沿多个原子变量传递,B错误;只有seq_cst保证全局顺序一致,C错误;release和acquire必须作用在同一原子变量上形成配对,D正确。

Q7. 条件变量的wait需要配合unique_lock,主要原因是?

单选题 · mid

  • A.提升运行速度
  • B.原子释放锁并睡眠← 正确答案
  • C.防止死锁出现
  • D.避免内存泄漏

答案:B。wait必须原子地完成“释放锁+入睡”,否则会出现“已经睡下但锁还没放”的窗口期。unique_lock支持手动解锁,让这份原子性成为可能。好比厨师睡着前必须先把厨房钥匙交出去。

Q8. 关于条件变量的虚假唤醒,正确的说法有?

多选题 · hard

  • A.没有收到通知也可能醒来← 正确答案
  • B.wait重载谓词可以防御← 正确答案
  • C.notify_all才会触发唤醒
  • D.while循环重查条件即可← 正确答案

答案:ABD。C++允许线程在没有notify时自发醒来,A正确;wait的谓词重载本质就是“醒来后重查条件”,B正确;notify_one同样会唤醒一个线程,C错误;用while循环重查条件是最标准的防御姿势,D正确。

Q9. 线程池最大的好处是?

单选题 · easy

  • A.增加额外CPU核心
  • B.避免线程反复创建销毁← 正确答案
  • C.减少整体代码行数
  • D.保证线程永不冲突

答案:B。线程的创建和销毁都有系统级开销,线程池让一组线程常驻待命,任务来了随取随用,省掉了反复“雇人/解雇”的成本。它不增加CPU核数,也不自动解决数据竞争。

Q10. 关于Amdahl定律,下列正确的有?

多选题 · mid

  • A.加速比上限由串行部分决定← 正确答案
  • B.核数无限加速比也有限← 正确答案
  • C.串行比例越大加速比越高
  • D.降低串行比例能提升扩展性← 正确答案

答案:ABD。Amdahl定律指出加速比上限是1/(1-p),串行比例越小扩展性越好,A、D正确;即使核数无穷大,上限仍是1/(1-p),B正确;串行比例越大只会让上限越低,C错误。好比厨房里帮厨再多,洗菜若必须一个人干,总时间就被洗菜卡死。