Python 单例日志记录器 :线程安全、竞争条件与锁优化

我们成功创建了一个单例类 Logger,并为所有与该类交互的用户提供了一个静态方法 getLogger

我们的 Logger 类包含以下关键特性:

🔹 不允许直接实例化 Logger 类。

🔹 实现了自定义错误处理,以防止直接实例化。

🔹 所有类的用户使用一个公共的静态 getLogger 方法来获取实例。

然而,目前的实现并不适合生产使用,因为它没有考虑多线程的场景——即,它不是线程安全的。这正是我们在本文中要解决的问题。

前提条件

threadingmultiprocessing 在 Python 中的比较

 

🧵 Python 中的线程

这是什么?

线程允许你在一个进程内运行多个线程(小型工作者)🧠。它们共享相同的内存,就像室友共享一所房子🏠。


💡 工作原理:

🔹 所有线程共享相同的内存空间 🧠

🔹 适用于 I/O 密集型任务 📡(例如,网络请求、文件读写)。

🔹 由 threading 模块控制 🧵

 

🧠 关键概念:

🔹 线程 = 轻量级 🚴‍♂️

🔹 内存在线程之间共享 🏠

🔹 但是……有一个讨厌的东西叫做 GIL(全局解释器锁)⛔,这意味着同一时间只能有一个线程运行 Python 代码,因此没有真正的并行性 😢(对于 CPU 密集型任务)。

 

✅ 优点:

🔹 启动速度超级快 ⚡

🔹 内存使用低 🪶

🔹 线程可以轻松共享数据 🧠

 

❌ 缺点:

🔹 由于 GIL 的原因,无法利用多个 CPU 核心 🚫🧠

🔹 存在竞争条件和死锁等错误的风险 🕳️

 

🔥 Python 中的多进程

什么是多进程?

多进程创建独立的进程,每个进程都有自己的内存,它们真正并行运行 🚀——就像不同的计算机一起工作 🤝

💡 工作原理:

🔹 使用 multiprocessing 模块 🛠️。

🔹 每个进程都有自己的内存空间 📦。

🔹 这里没有 GIL——每个进程都获得一个核心! 🧠🧠🧠

 

🧠 最适合:

🔹 CPU 密集型任务 🧮 – 数值计算、数据处理、机器学习等。

🔹 当你需要真正的并行性 ⚙️⚙️

优点

🔹 真正的并行性 💥

🔹 非常适合 CPU 密集型任务 🧠💪

🔹 没有 GIL = 没问题 🚫

缺点

🔹 更高的内存使用 🧠💸

🔹 启动速度较慢 ⚙️

🔹 进程间共享数据更复杂 🧵➡️📦

我们会使用哪一个?

考虑到我们在 Python 中可用的不同选项,当然我们会选择 threading 模块,因为这是一个 I/O 用例,并且我们在这里并没有进行任何重计算。

 

为什么它不是线程安全的?

竞态条件示例

 

参照上面的图示。想象一个场景,其中有两个线程 Thread 1Thread 2 都试图在应用程序运行的初始阶段访问 getLogger,即 __loggerInstanceNone

🔹 线程 1 检查 cls.__loggerInstance 是否为 None — 是的,所以它继续执行。

🔹 线程 2 同时运行,检查 cls.__loggerInstance — 仍然是 None,因此它也继续执行。

两个线程都调用 __new__()__init__() → 🧨 砰!创建了两个实例!

这被称为 竞争条件,在多线程环境中确实可能发生。

🧪 如何重现该问题

您可以人为地减慢实例化过程以引发该问题:为了重现该问题,我们将对现有代码库进行以下更改

📄 Logger.py

import time

class Logger:

    # 私有静态变量,用于跟踪创建的实例数量
    __numInstances = 0

    # 私有静态变量,用于标识实例是否已创建

    __loggerInstance = None

    def __new__(cls):
        # 私有构造函数,使用 __new__ 防止直接实例化
        raise Exception("请使用 getLogger() 创建实例。")

    def __init__(self):
        Logger.__numInstances = Logger.__numInstances + 1
        print("日志记录器实例化,总实例数量 - ", Logger.__numInstances)

def log(self, message: str):
    print(message)

@classmethod
def getLogger(cls):
    # 返回单例实例,如果不存在则创建一个
    if cls.__loggerInstance is None:

        time.sleep(0.1)  # 模拟延迟
        # 绕过 __new__ 直接实例化类

cls.__loggerInstance = super(Logger, cls).__new__(cls)

# 在首次创建时手动触发 __init__
cls.__loggerInstance.__init__()

return cls.__loggerInstance

📄 main.py

from user1 import doProcessingUser1
from user2 import doProcessingUser2
import threading
import multiprocessing

if __name__ == "__main__":

    t1 = threading.Thread(target=doProcessingUser1)
    t2 = threading.Thread(target=doProcessingUser2)

    t1.start()

t2.start()

t1.join()
t2.join()

print("所有线程已完成")

添加了 time.sleep(0.1) 来模拟执行 getLogger 函数时的延迟。

输出(我们遇到了竞争条件)


 

日志记录器已实例化,总实例数 -  1
来自第二个用户的日志
日志记录器已实例化,总实例数 -  2
来自第一个用户的日志
所有线程已完成

✅ 如何使其线程安全

使用 threading.Lock 来同步对单例逻辑的访问。让我们进行这些更改,然后讨论到目前为止我们所编辑的内容。

