多线程 Python 示例:学习 GIL Python

⚡ 智能摘要

多线程 Python 它在一个进程内运行多个线程,使它们共享内存并并发工作。线程模块创建并管理这些线程,而全局解释器锁限制了真正的并行性,因此该技术最适合输入/输出密集型任务。

  • 🔘 线程与进程: 线程是进程内的一个执行单元,线程在独立运行时共享进程数据。
  • ☑️ 两个模块: 过时的 _thread 模块提供底层调用,而更高级别的 threading 模块是构建线程的现代标准。
  • 创建主题: 扩展 Thread 类,重写 __init__ 和 run() 方法,然后调用 start() 启动线程,调用 join() 等待线程完成。
  • 🧪 避免竞争条件: 锁一次只允许一个线程进入临界区,防止死锁和共享数据损坏。
  • 🛠️ 了解 GIL: 全局解释器锁允许一个线程运行 Python 一次只能处理一个字节码,因此 CPU 密集型工作需要多进程处理。
  • 🤖 AI 工作负载: 多线程可以加快输入/输出密集型的AI步骤,例如数据加载,而NumPy和Py则用于加速这些步骤。Torch 释放 GIL 以进行并行计算。

多线程 Python

此 Python 编程语言允许你使用多进程或多线程。在本教程中,你将学习如何编写多线程应用程序。 Python.

什么是线程?

在并发编程中,线程是执行单元。多线程是一种允许 CPU 同时执行同一进程多个任务的技术。这些线程可以独立执行,同时共享进程资源。

什么是过程?

进程本质上就是正在执行的程序。当你在计算机上启动一个应用程序(例如浏览器或文本编辑器)时,操作系统会创建一个进程。 的过程。

什么是多线程 Python?

多线程 Python 多线程编程是一种众所周知的技术,它允许进程中的多个线程与主线程共享数据空间,从而实现线程间的信息共享和通信,提高效率。线程比进程更轻量级。多个线程可以独立执行,同时共享进程资源。多线程的目的是同时运行多个任务和函数。

什么是多处理?

多处理 允许您同时运行多个不相关的进程。这些进程不共享资源并通过 IPC 进行通信。

Python 多线程与多处理

为了理解进程和线程,请考虑以下场景:计算机上的 .exe 文件就是一个程序。当您打开它时,操作系统会将其加载到内存中,然后由 CPU 执行。此时正在运行的程序实例称为进程。

每个过程都包含两个基本组成部分:

  • 此 Code
  • 数据

现在,一个流程可以包含一个或多个子部分,称为 线程。 这取决于操作系统架构。你可以把线程理解为进程中可以由操作系统独立执行的一部分。

换句话说,它是一系列可以由操作系统独立运行的指令。单个进程内的线程共享该进程的数据,并且被设计成协同工作以实现并行处理。

为什么要使用多线程?

多线程允许您将应用程序分解为多个子任务并同时运行这些任务。如果您正确使用多线程,您的应用程序速度、性能和渲染都可以得到改善。

Python 多线程

Python 支持多进程和多线程结构。本教程将主要侧重于实现。 多线程 应用程序 Python有两个主要模块可用于处理线程。 Python:

  1. 模块,和
  2. 穿线 模块

但是,在 Python此外,还有一种叫做全局解释器锁(GIL)的东西。它并不能带来多少性能提升,甚至可能还会降低性能。 降低 一些多线程应用程序的性能。您将在本教程的后续部分中了解所有相关信息。

Thread 和 Threading 模块

您将在本教程中学习的两个模块是 线程模块线程模块.

然而,thread 模块早已被弃用。从 Python 3,它已被指定为过时的,并且只能作为 _线 为了向后兼容。

你应该使用更高级别的 穿线 本模块适用于您计划部署的应用程序。此处对线程模块的介绍仅出于教学目的。

Thread 模块

使用该模块创建新线程的语法如下:

thread.start_new_thread(function_name, arguments)

好了,现在你已经了解了开始编码的基本理论。所以,打开你的 IDLE 或记事本并输入以下内容:

import time
import _thread

def thread_test(name, wait):
   i = 0
   while i <= 3:
      time.sleep(wait)
      print("Running %s\n" %name)
      i = i + 1

   print("%s has finished execution" %name)

if __name__ == "__main__":
    
    _thread.start_new_thread(thread_test, ("First Thread", 1))
    _thread.start_new_thread(thread_test, ("Second Thread", 2))
    _thread.start_new_thread(thread_test, ("Third Thread", 3))

保存文件并按 F5 运行程序。如果一切操作正确,您应该看到以下输出:

Thread 模块

在接下来的章节中,您将了解更多关于比赛状况以及如何应对比赛状况的信息。

Thread 模块

