并发和并行
并发
核心是单个CPU,在同一时间段内,处理多个任务。本质是时间片轮转(快速切换),微观上的任意一个瞬间,其实只有一件事情在发生。其实就是单个CPU采用时间片转轮转,一会儿去执行这点任务,一会儿去执行那点任务。
并行
多个 CPU 同时执行多个任务。
多进程
什么是进程
进程是操作系统进行资源分配的基本单位。操作系统中一个正在运行的程序或软件就是一个进程。每个进程都有自己独立的一块内存空间。一个进程崩溃后,在保护模式下不会对其他进程产生影响,多进程是指在操作系统中同时运行多个程序。
使用multiprocessing.Process创建进程
Unix/Linux操作系统提供了一个 os.fork() 系统调用,它非常特殊。普通的函数调用,调用一次,返回一次,但是 fork() 调用一次,返回两次,因为操作系统自动把当前进程(父进程)复制了一份(子进程),然后,分别在父进程和子进程内返回。Windows 中没有 fork() 调用,不过Python提供了一个跨平台的多进程模块 multiprocessing。multiprocessing 模块提供了一个 Process 类来代表一个进程对象。
multiprocessing.Process(group=None, target=None, name=None, args=(), kwargs={}, *, daemon=None)
- target (最常用) 含义:目标任务。你要让这个新进程去执行哪个函数,就把那个函数名传给它。 注意:传的是函数名,不要加括号!比如写 target=worker,千万别写成 target=worker()。
- args (常用) 含义:专门用来给 target 函数传递位置参数的。它必须是一个元组 (tuple)。 坑点警告:如果你的函数只有一个参数,元组里面必须加一个逗号!比如 args=(10,),如果写成 args=(10) 会报错,因为括号会被当成数学运算符而不是元组。
- kwargs (常用) 含义:专门用来给 target 函数传递关键字参数的。它必须是一个字典 (dict)。 用法:比如 kwargs={"name": "张三", "age": 18}。
- name (可选) 含义:给你的子进程起个名字。主要为了打印日志或调试时方便认出它是谁。 默认:如果你不传,系统会自动按顺序给它起名,比如 Process-1、Process-2。
- daemon (特殊) 含义:设置是否为“守护进程”(必须作为关键字参数传入,因为前面有个 * 分隔符)。 用法: daemon=True:这个子进程变成守护进程。它的命绑定在父进程身上。主程序(父进程)只要代码执行完了,不管守护进程活没干完,都会被立刻强行杀死。 daemon=False(默认通常是这个):非守护进程。主程序执行完代码后,会乖乖留在原地,等待所有子进程干完活,大家才一起彻底退出。 None:继承父进程的设置。
- group (凑数的) 含义:应该始终保留为 None。 为什么存在:Python 为了让大家学了多线程 threading.Thread 后能无缝切换到多进程,故意让 Process 的参数长得和 Thread 一模一样。这个参数在多进程里没实际用处,就是个为了保持队形兼容的摆设。
守护进程,非守护进程
核心就看一件事:当主程序(父进程)把自己的代码执行完准备下班时,它是怎么对待子进程的?守护进程是当主进程执行完,直接结束整个程序的执行。而非守护进程是主进程会等到子进程把任务也执行完才结束整个程序。
最常见的还是非守护进程,比如你开个子进程去下载文件,你肯定希望文件下载完程序再关掉,哪怕主进程没别的事干了。
守护进程,主程序的代码只要一执行完,立刻就会退出。同时,系统会毫不留情地强行杀死所有还在运行的守护进程。适合跑一些跑一些“后台辅助任务”。比如:
- 后台定时发送心跳包(告诉服务器我还活着)。
- 后台默默收集电脑的 CPU 占用率。 一旦主程序业务办完关掉了,这些心跳包、监控也就没必要发了,直接被强杀是符合逻辑的。
Process 的属性和方法与其他常用方法
- name:获取进程名称。
- pid:获取进程号。
- daemon:判断或设置进程是否为守护进程。
- exitcode:获取子进程的退出状态码。
- start():启动进程,调用传入 target 的对象。start() 只能被调用一次。 这才是真正召唤分身术的方法!调用它,系统才会向操作系统申请创建一个全新的子进程,然后在这个新进程里去执行你的代码。永远只调用
start(),让底层自动去调run()。 - run():默认调用传入 target 的对象,如果子类化了 Process,可以重写此方法来自定义行为。 它只是一个普通的函数调用。如果你在主程序里手滑写成了
p.run(),代码依然会执行,但它是在主进程里串行执行的,根本没有创建新进程!这就失去了多进程的意义。 - join([timeout]):阻塞主进程,直到子进程结束或超时。timeout参数可选,意为阻塞多少秒。 个人理解:join 的意思就是主进程,你给我站住,等子进程干完活你再往下走。如果没有 join():主进程如果代码跑到头了,就会直接结束下班(如果是守护进程,子进程还会被无情杀掉)。加了 timeout:主进程说我最多只等你 3 秒(join(3)),3 秒后你还没干完,我也得往下走了。
- terminate():强制终止子进程,发送的是 SIGTERM 信号。相当于保安走过去跟子进程说:“系统要你停下,请收拾一下东西离开”。子进程有机会做一些轻微的清理工作。
- kill():杀死进程,与 terminate() 类似,但更彻底。发送的是
SIGKILL信号。相当于一枪毙命。如果子进程正好在写文件,文件可能会损坏或者数据丢失。 - is_alive():检查进程是否仍在运行。
- os.getpid():获取当前进程编号。
- os.getppid():获取当前进程的父进程编号。
使用举例
import time
import multiprocessing
# 向文件中写入数据
def write_file():
with open("test.txt", "a") as f:
while True:
f.write("hello world\n")
f.flush()
time.sleep(0.5)
# 从文件中读取数据
def read_file():
with open("test.txt", "r") as f:
while True:
time.sleep(0.1)
print(f.read(1))
if __name__ == "__main__":
# 创建一个子进程用于写文件
p1 = multiprocessing.Process(target=write_file)
# 创建一个子进程用于读文件
p2 = multiprocessing.Process(target=read_file)
# 启动子进程
p1.start()
# 启动子进程
p2.start()
为啥上面的代码需要加上__name__ == "main" ?
首先要说的这是windows里面特别需要写的,在 Windows 下,Python 创建子进程的方式叫 spawn。它的底层逻辑是重新打开一个完整的 Python 解释器,并且把你的代码从头到尾再导入(import)执行一遍,以便找到你要运行的那个函数(比如 write_file)如果不加会怎样?:如果主程序没有用 if name == "main": 保护起来,子进程在导入代码时,就会看到 p1.start(),于是它傻乎乎地又去创建“孙子进程”;孙子进程又看到 start(),去创建“重孙子进程”……瞬间变成无限套娃(递归炸弹),你的电脑会卡死,程序也会直接抛出 RuntimeError 崩溃。 加上后的作用:这行代码的意思是“只有当我是被用户直接右键运行的那个主脚本时,我底下的代码才执行”。子进程在后台导入这个脚本时,name 会变成模块名而不是 "main",所以它不会去执行创建进程的代码,这就完美切断了套娃。
f.flush() 是干啥的?
它是用来**“强制刷新内存缓冲区,立刻把字刻在硬盘上”**的。
背景:为了保护硬盘和提高速度,Python(以及操作系统)在往文件里写数据时,非常“抠门”。你调了一次 f.write("hello"),它不会立刻启动磁头去写硬盘,而是把 "hello" 暂存到内存里的一个叫缓冲区(Buffer)的地方。等这个区塞满了,或者文件关闭了,它才磨磨唧唧地一次性把数据打包倒进硬盘里。 在这段代码里的作用:因为你是一个进程负责写,另一个进程负责读。如果不加 f.flush(),写进程的数据全憋在自己的内存缓冲区里,读进程去硬盘文件上摸,摸出来的全是一片空白。加了 flush(),就是强迫它“写一行就立刻存入硬盘”,这样读进程才能马上读到。
with 是怎么使用的?
with 被称为上下文管理器(Context Manager) ,它最伟大的作用就是**“帮你自动擦屁股”。只要是涉及到“打开/关闭”、“获取/释放”资源的场景(比如读写文件、网络连接、多线程加锁),都应该用 with。它省去了你手动写 close() 或 release() 的烦恼,安全又简洁。
下面是以前的写法
f = open("test.txt", "a")
try:
f.write("hello")
# 如果这里代码出 Bug 崩溃了,f.close() 就永远不会执行,文件就被锁死占用,导致资源泄漏。
finally:
f.close()
下面是使用with的写法
with open("test.txt", "a") as f:
f.write("hello")
# 只要代码脱离了 with 的缩进块,无论是正常结束,还是中间报错崩溃了...
# ...Python 都会在底层 100% 确保自动帮你执行 f.close()!
自定义Process子类创建进程
这个是通过继承 multiprocessing.Process 对象,然后继承的话,最大的好处就是可以自己加一些属性,对一些需要一些自己属性的情况会比较有效,另外这种用法需要重写run方法,上面使用多线程的是将操作(函数)传给target,而这个方法是将操作放在run里面,系统内部会自己调用run方法。
import os
import multiprocessing
import time
class Worker(multiprocessing.Process):
# 重写 __init__ 方法,接收我们需要传给子进程的数据
def __init__(self, task_id, task_name):
# 【坑点警告】:必须首先调用父类的 __init__ 方法!
# 这一步是让底层操作系统准备好创建进程需要的环境
super().__init__()
# 将传入的数据保存为类属性,供后续 run 方法使用
self.task_id = task_id
self.task_name = task_name
# 重写 run 方法,这是子进程真正干活的地方
def run(self):
# 这里可以直接愉快地调用 self 里的数据了
print(f"✅ [开始执行] 进程ID: {os.getpid()} | 父进程ID: {os.getppid()} | 任务编号: {self.task_id} | 任务名称: {self.task_name}")
# 模拟干活耗时
time.sleep(1)
print(f"🏁 [执行完毕] 任务 {self.task_name} 完成!")
if __name__ == "__main__":
processes = []
# 比如我们有 3 个不同的任务要交给 3 个进程去办
tasks = ["清洗数据", "训练模型", "导出报表"]
for i in range(len(tasks)):
# 创建进程对象时,把数据塞进参数里
p = Worker(task_id=i+1, task_name=tasks[i])
processes.append(p)
p.start()
# 为了让打印结果好看点,让主进程等所有子进程干完活
for p in processes:
p.join()
print("🌟 主进程:所有任务分配完毕并执行成功,下班!")
进程池
当需要大量的子进程,又为了减小反复创建和销毁带来的消耗,可以用进程池
multiprocessing.Pool([processes[,initializer[,initargs[,maxtasksperchild[,context]]]]])
先看下参数的写法,官方文档(尤其是 CPython 底层 C 语言实现的内置函数)用来描述函数该如何调用的文档标记法.上面写法核心的一点其实[],[]都是表示可用可无。看上面的例子意思是processes参数是可有可无的,为啥后面又套了[,initializer,首先[,initializer,是套在processes里面的,首先说明只有有了processes,你才能考虑传后面initializer参数,然后为啥后面直接跟个逗号呢,是因为如果你要传参数的肯定是 (processes,initializer 。。。),有initializer就会有一个逗号。
详细解释下上面的参数:
- processes,进程池里面需要多少个进程,
如果不传这个参数,系统会默认使用 os.cpu_count()(你有几个 CPU 逻辑核心,就开几个进程)。这是处理计算密集型任务的黄金法则。因为核心就那么多,开多了不但没用,反而会因为互相抢占 CPU 变慢。
2.initializer:如果不为 None,则每个工作进程将会在启动时调用 initializer(*initargs)。
3.maxtasksperchild:一个工作进程在它退出或被一个新的工作进程代替之前能完成的任务数量,为了释放未使用的资源。默认的 maxtasksperchild 是 None,意味着工作进程寿与池齐。 maxtasksperchild如果你设置了
maxtasksperchild=10,意思是这个工作进程在连着干完 10 个任务后,系统会强制把它干掉,然后立刻原地重生一个干干净净的新工作进程来接班。这就完美清除了长期运行积累的内存垃圾! 4.context:可被用于指定启动的工作进程的上下文。通常一个进程池是使用函数 multiprocessing.Pool() 或者一个上下文对象的 Pool() 方法创建的。
import multiprocessing
import os
import time
# 初始化函数:每次新员工上岗,都会打卡一次
def init_worker():
print(f"🌟 [新员工入职] 进程 {os.getpid()} 完成初始化,准备接单!")
# 真正的任务函数
def task(task_id):
print(f" -> 进程 {os.getpid()} 正在处理任务 {task_id}")
time.sleep(0.5) # 稍微睡一下,让打印有节奏感
return f"任务 {task_id} 完成"
if __name__ == '__main__':
print("主进程开始建池子,规定每人最多处理 2 个任务...\n")
# 结合使用 initializer 和 maxtasksperchild
with multiprocessing.Pool(
processes=2, # 保持池子里只有 2 个人
initializer=init_worker, # 新人入职必须打卡
maxtasksperchild=2 # 核心:干满 2 单强制下班,换新人接盘
) as pool:
# 丢进 6 个任务让它们处理
tasks = [101, 102, 103, 104, 105, 106]
pool.map(task, tasks)
print("\n所有任务处理完毕!")
进程池的常用方法
- apply(func[, args[, kwds]]):使用 args 参数以及 kwds 命名参数同步调用 func , 在返回结果前阻塞。另外 func 只会在一个进程池中的一个工作进程中执行。
- apply_async(func[, args[, kwds[, callback[, error_callback]]]]):使用 args 参数以及 kwds 命名参数异步调用 func,并立即返回一个 AsyncResult 对象,不会阻塞。可以通过 callback 获取结果和通过 error_callback 处理异常。
- close():阻止后续任务提交到进程池,当所有任务执行完成后,工作进程会退出。
- terminate():不必等待未完成的任务,立即停止工作进程。当进程池对象被垃圾回收时,会立即调用 terminate()。
- join():阻塞主进程,等待工作进程结束。调用 join() 前必须先调用 close() 或者 terminate()。
apply vs apply_async (同步 vs 异步)
apply(同步,很少用): 你往池子里扔一个任务,然后主进程就像被定住了一样死等,直到这个任务被某个子进程执行完、拿到结果了,主进程才往下走,再去派发下一个任务。 致命缺点:因为主进程在等,所以哪怕你池子里有 10 个工作进程,实际上每次也只有 1 个进程在干活。这完全失去了多进程并发的意义,相当于白建了池子。所以日常开发中几乎见不到有人用 apply。
apply_async(异步,最常用): 主进程就像一个无情的发牌机器,只管疯狂地把任务往池子里扔。扔完一个立刻扔下一个,绝对不等结果。 神仙功能:因为它不等结果,怎么知道子进程干没干完呢?它提供了 callback(回调函数)。当某个子进程干完活后,会自动跑去执行这个 callback 汇报结果,主进程完全不需要操心。真正的并发全靠它!
close() -> terminate() -> join()
- close():意思是“挂出暂停后续任务的牌子”。池子不再接收新任务了,但是已经在池子里的、正在干活的进程,可以不慌不忙地把手头的活儿干完。
- terminate()(暴力拉闸): 意思是“立刻拔电源”。不管是新任务还是正在处理的任务,统统立刻死掉。
- join()(苦苦等待,必须放在拉闸之后!): 意思是“主进程原地等着,直到池子里所有进程彻底下班”。 大坑警告:你的笔记里写得很对,调用 join() 之前必须先调用 close() 或 terminate()。为什么?因为如果不先挂出“不再接客”的牌子(close),主进程怎么知道永远不会有新任务进来了呢?它就会无限期地傻等下去,导致程序死锁卡住。
同步任务
with multiprocessing.Pool(4) as pool:
# map 会在这里死死卡住主进程,直到全干完
results = pool.map(task_func, [1, 2, 3, 4])
# 自动 terminate(),安全退出
异步任务
with multiprocessing.Pool(4) as pool:
for i in range(10):
# 瞬间扔进去 10 个任务,主进程根本不停留
pool.apply_async(task_func, args=(i,))
# 🚨 极度重要 🚨
# 在跳出 with 之前,必须手动挂上“停业”牌子,并让主进程原地坐下等!
pool.close()
pool.join()
# 等上面 join 彻底结束了,主进程才会来到这里,跳出 with 触发 terminate
print("这下才算安全退出")
进程间通信
进程是使用的内存是相互隔离的,而线程之间是共享的。下面看一个例子,进程p1,进程p2,和外层的list1,三个list1都是不同的list1,对这个三个任意一个做操作,都不影响其他两个。反过来想,如果我们想使用同一个就必须要用到进程间的通信了。
import os
import multiprocessing
# 向list1中添加10个元素
def func(list1):
for i in range(10):
list1.append(i)
print(os.getpid(), list1)
if __name__ == "__main__":
list1 = []
p1 = multiprocessing.Process(target=func, args=(list1,))
p2 = multiprocessing.Process(target=func, args=(list1,))
p1.start()
p2.start()
p1.join()
p2.join()
print(os.getpid(), list1)
可以使用 Queue 进行通信,这个Queue底层肯定是能保证进程安全的,不论get获取数据还是put设置数据. multiprocessing.Queue([maxsize]) 返回一个使用一个管道和少量锁和信号量实现的共享队列(先进先出)实例。这个maxsize 意思是保证生产者和消费者最大差值是多少,不能生产者生产的太快,而消费者比较慢,大量的数据放在内存中,影响内存,而生产者最多比消费者快多少,这个就是maxsize。 这说明 Queue 内部自动帮你上好了锁。不管多少个进程同时往里面塞数据(put),或者同时从里面抢数据(get),数据绝对不会乱,非常安全。
Queue的常用方法
- qsize():返回队列的大致长度。由于多线程或者多进程的上下文,这个数字是不可靠的。
- empty():如果队列是空的返回 True。由于多线程或多进程的环境,该状态是不可靠的。
- full():如果队列是满的返回 True。由于多线程或多进程的环境,该状态是不可靠的。
- put(obj[, block[, timeout]]):将 obj 放入队列。如果可选参数 block 是 True(默认值)而且 timeout 是 None(默认值),将会阻塞当前进程,直到有空的缓冲槽。如果 timeout 是正数,将会在阻塞了最多 timeout 秒之后还是没有可用的缓冲槽时抛出 queue.Full 异常。反之(block 是 False 时),仅当有可用缓冲槽时才放入对象,否则抛出 queue.Full 异常(在这种情形下 timeout 参数会被忽略)。
- put_nowait(obj):相当于 put(obj, False)。
- get([block[, timeout]]):从队列中取出并返回对象。如果可选参数 block 是 True (默认值)而且 timeout 是 None(默认值),将会阻塞当前进程,直到队列中出现可用的对象。如果 timeout 是正数,将会在阻塞了最多 timeout 秒之后还是没有可用的对象时抛出 queue.Empty 异常。反之(block 是 False 时),仅当有可用对象能够取出时返回,否则抛出 queue.Empty 异常(在这种情形下 timeout 参数会被忽略)。
- get_nowait():相当于 get(False)
import time
import random
import multiprocessing
# 间隔随机时间向queue中放入随机数
def func1(queue):
while True:
queue.put(random.randint(1, 50))
time.sleep(random.random())
# 从queue中取出数据
def func2(queue):
while True:
print("=" * queue.get())
if __name__ == "__main__":
queue = multiprocessing.Queue()
p1 = multiprocessing.Process(target=func1, args=(queue,))
p2 = multiprocessing.Process(target=func2, args=(queue,))
p1.start()
p2.start()
p1.join()
p2.join()
multiprocessing.Queue存在兼容性问题,如果要使用进程池,可以使用Mananger().Queue。如果你是上面的写法 multiprocessing.Process(target=func1, args=(queue,))这里面的queue可以用 multiprocessing.Queue()方式创建,而如果你是用的线程池的方式,就要用multiprocessing.Manager().Queue()
import time
import random
import multiprocessing
# 间隔随机时间向queue中放入随机数
def func1(queue):
while True:
queue.put(random.randint(1, 50))
time.sleep(random.random())
# 从queue中取出数据
def func2(queue):
while True:
print("=" * queue.get())
if __name__ == "__main__":
queue = multiprocessing.Manager().Queue()
pool = multiprocessing.Pool(2)
pool.apply_async(func1, (queue,))
pool.apply_async(func2, (queue,))
pool.close()
pool.join()
多线程
什么是多线程?
线程是处理器任务调度和执行的基本单位。一个进程至少有一个线程,也可以运行多个线程。多个线程之间可共享数据。线程运行出错异常后,如果没有捕获,会导致整个进程崩溃。多线程是指在同一进程中同时执行多个任务。
使用threading.Thread创建线程
Python的标准库提供了两个模块:_thread 和 threading,_thread 是低级模块,threading是高级模块,对 _thread 进行了封装。绝大多数情况下,我们只需要使用 threading 这个高级模块。
Thread 的创建
threading.Thread(group=None, target=None, name=None, args=(), kwargs={}, *, daemon=None)
- group:应为 None,保留给将来实现 ThreadGroup 类的扩展使用。
- target:用于 run() 方法调用的可调用对象。默认是 None,表示不需要调用任何方法。
- name:线程名称。 在默认情况下,会以 “Thread-N” 的形式构造唯一名称,其中 N 为一个较小的十进制数值,或是 "Thread-N (target)" 的形式,其中 “target” 为 target.name,如果指定了 target 参数的话。
- args:用于发起调用目标函数的参数列表或元组。 默认为 ()。
- kwargs:用于调用目标函数的关键字参数字典。默认是 {}。
- daemon:True 或 False 来设置该线程是否为守护模式。如果是 None (默认值),线程将继承当前线程的守护模式属性。
Thread 的属性和方法与其他常用方法
- name:线程的名称。
- daemon:线程是否为守护线程。
- ident:线程标识符。
- native_id:此线程的线程id(tid),由 OS(内核)分配。
- start():启动线程,调用线程的 run() 方法。
- run():定义线程的行为,默认调用传入的 target 对象。
- join([timeout=None]):阻塞主线程,直到当前线程运行完成或达到超时时间。
- is_alive():线程是否在运行。
- threading.enumerate():查看都有哪些线程。
- threading.current_thread():返回当前线程实例。
使用举例
import time
import threading
# 交替打印 00000 和 11111
def func():
flag = 0
while True:
print(threading.current_thread().name, f"{flag}" * 5)
flag = flag ^ 1 # 替换0和1
time.sleep(0.5)
if __name__ == "__main__":
t1 = threading.Thread(target=func, name="线程1")
t2 = threading.Thread(target=func, name="线程2")
t1.start()
t2.start()
自定义Thread子类创建线程
和进程那很类似
import time
import threading
class Worker(threading.Thread):
def __init__(self, name):
super().__init__()
self.name = name
def run(self):
flag = 0
while True:
print(f"\r{self.name}:{str(flag)*5}", end="")
flag = flag ^ 1 # 替换0和1
time.sleep(0.2)
if __name__ == "__main__":
t1 = Worker("线程1")
t2 = Worker("线程2")
t1.start()
t2.start()
线程池
ThreadPoolExecutor 是 concurrent.futures 模块中的线程池实现,它允许我们轻松地提交任务到线程池,并管理任务的执行和结果。
1)线程池的创建
concurrent.futures.ThreadPoolExecutor(max_workers=None, thread_name_prefix="", initializer=None, initargs=())
- max_workers:线程池的最大线程数(默认取决于系统资源),进程那默认是CPU的核心数。
- thread_name_prefix:线程名称前缀。
- initializer:可选的初始化函数。
- initargs:传递给初始化函数的参数。
2)线程池的常用方法
- submit(fn, *args, **kwargs):提交一个任务到线程池,返回一个 Future 对象。可使用 Future.result() 获取任务结果,这个方法类似于进程的start方法。
- map(func, *iterables, timeout=None, chunksize=1):类似于内置的 map() 函数,但在线程池中并行执行。Iterables为可迭代对象,传递给目标函数。chunksize 对 ThreadPoolExecutor 没有效果。
- shutdown(wait=True, cancel_futures=False):关闭线程池,等待所有任务完成。wait 表示是否等待线程池中的所有线程完成任务。cancel_futures 表示是否取消尚未开始的任务。
3)使用例子
import concurrent.futures
def func(tname):
global word
for i, char in enumerate(word):
word[i] = chr(ord(char) ^ 1)
print(f"{tname}: {word}\n", end="")
return word
if __name__ == "__main__":
word = list("idmmn!vnsme")
# 使用 with 语句来确保线程被迅速清理
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
future1 = executor.submit(func, "线程1")
future2 = executor.submit(func, "线程2")
future3 = executor.submit(func, "线程3")
word = future1.result()
word = future2.result()
word = future3.result()
print("".join(word)) # hello world
互斥锁
import time
import threading
def func():
global g_num
for _ in range(10):
tmp = g_num + 1
# time.sleep(0.01)
g_num = tmp
print(f"{threading.current_thread().name}: {g_num}\n", end="")
if __name__ == "__main__":
g_num = 0
threads = [threading.Thread(target=func, name=f"线程{i}") for i in range(3)]
[t.start() for t in threads]
[t.join() for t in threads]
print(g_num) # 30
某个线程要更改共享数据时,先将其锁定,此时其他线程不能更改。直到该线程释放资源,将资源的状态变成“非锁定”,其他的线程才能再次锁定该资源。互斥锁保证了每次只有一个线程进行写入操作,从而保证了多线程情况下数据的正确性。
互斥锁的使用 可以通过 threading.Lock() 创建互斥锁。 使用 lock.acquire([blocking=True][, timeout=-1]) 来获取锁(blocking 如果为 True,线程会阻塞直到获取到锁。如果为 False,线程立即返回。获取锁成功返回 True,否则返回 False。timeout 为等待的超时时间,单位为秒。如果超时仍未获取到锁,则返回 False。)。 使用 lock.release() 释放锁。
import time
import threading
def func():
global g_num
for _ in range(10):
lock.acquire() # 获取锁
tmp = g_num + 1
time.sleep(0.01)
g_num = tmp
lock.release() # 释放锁
print(f"{threading.current_thread().name}: {g_num}\n", end="")
if __name__ == "__main__":
g_num = 0
lock = threading.Lock() # 创建锁
threads = [threading.Thread(target=func, name=f"线程{i}") for i in range(3)]
[t.start() for t in threads]
[t.join() for t in threads]
print(g_num) # 30
常见的锁
1. 互斥锁 Lock:单人公共厕所
最基础、最常用的锁。同一时刻只允许一个线程进入临界区执行代码。
- 特点:非递归。一个线程拿到锁后,如果逻辑出错再次请求这把锁,会发生死锁。
- 适用场景:保护普通全局变量、文件写入、数据库单条记录更新,防止被并发篡改。
实战示例:银行账户并发取钱
python
import threading
import time
balance = 1000
lock = threading.Lock()
def withdraw(amount):
global balance
with lock: # 自动上锁
if balance >= amount:
time.sleep(0.1) # 模拟网络延迟
balance -= amount
print(f"✅ 取款 {amount} 成功,余额: {balance}")
else:
print(f"❌ 余额不足!取款 {amount} 失败。余额: {balance}")
# 退出缩进,自动解锁
threads = [threading.Thread(target=withdraw, args=(800,)) for _ in range(2)]
for t in threads: t.start()
for t in threads: t.join()
2. 递归锁 RLock:套娃房间的万能钥匙
专门为解决 Lock 的“自己锁自己”缺陷而生。
- 特点:同一个线程可以无限次地对同一个
RLock加锁。底层维护了加锁计数器,加了多少次,退出时就会解多少次。 - 适用场景:递归函数加锁,或者相互嵌套的复杂函数调用链。
实战示例:复杂的函数嵌套调用
python
import threading
# 必须使用 RLock。如果换成普通 Lock,下面的嵌套调用必然死锁!
rlock = threading.RLock()
def step1():
with rlock:
print("步骤 1 执行中...")
# 灾难发生点(如果是普通 Lock 的话):
# 程序走到这里尝试调用 step2(),相当于去拿同一把锁。
# 此时锁已经被外层的自己拿着了,还没交还回去!
# 如果是普通的 Lock,它就会在这里死死卡住,再也走不下去,发生死锁。
# 但因为这里用的是 RLock,所以它允许自己锁自己,畅通无阻!
step2()
def step2():
with rlock:
print("步骤 2 执行中...")
print("完美执行,没有把自己卡死!")
threading.Thread(target=step1).start()
3. 信号量 Semaphore:有 N 个车位的停车场
允许指定数量的线程同时进入临界区。
- 特点:内部有个计数器。每进一个线程计数器减 1,减到 0 外面的线程就开始阻塞等待。
- 适用场景:并发限流。防止瞬间并发量过大把下游服务(如数据库、第三方 API)打挂。
实战示例:爬虫并发网络连接限制
python
import threading
import time
# 限制最多只能有 3 个线程同时发起下载
sema = threading.Semaphore(3)
def crawler(task_id):
with sema:
print(f"🚀 线程 {task_id} 开始下载文件...")
time.sleep(2) # 模拟耗时下载
print(f"✅ 线程 {task_id} 下载完成!释放连接。")
for i in range(1, 8):
threading.Thread(target=crawler, args=(i,)).start()
# 你会观察到,任务总是 3个、3个 地一批批执行
4. 事件锁 Event:马路上的全局红绿灯
基于内部的布尔标志进行线程间的同步控制。
-
特点:
wait():遇红灯(False)罚站等待。set():亮绿灯(True),瞬间放行所有等待的线程。clear():切回红灯。
-
适用场景:主线程做前置初始化,多个子线程等待统一命令“鸣枪起跑”。
实战示例:系统服务启动通知
python
import threading
import time
event = threading.Event()
def worker(worker_id):
print(f"👷 工人 {worker_id} 等待主控室指令...")
event.wait() # 死死等待,直到有人 set()
print(f"👷 工人 {worker_id} 收到绿灯!开工!")
for i in range(3):
threading.Thread(target=worker, args=(i,)).start()
print("👑 主控室:正在初始化系统核心组件 (耗时 3 秒)...")
time.sleep(3)
print("👑 主控室:系统就绪,全军突击!")
event.set() # 发送信号!所有工人瞬间动起来
5. 条件变量 Condition:流水线上的呼叫铃
结合了锁和等待唤醒机制。
- 特点:必须配合锁使用。发现条件不满足,调用
wait()睡觉并交出锁;条件满足后,调用notify()叫醒一个或全部(notify_all())睡觉的线程。 - 适用场景:生产者-消费者模型。(注:现代 Python 开发通常优先使用内置的
queue.Queue代替手搓 Condition)
实战示例:简易生产者与消费者
python
import threading
import time
cond = threading.Condition()
stock = 0
def consumer():
global stock
with cond:
while stock == 0:
print("👤 消费者:没货了,我先睡一会儿...")
cond.wait() # 睡觉,同时自动交出锁
stock -= 1
print(f"👤 消费者:抢到 1 件商品,剩余库存 {stock}")
def producer():
global stock
with cond:
print("🏭 生产者:咔咔咔生产了 1 件!")
stock += 1
cond.notify() # 敲铃!唤醒一个正在 wait 的消费者
threading.Thread(target=consumer).start()
time.sleep(1) # 确保消费者先跑,发现没货进入等待
threading.Thread(target=producer).start()
6. 栅栏锁 Barrier:集齐七颗龙珠召唤神龙
要求指定数量的线程都在某一个点汇合,才能继续往下走。
- 特点:创建时指定凑齐人数
N。线程执行到wait()就在原地罚站,直到人数刚好凑够N,栅栏瞬间打开。 - 适用场景:分片并行计算。把大任务拆给多个线程,等大家各自算完局部结果后,在终点集合,合并出最终结果。
实战示例:旅游团大巴发车
python
import threading
import time
import random
# 要求必须凑齐 4 个人才能发车
barrier = threading.Barrier(4)
def tourist(name):
time.sleep(random.uniform(0.5, 2)) # 模拟大家上厕所的时间不同
print(f"🏃 {name} 已经回到大巴车上,等待其他人...")
# 到达集合点,原地罚站
barrier.wait()
print(f"🎉 {name}:所有人都到齐了,大巴终于发车啦!")
names = ["张三", "李四", "王五", "赵六"]
for name in names:
threading.Thread(target=tourist, args=(name,)).start()
GIL
Python 全局解释器锁(Global Interpreter Lock, 简称 GIL)是一个锁,同一时间只允许一个线程保持 Python 解释器的控制权,这意味着在任何时间点都只能有一个线程处于执行状态。执行单线程程序时看不到 GIL 的影响,但它可能是 CPU 密集型和多线程代码中的性能瓶颈。GIL并不是Python的特性,它是在实现Python解析器(CPython)时所引入的一个概念。 Python于1991年诞生,从操作系统没有线程概念的时代就已经存在了。由于物理上的限制,各CPU厂商在核心频率上的比赛已经被多核所取代。为了利用多核,Python开始支持多线程。而为了解决多线程之间数据完整性和状态同步,于是有了GIL,GIL 提供了线程安全的内存管理。 GIL 的存在会对多线程的效率有不小影响。甚至就几乎等于Python是个单线程的程序。我们可能会想 GIL只要释放的勤快效率也不会差,至少也不会比单线程的效率差。理论上是这样。 但实际上,Python为了让各个线程能够平均利用CPU时间,会计算当前已执行的微代码数量,达到一定阈值后就强制释放GIL。而这时也会触发一次操作系统的线程调度(当然是否真正进行上下文切换由操作系统自主决定)。从释放 GIL 到获取 GIL 之间几乎是没有间隙的。所以当其他在其他核心上的线程被唤醒时,大部分情况下主线程已经又再一次获取到 GIL 了。这个时候被唤醒执行的线程只能白白的浪费CPU时间,看着另一个线程拿着 GIL 执行。然后达到切换时间后进入待调度状态,再被唤醒,再等待,以此往复恶性循环。
个人理解一个线程在执行这段加锁的代码,GIL 是其他线程即便是不执行这段代码了,也停下来。而上面的其他锁不是,遇到上面其他锁是可以继续后执行,只有恰好是也执行这段代码的时间才会停下来等待
进程和线程的对比
区别
- 资源分配 进程拥有独立的内存空间和系统资源,每个进程都有自己的代码段、数据段和堆栈等。而线程共享所属进程的内存空间和资源,同一进程内的线程之间可以直接访问共享内存。
- 开销 创建进程需要分配独立的内存、打开文件等系统资源,开销较大。 创建线程只需在所属进程的内存空间内进行少量资源分配,开销较小。
- 并发性 在多核心 CPU 环境下,进程和线程都可以异步执行但进程之间的异步是真正的异步(每个进程在不同核心上同时执行),而线程之间的异步在单核心 CPU 上是通过时间片轮转实现的 “伪异步”(在同一时刻只有一个线程执行),在多核心 CPU 上可以实现异步。但是在Cpython中,因为GIL的存在,也不是真正的异步
- 独立性 进程之间相互独立,一个进程的崩溃通常不会影响其他进程。而同一进程内的线程之间相互影响,一个线程出现问题可能导致整个进程崩溃。
- 通信 进程间通信相对复杂,需要使用特殊的机制,如管道、消息队列、共享内存等。 线程间通信相对简单,因为它们共享内存,可以直接访问共享变量。
使用场景
- 适合使用多线程的情况: I/O 密集型任务:如网络请求、文件读写等。线程共享内存,切换开销小,在等待 I/O 操作完成的时间内可以切换到其他线程执行,提高整体效率。例如一个程序需要同时从多个网站下载数据,使用多线程可以在等待网络响应时执行其他下载任务。 对资源共享要求高:线程间共享内存,方便数据共享和通信。例如在一个图形界面程序中,多个线程需要共享界面数据并进行实时更新。
- 适合使用多进程的情况: CPU 密集型任务:多进程可以利用多核心 CPU 实现真正的并行计算,充分发挥硬件性能。例如进行复杂的科学计算、数据处理等任务,每个进程在不同核心上独立计算,提高计算速度。 需要隔离的任务:进程相互独立,一个进程崩溃不会影响其他进程。对于一些可能出现异常或不稳定的任务,使用多进程可以保证系统的稳定性。例如运行多个独立的服务,每个服务作为一个进程,避免一个服务出错影响其他服务。