📄 logger.py

import time

import threading

class Logger:

    # 私有静态变量,用于跟踪创建的实例数量
    __numInstances = 0

    # 私有静态变量,用于指示实例是否已创建
    __loggerInstance = None

    __mutexLock = threading.Lock()  # 用于线程安全的单例创建(私有静态)

    def __new__(cls):
        # 私有构造函数,使用 __new__ 防止直接实例化
        raise Exception("请使用 getLogger() 创建实例。")

    def __init__(self):

Logger.__numInstances = Logger.__numInstances + 1
print("Logger 实例化,总实例数量 - ", Logger.__numInstances)

def log(self, message: str):
    print(message)

@classmethod
def getLogger(cls):
    # 返回单例实例,如果不存在则创建一个
    with cls.__mutexLock:

if cls.__loggerInstance is None:

                time.sleep(0.1)  # 模拟延迟

                # 跳过 __new__ 并直接实例化类
                cls.__loggerInstance = super(Logger, cls).__new__(cls)

                # 在首次创建时手动触发 __init__
                cls.__loggerInstance.__init__()

        return cls.__loggerInstanc

 

在这里,我们添加了以下内容

🔹 私有静态变量 __mutexLock,用于让只有一个线程,即 Thread 1Thread 2 访问 getLogger 函数。

🔹 with cls.__mutexLock:

 

👉 这有什么作用:
with 语句在块开始时自动获取锁 ✅

然后,一旦退出块,它会自动释放锁(即使发生异常也会如此!) 🔓

因此,从技术上讲,锁在 with 下的缩进块完成后立即被释放。

✅ 所以不需要手动调用 .acquire() 或 .release() — with 块会干净且安全地处理这些 🙌

以下是一个图示,展示了这如何帮助我们避免之前遇到的原始 race condition 问题。

竞态条件避免与解决方案

🧵 线程 1 和 线程 2 执行流程

🟩 线程 1(图的左侧):
调用 getLogger()
→ 线程 1 想要获取日志实例。

获取锁 🔐
→ 由于这是第一个进入的线程,它获取了 __mutexLock 的锁。

执行 getLogger()
→ 发现 __loggerInstanceNone,创建单例日志实例。

释放锁 🔓
→ 完成临界区,允许其他线程进入。

🟦线程 2(图的右侧):
也在(大致)同一时间调用 getLogger()

等待锁 ⏳
→ 它遇到了锁,但线程 1 已经持有锁。

仍在等待… 😬
→ 线程 1 正在执行它的操作。线程 2 在这里静静等待。

获取锁(在线程 1 释放后) ✅

→ 现在线程 2 进入了临界区。

执行 getLogger()
→ 但这次,它发现 __loggerInstance 不是 None,所以它直接返回现有的日志记录器!

✅ 这如何防止竞争条件:

没有锁:

两个线程可能同时检查 __loggerInstance 是否为 None,并且两个线程可能会同时尝试创建它 → ❌ 多个实例(竞争条件!)。

有锁:

每次只有一个线程进入临界区,确保只创建一个日志记录器实例。

最终输出

Logger Instantiated, Total number of instances -  1
Log from the first user
Log from the second user
All threads finished

现在,正如预期的那样,我们只看到一个类的实例被实例化,即使在多线程环境中也是如此。

最后一个优化

这个优化基于锁的使用在进程中是昂贵的这一事实。因此,我们将聪明地使用它。

请注意我们当前的 getLogger 函数的一个小细节

def getLogger(cls):
        # 返回单例实例,如果不存在则创建一个

        with cls.__mutexLock:

            if cls.__loggerInstance is None:
time.sleep(0.1)  # 模拟延迟

                # 绕过 __new__ 并直接实例化类
                cls.__loggerInstance = super(Logger, cls).__new__(cls)

                # 在首次创建时手动触发 __init__
                cls.__loggerInstance.__init__()

        return cls.__loggerInstance

 

在当前的实现中,我们在每次调用 getLogger 函数时都获取一个锁,这样效率很低。

然而,锁只在程序开始时是必要的。一旦 __loggerInstance 被设置,任何后续访问 getLogger 函数的线程都不会创建新的实例——它们只会接收到现有的实例。

为了优化这一点,我们将在获取锁之前添加一个小检查,以确保它仅在初始实例化期间使用。

def getLogger(cls):
        # 返回单例实例,如果不存在则创建一个
        if cls.__loggerInstance is None:  # 🚀 快速无锁检查

            with cls.__mutexLock:

                if cls.__loggerInstance is None:

                    time.sleep(0.1)  # 模拟延迟

                    # 跳过 __new__ 方法,直接实例化类
                    cls.__loggerInstance = super(Logger, cls).__new__(cls)

                    # 在首次创建时手动触发 __init__
                    cls.__loggerInstance.__init__()

        return cls.__loggerInstance

我们添加了上述条件 if cls.__loggerInstance is None: 来检查我们是否处于执行的初始阶段,然后才获取锁以实例化该类。

这种类型的锁定被称为 双重检查锁定模式

🪄 为什么这样更好:

大多数情况下(在记录器创建之后),该方法将完全跳过锁定 🙌

锁定仅在第一次初始化时发生一次

仍然是100%线程安全的 ✅

如以下来自 main.py 的响应所测试的

Logger Instantiated, Total number of instances -  1

日志来自第一个用户  
日志来自第二个用户  
所有线程已完成  

这与我们之前得到的完全相同

更多