线程:概念和应用(1)
2020-02-14
翻译:老齐
译者注:与本文相关图书推荐:《Python大学实用教程》《跟老齐学Python:轻松入门》
本文将分两部分刊发。
第一部分
Python线程允许程序的不同部分同时运行,并可以简化设计。如果你对Python有一些经验,并且希望使用线程为程序加速,那么本文就是为你准备的!
什么是线程?
线程是一个独立的流,这意味着你的程序可以同时做两件事,但是,对于大多数Python程序,不同的线程实际上并不同时执行,它们只是看起来像是同时执行。
人们很容易认为线程是在程序上运行两个(或更多)不同的处理器,每个处理器同时执行一个独立的任务。这种看法大致正确,线程可能在不同的处理器上运行,但一个处理器一次只能运行一个线程。
要同时运行多个任务,不能用Python的标准方式实现,可以用不同的编程语言,或者多个进程实现,这样做的开发成本就高了。
由于用CPython实现了Python业务,线程可能不会加速所有任务,这是GIL(全称Global Interpreter Lock)的原因,一次只能运行一个Python线程。
如果某项任务需要花费大量时间等待外部事件,那么就可以应用多线程。如果是需要对CPU占用高并且花费很少时间等待外部事件,多线程可能枉费。
对于用Python编写并在标准CPython实现上运行的代码,这是正确的。如果你的线程是用C编写的,那么它们就能够释放GIL、并发运行。如果你在不同的Python实现上运行,也可以查看文档,了解它如何处理线程。
如果你正在运行一个标准的Python程序,只使用Python编写,并且有一个CPU受限的问题,那么你应该用多进程解决此问题。
将程序架构为使用线程也可以提高设计的清晰度。你将在下文中学习的大多数示例不一定会运行得更快,因为它们使用线程。在这些示例中使用线程有助于使设计更清晰、更易于推理。
所以,让我们停止谈论线程并开始使用它!
创建一个线程
现在你已经知道了什么是线程,让我们来学习如何制作线程。Python标准库提供了线程模块threading
,它包含了你将在本文中看到的大部分内容。在这个模块中,Thread
是对线程的封装,提供了简单的实现接口。
要创建一个线程,需要创建Thread
的实例,然后调用它的.start()
方法:
1 | import logging |
如果你查看日志,可以看到在main
部分正在创建和启动线程:
1 | x = threading.Thread(target=thread_function, args=(1,)) |
用函数thread_function()
和arg(1,)
创建一个Thread
实例。在本文中用整数作为线程的名称,threading.get_ident()
可以返回线程的名称,但可读性较差。
thread_function()
函数的作用不大,它只是记录一些日志消息,在这些消息之间加上time.sleep()
。
当你执行此程序时,输出将如下所示:
1 | $ ./single_thread.py |
你会注意到代码的main
部分结束之后,Thread
才结束。后面会揭示这么做的原因。
守护线程
在计算机科学中,daemon
是在后台运行的程序。
Python的threading
模块对daemon
有更具体的含义。当程序退出时,守护线程会立即关闭。考虑这些定义的一种方法是将daemon
视为在后台运行的线程,而不必担心关闭它。
如果程序中正在执行的Threads
不是daemons
,则程序将在终止之前等待这些线程完成。然而,如果Threads
是daemons
,当程序退出时,它们就终止了。
让我们更仔细地看看上面程序的输出,最后两行是有点意思的。当运行这个程序时,在__main__
打印完all done
后以及线程结束之前会暂停大约2秒。
这个暂停是Python等待非后台线程完成。当Python程序结束时,关闭操作是清除线程中的程序。
如果查看threading
模块的源代码,你将看到threading._shutdown()
方法,它会遍历所有正在运行的线程,并在每一个没有设置daemon
标志的线程上调用.join()
方法。
因此,程序在退出时会等待,因为线程本身正在sleep(time.sleep(2)
)中。一旦完成并打印了消息,.join()
将返回,程序才可以退出。
通常,这是你想要的,但是我们还有其他的选择。让我们首先使用一个daemon
线程来重复这个程序。你可以修改Thread
实例化时的参数,添加daemon=True
:
1 | x = threading.Thread(target=thread_function, args=(1,), daemon=True) |
现在运行程序时,应看到以下输出:
1 | $ ./daemon_thread.py |
与前面不同的是,前面所输出的最后一行在这里没有了。thread_function()
没有执行完,它是一个daemon
线程,所以当_main__
执行到达它的末尾时,程序结束,后台线程也就结束了。
线程实例的.join()
方法
守护线程很方便,但是,如果要实现线程完全执行,而不是被迫退出,应该怎么办?现在让我们回到原始程序,看看注释掉的那一行:
1 | # x.join() |
要让一个线程等待另一个线程完成,可以调用.join()
。取消对该行的注释,主线程将暂停并等待线程x
,直到它运行结束。
你是否在程序中用守护线程或普通线程测试了这个问题?这并不重要。如果执行某个线程的.join()
方法,该语句将一直等待,直到每个线程都完成。
使用多线程
到目前为止,示例代码只使用了两个线程:一个是主线程,另一个是以threading.Thread
对象开始的线程。
通常,您会希望启动更多线程并让它们做一些有趣的工作。我们先来看看比复杂的方法,然后再看比较简单的方法。
启动多线程比较复杂的方法是你已经知道的:
1 | import logging |
这段代码使用与上面看到的相同机制来启动线程,创建一个Thread
实例对象,然后调用.start()
。程序中生成一个由Thread
实例组成的列表,后面再调用每个实例.join()
方法。
多次运行此代码可能会产生一些有趣的结果。下面是我的机器的输出示例:
1 | $ ./multiple_threads.py |
如果仔细检查输出,你将看到所有三个线程都按照你可能期望的顺序开始,但在本例中,它们是按照相反的顺序完成的!多次运行将产生不同的排序,可以通过查找Thread x: finishing
消息来了解每个线程何时完成。
线程的运行顺序由操作系统决定,很难预测,它可能(而且很可能)因运行而异,因此在设计使用线程的算法时需要注意这一点。
幸运的是,Python提供了几个模块,你稍后将看到这些模块用来帮助协调线程并使它们一起运行。在此之前,让我们看看如何更简单地管理一组线程。
使用ThreadPoolExecutor
有一种比上面看到的更容易启动多线程的方法,它被称为ThreadPoolExecutor
,是标准库中的concurrent.futures
的一员(从Python3.2开始)。
创建它的最简单方法是使用上下文管理器的with
语句,用它实现对线程池的创建和销毁。
下面是为了使用ThreadPoolExecutor
而重写的上一个示例中的__main__
部分代码:
1 | import concurrent.futures |
代码创建了一个ThreadPoolExecutor
作为上下文管理器,告诉它需要在线程池中有多少个工作线程。然后它使用.map()
遍历可迭代对象,在上面的例子中是range(3)
,将每个可迭代对象传递给线程池中的一个线程。
with
语句块的尾部,默认会调用ThreadPoolExecutor
的每个线程的.join()
方法,建议你尽可能使用ThreadPoolExecutor
作为上下文管理器,这样你就永远不会忘记对执行线程.join()
。
注意:使用ThreadPoolExecutor
可能会导致一些混乱的错误。
例如,如果调用不带参数的函数,但在.map()
中传了参数,则线程应当抛出异常。
不幸的是,ThreadPoolExecutor
隐藏了该异常,并且(在上面的情况下)程序将在没有输出的情况下终止。一开始调试可能会很混乱。
运行正确的示例代码将生成如下输出:
1 | $ ./executor.py |
同样,请注意Thread 1
是在Thread 0
之前完成的,线程执行顺序的调度是由操作系统完成的,所遵循的计划也不易理解。
(未完待续)
关注微信公众号:老齐教室。读深度文章,得精湛技艺,享绚丽人生。
若你觉得我的文章对你有帮助,欢迎点击上方按钮对我打赏
关注微信公众号,读文章、听课程,提升技能