跳到主要內容
程式語言

Python Singleton:從 `__new__` 到 thread-safe metaclass,該怎麼實現?

用 Python 實現 Singleton:模組、__new__、metaclass 與 factory;理解 __init__、雙重檢查鎖、執行緒安全、多程序限制,以及何時不該使用。

Singleton(單例模式)要解決的事情很單純:一個類別在某個作用域內只建立一個 instance,並提供固定的存取入口。

它常出現在設定載入器、logger、資料庫連線池、快取或硬體資源的管理者。不過「整個程式只有一份」聽起來簡單,實作時卻會立刻遇到幾個問題:第二次呼叫 class 時 __init__ 會不會再跑?多執行緒會不會同時建出兩份?多程序還算單例嗎?

這篇會從 Python 的物件建立流程開始,逐步建立正確的心智模型。


Python 建立 instance 時發生什麼事?

當你寫下 Service(),Python 大致會做兩件事:

  1. 呼叫 __new__:配置或取得 instance。
  2. 對這個 instance 呼叫 __init__:初始化它的狀態。
class Service:
    def __new__(cls):
        print("__new__")
        return super().__new__(cls)

    def __init__(self):
        print("__init__")

Service()
# __new__
# __init__

__new__ 是 class method,第一個參數是 cls;它必須回傳 instance。__init__ 則是一般的 instance method,負責設定已存在物件的內容。

這個順序正是用 __new__ 實作 Singleton 的關鍵:第一次配置物件,之後都把同一個物件回傳。


最小實作:在 __new__ 快取 instance

class Settings:
    _instance = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance


first = Settings()
second = Settings()

print(first is second)  # True

is 比較的是兩個變數是否指向同一個物件,所以它才是驗證單例的正確方式;== 只是在比較值是否相等。

這段程式在單執行緒下足以保證只配置一份 Settings。不過它有一個很常見、也很隱蔽的問題。


陷阱:同一份 instance,__init__ 仍會重複執行

即使 __new__ 回傳了舊 instance,Settings() 這個呼叫仍會接著呼叫 __init__。

class Settings:
    _instance = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance

    def __init__(self, environment):
        print(f"載入 {environment} 設定")
        self.environment = environment


first = Settings("production")
second = Settings("development")

print(first is second)       # True
print(first.environment)     # development

第二次呼叫沒有建立新物件,卻覆寫了第一次的狀態。若初始化包含讀檔、建立 socket、註冊 callback,後果可能更難追。

可以加上一個初始化旗標,讓真正的初始化只跑一次:

class Settings:
    _instance = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance

    def __init__(self, environment="production"):
        if getattr(self, "_initialized", False):
            return

        self.environment = environment
        self._initialized = True

此時第一次傳入的 environment 會決定這個 process 中 Settings 的設定;後續呼叫帶的參數會被忽略。這不是小細節,而是 API 合約:Singleton 的建構參數通常只能在第一次呼叫時有效。

若這種行為會讓呼叫者困惑,與其默默忽略參數,不如把設定改成明確的 configure() 方法,或乾脆不要把它做成 Singleton。


多執行緒下的 race condition

前面的 if cls._instance is None 不是原子操作。兩條 thread 可能剛好這樣交錯:

Thread A:看到 _instance 是 None
Thread B:看到 _instance 是 None
Thread A:建立 instance A
Thread B:建立 instance B

最後 _instance 只會保存其中一份,但另一份已經被建立、甚至可能完成副作用。不要因為 CPython 有 GIL 就依賴這段程式永遠安全:程式可能在檢查與賦值之間切換,也可能未來跑在沒有相同 GIL 行為的 Python 實作上。

最直接的修正是在建立前取得 lock:

from threading import Lock


class Settings:
    _instance = None
    _lock = Lock()

    def __new__(cls):
        with cls._lock:
            if cls._instance is None:
                cls._instance = super().__new__(cls)
        return cls._instance

每次呼叫都要取得 lock,通常對設定物件這種低頻操作已經足夠清楚。若建構會牽涉複雜邏輯,還要思考初始化旗標的讀寫也必須由同一把 lock 保護;否則另一條 thread 可能拿到「已配置但尚未初始化完成」的 instance。

Double-checked locking:初始化後不再取得 lock

若 singleton 位於高頻熱路徑,每次存取都取得 lock 的成本可能變得可見。可以先在未鎖定的狀態讀取 instance;只有還沒建立時才進入 lock,並在 lock 內再檢查一次:

from threading import Lock


