..

记一次 Matplotlib 解决多线程画图的故事

说起运维, 大家可能想到的第一个词就是“苦逼”🤔。 但近些年来, 这个职位发生了翻天覆地的变化: 人肉运维(PE) → 自动化运维(DevOps) → 智能运维(AIOps)。 身为SRE 大军中的一员(什么是 SRE), 也在智能运维的边缘试探: 希望打造监控告警「智能降噪」, 「根因定位」, 「自愈」的处理流程, 终极目标就是让每个人都睡个好觉。

而上述流程中不是核心, 却不可或缺的一部分就是投递告警时, 将隐晦的告警消息(文字)可视化,转化为生动的图片与诊断结果。 由于我们的整个平台是由 Python 搭建的, 关于绘图调研过多个第三方工具, 但不是太慢就是依赖过重, 最终选择了经典的 Matplotlib.

问题:

处理告警是 I/O 密集型的场景(拉取数据等操作), 自然而然的开启了多线程提升处理效率. 但有一次发生故障, 并导致报警疯狂投递时, 发现了一件诡异的事情: 消息里的图片是全白的或者错位了 :(

重现:

根据多年的经验(pingganjue), 发现引入的 import matplotlib.pyplot as plt 是个全局变量.. 应该就是它引起的线程不安全.
写了个小 demo 重现了一下:

import time
from concurrent.futures.thread import ThreadPoolExecutor


def draw_image(label, sleep=0):
    print(f"plot {label}")
    time.sleep(sleep)
    print(f"save {label}")


if __name__ == '__main__':
    with ThreadPoolExecutor(max_workers=5) as executor:
        # 线程不安全的问题
        executor.submit(draw_image, "A", sleep=1)
        executor.submit(draw_image, "B", sleep=0)
# Output:
# plot A
# plot B
# save B
# save A

如果 plot Aplot B 都是画一条线的话, 第三步保存的时候, 就会把画了两条线的图保存了.

解决:

在说解决方案前, 需要了解一个概念: 原子操作(atomic operation)

python 中很有趣的一点: sort 是原子操作, 而+=不是原子操作。 详情可以阅读这篇文章, 写的很好: 《深入理解 GIL:如何写出高性能及线程安全的 Python 代码》

用 matplot 画图绝对不是个原子操作, 那要怎么解决线程的安全的问题呢? 要是觉得不安全, 就加个锁🔒呗

import logging
import time
from concurrent.futures.thread import ThreadPoolExecutor
from threading import Lock, RLock

logger = logging.getLogger(__name__)

lock = Lock()
rlock = RLock()


def draw_image_with_lock(label, sleep=0):
    with lock:
        print(f"plot {label}")
        time.sleep(sleep)
        print(f"save {label}")


if __name__ == '__main__':
    with ThreadPoolExecutor(max_workers=5) as executor:
        # 加锁 Lock
        # 缺点: 容易出现 deadlock 的情况, 需要注意
        executor.submit(draw_image_with_lock, "A", sleep=1)
        executor.submit(draw_image_with_lock, "B", sleep=0)

# Output: 
# plot A
# save A
# plot B
# save B

从 Output 可以看到, A&B 画图和保存的顺序没有错乱了。 当加锁会导致 deadlock 的情况, 这个 case 不太容易出现, 但还是要格外的小心。

其他:

分享一下我用 matplotlib 画的酷酷的图💪:

EOF