>>> from env_helper import info; info()
页面更新时间: 2024-01-13 11:51:14
运行环境:
    Linux发行版本: Debian GNU/Linux 12 (bookworm)
    操作系统内核: Linux-6.1.0-16-amd64-x86_64-with-glibc2.36
    Python版本: 3.11.2

5.6. 多线程

为了引入多线程的概念,让我们来看一个例子。假设你想 安排一些代码,在一段延迟后或在特定时间运行。可以在 程序启动时添加如下代码:

import time, datetime

startTime = datetime.datetime(2029, 10, 31, 0, 0, 0)
while datetime.datetime.now() < startTime:
    time.sleep(1)

print('Program now starting on Halloween 2029')

这段代码指定2029年10月31日作为开始时间,不断调用 time.sleep(1) , 直到开始时间。在等待 time.sleep() 的循环调用完成时,程序不能做任何事情,它只是坐在那里, 直到2029年万圣节。这是因为 Python 程序在默认情况下,只有一个执行线程。

要理解什么是执行线程,就要回忆关于控制流的讨论,当时你想象程序的执行就像把手指放在一行代码上, 然后移动到下一行,或是流控制语句让它去的任何地方。 单线程程序只有一个“手指”。但多线程的程序有多个“手指”。 每个“手指”仍然移动到控制流语句定义的下一行代码,但这些“手指”可以在程序的不同地方, 同时执行不同的代码行)。

不必让所有的代码等待,直到 time.sleep() 函数完成, 你可以使用 Python 的 threading 模块,在单独的线程中执行延迟或安排的代码。 这个单独的线程将因为 time.sleep() 调用而暂停。 同时,程序可以在原来的线程中做其他工作。

5.6.1. 在 Python 中使用多线程

要得到单独的线程,首先要调用 threading.Thread() 函数,生成一个 Thread 对象。 在下面的实例中,定义了一个函数 takeANap() , 希望用于新线程中。 为了创建一个 Thread 对象,我们调用 threading.Thread() , 并传入关键字参数 target = takeANap 。 这意味着我们要在新线程中调用的函数是 takeANap() 。 请注意, 关键字参数是 target = takeANap ,而不是 target = takeANap() 。 这是因为你想将 takeANap() 函数本身作为参数,而不是调用 takeANap() ,并传入它的返回值。

我们将 threading.Thread() 创建的 Thread 对象保存在 threadObj 中,然后调用 threadObj.start() , 创建新的线程,并开始在新线程中执行目标函数。

运行该程序,查看其输出:

>>> import threading, time
>>> print('Start of program.')
>>> def takeANap():
>>>     time.sleep(5)
>>>     print('Wake up!')
>>> threadObj = threading.Thread(target=takeANap)
>>> threadObj.start()
>>>
>>> print('End of program.')
Start of program.
End of program.
Wake up!

这可能有点令人困惑。因为 print('End of program.') 是程序的最后一行,你可能会认为,它应该是最后打印的内容。 Wake up! 在它后面是因为,当 threadObj.start() 被调用时, threadObj 的目标函数运行在一个新的执行线程中。 将它看成是第二根“手指”,出现在 takeANap() 函数开始处。 主线程继续 print('End of program.') 。 同时,新线程已执行了 time.sleep(5) 调用,暂停 5 秒钟。 之后它从5秒钟小睡中醒来,打印了 'Wake up!' , 然后从 takeANap() 函数返回。

按时间顺序, 'Wake up!' 是程序最后打印的内容。但这个不是这么明显的。 实际上不同线程执行没有固定的前后,这个程序可以认为是几乎同时开始两个线程, 而主线程由于 print() 函数调用太快,所以才会马上打印出结果。

进一步再说明一下。

通常,程序在文件中最后一行代码执行后终止(或调用 sys.exit() )。 但 threadDemo.py 有两个线程:

  • 第一个是最初的线程,从程序开始处开始,在 print('End of program.') 后结束。

  • 第二个线程是调用 threadObj.start() 时创建的,始于 takeANap() 函数的开始处,在 takeANap() 返回后结束。

在程序的所有线程终止之前, Python 程序不会终止。 在运行上面代码时,即使最初的线程已经终止,第二个线程仍然执行 time.sleep(5) 调用。

5.6.2. 向线程的目标函数传递参数

如果想在新线程中运行的目标函数有参数,可以将目标函数的参数传入 threading.Thread() 。 例如,假设想在自己的线程中运行以下 print() 调用:

>>> print ('Cats' , 'Dogs','Frogs',sep=' & ')
Cats & Dogs & Frogs

print() 调用有3个常规参数:'Cats''Dogs''Frogs' ,以及一个关键字参数: sep='&' 常规参数可以作为一个列表,传递给 threading.Thread() 中的 args 关键字参数。 关键字参数可以作为一个字典,传递给 threading.Thread() 中的 kwargs关键字参数。

在交互式环境中输入以下代码.

>>> import threading
>>> threadObj = threading.Thread(target=print, args=['Cats', 'Dogs','Frogs'], kwargs={'sep': ' & '})
>>> threadObj.start()
Cats & Dogs & Frogs
Wake up!

为了确保参数 'Cats''Dogs''Trogs' 给新线程中的 print() , 我们将 args=['Cats','DogsVFrogs'] 传入 threading.Thread() 。 为了确保关键字参数 sep='&' 传递给新线程中的 print() , 我们将 kwargs={'sep': ' & '} 传入 threading.Thread()

threadObj.start() 调用将创建一个新线程来调用 print() 函数, 它会传入 'Cats''Dogs''Frogs' ,作为参数,以及 '&' 作为 sep 关键字参数。

下面创建新线程调用 print() 的方法是不正确的:

threadObj = threading.Thread(target=print('Cats', 'Dogs', 'Frogs', sep= ' & '))

这行代码最终会调用 print()函数,将它的返回值( print() 的返回值总是无)作为 target 关键字参数。 它没有传递 print() 函数本身。 如果要向新线程中的函数传递参数, 就使用 threading.Thread() 函数的 argskwargs关键字参数。

5.6.3. 并发问题

可以轻松地创建多个新线程,让它们同时运行。但多线程也可能会导致所谓的并发问题。 如果这些线程同时读写变量,导致互相干扰,就会发生并发问题。并发问题可能很难一致地重现,所以难以调试。

多线程编程本身就是一个广泛的主题,超出了本书的范围。 必须记住的是:为了避免并发问题,绝不让多个线程读取或写入相同的变量。 当创建一个新的 Thread对象时,要确保其目标函数只使用该函数中的局部变量。 这将避免程序中难以调试的并发问题。