代码解释

  1. 这些语句导入了时间和线程模块,用于处理执行和延迟。 Python 线程。
  2. 这里你定义了一个函数,名为 线程测试, 它将由 启动新线程 该方法会运行一个 while 循环,循环四次,并打印调用它的线程名称。每次循环结束后,它会打印一条消息,表明该线程已执行完毕。
  3. 这是程序的主要部分。在这里,你只需调用 启动新线程 方法与 线程测试 将函数作为参数传递。这将为传递的函数创建一个新线程并开始执行它。请注意,您可以将 `thread_test` 替换为任何其他想要作为线程运行的函数。

Threading 模块

该模块是线程的高级实现。 Python 它是管理多线程应用程序的事实标准。与线程模块相比,它提供了更广泛的功能。

Threading 模块的结构

Threading 模块的结构

以下是此模块中定义的一些有用函数的列表:

功能名称 描述
活跃计数() 返回 Thread 仍然活着的物体。
当前线程() 返回 Thread 类的当前对象。
枚举() 列出所有活动的线程对象。
是Daemon() 如果线程是守护进程,则返回 true。
活着() 如果线程仍然处于活动状态,则返回 true。
线程类方法
开始() 启动线程活动。每个线程只能调用一次,因为如果调用多次,将引发运行时错误。
跑步() 该方法表示线程的活动,可以被扩展 Thread 类的类覆盖。
加入() 它会阻止其他代码的执行,直到调用 join() 方法的线程终止。

背景故事:Thread 类

在使用线程模块编写多线程程序之前,理解 Thread 类至关重要。Thread 类是定义线程模板和操作的主要类。 Python.

创建多线程最常见的方法是 Python 应用程序是声明一个继承自 Thread 类的类,并重写其 run() 方法。

Thread 类,简而言之,表示在单独的 控制。

因此,在编写多线程应用程序时,您将执行以下操作:

  1. 定义一个继承自 Thread 类的类
  2. 覆盖 __init__ 构造函数
  3. 覆盖 跑步() 方法

一旦创建了线程对象, 开始() 可以使用该方法开始执行此活动,并且 加入() 方法可用于阻止所有其他代码,直到当前活动完成。

现在,让我们尝试使用线程模块来实现你之前的示例。再次启动你的程序。 IDLE 并输入以下内容:

import time
import threading

class threadtester (threading.Thread):
    def __init__(self, id, name, i):
       threading.Thread.__init__(self)
       self.id = id
       self.name = name
       self.i = i
       
    def run(self):
       thread_test(self.name, self.i, 5)
       print ("%s has finished execution " %self.name)

def thread_test(name, wait, i):

    while i:
       time.sleep(wait)
       print ("Running %s \n" %name)
       i = i - 1

if __name__=="__main__":
    thread1 = threadtester(1, "First Thread", 1)
    thread2 = threadtester(2, "Second Thread", 2)
    thread3 = threadtester(3, "Third Thread", 3)

    thread1.start()
    thread2.start()
    thread3.start()

    thread1.join()
    thread2.join()
    thread3.join()

执行上述代码时的输出如下:

背景故事:Thread 类

代码解释

背景故事:Thread 类

  1. 这部分与之前的示例相同。在这里,你需要导入 time 和 thread 模块,它们用于处理程序的执行和延迟。 Python 线程。
  2. 在这一部分中,您将创建一个名为threadtester的类,它继承或扩展了 Thread 线程模块的类。这是创建线程最常见的方法之一。 Python但是,您只需要重写构造函数和 跑步() 方法。如您在上面的代码示例中所见, __init__ 方法(构造函数)已被重写。同样,您还重写了 跑步() 方法。它包含要在线程内执行的代码。在此示例中,您调用了 thread_test() 函数。
  3. 这是 thread_test() 方法,它接受以下值: i 以 i 为参数,每次迭代将其减 1,并循环执行剩余的代码,直到 i 变为 0。在每次迭代中,它打印当前正在执行的线程的名称,并休眠 wait 秒(这也是一个参数)。
  4. thread1 = threadtester(1, “First Thread”, 1) 这里,我们创建一个线程并传递我们在 __init__ 中声明的三个参数。第一个参数是线程的 id,第二个参数是线程的名称,第三个参数是计数器,它决定了 while 循环应该运行多少次。
  5. thread2.start() start 方法用于启动线程的执行。在内部,start() 函数会调用类的 run() 方法。
  6. thread3.join() join() 方法阻止其他代码的执行,并等待直到调用它的线程完成。

正如您所知,同一进程中的线程可以访问该进程的内存和数据。因此,如果多个线程同时尝试修改或访问数据,则可能会出现错误。

下一节中,您将看到当线程在未检查现有访问事务的情况下访问数据和临界区时可能出现的各种复杂情况。

死锁和竞争条件

