Python 多任务编程:进程、线程、协程全解
这里写目录标题
- 前言
- 一、python多任务概念
- 1.1 什么是多任务,作用是什么
- 1.2 多任务实现方式
- 进程
- 线程
- 协程
- 1.3 任务分类
- CPU 密集型
- I/O 密集型
- 1.4 GIL 全局解释器锁
- 二、Python 进程详解
- 2.1 进程基础概念、实现与参数传递
- 2.2 进程启动的底层原理与重要注意事项
- 2.3 进程间通信(Queue)与底层原理
- 三、Python 线程详解
- 3.1 线程基础:概念、实现与传参
- 3.2 线程核心注意事项(无序、守护、共享变量及安全问题)
- 3.3 互斥锁与死锁
- 四、线程池
- 4.1 线程池概述:是什么,为什么用?
- 4.2 Python 中的线程池实现
- 4.2.1 创建线程池
- 4.2.2 提交任务的两种方式
- 4.2.3 获取结果、异常与回调
- 4.2.4 关闭线程池
- 4.3 深入原理:队列 + 工作线程模型
- 4.4 完整示例:并发下载网页(I/O 密集型)
- 五、python协程详解
- 5.1 协程基础概念
- 5.2 协程核心三要素
- 5.3 协程实现与并发实战
- 5.3.1 async 和 await协程核心语法规则
- async 关键字作用
- await 关键字使用约束与核心作用
- 5.3.2 基础示例:await 自动封装协程对象
- 5.3.3 并发实战:手动创建 `Task + asyncio.gather`
- 5.4 协程底层调度原理、使用注意事项及异步本质
- 5.4.1 协程底层调度原理
- 5.4.2 协程为什么是异步的实现方式?体现在哪里
- 六、进程 vs 线程vs 协程:核心对比
前言
在 Python 开发中,多任务编程是提升程序执行效率、充分利用 CPU 资源的核心手段,而进程、线程、协程是实现多任务的方式。
一、python多任务概念
1.1 什么是多任务,作用是什么
什么是多任务
多任务指同一时间内执行多个任务(用户感官上的同时),比如电脑同时运行微 信、浏览器、IDE,就是典型的多任务场景。多任务的作用
充分利用 CPU 资源,避免单任务阻塞导致的效率低下,大幅提升程序运行速度。多任务核心:并发 vs 并行
- 并发:任务数 > CPU 核心数,操作系统交替执行任务(单核 CPU 必为并发),因 CPU 速度极快,感官上是同时运行。
- 并行:任务数 ≤ CPU 核心数,多个 CPU 核心真正同时执行任务,效率更高。
1.2 多任务实现方式
进程
进程是操作系统资源分配的最小单位,每个进程拥有独立的内存空间和系统资源。多进程可以实现真正的并行(利用多核 CPU),但创建和切换开销较大,进程间通信也相对复杂。在 Python 中,multiprocessing模块用于实现多进程,适合 CPU 密集型任务。
线程
线程是操作系统调度的最小执行单元,同一进程内的多个线程共享内存空间,因此创建和切换开销较小,通信方便。但由于 Python 的 GIL(全局解释器锁),多线程无法实现真正的并行,仅能并发执行,适合 I/O 密集型任务。threading 模块提供了线程支持。
协程
协程是用户态的轻量级任务,完全由程序自身控制切换(如 async/await),无需操作系统参与,因此切换开销极低,单线程内可轻松运行数万甚至数十万个协程。协程不存在锁竞争问题,非常适合高并发的 I/O 场景。Python 的 asyncio 是协程编程的主流方案。
1.3 任务分类
CPU 密集型
任务主要消耗 CPU 计算资源(如循环计算、图像处理、模型训练),提升效率的关键是充分利用多核并行,适合采用多进程。
I/O 密集型
任务主要花费时间在等待输入输出(如文件读写、网络请求、数据库查询),提升效率的关键是减少阻塞等待,适合采用多线程或协程。
1.4 GIL 全局解释器锁
GIL(Global Interpreter Lock,全局解释器锁)是 CPython 解释器中的一个互斥锁,它确保同一时刻只有一个线程执行 Python 字节码。
这意味着即使有多个 CPU 核心,python多线程也无法实现并行执行字节码,只能是并发。
需要说明的是,线程内部执行分为用户态和内核态,内核态就是当字节码执行到需要操作系统服务时(如打开文件、发送网络数据、创建线程),解释器会调用 C 库函数,最终触发系统调用,线程执行从用户态切换到内核态。
在进入内核态、且系统调用已经发起之后,CPython 解释器会主动释放 GIL,让其他等待中的线程有机会获取 GIL 并执行字节码。与此同时,当前线程继续在内核态运行操作系统代码;若该操作需要等待外部数据(如网络响应尚未到达),操作系统会将该线程挂起(阻塞),并让出 CPU 时间片,直到数据就绪后被重新唤醒。
因此,在python中,执行CPU密集型任务,适合用多进程,在执行IO密集型任务时,适合多线程,GIL对IO密集型任务的影响比较小。
二、Python 进程详解
2.1 进程基础概念、实现与参数传递
进程是操作系统资源分配的最小单位,一个运行的程序(如 Python 脚本)就是一个进程,每个进程拥有独立的内存空间和系统资源。进程内至少有一个主线程,也可以创建多个子线程。多进程即通过主进程创建子进程,实现多任务的并行或并发。
实现步骤(multiprocessing 模块):
import multiprocessing- 创建进程对象:
p = multiprocessing.Process(target=任务函数, args/kwargs, name='进程名') - 启动子进程:
p.start()
参数传递支持元组和字典两种方式:
p1=multiprocessing.Process(target=coding,args=(3,"小明"))# 元组传参p2=multiprocessing.Process(target=music,kwargs={"count":2,"name":"小明"})# 字典传参获取进程编号(用于管理和调试):
- 当前进程 ID:os.getpid() 或 multiprocessing.current_process().pid
- 父进程 ID:os.getppid()
2.2 进程启动的底层原理与重要注意事项
启动原理:
Windows(默认 spawn 模式)
调用Process.start()会调用系统 API 生成全新子进程,启动独立 Python 解释器,执行python -c加载spawn_main入口脚本。父进程通过管道序列化传递目标函数与参数,子进程通过该脚本读取数据后导入当前脚本,执行指定任务。
Linux/macOS(默认 fork 模式)
底层直接调用系统fork()复制父进程内存空间,不新建解释器,无需执行额外 python 命令。子进程拷贝父进程运行状态后,清理多余资源,直接运行目标函数,不会完整重新加载脚本。
if__name__ == '__main__'作用
两种模式下子进程都会执行脚本顶层代码。若创建进程的代码未放入该判断块,spawn 模式会反复新建解释器递归创建进程;fork 模式也会持续复制子进程,最终出现无限创建进程、程序卡死的问题,因此进程实例化代码必须写在此分支内。
三大注意事项:
- 进程间不共享全局变量:子进程是主进程的资源副本,同名全局变量互不影响。
- 主进程默认等待所有子进程结束:会等子进程执行完毕再退出。
- 让主进程不等待子进程的方法:
- 守护进程(推荐):
p.daemon = True,主进程退出时子进程直接销毁。 - 手动终止(不推荐):
p.terminate(),易产生僵尸进程,无法释放资源。
- 守护进程(推荐):
2.3 进程间通信(Queue)与底层原理
由于进程内存隔离,无法像线程那样共享全局变量,需要通过multiprocessing.Queue实现数据传递。
frommultiprocessingimportProcess,Queuedefwrite(q):foriin["a","b","c"]:q.put(i)defread(q):whilenotq.empty():print(q.get())if__name__=='__main__':q=Queue()p1=Process(target=write,args=(q,))p2=Process(target=read,args=(q,))p1.start()p1.join()# 等待写进程结束p2.start()底层原理:multiprocessing.Queue内部基于操作系统的管道(或消息队列)实现。当进程 A 放入数据时,数据先被 pickle 序列化 成字节流,然后拷贝到操作系统内核空间的一块缓冲区;进程 B 取出数据时,内核将这份数据拷贝到进程 B 的内存中,再反序列化还原为对象。整个过程两个进程无法直接访问对方内存,全靠操作系统充当可靠的中介。
关于锁机制:虽然两个进程拥有各自内存中的 Queue 实例,但这些实例内部的_reader、_writer(管道文件描述符)以及_lock(跨进程锁)都指向操作系统中相同的内核对象(共享资源)。_lock是为了保护对管道缓冲区的并发访问,防止写入数据混乱。实际上 Queue 内部不止一把锁,还包括用于同步读取和写入的条件变量等,但核心思想就是:跨进程共享的资源必须通过系统级的锁来保证原子操作。
进程A(生产者)操作系统内核 进程B(消费者)|||1. put(obj)||- 获取锁(Lock)||- pick序列化 obj ->bytes||- 把bytes放入内部缓冲区|||||2. 后台线程运行||- 从缓冲区取出bytes||- 调用 write(pipe_fd, bytes)--->[管道缓冲区]|- 释放锁|||||||数据准备好||||||3. get()进程B被唤醒||- 获取锁||- 调用 read(pipe_fd)<---- 读数据 ,反序列化,释放锁||- 返回 obj三、Python 线程详解
3.1 线程基础:概念、实现与传参
线程是 CPU 调度的最小单位,依附于进程存在。一个进程默认有一个主线程,可创建多个子线程,线程间共享进程的所有资源。多线程的资源开销远小于进程,适合 I/O 密集型任务。
实现步骤(threading 模块):
- import threading
- 创建线程对象:t = threading.Thread(target=任务函数, args/kwargs)
- 启动线程:t.start()
传参方式与进程完全相同,支持元组args=(3, "小红")和字典kwargs。
3.2 线程核心注意事项(无序、守护、共享变量及安全问题)
线程执行无序:由 CPU 调度决定,与创建顺序无关,多次运行结果可能不同。
主线程默认等待子线程结束:可设置守护线程让主线程退出时销毁子线程:
t=threading.Thread(target=work,daemon=True)# 方法1t.setDaemon(True)# 方法2线程间共享全局变量:这是线程的核心优势,无需额外通信机制。
共享变量导致数据安全问题:多线程同时修改同一全局变量会产生资源竞争,导致计算结果错误。
3.3 互斥锁与死锁
互斥锁:保证同一时刻只有一个线程操作共享数据。步骤:创建锁 →acquire()上锁 →release()解锁。
importthreading g_num=0mutex=threading.Lock()def sum_num(): global g_num mutex.acquire()foriinrange(1000000): g_num+=1mutex.release()print(f"结果:{g_num}")t1=threading.Thread(target=sum_num)t2=threading.Thread(target=sum_num)t1.start();t2.start()死锁问题:多个线程互相等待对方释放锁,导致程序卡死。常见原因:上锁后未释放、嵌套锁顺序不当。解决方法:保证锁的成对释放,避免嵌套锁,或使用 with 语句自动释放。
lock1=threading.Lock()lock2=threading.Lock()deffunc1():lock1.acquire()time.sleep(0.1)lock2.acquire()# 等待 lock2,但 lock2 可能被 func2 持有lock2.release()lock1.release()deffunc2():lock2.acquire()time.sleep(0.1)lock1.acquire()# 等待 lock1,但 lock1 被 func1 持有lock1.release()lock2.release()四、线程池
4.1 线程池概述:是什么,为什么用?
什么是线程池
线程池是一种 池化技术:预先创建一组可复用的线程,任务到来时分配给空闲线程执行,执行完后线程不销毁,而是等待下一个任务。为什么需要线程池
- 降低资源消耗:复用已存在的线程,减少频繁创建/销毁的开销。
- 提高响应速度:任务到达时免去线程创建时间。
- 便于统一管理:控制最大并发数,避免线程过多导致系统崩溃。
线程池 vs. 手动创建线程
对比项 手动创建线程 线程池 创建/销毁开销 高,每个任务都要创建和销毁线程 低,线程复用 资源控制 难以控制最大线程数 可以设置最大线程数 代码复杂度 需要自行管理线程生命周期和异常 简洁,只需提交任务
4.2 Python 中的线程池实现
python中主要使用concurrent.futures.ThreadPoolExecutor类实现线程池的创建和使用
4.2.1 创建线程池
fromconcurrent.futuresimportThreadPoolExecutor# 默认线程数:min(32, CPU核心数+4)executor=ThreadPoolExecutor()# 自定义最大线程数(常用)executor=ThreadPoolExecutor(max_workers=5)# 还可以指定线程名前缀、初始线程(Python 3.8+)executor=ThreadPoolExecutor(max_workers=5,thread_name_prefix="MyPool")核心参数:
max_workers:线程池中最多同时运行的线程数。并非越多越好,建议根据 IO 密集型任务 的性质设置适当值(如 CPU 核心数 * 5 或更高)。thread_name_prefix:为线程池中的线程命名,方便调试。
4.2.2 提交任务的两种方式
submit(fn, *args, **kwargs)—— 提交单个任务future=executor.submit(print,"Hello")# future 是一个 Future 对象,代表异步操作的句柄map(func, *iterables, timeout=None)—— 批量提交defsquare(n):returnn*nwithThreadPoolExecutor()asexecutor:results=executor.map(square,[1,2,3,4,5])forrinresults:print(r)# 输出顺序与输入顺序一致
4.2.3 获取结果、异常与回调
Future.result(timeout=None):阻塞等待结果,可设置超时,任务异常时此处会抛出。Future.done() / running() / cancelled()等用于状态查询。add_done_callback(fn):任务完成后自动调用回调函数(在任务线程中执行,注意线程安全)。defcallback(future):print(f"结果:{future.result()}")future=executor.submit(pow,2,10)future.add_done_callback(callback)
4.2.4 关闭线程池
# 方法一:等待所有任务完成,并禁止新任务executor.shutdown(wait=True)# wait=True 为默认,阻塞直到全部完成# 方法二:使用上下文管理器(推荐)withThreadPoolExecutor(max_workers=5)asexecutor:executor.submit(func,arg)# 离开 with 块时自动调用 shutdown(wait=True)4.3 深入原理:队列 + 工作线程模型
ThreadPoolExecutor的核心是一个生产者‑消费者模型:
一个任务队列(
queue.Queue):主线程通过submit()将任务(包装成(fn, args, kwargs, future))放入队列。一组工作线程:线程池在初始化时不会立即创建所有线程,而是懒创建——当提交第一个任务时,如果当前线程数不足
max_workers,就创建新线程;每个工作线程不断从队列中取任务执行,执行完后继续取下一个,永不退出(除非收到关闭信号)。线程池里的线程默认都是守护线程,关闭线程池时可以通过
wait参数指定关闭线程池的行为,是等所有线程都完成还是让守护线程生效。classThreadPoolExecutor:def__init__(self,max_workers):self._work_queue=queue.Queue()self._threads=set()self._max_workers=max_workers self._shutdown=Falsedefsubmit(self,fn,*args,**kwargs):ifself._shutdown:raiseRuntimeError('cannot schedule new futures')f=Future()self._work_queue.put((fn,args,kwargs,f))# 懒创建线程iflen(self._threads)<self._max_workers:t=threading.Thread(target=self._worker)t.daemon=Trueself._threads.add(t)t.start()returnfdef_worker(self):whileTrue:item=self._work_queue.get()ifitemisNone:# 毒丸信号breakfn,args,kwargs,future=itemtry:result=fn(*args,**kwargs)future.set_result(result)exceptExceptionase:future.set_exception(e)# 线程退出前清理self._threads.discard(threading.current_thread())defshutdown(self,wait=True):self._shutdown=Truefor_inrange(len(self._threads)):self._work_queue.put(None)# 让所有工作线程退出ifwait:fortinself._threads:t.join()
4.4 完整示例:并发下载网页(I/O 密集型)
并发下载多个网页(IO 密集型)
importrequestsfromconcurrent.futuresimportThreadPoolExecutor,as_completed urls=["https://example.com","https://httpbin.org/get","https://www.python.org"]deffetch(url):resp=requests.get(url,timeout=5)returnurl,resp.status_codewithThreadPoolExecutor(max_workers=3)asexecutor:future_to_url={executor.submit(fetch,url):urlforurlinurls}forfutureinas_completed(future_to_url):url=future_to_url[future]try:url,status=future.result()print(f"{url}->{status}")exceptExceptionase:print(f"{url}失败:{e}")五、python协程详解
5.1 协程基础概念
- 什么是协程
协程是用户态的轻量级任务,完全由程序自身控制切换(无需操作系统参与)。多个协程可以在一个线程内并发执行,遇到 I/O 等待时主动让出 CPU,事件循环调度其他就绪协程继续运行。 - 为什么需要协程
极高并发:一个线程可轻松创建数万甚至数十万个协程,内存开销极小(每个协程仅数百字节)。
无锁编程:协程间切换不涉及共享资源竞争(单线程内串行执行),无需费力处理锁。
5.2 协程核心三要素
协程的运行依赖 3 个核心对象,这是理解协程的关键,三者的转换关系必须牢记:
| 核心对象 | 定义 | 核心作用 | 能否直接被调度 |
|---|---|---|---|
| 事件循环(Event Loop) | 协程的调度中心 | 管理所有任务,监听 IO 状态,自动切换可执行任务 | - |
| 协程对象(Coroutine) | async 函数调用后生成的对象 | 封装协程逻辑,是任务的原始载体 | ❌ 不能 |
| Task 任务 | 协程对象的封装体(继承自 Future) | 事件循环的最小调度单位,支持并发 | ✅ 能 |
三者转换规则
协程函数 → 协程对象
用async def定义协程函数,直接调用函数不会执行,只会生成协程对象;协程对象 → Task 对象
- 显式转换(推荐):
task = asyncio.create_task(coro),获得 Task 句柄,可控制、查询状态; - 隐式转换:
await coro时事件循环内部自动包装为 Task,并注册到事件循环,但无法获得引用;
- 显式转换(推荐):
Task 对象 → 事件循环调度执行
事件循环运行 Task,Task 执行到await时主动挂起,等待事件(I/O、定时器)。事件循环在此期间调度其他 Task,待事件发生后恢复该 Task 的执行。
5.3 协程实现与并发实战
5.3.1 async 和 await协程核心语法规则
Python 协程基于asyncio模块,核心依靠async和await两个关键字,同时内置隐式转换逻辑。
async 关键字作用
- 用于修饰普通函数,将其定义为协程函数;
- 协程函数直接调用不会执行内部业务逻辑,仅返回一个协程对象;
- 协程对象只是逻辑载体,本身无法被事件循环直接调度。
await 关键字使用约束与核心作用
使用约束:只能在 async 修饰的协程函数内部使用,普通同步函数中禁止出现;
核心作用:
- 暂停当前协程后续代码,主动挂起当前协程,让出执行控制权给事件循环,用于调度其他协程任务;
- 等待后方异步 I/O 操作完成,I/O 就绪后恢复当前协程继续执行;
- 是协程实现单线程多任务切换、异步并发的核心支撑。
后置对象隐式转换规则
- await 后面可接收协程对象、Task 对象、Future 对象,底层自动处理转换:
- 若后置是裸协程对象:asyncio 底层会自动将其封装为 Task 任务,注册到事件循环参与调度,无需手动写
create_task();
若已经手动通过create_task()封装为 Task:直接等待任务执行完毕,不会重复封装; - 场景选用:简单串行逻辑直接 await 协程对象 即可;需要并发、获取任务状态、设置回调时,必须手动创建 Task。
5.3.2 基础示例:await 自动封装协程对象
importasyncio# 定义协程函数asyncdefwork(name):print(f"任务{name}:开始执行")# await 后接协程对象,底层自动转为 Task 调度awaitasyncio.sleep(1)print(f"任务{name}:执行完毕")# 主协程入口asyncdefmain():# 调用协程函数,生成协程对象coro=work("测试任务")# await 自动把协程对象封装为 Task 并执行awaitcoro# 启动事件循环,运行主协程asyncio.run(main())5.3.3 并发实战:手动创建Task + asyncio.gather
单纯顺序 await 多个协程只是串行执行,无法发挥协程并发优势;
想要真正并发,必须手动把协程对象封装为 Task,再通过asyncio.gather()批量调度。
importasyncioasyncdefwork(name,delay):print(f"任务{name}:开始执行,等待{delay}s")awaitasyncio.sleep(delay)print(f"任务{name}:执行完毕")returnf"{name}完成"asyncdefmain():# 手动将协程对象封装为 Task,加入事件循环待命task1=asyncio.create_task(work("A",2))task2=asyncio.create_task(work("B",1))# 并发等待所有 Task 执行完成results=awaitasyncio.gather(task1,task2)print("所有任务执行结果:",results)# 总耗时约 2s,实现真正并发asyncio.run(main())5.4 协程底层调度原理、使用注意事项及异步本质
5.4.1 协程底层调度原理
- 调度模式:协作式调度(非抢占式)
进程、线程是操作系统强制抢占式切换;协程没有操作系统参与,是任务遇到 await 主动让出控制权,属于用户态协作切换,除非协程主动让出控制权,否则当前协程一直占用。 - 完整切换流程
- 协程执行到 await 触发 IO 等待 → 主动挂起,释放事件循环控制权;
- 事件循环检索就绪队列,调度其他可运行的 Task 协程;
- 原有 IO 操作完成后,事件循环重新唤醒原协程,继续执行后续代码。
- 性能优势:全程在用户态完成切换,无操作系统上下文切换开销,资源消耗极低。
5.4.2 协程为什么是异步的实现方式?体现在哪里
- 异步核心特征:非阻塞、等待不占用资源
普通同步代码遇到 IO 会阻塞整个线程,原地空等占用资源;协程遇到 await 触发 IO 操作时(异步IO操作),仅瞬间完成请求发起、向事件循环注册监听事件,随后主动挂起并让出协程控制权,不会原地阻塞死等,事件循环可调度执行其他任务,实现 IO 等待时段的资源复用。 - 调度异步化
由事件循环统一调度多个协程,任务执行顺序不由代码编写顺序决定,而是由 IO 就绪状态动态唤醒调度,完全契合异步编程调度思想。 - IO 等待可并行重叠
所有异步网络请求、数据库读写等真实 IO 任务,底层逻辑和 asyncio.sleep 完全一致;多个协程无需串行挨个等待,IO 等待过程交由操作系统后台并行处理,等待时间相互重叠,这是协程异步高并发、省时间的根本原因。
六、进程 vs 线程vs 协程:核心对比
| 对比维度 | 进程 | 线程 | 协程 |
|---|---|---|---|
| 调度方 | 操作系统 | 操作系统 | 用户程序(自身) |
| 资源开销 | 极大 | 中等 | 极小 |
| 内存共享 | 不共享 | 共享 | 完全共享 |
| 切换开销 | 高 | 中 | 极低 |
| 锁竞争 | 无 | 有 | 无 |
| 多核利用 | ✅ 支持 | ❌ 受 GIL 限制 | ❌ 单线程 |
| 并发能力 | 低 | 中 | 极高(单机万级) |
| 适用场景 | CPU 密集型 | IO 密集型 | 高并发 IO 密集型 |
