首页 / PYTHON / Python多线程编程
Python多线程编程
内容导读
互联网集市收集整理的这篇技术教程文章主要介绍了Python多线程编程,小编现在分享给大家,供广大互联网技能从业者学习和参考。文章包含17443字,纯文字阅读大概需要25分钟。
内容图文
![Python多线程编程](/upload/InfoBanner/zyjiaocheng/840/3d817a298445491b943ea5a4ee7c68df.jpg)
1. 简介/动机
在多线程(multithreaded, MT)编程出现之前,计算机程序的执行是由单个步骤序列组成的,该序列在主机的 CPU 中按照同步顺序执行。无论是任务本身需要按照步骤顺序执行,还是整个程序实际上包含多个子任务,都需要按照这种顺序方式执行。那么,假如这些子任务相互独立,没有因果关系(也就是说,各个子任务的结果并不影响其他子任务的结果),这种做法是不是不符合逻辑呢?要是让这些独立的任务同时运行,会怎么样呢?很明显,这种并行处理方式可以显著地提高整个任务的性能。这就是多线程编程。
多线程编程对于具有如下特点的编程任务而言是非常理想的:本质上是异步的;需要多个并发活动;每个活动的处理顺序可能是不确定的,或者说是随机的、不可预测的。这种编程任务可以被组织或划分成多个执行流,其中每个执行流都有一个指定要完成的任务。根据应用的不同,这些子任务可能需要计算出中间结果,然后合并为最终的输出结果。
计算密集型的任务可以比较容易地划分成多个子任务,然后按顺序执行或按照多线程方式执行。而那种使用单线程处理多个外部输入源的任务就不那么简单了。如果不使用多线程,要实现这种编程任务就需要为串行程序使用一个或多个计时器,并实现一个多路复用方案。
一个串行程序需要从每个 I/O 终端通道来检查用户的输入;然而,有一点非常重要,程序在读取 I/O 终端通道时不能阻塞,因为用户输入的到达时间是不确定的,并且阻塞会妨碍其他 I/O 通道的处理。串行程序必须使用非阻塞 I/O 或拥有计时器的阻塞 I/O(以保证阻塞只是暂时的)。
由于串行程序只有唯一的执行线程,因此它必须兼顾需要执行的多个任务,确保其中的某个任务不会占用过多时间,并对用户的响应时间进行合理的分配。这种任务类型的串行程序的使用,往往造成非常复杂的控制流,难以理解和维护。
使用多线程编程,以及类似 Queue 的共享数据结构(本章后面会讨论的一种多线程队列数据结构),这个编程任务可以规划成几个执行特定函数的线程。
- UserRequestThread:负责读取客户端输入,该输入可能来自 I/O 通道。程序将创建多个线程,每个客户端一个,客户端的请求将会被放入队列中。
- RequestProcessor:该线程负责从队列中获取请求并进行处理,为第 3 个线程提供输出。
- ReplyThread:负责向用户输出,将结果传回给用户(如果是网络应用),或者把数据写到本地文件系统或数据库中。
使用多线程来规划这种编程任务可以降低程序的复杂性,使其实现更加清晰、高效、简洁。每个线程中的逻辑都不复杂,因为它只有一个要完成的特定作业。比如,UserRequestThread 的功能仅仅是读取用户输入,然后把输入数据放到队列里,以供其他线程后续处理。每个线程都有其明确的作业,你只需要设计每类线程去做一件事,并把这件事情做好就可以了。这种特定任务线程的使用与亨利·福特生产汽车的流水线模型有些许相似。
2. 线程和进程
2.1 进程
计算机程序只是存储在磁盘上的可执行二进制(或其他类型)文件。只有把它们加载到内存中并被操作系统调用,才拥有其生命期。 进程(有时称为重量级进程)则是一个执行中的程序。每个进程都拥有自己的地址空间、内存、数据栈以及其他用于跟踪执行的辅助数据。操作系统管理其上所有进程的执行,并为这些进程合理地分配时间。进程也可以通过派生(fork 或 spawn)新的进程来执行其他任务,不过因为每个新进程也都拥有自己的内存和数据栈等,所以只能采用进程间通信(IPC)的方式共享信息。
2.2 线程
线程(有时候称为轻量级进程)与进程类似,不过它们是在同一个进程下执行的,并共享相同的上下文。可以将它们认为是在一个主进程或“主线程”中并行运行的一些“迷你进程”。
线程包括开始、执行顺序和结束三部分。它有一个指令指针,用于记录当前运行的上下文。当其他线程运行时,它可以被抢占(中断)和临时挂起(也称为睡眠) ——这种做法叫做让步(yielding)。
一个进程中的各个线程与主线程共享同一片数据空间,因此相比于独立的进程而言,线程间的信息共享和通信更加容易。线程一般是以并发方式执行的,正是由于这种并行和数据共享机制,使得多任务间的协作成为可能。当然,在单核 CPU 系统中,因为真正的并发是不可能的,所以线程的执行实际上是这样规划的:每个线程运行一小会儿,然后让步给其他线程(再次排队等待更多的 CPU 时间)。在整个进程的执行过程中,每个线程执行它自己特定的任务,在必要时和其他线程进行结果通信。
当然,这种共享并不是没有风险的。如果两个或多个线程访问同一片数据,由于数据访问顺序不同,可能导致结果不一致。这种情况通常称为竞态条件(race condition)。幸运的是,大多数线程库都有一些同步原语,以允许线程管理器控制执行和访问。
另一个需要注意的问题是,线程无法给予公平的执行时间。这是因为一些函数会在完成前保持阻塞状态,如果没有专门为多线程情况进行修改,会导致 CPU 的时间分配向这些贪婪的函数倾斜。
3. 线程和 Python
本节将讨论在如何在 Python 中使用线程,其中包括全局解释器锁对线程的限制和一个快速的演示脚本。
3.1 全局解释器锁
Python 代码的执行是由Python虚拟机
(又名解释器主循环)进行控制的。 Python 在设计时是这样考虑的,在主循环中同时只能有一个控制线程在执行;就像单核 CPU 系统中的多进程一样,内存中可以有许多程序,但是在任意给定时刻只能有一个程序在运行。同理,尽管 Python 解释器中可以运行多个线程,但是在任意给定时刻只有一个线程会被解释器执行。
对 Python 虚拟机的访问是由全局解释器锁(GIL)控制的。这个锁就是用来保证同时只能有一个线程运行的。在多线程环境中, Python 虚拟机将按照下面所述的方式执行:
- 设置 GIL。
- 切换进一个线程去运行。
- 执行下面操作之一。
a. 指定数量的字节码指令。
b. 线程主动让出控制权(可以调用time.sleep(0)
来完成)。 - 把线程设置回睡眠状态(切换出线程)。
- 解锁 GIL。
- 重复上述步骤。
当调用外部代码(即,任意 C/C++扩展的内置函数)时, GIL 会保持锁定,直至函数执行结束(因为在这期间没有 Python 字节码计数)。编写扩展函数的程序员有能力解锁 GIL,然而,作为 Python 开发者, 你并不需要担心 Python 代码会在这些情况下被锁住。例如,对于任意面向 I/O 的 Python 例程(调用了内置的操作系统 C 代码的那种),GIL 会在 I/O 调用前被释放,以允许其他线程在 I/O 执行的时候运行。而对于那些没有太多 I/O 操作的代码而言,更倾向于在该线程整个时间片内始终占有处理器(和 GIL)。换句话说就是, I/O 密集型的 Python 程序要比计算密集型的代码能够更好地利用多线程环境。
如果你对源代码、解释器主循环和 GIL 感兴趣,可以看看Python/ceval.c
文件。
3.2 退出线程
当一个线程完成函数的执行时,它就会退出。另外,还可以通过调用诸如thread.exit()
之类的退出函数,或者 sys.exit()
之类的退出 Python 进程的标准方法,亦或者抛出SystemExit
异常,来使线程退出。不过,你不能直接“终止”一个线程。
下一节将会详细讨论两个与线程相关的 Python 模块,不过在这两个模块中,不建议使用thread模块。给出这个建议有很多原因,其中最明显的一个原因是模块thread在主线程退出之后,所有其他线程都会在没有清理的情况下直接退出。而另一个模块 threading 会确保在所有“重要的”子线程退出前,保持整个进程的存活(对于“重要的”这个含义的说明,请阅读下面的核心提示:“避免使用 thread 模块”)。
而主线程应该做一个好的管理者,负责了解每个单独的线程需要执行什么,每个派生的线程需要哪些数据或参数,这些线程执行完成后会提供什么结果。这样,主线程就可以收集每个线程的结果,然后汇总成一个有意义的最终结果。
3.3 在 Python 中使用线程
Python 虽然支持多线程编程,但是还需要取决于它所运行的操作系统。如下操作系统是支持多线程的:绝大多数类 UNIX 平台(如 Linux、 Solaris、 Mac OS X、 *BSD 等),以及Windows 平台。 Python 使用兼容 POSIX 的线程,也就是众所周知的 pthread。
默认情况下,从源码构建的 Python(2.0 及以上版本)或者 Win32 二进制安装的 Python,线程支持是已经启用的。要确定你的解释器是否支持线程,只需要从交互式解释器中尝试导入 thread 模块即可,如下所示(如果线程是可用的,则不会产生错误)。
>>> import thread
>>>
如果你的 Python 解释器没有将线程支持编译进去,模块导入将会失败。
>>> import thread
Traceback (innermost last):
File "<stdin>", line 1, in ?
ImportError: No module named thread
这种情况下,你可能需要重新编译你的 Python 解释器才能够使用线程。一般可以在调用configure 脚本的时候使用–with-thread 选项。查阅你所使用的发行版本的 README 文件,来获取如何在你的系统中编译线程支持的 Python 的指定指令。
注意:Python3.x 线程中 thread 模块已被废弃,用户可以使用 threading 模块代替。在 Python3.x 中不能再使用"thread" 模块。为了兼容性,Python3.x 将 thread 重命名为 “_thread”。
In [1]: import thread
---------------------------------------------------------------------------
ModuleNotFoundError Traceback (most recent call last)
<ipython-input-1-e75c663b2a08> in <module>
----> 1 import thread
ModuleNotFoundError: No module named 'thread'
In [2]: import threading
In [3]: import _thread
In [4]:
3.4 不使用线程的情况
在第一个例子中, 我们将使用 time.sleep()函数来演示线程是如何工作的。 time.sleep()函数需要一个浮点型的参数,然后以这个给定的秒数进行“睡眠”,也就是说,程序的执行会暂时停止指定的时间。
创建两个时间循环:一个睡眠 4 秒(loop0());另一个睡眠 2 秒(loop1())(这里使用“loop0”和“loop1”作为函数名,暗示我们最终会有一个循环序列)。如果在一个单进程或单线程的程序中顺序执行 loop0()和 loop1(),就会像示例 4-1 中的 onethr.py 一样,整个执行时间至少会达到 6 秒钟。而在启动 loop0()和 loop1()以及执行其他代码时,也有可能存在 1 秒的开销,使得整个时间达到 7 秒。
示例1 使用单线程执行循环(
onethr.py
)
该脚本在一个单线程程序里连续执行两个循环。一个循环必须在另一个开始前完成。总共消耗的时间是每个循环所用时间之和。
from time import sleep, ctime
def loop0():
print('start loop 0 at:', ctime())
sleep(4)
print('loop 0 done at:', ctime())
def loop1():
print('start loop 1 at:', ctime())
sleep(2)
print('loop 1 done at:', ctime())
def main():
print('starting at:', ctime())
loop0()
loop1()
print('all DONE at:', ctime())
if __name__ == '__main__':
main()
可以通过执行onethr.py
来验证这一点,下面是输出结果:
$python onethr.py
starting at: Thu Feb 28 10:54:40 2019
start loop 0 at: Thu Feb 28 10:54:40 2019
loop 0 done at: Thu Feb 28 10:54:44 2019
start loop 1 at: Thu Feb 28 10:54:44 2019
loop 1 done at: Thu Feb 28 10:54:46 2019
all DONE at: Thu Feb 28 10:54:46 2019
现在,假设 loop0()和 loop1()中的操作不是睡眠,而是执行独立计算操作的函数,所有结果汇总成一个最终结果。那么,让它们并行执行来减少总的执行时间是不是有用的呢?这就是现在要介绍的多线程编程的前提。
3.5 Python 的 threading 模块
Python 提供了多个模块来支持多线程编程,包括 thread、 threading 和 Queue 模块等。程序是可以使用 thread 和 threading 模块来创建与管理线程。 thread 模块提供了基本的线程和锁定支持;而 threading 模块提供了更高级别、功能更全面的线程管理。使用 Queue 模块,用户可以创建一个队列数据结构,用于在多线程之间进行共享。我们将分别来查看这几个模块,并给出几个例子和中等规模的应用。
核心提示:避免使用 thread 模块
??推荐使用更高级别的 threading 模块,而不使用 thread 模块有很多原因。 threading 模块更加先进,有更好的线程支持,并且 thread 模块中的一些属性会和 threading 模块有冲突。另一个原因是低级别的 thread 模块拥有的同步原语很少(实际上只有一个),而 threading模块则有很多。
??不过,出于对 Python 和线程学习的兴趣,我们将给出使用 thread 模块的一些代码。给出这些代码只是出于学习目的,希望它能够让你更好地领悟为什么应该避免使用thread 模块。我们还将展示如何使用更加合适的工具,如 threading 和 Queue 模块中的那些方法。
??避免使用 thread 模块的另一个原因是它对于进程何时退出没有控制。当主线程结束时,所有其他线程也都强制结束,不会发出警告或者进行适当的清理。如前所述,至少threading 模块能确保重要的子线程在进程退出前结束。
??我们只建议那些想访问线程的更底层级别的专家使用 thread 模块。为了强调这一点,在 Python3 中该模块被重命名为_thread。你创建的任何多线程应用都应该使用 threading 模块或其他更高级别的模块。
4. thread 模块
让我们先来看看 thread 模块提供了什么。除了派生线程外, thread 模块还提供了基本的同步数据结构,称为锁对象(lock object,也叫原语锁、 简单锁、 互斥锁、 互斥和二进制信号量)。如前所述,这个同步原语和线程管理是密切相关的。
表 4-1 列出了一些常用的线程函数,以及 LockType 锁对象的方法。
thread 模块的核心函数是 start_new_thread()。它的参数包括函数(对象)、函数的参数以及可选的关键字参数。将专门派生新的线程来调用这个函数。
把多线程整合进onethr.py
这个例子中。把对 loop*()函数的调用稍微改变一下,得到示例4-2 中的mtsleepA.py
文件。
表 4-1 thread 模块和锁对象
函数/方法 | 描 述 |
---|---|
thread |
模块的函数 |
start_new_thread(function, args, kwargs=None) |
派生一个新的线程,使用给定的 args 和可选的 kwargs 来执行 function |
allocate_lock() |
分配 LockType 锁对象 |
exit() |
给线程退出指令 |
LockType |
锁对象的方法 |
acquire(wait=None) |
尝试获取锁对象 |
locked() |
如果获取了锁对象则返回 True,否则,返回 False |
release() |
释放锁 |
示例2 使用thread模块(
mtsleepA.py
)
这里执行的循环和onethr.py
是一样的,不过这次使用了thread模块提供的简单多线程机制。两个循环是并发执行的(很明显,短的那个先结束),因此总的运行时间只与最慢的那个线程相关,而不是每个线程运行时间之和。
import _thread as thread
from time import sleep, ctime
def loop0():
print('start loop 0 at:', ctime())
sleep(4)
print('loop 0 done at:', ctime())
def loop1():
print('start loop 1 at:', ctime())
sleep(2)
print('loop 1 done at:', ctime())
def main():
print('starting at:', ctime())
thread.start_new_thread(loop0, ())
thread.start_new_thread(loop1, ())
sleep(6)
print('all DONE at:', ctime())
if __name__ == '__main__':
main()
start_new_thread()
必须包含开始的两个参数,于是即使要执行的函数不需要参数,也需要传递一个空元组。
与之前的代码相比,本程序执行后的输出结果有很大不同。原来需要运行 6~7 秒的时间,而现在的脚本只需要运行 4 秒,也就是最长的循环加上其他所有开销的时间之和。
$python mtsleepA.py
starting at: Thu Feb 28 11:09:56 2019
start loop 0 at: Thu Feb 28 11:09:56 2019
start loop 1 at: Thu Feb 28 11:09:56 2019
loop 1 done at: Thu Feb 28 11:09:58 2019
loop 0 done at: Thu Feb 28 11:10:00 2019
all DONE at: Thu Feb 28 11:10:02 2019
睡眠 4 秒和睡眠 2 秒的代码片段是并发执行的,这样有助于减少整体的运行时间。你甚至可以看到 loop 1 是如何在 loop 0 之前结束的。
这个应用程序中剩下的一个主要区别是增加了一个 sleep(6)调用。为什么必须要这样做呢?这是因为如果我们没有阻止主线程继续执行,它将会继续执行下一条语句,显示“all DONE”然后退出,而 loop0()和 loop1()这两个线程将直接终止。
我们没有写让主线程等待子线程全部完成后再继续的代码,即我们所说的线程需要某种形式的同步。在这个例子中,调用 sleep()来作为同步机制。将其值设定为 6 秒是因为我们知道所有线程(用时 4 秒和 2 秒的)会在主线程计时到 6 秒之前完成。
你可能会想到,肯定会有比在主线程中额外延时 6 秒更好的线程管理方式。由于这个延时,整个程序的运行时间并没有比单线程的版本更快。像这样使用 sleep()来进行线程同步是不可靠的。如果循环有独立且不同的执行时间要怎么办呢?我们可能会过早或过晚退出主线程。这就是引出锁的原因。
再一次修改代码,引入锁,并去除单独的循环函数,修改后的代码为mtsleepB.py
,如示例 4-3 所示。我们可以看到输出结果与mtsleepA.py
相似。唯一的区别是我们不需要再像mtsleepA.py
那样等待额外的时间后才能结束。通过使用锁,我们可以在所有线程全部完成执行后立即退出。其输出结果如下所示。
$ mtsleepB.py
starting at Thu Feb 28 11:38:17 2019
start loop 0 at: Thu Feb 28 11:38:17 2019
start loop 1 at: Thu Feb 28 11:38:17 2019
loop 1 done at: Thu Feb 28 11:38:19 2019
loop 0 done at: Thu Feb 28 11:38:21 2019
all DONE at: Thu Feb 28 11:38:21 2019
那么我们是如何使用锁来完成任务的呢?下面详细分析源代码。
示例 3 使用线程和锁(
mtsleepB.py
)
与mtsleepA.py
中调用 sleep()来挂起主线程不同,锁的使用将更加合理。
import _thread as thread
from time import sleep, ctime
loops = [4, 2]
def loop(nloop, nsec, lock):
print('start loop', nloop, 'at:', ctime())
sleep(nsec)
print('loop', nloop, 'done at:', ctime())
lock.release()
def main():
print('starting at', ctime())
locks = []
nloops = range(len(loops)) # 返回[0, len(loops))范围内的一个可迭代对象(类型是对象),而不是列表类型,所以打印的时候不会打印列表。
for i in nloops:
lock = thread.allocate_lock()
lock.acquire()
locks.append(lock)
for i in nloops:
thread.start_new_thread(loop, (i, loops[i], locks[i]))
for i in nloops:
while locks[i].locked():
pass
print('all DONE at:', ctime())
if __name__ == '__main__':
main()
逐行解释
第 1~6 行
在 UNIX 启动行后,导入了 time 模块的几个熟悉属性以及 thread 模块。我们不再把 4秒和 2 秒硬编码到不同的函数中,而是使用了唯一的 loop()函数,并把这些常量放进列表loops 中。
第 8~12 行
loop()函数代替了之前例子中的 loop*()函数。因此,我们必须在 loop()函数中做一些修改,以便它能使用锁来完成自己的任务。其中最明显的变化是我们需要知道现在处于哪个循环中,以及需要睡眠多久。最后一个新的内容是锁本身。每个线程将被分配一个已获得的锁。当sleep()的时间到了的时候,释放对应的锁,向主线程表明该线程已完成。
第 14~34 行
大部分工作是在 main()中完成的,这里使用了 3 个独立的 for 循环。首先创建一个锁的列表,通过使用 thread.allocate_lock()函数得到锁对象,然后通过 acquire()方法取得(每个锁)。取得锁效果相当于“把锁锁上”。一旦锁被锁上后,就可以把它添加到锁列表 locks 中。下一个循环用于派生线程,每个线程会调用 loop()函数,并传递循环号、睡眠时间以及用于该线程的锁这几个参数。那么为什么我们不在上锁的循环中启动线程呢?这有两个原因:其一,我们想要同步线程,以便“所有的马同时冲出围栏”;其二,获取锁需要花费一点时间。如果线程执行得太快,有可能出现获取锁之前线程就执行结束的情况。
在每个线程执行完成时,它会释放自己的锁对象。最后一个循环只是坐在那里等待(暂停主线程),直到所有锁都被释放之后才会继续执行。因为我们按照顺序检查每个锁,所有可能会被排在循环列表前面但是执行较慢的循环所拖累。这种情况下,大部分时间是在等待最前面的循环。当这种线程的锁被释放时,剩下的锁可能早已被释放(也就是说,对应的线程已经执行完毕)。结果就是主线程会飞快地、没有停顿地完成对剩下锁的检查。最后,你应该知道只有当我们直接调用这个脚本时,最后几行语句才会执行 main()函数。
正如在前面的核心笔记中所提示的,这里使用 thread 模块只是为了介绍多线程编程。多线程应用程序应当使用更高级别的模块,比如下一节将要讨论到的 threading 模块。
5. threading 模块
现在介绍更高级别的 threading 模块。除了 Thread 类以外,该模块还包括许多非常好用
的同步机制。表 4-2 给出了 threading 模块中所有可用对象的列表。
表 4-2 threading 模块的对象
对 象 描 述
Thread 表示一个执行线程的对象
Lock 锁原语对象(和 thread 模块中的锁一样)
RLock 可重入锁对象,使单一线程可以(再次)获得已持有的锁(递归锁)
Condition 条件变量对象,使得一个线程等待另一个线程满足特定的“条件”,比如改变状态或
某个数据值
Event 条件变量的通用版本,任意数量的线程等待某个事件的发生,在该事件发生后所有
线程将被激活
Semaphore 为线程间共享的有限资源提供了一个“计数器”,如果没有可用资源时会被阻塞
BoundedSemaphore 与 Semaphore 相似,不过它不允许超过初始值
Timer 与 Thread 相似,不过它要在运行前等待一段时间
Barrier① 创建一个“障碍”,必须达到指定数量的线程后才可以继续
① Python 3.2 版本中引入。
本节将研究如何使用 Thread 类来实现多线程。由于之前已经介绍过锁的基本概念,因此
这里不会再对锁原语进行介绍。因为 Thread()类同样包含某种同步机制,所以锁原语的显式
使用不再是必需的了。
参考:
Python3入门之线程threading常用方法 https://www.cnblogs.com/chengd/articles/7770898.html
内容总结
以上是互联网集市为您收集整理的Python多线程编程全部内容,希望文章能够帮你解决Python多线程编程所遇到的程序开发问题。 如果觉得互联网集市技术教程内容还不错,欢迎将互联网集市网站推荐给程序员好友。
内容备注
版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 gblab@vip.qq.com 举报,一经查实,本站将立刻删除。
内容手机端
扫描二维码推送至手机访问。