싱글턴(Singleton) 패턴 : 심화The Singleton Pattern: Advanced
Eager / Lazy 싱글턴 패턴과 쓰레드Eager / Lazy singleton patterns and threads
type으로 클래스 동적으로 생성
이번에는 type을 직접 사용해서 클래스를 만드는 방법을 알아보겠습니다. type은 세가지 인자를 받아 클래스를 동적으로 생성합니다.
-
name: 클래스의 이름 -
bases: 베이스 클래스의 튜플 -
attrs: 클래스 딕셔너리
AdvancedList = type('AdvancedList', (list,), { "hello": lambda self: print("hello") })
h = AdvancedList((1,2,3,4))
h.hello()
AdvancedList라는 이름의 클래스를 만들고 기본 베이스 클래스는 list입니다. 그리고 속성으로 hello 메서드를 정의합니다.
Eager
%%{
init: {
'theme': 'base',
'themeVariables': {
'primaryColor': '#2a3844',
'lineColor': '#fff',
'primaryTextColor': '#fff',
'tertiaryColor': '#fff'
}
}
}%%
classDiagram
direction BT
class SingletonMeta{
-_instances: dict
-__init__()
-__call__() Singleton
}
class Singleton{
+some_business_logic()
}
Singleton --|> SingletonMeta
싱글턴 구현 : Eager
Eager 싱글턴 패턴은 인스턴스가 필요하기 전에 미리 생성되는 싱글턴 디자인 패턴의 한 형태입니다.
- 메타클래스를 정의하고
__init__을 오버라이딩 - 메타클래스의
super를 이용하여 클래스를 정의 및 객체 생성 - 메타클래스의
__call__울 오버라이딩
import logging
class SingletonMeta(type):
_instances = {}
def __init__(cls, name, bases, attrs):
logging.info("metaclass __init__")
super().__init__(name, bases, attrs)
instance = super().__call__()
cls._instances[cls] = instance
def __call__(cls, *args, **kwargs):
logging.info("metaclass __call__")
return cls._instances.get(cls)
from eager.meta import SingletonMeta
class Singleton(metaclass=SingletonMeta):
def some_business_logic(self):
...
from eager.singleton import Singleton
def test_meta_singleton():
obj_1 = Singleton()
obj_2 = Singleton()
assert obj_1 is obj_2
디버깅을 해보면 클래스로 객체를 생성하는 시점이 아니라, 클래스가 로딩되는 시점에 메타클래스의 __init__ 메서드가 자동으로 호출되는 것을 확인 할 수 있습니다. 따라서 메타클래스를 상속 받은 각 클래스에 대해서 인스턴스를 생성 할 수 있습니다.
메타클래스는 super()는 상속 받는 type입니다. 앞서 봤던 type을 사용하여 클래스를 생성하는 과정과 동일합니다. 이후 메타클래스의 __call__ 메서드가 호출되면서 미리 만들어져 있던 객체를 리턴 합니다.
INFO root:meta.py:8 metaclass __init__
INFO root:meta.py:15 metaclass __call__
INFO root:meta.py:15 metaclass __call__
이 방식은 프로그램 시작 시점에 싱글턴 인스턴스를 생성함으로써, 멀티스레딩 환경에서의 동시성 문제를 자연스럽게 회피할 수 있습니다. Eager 싱글턴 패턴은 인스턴스의 생성 시점을 명확하게 제어할 수 있으며, 런타임에 추가적인 처리 없이 인스턴스에 접근할 수 있다는 장점이 있습니다.
반면, 리소스가 낭비 될수 있고 초기화 순서에 의존이 생길 수 있으며, 그로 인해 코드의 유연성이 부족할 수 있습니다.
Eager 패턴 / Lazy 패턴
Eager 싱글턴 패턴과 Lazy 싱글턴 패턴은 객체의 생성 시점에 따라 구분됩니다.
Eager 싱글턴 패턴
- 정의: Eager 싱글턴 패턴에서는 클래스가 로드되는 시점에 싱글턴 인스턴스가 생성. 즉, 프로그램 시작 시 또는 클래스가 처음 로드될 때 인스턴스가 생성되어 메모리에 할당.
- 장점: 멀티스레딩 환경에서의 안전성이 보장. 인스턴스가 프로그램 시작 시에 생성되기 때문에, 동시성 문제 없이 인스턴스에 접근 가능.
- 단점: 인스턴스가 실제로 필요하지 않은 경우에도 미리 생성되므로, 리소스 낭비가 발생.
Lazy 싱글턴 패턴
- 정의: Lazy 싱글턴 패턴에서는 싱글턴 인스턴스가 실제로 필요한 순간, 즉 인스턴스에 처음 접근하는 시점에 생성.
- 장점: 인스턴스가 실제로 필요할 때만 생성되므로, 리소스를 효율적 사용. 초기 로딩 시간을 단축.
- 단점: 멀티스레딩 환경에서 추가적인 동기화 처리가 필요.
클래스 다이어그램 : Thread Safe
%%{
init: {
'theme': 'base',
'themeVariables': {
'primaryColor': '#2a3844',
'lineColor': '#fff',
'primaryTextColor': '#fff',
'tertiaryColor': '#fff'
}
}
}%%
classDiagram
direction BT
class SingletonMeta{
-_instances: dict
-__call__() Singleton
}
class Singleton{
+some_business_logic()
}
Singleton --|> SingletonMeta
싱글턴 구현 : Thread Safe
race condition은 여러 스레드나 프로세스가 동시에 공유 자원에 접근할 때 발생할 수 있는 문제입니다. 멀티 쓰레딩 환경에서 Lazy 싱글턴 패턴은 인스턴스 생성 시점에 여러 스레드가 동시에 접근할 경우, 동일한 싱글턴 인스턴스가 여러 번 생성될 race condition 위험이 있습니다.
따라서 이를 방지하기 위한 동기화 로직이 추가로 필요합니다.
- 메타클래스를 정의하고
__new__을 오버라이딩 -
threading.Lock()을 활용
from typing import Self
import threading
class SingletonMeta(type):
_instances = None
_lock = threading.Lock()
def __call__(cls, *args, **kwargs) -> Self:
with cls._lock:
if cls not in cls._instances:
cls._instances[cls] = super().__call__(*args, **kwargs)
return cls._instances[cls]
from thread.meta import SingletonMeta
class Singleton(metaclass=SingletonMeta):
def some_business_logic(self):
pass
멀티 쓰레딩 환경에서 확인하기
멀티 쓰레딩 환경에서 race condition 환경을 재현하기 위해서 각 메타클래스에 time.sleep을 추가하겠습니다. Lazy 싱글턴 패턴에서 sleep 상대적으로 시간을 짧게 부여해서 테스트가 번갈아 성공과 실패하는 것을 확인 할 수 있습니다. 반면, thread.Lock() 사용한 싱글턴 패턴에서는 긴 sleep 시간을 부여해도 안정적으로 하나의 객체만 생성하는 것을 확인 할 수 있습니다.
import threading
import time
class SingletonMeta(type):
_instances = {}
_lock = threading.Lock()
def __call__(cls, *args, **kwargs):
with cls._lock:
if cls not in cls._instances:
time.sleep(1) # race condition 발생시키 위해서 추가
instance = super().__call__(*args, **kwargs)
cls._instances[cls] = instance
return cls._instances[cls]
import time
class SingletonMeta(type):
_instances = {}
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
time.sleep(0.00001) # race condition 발생시키 위해서 추가
instance = super().__call__(*args, **kwargs)
cls._instances[cls] = instance
return cls._instances[cls]
import logging
import threading
from meta.singleton import Singleton as LazySingleton
from thread.singleton import Singleton as ThreadSafeSingleton
def test_thread_safe_singleton():
result = race_condition(get_thread_singleton_instance)
assert len(set(result)) == 1
def test_lazy_singleton():
result = race_condition(get_lazy_singleton_instance)
assert len(set(result)) == 1
def race_condition(func):
threads = []
results = [None] * 100 # 결과 저장 리스트
barrier = threading.Barrier(100) # 100개의 쓰레드가 동시에 실행되도록 설정
def wrapper(index):
barrier.wait() # 모든 쓰레드가 이 지점에 도달할 때까지 대기
results[index] = func()
for i in range(100):
t = threading.Thread(target=wrapper, args=(i,))
threads.append(t)
for t in threads:
t.start()
for t in threads:
t.join()
return results # 결과 리스트를 반환
def get_thread_singleton_instance():
s = ThreadSafeSingleton()
logging.info(s)
return s
def get_lazy_singleton_instance():
s = LazySingleton()
logging.info(s)
return s
# test_lazy_singleton 실행시
# - 테스트를 성공할때도 실패할때도 있는 것을 확인 가능
> assert len(set(result)) == 1
E assert 4 == 1
마무리
파이썬 싱글턴 패턴에 대해 알아보았습니다. 여러가지 싱글턴 패턴 중에서 어떤 것을 사용할지는 상황에 맞게 선택해야 합니다.
- 객체가 언제 생성 되는가
- 멀티스레딩 환경
Lazy 싱글턴 패턴은 인스턴스가 실제로 필요한 순간에만 생성되어 리소스를 효율적으로 사용할 수 있지만, 멀티스레딩 환경에서의 동기화 처리가 필요한 단점이 있습니다.
Eager 싱글턴 패턴은 프로그램 시작 시에 인스턴스를 생성하여 멀티스레딩 환경에서의 안전성을 보장하는 반면, 리소스 낭비의 가능성이 있습니다.
Creating classes dynamically with type
This time, let’s look at how to create a class by using type directly. type takes three arguments and creates a class dynamically.
-
name: the name of the class -
bases: a tuple of base classes -
attrs: the class dictionary
AdvancedList = type('AdvancedList', (list,), { "hello": lambda self: print("hello") })
h = AdvancedList((1,2,3,4))
h.hello()
This creates a class named AdvancedList whose base class is list, and defines a hello method as one of its attributes.
Eager
%%{
init: {
'theme': 'base',
'themeVariables': {
'primaryColor': '#2a3844',
'lineColor': '#fff',
'primaryTextColor': '#fff',
'tertiaryColor': '#fff'
}
}
}%%
classDiagram
direction BT
class SingletonMeta{
-_instances: dict
-__init__()
-__call__() Singleton
}
class Singleton{
+some_business_logic()
}
Singleton --|> SingletonMeta
Singleton implementation: Eager
The Eager singleton pattern is a form of the singleton design pattern in which the instance is created ahead of time, before it’s needed.
- Define a metaclass and override
__init__ - Use the metaclass’s
superto define the class and create the object - Override the metaclass’s
__call__
import logging
class SingletonMeta(type):
_instances = {}
def __init__(cls, name, bases, attrs):
logging.info("metaclass __init__")
super().__init__(name, bases, attrs)
instance = super().__call__()
cls._instances[cls] = instance
def __call__(cls, *args, **kwargs):
logging.info("metaclass __call__")
return cls._instances.get(cls)
from eager.meta import SingletonMeta
class Singleton(metaclass=SingletonMeta):
def some_business_logic(self):
...
from eager.singleton import Singleton
def test_meta_singleton():
obj_1 = Singleton()
obj_2 = Singleton()
assert obj_1 is obj_2
If you step through it with a debugger, you’ll see the metaclass’s __init__ method is called automatically when the class is loaded, not when an object is created from the class. That lets you create an instance for each class that uses the metaclass.
For the metaclass, super() is type, which it inherits from. This is the same process as creating a class with type, which we saw earlier. After that, when the metaclass’s __call__ method is called, it returns the object that was created in advance.
INFO root:meta.py:8 metaclass __init__
INFO root:meta.py:15 metaclass __call__
INFO root:meta.py:15 metaclass __call__
By creating the singleton instance when the program starts, this approach naturally sidesteps concurrency issues in a multithreaded environment. The Eager singleton pattern has the advantages of giving you clear control over when the instance is created and letting you access the instance at runtime without any extra handling.
On the other hand, it can waste resources and introduce dependencies on initialization order, which can make the code less flexible.
Eager pattern / Lazy pattern
The Eager and Lazy singleton patterns differ in when the object is created.
Eager singleton pattern
- Definition: In the Eager singleton pattern, the singleton instance is created when the class is loaded. That is, the instance is created and allocated in memory when the program starts or when the class is first loaded.
- Pros: Safe in a multithreaded environment. Since the instance is created at program start, it can be accessed without concurrency issues.
- Cons: The instance is created up front even when it’s never actually needed, which wastes resources.
Lazy singleton pattern
- Definition: In the Lazy singleton pattern, the singleton instance is created at the moment it’s actually needed, i.e. the first time the instance is accessed.
- Pros: The instance is created only when it’s actually needed, so resources are used efficiently. It also shortens the initial load time.
- Cons: Requires extra synchronization in a multithreaded environment.
Class diagram: Thread Safe
%%{
init: {
'theme': 'base',
'themeVariables': {
'primaryColor': '#2a3844',
'lineColor': '#fff',
'primaryTextColor': '#fff',
'tertiaryColor': '#fff'
}
}
}%%
classDiagram
direction BT
class SingletonMeta{
-_instances: dict
-__call__() Singleton
}
class Singleton{
+some_business_logic()
}
Singleton --|> SingletonMeta
Singleton implementation: Thread Safe
A race condition is a problem that can occur when multiple threads or processes access a shared resource at the same time. In a multithreaded environment, if several threads hit a Lazy singleton at the moment the instance is being created, there’s a risk of a race condition in which the “single” instance gets created multiple times.
So we need additional synchronization logic to prevent this.
- Define a metaclass and override
__new__ - Use
threading.Lock()
from typing import Self
import threading
class SingletonMeta(type):
_instances = None
_lock = threading.Lock()
def __call__(cls, *args, **kwargs) -> Self:
with cls._lock:
if cls not in cls._instances:
cls._instances[cls] = super().__call__(*args, **kwargs)
return cls._instances[cls]
from thread.meta import SingletonMeta
class Singleton(metaclass=SingletonMeta):
def some_business_logic(self):
pass
Checking in a multithreaded environment
To reproduce a race condition in a multithreaded environment, let’s add time.sleep to each metaclass. Even with a relatively short sleep in the Lazy singleton, you can see the test alternate between passing and failing. In contrast, the singleton using thread.Lock() reliably creates only one object even with a long sleep.
import threading
import time
class SingletonMeta(type):
_instances = {}
_lock = threading.Lock()
def __call__(cls, *args, **kwargs):
with cls._lock:
if cls not in cls._instances:
time.sleep(1) # added to trigger a race condition
instance = super().__call__(*args, **kwargs)
cls._instances[cls] = instance
return cls._instances[cls]
import time
class SingletonMeta(type):
_instances = {}
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
time.sleep(0.00001) # added to trigger a race condition
instance = super().__call__(*args, **kwargs)
cls._instances[cls] = instance
return cls._instances[cls]
import logging
import threading
from meta.singleton import Singleton as LazySingleton
from thread.singleton import Singleton as ThreadSafeSingleton
def test_thread_safe_singleton():
result = race_condition(get_thread_singleton_instance)
assert len(set(result)) == 1
def test_lazy_singleton():
result = race_condition(get_lazy_singleton_instance)
assert len(set(result)) == 1
def race_condition(func):
threads = []
results = [None] * 100 # list to store results
barrier = threading.Barrier(100) # make 100 threads run at the same time
def wrapper(index):
barrier.wait() # wait until every thread reaches this point
results[index] = func()
for i in range(100):
t = threading.Thread(target=wrapper, args=(i,))
threads.append(t)
for t in threads:
t.start()
for t in threads:
t.join()
return results # return the results list
def get_thread_singleton_instance():
s = ThreadSafeSingleton()
logging.info(s)
return s
def get_lazy_singleton_instance():
s = LazySingleton()
logging.info(s)
return s
# When running test_lazy_singleton
# - you can see it sometimes passes and sometimes fails
> assert len(set(result)) == 1
E assert 4 == 1
Wrapping up
We’ve looked at the singleton pattern in Python. Which of the various singleton patterns to use depends on the situation.
- When the object is created
- Whether you’re in a multithreaded environment
The Lazy singleton pattern creates the instance only when it’s actually needed, so it uses resources efficiently, but its drawback is that it needs synchronization in a multithreaded environment.
The Eager singleton pattern creates the instance at program start, guaranteeing safety in a multithreaded environment, but it can waste resources.