在学习死锁和竞态条件之前,了解一些与并发编程相关的基本定义会很有帮助:

  • 关键部分: 它是一段用于访问或修改共享变量的代码片段,必须作为原子事务执行。
  • 上下文切换: 这是 CPU 在从一个任务切换到另一个任务之前,用来存储线程状态的过程,以便之后可以从同一位置恢复执行。

死锁

死锁 是开发人员在编写并发/多线程应用程序时面临的最令人担忧的问题。 Python理解死锁的最佳方法是使用计算机科学中一个经典的例子问题,即死锁问题。 餐饮 Philo索弗斯问题。

哲学家就餐的问题陈述如下:

如图所示,五位哲学家围坐在一张圆桌旁,桌上有五盘意大利面(一种意大利面食)和五把叉子。

餐饮 Philo索弗斯问题

餐饮 Philo索弗斯问题

在任何特定时间,哲学家要么在吃饭,要么在思考。

此外,哲学家必须先拿起与自己相邻的两把餐叉(即左餐叉和右餐叉)才能吃到意大利面条。当五位哲学家同时拿起自己的右餐叉时,就会发生死锁问题。

由于每个哲学家都有一把叉子,他们都会等待其他人放下叉子。结果,他们都吃不到意大利面条了。

类似地,在并发系统中,当不同的线程或进程(哲学家)试图同时获取共享系统资源(分叉)时,就会发生死锁。结果,所有进程都没有机会执行,因为它们正在等待其他进程持有的另一个资源。

比赛条件

竞态条件是指当系统同时执行两个或多个操作时,程序会出现一种不希望出现的状态。例如,考虑以下简单的 for 循环:

i=0; # a global variable
for x in range(100):
    print(i)
    i+=1;

如果你创建 n 由于同时运行这段代码的线程数量众多,因此无法在程序执行完毕时确定线程共享的变量 i 的值。这是因为在实际的多线程环境中,线程之间可能会发生重叠,一个线程获取并修改的 i 值可能在其他线程访问它之前就已经发生了变化。

这是多线程或分布式系统中可能出现的两大类问题。 Python 应用程序。在下一节中,您将学习如何通过线程同步来解决这个问题。

Sync同步线程

为了处理竞争条件、死锁和其他基于线程的问题,threading 模块提供了 对象。这个想法是,当一个线程想要访问特定资源时,它会获取该资源的锁。一旦一个线程锁定了某个资源,其他线程就无法访问它,直到锁被释放。因此,对资源的更改将是原子的,并且可以避免竞争条件。

锁是 _线 模块。在任何给定时间,锁只能处于以下两种状态之一: 锁定 or 解锁。 它支持两种方法:

  1. 获得(): 当锁状态为解锁状态时,调用 acquire() 方法会将状态更改为锁定状态并返回。但是,如果状态为锁定状态,则对 acquire() 的调用会被阻塞,直到其他线程调用 release() 方法为止。
  2. 发布(): release() 方法用于将状态设置为未锁定,即释放锁。任何线程都可以调用该方法,而不必是获取锁的线程。

以下是在应用程序中使用锁的示例。启动你的 IDLE 并输入以下内容:

import threading
lock = threading.Lock()

def first_function():
    for i in range(5):
        lock.acquire()
        print ('lock acquired')
        print ('Executing the first funcion')
        lock.release()

def second_function():
    for i in range(5):
        lock.acquire()
        print ('lock acquired')
        print ('Executing the second funcion')
        lock.release()

if __name__=="__main__":
    thread_one = threading.Thread(target=first_function)
    thread_two = threading.Thread(target=second_function)

    thread_one.start()
    thread_two.start()

    thread_one.join()
    thread_two.join()

现在,按 F5。您应该看到如下输出:

Sync同步线程

代码解释

Sync同步线程

  1. 在这里,你只需通过调用 线程.Lock() 工厂函数。在内部,Lock() 返回平台维护的最有效的具体 Lock 类的实例。
  2. 在第一个语句中,通过调用 acquire() 方法获取锁。当锁被授予后,你将打印 “已获取锁” 到控制台。一旦您希望线程运行的所有代码都已执行完毕,就可以通过调用 release() 方法来释放锁。

理论上没问题,但你怎么知道锁真的生效了呢?看看输出结果,你会发现每个 print 语句每次都只打印一行。回想一下,在之前的例子中,print 的输出是杂乱无章的,因为多个线程同时访问了 print() 方法。而在这里,print 函数只有在获取到锁之后才会被调用。因此,输出是逐行逐个显示的。

除了锁具之外, Python 它还支持以下列出的其他一些线程同步机制:

  1. 回锁
  2. Semaphores
  3. 医美问题
  4. 事件,和
  5. 阻隔性

全局解释器锁(以及如何处理它)

