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)”이라는 용어는 인터프리터 간에 네이티브하게 공유할 수 있는 객체를 가리킵니다. 이는 같은 용어로 pickle 모듈을 지원하는 객체까지 설명하는 PEP 734와 다릅니다.
새로운 SharedObjectProxy 타입 외에, 네이티브하게 공유할 수 있는 객체 목록은 문서에서 확인할 수 있습니다.
동기
많은 객체를 서브인터프리터 간에 공유할 수 없습니다
Python 3.14에서는 새로운 concurrent.interpreters 모듈을 사용하여 하나의 Python 프로세스 안에 여러 인터프리터를 만들 수 있습니다. 이 방식은 공유 상태가 없는 코드에서는 잘 동작합니다. 하지만 서브인터프리터의 주요 용도 중 하나가 global interpreter lock을 우회하는 것이기 때문에, 프로그램이 쉽게 공유할 수 없는 매우 복잡한 자료 구조를 필요로 하는 경우가 꽤 흔합니다. 결과적으로 이는 동시성을 위한 서브인터프리터의 실용성을 떨어뜨립니다.
이 글을 쓰는 시점에 서브인터프리터는 소수의 타입만 네이티브하게 공유할 수 있으며, 다른 타입에 대해서는 pickle 모듈에 의존합니다. 많은 종류의 객체(open()이 반환하는 파일 객체 등)를 pickle로 직렬화할 수 없으므로, 이는 매우 제한적일 수 있습니다. 또한 직렬화는 매우 비용이 큰 연산일 수 있어 멀티스레드 애플리케이션에는 적합하지 않습니다.
근거
객체 공유를 위한 대체 수단
공유 객체 프록시는 인터프리터 간에 객체를 공유하기 위한 대체 수단으로 설계되었습니다. 공유 객체 프록시는 직렬화하거나 다른 방법으로 공유할 수 없는 매우 복잡한 객체에 대해 최후의 수단으로만 사용해야 합니다.
따라서 이 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로 저장된 컨텍스트 변수처럼 현재 thread state에 저장된 정보는 유지됩니다. 따라서 다음과 같은 경우도 올바르게 동작합니다:
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);
주어진 스레드에 어떤 인터프리터의 스레드 상태가 없으면, 공유 객체 프록시는 자체 스레드 상태를 생성합니다. 이 스레드 상태는 해당 인터프리터가 소유하며(즉, 인터프리터 종료 처리 전까지 파괴되지 않습니다), 그 스레드에서 이루어지는 모든 공유 객체 프록시 접근에 걸쳐 유지됩니다. 다시 말해 공유 객체 프록시는 스레드 지역 변수와 그와 비슷한 상태가 사라지지 않도록 보장합니다.
메모리 관리
모든 프록시 객체는 자신이 감싼 객체에 대한 strong reference를 보유합니다. 따라서 공유 객체 프록시가 감싼 객체에 대한 마지막 참조를 보유하고 있다면, 프록시가 다른 인터프리터에 속해 있더라도 프록시가 파괴될 때 감싼 객체도 파괴될 수 있습니다. 예를 들면 다음과 같습니다:
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와 그 사용법에 관한 중요한 정보는 concurrent.interpreters 문서에 추가될 것입니다.
참조 구현
이 PEP의 참조 구현은 python/cpython#145150에서 찾을 수 있습니다.
기각된 아이디어
범용 공유 프로토콜 도입
이 PEP는 원래 객체의 __share__() 메서드를 호출하거나, 그렇지 않으면 객체를 SharedObjectProxy로 암묵적으로 감싸는 share() 함수를 명시했었습니다.
이 제안이 동작하는 데 이것이 필요하지 않다고 판단되었으므로, 이 프로토콜은 향후 PEP에서 다루도록 남겨 둡니다.
프록시 객체의 직접 공유
이 제안의 초기 개정판은 SharedObjectProxy의 인스턴스를 immortal로 만드는 방식을 취했습니다. 이 덕분에 프록시 객체를 인터프리터 간에 직접 공유할 수 있었습니다. 프록시 객체의 참조 횟수가 스레드 안전했기 때문입니다(불멸 객체이므로 참조 횟수가 절대 바뀌지 않았기 때문입니다).
이 방식은 구현을 상당히 복잡하게 만드는 것으로 드러났고, 결국 CPython 유지 관리자에게 부담이 되었을 수많은 엣지 케이스도 낳았습니다.
감사의 글
이 PEP는 Eric Snow, Petr Viktorin, Kirill Podoprigora, Adam Turner, Yury Selivanov, Steve Dower와의 논의와 피드백이 없었다면 가능하지 않았을 것입니다.
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.