Python 中的多线程和多进程 | 长文详解

简介: Python 中的多线程和多进程 | 长文详解

本文深入探讨了 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)实现
              相关文章
              |
              16天前
              |
              调度 开发者 Python
              深入浅出操作系统:进程与线程的奥秘
              在数字世界的底层,操作系统扮演着不可或缺的角色。它如同一位高效的管家,协调和控制着计算机硬件与软件资源。本文将拨开迷雾,深入探索操作系统中两个核心概念——进程与线程。我们将从它们的诞生谈起,逐步剖析它们的本质、区别以及如何影响我们日常使用的应用程序性能。通过简单的比喻,我们将理解这些看似抽象的概念,并学会如何在编程实践中高效利用进程与线程。准备好跟随我一起,揭开操作系统的神秘面纱,让我们的代码运行得更加流畅吧!
              |
              15天前
              |
              消息中间件 Unix Linux
              【C语言】进程和线程详解
              在现代操作系统中,进程和线程是实现并发执行的两种主要方式。理解它们的区别和各自的应用场景对于编写高效的并发程序至关重要。
              45 6
              |
              16天前
              |
              调度 开发者
              深入理解:进程与线程的本质差异
              在操作系统和计算机编程领域,进程和线程是两个核心概念。它们在程序执行和资源管理中扮演着至关重要的角色。本文将深入探讨进程与线程的区别,并分析它们在现代软件开发中的应用和重要性。
              40 5
              |
              14天前
              |
              算法 调度 开发者
              深入理解操作系统:进程与线程的管理
              在数字世界的复杂编织中,操作系统如同一位精明的指挥家,协调着每一个音符的奏响。本篇文章将带领读者穿越操作系统的幕后,探索进程与线程管理的奥秘。从进程的诞生到线程的舞蹈,我们将一起见证这场微观世界的华丽变奏。通过深入浅出的解释和生动的比喻,本文旨在揭示操作系统如何高效地处理多任务,确保系统的稳定性和效率。让我们一起跟随代码的步伐,走进操作系统的内心世界。
              |
              16天前
              |
              调度 开发者
              核心概念解析:进程与线程的对比分析
              在操作系统和计算机编程领域,进程和线程是两个基本而核心的概念。它们是程序执行和资源管理的基础,但它们之间存在显著的差异。本文将深入探讨进程与线程的区别,并分析它们在现代软件开发中的应用和重要性。
              34 4
              |
              21天前
              |
              数据采集 存储 数据处理
              Python中的多线程编程及其在数据处理中的应用
              本文深入探讨了Python中多线程编程的概念、原理和实现方法,并详细介绍了其在数据处理领域的应用。通过对比单线程与多线程的性能差异,展示了多线程编程在提升程序运行效率方面的显著优势。文章还提供了实际案例,帮助读者更好地理解和掌握多线程编程技术。
              |
              1月前
              |
              并行计算 数据处理 调度
              Python中的并发编程:探索多线程与多进程的奥秘####
              本文深入探讨了Python中并发编程的两种主要方式——多线程与多进程,通过对比分析它们的工作原理、适用场景及性能差异,揭示了在不同应用需求下如何合理选择并发模型。文章首先简述了并发编程的基本概念,随后详细阐述了Python中多线程与多进程的实现机制,包括GIL(全局解释器锁)对多线程的影响以及多进程的独立内存空间特性。最后,通过实例演示了如何在Python项目中有效利用多线程和多进程提升程序性能。 ####
              |
              26天前
              |
              监控 JavaScript 前端开发
              python中的线程和进程(一文带你了解)
              欢迎来到瑞雨溪的博客,这里是一位热爱JavaScript和Vue的大一学生分享技术心得的地方。如果你从我的文章中有所收获,欢迎关注我,我将持续更新更多优质内容,你的支持是我前进的动力!🎉🎉🎉
              21 0
              |
              26天前
              |
              数据采集 Java Python
              爬取小说资源的Python实践:从单线程到多线程的效率飞跃
              本文介绍了一种使用Python从笔趣阁网站爬取小说内容的方法,并通过引入多线程技术大幅提高了下载效率。文章首先概述了环境准备,包括所需安装的库,然后详细描述了爬虫程序的设计与实现过程,包括发送HTTP请求、解析HTML文档、提取章节链接及多线程下载等步骤。最后,强调了性能优化的重要性,并提醒读者遵守相关法律法规。
              56 0
              |
              5月前
              |
              运维 关系型数据库 MySQL
              掌握taskset:优化你的Linux进程,提升系统性能
              在多核处理器成为现代计算标准的今天,运维人员和性能调优人员面临着如何有效利用这些处理能力的挑战。优化进程运行的位置不仅可以提高性能,还能更好地管理和分配系统资源。 其中,taskset命令是一个强大的工具,它允许管理员将进程绑定到特定的CPU核心,减少上下文切换的开销,从而提升整体效率。
              掌握taskset:优化你的Linux进程,提升系统性能