在深入了解 Python在GIL中,让我们先定义几个术语,以便更好地理解接下来的内容:

  1. CPU密集型代码:指任何将由CPU直接执行的代码。
  2. I/O 密集型代码:这可以是任何通过操作系统访问文件系统的代码。
  3. CPython:这是参考 履行 of Python 并且可以描述为用 C 编写的解释器和 Python (编程语言)。

GIL 是什么 Python?

全局解释器锁 (GIL) in Python GIL(全局解释器锁)是一种用于处理进程的进程锁或互斥锁。它确保一次只能有一个线程访问特定的资源,并且还能防止同时使用对象和字节码。这有利于单线程程序提高性能。 Python 非常简单,易于实施。

可以使用锁来确保在给定时间内只有一个线程可以访问特定资源。

的功能之一 Python 它在每个解释器进程上使用全局锁,这意味着每个进程都对…… Python 口译器本身也是一种资源。

例如,假设你已经写了一个 Python 这是一个使用两个线程同时执行 CPU 操作和 I/O 操作的程序。运行此程序时,会发生以下情况:

  1. 此 Python 解释器创建一个新进程并生成线程。
  2. 当线程1开始运行时,它会首先获取GIL并锁定它。
  3. 如果线程 2 现在想要执行,那么即使另一个处理器空闲,它也必须等待 GIL 被释放。
  4. 现在假设线程 1 正在等待 I/O 操作。此时,它将释放 GIL,而线程 2 将获取它。
  5. 完成 I/O 操作后,如果线程 1 想要立即执行,它将不得不再次等待线程 2 释放 GIL。

因此,任何时候都只能有一个线程访问解释器,这意味着只会有一个线程在执行代码。 Python 特定时间点的代码。

在单核处理器上,这样做没有问题,因为它会使用时间片轮转(参见本教程第一部分)来处理线程。然而,在多核处理器上,CPU密集型函数在多个线程上执行会显著影响程序的效率,因为它实际上无法同时利用所有可用核心。

为什么需要 GIL?

的CPython 垃圾回收器使用一种称为引用计数的高效内存管理技术。其工作原理如下:每个对象 Python 对象拥有引用计数,当它被赋值给新的变量名或添加到容器(例如元组、列表等)时,引用计数会增加。同样,当引用超出作用域或调用 del 语句时,引用计数会减少。当对象的引用计数达到 0 时,它会被垃圾回收,并释放分配的内存。

但问题在于,引用计数变量和其他全局变量一样,容易出现竞态条件。为了解决这个问题,开发者们…… Python 决定使用全局解释器锁。另一种方法是给每个对象添加一个锁,但这会导致死锁,并增加 acquire() 和 release() 调用带来的开销。

因此,GIL 是多线程的一个重要限制因素。 Python 运行大量 CPU 密集型操作的程序(实际上是单线程程序)。如果您想在应用程序中利用多个 CPU 核心,请使用…… 多处理 模块代替。

常见问题

对于网络调用、文件访问或数据库查询等 I/O 密集型任务,应使用多线程,因为这些任务中的线程需要等待外部资源。对于 CPU 密集型任务(例如繁重的计算),应使用多进程,因为它可以在多个核心上运行并绕过全局解释器锁 (GIL)。

默认情况下并非如此。PEP 703 引入了一个可选的自由线程构建。 Python 3.13 禁用了 GIL,并且 Python 3.14 版本延续了这一特性。标准版本仍然包含 GIL,因此大多数程序可以像以前一样运行。

守护线程在后台运行,不会阻止程序退出。当只剩下守护线程时, Python 关闭它们并退出。在调用 start() 之前,将 thread.daemon 设置为 True。

Thread 类不会直接返回 run() 的结果。可以将结果存储在线程对象或共享队列中,或者使用 concurrent.futures.ThreadPoolExecutor,它的 submit() 方法会返回一个 Future 对象,你可以使用 result() 读取该对象。

ThreadPoolExecutor 是一个高级辅助函数,用于管理工作线程池。您可以使用 submit() 或 map() 提交任务,它负责线程的创建、重用和清理,并返回包含每个结果的 Future 对象。

你可以创建成百上千个线程,但 GIL 只允许一个线程执行。 Python 字节码在任何时刻都会被读取。它们在 I/O 等待期间仍然会重叠,因此,对于 I/O 密集型程序而言,多个并发线程的帮助远大于 CPU 密集型程序。

是的,主要用于 I/O 密集型步骤,例如加载数据、调用 API 或读取文件。繁重的训练任务是 CPU 密集型的,因此需要像 NumPy 和 Py 这样的库。Tor在 C 代码中释放 GIL 或依靠多进程实现真正的并行性。

是的。GitHub Copilot 可以根据注释或函数名自动补全常见模式,例如线程子类、锁的使用以及 ThreadPoolExecutor 的设置。但在使用生成的代码之前,务必检查是否存在竞态条件以及锁的处理是否正确。

总结一下这篇文章: