Python 中的并发 - 快速指南


Python 中的并发 - 简介

在本章中,我们将了解Python中并发的概念,并了解不同的线程和进程。

什么是并发?

简单来说,并发就是两个或多个事件同时发生。并发是一种自然现象,因为许多事件在任何给定时间同时发生。

在编程方面,并发是指两个任务在执行中重叠。通过并发编程,我们的应用程序和软件系统的性能可以得到提高,因为我们可以并发处理请求,而不是等待前一个请求完成。

并发的历史回顾

以下几点将为我们简要回顾并发的历史 -

从铁路的概念来看

并发性与铁路的概念密切相关。对于铁路,需要在同一铁路系统上处理多列火车,以便每列火车都能安全到达目的地。

学术界的并发计算

人们对计算机科学并发性的兴趣始于 Edsger W. Dijkstra 于 1965 年发表的研究论文。在这篇论文中,他发现并解决了并发控制的互斥问题。

高级并发原语

最近,由于高级并发原语的引入,程序员正在获得改进的并发解决方案。

提高编程语言的并发性

Google 的 Golang、Rust 和 Python 等编程语言在帮助我们获得更好的并发解决方案的领域取得了令人难以置信的发展。

什么是线程和多线程?

线程是操作系统中可以执行的最小执行单元。它本身不是程序,而是在程序内运行。换句话说,线程并不是相互独立的。每个线程与其他线程共享代码部分、数据部分等。它们也被称为轻量级流程。

线程由以下组件组成 -

  • 程序计数器由下一条可执行指令的地址组成

  • 寄存器组

  • 唯一的ID

另一方面,多线程是 CPU 通过同时执行多个线程来管理操作系统使用的能力。多线程的主要思想是通过将一个进程划分为多个线程来实现并行性。可以借助以下示例来理解多线程的概念。

例子

假设我们正在运行一个特定的进程,其中我们打开 MS Word 以在其中键入内容。将分配一个线程来打开 MS Word,并要求另一个线程在其中键入内容。现在,如果我们想要编辑现有的,则需要另一个线程来完成编辑任务等等。

什么是进程和多重处理?

流程被定义为一个实体,它代表系统中要实现的基本工作单元简而言之,我们将计算机程序编写在文本文件中,当我们执行该程序时,它就成为执行程序中提到的所有任务的进程。在进程生命周期中,它会经历不同的阶段——启动、就绪、运行、等待和终止。

下图显示了流程的不同阶段 -

多重处理

一个进程只能有一个线程(称为主线程),也可以有多个线程(有自己的一组寄存器、程序计数器和堆栈)。下图将向我们展示差异 -

多处理一

另一方面,多处理是在单个计算机系统中使用两个或多个 CPU 单元。我们的主要目标是充分发挥硬件的潜力。为了实现这一目标,我们需要利用计算机系统中可用的全部 CPU 核心。多处理是做到这一点的最佳方法。

多处理二

Python 是最流行的编程语言之一。以下是使其适合并发应用程序的一些原因 -

语法糖

语法糖是编程语言中的语法,旨在使内容更易于阅读或表达。它使语言对于人类使用来说变得“更甜蜜”:事物可以更清晰、更简洁地表达,或者根据偏好以其他风格表达。Python 附带了 Magic 方法,可以定义这些方法来作用于对象。这些 Magic 方法被用作语法糖,并绑定到更易于理解的关键字。

大型社区

Python 语言在人工智能、机器学习、深度学习和定量分析领域的数据科学家和数学家中得到了广泛的采用。

用于并发编程的有用 API

Python 2 和 3 有大量专用于并行/并发编程的 API。其中最流行的是线程、concurrent.features、multiprocessing、asyncio、gevent 和 greenlet等。

Python 在实现并发应用程序方面的局限性

Python 对并发应用程序有限制。Python 中存在这种限制,称为GIL(全局解释器锁) 。GIL 不允许我们利用 CPU 的多个核心,因此我们可以说 Python 中不存在真正的线程。我们可以这样理解GIL的概念:

GIL(全局解释器锁)

它是 Python 世界中最具争议的话题之一。在 CPython 中,GIL 是互斥锁 - 互斥锁,它使事物成为线程安全的。换句话说,我们可以说 GIL 阻止了多个线程并行执行 Python 代码。锁一次只能由一个线程持有,如果我们想要执行一个线程,那么它必须首先获取锁。下图将帮助您了解 GIL 的工作原理。

局限性

然而,Python 中有一些库和实现,例如Numpy、JpythonIronPytbhon。这些库无需与 GIL 进行任何交互即可工作。

并发与并行

并发和并行都与多线程程序相关,但是对于它们之间的相似性和差异存在很多困惑。这方面的一个大问题是:并发是否是并行的?尽管这两个术语看起来非常相似,但上述问题的答案是否定的,并发和并行性并不相同。现在,如果它们不同,那么它们之间的基本区别是什么?

简单来说,并发性涉及管理不同线程对共享状态的访问,而另一方面,并​​行性涉及利用多个 CPU 或其内核来提高硬件性能。

并发细节

并发是指两个任务在执行中重叠。这可能是应用程序同时处理多个任务的情况。我们可以用图解的方式来理解;多个任务同时取得进展,如下 -

并发性

并发级别

在本节中,我们将讨论编程方面的三个重要的并发级别 -

低级并发

在此并发级别中,显式使用Atomics操作。我们不能使用这种并发来构建应用程序,因为它很容易出错并且难以调试。即使Python也不支持这种并发。

中级并发

在这种并发中,没有使用显式的Atomics操作。它使用显式锁。Python 和其他编程语言支持这种并发。大多数应用程序程序员都使用这种并发性。

高级并发

在这种并发中,既不使用显式Atomics操作,也不使用显式锁。Python有concurrent.futures模块来支持这种并发。

