并发是现代编程的重要组成部分,因为我们有多个核心和许多需要协作的任务。然而,当并发程序不是顺序运行时,就更难理解它们。工程师在这些程序中识别错误和性能问题不像在单线程、单任务程序中那么容易。
使用 Python,您有多种并发选项。最常见的可能是使用 threading 模块的多线程、使用 subprocess 和 multiprocessing 模块的多进程,以及更新的带有 asyncio 模块的 async 语法。在 VizTracer 之前,缺乏分析使用这些技术的程序的工具。
VizTracer 是一款用于追踪和可视化 Python 程序的工具,它有助于日志记录、调试和性能分析。尽管它在单线程、单任务程序中也能很好地工作,但它在并发程序中的实用性使其独一无二。
尝试一个简单的任务
从一个简单的练习任务开始:找出数组中的整数是否为质数,并返回一个布尔数组。这是一个简单的解决方案
def is_prime(n):
for i in range(2, n):
if n % i == 0:
return False
return True
def get_prime_arr(arr):
return [is_prime(elem) for elem in arr]
尝试在单线程中正常运行它,使用 VizTracer
if __name__ == "__main__":
num_arr = [random.randint(100, 10000) for _ in range(6000)]
get_prime_arr(num_arr)
viztracer my_program.py

(Tian Gao,CC BY-SA 4.0)
调用堆栈报告表明它花费了大约 140 毫秒,其中大部分时间花费在 get_prime_arr
中。

(Tian Gao,CC BY-SA 4.0)
它只是在数组中的元素上一次又一次地执行 is_prime
函数。
这是您所期望的,而且没有那么有趣(如果您了解 VizTracer)。
尝试一个多线程程序
尝试使用多线程程序来完成它
if __name__ == "__main__":
num_arr = [random.randint(100, 10000) for i in range(2000)]
thread1 = Thread(target=get_prime_arr, args=(num_arr,))
thread2 = Thread(target=get_prime_arr, args=(num_arr,))
thread3 = Thread(target=get_prime_arr, args=(num_arr,))
thread1.start()
thread2.start()
thread3.start()
thread1.join()
thread2.join()
thread3.join()
为了匹配单线程程序的工作负载,这里使用了 2,000 个元素的数组用于三个线程,模拟了三个线程共享任务的情况。

(Tian Gao,CC BY-SA 4.0)
正如您如果熟悉 Python 的全局解释器锁 (GIL) 所预期的那样,它不会变得更快。由于开销,它花费了稍微超过 140 毫秒。但是,您可以观察到多个线程的并发性

(Tian Gao,CC BY-SA 4.0)
当一个线程工作时(执行多个 is_prime
函数),另一个线程被冻结(一个 is_prime
函数);之后,它们切换了。这是由于 GIL 造成的,这也是 Python 没有真正多线程的原因。它可以实现并发,但不能实现并行。
尝试使用多进程
为了实现并行,最佳方法是使用 multiprocessing 库。这是另一个使用多进程的版本
if __name__ == "__main__":
num_arr = [random.randint(100, 10000) for _ in range(2000)]
p1 = Process(target=get_prime_arr, args=(num_arr,))
p2 = Process(target=get_prime_arr, args=(num_arr,))
p3 = Process(target=get_prime_arr, args=(num_arr,))
p1.start()
p2.start()
p3.start()
p1.join()
p2.join()
p3.join()
要使用 VizTracer 运行它,您需要一个额外的参数
viztracer --log_multiprocess my_program.py

(Tian Gao,CC BY-SA 4.0)
整个程序在 50 毫秒多一点的时间内完成,实际任务在 50 毫秒标记之前完成。程序的速度大约提高了三倍。
为了与多线程版本进行比较,这是多进程版本

(Tian Gao,CC BY-SA 4.0)
在没有 GIL 的情况下,多进程可以实现并行性,这意味着多个 is_prime
函数可以并行执行。
但是,Python 的多线程并非毫无用处。例如,对于计算密集型和 I/O 密集型程序,您可以使用 sleep 模拟 I/O 绑定任务
def io_task():
time.sleep(0.01)
尝试在单线程、单任务程序中运行它
if __name__ == "__main__":
for _ in range(3):
io_task()

(Tian Gao,CC BY-SA 4.0)
整个程序花费了大约 30 毫秒;没什么特别的。
现在使用多线程
if __name__ == "__main__":
thread1 = Thread(target=io_task)
thread2 = Thread(target=io_task)
thread3 = Thread(target=io_task)
thread1.start()
thread2.start()
thread3.start()
thread1.join()
thread2.join()
thread3.join()

(Tian Gao,CC BY-SA 4.0)
程序花费了 10 毫秒,并且很明显三个线程是如何并发工作并提高整体性能的。
尝试使用 asyncio
Python 正在尝试引入另一个有趣的功能,称为异步编程。您可以为此任务创建一个异步版本
import asyncio
async def io_task():
await asyncio.sleep(0.01)
async def main():
t1 = asyncio.create_task(io_task())
t2 = asyncio.create_task(io_task())
t3 = asyncio.create_task(io_task())
await t1
await t2
await t3
if __name__ == "__main__":
asyncio.run(main())
由于 asyncio 实际上是一个带有任务的单线程调度器,因此您可以直接在它上面使用 VizTracer

<p class="rtecenter"><sup>(Tian Gao,<a href="https://open-source.net.cn/%3Ca%20href%3D"https://creativecommons.org/licenses/by-sa/4.0/" rel="ugc">https://creativecommons.org/licenses/by-sa/4.0/" target="_blank">CC BY-SA 4.0</a>)</sup></p>
它仍然花费了 10 毫秒,但显示的大多数函数都是底层结构,这可能不是用户感兴趣的。为了解决这个问题,您可以使用 --log_async
来分离实际任务
viztracer --log_async my_program.py

(Tian Gao,CC BY-SA 4.0)
现在用户任务更加清晰。在大多数时间里,没有任务在运行(因为它唯一做的就是休眠)。这是有趣的部分

(Tian Gao,CC BY-SA 4.0)
这显示了任务何时创建和执行。Task-1 是 main()
协程,并创建了其他任务。任务 2、3 和 4 执行了 io_task
和 sleep
,然后等待唤醒。如图所示,任务之间没有重叠,因为这是一个单线程程序,VizTracer 以这种方式将其可视化,使其更易于理解。
为了使其更有趣,在任务中添加一个 time.sleep
调用来阻塞异步循环
async def io_task():
time.sleep(0.01)
await asyncio.sleep(0.01)

(Tian Gao,CC BY-SA 4.0)
程序花费了更长的时间(40 毫秒),并且任务填补了异步调度器中的空白。
此功能对于诊断异步程序中的行为和性能问题非常有帮助。
了解 VizTracer 正在发生什么
使用 VizTracer,您可以在时间轴上看到程序中正在发生的事情,而不是从复杂的日志中想象它。这有助于您更好地理解并发程序。
VizTracer 是开源的,在 Apache 2.0 许可下发布,并支持所有常见的操作系统(Linux、macOS 和 Windows)。您可以在 VizTracer 的 GitHub 仓库中了解更多关于其功能的信息并访问其源代码。
评论已关闭。