PEP 797 – 공유 객체 프록시
- Author:
- Peter Bierma <peter at python.org>
- Discussions-To:
- Discourse thread
- Status:
- Rejected
- Type:
- Standards Track
- Created:
- 08-Aug-2025
- Python-Version:
- 3.16
- Post-History:
- 01-Jul-2025, 13-Jan-2026
- Resolution:
- 08-Jul-2026
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
초록
이 PEP는 concurrent.interpreters 모듈에 새로운 SharedObjectProxy() 타입을 도입합니다. 이 타입을 사용하면 객체 프록시를 통해 임의의 객체를 인터프리터 간에 공유할 수 있지만, 여러 인터프리터에서 동시에 액세스할 때는 효율성이 떨어집니다.
예를 들어 다음과 같습니다.
from concurrent import interpreters
with open("spanish_inquisition.txt") as unshareable:
interp = interpreters.create()
proxy = interpreters.SharedObjectProxy(unshareable)
interp.prepare_main(file=proxy)
interp.exec("file.write('I didn't expect the Spanish Inquisition')")
용어
이 PEP에서 “share”, “sharing”, “shareable”이라는 용어는 인터프리터 간에 네이티브하게 공유 가능한 객체를 가리킵니다. 이와는 달리 PEP 734는 pickle 모듈을 지원하는 객체를 설명할 때에도 이러한 용어를 사용합니다.
새로운 SharedObjectProxy 타입 외에도, 네이티브하게 공유 가능한 객체 목록은 문서에서 확인할 수 있습니다.
동기
많은 객체는 서브인터프리터 간에 공유할 수 없습니다.
Python 3.14에서는 새로운 concurrent.interpreters 모듈을 사용하여 하나의 Python 프로세스에서 여러 인터프리터를 생성할 수 있습니다. 공유 상태가 없는 코드에는 이 방식이 잘 작동하지만, 서브인터프리터의 주요 용도 중 하나가 전역 인터프리터 잠금을 우회하는 것이므로, 프로그램에서 쉽게 공유할 수 없는 매우 복잡한 데이터 구조를 필요로 하는 경우가 상당히 흔합니다. 결과적으로 이는 동시성 측면에서 서브인터프리터의 실용성을 저해합니다.
현재 서브인터프리터는 소수의 타입만 네이티브하게 공유할 수 있으며, 다른 타입에는 pickle 모듈을 사용합니다. 이는 매우 제한적일 수 있습니다. pickle로 직렬화할 수 없는 객체 타입이 많기 때문입니다(예: open()이 반환하는 파일 객체). 또한 직렬화는 매우 비용이 많이 드는 작업일 수 있으므로, 멀티스레드 애플리케이션에는 적합하지 않습니다.
근거
객체 공유를 위한 대체 수단
공유 객체 프록시는 인터프리터 간에 객체를 공유하기 위한 대체 수단으로 설계되었습니다. 공유 객체 프록시는 직렬화하거나 다른 방법으로 공유할 수 없는 매우 복잡한 객체에 대한 최후의 수단으로만 사용해야 합니다.
이는 이 PEP가 승인되더라도 객체를 인터프리터 간에 공유하는 다른 방법을 구현하는 것이 여전히 유용하다는 의미입니다.
사양
- 여러 인터프리터에서 객체에 액세스할 수 있게 하는 프록시 타입입니다. 이 객체의 인스턴스는 서브인터프리터 간에 네이티브하게 공유할 수 있습니다.
인터프리터 전환
래핑된 객체와 상호 작용할 때 프록시는 해당 객체가 생성된 인터프리터로 전환합니다. 속성에 액세스하는 것과 같이 객체에 액세스하는 모든 경우에 이러한 전환이 발생해야 합니다. 다음 코드에서 foo는 프록시를 통해 서브인터프리터에서 액세스되더라도 항상 메인 인터프리터에서만 호출된다는 점을 시각화해 보십시오.
from concurrent import interpreters
def foo():
assert interpreters.get_current() == interpreters.get_main()
interp = interpreters.create()
proxy = interpreters.share(foo)
interp.prepare_main(foo=proxy)
interp.exec("foo()")
메서드 프록시િંગ
공유 객체 프록시의 메서드는 액세스될 때 해당 메서드를 소유한 인터프리터로 전환합니다. 또한 메서드에 전달되는 모든 인자는 암묵적으로 공유 가능한 상태가 됩니다. 기본적으로 공유할 수 없는 경우에는 SharedObjectProxy의 인스턴스로 래핑합니다. 메서드의 반환 값에도 동일한 과정이 적용됩니다.
예를 들어 객체 프록시의 __add__ 메서드는 대략 다음 코드와 동등합니다.
def __add__(self, other):
with self.switch_interpreter():
result = self.value.__add__(share(other))
return share(result)
멀티스레드 확장
래핑된 객체의 인터프리터로 전환하려면 객체 프록시는 현재 스레드의 attached thread state를 교체해야 하며, 이로 인해 대상 인터프리터의 GIL이 활성화되어 있는 경우 현재 스레드는 해당 GIL에서 대기하게 됩니다. 이는 공유 객체 프록시에 동시 액세스가 발생할 때 경합이 발생한다는 의미이지만, 대상 인터프리터의 GIL을 기다리는 동안 해당 인터프리터의 다른 스레드가 자유롭게 실행될 수 있으므로 멀티코어 스레딩에는 여전히 유용합니다.
예를 들어 여러 인터프리터가 주 인터프리터의 프록시를 통해 로그에 기록하려고 하지만 로그를 계속 기다리고 싶어 하지는 않는다고 가정하십시오. 각 인터프리터에서 별도의 스레드를 통해 프록시에 액세스하면 계산을 수행하는 스레드는 프록시에 액세스하는 동안에도 계속 실행될 수 있습니다.
from concurrent import interpreters
def write_log(message):
print(message)
def execute(n, write_log):
from threading import Thread
from queue import Queue
log = Queue()
# By performing this in a separate thread, 'execute' can still run
# while the log is being accessed by the main interpreter.
def log_queue_loop():
while True:
write_log(log.get())
thread = Thread(target=log_queue_loop)
thread.start()
for i in range(100000):
n ** i
log.put(f"Completed an iteration: {i}")
thread.join()
proxy = interpreters.SharedObjectProxy(write_log)
for n in range(4):
interp = interpreters.create()
interp.call_in_thread(execute, n, proxy)
프록시 복사
일반적인 생각과 달리 공유 객체 프록시 자체는 하나의 인터프리터에서만 사용할 수 있습니다. 프록시의 참조 횟수는 스레드로부터 안전하지 않으므로 여러 인터프리터에서 액세스할 수 없기 때문입니다. 대신 인터프리터 경계를 넘을 때 대상 인터프리터를 위한 새 프록시가 생성되며, 이 프록시는 원래 프록시와 동일한 객체를 래핑합니다.
예를 들어 다음 코드에서는 프록시가 하나가 아니라 두 개 생성됩니다.
from concurrent import interpreters
interp = interpreters.create()
foo = object()
proxy = interpreters.SharedObjectProxy(foo)
# The proxy crosses an interpreter boundary here. 'proxy' is *not* directly
# send to 'interp'. Instead, a new proxy is created for 'interp', and the
# reference to 'foo' is merely copied. Thus, both interpreters have their
# own proxy that are wrapping the same object.
interp.prepare_main(proxy=proxy)
스레드 로컬 상태
객체 프록시에 액세스하면 현재 스레드 상태에 저장된 정보가 유지됩니다. 예를 들어 threading.local이 저장하는 스레드 로컬 변수와 contextvars에 저장된 컨텍스트 변수가 이에 해당합니다. 이를 통해 다음과 같은 경우가 올바르게 작동합니다.
from concurrent import interpreters
from threading import local
thread_local = local()
thread_local.value = 1
def foo():
assert thread_local.value == 1
interp = interpreters.create()
proxy = interpreters.SharedObjectProxy(foo)
interp.prepare_main(foo=proxy)
interp.exec("foo()")
객체 프록시에 액세스할 때 스레드 로컬 데이터를 유지하려면 각 스레드가 각 인터프리터에서 마지막으로 사용된 스레드 상태를 추적해야 합니다. C에서는 이 동작이 다음과 같이 나타납니다.
// Error checking has been omitted for brevity
PyThreadState *tstate = PyThreadState_New(interp);
// By swapping the current thread state to 'interp', 'tstate' will be
// associated with 'interp' for the current thread. That means that accessing
// a shared object proxy will use 'tstate' instead of creating its own
// thread state.
PyThreadState *save = PyThreadState_Swap(tstate);
// 'save' is now the most recently used thread state, so shared object
// proxies in this thread will use it instead of 'tstate' when accessing
// 'interp'.
PyThreadState_Swap(save);
특정 스레드에서 어떤 인터프리터에 대한 스레드 상태가 존재하지 않는 경우 공유 객체 프록시는 자체 스레드 상태를 생성하며, 이 상태는 해당 인터프리터가 소유합니다. 즉 인터프리터가 종료될 때까지 삭제되지 않으며 스레드에서 이루어지는 모든 공유 객체 프록시 액세스에 걸쳐 유지됩니다. 다시 말해 공유 객체 프록시는 스레드 로컬 변수 및 이와 유사한 상태가 사라지지 않도록 보장합니다.
메모리 관리
모든 프록시 객체는 래핑하는 객체에 대한 강한 참조를 보유합니다. 따라서 공유 객체 프록시가 객체에 대한 마지막 참조를 보유하고 있다면, 해당 프록시가 다른 인터프리터에 속해 있더라도 공유 객체 프록시를 삭제하는 과정에서 래핑된 객체가 삭제될 수 있습니다. 예를 들어 다음과 같습니다.
from concurrent import interpreters
interp = interpreters.create()
foo = object()
proxy = interpreters.share(foo)
interp.prepare_main(proxy=proxy)
del proxy, foo
# 'foo' is still alive at this point, because the proxy in 'interp' still
# holds a reference to it. Destruction of 'interp' will then trigger the
# destruction of 'proxy', and subsequently the destruction of 'foo'.
interp.close()
공유 객체 프록시는 가비지 컬렉터 프로토콜을 지원하지만, 가비지 컬렉션이 래핑된 객체의 인터프리터에서 수행되는 경우에만 래핑하는 객체를 순회합니다. 시각적으로 나타내면 다음과 같습니다.
from concurrent import interpreters
import gc
proxy = interpreters.share(object())
# This prints out [<object object at 0x...>], because the object is owned
# by this interpreter.
print(gc.get_referents(proxy))
interp = interpreters.create()
interp.prepare_main(proxy=proxy)
# This prints out [], because the wrapepd object must be invisible to this
# interpreter.
interp.exec("import gc; print(gc.get_referents(proxy))")
인터프리터 수명 관리
인터프리터가 소멸된 후에도 해당 인터프리터가 소유한 객체를 래핑하는 공유 객체 프록시는 다른 곳에 남아 있을 수 있습니다. 이로 인해 충돌이 발생하는 것을 방지하기 위해 인터프리터는 자신이 소유한 객체를 가리키는 모든 프록시를 무효화하며, 이후 프록시에 액세스하면 예외가 발생합니다.
이를 보여 주기 위해 다음 코드 조각은 먼저 Alive를 출력한 다음 인터프리터를 삭제하면 RuntimeError를 발생시킵니다.
from concurrent import interpreters
def test():
from concurrent import interpreters
class Test:
def __str__(self):
return "Alive"
return interpreters.share(Test())
interp = interpreters.create()
wrapped = interp.call(test)
print(wrapped) # Alive
interp.close()
print(wrapped) # RuntimeError
하위 호환성
이 PEP에는 알려진 하위 호환성 문제가 없습니다.
보안 영향
이 PEP에는 알려진 보안 영향이 없습니다.
가르치는 방법
새로운 API와 해당 API 사용 방법에 관한 중요한 정보가 concurrent.interpreters 문서에 추가됩니다.
참조 구현
이 PEP의 참조 구현은 python/cpython#145150에서 확인할 수 있습니다.
거부된 아이디어
범용 공유 프로토콜 도입
이 PEP에서는 객체의 __share__() 메서드를 호출하거나, 그렇지 않으면 객체를 암시적으로 SharedObjectProxy로 래핑하는 share() 함수를 지정했었습니다.
이 제안이 작동하는 데 필요하지 않다고 판단했으므로, 이 프로토콜은 향후 PEP에서 수행하도록 남겨 둡니다.
프록시 객체 직접 공유
이 제안의 초기 개정판에서는 SharedObjectProxy의 인스턴스를 immortal로 만드는 접근 방식을 취했습니다. 이로 인해 프록시 객체를 인터프리터 간에 직접 공유할 수 있었습니다. 불멸성으로 인해 참조 횟수가 변경되지 않았으므로 참조 횟수가 스레드 안전했기 때문입니다.
이 방식은 구현을 훨씬 더 복잡하게 만들었으며, CPython 유지 관리자에게 부담이 되었을 많은 엣지 케이스도 발생하게 되었습니다.
감사의 말
Eric Snow, Petr Viktorin, Kirill Podoprigora, Adam Turner, Yury Selivanov, Steve Dower의 논의와 피드백이 없었다면 이 PEP는 가능하지 않았을 것입니다.
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.