并发系统的属性

为了使程序或并发系统正确,它必须满足一些属性。与系统终止相关的属性如下 -

正确性属性

正确性属性意味着程序或系统必须提供所需的正确答案。为了简单起见,我们可以说系统必须正确地将起始程序状态映射到最终状态。

安全性能

安全属性意味着程序或系统必须保持在“良好”“安全”的状态,并且永远不会做任何“坏”的事情。

活性特性

这个属性意味着一个程序或系统必须“取得进展”并且它将达到某种理想的状态。

并发系统的参与者

这是并发系统的一个常见属性,其中可以有多个进程和线程,它们同时运行以在各自的任务上取得进展。这些进程和线程称为并发系统的参与者。

并发系统资源

参与者必须利用内存、磁盘、打印机等资源来执行他们的任务。

一定的规则集

每个并发系统都必须拥有一组规则来定义参与者要执行的任务类型以及每个任务的时间安排。这些任务可以是获取锁、共享内存、修改状态等。

并发系统的障碍

在实现并发系统时,程序员必须考虑以下两个重要问题,这可能是并发系统的障碍 -

数据共享

实现并发系统时的一个重要问题是多个线程或进程之间的数据共享。实际上,程序员必须确保锁保护共享数据,以便对共享数据的所有访问都是串行的,并且一次只有一个线程或进程可以访问共享数据。万一,当多个线程或进程都试图访问相同的共享数据时,不是所有线程或进程都会被阻塞,而是至少其中一个会被阻塞并保持空闲状态。换句话说,我们可以说,当锁有效时,我们一次只能使用一个进程或线程。可以有一些简单的解决方案来消除上述障碍 -

数据共享限制

最简单的解决方案是不共享任何可变数据。在这种情况下,我们不需要使用显式锁定,并且由于相互数据而导致的并发障碍也将得到解决。

数据结构协助

很多时候并发进程需要同时访问相同的数据。除了使用显式锁之外,另一种解决方案是使用支持并发访问的数据结构。例如,我们可以使用队列模块,它提供线程安全的队列。我们还可以使用multiprocessing.JoinableQueue类来实现基于多处理的并发。

不可变的数据传输

有时,我们使用的数据结构(例如并发队列)不适合,那么我们可以传递不可变数据而不锁定它。

可变数据传输

延续上述解决方案,假设如果只需要传递可变数据,而不是不可变数据,那么我们可以传递只读的可变数据。

I/O资源共享

实现并发系统的另一个重要问题是线程或进程对 I/O 资源的使用。当一个线程或进程长时间使用 I/O 而其他线程或进程处于空闲状态时,就会出现问题。在处理 I/O 密集型应用程序时,我们可以看到这种障碍。可以通过一个例子来理解,即从网络浏览器请求页面。这是一个繁重的应用程序。在这里,如果请求数据的速率低于消耗数据的速率,那么我们的并发系统中就会出现 I/O 障碍。

以下 Python 脚本用于请求网页并获取我们的网络获取所请求页面所需的时间 -

import urllib.request
import time
ts = time.time()
req = urllib.request.urlopen('https://www.tutorialspoint.com')
pageHtml = req.read()
te = time.time()
print("Page Fetching Time : {} Seconds".format (te-ts))

执行上述脚本后,我们可以得到如下所示的页面抓取时间。

输出

Page Fetching Time: 1.0991398811340332 Seconds

我们可以看到,获取页面的时间超过一秒。现在,如果我们想要获取数千个不同的网页,您可以了解我们的网络需要花费多少时间。

什么是并行性?

并行性可以定义为将任务拆分为可以同时处理的子任务的技术。它与上面讨论的并发相反,并发是两个或多个事件同时发生。我们可以用图解的方式来理解;一个任务分为多个可以并行处理的子任务,如下所示 -

并行性

要更多地了解并发和并行之间的区别,请考虑以下几点 -

并发但非并行

一个应用程序可以是并发的,但不是并行的,意味着它同时处理多个任务,但这些任务不会分解为子任务。

并行但非并发

应用程序可以是并行的,但不是并发的,这意味着它一次只能处理一个任务,并且可以并行处理分解为子任务的任务。

既不是并行也不是并发

应用程序既不能是并行的,也不能是并发的。这意味着它一次仅处理一项任务,并且该任务永远不会分解为子任务。

并行和并发

应用程序可以是并行的,也可以是并发的,这意味着它可以同时处理多个任务,并且该任务被分解为子任务以并行执行它们。

并行的必要性

我们可以通过将子任务分布在单个CPU的不同核心之间或在网络内连接的多台计算机之间来实现并行性。

考虑以下要点来理解为什么需要实现并行性 -

高效的代码执行

借助并行性,我们可以高效地运行代码。它将节省我们的时间,因为部分相同的代码是并行运行的。

比顺序计算更快

顺序计算受到物理和实际因素的限制,无法获得更快的计算结果。另一方面,这个问题通过并行计算得到了解决,并且给我们带来了比顺序计算更快的计算结果。

更少的执行时间

并行处理减少了程序代码的执行时间。

如果我们谈论现实生活中并行性的例子,我们计算机的显卡就是凸显并行处理真正威力的例子,因为它有数百个独立工作的处理核心,可以同时执行。由于这个原因,我们也能够运行高端应用程序和游戏。

了解实施处理器

我们知道并发、并行性以及它们之间的区别,但是我们知道要在其上实现它的系统吗?了解我们将要实施的系统是非常有必要的,因为它使我们有利于在设计软件时做出明智的决策。我们有以下两种处理器 -

单核处理器

单核处理器能够在任何给定时间执行一个线程。这些处理器使用上下文切换来存储特定时间线程的所有必要信息,然后恢复这些信息。上下文切换机制帮助我们在给定的秒内在多个线程上取得进展,看起来系统好像正在处理多个事情。

单核处理器具有许多优点。这些处理器需要较少的功率,并且多个内核之间没有复杂的通信协议。另一方面,单核处理器的速度有限,不适合较大的应用。

多核处理器

多核处理器具有多个独立的处理单元(也称为核心)

此类处理器不需要上下文切换机制,因为每个内核都包含执行一系列存储指令所需的一切。

获取-解码-执行周期

多核处理器的核心遵循一个周期执行。该周期称为获取-解码-执行周期。它涉及以下步骤 -

拿来

这是周期的第一步,涉及从程序存储器中获取指令。

解码

最近获取的指令将被转换为一系列信号,这些信号将触发 CPU 的其他部分。

执行

这是执行获取和解码的指令的最后一步。执行结果将存储在CPU寄存器中。

这样做的一个优点是多核处理器的执行速度比单核处理器更快。它适用于较大的应用。另一方面,多核之间复杂的通信协议也是一个问题。多核处理器比单核处理器需要更多的功率。

系统和内存架构

在设计程序或并发系统时需要考虑不同的系统和内存架构风格。这是非常有必要的,因为一种系统和内存风格可能适合一项任务,但对于其他任务可能容易出错。

支持并发的计算机系统体系结构

Michael Flynn 在 1972 年给出了对不同类型的计算机系统架构进行分类的分类法。该分类法定义了四种不同的风格,如下 -

  • 单指令流、单数据流(SISD)
  • 单指令流、多数据流(SIMD)
  • 多指令流、单数据流(MISD)
  • 多指令流、多数据流(MIMD)。

单指令流、单数据流(SISD)

顾名思义,此类系统将具有一个连续的输入数据流和一个用于执行该数据流的处理单元。它们就像具有并行计算架构的单处理器系统。以下是 SISD 的架构 -

SSID

SISD的优势

SISD架构的优点如下:

  • 它需要更少的电力。
  • 不存在多核之间复杂的通信协议问题。

SISD的缺点

SISD架构的缺点如下:

  • SISD架构的速度与单核处理器一样受到限制。
  • 它不适合较大的应用程序。

单指令流、多数据流(SIMD)

顾名思义,此类系统将具有多个传入数据流和多个处理单元,这些处理单元可以在任何给定时间执行单个指令。它们就像具有并行计算架构的多处理器系统。以下是 SIMD 的架构 -

模拟指令

SIMD 的最佳示例是显卡。这些卡有数百个单独的处理单元。如果我们谈论 SISD 和 SIMD 之间的计算差异,那么对于加法数组[5,15,20][15,25,10], SISD 架构将必须执行三种不同的加法操作。另一方面,使用 SIMD 架构,我们可以在单个加法操作中添加 then 。

SIMD的优点

SIMD架构的优点如下:

  • 仅使用一条指令即可对多个元素执行相同的操作。

  • 系统的吞吐量可以通过增加处理器的核心数量来增加。

  • 处理速度高于SISD架构。

SIMD 的缺点

SIMD 架构的缺点如下:

  • 处理器的多个核心之间存在复杂的通信。
  • 成本比SISD架构高。

多指令单数据 (MISD) 流

具有 MISD 流的系统具有多个处理单元,通过对同一数据集执行不同的指令来执行不同的操作。以下是 MISD 的架构 -

MISD

MISD架构的代表尚未在商业上存在。

多指令多数据 (MIMD) 流

在采用MIMD架构的系统中,多处理器系统中的每个处理器可以对不同的数据集独立地并行执行不同的指令集。它与 SIMD 架构相反,在 SIMD 架构中,单个操作在多个数据集上执行。以下是 MIMD 的架构 -

多模多态性

普通的多处理器使用 MIMD 架构。这些架构基本上应用于计算机辅助设计/计算机辅助制造、仿真、建模、通信交换机等多个应用领域。

支持并发的内存架构

在使用并发和并行性等概念时,始终需要加快程序速度。计算机设计者发现的一种解决方案是创建共享内存多计算机,即具有单个物理地址空间的计算机,该空间由处理器具有的所有内核访问。在这种情况下,可以有多种不同的架构风格,但以下是三种重要的架构风格 -

UMA(统一内存访问)

在该模型中,所有处理器统一共享物理内存。所有处理器对所有存储器字具有相同的访问时间。每个处理器可以有一个私有高速缓冲存储器。外围设备遵循一组规则。

当所有处理器对所有外围设备具有平等的访问权限时,系统称为对称多处理器。当只有一个或几个处理器可以访问外围设备时,系统称为非对称多处理器

乌玛

非一致内存访问 (NUMA)

在 NUMA 多处理器模型中,访问时间随内存字的位置而变化。这里,共享内存物理上分布在所有处理器之间,称为本地内存。所有本地存储器的集合形成一个可以被所有处理器访问的全局地址空间。

NUMA

仅高速缓存内存架构 (COMA)

COMA 模型是 NUMA 模型的专门版本。这里,所有分布式主存储器都被转换为高速缓冲存储器。

昏迷

Python 中的并发 - 线程

一般来说,我们知道线是一种非常细的绞合线,通常由棉或丝织物制成,用于缝制衣服等。计算机编程领域也使用同一术语“线程”。现在,我们如何将用于缝纫衣服的线和用于计算机编程的线联系起来?这里两个线程执行的角色是相似的。在衣服中,线将布料固定在一起,而在另一面,在计算机编程中,线保持计算机程序并允许程序执行顺序动作或同时执行多个动作。

线程是操作系统中最小的执行单元。它本身不是一个程序,而是在一个程序内运行。换句话说,线程之间并不是相互独立的,而是与其他线程共享代码段、数据段等。这些线程也称为轻量级进程。

线程状态

