为了更好地理解并发性如何帮助我们的应用程序表现更好,首先需要学习和充分理解并发编程的术语。我们将进一步了解并发的含义,以及 asyncio 如何使用称为多任务的概念来实现它。并发和并行是两个概念,帮助我们理解编程如何安排和执行驱动行动的各种任务、方法和例程。
当我们说两个任务同时发生时,我们的意思是这些任务在同一时间进行。以烘焙师烘焙两个不同蛋糕为例。为了烘烤这些蛋糕,我们需要预热烤箱。预热可能需要几十分钟,具体取决于烤箱和烘焙温度,但在开始其他任务(如将面粉和糖与鸡蛋混合在一起)之前,我们不需要等待烤箱预热。我们可以在烤箱发出哔哔声表示已预热之前做其他工作。
我们也不需要在完成第一个蛋糕之前限制自己开始第二个蛋糕的工作。我们可以开始做第一个蛋糕糊,把它放进立式搅拌机,然后开始准备第二个蛋糕糊,同时第一个蛋糕糊完成搅拌。在这个模型中,我们在不同任务之间进行并发切换。任务之间的这种切换(在烤箱加热时做其他事情,在两个不同的蛋糕之间切换)是并发行为。
虽然并发意味着多个任务同时在进行,但这并不意味着它们是并行运行的。当我们说某物在并行运行时,我们的意思是不仅有多个任务在并发发生,而且它们也在同时执行。回到我们的蛋糕烘焙例子,想象一下我们有第二个烘焙师的帮助。在这种情况下,我们可以在第二个烘焙师处理第二个蛋糕时处理第一个蛋糕。两个人同时制作蛋糕糊就是并行的,因为我们有两个不同的任务在并发运行(图 1.1)。
图 1.1 并发是指多个任务同时发生,但在给定时间点我们只主动做其中一件。并行是指多个任务同时发生,并且我们主动同时执行多个任务。
将这一点放到由我们操作系统运行的应用程序术语中,假设它有两个应用程序在运行。在仅仅是并发的系统中,我们可以在这些应用程序之间切换,运行一个应用程序一小会儿,然后让另一个运行。如果我们这样做得足够快,就会给人一种两个事情同时发生的感觉。在并行系统中,两个应用程序同时运行,我们主动同时运行两个东西。
并发和并行的概念是相似的(图 1.2),而且区分起来有些令人困惑,但理解它们之间的区别很重要。
图 1.2 并发时,我们在两个应用程序之间切换。并行时,我们主动同时运行两个应用程序。
并发是关于多个可以彼此独立发生的任务。我们可以在只有一个核心的 CPU 上实现并发,因为操作将采用抢占式多任务处理(下一节定义)在任务之间切换。然而,并行意味着我们必须同时执行两个或更多任务。在只有一个核心的机器上,这是不可能的。为了使这成为可能,我们需要一个具有多个核心的 CPU,可以一起运行两个任务。
虽然并行意味着并发,但并发并不总是意味着并行。运行在多核机器上的多线程应用程序既是并发的又是并行的。在这种设置中,我们有多个任务同时运行,并且有两个核心独立执行与这些任务相关的代码。然而,通过多任务处理,我们可以有多个任务并发发生,但在给定时间只有一个任务在执行。
多任务处理在当今世界无处不在。我们在做早餐时,会一边接电话或回短信,一边等水烧开泡茶。我们甚至在上下班通勤时也进行多任务处理,比如在火车送我们到站时看书。本节讨论两种主要的多任务处理:抢占式多任务处理和协作式多任务处理。
在这种模型中,我们让操作系统决定如何通过一个称为时间片的过程切换当前执行的工作。当操作系统在工作之间切换时,我们称之为抢占。
这种机制在底层如何工作取决于操作系统本身。它主要通过使用多线程或多进程来实现。
在这种模型中,我们不依赖操作系统来决定何时切换当前执行的工作,而是在应用程序中明确编码可以运行其他任务的点。我们应用程序中的任务在一个模型中协作,明确地说:“我暂停我的任务一会儿;去运行其他任务吧。”
asyncio 使用协作式多任务处理来实现并发性。当我们的应用程序到达一个可能等待结果返回的点时,我们在代码中明确标记这一点。这允许其他代码在我们等待结果在后台返回时运行。一旦我们标记的任务完成,我们实际上“醒来”并恢复执行任务。这给了我们一种并发形式,因为我们可以同时启动多个任务,但重要的是,不是并行的,因为它们没有同时执行代码。
协作式多任务处理比抢占式多任务处理有优势。首先,协作式多任务处理资源密集度更低。当操作系统需要在运行的线程或进程之间切换时,它涉及到上下文切换。上下文切换是密集操作,因为操作系统必须保存正在运行的进程或线程的信息以便重新加载。
第二个优势是粒度。操作系统知道线程或任务应该基于其使用的调度算法暂停,但那可能不是暂停的最佳时机。通过协作式多任务处理,我们明确标记暂停任务的最佳区域。这给我们带来了一些效率收益,因为我们只在明确知道是正确时间时才切换任务。现在我们已经理解了并发性、并行性和多任务处理,我们将使用这些概念来理解如何在 Python 中使用线程和进程实现它们。