本文深入探讨了 Python 中的多线程和多进程,以及它们如何与并发和并行相关联。
介绍
多线程和多进程是编程中最基本的两个概念之一。如果你已经编写了一段时间的代码,你应该已经遇到过一些情况,其中你想加快代码中某些部分的特定操作。Python支持各种机制,使各种任务可以(几乎)同时执行。
在本教程中,我们将理解多线程和多进程,并看看这些技术如何在Python中实现。我们还将讨论根据应用程序是 I/O-bound 还是 CPU-bound 来使用哪种技术。
在讨论线程和多进程之前,了解两个经常互换使用的术语很重要。并发性和并行性是紧密相关但不同的概念。
并发和并行
在许多情况下,我们可能需要加速代码库中的一些操作,以提高执行性能。这通常可以通过并行或并发执行多个任务来实现(即通过在多个任务之间交错执行)。是否可以利用并发或并行实际上取决于您的代码和运行它的机器。
在并发执行中,两个或多个任务可以开始、执行和完成重叠的时间段。因此,这些任务不一定需要同时运行,只需以重叠的方式取得进展即可。
并发:至少两个线程正在取得进展的情况。一种更广泛的并行形式,可以将时间分片作为虚拟并行的形式。
— Sun 的多线程编程指南
现在让我们考虑一个使用案例,我们有一台只有单核 CPU 的计算机。这意味着作为应用程序的一部分需要执行的任务不能在完全相同的时间内取得进展,因为处理器只能同时处理一个任务。并发运行多个任务意味着处理器执行上下文切换,以便多个任务可以同时进行。
并发的主要目标之一是通过来回切换,以防止任务相互阻塞,当其中一个任务被迫等待(例如等待来自外部资源的响应)时。例如,任务 A 进展到某个点,CPU 停止处理任务 A,切换到任务 B 并开始对其进行一段时间的处理,然后可能切换回任务 A 完成它,最后再回到任务 B 完成这个任务。
下图显示了在单个核心中并发执行两个任务的应用程序的示例:
另一方面,在并行性中,多个任务(甚至一个任务的几个组件)可以真正同时运行(例如在多核处理器或具有多个 CPU 的计算机上)。因此,在具有单个处理器和单个内核的机器上不可能进行并行处理。
并行:至少两个线程同时执行时出现的一种情况。
— Sun的多线程编程指南
通过并行处理,我们能够最大限度地利用硬件资源。考虑一个拥有16个 CPU 核心的情境,启动多个进程或线程以利用所有这些核心可能比仅依靠单个核心,同时其余15个核心处于空闲状态更加明智。
在多核环境中,每个核心都可以同时执行一个任务,如下图所示:
回顾一下,并发可以看作是系统或程序的属性,指的是单个CPU(核心)如何同时(即并发地)在多个任务上取得进展,而并行性是执行至少两个任务实际上在同一时间运行的实时行为。此外,需要强调的是,在任务执行过程中,并发和并行性可以结合使用。事实上,我们可以有各种组合方式:
- 既不并发也不并行:这也称为顺序执行,其中任务严格按顺序执行。
- 并发但不并行:这意味着任务似乎同时取得进展,但实际上系统在各种正在进行的任务之间进行切换,直到所有任务都被执行。因此,没有真正的并行性,因此没有两个任务在完全相同的时间被执行。
- 并行但不并发:这是一个相当罕见的情况,其中只有一个任务在任何给定时间被执行,但任务本身被分解成正在并行处理的子任务。然而,每个任务都必须在下一个任务被选中并执行之前完成。
- 并发和并行:这基本上可以通过两种方式实现;第一种是简单的并行和并发执行,应用程序启动多个线程在多个CPU和/或核心上执行。第二种实现方式是,应用程序能够同时处理多个任务,但同时也将每个单独的任务分解成子任务,以便这些子任务最终可以并行执行。
现在我们已经基本了解了并发和并行的工作原理,让我们使用Python的一些示例来探索多进程和多线程。
Python中的线程
线程是在进程上下文中执行的一系列指令。一个进程可以生成多个线程,但它们都将共享同一内存。
在使用Python进行多线程的CPU密集型任务时,您最终会注意到执行未被优化,甚至在使用多个线程时可能会运行得更慢。通常情况下,多线程代码在多核机器上的使用预期是利用可用的核心,从而增加整体性能。
实际上,Python进程不能并行运行线程,但是在I/O密集型操作期间,可以通过上下文切换同时运行它们。
实际上,这种限制是由GIL实施的。Python全局解释器锁(GIL)防止同一进程中的线程同时执行。
GIL是一个互斥锁,用于保护对Python对象的访问,防止多个线程同时执行Python字节码
- Python Wiki
GIL是必要的,因为Python的解释器不是线程安全的。在线程中访问Python对象时,每次都会执行这个全局锁。在任何给定的时间,只有一个线程可以为特定对象获取锁。因此,CPU密集型代码不会通过Python多线程获得性能提升。
CPython实现细节:
在CPython中,由于全局解释器锁定,只能有一个线程同时执行Python代码(尽管某些性能导向的库可能会克服此限制)。
如果您希望应用程序更好地利用多核机器的计算资源,建议使用multiprocessing或concurrent.futures.ProcessPoolExecutor。
-Python文档
您可以在我的一篇最近的文章中阅读有关Python全局解释器锁定的更多信息,但是目前提供的信息足以理解全局解释器锁定如何限制Python中多线程应用程序的功能(以及为什么首先“需要”它)。
现在我们已经了解了Python中多线程应用程序的工作方式,让我们编写一些代码并利用这种技术。
在Python中,可以使用线程模块实现线程。现在让我们考虑一个用于下载图像的函数-这显然是一个 I / O-bound 任务:
import requests def download_img(img_url: str): """ Download image from img_url in curent directory """ res = requests.get(img_url, stream=True) filename = f"{img_url.split('/')[-1]}.jpg" with open(filename, 'wb') as f: for block in res.iter_content(1024): f.write(block)
示例 CPU-bound 函数
现在让我们尝试使用下面的代码片段从 Unsplash 下载几张图片。请注意,为了更清楚地演示线程的效果,我们有意尝试下载这些图像 5 次(见 for 循环):
import requests def download_img(img_url: str): """ Download image from img_url in curent directory """ res = requests.get(img_url, stream=True) filename = f"{img_url.split('/')[-1]}.jpg" with open(filename, 'wb') as f: for block in res.iter_content(1024): f.write(block) if __name__ == '__main__': images = [ # Photo credits: https://unsplash.com/photos/IKUYGCFmfw4 'https://images.unsplash.com/photo-1509718443690-d8e2fb3474b7', # Photo credits: https://unsplash.com/photos/vpOeXr5wmR4 'https://images.unsplash.com/photo-1587620962725-abab7fe55159', # Photo credits: https://unsplash.com/photos/iacpoKgpBAM 'https://images.unsplash.com/photo-1493119508027-2b584f234d6c', # Photo credits: https://unsplash.com/photos/b18TRXc8UPQ 'https://images.unsplash.com/photo-1482062364825-616fd23b8fc1', # Photo credits: https://unsplash.com/photos/XMFZqrGyV-Q 'https://images.unsplash.com/photo-1521185496955-15097b20c5fe', # Photo credits: https://unsplash.com/photos/9SoCnyQmkzI 'https://images.unsplash.com/photo-1510915228340-29c85a43dcfe', ] for img in images * 5: download_img(img)
从 Unsplash 使用 Python 下载图片(I/O bound 任务)
因此,我们的小应用程序可以正常工作,但我们肯定可以通过利用线程来优化代码(不要忘记下载-多张-图像是 I/O-bound 任务)。
import requests from queue import Queue from threading import Thread NUM_THREADS = 5 q = Queue() def download_img(): """ Download image from img_url in curent directory """ global q while True: img_url = q.get() res = requests.get(img_url, stream=True) filename = f"{img_url.split('/')[-1]}.jpg" with open(filename, 'wb') as f: for block in res.iter_content(1024): f.write(block) q.task_done() if __name__ == '__main__': images = [ # Photo credits: https://unsplash.com/photos/IKUYGCFmfw4 'https://images.unsplash.com/photo-1509718443690-d8e2fb3474b7', # Photo credits: https://unsplash.com/photos/vpOeXr5wmR4 'https://images.unsplash.com/photo-1587620962725-abab7fe55159', # Photo credits: https://unsplash.com/photos/iacpoKgpBAM 'https://images.unsplash.com/photo-1493119508027-2b584f234d6c', # Photo credits: https://unsplash.com/photos/b18TRXc8UPQ 'https://images.unsplash.com/photo-1482062364825-616fd23b8fc1', # Photo credits: https://unsplash.com/photos/XMFZqrGyV-Q 'https://images.unsplash.com/photo-1521185496955-15097b20c5fe', # Photo credits: https://unsplash.com/photos/9SoCnyQmkzI 'https://images.unsplash.com/photo-1510915228340-29c85a43dcfe', ] for img_url in images * 5: q.put(img_url) for t in range(NUM_THREADS): worker = Thread(target=download_img) worker.daemon = True worker.start() q.join()
使用线程下载 Unsplash 图片
总之,Python 中的线程允许在单个进程中创建多个线程,但由于 GIL,它们中的任何一个都不会在完全相同的时间运行。当涉及到并发运行多个 I/O bound 任务时,线程仍然是一个非常好的选择。如果您想利用多核机器上的计算资源,那么多进程就是正确的选择。
您还应该注意,线程带有管理线程的开销,因此应避免将它们用于基本任务。此外,它们还增加了程序的复杂性,这意味着调试可能会变得有些棘手。因此,请仅在确实有明显价值的情况下使用线程。
Python中的多进程
现在,如果我们想利用多核系统并最终在真正并行的上下文中运行任务,我们需要执行多进程而不是多线程。
在Python中,可以使用multiprocessing模块(或concurrent.futures.ProcessPoolExecutor)实现多进程,以便可以生成多个操作系统进程。因此,在Python中进行多进程可以绕过GIL及其引起的限制,因为每个进程现在都将拥有自己的解释器和自己的GIL。
multiprocessing是一个支持使用类似于线程模块的API来生成进程的包。
multiprocessing包提供本地和远程并发,通过使用子进程而不是线程有效地绕过了全局解释器锁。
由于这个原因,multiprocessing模块允许程序员充分利用给定计算机上的多个处理器。它可以在Unix和Windows上运行。
-Python文档
在前一节中,我们谈到了线程,我们看到线程根本无法改善CPU密集型任务。这可以通过使用多进程来实现。让我们使用在上一节中使用的相同的函数append_to_list(),但这次不使用线程,而是使用多进程来利用我们的多核机器。
现在让我们考虑一个涉及向列表中附加多个随机整数的函数的CPU密集型操作。
import random def append_to_list(lst, num_items): """ Appends num_items integers within the range [0-20000000) to the input lst """ for n in random.sample(range(20000000), num_items): lst.append(n)
一个 CPU-bound 任务
现在我们假设我们想要运行这个函数两次,如下所示:
def append_to_list(lst, num_items): """ Appends num_items integers within the range [0-20000000) to the input lst """ for n in random.sample(range(20000000), num_items): lst.append(n) if __name__ == "__main__": for i in range(2): append_to_list([], 10000000)
没有使用 multiprocessing 的 CPU-bound 任务
让我们计时这个执行,并检查结果。
$ time python3 test.py real 0m35.087s user 0m34.288s sys 0m0.621s
现在让我们稍微重构一下代码,并使用两个不同的进程,以便每个函数调用都在它自己的进程中执行:
import random import multiprocessing NUM_PROC = 2 def append_to_list(lst, num_items): """ Appends num_items integers within the range [0-20000000) to the input lst """ for n in random.sample(range(20000000), num_items): lst.append(n) if __name__ == "__main__": jobs = [] for i in range(NUM_PROC): process = multiprocessing.Process( target=append_to_list, args=([], 10000000) ) jobs.append(process) for j in jobs: j.start() for j in jobs: j.join()
使用 multiprocessing 进行 CPU-bound 任务
最后让我们计时这个执行,并检查结果:
$ time python3 test.py real 0m15.251s user 0m29.599s sys 0m0.659s
我们可以清楚地看到(即使用户和系统时间保持了大致相同),实际时间已经下降了一个大于二的因素(这是预期的,因为我们本质上将负载分配给了两个不同的进程,使它们可以并行运行)。
总之,Python中的多进程可以在需要利用多核系统的计算能力时使用。实际上,multiprocessing模块可以让您并行运行多个任务和进程。与线程相比,multiprocessing通过使用子进程而不是线程来绕过GIL,因此多个进程可以真正同时运行。这种技术主要适用于CPU-bound任务。
终极思考
在今天的文章中,我们介绍了编程中最基本的两个概念,即并发和并行以及它们在执行方面的区别或结合。此外,我们讨论了线程和多进程,并探讨了它们的主要优缺点以及一些用例,这些用例可能有助于您了解何时使用其中之一。最后,我们展示了如何使用Python实现线程或多进程应用程序。
线程
- 线程共享相同的内存,并且可以写入和读取共享变量
- 由于Python全局解释器锁定,两个线程不会同时执行,但是会并发执行(例如上下文切换)
- 适用于I/O-bound任务
- 可以使用线程模块实现
多进程
- 每个进程都有自己的内存空间
- 每个进程可以包含一个或多个子进程/线程
- 可以利用多核机器实现并行,因为进程可以在不同的CPU核心上运行
- 适用于CPU-bound任务
- 可以使用multiprocessing模块(或concurrent.futures.ProcessPoolExecutor)实现