为了深入理解线程的功能,我们需要了解线程的生命周期或不同的线程状态。通常,线程可以存在五种不同的状态。不同的状态如下所示 -

新线程

新线程在新状态下开始其生命周期。然而,现阶段它还没有启动,也没有分配任何资源。我们可以说它只是一个对象的实例。

可运行

当新出生的线程启动时,该线程变得可运行,即等待运行。在这种状态下,它拥有所有资源,但任务调度程序尚未调度它运行。

跑步

在此状态下,线程取得进展并执行任务调度程序选择运行的任务。现在,线程可以进入死亡状态或不可运行/等待状态。

非运行/等待

在此状态下,线程被暂停,因为它要么等待某些 I/O 请求的响应,要么等待其他线程执行完成。

死的

可运行线程在完成其任务或以其他方式终止时进入终止状态。

下图显示了线程的完整生命周期 -

死的

螺纹类型

在本节中,我们将看到不同类型的线程。类型描述如下 -

用户级线程

这些是用户管理的线程。

在这种情况下,线程管理内核并不知道线程的存在。线程库包含用于创建和销毁线程、在线程之间传递消息和数据、调度线程执行以及保存和恢复线程上下文的代码。应用程序以单线程启动。

用户级线程的示例是 -

  • Java线程
  • POSIX 线程
死的

用户级线程的优点

以下是用户级线程的不同优点 -

  • 线程切换不需要内核模式权限。
  • 用户级线程可以在任何操作系统上运行。
  • 调度可以是用户级线程中特定于应用程序的。
  • 用户级线程的创建和管理速度很快。

用户级线程的缺点

以下是用户级线程的不同缺点 -

  • 在典型的操作系统中,大多数系统调用都是阻塞的。
  • 多线程应用程序无法利用多处理。

内核级线程

操作系统管理的线程作用于内核,内核是操作系统的核心。

在这种情况下,内核进行线程管理。应用程序区域没有线程管理代码。内核线程由操作系统直接支持。任何应用程序都可以编程为多线程。单个进程支持应用程序中的所有线程。

内核维护整个进程以及进程内各个线程的上下文信息。内核的调度是在线程的基础上完成的。内核在内核空间中执行线程的创建、调度和管理。内核线程的创建和管理速度通常比用户线程慢。内核级线程的示例是 Windows、Solaris。

死的

内核级线程的优点

以下是内核级线程的不同优点 -

  • 内核可以在多个进程上同时调度来自同一进程的多个线程。

  • 如果进程中的一个线程被阻塞,内核可以调度同一进程的另一个线程。

  • 内核例程本身可以是多线程的。

内核级线程的缺点

  • 内核线程的创建和管理速度通常比用户线程慢。

  • 将控制从同一进程中的一个线程转移到另一个线程需要切换到内核的模式。

线程控制块-TCB

线程控制块(TCB)可以定义为操作系统内核中主要包含线程信息的数据结构。TCB 中存储的线程特定信息将突出显示有关每个进程的一些重要信息。

考虑与 TCB 中包含的线程相关的以下几点 -

  • 线程标识- 它是分配给每个新线程的唯一线程 ID (tid)。

  • 线程状态- 它包含与线程状态(运行、可运行、非运行、死亡)相关的信息。

  • 程序计数器(PC) - 它指向线程的当前程序指令。

  • 寄存器集- 它包含分配给它们用于计算的线程寄存器值。

  • 堆栈指针- 它指向进程中线程的堆栈。它包含线程范围内的局部变量。

  • 指向 PCB 的指针- 它包含指向创建该线程的进程的指针。

印刷电路板

进程与线程的关系

在多线程中,进程和线程是两个非常密切相关的术语,它们具有相同的目标,即使计算机能够一次执行多于一件事。一个进程可以包含一个或多个线程,但相反,线程不能包含进程。然而,它们仍然是执行的两个基本单位。执行一系列指令的程序启动进程和线程。

下表显示了进程和线程之间的比较 -

过程 线
进程是重量级的或资源密集型的。 线程是轻量级的,它比进程占用更少的资源。
进程切换需要与操作系统交互。 线程切换不需要与操作系统交互。
在多个处理环境中,每个进程执行相同的代码,但拥有自己的内存和文件资源。 所有线程可以共享同一组打开的文件、子进程。
如果一个进程被阻塞,那么在第一个进程被解除阻塞之前,其他进程都不能执行。 当一个线程被阻塞并等待时,同一任务中的第二个线程可以运行。
不使用线程的多个进程会使用更多资源。 多线程进程使用更少的资源。
在多个进程中,每个进程独立于其他进程运行。 一个线程可以读取、写入或更改另一线程的数据。
如果父进程发生任何变化,则不会影响子进程。 如果主线程发生任何变化,则可能会影响该进程的其他线程的Behave。
为了与兄弟进程通信,进程必须使用进程间通信。 线程可以直接与该进程的其他线程通信。

多线程的概念

正如我们之前讨论的,多线程是 CPU 通过同时执行多个线程来管理操作系统使用的能力。多线程的主要思想是通过将一个进程划分为多个线程来实现并行性。更简单地说,多线程就是利用线程的概念来实现多任务的方式。

可以借助以下示例来理解多线程的概念。

例子

假设我们正在运行一个进程。该过程可能是打开 MS Word 来写东西。在这个过程中,一个线程将被分配打开MS字,而另一个线程将被要求写入。现在,假设如果我们想要编辑某些内容,则需要另一个线程来执行编辑任务等等。

下图帮助我们理解内存中如何存在多个线程 -

多线程

从上图中我们可以看到,一个进程中可以存在多个线程,其中每个线程都包含自己的寄存器组和局部变量。除此之外,进程中的所有线程共享全局变量。

多线程的优点

