1. 串行、并行与并发
1.1 串行(Serial)
What(是什么)
想象你在排队买奶茶,前面的人点完单、付完钱、拿到奶茶走了,才轮到你。你后面的人也必须等你完成全部步骤后才能上前。这就是串行------事情一件一件按顺序做,前一件彻底完成后,下一件才能开始。
在编程里,串行就是让CPU把第一个任务完全执行完,再去执行第二个任务,再执行第三个......一个接一个,绝不插队。
Why(为什么要了解它)
这是最自然、最简单的做事方式。如果你只有一个人(一个CPU),而且后面的任务必须依赖前面任务的结果(比如你要先打开文件,才能读取内容;先做饭,才能吃饭),那就必须串行。理解串行是理解其他复杂方式的基础。
How(怎么用)
直接按顺序写代码就可以了,没有任何特殊语法:
python
import time
def task1():
print("第一个任务开始了")
time.sleep(2) # 假装这个任务需要2秒钟
print("第一个任务完成了")
def task2():
print("第二个任务开始了")
time.sleep(2)
print("第二个任务完成了")
# 按顺序调用
task1() # 先执行第一个
task2() # 再执行第二个
print("所有任务都完成了")
- 业务场景:顺序处理文件、生成连续的编号
- Web后端重要性:⭐(只是基础概念,实际开发很少单独用)
- 面试:了解即可
1.2 并行(Parallelism)
What(是什么)
还是买奶茶的例子。这次奶茶店有3个店员,每个人都能独立工作。你和另外两个朋友同时走到3个不同的店员面前,同时点单、同时付钱、同时拿到奶茶。这就是并行------多个任务真的同时在进行。
在编程里,并行需要你的电脑有多个CPU核心(现在的电脑一般都是4核、8核甚至更多)。每个核心就像一个独立的店员,可以各自处理一个任务,真正的同时工作。
Why(为什么会这样)
早期电脑只有一个CPU核心,就像只有一个店员。后来人们发现,把很多个CPU核心放在一起,就能同时干更多活,速度直接翻倍。但前提是,这些任务之间不能互相依赖------你不能让一个任务等另一个任务的结果,不然就没法同时进行了。
How(怎么用)
在Python中,需要用到multiprocessing模块来创建多个"进程",操作系统会自动把不同进程分配到不同的CPU核心上:
python
import multiprocessing
import time
def make_tea(name):
print(f"{name}开始泡茶")
time.sleep(2) # 泡茶需要2秒
print(f"{name}的茶泡好了")
def make_coffee(name):
print(f"{name}开始冲咖啡")
time.sleep(2) # 冲咖啡需要2秒
print(f"{name}的咖啡冲好了")
if __name__ == '__main__':
# 创建两个进程,它们会同时运行
worker1 = multiprocessing.Process(target=make_tea, args=("小明",))
worker2 = multiprocessing.Process(target=make_coffee, args=("小红",))
# 启动进程(两个同时开始)
worker1.start()
worker2.start()
# 等待两个进程都完成
worker1.join()
worker2.join()
print("茶和咖啡都好了!")
# 总耗时约2秒,而不是4秒
- 业务场景:批量处理图片、训练AI模型、大量计算任务
- Web后端重要性:⭐⭐(后台定时计算任务会用到)
- 面试:★(常问和并发的区别)
1.3 并发(Concurrency)
What(是什么)
这次你一个人在奶茶店工作,但突然来了3个客人。你不可能同时服务他们(因为只有一双手),但你很聪明------你先帮A客人点单,在等收银机反应的时候,转头去帮B客人点单,再让A付钱,同时帮C点单......你快速地在3个客人之间切换,每个人都觉得你在服务他们,但实际上你只是在很短的时间内不断切换。这就是并发------看起来像同时进行,实际上是在快速切换。
在编程里,当一个CPU核心要处理很多任务时,操作系统会把时间切成很小很小的碎片(可能每片只有几毫秒),一会儿执行任务A,一会儿执行任务B,一会儿执行任务C。因为切换得实在太快了,你根本感觉不到,从外面看就好像在同时执行一样。
Why(为什么要这样)
你的电脑不可能为每一个程序都配一个CPU核心(你同时开了浏览器、微信、音乐播放器、编辑器......可能有几十个程序)。而且很多时间CPU其实在等待------比如等网络数据返回、等文件读完、等打字输入。如果CPU只会傻等,那大部分时间就被浪费了。并发让CPU在等待的时候去干别的事,大大提高效率。
How(怎么用)
可以用多线程或者协程来实现,后面会详细讲解。
- 业务场景:Web服务器同时处理多个用户请求、聊天软件同时和多人聊天
- Web后端重要性:⭐⭐⭐⭐⭐(这是Web后端的核心)
- 面试:★★★★
面试题 :并行和并发的区别是什么?
面试答案:并行是真正的同时执行(需要多核CPU),并发是看起来像同时执行但实际上是快速切换(单核也可以)。并行关注物理上的同时,并发关注逻辑上的同时。
2. 进程、线程、协程的区别
先讲一个故事,帮助理解这三个概念:
乔布斯想开一家手机工厂。
他先建了一条生产线 ,上面有各种设备和材料。这条生产线就是进程------它包含了生产手机所需要的所有资源(材料、工具、空间),是生产活动的基础。
光有生产线不行,还得有工人 来操作。于是他招了5个工人,这些工人能真正动手把手机做出来。工人就是线程------他们是真正干活的人。
干了一段时间,乔布斯发现工人们经常要等零件送来,等的时候只能干坐着。于是他想了个招:规定工人在等待的时候,不要傻等,先去干点别的活,比如整理工具、记录数据。等零件到了再回来继续。这就是协程------工人利用等待时间去干其他事,不浪费任何时间。
2.1 进程(Process)
What(是什么)
进程就是一个正在运行的程序。你打开浏览器,就启动了一个浏览器进程;打开微信,就启动了一个微信进程。操作系统会给每个进程分配独立的内存空间,就像一个独立的"小房间",不同进程之间不能随便串门。
Why(为什么会这样设计)
如果所有程序都共享同一个内存空间,那一个程序崩溃就可能把其他程序的数据弄乱,甚至导致整个电脑崩溃。给每个进程独立的空间,就像给每个人独立的房间,互不干扰,系统就更稳定安全。
How(怎么用)
python
import multiprocessing
import os
def say_hello(name):
print(f"你好,我是{name}")
print(f"我的进程ID是:{os.getpid()}") # 每个进程有唯一的编号
print(f"创建我的父进程ID是:{os.getppid()}")
if __name__ == '__main__':
print(f"主进程ID:{os.getpid()}")
# 创建子进程
p = multiprocessing.Process(target=say_hello, args=("小明",))
p.start() # 启动子进程
p.join() # 等待子进程结束
print("子进程结束了")
- 特点:独立内存空间,互不影响;可利用多核CPU;但创建和切换开销大
- 业务场景:需要隔离的任务、利用多核计算
- Web后端重要性:⭐⭐⭐(Web服务器用多个worker进程)
2.2 线程(Thread)
What(是什么)
线程是进程内部真正干活的最小单位。一个进程可以包含多个线程,就像一条生产线上可以有多个工人。这些工人共享同一条生产线上的所有材料和工具(共享内存),但每个工人有自己的工作步骤和节奏(独立的执行路径)。
Why(为什么会设计线程)
有时候一个进程需要同时做多件事。比如微信,你一边打字聊天,一边接收消息,一边下载文件。如果只能一件事一件事做,打字的时候就不能收消息,那体验就太差了。线程让一个进程内部能同时推进多件事。
而且线程比进程更"轻量"------创建线程不需要重新分配一整套资源(生产线已经有了),切换线程比切换进程快得多,因为不需要切换整个内存空间。
How(怎么用)
python
import threading
import time
def chat(name):
"""模拟聊天任务"""
for i in range(3):
print(f"{name}在聊天...第{i+1}句")
time.sleep(1)
def download(name):
"""模拟下载任务"""
for i in range(3):
print(f"{name}在下载文件...{i+1}%")
time.sleep(1)
# 创建两个线程,就像两个工人同时工作
thread1 = threading.Thread(target=chat, args=("小明",))
thread2 = threading.Thread(target=download, args=("小红",))
# 启动线程
thread1.start()
thread2.start()
# 等待两个线程完成
thread1.join()
thread2.join()
print("聊天和下载都完成了!")
- 特点:共享进程内存;切换比进程快;但受GIL限制(后面讲)
- 业务场景:需要同时执行多个I/O操作的场景
- Web后端重要性:⭐⭐⭐
2.3 协程(Coroutine)
What(是什么)
协程是比线程更小的执行单位。如果说线程是工人,协程就是工人灵活的工作方式------当一个任务需要等待时,工人主动放下这个任务,去做另一个任务,等之前那个任务准备好了再切回来继续。这一切都是工人自己主动控制的,不需要领导(操作系统)来安排。
Why(为什么要发明协程)
线程虽然比进程轻,但切换线程仍然需要操作系统出面(这叫做"内核态切换"),会消耗不少时间。如果有一万个用户同时访问你的网站,难道要创建一万个线程吗?那切换的开销就太大了。
协程的切换完全在用户自己代码里完成(这叫"用户态切换"),不需要操作系统参与,非常轻量。一个线程里可以跑成千上万个协程,适合处理大量I/O等待的场景(比如Web服务器等数据库查询返回)。
How(怎么用)
python
import asyncio
async def make_breakfast(name):
"""做早餐,每个步骤都需要等待"""
print(f"{name}开始煮水")
await asyncio.sleep(2) # 等水烧开,但不傻等,去做别的事
print(f"{name}水开了,开始煮面")
await asyncio.sleep(2) # 等面煮熟
print(f"{name}面煮好了!")
async def main():
# 同时做两份早餐
await asyncio.gather(
make_breakfast("妈妈"),
make_breakfast("爸爸")
)
asyncio.run(main())
# 总耗时约2秒,而不是4秒
- 特点:超轻量、单线程内切换、适合I/O密集型
- 业务场景:高并发Web API、爬虫、实时通信
- Web后端重要性:⭐⭐⭐⭐⭐(核心技能)
三者的核心区别总结
| 进程 | 线程 | 协程 | |
|---|---|---|---|
| 比喻 | 生产线 | 工人 | 工人灵活切换任务 |
| 能不能共享内存 | ❌ 各有各的房间 | ✅ 同一个房间 | ✅ 同一个房间 |
| 谁来调度 | 操作系统 | 操作系统 | 程序员自己 |
| 切换代价 | 大(换房间) | 中(换工具) | 小(换个姿势干活) |
| 能利用多核吗 | ✅ | ❌(受GIL限制) | ❌(单线程内) |
面试题 :进程、线程、协程的区别?
面试答案:进程是操作系统资源分配的最小单位,拥有独立内存空间;线程是CPU调度的最小单位,同一进程的线程共享内存;协程是用户态的轻量级调度单位,切换在代码层面完成。切换开销:进程 > 线程 > 协程。进程和线程由操作系统调度,协程由程序员显式控制。
3. 同步与异步
What(是什么)
想象你去书店买书:
- 同步:你问老板有没有《Python》这本书,老板说"我查查,你等着"。于是你站在柜台前干等5分钟,期间什么也做不了。等老板查完告诉你结果,你才能继续下一步(有书就买,没书就走)。
- 异步:你问老板有没有这本书,老板说"我查查,查到了告诉你"。你不用干等,先去旁边星巴克喝杯咖啡、刷会儿手机。15分钟后老板发微信说"有货,给你留了一本",你再去买单。
在编程中:
- 同步:调用方发出请求后,一直等待被调用方返回结果,等到了才继续执行。过程中调用方被"阻塞"。
- 异步:调用方发出请求后,不等待结果,继续执行自己的代码。被调用方完成后,主动通知调用方来处理结果。
Why(为什么需要异步)
如果只有同步,那每次等待(等网络回复、等文件读取、等数据库查询)都会让整个程序卡住。想象一个网站,如果每次处理用户请求都要等数据库返回,等的时候其他用户都不能访问,那这个网站就废了。
异步让程序在等待的时候去处理其他事情,一个线程就能同时应对很多请求,效率大大提升。
How(怎么用)
python
import asyncio
# 同步方式(会阻塞)
def sync_cook():
print("开始煮饭")
time.sleep(3) # 傻等3秒,什么也不干
print("饭煮好了")
# 异步方式(不阻塞)
async def async_cook():
print("开始煮饭")
await asyncio.sleep(3) # 等饭熟的这3秒可以去干别的
print("饭煮好了")
async def async_wash():
print("开始洗菜")
await asyncio.sleep(2)
print("菜洗好了")
async def main():
# 同时煮饭和洗菜
await asyncio.gather(async_cook(), async_wash())
print("菜和饭都好了!")
asyncio.run(main())
# 总耗时3秒(最长的那个),而不是5秒
- 业务场景:Web后端处理并发请求、调用第三方API
- Web后端重要性:⭐⭐⭐⭐⭐
- 面试:★★★★
面试题 :同步和异步的区别?
面试答案:同步是调用方等待被调用方返回结果后再继续,会阻塞当前线程;异步是调用方不等待结果立即返回,被调用方完成后通过回调、事件等方式通知调用方。异步能避免线程阻塞,提升系统吞吐量。
4. 线程 Thread
4.1 线程的创建
What(是什么)
在Python中,创建线程就像雇佣一个新工人。你可以直接把工作内容告诉他(函数包装),也可以让他按照一个标准的工人职责来做事(类包装)。
Why(为什么有两种方式)
- 函数包装:简单直接,适合临时任务。就像临时工,告诉他干什么就行。
- 类包装:更规范,适合复杂任务。就像正式工,有标准的工作流程(run方法),可以有自己的工具和状态。
How(怎么用)
python
import threading
import time
# ========== 方式一:函数包装 ==========
def worker(name, count):
"""工人要干的工作"""
for i in range(count):
print(f"工人{name}正在干活...第{i+1}次")
time.sleep(1)
# 创建线程(告诉工人干什么活)
t1 = threading.Thread(target=worker, args=("张三", 3))
t2 = threading.Thread(target=worker, args=("李四", 3))
# 开始工作
t1.start()
t2.start()
# 等待工人干完活
t1.join()
t2.join()
print("所有工人都干完了")
# ========== 方式二:类包装 ==========
class MyWorker(threading.Thread):
"""正式的工人类"""
def __init__(self, name, count):
super().__init__() # 必须先调用父类的初始化
self.name = name
self.count = count
def run(self):
"""工人标准的工作流程(必须叫run)"""
for i in range(self.count):
print(f"正式工{self.name}工作中...第{i+1}次")
time.sleep(1)
# 创建正式工
w1 = MyWorker("王五", 3)
w2 = MyWorker("赵六", 3)
# 开始工作
w1.start()
w2.start()
# 等待完成
w1.join()
w2.join()
print("所有正式工都干完了")
注意:
-
必须调用
start()才能真正启动线程,创建后不会自动运行 -
join()是让主线程等待子线程结束 -
业务场景:简单的后台I/O任务
-
Web后端重要性:⭐⭐
-
面试:★★★(创建方式和start/run区别)
4.2 join():让主线程等待子线程
What(是什么)
join() 翻译过来就是"加入等待"。主线程调用子线程的 join() 方法,就相当于主线程说:"你还没干完,那我也等着,等你干完我再继续走。"
Why(为什么要这样)
默认情况下,主线程不会等子线程。如果主线程先走完了,程序就结束了,但子线程可能还没干完活就被强行打断了。这就像一个老板自己先下班了,工人活还没干完就被迫停工。
有时候我们需要等所有子线程都完成,拿到它们的结果后,主线程才能继续(比如汇总所有工人的成果)。
How(怎么用)
python
import threading
import time
def long_task(name, seconds):
print(f"{name}开始一个需要{seconds}秒的任务")
time.sleep(seconds)
print(f"{name}的任务完成了")
# 创建三个任务,完成时间不同
task_a = threading.Thread(target=long_task, args=("A任务", 3))
task_b = threading.Thread(target=long_task, args=("B任务", 1))
task_c = threading.Thread(target=long_task, args=("C任务", 2))
# 全部启动
task_a.start()
task_b.start()
task_c.start()
print("主线程: 我已经启动了所有任务")
print("主线程: 现在我等待所有任务完成...")
# 等待每一个任务完成
task_a.join()
print("主线程: A任务已经完成了")
task_b.join()
print("主线程: B任务已经完成了")
task_c.join()
print("主线程: C任务已经完成了")
print("主线程: 所有任务完成,我继续工作")
- 业务场景:等待所有子任务完成后汇总结果
- Web后端重要性:⭐⭐
- 面试:★★★
4.3 守护线程(daemon thread)
What(是什么)
守护线程就像后台的清洁工。工作日白天老板和员工都在,清洁工也跟着工作;但一旦下班、所有人都走了,清洁工也得马上离开,不需要把整个大楼都打扫完。
在编程中,守护线程是:当所有"非守护线程"(包括主线程)都结束时,守护线程会被自动强制终止,不管它干没干完。
Why(为什么需要守护线程)
有些线程的工作是辅助性的(比如垃圾回收、定时保存日志、心跳检测),它们本身不是业务的核心。如果业务都做完了,这些辅助工作继续做也没有意义了。如果因为它们没完成导致程序一直不退出,反而不合适。
最典型的应用:垃圾回收器(GC)
Python的垃圾回收器会自动清理不再使用的内存。它运行在一个守护线程中。当你的程序正常运行时,GC在后台默默工作;当程序退出时,GC线程自动结束,因为剩下的内存操作系统会一并回收。
How(怎么用)
python
import threading
import time
def background_worker():
"""一个永远干不完的后台任务"""
count = 0
while True:
count += 1
print(f"后台任务进行中...第{count}次")
time.sleep(0.5)
def main_work():
"""主业务流程"""
print("主业务开始")
for i in range(3):
print(f"主业务处理中...步骤{i+1}")
time.sleep(1)
print("主业务完成!")
# 创建守护线程
bg = threading.Thread(target=background_worker)
bg.daemon = True # 设置为守护线程(必须在start之前设置)
bg.start()
# 执行主业务
main_work()
print("主程序结束,守护线程自动终止")
# 程序到这里就结束了,守护线程不会继续运行
注意区分 join() 和 daemon:
-
join()→ 主线程主动等待子线程结束 -
daemon→ 主线程结束时,守护线程被动被终止 -
两者解决的是不同问题,不矛盾
-
业务场景:GC线程、心跳检测、日志刷新
-
Web后端重要性:⭐(框架底层已处理)
-
面试:★★★
面试题 :什么是守护线程?有什么典型应用?
面试答案:守护线程是一种当所有非守护线程结束时自动终止的线程。典型应用是垃圾回收器(GC),因为程序退出时GC再回收已无意义,设为守护线程能随主程序自动结束,不会阻止进程退出。
5. GIL(全局解释器锁)
What(是什么)
GIL的全称是Global Interpreter Lock(全局解释器锁)。它是CPython解释器里的一把大锁。这把锁规定:在任何一个时刻,只有一个线程能够真正执行Python代码。
也就是说,即使你创建了10个线程,即使你的电脑有8个CPU核心,同一时刻也只能有1个线程在跑Python代码,其他9个都在排队等着。
Why(为什么会有这把锁)
这要从Python的出生讲起。
1991年,Python的创始人Guido大叔开始写Python的时候,电脑基本都是单核的,根本没多核这回事。Python内部管理内存用了一种叫"引用计数"的方法------每个对象记一个数字,表示有多少地方在用这个对象。当计数变成0,对象就被回收。
问题来了:如果有多个线程同时修改这个引用计数,就可能出现计数错乱。比如线程A刚读到计数=1,准备减1;线程B也读到计数=1,也准备减1。结果两个线程都减了1,计数变成0(正确应该是-1),对象就被错误回收了,程序就崩溃了。
解决这个问题最彻底的方法是给每个对象都加锁,但那样太复杂,而且会大大降低单线程的性能。Guido大叔选择了一个简单粗暴的方案:加一把全局锁,任何时候只让一个线程执行,这样就安全了。虽然现在看这是性能瓶颈,但当时确实是最好的选择。
How(怎么应对GIL)
GIL主要影响CPU密集型 任务(需要大量计算的),对I/O密集型任务(网络请求、文件读写)影响不大。为什么呢?因为线程在等待I/O时会主动释放GIL,让其他线程有机会执行。
python
import threading
import time
# I/O密集型任务(GIL影响小)
def io_task(name):
print(f"{name}开始下载")
time.sleep(2) # 模拟I/O等待,此时会释放GIL
print(f"{name}下载完成")
# CPU密集型任务(GIL影响大)
def cpu_task(name):
print(f"{name}开始计算")
total = 0
for i in range(50_000_000): # 大量计算
total += i
print(f"{name}计算完成,结果={total}")
# I/O任务用多线程------还是有效的
print("=== I/O密集型:多线程可以加速 ===")
start = time.time()
t1 = threading.Thread(target=io_task, args=("任务1",))
t2 = threading.Thread(target=io_task, args=("任务2",))
t1.start(); t2.start()
t1.join(); t2.join()
print(f"耗时: {time.time()-start:.2f}秒") # 约2秒,不是4秒
# CPU任务用多线程------基本没用,甚至更慢
print("\n=== CPU密集型:多线程基本无效 ===")
start = time.time()
t1 = threading.Thread(target=cpu_task, args=("任务1",))
t2 = threading.Thread(target=cpu_task, args=("任务2",))
t1.start(); t2.start()
t1.join(); t2.join()
print(f"耗时: {time.time()-start:.2f}秒") # 和顺序执行差不多
# 解决方案:CPU密集型用多进程
print("\n=== CPU密集型:多进程方案 ===")
import multiprocessing
start = time.time()
p1 = multiprocessing.Process(target=cpu_task, args=("进程1",))
p2 = multiprocessing.Process(target=cpu_task, args=("进程2",))
p1.start(); p2.start()
p1.join(); p2.join()
print(f"耗时: {time.time()-start:.2f}秒") # 时间减半!
- 业务场景:Web后端主要是I/O密集型(等数据库、等网络),GIL影响不大
- Web后端重要性:⭐⭐⭐⭐(理解GIL才能正确选型)
- 面试:★★★★★(必问)
面试题 :什么是GIL?它影响什么?怎么解决?
面试答案:GIL是CPython的全局解释器锁,同一时刻只允许一个线程执行Python字节码。它使得CPU密集型任务无法利用多核加速。解决方案:1) CPU密集型用多进程;2) I/O密集型用多线程或协程;3) 使用C扩展或Cython释放GIL;4) 使用无GIL的Python实现(如Jython、PyPy)。
6. 线程同步:互斥锁 Lock
What(是什么)
互斥锁就像公共厕所门上的锁。一次只能有一个人进去,其他人必须在外面排队。等里面的人出来,下一个才能进去。
在编程中,多个线程同时修改同一个数据时,可能会造成数据错乱。锁的作用就是保证同一时刻只有一个线程能操作这份数据,其他人等着。
Why(为什么需要锁)
想象你和老婆共用一个银行账户,里面有100元。你准备取80元,你老婆也准备取80元。
没有锁的情况:
- 你查余额:100元,够取80
- 你老婆也查余额:100元,够取80
- 你取了80,余额变成20
- 你老婆也取了80,余额变成-60
问题出在哪?你们同时读取了余额,都以为还有100元。这就是"竞态条件"------多个线程同时操作共享数据导致的结果不可预测。
有锁的情况:
- 你拿到锁,进入
- 你老婆也想进,但锁被人拿着,只能等
- 你查余额:100元,取80,余额变成20,然后释放锁
- 你老婆拿到锁,查余额:20元,不够取80,放弃,释放锁
这样就安全了。
How(怎么用)
python
import threading
import time
class BankAccount:
def __init__(self, balance, owner):
self.balance = balance
self.owner = owner
class WithdrawThread(threading.Thread):
def __init__(self, amount, account, name):
super().__init__()
self.amount = amount
self.account = account
self.name = name
self.withdrawn = 0
def run(self):
# 先拿到锁
lock.acquire()
# 检查余额是否够
if self.account.balance >= self.amount:
time.sleep(0.1) # 模拟处理时间
self.account.balance -= self.amount
self.withdrawn = self.amount
print(f"{self.name}取款成功:取走{self.amount}元,余额{self.account.balance}")
else:
print(f"{self.name}取款失败:余额不足(当前{self.account.balance},需要{self.amount})")
# 释放锁
lock.release()
# 创建账户和锁
account = BankAccount(100, "家庭账户")
lock = threading.Lock()
# 两人同时取钱
husband = WithdrawThread(80, account, "老公")
wife = WithdrawThread(80, account, "老婆")
husband.start()
wife.start()
husband.join()
wife.join()
print(f"最终余额:{account.balance}元")
# 输出:老公取款成功:取走80元,余额20
# 老婆取款失败:余额不足(当前20,需要80)
# 最终余额:20元 ✓
推荐用 with 语句,会自动释放锁,更安全:
python
def run(self):
with lock: # 自动获取和释放锁
if self.account.balance >= self.amount:
self.account.balance -= self.amount
- 业务场景:金融交易、库存扣减、计数器
- Web后端重要性:⭐⭐(分布式系统多用数据库锁或Redis锁)
- 面试:★★★★
7. 死锁 Deadlock
What(是什么)
死锁是两个线程互相等待对方释放锁,结果谁也走不了,永远卡住。
经典例子:两个人做饭都需要"菜刀"和"锅"。
- 小张先拿到菜刀,等着拿锅
- 小李先拿到锅,等着拿菜刀
- 小张等小李放锅,小李等小张放刀→僵住了,谁也做不了饭
Why(为什么会发生)
通常是因为加锁的顺序不一致。上面的例子中,小张先拿刀后拿锅,小李先拿锅后拿刀。如果两人都约定"先拿刀再拿锅",就不会死锁。
How(怎么避免)
python
import threading
import time
lock_knife = threading.Lock() # 菜刀锁
lock_pot = threading.Lock() # 锅锁
def person_a():
"""小张:统一先拿刀再拿锅"""
with lock_knife:
print("小张拿到了菜刀")
time.sleep(0.1) # 模拟切菜的时间
with lock_pot:
print("小张拿到了锅,可以炒菜了!")
time.sleep(0.2)
print("小张炒完菜,释放所有工具")
def person_b():
"""小李:也统一先拿刀再拿锅(保持顺序一致)"""
with lock_knife: # 关键:和person_a一样的顺序
print("小李拿到了菜刀")
time.sleep(0.1)
with lock_pot:
print("小李拿到了锅,可以炒菜了!")
time.sleep(0.2)
print("小李炒完菜,释放所有工具")
# 两人同时开始做饭
t_a = threading.Thread(target=person_a)
t_b = threading.Thread(target=person_b)
t_a.start()
t_b.start()
t_a.join()
t_b.join()
print("所有人都做完饭了")
预防死锁的原则:
- 保持统一的加锁顺序
- 尽量避免同时持有多个锁
- 如果必须多个锁,可以使用
trylock(尝试获取,获取不到就放弃)
- 业务场景:复杂资源依赖
- Web后端重要性:⭐
- 面试:★★
面试题 :死锁是什么?怎么避免?
面试答案:死锁是多个线程各自持有对方需要的锁,互相等待,谁也无法继续。解决方法是保持统一的加锁顺序,或避免同时持有多个锁。
8. 信号量 Semaphore
What(是什么)
信号量就像停车场入口的电子屏显示"剩余车位:3"。当有3个车位时,前3辆车可以同时停进去。第4辆车来了发现车位满了,只能等着。等有车开出来,有空位了,才能进去。
在编程中,信号量允许指定数量的线程同时访问资源,而锁只允许1个。
Why(为什么需要信号量)
有时候资源可以同时支持多个线程访问,但不能无限多。比如:
- 数据库连接池最多10个连接,不能让100个线程都同时连
- 文件同时读最多5个,超过可能影响性能
- API频率限制每秒最多3个请求
用锁的话一次只能1个,太浪费;不用控制的话并发太多,系统可能扛不住。信号量正好解决这个问题。
How(怎么用)
python
import threading
import time
import random
# 创建信号量:最多允许3个线程同时执行
semaphore = threading.Semaphore(3)
def visit_museum(name):
"""参观博物馆(最多同时容纳3人)"""
print(f"{name}在门口排队,等待进入...")
with semaphore: # 获取"入场券",如果满了就等着
print(f"✅ {name}进入了博物馆")
# 参观时间随机
visit_time = random.randint(1, 3)
time.sleep(visit_time)
print(f"👋 {name}参观完毕,离开博物馆(参观了{visit_time}秒)")
# 10个人同时想来博物馆
visitors = []
for i in range(1, 11):
t = threading.Thread(target=visit_museum, args=(f"游客{i}",))
visitors.append(t)
t.start()
# 等待所有人参观完
for t in visitors:
t.join()
print("博物馆闭馆了!")
- 业务场景:连接池限流、API频率控制、爬虫并发限制
- Web后端重要性:⭐⭐⭐
- 面试:★
9. 事件 Event
What(是什么)
Event就像一个"信号旗"。一面是红色的(False),一面是绿色的(True)。线程可以等待旗子变绿(wait),另一个线程可以把旗子翻成绿色(set),唤醒所有等待的人。
生活中类似"同学们,菜上齐了,开吃吧!"------同学们(线程们)一直在等这句话,班长(另一个线程)喊出这句话(set),大家才开始动筷子。
Why(为什么用Event)
线程间需要协调:有些事情必须等所有线程都准备好才开始,或者等某个条件满足才触发。与其让线程不停地问"好了吗?好了吗?"(轮询,浪费CPU),不如让它们睡着等待,条件满足时主动叫醒它们。
How(怎么用)
python
import threading
import time
# 创建事件对象(默认是False)
start_eating = threading.Event()
def guest(name):
"""客人线程:等主人喊开吃"""
print(f"客人{name}已就座,等待开吃...")
start_eating.wait() # 在这里等着,直到被唤醒
print(f"客人{name}开始吃了!")
def host():
"""主人线程:菜上齐后喊开吃"""
print("主人:菜还没上齐,大家稍等...")
time.sleep(3)
print("主人:菜上齐了,大家开吃吧!")
start_eating.set() # 设置事件为True,唤醒所有等待的客人
# 创建4个客人
guests = []
for i in range(1, 5):
t = threading.Thread(target=guest, args=(f"客人{i}",))
guests.append(t)
t.start()
# 主人也启动
host_thread = threading.Thread(target=host)
host_thread.start()
# 等待所有线程结束
for t in guests:
t.join()
host_thread.join()
print("饭局结束!")
- 业务场景:服务就绪通知、定时触发、游戏开始信号
- Web后端重要性:⭐
- 面试:★
10. 生产者与消费者模式
What(是什么)
这是一个经典的并发协作模型:
- 生产者:负责"生产"数据的人/线程(比如厨师做包子)
- 消费者:负责"消费"数据的人/线程(比如顾客吃包子)
- 缓冲区:一个中转站(比如放包子的盘子)。厨师做好了放盘子里,顾客从盘子里拿。厨师不用管顾客什么时候来,顾客也不用管厨师什么时候做。
Why(为什么需要这个模式)
- 解耦:生产者和消费者互不依赖。换了厨师/换了顾客都不影响另一方。
- 平衡速度差异:厨师做包子快、顾客吃得慢?没关系,包子放盘子里。厨师做包子慢、顾客吃得快?也没关系,顾客吃完了盘子空的就等一等。
- 提高效率:厨师可以一直做包子,顾客可以一直吃,谁也不用等谁。
How(怎么用)
Python的 queue.Queue 就是一个现成的缓冲区,它是线程安全的(内部已经加了锁)。
python
import threading
import queue
import time
import random
def chef(q, chef_name):
"""厨师(生产者):做包子"""
for i in range(5):
dumpling = f"{chef_name}做的{random.choice(['肉包','菜包','豆沙包'])}"
print(f"👨🍳 {chef_name}生产了:{dumpling}")
q.put(dumpling)
time.sleep(random.uniform(0.5, 1)) # 做包子需要时间
q.put(None) # 放一个特殊的"停止"标记
def customer(q, customer_name):
"""顾客(消费者):吃包子"""
while True:
dumpling = q.get() # 从盘子里拿包子
if dumpling is None: # 遇到停止标记
q.put(None) # 放回去,让其他顾客也能看到
break
print(f"🍽️ {customer_name}吃了:{dumpling}")
time.sleep(random.uniform(0.3, 0.8)) # 吃包子需要时间
# 一个放包子的盘子(队列)
plate = queue.Queue()
# 1个厨师,3个顾客
chef_thread = threading.Thread(target=chef, args=(plate, "小王"))
customer1 = threading.Thread(target=customer, args=(plate, "小明"))
customer2 = threading.Thread(target=customer, args=(plate, "小红"))
customer3 = threading.Thread(target=customer, args=(plate, "小刚"))
chef_thread.start()
customer1.start()
customer2.start()
customer3.start()
chef_thread.join()
customer1.join()
customer2.join()
customer3.join()
print("\n今天的包子卖完了!")
- 业务场景:消息队列(如Celery + Redis)、日志异步写入、爬虫调度
- Web后端重要性:⭐⭐⭐⭐(核心设计模式)
- 面试:★★★★
面试题 :说说生产者消费者模式,它的好处是什么?
面试答案:生产者消费者模式通过缓冲区解耦生产者和消费者。好处:1)解耦,双方互不依赖;2)平衡速度差异,缓冲区吸收波动;3)提高并发效率。Python中使用queue.Queue实现,它是线程安全的。
11. 进程 Process
11.1 进程创建
What(是什么)
前面线程是在一个程序内部创建多个"工人"。进程是更重量级的,它在操作系统中创建一个完全独立的"新程序副本"。每个进程有自己的内存空间,互不干扰。
Why(为什么需要多进程)
- 绕过Python的GIL锁,真正利用多核CPU做并行计算
- 进程之间相互隔离,一个进程崩溃不会影响其他进程
- 适合需要高稳定性的场景
How(怎么用)
python
import multiprocessing
import os
import time
# ========== 方式一:方法包装 ==========
def func1(name):
print(f"当前进程ID: {os.getpid()}") # 当前进程的ID
print(f"父进程ID: {os.getppid()}") # 创建我的进程的ID
print(f"Process:{name} start")
time.sleep(3)
print(f"Process:{name} end")
if __name__ == "__main__":
print(f"主进程ID: {os.getpid()}")
# 创建子进程
p1 = multiprocessing.Process(target=func1, args=('进程1',))
p2 = multiprocessing.Process(target=func1, args=('进程2',))
p1.start()
p2.start()
p1.join()
p2.join()
print("主进程结束")
# ========== 方式二:类包装 ==========
class MyProcess(multiprocessing.Process):
def __init__(self, name):
super().__init__() # 必须调用父类的初始化
self.name = name
def run(self):
print(f"进程{self.name}开始了,PID={os.getpid()}")
time.sleep(3)
print(f"进程{self.name}结束了")
if __name__ == "__main__":
p1 = MyProcess("工人A")
p2 = MyProcess("工人B")
p1.start()
p2.start()
p1.join()
p2.join()
print("所有进程完成")
重要提醒(Windows用户特别注意) :
在Windows系统上,创建进程的代码必须放在 if __name__ == "__main__": 里面,否则会无限递归创建进程,导致程序崩溃。
- 业务场景:CPU密集型计算、Web服务器多worker
- Web后端重要性:⭐⭐⭐
- 面试:★★★
11.2 进程间通信 - Queue
What(是什么)
前面说了,每个进程有自己独立的内存空间,不能直接共享变量。那不同进程之间怎么传递数据呢?答案是:用一个公共的队列------就像一个收发室,进程A把信件放进收发室,进程B从收发室取走。
Why(为什么不能直接用变量)
进程间内存隔离,一个进程修改了变量,另一个进程完全看不到。这就像两个住在不同房子的人,你不能指望把你的东西放到别人家的柜子里。必须通过公共的"信箱"(队列)来传递。
How(怎么用)
python
import multiprocessing
def worker(q):
"""子进程:从队列中接收数据"""
print("子进程:等待接收数据...")
data = q.get() # 从队列获取数据
print(f"子进程收到了:{data}")
if __name__ == '__main__':
# 创建队列
q = multiprocessing.Queue()
# 必须先启动子进程
p = multiprocessing.Process(target=worker, args=(q,))
p.start()
# 再放入数据(顺序很重要!)
q.put("你好,这是主进程发来的消息")
p.join()
print("通信完成")
⚠️ 重要 :必须先 start 子进程,再 put 数据。如果反过来,数据可能丢失或导致卡死。因为队列内部需要两端都连接好才能正常传输。
- 业务场景:多进程任务分发和结果收集
- Web后端重要性:⭐⭐
- 面试:★★
11.3 进程间通信 - Pipe
What(是什么)
Pipe就像一个两头通的管道。两个人各拿一头,说的话对方能听到(全双工),或者一个只能说一个只能听(半双工)。它比Queue更轻量,适合两个进程直接一对一通信。
Why(为什么用Pipe)
Queue适合多对多(多个生产者和多个消费者),而Pipe适合点对点(两个进程直接对话)。更轻量,更简单。
How(怎么用)
python
import multiprocessing
import time
def func1(conn):
"""进程1:发消息给进程2,并接收回复"""
msg = "Hello! 我是进程1"
print(f"进程1发送: {msg}")
conn.send(msg)
time.sleep(0.5)
# 接收进程2的回复
reply = conn.recv()
print(f"进程1收到回复: {reply}")
def func2(conn):
"""进程2:接收进程1的消息,并回复"""
msg = conn.recv()
print(f"进程2收到: {msg}")
# 回复
reply = "你好! 我是进程2,我收到你的消息了"
print(f"进程2回复: {reply}")
conn.send(reply)
if __name__ == '__main__':
# 创建管道,得到两头
conn1, conn2 = multiprocessing.Pipe()
# 创建两个进程,各拿一头
p1 = multiprocessing.Process(target=func1, args=(conn1,))
p2 = multiprocessing.Process(target=func2, args=(conn2,))
p1.start()
p2.start()
p1.join()
p2.join()
print("通信结束")
- 业务场景:两个进程直接交换数据
- Web后端重要性:⭐
- 面试:★★
11.4 进程间通信 - Manager
What(是什么)
Manager就像一个"共享白板"。多个进程可以同时在这个白板上写字、查看内容。大家都看到同一块白板的内容,实现了数据的共享。
Why(为什么需要Manager)
Queue和Pipe都是"传递"数据,数据从一个进程到了另一个进程。但有时候我们需要的是"共享"数据------多个进程都能看到和修改同一份数据(像一个共享的列表、字典)。Manager就是为了这个场景设计的。
How(怎么用)
python
import multiprocessing
def worker(shared_list, shared_dict, name):
"""子进程修改共享数据"""
shared_list.append(f"来自{name}的数据")
shared_dict[name] = f"{name}已完成任务"
print(f"{name}修改了共享数据")
if __name__ == '__main__':
# 使用 Manager 作为上下文管理器
with multiprocessing.Manager() as manager:
# 创建共享的列表和字典
shared_list = manager.list(["初始数据"])
shared_dict = manager.dict({"状态": "进行中"})
print("修改前:", shared_list, shared_dict)
# 创建多个进程
p1 = multiprocessing.Process(target=worker, args=(shared_list, shared_dict, "进程A"))
p2 = multiprocessing.Process(target=worker, args=(shared_list, shared_dict, "进程B"))
p1.start()
p2.start()
p1.join()
p2.join()
print("修改后:", shared_list)
print("修改后:", shared_dict)
- 业务场景:多进程共享配置、状态
- Web后端重要性:⭐(分布式系统更常用Redis)
- 面试:★★
11.5 进程池 Pool
What(是什么)
进程池就像一家有固定员工数量的公司。公司不会每来一个项目就招一批新人、做完项目再辞退(那样太耗时了)。而是长期维持5个固定员工,有活就分配给他们,做完了一个接着下一个。
Why(为什么用进程池)
- 节省进程创建和销毁的时间(创建进程很慢)
- 节省内存(进程多了占内存)
- 控制并发数量(防止进程过多导致系统过载)
How(怎么用)
python
import multiprocessing
import os
import time
import random
def process_task(name):
"""要执行的任务"""
pid = os.getpid()
print(f"进程{pid}开始处理任务: {name}")
time.sleep(random.uniform(0.5, 2)) # 模拟处理时间
print(f"进程{pid}完成任务: {name}")
return f"{name}的结果"
def handle_result(result):
"""任务完成后的回调函数"""
print(f"📬 收到结果: {result}")
if __name__ == '__main__':
# 创建一个有3个进程的进程池
with multiprocessing.Pool(processes=3) as pool:
# 提交8个任务(进程只有3个,所以会自动排队)
results = []
tasks = ['任务A', '任务B', '任务C', '任务D', '任务E', '任务F', '任务G', '任务H']
for task in tasks:
# 异步提交(不等待,立刻返回)
result = pool.apply_async(
process_task,
args=(task,),
callback=handle_result # 任务完成后自动调用
)
results.append(result)
# 等待所有任务完成
pool.close() # 不再接受新任务
pool.join() # 等待所有任务结束
print("\n所有任务都完成了!")
# map方式:更简洁的批量处理
print("\n=== map方式批量处理 ===")
def double(x):
return x * 2
with multiprocessing.Pool(processes=4) as pool:
results = pool.map(double, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
print(f"结果: {results}")
- 业务场景:批量处理数据、并行下载、图片处理
- Web后端重要性:⭐⭐(异步任务处理)
- 面试:★★★
12. 协程 Coroutine
12.1 yield 方式(已淘汰,仅了解)
What(是什么)
最早期的协程是用Python的生成器功能(yield)来模拟的。生成器可以在执行到一半时暂停,交出控制权,之后再从暂停的地方继续。利用这个特性可以实现任务的切换。
Why(为什么被淘汰)
yield虽然能暂停和恢复,但有个致命缺陷:它不能真正解决IO等待问题。time.sleep() 会让整个线程都睡过去,其他任务也执行不了。就像一个工人说"我等零件的时候先干别的",但实际上还是坐在那里等,并没有真正去干别的。
How(了解即可,生产不用)
python
import time
def task1():
"""北京任务"""
for i in range(3):
print(f'北京:第{i}次打印啦')
yield # 暂停,交出控制权
time.sleep(1) # 这里会阻塞整个线程
def task2():
"""上海任务"""
generator = task1()
for k in range(3):
print(f'上海:第{k}次打印了')
next(generator) # 恢复task1
time.sleep(1) # 这里也会阻塞
if __name__ == '__main__':
start = time.time()
task2()
print(f"耗时{time.time()-start:.2f}秒") # 约5秒(并没省时间)
- 业务场景:无
- Web后端重要性:⭐
- 面试:★
12.2 asyncio 协程(现代标准,重点掌握)
What(是什么)
asyncio是Python 3.5之后正式推出的协程方案。核心是三个关键字:
async:声明一个函数是协程函数(可以暂停的函数)await:在这里暂停,去执行其他协程,等条件满足再回来asyncio.run():启动事件循环,驱动所有协程运行
事件循环(Event Loop) 就像一个大管家,管理着所有协程。哪个协程准备好了就让谁执行,遇到await就把控制权交给另一个准备好的协程。
Why(为什么asyncio这么重要)
- 极轻量:一个线程可以跑成千上万个协程,切换几乎无开销
- 非阻塞IO:遇到IO等待时,协程主动让出,事件循环去执行其他协程,CPU不浪费
- 简单:async/await语法清晰,就像写同步代码一样,但实际上是异步执行的
最适合的场景:Web后端(处理大量并发请求)、爬虫(同时抓取很多页面)、实时通信
How(怎么用)
python
import asyncio
import time
async def fetch_web(url, delay):
"""模拟请求网页(每个网页返回时间不同)"""
print(f"开始请求 {url}")
await asyncio.sleep(delay) # 模拟网络IO,这里不阻塞!
print(f"{url} 请求完成(耗时{delay}秒)")
return f"{url}的内容"
async def main():
"""主函数:同时请求多个网页"""
start_time = time.time()
# 同时发起3个请求
results = await asyncio.gather(
fetch_web("百度", 2),
fetch_web("淘宝", 1),
fetch_web("京东", 2)
)
print(f"\n所有结果: {results}")
print(f"总耗时: {time.time() - start_time:.2f}秒")
# 输出约2秒(最长的那个),而不是5秒
# 运行
if __name__ == '__main__':
asyncio.run(main())
# ============ 更接近实际的例子 ============
print("\n=== 模拟Web后端处理请求 ===")
async def handle_request(user_id, request_name):
"""处理用户请求的完整流程"""
print(f"[用户{user_id}] 收到请求: {request_name}")
# 第1步:查询数据库(模拟)
print(f"[用户{user_id}] 查询数据库中...")
await asyncio.sleep(0.5) # 模拟数据库查询耗时
# 第2步:调用第三方API(模拟)
print(f"[用户{user_id}] 调用外部API...")
await asyncio.sleep(1.0) # 模拟API调用耗时
# 第3步:返回结果
print(f"[用户{user_id}] 请求完成!")
return f"[用户{user_id}] 的{request_name}结果"
async def web_server():
"""模拟Web服务器同时处理3个请求"""
start = time.time()
results = await asyncio.gather(
handle_request(1, "查看订单"),
handle_request(2, "支付"),
handle_request(3, "查询物流")
)
print(f"\n处理结果: {results}")
print(f"3个请求总耗时: {time.time() - start:.2f}秒")
# 约1.5秒(最长的请求),而不是4.5秒
asyncio.run(web_server())
- 业务场景:FastAPI/Starlette Web框架、爬虫、WebSocket
- Web后端重要性:⭐⭐⭐⭐⭐(核心支柱)
- 面试:★★★★★
面试题 :asyncio的原理是什么?和线程相比有什么优势?
面试答案:asyncio基于事件循环(Event Loop),协程通过await主动让出控制权,事件循环调度其他协程。优势:1)单线程内实现并发,无锁开销;2)协程切换在用户态,极轻量;3)一个线程可支撑上万个并发连接。线程由操作系统调度,切换开销大且受GIL限制。协程适合I/O密集型高并发场景。
13. Web后端技术选型总结
| 场景 | 推荐方案 | 重要度 | 原因 |
|---|---|---|---|
| 处理大量HTTP请求 | FastAPI + asyncio | ⭐⭐⭐⭐⭐ | 异步非阻塞,单机支撑数万并发 |
| 后台CPU密集计算 | 进程池(multiprocessing) | ⭐⭐⭐⭐ | 利用多核,避开GIL |
| 异步任务队列 | Celery + Redis/RabbitMQ | ⭐⭐⭐⭐ | 生产者消费者模式,解耦可靠 |
| API频率限流 | Semaphore | ⭐⭐⭐ | 控制并发数量 |
| 文件批量处理 | 进程池 Pool.map | ⭐⭐⭐ | 并行处理大量文件 |
| 两进程通信 | Pipe/Queue | ⭐⭐ | 简单点对点或队列通信 |
| 多进程共享状态 | Manager/Redis | ⭐⭐ | 共享数据,分布式用Redis |
📌 学习建议:
作为Web后端开发者,按重要度分配学习时间:
- 最优先(⭐⭐⭐⭐⭐):协程asyncio、并发概念、同步/异步区别 → 这是日常工作的核心
- 重要(⭐⭐⭐⭐):GIL原理、生产者消费者模式 → 理解原理,指导选型
- 了解(⭐⭐⭐):进程/线程创建、锁、Pool → 偶尔用到,知道什么时候用就行
- 知道即可(⭐⭐及以下):Pipe、Manager、Event等 → 阅读代码时能看懂
💡 每个代码块都是完整可运行的,复制到
.py文件就能直接执行。建议边看边跑代码,改改参数,感受不同。