class SingletonMeta(type):
    _instances: dict[type, object] = {}
    _lock = Lock()

    def __call__(cls, *args, **kwargs):
        # 快路徑:已初始化後,不需要取得 lock。
        instance = SingletonMeta._instances.get(cls)
        if instance is not None:
            return instance

        # 慢路徑:只有第一批呼叫者會走到這裡。
        with SingletonMeta._lock:
            # 必須再檢查一次,避免兩條 thread 都通過外層檢查。
            instance = SingletonMeta._instances.get(cls)
            if instance is None:
                # lock 要涵蓋 __new__ 和 __init__,不能發布半初始化物件。
                instance = super().__call__(*args, **kwargs)
                SingletonMeta._instances[cls] = instance
            return instance

外層檢查只是一種效能最佳化;內層檢查才是正確性的核心。若 Thread A 和 B 都先看到 None,A 建好物件後,B 取得 lock 時會在第二次檢查發現 instance 已存在,直接回傳 A 建立的物件。

不過別急著把所有實作換成這個版本。Singleton 大多只建立或取得少數幾次,簡單的「每次都鎖」通常更值得選。Double-checked locking 的無鎖讀取也依賴執行環境對共享物件存取的行為;CPython 目前的 free-threaded 實作會為內建容器提供內部保護,但官方明確指出這是實作描述,而不是未來 Python 語言層級的保證。要寫可攜的函式庫、或根本沒有量測到瓶頸時,保留單一明確的 lock 更穩妥。

無論哪個版本,super().__call__() 都必須在 lock 裡:它會執行 __new__ 與 __init__。只鎖住字典賦值、卻在 lock 外初始化,可能讓另一條 thread 看見未完成初始化的物件。也應避免在 __init__ 裡再次呼叫同一個 singleton;那會造成遞迴建立或 lock 重入問題。


用 metaclass 集中管理多個 Singleton

如果專案裡有多個單例類別,把 _instance、_lock 和 __new__ 複製到每個 class 會變得冗長。這時可以讓 metaclass 攔截 ClassName() 的呼叫。

from threading import Lock


class SingletonMeta(type):
    _instances: dict[type, object] = {}
    _lock = Lock()

    def __call__(cls, *args, **kwargs):
        # 在 lock 內完成建立與 __init__,不會把半初始化物件交給其他 thread。
        with SingletonMeta._lock:
            if cls not in SingletonMeta._instances:
                SingletonMeta._instances[cls] = super().__call__(*args, **kwargs)
        return SingletonMeta._instances[cls]


class Logger(metaclass=SingletonMeta):
    def __init__(self, level="INFO"):
        self.level = level


class Cache(metaclass=SingletonMeta):
    pass


first_logger = Logger("DEBUG")
second_logger = Logger("WARNING")

print(first_logger is second_logger)  # True
print(first_logger.level)              # DEBUG
print(Logger() is Cache())             # False

type.__call__ 原本會依序處理 __new__ 和 __init__。上例覆寫它,第一次才交給 super().__call__() 建立完整物件;之後直接從 _instances 字典取回。因此不像單純覆寫 __new__,第二次 Logger() 不會再執行 Logger.__init__()。

這裡故意把 lock 包住整段建立流程。好處是語意正確、容易檢查;代價是不同的 singleton class 第一次建立時也會共用同一把 lock。若初始化很慢、而且真有大量不同類別同時首次建立的需求,可以改用「每個 class 一把 lock」的設計;多數應用不值得先付出那個複雜度。

metaclass 的限制

Python 的 class 只能有一個 metaclass。若類別已經需要某個框架提供的 metaclass,SingletonMeta 可能無法直接共用,必須組合 metaclass 或採用其他做法。這也是為什麼它雖然整齊,卻不一定是最 Pythonic 的第一選擇。


Python 最簡單的 Singleton:模組

Python 模組第一次 import 後,會被快取在 sys.modules。同一個 process 中再次 import 同一個模組,通常取得的是同一個 module object。對很多需求來說,模組本身就是天然的 singleton scope。

# app_config.py
class Config:
    def __init__(self):
        self.timeout = 30


config = Config()

其他地方直接 import 那個實例:

from app_config import config

print(config.timeout)

這個做法沒有 metaclass,也不會讓 Config() 看似能隨意呼叫卻其實只拿到舊物件。依賴關係相對透明,而且 Python 的 import 機制會在並行 import 時協調模組初始化。

不過要避免在 module import 階段做非常慢、不可恢復的 I/O,例如直接連線到資料庫。比較好的方式是讓模組持有 manager,將昂貴資源延後到明確的方法呼叫時才建立。


Factory function:讓存取入口更明確

不一定要讓 class 本身成為 singleton。若希望延遲建立資源,而且希望呼叫端明確知道自己正在取得共享物件,factory function 往往比 metaclass 好讀:

from threading import Lock


class DatabasePool:
    def __init__(self):
        print("建立連線池")


_pool = None
_pool_lock = Lock()


def get_database_pool() -> DatabasePool:
    global _pool

    if _pool is None:
        with _pool_lock:
            if _pool is None:
                _pool = DatabasePool()
    return _pool

這是將前一節的 double-checked locking 用在一個明確的 getter 上。DatabasePool() 仍然是普通類別,因此測試可以直接建立獨立的 pool;正式程式則統一透過 get_database_pool() 取得 process 內共享的那份。

functools.cache 或 lru_cache(maxsize=1) 也能快取 factory 的回傳值,但它適合「可重複計算的結果」,不適合嚴格的單次初始化。多條 thread 在第一個結果寫進快取前仍可能各自執行被包裝的函數一次;若建立連線、寫檔或註冊資源,仍應使用顯式 lock。


Decorator 與 Enum:特定情境的替代方案

Decorator 也能把一個 class 包成「永遠回傳同一個 instance」的 callable,但 decorator 的回傳值通常不再是原本的 class。isinstance()、繼承、型別提示與框架整合都可能變得不直觀,因此較適合小型、內部工具,不是一般應用的首選。

若你要的是一組固定且預先存在的唯一物件,Enum 的 member 天生就是 singleton:

from enum import Enum


class AppResource(Enum):
    SETTINGS = "settings"
    METRICS = "metrics"


assert AppResource.SETTINGS is AppResource.SETTINGS

但 Enum 適合列舉值,不適合把需要連線、關閉、重設或依環境設定的 service 硬塞進去。


Borg pattern:共享狀態,但不是同一個物件

另一個常被拿來討論的做法叫 Borg 或 Monostate pattern:每次都建立不同 instance,但讓它們共用同一個 __dict__。

class Borg:
    _shared_state = {}

    def __init__(self):
        self.__dict__ = self._shared_state


first = Borg()
second = Borg()
first.theme = "dark"

print(first is second)  # False
print(second.theme)     # dark

它解決的是「所有 instance 看見同一份狀態」,不是「只有一個 instance」。這讓測試或序列化有時比較方便,但也很容易因為共享的隱性狀態而難以推理;不要把它當作 Singleton 的等價替代品。


Singleton 的作用域不是全世界

前面所有做法的保證範圍都是一個 Python interpreter process。使用 multiprocessing、Gunicorn/Uvicorn 的多 worker,或容器水平擴展時,每個 process 都有自己的記憶體和自己的 singleton。

Worker process 1  → Settings instance 1
Worker process 2  → Settings instance 2
Worker process 3  → Settings instance 3

若需求其實是「整個部署環境只能有一個執行者」,Python class 做不到。你需要把協調移到 process 外,例如資料庫的唯一約束或 advisory lock、Redis lock、訊息佇列的 consumer group,或由外部服務保存共享狀態。這同時也需要處理 lock 過期、程序崩潰與網路分割,不是 SingletonMeta 能解的問題。


怎麼測試?

至少確認 identity,以及初始化不會被第二次呼叫覆寫:

def test_logger_is_a_singleton():
    first = Logger("DEBUG")
    second = Logger("WARNING")

    assert first is second
    assert second.level == "DEBUG"

Singleton 特別容易造成測試互相污染:前一個 test 改了全域 instance 的狀態,下一個 test 就繼承它。對 metaclass 實作,可以在測試 fixture 中明確清掉快取;正式程式碼不應隨意暴露 reset API。

import pytest


@pytest.fixture(autouse=True)
def reset_singletons():
    SingletonMeta._instances.clear()

更根本的改善是依賴注入:把需要的 logger、設定或 repository 透過建構子傳進去,測試時傳入 fake object。這樣大多數程式碼不需要知道它背後是不是 singleton,測試的隔離性也更好。


何時該用,何時該停一下?

適合考慮 Singleton 的情況:

  • 一份共享資源確實需要集中管理,例如 process 內的連線池或指標快取。
  • 它的生命週期和 process 一致,而且重複初始化明確沒有意義。
  • 你能定義並測試它在多執行緒下的初始化與關閉行為。

先不要用的情況:

  • 只是想「方便在任何地方拿到資料」;這通常會變成難以追蹤的 global state。
  • 建構參數可能因呼叫者不同而不同;首次呼叫者不該偷偷決定所有人的設定。
  • 需求其實跨 process、跨機器或跨部署副本。

在 Python 裡,先選擇模組層級的明確 instance;真的需要 class 形式且有多個同類需求時,再使用 thread-safe metaclass。模式本身不是目的,清楚的生命週期、可測試性與資源所有權才是。