现在让我们看看多线程的一些优点。优点如下:

  • 通信速度- 多线程提高了计算速度,因为每个核心或处理器同时处理单独的线程。

  • 程序保持响应- 它允许程序保持响应,因为一个线程等待输入,另一个线程同时运行 GUI。

  • 访问全局变量- 在多线程中,特定进程的所有线程都可以访问全局变量,如果全局变量有任何更改,那么其他线程也可以看到它。

  • 资源利用- 每个程序中运行多个线程可以更好地利用CPU,并且CPU的空闲时间变得更少。

  • 数据共享- 每个线程不需要额外的空间,因为程序中的线程可以共享相同的数据。

多线程的缺点

现在让我们看看多线程的一些缺点。缺点如下:

  • 不适合单处理器系统- 与多处理器系统上的性能相比,多线程很难在单处理器系统上实现计算速度方面的性能。

  • 安全问题- 正如我们所知,程序中的所有线程共享相同的数据,因此始终存在安全问题,因为任何未知的线程都可以更改数据。

  • 复杂性增加- 多线程会增加程序的复杂性,调试变得困难。

  • 导致死锁状态- 多线程可能导致程序面临达到死锁状态的潜在风险。

  • 需要同步- 需要同步以避免互斥。这会导致更多的内存和 CPU 利用率。

线程的实现

在本章中,我们将学习如何在Python中实现线程。

用于线程实现的Python模块

Python 线程有时被称为轻量级进程,因为线程占用的内存比进程少得多。线程允许同时执行多个任务。在 Python 中,我们有以下两个模块在程序中实现线程 -

  • <_thread>模块

  • <线程>模块

这两个模块之间的主要区别在于<_thread>模块将线程视为函数,而<threading>模块将每个线程视为对象并以面向对象的方式实现它。此外,<_thread>模块在低级线程中有效,并且功能比<threading>模块少。

<_thread> 模块

在Python的早期版本中,我们有<thread>模块,但它在很长一段时间内被认为是“已弃用”的。我们鼓励用户改用<threading>模块。因此,在Python 3中,“thread”模块不再可用。为了在 Python3 中向后不兼容,它已被重命名为“ <_thread> ”。

为了借助<_thread>模块生成新线程,我们需要调用它的start_new_thread方法。可以借助以下语法来理解此方法的工作原理 -

_thread.start_new_thread ( function, args[, kwargs] )

这里 -

  • args是参数的元组

  • kwargs是关键字参数的可选字典

如果我们想在不传递参数的情况下调用函数,那么我们需要在args中使用参数的空元组。

此方法调用立即返回,子线程启动,并使用传递的参数列表(如果有)调用函数。当函数返回时,线程终止。

例子

以下是使用<_thread>模块生成新线程的示例。我们在这里使用 start_new_thread() 方法。

import _thread
import time

def print_time( threadName, delay):
   count = 0
   while count < 5:
      time.sleep(delay)
      count += 1
      print ("%s: %s" % ( threadName, time.ctime(time.time()) ))

try:
   _thread.start_new_thread( print_time, ("Thread-1", 2, ) )
   _thread.start_new_thread( print_time, ("Thread-2", 4, ) )
except:
   print ("Error: unable to start thread")
while 1:
   pass

输出

以下输出将帮助我们在<_thread>模块的帮助下理解新线程的生成。

Thread-1: Mon Apr 23 10:03:33 2018
Thread-2: Mon Apr 23 10:03:35 2018
Thread-1: Mon Apr 23 10:03:35 2018
Thread-1: Mon Apr 23 10:03:37 2018
Thread-2: Mon Apr 23 10:03:39 2018
Thread-1: Mon Apr 23 10:03:39 2018
Thread-1: Mon Apr 23 10:03:41 2018
Thread-2: Mon Apr 23 10:03:43 2018
Thread-2: Mon Apr 23 10:03:47 2018
Thread-2: Mon Apr 23 10:03:51 2018

<线程>模块

<threading>模块以面向对象的方式实现,并将每个线程视为一个对象。因此,它为线程提供了比 <_thread> 模块更强大、更高级的支持。该模块包含在 Python 2.4 中。

<threading> 模块中的其他方法

<threading>模块包含<_thread>模块的所有方法,但它还提供其他方法附加方法如下 -

  • threading.activeCount() - 此方法返回活动线程对象的数量

  • threading.currentThread() - 此方法返回调用者线程控制中的线程对象的数量。

  • threading.enumerate() - 此方法返回当前活动的所有线程对象的列表。

  • 为了实现线程,<threading>模块具有Thread类,它提供以下方法 -

    • run() - run() 方法是线程的入口点。

    • start() - start() 方法通过调用 run 方法启动线程。

    • join([time]) - join() 等待线程终止。

    • isAlive() - isAlive() 方法检查线程是否仍在执行。

    • getName() - getName() 方法返回线程的名称。

    • setName() - setName() 方法设置线程的名称。

如何使用<threading>模块创建线程?

在本节中,我们将学习如何使用<threading>模块创建线程。按照以下步骤使用 <threading> 模块创建一个新线程 -

  • 步骤 1 - 在此步骤中,我们需要定义Thread类的新子类。

  • 步骤 2 - 然后为了添加额外的参数,我们需要重写__init__(self [,args])方法。

  • 步骤 3 - 在这一步中,我们需要重写 run(self [,args]) 方法来实现线程启动时应该执行的操作。

  • 现在,创建新的Thread子类后,我们可以创建它的实例,然后通过调用start()启动一个新线程,start() 又调用run()方法。

例子

考虑此示例以了解如何使用<threading>模块生成新线程。

import threading
import time
exitFlag = 0

class myThread (threading.Thread):
   def __init__(self, threadID, name, counter):
      threading.Thread.__init__(self)
      self.threadID = threadID
      self.name = name
      self.counter = counter
   def run(self):
      print ("Starting " + self.name)
      print_time(self.name, self.counter, 5)
      print ("Exiting " + self.name)
