首页 论坛 置顶 Python 单例日志记录器 :线程安全、竞争条件与锁优化

正在查看 1 个帖子:1-1 (共 1 个帖子)
  • 作者
    帖子
  • #15008

    我们成功创建了一个单例类 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
    
    日志来自第一个用户  
    日志来自第二个用户  
    所有线程已完成  
    

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

正在查看 1 个帖子:1-1 (共 1 个帖子)
  • 哎呀,回复话题必需登录。