page contents

Python 并发面试:会开线程不算会,能说清竞态和死锁才算

并发题是面试里最容易暴露真实水平的话题——不过不是考你能不能背出 threading 的 API,而是考你对线程底下正在发生的事情有没有清醒的认知。

attachments-2026-10-KbfNEnOj6ae1584f276ad.png

并发题是面试里最容易暴露真实水平的话题——不过不是考你能不能背出 threading 的 API,而是考你对线程底下正在发生的事情有没有清醒的认知。

GIL 是什么、竞态怎么产生、死锁怎么排查、协程和线程怎么选,这些问题答下来,写没写过并发代码一目了然。下面 8 题,从选型到进程通信、锁、协程,把三种并发方式一次过完。

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

   

答案:多线程在同一进程内、共享内存、创建轻;但受 GIL 影响,CPU 密集无法并行。多进程各有独立内存、能真正并行,但创建开销大、要 IPC 通信。

为什么这么考:并发选型的基础,必考。答完会跟一句:GIL 现在到底还在不在?要答准——默认 CPython 构建 GIL 仍是开启的,free-threading(不带 GIL)是可选 opt-in 构建,不是默认。这一句说清楚,比背"多线程快还是多进程快"更能站稳。

二、什么时候用多线程,什么时候用多进程?

   

答案:

•I/O 密集(网络请求、读写文件)→ 多线程。等待时释放 GIL,简单高效。

•CPU 密集(纯计算)→ 多进程。绕开 GIL,真正用上多核。

为什么这么考:光会 import threading 没用,得会选型。选错是有真实代价的:拿多线程跑纯计算,多核一个都用不满,还得付线程切换的额外开销,可能比单线程还慢。追问是"多进程开几个"——按 CPU 核数来,multiprocessing.cpu_count() 能取到,还要给系统留一点。答到"选型看密集类型、进程数按核定",这题就过了。

三、多进程之间怎么通信?

   

   python

from multiprocessing import Process, Queue

def worker(q):
    q.put("结果")

q = Queue()
p = Process(target=worker, args=(q,))
p.start()
p.join()
print(q.get())    # '结果'

答案:用 multiprocessing.Queue 或 Pipe。进程间内存不共享,必须走 IPC。

为什么这么考:很多人以为全局变量进程间共享,考你懂不懂 IPC。追问时补一句更稳:每个进程有自己独立的内存副本,子进程里改全局变量,父进程看不到。再往深一层,Queue / Pipe 之外还有 multiprocessing.Manager、Value、Array 能传数据,各有适用场景。答出 Queue / Pipe 就算基本过关,能再带一句 Manager 会更完整。

四、多线程下共享变量会出问题吗?

   

   python

import threading

count = 0
def add():
    global count
    for _ in range(1_000_000):
        count += 1

t1 = threading.Thread(target=add)
t2 = threading.Thread(target=add)
t1.start(); t2.start(); t1.join(); t2.join()
print(count)    # 不是 2_000_000!有竞态

答案:会。count += 1 不是原子操作(读 → 算 → 写三步),两个线程可能互相覆盖,结果偏小。GIL 只能保证解释器层面同一时刻只有一个线程执行,保证不了你的多步操作是原子的。

为什么这么考:这是"懂并发"和"只会开线程"的分界线。真实踩坑是线上计数器跑着跑着变少,查半天才发现多线程同时在 +=。追问会落在"GIL 不是保证安全吗"——要解释清:GIL 保证同一时刻只有一个线程在执行,但解释器会在字节码之间切走线程。count += 1 拆成的读、算、写三步,中间完全可能被打断,结果被覆盖。能讲到这一层,基本算通过。

五、怎么解决竞态?

   

   python

lock = threading.Lock()
count = 0
def add():
    global count
    for _ in range(1_000_000):
        with lock:        # 加锁,with 保证释放
            count += 1