def print_time(threadName, delay, counter):
   while counter:
      if exitFlag:
         threadName.exit()
      time.sleep(delay)
      print ("%s: %s" % (threadName, time.ctime(time.time())))
      counter -= 1

thread1 = myThread(1, "Thread-1", 1)
thread2 = myThread(2, "Thread-2", 2)

thread1.start()
thread2.start()
thread1.join()
thread2.join()
print ("Exiting Main Thread")
Starting Thread-1
Starting Thread-2

输出

现在,考虑以下输出 -

Thread-1: Mon Apr 23 10:52:09 2018
Thread-1: Mon Apr 23 10:52:10 2018
Thread-2: Mon Apr 23 10:52:10 2018
Thread-1: Mon Apr 23 10:52:11 2018
Thread-1: Mon Apr 23 10:52:12 2018
Thread-2: Mon Apr 23 10:52:12 2018
Thread-1: Mon Apr 23 10:52:13 2018
Exiting Thread-1
Thread-2: Mon Apr 23 10:52:14 2018
Thread-2: Mon Apr 23 10:52:16 2018
Thread-2: Mon Apr 23 10:52:18 2018
Exiting Thread-2
Exiting Main Thread

各种线程状态的Python程序

线程有五种状态——新建、可运行、运行、等待和死亡。在这五种状态中,我们将主要关注三种状态——运行、等待和死亡。线程在运行状态获取资源,在等待状态等待资源;如果执行并获取资源,则资源的最终释放处于死亡状态。

下面的Python程序在start()、sleep()和join()方法的帮助下将分别显示线程如何进入运行、等待和死亡状态。

步骤 1 - 导入必要的模块,<threading> 和 <time>

import threading
import time

步骤 2 - 定义一个函数,在创建线程时将调用该函数。

def thread_states():
   print("Thread entered in running state")

步骤 3 - 我们使用 time 模块的 sleep() 方法让我们的线程等待 2 秒。

time.sleep(2)

步骤 4 - 现在,我们创建一个名为 T1 的线程,它采用上面定义的函数的参数。

T1 = threading.Thread(target=thread_states)

步骤 5 - 现在,在 start() 函数的帮助下,我们可以启动我们的线程。它将产生我们在定义函数时设置的消息。

T1.start()
Thread entered in running state

步骤 6 - 现在,我们终于可以在线程执行完成后使用 join() 方法终止线程。

T1.join()

在Python中启动一个线程

在Python中,我们可以通过不同的方式启动一个新线程,但其中最简单的一种是将其定义为单个函数。定义函数后,我们可以将其作为新threading.Thread对象的目标传递,依此类推。执行以下 Python 代码以了解该函数的工作原理 -

import threading
import time
import random
def Thread_execution(i):
   print("Execution of Thread {} started\n".format(i))
   sleepTime = random.randint(1,4)
   time.sleep(sleepTime)
   print("Execution of Thread {} finished".format(i))
for i in range(4):
   thread = threading.Thread(target=Thread_execution, args=(i,))
   thread.start()
   print("Active Threads:" , threading.enumerate())

输出

Execution of Thread 0 started
Active Threads:
   [<_MainThread(MainThread, started 6040)>,
      <HistorySavingThread(IPythonHistorySavingThread, started 5968)>,
      <Thread(Thread-3576, started 3932)>]

Execution of Thread 1 started
Active Threads:
   [<_MainThread(MainThread, started 6040)>,
      <HistorySavingThread(IPythonHistorySavingThread, started 5968)>,
      <Thread(Thread-3576, started 3932)>,
      <Thread(Thread-3577, started 3080)>]

Execution of Thread 2 started
Active Threads:
   [<_MainThread(MainThread, started 6040)>,
      <HistorySavingThread(IPythonHistorySavingThread, started 5968)>,
      <Thread(Thread-3576, started 3932)>,
      <Thread(Thread-3577, started 3080)>,
      <Thread(Thread-3578, started 2268)>]

Execution of Thread 3 started
Active Threads:
   [<_MainThread(MainThread, started 6040)>,
      <HistorySavingThread(IPythonHistorySavingThread, started 5968)>,
      <Thread(Thread-3576, started 3932)>,
      <Thread(Thread-3577, started 3080)>,
      <Thread(Thread-3578, started 2268)>,
      <Thread(Thread-3579, started 4520)>]
Execution of Thread 0 finished
Execution of Thread 1 finished
Execution of Thread 2 finished
Execution of Thread 3 finished

Python 中的守护线程

在Python中实现守护线程之前,我们需要了解守护线程及其用法。从计算的角度来看,守护进程是一个后台进程,处理各种服务的请求,例如数据发送、文件传输等。如果不再需要,它就会处于Hibernate状态。也可以借助非守护线程来完成相同的任务。但是,在这种情况下,主线程必须手动跟踪非守护线程。另一方面,如果我们使用守护线程,那么主线程可以完全忘记这一点,并且当主线程退出时它将被杀死。关于守护线程的另一个重要点是,我们可以选择仅将它们用于非必要的任务,如果任务未完成或在其间被杀死,这些任务不会影响我们。以下是 python 中守护线程的实现 -

import threading
import time

def nondaemonThread():
   print("starting my thread")
   time.sleep(8)
   print("ending my thread")
def daemonThread():
   while True:
   print("Hello")
   time.sleep(2)
if __name__ == '__main__':
   nondaemonThread = threading.Thread(target = nondaemonThread)
   daemonThread = threading.Thread(target = daemonThread)
   daemonThread.setDaemon(True)
   daemonThread.start()
   nondaemonThread.start()

