系统学习Python——并发模型和异步编程:基础知识
分类目录:《系统学习Python》总目录
并行是并发的一种特殊情况。**所有并行系统都是并发的,但不是所有并发系统都是并行的。**在21世纪初,我们可以使用单核设备在GNU Linux上同时处理100个进程。一台拥有4个CPU核的现代笔记本计算机,在正常情况下,任何时间段内运行的进程数随随便便都会超过200个。如果并行执行200个任务,则需要200个核。因此,多数计算实际是并发的,而不是并行的。操作系统管理着数百个进程,确保每个进程都有机会取得进展,即使CPU本身同时做的事情不能超过4件。
《系统学习Python——并发模型和异步编程》系列文章假定我们事先不具备并发或并行编程知识。我们会简要介绍相关概念之后,将通过简单的示例学习和比较Python为并发编程提供的3个核心包:threading
、multiprocessing
和asyncio
。我们还会讲解增强Python应用性能和伸缩性的第三方工具、库、应用服务器和分布式任务队列。同时,我们也会讲解Python的3种并发方式:线程、进程和原生协程。
导致并发编程困难的因素很多,但我们会讲到启动线程或进程以及如何跟踪线程或进程。调用一个函数,发出调用的代码开始阻塞,直到函数返回。因此,我们知道函数什么时候执行完毕,而且能轻松地得到函数的返回值。如果函数可能抛出异常,则把函数调用放在try
/except
块中,捕获错误。这些熟悉的概念在我们启动线程或进程后都不可用了。同时,我们无法轻松地得知操作何时结束,若想获取结果或捕获错误,则需要设置某种通信信道,例如消息队列。此外,启动线程或进程有一定消耗,仅仅为了计算一个结果就退出,肯定得不偿失。通常,更好的选择是让各个线程或进程进入一个职程(Worker),循环等待要处理的输入,以此分摊启动成本。但是,这又进一步增加了通信难度,还会引起更多问题。如果不需要职程了,那么如何退出呢?怎样退出才能做到不中断作业,避免留下未处理完毕的数据和未释放的资源(例如打开的文件)呢?同样,解决这些问题通常涉及消息和队列。协程的启动成本很低。使用await
关键字启动的协程,返回值容易获取,可以安全取消,捕获异常的位置也明确。但是,协程通常由异步框架启动,因此监控难度与线程或进程相当。
最后,我们还会说明Python协程和线程不适合CPU密集型任务。鉴于此,并发编程需要学习新的概念和编程模式。首先,我们要对核心概念确立统一认识。
术语定义
- 并发:处理多个待定任务,一次处理一个或并行处理多个(如果条件允许),直到所有任务最终都成功或失败。对于单核CPU,如果操作系统的调度程序支持交叉执行待定任务,也能实现并发。并发也叫多任务处理(Multitasking)。
- 并行:同时执行多个计算任务的能力。需要一个多核CPU、多个CPU、一个GPU或一个集群中的多台计算机。
- 执行单元:并发执行代码的对象的统称,每个对象的状态和调用栈是独立的。Python原生支持3种执行单元:进程、线程和协程。
- 进程:计算机程序运行时的一个实例,消耗内存和部分CPU时间。现代桌面操作系统通常同时管理数百个进程,每个进程都隔离在自己的私有内存空间中。进程通过管道、套接字或内存映射文件进行通信—这些方式都只能携带原始字节。Python对象必须序列化(转换)为原始字节才能从一个进程传递到另一个进程。这个过程耗费资源,而且不是所有Python对象都可以序列化。**进程可以派生子进程,子进程彼此之间以及与父进程之间是隔离的。**进程支持抢占式多任务处理机制:操作系统调度程序定期抢占(挂起)运行中的进程,让其他进程运行。这意味着冻结的进程理论上不会冻结整个系统。
- 线程:单个进程中的执行单元。**一个进程启动后,只使用一个线程,即主线程。通过调用操作系统API,进程可以创建更多线程,执行并发操作。**一个进程内的线程共享相同的内存空间(存储活动的Python对象)。因此,线程之间可以轻松地共享数据,但是如果多个线程同时更新同一个对象,则可能导致数据损坏。与进程一样,线程在操作系统调度程序的监督下也可以实现抢占式多任务处理。对于同一份作业,线程消耗的资源比进程少。
- 协程:可以挂起自身并在以后恢复的函数。在Python中,经典协程由生成器函数构建,原生协程使用
async def
定义。我们在已经《系统学习Python》之前的文章中介绍过经典协程,原生协程的用法将在《系统学习Python——并发模型和异步编程》系列文章中讨论。Python协程通常在事件循环(也在同一个线程中)的监督下在单个线程中运行。asyncio
、Curio
或Trio
等异步编程框架为基于协程的非阻塞I/O提供了事件循环和支持库。协程支持协作式多任务处理:一个协程必须使用yield
或await
关键字显式放弃控制权,另一个协程才可以并发(而非并行)开展工作。这意味着,协程中只要有导致阻塞的代码,事件循环和其他所有协程的执行就都会受到阻塞,这一点与进程和线程的抢占式多任务处理形成鲜明对比。另外,对于同一份作业,协程消耗的资源比线程或进程少。 - 队列:一种数据结构,可以放入和取出项,顺序通常是先入先出(FIFO)。独立的执行单元可以通过队列交换应用数据和控制消息,例如错误代码和终止信号。队列的实现因底层并发模型而异:Python标准库中的
queue
包提供的队列类支持线程,multiprocessing
和asyncio
包则实现了其他队列类。queue
和asyncio
包中还有非先入先出队列:LifoQueue
和PriorityQueue
。 - 锁:一种供执行单元用来同步操作和避免数据损坏的对象。更新共享数据结构时,当前代码应持有相关的锁,并告诉程序的其他部分等到锁被释放后再访问这个数据结构。最简单的锁是互斥锁。锁的实现取决于底层并发模型。
- 争用:对有限资源的争夺。当多个执行单元尝试访问共享资源(例如锁或存储器)时,就会发生资源争用。当计算密集型进程或线程必须等待操作系统调度程序为其分配CPU时间时,还会发生CPU争用。
参考文献:
[1] Mark Lutz. Python学习手册[M]. 机械工业出版社, 2018.
[2] 卢西亚诺·拉马略.流畅的Python 第2版(全2册) 编程语言[M].人民邮电出版社,2023.