答案:用 Lock 把临界区包起来,with 自动释放。代价是并发度下降,但正确性优先。

为什么这么考:光说"加锁"不够,要能补一句为什么用 with:手写 acquire() / release(),中间抛异常锁就永远不放了,线程直接卡住。with 保证异常也能释放。追问是"加了锁就一定没问题吗"——不一定,多把锁、拿锁顺序不一致就会死锁,正好接下一题。能把这个坑连起来说,比单独背定义显得更懂。

六、死锁是什么?怎么避免?

   

答案:两个线程各自持有一把锁、又互相等对方释放,就互相卡死。避免:按固定顺序拿锁、给锁加超时、少嵌套锁、用 with 保证释放。

为什么这么考:死锁是最能看出实战水平的题。真实场景:转账里 A 转 B 和 B 转 A 同时发生,两笔事务拿锁顺序相反,就互相卡死。这是教科书上"锁顺序不一致"的活例子。追问:怎么排查死锁?程序挂起时用 py-spy dump 或 faulthandler 把线程栈打出来,看每个线程卡在哪把锁;或者给 acquire(timeout=...) 加超时,别让线程死等。落到"所有线程拿锁顺序必须全局统一",这题就答扎实了。

七、线程 / 进程池怎么用?

   

   python

from concurrent.futures import ThreadPoolExecutor

def work(url):
    return len(url)

with ThreadPoolExecutor(max_workers=4) as pool:
    results = list(pool.map(work, ["a.com", "b.com", "c.com"]))

答案:concurrent.futures 的 ThreadPoolExecutor / ProcessPoolExecutor,map 提交一批任务统一拿结果,比自己手搓线程队列省事。

为什么这么考:生产环境几乎没人手搓 Thread,都用池。追问会是"池比手搓线程好在哪":线程复用省掉反复创建销毁的开销,with 退出自动等所有任务结束;submit + future.result() 拿单个结果,map 拿一批。再补一刀:CPU 密集任务别用 ThreadPoolExecutor,它还是在 GIL 下跑,要换 ProcessPoolExecutor 才真正上多核。答到"会选池、会收结果"就够了。

八、协程和线程有什么区别?

   

   python

import asyncio

async def fetch():
    await asyncio.sleep(1)
    return "ok"

async def main():
    r = await asyncio.gather(fetch(), fetch())   # 并发跑两个
    print(r)

asyncio.run(main())

答案:协程是单线程内的协作式并发,靠 await 主动让出;线程是系统级抢占式。协程开销更小、没有线程切换成本,适合大量 I/O。

为什么这么考:考你对线程 / 进程 / 协程三种方式的整体认知。追问会往两个方向走:协程能替代线程吗——不能,单线程内再快也吃不满多核,真正并行还得靠多进程。协程里能随便调阻塞函数吗——不能,requests.get、time.sleep 这类阻塞调用会把整个事件循环卡住,所有协程一起等,得用异步版本,或 run_in_executor 把它丢给线程池。答出"协程管并发、线程池兜底阻塞调用"这个组合,说明你真处理过混用场景。

 更多相关技术内容咨询欢迎前往并持续关注好学星城论坛了解详情。

想高效系统的学习Python编程语言,推荐大家关注一个微信公众号:Python编程学习圈。每天分享行业资讯、技术干货供大家阅读,关注即可免费领取整套Python入门到进阶的学习资料以及教程,感兴趣的小伙伴赶紧行动起来吧。

attachments-2022-05-rLS4AIF8628ee5f3b7e12.jpg

你可能感兴趣的文章

相关问题

0 条评论

请先 登录 后评论
Pack
Pack

2545 篇文章

作家榜 »

  1. Pack 2545 文章
  2. 轩辕小不懂 2403 文章
  3. 小柒 2228 文章
  4. Nen 576 文章
  5. 王昭君 216 文章
  6. 文双 71 文章
  7. 小威 64 文章
  8. Cara 36 文章