在上面的代码中,有两个函数,即>nondaemonThread()>daemonThread()。第一个函数打印其状态并在 8 秒后Hibernate,而 deamonThread() 函数则无限期地每 2 秒打印一次 Hello。我们可以借助以下输出来理解非守护线程和守护线程之间的区别 -

Hello

starting my thread
Hello
Hello
Hello
Hello
ending my thread
Hello
Hello
Hello
Hello
Hello

同步线程

线程同步可以定义为一种方法,借助该方法我们可以确保两个或多个并发线程不会同时访问称为临界区的程序段。另一方面,我们知道临界区是程序中访问共享资源的部分。因此,我们可以说同步是确保两个或多个线程不会通过同时访问资源而相互交互的过程。下图显示了四个线程试图同时访问程序的关键部分。

同步中

为了更清楚地说明,假设有两个或多个线程尝试同时将对象添加到列表中。这一Behave无法成功结束,因为它要么会删除一个或所有对象,要么会完全破坏列表的状态。这里同步的作用是一次只有一个线程可以访问该列表。

线程同步问题

我们在实现并发编程或应用同步原语时可能会遇到问题。在本节中,我们将讨论两个主要问题。问题是 -

  • 僵局
  • 比赛条件

比赛条件

这是并发编程中的主要问题之一。对共享资源的并发访问可能会导致竞争状况。竞争条件可以定义为当两个或多个线程可以访问共享数据然后尝试同时更改其值时发生的情况。因此,变量的值可能是不可预测的,并且根据进程的上下文切换的时间而变化。

例子

考虑这个例子来理解竞争条件的概念 -

步骤 1 - 在这一步中,我们需要导入线程模块 -

import threading

步骤 2 - 现在,定义一个全局变量,例如 x,其值为 0 -

x = 0

步骤3 - 现在,我们需要定义increment_global()函数,它将在这个全局函数x中加1 -

def increment_global():

   global x
   x += 1

步骤4 - 在这一步中,我们将定义taskofThread()函数,它将调用increment_global()函数指定的次数;对于我们的例子,它是 50000 次 -

def taskofThread():

   for _ in range(50000):
      increment_global()

步骤 5 - 现在,定义创建线程 t1 和 t2 的 main() 函数。两者都将在 start() 函数的帮助下启动,并在 join() 函数的帮助下等待完成工作。

def main():
   global x
   x = 0
   
   t1 = threading.Thread(target= taskofThread)
   t2 = threading.Thread(target= taskofThread)

   t1.start()
   t2.start()

   t1.join()
   t2.join()

步骤 6 - 现在,我们需要给出我们想要调用 main() 函数的迭代次数的范围。在这里,我们调用了 5 次。

if __name__ == "__main__":
   for i in range(5):
      main()
      print("x = {1} after Iteration {0}".format(i,x))

在下面显示的输出中,我们可以看到竞争条件的影响,因为每次迭代后 x 的值预计为 100000。但是,该值存在很多变化。这是由于线程并发访问共享全局变量x造成的。

输出

x = 100000 after Iteration 0
x = 54034 after Iteration 1
x = 80230 after Iteration 2
x = 93602 after Iteration 3
x = 93289 after Iteration 4

使用锁处理竞争条件

正如我们在上面的程序中看到的竞争条件的影响,我们需要一个同步工具,它可以处理多个线程之间的竞争条件。在Python中,<threading>模块提供了Lock类来处理竞争条件。此外,Lock类提供了不同的方法,借助这些方法我们可以处理多个线程之间的竞争条件。这些方法描述如下 -

acquire() 方法

该方法用于获取锁,即阻塞锁。锁可以是阻塞的或非阻塞的,具体取决于以下 true 或 false 值 -

  • 将值设置为 True - 如果使用 True(默认参数)调用 acquire() 方法,则线程执行将被阻止,直到锁解锁。

  • 将值设置为 False - 如果使用 False(不是默认参数)调用 acquire() 方法,则线程执行不会被阻止,直到将其设置为 true,即直到它被锁定。

释放()方法

该方法用于释放锁。以下是与此方法相关的一些重要任务 -

  • 如果锁被锁定,则release()方法将解锁它。它的作用是,如果多个线程被阻塞并等待锁解锁,则只允许一个线程继续执行。

  • 如果锁已经解锁,它将引发ThreadError 。

现在,我们可以用锁类及其方法重写上面的程序来避免竞争条件。我们需要使用锁参数定义taskofThread()方法,然后需要使用acquire()和release()方法来阻塞和非阻塞锁以避免竞争条件。

例子

以下是 python 程序的示例,用于理解处理竞争条件的锁的概念 -

import threading

x = 0

def increment_global():

   global x
   x += 1

def taskofThread(lock):

   for _ in range(50000):
      lock.acquire()
      increment_global()
      lock.release()

def main():
   global x
   x = 0

   lock = threading.Lock()
   t1 = threading.Thread(target = taskofThread, args = (lock,))
   t2 = threading.Thread(target = taskofThread, args = (lock,))

   t1.start()
   t2.start()

   t1.join()
   t2.join()

if __name__ == "__main__":
   for i in range(5):
      main()
      print("x = {1} after Iteration {0}".format(i,x))

以下输出表明竞争条件的影响被忽略;因为每次迭代之后 x 的值现在为 100000,这符合该程序的预期。

输出

x = 100000 after Iteration 0
x = 100000 after Iteration 1
x = 100000 after Iteration 2
x = 100000 after Iteration 3
x = 100000 after Iteration 4

死锁 - 哲学家就餐问题

死锁是设计并发系统时可能面临的一个棘手问题。我们可以借助哲学家就餐问题来说明这个问题,如下 -

Edsger Dijkstra 最初介绍了哲学家就餐问题,这是并发系统最大问题之一(称为死锁)的著名例证之一。

