多线程 Python 示例:学习 GIL Python

Python 编程语言允许你使用多处理或多线程。在本教程中,你将学习如何使用 Python.

什么是线程?

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

什么是过程?

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

什么是多线程 Python?

多线程 Python 编程是一种众所周知的技术,其中进程中的多个线程与主线程共享其数据空间,这使得线程内的信息共享和通信变得简单而高效。线程比进程更轻量。多个线程可以单独执行,同时共享其进程资源。多线程的目的是同时运行多个任务和功能单元。

什么是多处理?

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

Python 多线程与多处理

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

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

  • 守则
  • 数据

现在,一个流程可以包含一个或多个子部分,称为 线程。 这取决于操作系统架构。您可以将线程视为进程的一部分,可由操作系统单独执行。

换句话说,它是可以由操作系统独立运行的指令流。单个进程内的线程共享该进程的数据,旨在协同工作以实现并行性。

为什么要使用多线程?

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

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. 这是程序的主要部分。在这里,你只需调用 启动新线程 方法与 线程测试 函数作为参数。这将为您作为参数传递的函数创建一个新线程并开始执行它。请注意,您可以替换此(线程_您可以将测试与您想要作为线程运行的任何其他函数一起使用。

Threading 模块

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

Threading 模块的结构
Threading 模块的结构

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

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

背景故事: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. 这部分与我们之前的示例相同。在这里,您导入了时间和线程模块,它们用于处理 Python 线程。
  2. 在这一部分中,您将创建一个名为threadtester的类,它继承或扩展了 Thread threading 模块的类。这是在 Python 中创建线程的最常见方法之一。但是,您只能覆盖构造函数和 跑步() 方法。如您在上面的代码示例中所见, __init__ 方法(构造函数)已被重写。同样,您还重写了 跑步() 方法。它包含要在线程内执行的代码。在此示例中,您调用了 thread_test() 函数。
  3. 这是 thread_test() 方法,其值为 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) 在 Python 中,GIL 是处理进程时使用的进程锁或互斥锁。它确保一个线程一次可以访问特定资源,并且还可以防止同时使用对象和字节码。这有利于单线程程序的性能提升。Python 中的 GIL 非常简单且易于实现。

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

的功能之一 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() 调用的开销。

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

总结

  • Python 支持2个多线程模块:
    1. __线 模块:它为线程提供了低级实现,并且已经过时了。
    2. 线程模块:它为多线程提供了高级实现,是当前的标准。
  • 要使用 threading 模块创建线程,必须执行以下操作:
    1. 创建一个扩展的类 Thread 类。
    2. 重写其构造函数(__init__)。
    3. 覆盖其 跑步() 方法。
    4. 创建该类的一个对象。
  • 可以通过调用 开始() 方法。
  • - 加入() 方法可用于阻塞其他线程,直到该线程(调用 join 的线程)完成执行。
  • 当多个线程同时访问或修改共享资源时,就会出现竞争条件。
  • 可以通过以下方式避免 Sync同步线程。
  • Python 支持6种方式同步线程:
    1. 回锁
    2. Semaphores
    3. 医美问题
    4. 事件,和
    5. 壁垒
  • 锁只允许获取锁的特定线程进入临界区。
  • Lock 有两种主要方法:
    1. 获得():将锁定状态设置为 锁定。 如果在锁定的对象上调用它,它会阻塞直到资源释放为止。
    2. 释放():将锁定状态设置为 解锁 并返回。如果在未锁定的对象上调用,则返回 false。
  • 全局解释器锁是一种机制,通过该机制,只有 1 个 CPython 解释器进程每次只能执行一次。
  • 它用于促进 C 的引用计数功能Pythons 的垃圾收集器。
  • 为了使 Python 对于 CPU 密集型操作的应用程序,您应该使用多处理模块。