并发

并发(concurrency)和其表现形式之一——并行处理(parallel processing)——是软件工程领域最广泛的话题之一。

为什么在应用程序中需要并发,什么时候使用它,以及在 Python 中你可以使用的最重要的并发模型。

  • 多线程(multithreading)。
  • 多进程(multiprocessing)。
  • 异步编程(asynchronous programming)。

为什么需要并发

并发不是应用程序实现的问题,而只是程序,算法或问题的属性。并行只是并发问题的可能的方法之一。如果两个事件互不影响,则两个事件是并发的。

通过推断程序、算法或问题中的事件,我们可以说,如果它们可以被完全或部分分解为顺序无关的组件(单位),则这些事件是并发的。并发的。可以彼此独立地处理这些单元,并且处理的顺序不会影响最终的结果。这意味可以同时地或并行地处理它们。

解决大规模问题,或者需要同时满足多个用户或软件代理的需求时:

  • 处理作业的时间受单个处理单元(单机、CPU 内核等)的性能的限制。
  • 在程序完成对上一个输入的处理之前,你不能接受和处理新的输入。

一般来说,对于以下情况,并发地处理并发问题是最佳方法:

  • 扩展问题很重要,并且在可接受的时间或可用资源范围内,处理它们的唯一方法是将执行分配到可并行处理工作的多个处理单元上。
  • 应用程序需要保持响应(接受新输入),即使它尚未完成处理旧的输入。

这涵盖了可以使用并发处理问题的大多数情况。第一组问题肯定需要并行处理解决方案,因此通常使用多线程和多处理模型来解决。第二组不一定需要并行处理,因此真实的解决方案实际上取决于问题的细节。

另一件值得一提的是前两组不是互相排斥。通常,你需要维护应用程序响应性,同时你无法在单个处理单元上处理输入。这就是为什么不同的并且看似可替代或冲突的并发方法可能经常同时使用的原因。

多线程

Python 提供了一些高级类和函数,通过它们可以轻松地使用线程。CPython 的线程实现中带有一些麻烦的细节,使得它们没其他语言那么实用。

什么是多线程

线程是执行线程的缩写。程序员可以将他或她的工作拆分到线程中,这些线程同时运行并共享同一内存上下文。

在 Python 中,多核 CPU 的多线程的性能优势有一些限制。

事实上,线程之间共享同样的上下文,这意味着你必须保护数据,避免并发访问这些数据。如果两个线程更新相同的没有任何保护的数据,则会发生竞态条件。这被称为竞争冒险(racehazard)。

锁机制有助于保护数据,在多线程编程中,总是要确保线程以安全的方式访问资源。两个线程锁定一个资源,并尝试获取另一个线程锁定的资源。它们将永远彼此等待。这被称为死锁(deadlock)。可重入锁(Reentrant locks)有助于这种情况,它通过确保线程在尝试两次锁定资源时不会被锁定。

在系统内核级别通常支持多线程。当机器具有带有单个核的单个处理器时,系统使用时间分片(timeslicing)机制。这里,CPU 可以很快地从一个线程切换到另一个线程,造成了线程同时运行的错觉。

Python 如何处理多线程

与一些其他语言不同,Python 使用多个内核级线程,每个线程可以运行任何解释器级线程。但是语言的标准实现即 CPython ——有一些主要限制,渲染线程在多个上下文中不可用。所有访问 Python 对象的线程都会被一个全局锁串行化。这是由许多解释器的内部结构完成的,和第三方 C 代码一样,它们不是线程安全的,需要进行保护。这种机制称为全局解释器锁(Global Interpreter Lock,GIL)。

当线程仅包含纯 Python 代码时,使用线程来加速程序没有什么意义,因为 GIL 会将其串行化。但请记住,GIL 只是强制在任何时候只有一个线程可以执行 Python 代码。实际上,全局解释器锁在许多阻塞系统调用上被释放,并且可以在不使用任何 Python/C API 函数的 C 扩展的部分中被释放。这意味着,多个线程可以执行 I/O 操作或在某些第三方扩展中并行执行 C 代码。

何时应该使用多线程

尽管有GIL限制,线程在某些情况下确实很有用。例如以下情况:

  • 构建响应式界面。
  • 委派工作。
  • 构建多用户应用程序。

多进程

实现并行性的另一种方法是多进程。彼此独立 Python 进程没有 GIL 的限制,这样可以有更好的资源利用率。这对于在多核处理器上运行的应用程序尤其重要,这些处理器可以真正的处理 CPU 密集型任务。

使用多个进程的另一个优点是它们不共享内存上下文。因此,很难破坏数据也难以在应用程序中引入死锁。

在任何编程语言中启动新进程的最基本的方法通常是在某个时刻派生程序。派生将内存上下文复制到子进程后,每个进程都会处理自己的地址空间。为了沟通,进程需要与系统范围的资源或使用低级工具(如信号)。Python 提供了一个很好的 multiprocessing 模块,为多进程创建了一个高级接口。这个模块的最大优点是它提供了一些抽象,这些抽象针对我们必须从头开始编写一个多线程应用的例子。它可以限制样板代码的数量,从而提高应用程序可维护性并降低其复杂性。

multiprocessing 提供了一种便捷的方式来处理进程,就像它们是线程一样。此模块包含一个与 Thread 类非常相似的 Process 类,可以在任何平台上使用。

进程模块之间的通信需要一些额外的工作,因为它们的本地内存在默认情况下不共享。为了简化这一点,multiprocessing 模块提供了进程之间的几种通信方式:

  • 使用 multiprocessing.Queue 类,它是早先用于线程之间通信的 queue.Queue 的近似克隆。
  • 使用 multiprocessing.Pipe,这是一个类似于套接字的双向通信通道。
  • 使用 multiprocessing.sharedctypes 模块,通过它可以在进程之间共享的专用内存池中创建任意 C 类型(从 ctypes 模块)。

一个更有趣的模式是由 Pipe 类提供的。它是一个双工(双向)通信通道,在概念上非常类似于 Unix 管道。管道的接口也非常类似于来自内置 socket 模块的简单套接字。与原始系统管道和套接字的区别在于,你可以发送任何可选对象(使用 pickle 模块),而不仅是原始字节。这使得进程之间可以更容易的通信,因为你几乎可以发送任何基本的 Python 类型。

另一种在进程之间共享状态的方法是在 multiprocessing.sharedctypes 中提供的类中使用共享内存池中的原始类型。最基本的是 Value 和 Array。

1.使用进程池

使用多进程而不是线程增加了一些实质性的开销。大多数情况下,它会增加内存占用,因为每个进程都有自己独立的内存上下文。

构建一个进程池,这是在依赖多进程以获得更好资源利用率的应用程序中控制资源使用的最佳模式。

multiprocessing 模块最好的一点是它提供了一个即用型的 Pool 类,可以处理管理多个工作进程的所有复杂性。这个池实现大大减少了所需的样板数量和与双向通信相关的问题数量。

2.使用 multiprocessing.dummy 作为 multithreading接口

多进程并不比多线程更好,在需要低延迟和/或高资源效率的情况下尤其如此。multiprocessing.dummy 模块,它复制 multiprocessing API,但使用多个线程,而不是派生/产生新进程。可以减少代码中的样板代码数量,并且还可以创建更多可插入的接口。

异步编程

从 Python 3.5 开始,异步编程比以前更容易,有了一些语法特性来巩固异步执行的概念。异步编程是类似线程但不涉及系统调度。这意味着异步程序可以并发地处理问题,但是其上下文在内部而不是由系统调度程序切换。

用于描述这种并发程序实体的一些示例名称如下。

  • Green threads 或 greenlets(greenlet, gevent,或 eventlet 项目)。
  • Coroutines(Python 3.5 原生异步编程)。
  • Tasklets(Stackless Python)。

协同多任务与异步 I/O

协同多任务(cooperative multitasking)是异步编程的核心。在这种类型的计算机多任务中,启动上下文切换(到另一个进程或线程)不是操作系统的责任,而是每个进程在空闲时自动释放控制以允许同时执行多个程序。这就是为什么它被称为协同。所有进程都需要协同才能顺利处理多任务。

由操作系统直接管理的上下文切换的线程和进程调度,现在是系统级并发的主要方法。但是协同多任务在应用程序级别上仍然是一个极好的并发工具。

协同多任务中最重要的问题是何时释放控制。在大多数异步应用程序中,控制在 I/O 操作时被调度程序或事件循环所释放。无论程序从文件系统读取数据还是通过套接字进行通信,这种 I/O 操作总是与进程变为空闲时的某些等待时间有关。等待时间取决于外部资源。

Python 中的线程被实现为系统级线程,因此操作系统可以抢占当前运行的线程,并在任何时间点控制另一个线程。在异步编程中,任务不会被主事件循环抢占。这就是为什么这种多任务的风格也被称为非抢占式多任务(non-preemptive multitasking)。

Python 中的 async 和 await 关键字

async 和 await 关键字是 Python 异步编程的主要构建块。

在 def 语句之前使用的 async 关键字就定义了一个新的协程。协程函数的执行可以在严格定义的情况下暂停和恢复。它的语法和行为与生成器非常相似。

使用 async 关键字定义的函数是特殊的。当被调用时,它们不执行里面的代码,而是返回一个协程对象。

在事件循环中调度其执行之前,协程对象不执行任何操作。asyncio 模块可用于提供基本的事件循环实现,以及许多其他异步实用程序。

await 用于等待协程或未来(future)的结果,并释放对事件循环的执行控制。

老 Python 版本中的 asyncio

asyncio 模块出现在 Python 3.4 中。从 Python 3.5 开始,你可以使用 async 和 await,但对于 Python 3.4,你需要使用 asyncio.coroutine 装饰器和 yield from 语句。

使用 futures 将异步代码同步化

异步编程很棒,特别是对于构建可扩展应用程序感兴趣的后端开发人员。在实践中,它是构建高度并发服务器的最重要的工具之一。

但现实是痛苦的。许多处理 I/O 繁忙问题的流行包并没有使用异步代码。主要原因是:

  • Python 3 及其一些高级功能的采用率依然很低。
  • Python 初学者对各种并发概念的理解不足。

从一开始尝试构建异步应用程序时,就会遇到很多困难,不兼容接口和 I/O 操作的非异步阻塞。

另一个问题是长时间运行 CPU 密集型操作。当你执行 I/O 操作时,可以很容易地从协程释放控制。当从文件系统或套接字写/读时,你最终会等待,所以使用 await 调用是最好的。这样可能使代码混乱,也不能保证良好的效果。分词应由解释器或操作系统负责。

在主要使用异步 I/O 设计的应用程序中进行一些繁重的 CPU 密集型操作时,解决方法是多线程或多处理。

Python 中并行处理 CPU 广泛的任务总是用多进程处理更好。多线程可以处理 I/O 操作同样好(快速,没有大量的资源开销)as async 和 await,如果正确设置和小心处理。

Python 标准库提供了 concurrent.futures 模块,它也与 asyncio 模块集成。你可以使用这两个模块一起调度在线程或其他进程中执行的阻塞函数,因为它是同步非阻塞协同。

1.Executors 与 futures

concurrent.futures 模块中最重要的类是 Executor 和 Future。

Executor 表示可并行处理工作项的资源池。这看起来非常类似于来自 multiprocessing 模块 -Pool 和 dummy.Pool 的类——但是具有完全不同的接口和语义。它是一个不用于实例化的基类,它有两个具体的实现。

  • ThreadPoolExecutor:这代表线程池。
  • ProcessPoolExecutor:这代表进程池。

每个执行者提供 3 个方法。

  • submit(fn, * args, * kwargs):这将在资源池上执行调度 fn 函数,并返回 Future 对象,该对象表示可调用的执行。
  • map(func, * iterables, timeout=None,chunksize=1):在一个迭代器上执行 func 函数,它的方式类似于 multiprocessing.Pool.map() 方法。
  • shutdown(wait=True):这将关闭执行程序并释放其所有资源。

如果你想使用 Executor.map() 方法,它与 multiprocessing 模块中的 Pool 类的 Pool.map() 方法的用法没什么不同。

2.在事件循环中使用 executors

Executor.submit() 方法返回的 Future 类实例在概念上非常接近异步编程中使用的协程。这就是为什么我们可以使用执行器在协同多任务和多进程或多线程之间进行混合。

此解决方法的核心是事件循环类的 BaseEventLoop.run_in_executor(executor, func, * args) 方法。它会在进程池或线程池中调度执行由 executor 参数表示的 func 函数。这个方法最重要的是它返回一个新的 awaitable(一个可以用 await 语句的等待的对象)。

小结

基于 multiprocessing 模块使用多进程比基于 threading 使用基本线程更容易。由于 multiprocessing.dummy,我们可以使用与线程相同的 API。因此,多进程和多线程之间的选择现在只是一个问题,哪个解决方案更适合问题,而不是哪个解决方案有更好的接口。

异步编程,这应该是 I/O 密集型应用程序的最佳解决方案。

有一些方法可能更适合给定的一组问题,没有一个单一的模式能解决所有的问题。