在这个问题中,有五位著名的哲学家坐在圆桌旁,吃着碗里的食物。有五把叉子可供五位哲学家用来吃饭。然而,哲学家们决定同时使用两把叉子来吃食物。

现在,哲学家有两个主要条件。首先,每个哲学家都可以处于进食状态或思考状态,其次,他们必须首先获得两把叉子,即左叉和右叉。当五位哲学家同时拿起左边的叉子时,问题就出现了。现在他们都在等待合适的叉子被释放,但他们永远不会放弃他们的叉子,直到他们吃完食物,而合适的叉子永远不会可用。于是,餐桌上就会出现僵局。

并发系统中的死锁

现在,如果我们看到,同样的问题也可能出现在我们的并发系统中。上面例子中的分叉就是系统资源,每个哲学家都可以代表一个进程,该进程正在竞争获取资源。

用Python程序解决

这个问题的解决方案可以通过将哲学家分为两种类型来找到:贪婪的哲学家慷慨的哲学家。主要是贪婪的哲学家会尝试拿起左边的叉子并等待它在那里。然后,他会等待合适的叉子出现,拿起它,吃,然后放下。另一方面,一个慷慨的哲学家会尝试拿起左边的叉子,如果它不在那里,他会等待一段时间后再次尝试。如果他们拿到了左边的叉子,那么他们就会尝试拿到右边的叉子。如果他们也能拿到正确的叉子,那么他们就会吃东西并释放两把叉子。然而,如果他们拿不到右边的叉子,那么他们就会释放左边的叉子。

例子

以下 Python 程序将帮助我们找到哲学家就餐问题的解决方案 -

import threading
import random
import time

class DiningPhilosopher(threading.Thread):

   running = True

   def __init__(self, xname, Leftfork, Rightfork):
   threading.Thread.__init__(self)
   self.name = xname
   self.Leftfork = Leftfork
   self.Rightfork = Rightfork

   def run(self):
   while(self.running):
      time.sleep( random.uniform(3,13))
      print ('%s is hungry.' % self.name)
      self.dine()

   def dine(self):
   fork1, fork2 = self.Leftfork, self.Rightfork

   while self.running:
      fork1.acquire(True)
      locked = fork2.acquire(False)
	  if locked: break
      fork1.release()
      print ('%s swaps forks' % self.name)
      fork1, fork2 = fork2, fork1
   else:
      return

   self.dining()
   fork2.release()
   fork1.release()

   def dining(self):
   print ('%s starts eating '% self.name)
   time.sleep(random.uniform(1,10))
   print ('%s finishes eating and now thinking.' % self.name)

def Dining_Philosophers():
   forks = [threading.Lock() for n in range(5)]
   philosopherNames = ('1st','2nd','3rd','4th', '5th')

   philosophers= [DiningPhilosopher(philosopherNames[i], forks[i%5], forks[(i+1)%5]) \
      for i in range(5)]

   random.seed()
   DiningPhilosopher.running = True
   for p in philosophers: p.start()
   time.sleep(30)
   DiningPhilosopher.running = False
   print (" It is finishing.")

Dining_Philosophers()

上面的程序使用了贪婪和慷慨哲学家的概念。该程序还使用了<threading>模块的Lock类的acquire()release()方法。我们可以在以下输出中看到解决方案 -

输出

4th is hungry.
4th starts eating
1st is hungry.
1st starts eating
2nd is hungry.
5th is hungry.
3rd is hungry.
1st finishes eating and now thinking.3rd swaps forks
2nd starts eating
4th finishes eating and now thinking.
3rd swaps forks5th starts eating
5th finishes eating and now thinking.
4th is hungry.
4th starts eating
2nd finishes eating and now thinking.
3rd swaps forks
1st is hungry.
1st starts eating
4th finishes eating and now thinking.
3rd starts eating
5th is hungry.
5th swaps forks
1st finishes eating and now thinking.
5th starts eating
2nd is hungry.
2nd swaps forks
4th is hungry.
5th finishes eating and now thinking.
3rd finishes eating and now thinking.
2nd starts eating 4th starts eating
It is finishing.

线程互通

在现实生活中,如果一群人正在执行一项共同任务,那么他们之间应该进行沟通才能正确完成任务。同样的类比也适用于线程。在编程中,为了减少处理器的理想时间,我们创建多个线程并为每个线程分配不同的子任务。因此,必须有一个通信设施,并且他们应该相互交互以同步方式完成工作。

考虑以下与线程互通相关的要点 -

  • 没有性能增益- 如果我们无法在线程和进程之间实现适当的通信,那么并发和并行性的性能增益是没有用的。

  • 正确完成任务- 如果线程之间没有适当的相互通信机制,则无法正确完成分配的任务。

  • 比进程间通信更高效- 线程间通信比进程间通信更高效且易于使用,因为进程内的所有线程共享相同的地址空间,并且不需要使用共享内存。

用于线程安全通信的Python数据结构

多线程代码会出现将信息从一个线程传递到另一个线程的问题。标准通信原语不能解决这个问题。因此,我们需要实现自己的复合对象,以便在线程之间共享对象,从而使通信线程安全。以下是一些数据结构,它们在进行一些更改后提供线程安全通信 -

为了以线程安全的方式使用集合数据结构,我们需要扩展集合类来实现我们自己的锁定机制。

例子

这是扩展类的 Python 示例 -

class extend_class(set):
   def __init__(self, *args, **kwargs):
      self._lock = Lock()
      super(extend_class, self).__init__(*args, **kwargs)

   def add(self, elem):
      self._lock.acquire()
	  try:
      super(extend_class, self).add(elem)
      finally:
      self._lock.release()
  
   def delete(self, elem):
      self._lock.acquire()
      try:
      super(extend_class, self).delete(elem)
      finally:
      self._lock.release()

在上面的例子中,一个名为extend_clas的类对象