多进程场景下如何使用 Logging 模块
一月份的时候,领导让我基于 docker 并行执行测试用例,当时积累了很多实践经验,现在拿出来复盘一下。
当时有个很严重的问题,执行测试用例的脚本是跑在不同进程上的,而且出日志的速度很快,这就可能导致以下的几种问题:
- 日志紊乱:比如两个进程分别输出
xxxx
和yyyy
两条日志,那么在文件中可能会得到类似xxyxyxyy
这样的结果
- 日志丢失:虽然读写日志是使用
O_APPEND
模式,保证了写文件的一致性,但是由于buffer
的存在(数据先写入buffer
,再触发flush
机制刷入磁盘),fwrite
的操作并不是多进程安全的。
为了应对上述可能出现的情况,我查阅了 Python 官方的 logging-cookbook (docs.python.org/zh-cn/3/how...),找到了解决方案。
官方文档这样写到:
尽管 logging 是线程安全的,将单个进程中的多个线程日志记录至单个文件也 是 受支持的,但将 多个进程 中的日志记录至单个文件则 不是 受支持的,因为在 Python 中并没有在多个进程中实现对单个文件访问的序列化的标准方案。 如果你需要将多个进程中的日志记录至单个文件,有一个方案是让所有进程都将日志记录至一个
SocketHandler
,然后用一个实现了套接字服务器的单独进程一边从套接字中读取一边将日志记录至文件。 (如果愿意的话,你可以在一个现有进程中专门开一个线程来执行此项功能。) 这一部分 文档对此方式有更详细的介绍,并包含一个可用的套接字接收器,你自己的应用可以在此基础上进行适配。你也可以编写你自己的处理程序,让其使用
multiprocessing
模块中的Lock
类来顺序访问你的多个进程中的文件。 现有的FileHandler
及其子类目前并不使用multiprocessing
,尽管它们将来可能会这样做。 请注意在目前,multiprocessing
模块并未在所有平台上都提供可用的锁功能 (参见 bugs.python.org/issue3770)。或者,你也可以使用
Queue
和QueueHandler
将所有的日志事件发送至你的多进程应用的一个进程中。 以下示例脚本演示了如何执行此操作。 在示例中,一个单独的监听进程负责监听其他进程的日志事件,并根据自己的配置记录。 尽管示例只演示了这种方法(例如你可能希望使用单独的监听线程而非监听进程 ------ 它们的实现是类似的),但你也可以在应用程序的监听进程和其他进程使用不同的配置,它可以作为满足你特定需求的一个基础。
文档提到的首个解决方案是使用 SocketHandler
。这种方法的核心思想是所有进程将日志信息发送到一个中央进程的套接字服务器上,由这个服务器进程负责将日志统一写入文件。这种方法的优点是可以有效地解决多进程日志写入同一文件的同步问题,避免了直接的文件操作冲突。你可以选择在现有的进程中开一个线程来专门处理从套接字接收日志的任务,从而不需要额外的进程资源。
另一个建议的方案是使用 Python 的 multiprocessing
模块中的 Lock
类来手动控制跨进程的文件访问。通过显式地在每个进程中实现锁机制,可以保证同一时间只有一个进程能写入日志文件。尽管这种方法提供了控制的精细度,但它也增加了编程的复杂性和运行时的管理负担。
文档还提到了一个使用 Queue
和 QueueHandler
的方法。在这种方法中,所有的日志事件首先被发送到一个队列中,然后由一个单独的监听进程(或线程)从队列中取出事件并进行日志记录。这种方式的好处是,它将日志记录的写入操作集中到一个进程中,从而避免了多进程直接写入同一个文件可能引起的问题。
我把使用 QueueHandler 的代码示例放在这里:
ini
# 你需要在你的代码中引入这些模块
import logging
import logging.handlers
import multiprocessing
# 以下两行导入仅用于此示例
from random import choice, random
import time
#
# 因为你需要为监听器和工作进程定义日志配置,所以监听器和工作进程函数需要一个 configurer 参数,它是一个用于为该进程配置日志的可调用对象。这些函数也会传递 queue,它们用于通信。
#
# 实际上,你可以按照自己的需求配置监听器,但请注意在这个简单的例子中,监听器并没有对接收的记录应用级别或过滤逻辑。
# 实际情况中,你可能希望在工作进程中执行这些逻辑,以避免发送在进程间会被过滤掉的事件。
#
# 设置了较小的文件轮转大小,这样你可以更容易地看到结果。
def listener_configurer():
root = logging.getLogger()
h = logging.handlers.RotatingFileHandler('mptest.log', 'a', 300, 10)
f = logging.Formatter('%(asctime)s %(processName)-10s %(name)s %(levelname)-8s %(message)s')
h.setFormatter(f)
root.addHandler(h)
# 这是监听进程的顶层循环:等待日志事件(LogRecords)在队列上,并处理它们,当接收到 None 作为 LogRecord 时退出。
def listener_process(queue, configurer):
configurer()
while True:
try:
record = queue.get()
if record is None: # 我们发送这个作为信号让监听器退出。
break
logger = logging.getLogger(record.name)
logger.handle(record) # 没有应用级别或过滤逻辑 - 直接处理!
except Exception:
import sys, traceback
print('哎呀!出问题了:', file=sys.stderr)
traceback.print_exc(file=sys.stderr)
# 数组用于此演示中的随机选择
LEVELS = [logging.DEBUG, logging.INFO, logging.WARNING,
logging.ERROR, logging.CRITICAL]
LOGGERS = ['a.b.c', 'd.e.f']
MESSAGES = [
'随机消息 #1',
'随机消息 #2',
'随机消息 #3',
]
# 工作进程的配置在工作进程运行开始时完成。
# 注意在 Windows 上你不能依赖 fork 语义,所以每个进程
# 将在启动时运行日志配置代码。
def worker_configurer(queue):
h = logging.handlers.QueueHandler(queue) # 只需要一个处理器
root = logging.getLogger()
root.addHandler(h)
# 发送所有消息,用于演示;没有应用其他级别或过滤逻辑。
root.setLevel(logging.DEBUG)
# 这是工作进程的顶层循环,它仅记录十个事件,并在终止前随机延迟。
# 打印消息只是为了让你知道它在做些什么!
def worker_process(queue, configurer):
configurer(queue)
name = multiprocessing.current_process().name
print('工作进程启动:%s' % name)
for i in range(10):
time.sleep(random())
logger = logging.getLogger(choice(LOGGERS))
level = choice(LEVELS)
message = choice(MESSAGES)
logger.log(level, message)
print('工作进程结束:%s' % name)
# 这里是演示的编排部分。创建队列,创建并启动监听器,创建十个工作进程并启动它们,等待它们完成,
# 然后发送一个 None 到队列告诉监听器结束。
def main():
queue = multiprocessing.Queue(-1)
listener = multiprocessing.Process(target=listener_process,
args=(queue, listener_configurer))
listener.start()
workers = []
for i in range(10):
worker = multiprocessing.Process(target=worker_process,
args=(queue, worker_configurer))
workers.append(worker)
worker.start()
for w in workers:
w.join()
queue.put_nowait(None)
listener.join()
if __name__ == '__main__':
main()
我最终是修改了日志发送端,把日志内容和进程编号一起发送,启动了一个 server 进程接收日志存放进数据库持久化,查询日志的时候可以根据进程编号查询对应进程的日志。