Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python 개선 제안 한국어 번역

PEP 805 – 안전한 병렬 Python

Author:
Mark Shannon <Mark.Shannon at arm.com>, Daniele Parmeggiani <Daniele.Parmeggiani at arm.com>
Discussions-To:
Discourse thread
Status:
Draft
Type:
Standards Track
Created:
08-Sep-2025
Python-Version:
3.16

Table of Contents

번역·라이선스 안내

이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판

초록

이 PEP는 안전한 Python 병렬 실행을 지원하기 위해 CPython의 내부 변경 사항과 새로운 API를 제안합니다. 이 PEP를 사용하면 코드의 병렬 실행은 기본적으로 경쟁 조건이 없습니다. 객체는 병렬 스레드 간에 공유해도 안전하다고 명시적으로 선언해야 하며, 그렇지 않으면 그러한 공유가 금지됩니다.

이 PEP는 PEP 703PEP 734를 모두 기반으로 하여 통합된 실행 모델을 제공합니다. 이 모델은 PEP 703보다 더 나은 안전성, PEP 734보다 더 나은 공유 기능, 그리고 둘 중 어느 것보다도 더 나은 성능을 제공합니다.

이 PEP는 각 객체에 추가 상태를 부여하여 런타임에 낮은 비용으로 연산이 안전한지 확인하고, 안전하지 않은 경우 예외를 발생시킬 수 있도록 합니다.

동기

전통적으로 CPython은 한 번에 하나의 스레드에서만 실행되었습니다. 이는 항상 Python의 한계로 여겨져 왔으며, Python이 병렬 실행을 지원해야 한다는 요구가 수년 동안 있어 왔습니다.

CPython에서 전역 인터프리터 잠금을 선택 사항으로 만들기 위한 PEP 703과 표준 라이브러리의 다중 인터프리터를 다루는 PEP 554는 병렬성을 지원하는 방법을 제공합니다. 다중 인터프리터는 안전하면서 병렬성을 지원하지만 사용하기 어렵고, 복사하지 않고 여러 인터프리터 간에 객체를 공유하는 것은 불가능합니다. PEP 703은 병렬 실행과 공유를 지원하지만 경쟁 조건을 허용하므로 안전하지 않습니다. 경쟁 조건은 위험하고 발견하기 어려운 버그를 초래합니다. 가장 극단적인 예로 Therac-25에서는 경쟁 조건 버그로 인해 여러 명이 사망했습니다. 경쟁 조건의 문제는 경쟁 조건이 도입하는 버그가 반드시 다른 버그보다 더 심각하다는 데 있는 것이 아니라, 이러한 버그를 발견하기가 매우 어렵고 테스트를 쉽게 빠져나갈 수 있다는 데 있습니다.

언어와 런타임의 강력한 지원 없이 병렬성을 올바르게 구현하는 것은 극도로 어렵습니다.

소프트웨어 개발에서 발생하는 결함의 상당 부분은 프로그래머가 자신의 코드가 실행될 수 있는 모든 상태를 충분히 이해하지 못하기 때문에 발생합니다. 다중 스레드 환경에서는 이러한 이해 부족과 그로 인한 문제가 크게 증폭되며, 주의를 기울이고 있다면 거의 공황 상태에 이를 정도입니다.

—John Carmack (C++의 함수형 프로그래밍)

Python은 전문 소프트웨어 엔지니어뿐만 아니라 많은 기술 전문가가 사용하며 교육 분야에서도 널리 사용됩니다. 우리는 이러한 사용자들이 Java나 PEP 703과 같은 경쟁 조건이 발생하기 쉬운 모델을 사용하면서 병렬 프로그래밍의 미묘한 부분까지 처리할 수 있으리라고 기대할 수 없습니다.

CPython 하나, 둘은 아닙니다

CPython은 현재 기본 빌드와 자유 스레딩 빌드의 두 가지로 나뉘어 있습니다. 자유 스레딩 지지자들은 몇 년 안에 자유 스레딩이 CPython의 유일한 버전이 되리라고 기대합니다. 저자들은 이를 달성하는 것이 매우 어려우며 불가능할 수도 있다고 생각합니다. 기본 빌드를 제거하려면 자유 스레딩 빌드에서 사용하기에 안전하지 않은 수많은 애플리케이션과 라이브러리를 중단시켜야 합니다. 많은 라이브러리가 자유 스레딩을 지원한다고 표시되어 있더라도, 경쟁 조건을 제거하기 어렵다는 점을 고려하면 자유 스레딩 환경에서 이들 모두를 완전히 안전하게 사용하기는 어려울 것입니다.

저자들은 이 PEP 또는 이와 유사한 것이 없다면 Python의 두 빌드에 영원히 갇히게 될 것을 우려합니다. 자유 스레딩 사용자는 병렬성을 포기하지 않으려 할 것이며, 기본 빌드 사용자는 자유 스레딩 빌드를 사용하는 위험을 감수할 수 없을 것입니다.

Note

threading 모듈을 가져오거나 스레드를 사용하는 C/C++ 애플리케이션을 임베드하는 방식으로 스레드를 사용하지 않는 모든 프로그램은 새 스레드를 생성할 수 없으므로 자유 스레딩이나 이 PEP에서 사소하게 안전합니다. 이러한 애플리케이션에서는 이 PEP가 자유 스레딩 빌드보다 더 나은 성능을 제공해야 하지만, 현재 기본 빌드에 비해서는 아무런 이점도 제공하지 않습니다.

근거

안전성을 유지하면서 익숙한 병렬 실행 모델을 사용할 수 있도록 하고자 합니다. 스레드, 잠금, 큐 및 불변성은 익숙한 개념이며 안전한 실행 모델을 위한 구성 요소를 제공합니다. 객체는 스레드 간에 공유해도 안전하거나, VM이 해당 객체의 공유를 방지해야 합니다. 프로그램이 정의되지 않은 방식으로 동작할 수 있는 C++/Java 모델은 Python에 적합하지 않습니다.

이 PEP에는 두 가지 주요 목표가 있습니다.

  • 안전한 방식으로 병렬 실행을 허용하는 메커니즘을 제공하는 것입니다.
  • 갑작스러운 호환성 손상 없이 애플리케이션을 단일 스레드 사용에서 여러 병렬 스레드 사용으로 점진적으로 전환할 수단을 제공하는 것입니다.

동기화 사분면 다이어그램

Unshared Shared
Mutable objects 😊 🔥 😨 🔥
Immutable objects 😊 😊

위 표는 네 가지 동기화 사분면을 보여 줍니다. 객체를 변경할 수 있고 병렬 스레드에서 접근할 수 있을 때에만 경쟁 조건이 발생할 수 있습니다. 이 PEP는 오른쪽 위 사분면에서 실행되는 코드의 양을 최소화하여 안전성을 제공하고자 합니다. 방법은 다음과 같습니다.

  • 위험한 사분면에서 인접한 두 사분면 중 하나로 실행을 이동할 수 있는 메커니즘을 제공하고,
  • 위험한 사분면에서의 실행이 적절하게 동기화되도록 보장합니다.

불변성은 동기화 없이도 안전한 실행을 가능하게 하므로, 이 PEP는 객체를 불변으로 만드는 메커니즘을 제공합니다. 불변성이 불가능한 경우, 이 PEP는 해당 객체가 하나의 실행 스레드에만 보이도록 하거나(왼쪽 위 사분면), 상호 배제 잠금(뮤텍스)으로 보호되도록 보장하여 안전한 실행을 위한 메커니즘을 제공합니다. 또한 이 PEP는 변경 가능한 객체가 공유될 때 안전하지 않은 실행을 방지하도록 CPython을 변경할 것을 제안합니다. 마지막으로 이 PEP는 병렬 실행으로 점진적으로 전환할 수 있도록 GIL을 일반화하는 방안을 제공합니다.

이 PEP는 OCaml의 아이디어, 구체적으로 Data Freedom à la ModePyrona project에서 영감을 받았습니다. 편향 참조 카운팅과 지연 참조 카운팅을 비롯한 필요한 여러 기술은 PEP 703을 위해 개발되었습니다.

명세

이 PEP는 현재 실행 스레드에서 객체에 접근하는 것이 안전한지에 따라 VM이 객체에 대한 접근을 제어할 것을 제안합니다.

핵심 개념은 객체에 대한 연산이 아니라 객체에 대한 접근을 제어한다는 것입니다. 스레드가 객체에 접근할 수 없다면 해당 스레드는 그 객체에 대해 안전하지 않은 연산을 수행할 수 없습니다. 그 객체에 대해 어떤 연산도 수행할 수 없기 때문입니다.

이에 대한 동기는 정확성과 성능 모두입니다. 연산을 보호하려면 어떤 연산이 경쟁 조건 없이 실행되고 어떤 연산이 그렇지 않은지 정확히 설명하는 상세한 모델이 필요합니다. 일부 표준 라이브러리 클래스에서는 가능할 수도 있지만, 일반적으로는 불가능하며 오류가 발생하기 매우 쉽습니다. 모든 객체에 대한 모든 연산을 검사하는 것 또한 비용이 지나치게 많이 듭니다. 객체별로 접근을 제어하면 비용을 낮게 유지할 수 있습니다. 몇 가지 드문 예외를 제외하면 힙 참조로부터 스레드 참조가 생성될 때에만 해당 연산을 검사할 필요가 있습니다.

Note

정확성은 객체에 대한 연산을 검사하는 것이 아니라 주로 객체에 대한 접근을 제한함으로써 보장됩니다. 이는 Java 및 C#과 같은 언어에서 사용되는 동기화 기법과 다릅니다.

객체 상태

모든 객체는 __shareable__ 상태를 갖게 되며, Python VM은 이 상태를 사용하여 객체가 안전하게 사용되도록 보장합니다. 객체의 __shareable__속성을 확인하여 이 상태를 조회할 수 있습니다.

객체의 __shareable__상태는 다음 중 하나일 수 있습니다:

  • 불변(Immutable): 수정할 수 없으며, ThreadGroups 사이에서 안전하게 공유할 수 있습니다.
  • 로컬(Local): 하나의 ThreadGroup에만 표시되며, 해당 ThreadGroup에 속한 스레드가 자유롭게 변경할 수 있습니다.
  • 보호됨(Protected): 객체는 변경 가능하며 뮤텍스로 보호됩니다.
  • 동기화됨(Synchronized): 일부 내장 객체에 대한 특수한 상태입니다. 객체에 대한 모든 연산은 내부적으로 보호되므로 외부 동기화가 필요하지 않습니다.

__shareable__속성은 읽기 전용입니다:

>>> o = object()
>>> o.__shareable__
Shareable.LOCAL
>>> o.__shareable__ = True
TypeError: cannot assign to __shareable__

클래스, 함수 및 모듈

모든 클래스는 local로 생성되지만, synchronized 또는 immutable로 만들 수 있습니다. 병렬 프로그램에서 최고의 안전성과 성능을 얻으려면 가능한 경우 클래스를 immutable로 만들어야 합니다.

수정 가능한 자유 변수가 있는 함수와 내부 함수가 수정할 수 있는 변수가 있는 함수는 local로 생성됩니다. 그 외의 모든 함수는 synchronized입니다. __kwdefaults__속성은 frozendict가 됩니다. __kwdefaults__속성은 여전히 변경할 수 있지만, 객체 자체를 변경하는 것이 아니라 전체 객체를 다시 할당하는 방식으로만 변경할 수 있습니다. 함수의 __code__, __closure__, __defaults__ 또는 __kwdefaults__속성을 수정하는 것은 더 이상 권장되지 않을 예정입니다.

대부분의 함수는 synchronized입니다. 예를 들면 다음과 같습니다.:

def egg():
    print("egg")

>>> t = Thread(target=egg, group=ThreadGroup("other"))
>>> t.start()
egg

그러나 클로저를 변경하는 내부 함수는 local입니다. 예를 들면 다음과 같습니다.:

def spam():  # this is local
    x = 0

    def inner():  # this is also local
        nonlocal x
        x += 1

    return inner

>>> func = spam()
>>> t = Thread(target=func, group=ThreadGroup("other"))
>>> t.start()
IllegalThreadAccessException:
    <function spam.<locals>.inner...> cannot be accessed by ThreadGroup 'other'

모듈은 로컬로 생성되며, 클래스와 마찬가지로 명시적으로 동결하거나 동기화할 수 있습니다. 원칙에 따라 모듈을 synchronized 또는 immutable로 만드는 데 도움이 되도록 모든 모듈은 전역 변수 __module__을 갖습니다. __module__은 모듈 객체를 참조하며 모듈이 생성될 때 초기화됩니다.

Python 모듈을 동결하려면 해당 모듈 코드의 끝에 다음을 추가하십시오.:

freeze(__module__)

Python 모듈을 동기화하려면 다음 코드를 추가하십시오.:

__module__.synchronize()

확장 모듈은 C API를 사용하여 자신을 immutable 또는 synchronized라고 선언할 수 있습니다.

가능한 경우 모듈을 동결해야 합니다.

기타 객체

뷰, 이터레이터 및 다른 변경 가능한 객체의 내부 상태에 의존하는 기타 객체는 해당 객체의 상태를 상속합니다. 예를 들어, locallistlistiteratorlocal로 생성되지만, protectedlistlistiteratorprotected로 생성됩니다. immutable 객체의 뷰와 이터레이터는 생성될 때 local로 생성됩니다.

그 외의 본질적으로 불변이 아닌 모든 객체(튜플이나 문자열과 같은)는 local로 생성됩니다. 이러한 local 객체는 나중에 immutable로 만들거나 protected로 만들 수 있습니다.

SynchronizedList, SynchronizedDictSynchronizedSet이라는 세 가지 새 클래스가 추가됩니다. 이들은 각각 list, dictset동기화된 버전입니다. 이 클래스들은 Python과 C 모두에서 원래 클래스와 동일한 API를 제공합니다. 동기화된 모듈의 __dict__SynchronizedDict가 되며, sys.modules도 마찬가지입니다. sys.pathSynchronizedList가 됩니다.

동기화된 클래스들은 객체 자체가 손상되지 않는다는 좁은 의미에서는 경쟁 조건을 방지하지만, 일반적으로 스레드 안전하지는 않습니다. 가능한 경우 불변 또는 로컬 컬렉션을 사용해야 합니다.

객체 딕셔너리

Python의 거의 모든 객체에는 __dict__ 속성이 있습니다. 객체를 동결하면 해당 객체의 __dict__frozendict로 변환됩니다. 모듈(또는 동기화를 지원하면서 __dict__도 가진 모든 객체)을 동기화하면 해당 객체의 __dict__SynchronizedDict로 변환됩니다.

ThreadGroup 객체

GIL에 현재 의존하고 있거나(우연히 또는 의도적으로), 다중 프로세싱을 사용하거나, _interpreters 모듈을 사용하는 애플리케이션을 스레드를 사용한 병렬 실행으로 포팅할 수 있도록 새 클래스인 threading.ThreadGroup이 추가됩니다.

ThreadGroup 객체를 공유하는 모든 스레드는 현재 모든 스레드가 GIL에 의해 직렬화되는 것과 동일한 방식으로 직렬화됩니다. 여러 ThreadGroup을 사용하면 다중 프로세싱이나 여러 인터프리터와 거의 동일한 기능을 제공하면서도 오버헤드가 더 낮고 복사 없이 객체를 공유할 수 있습니다.

스레드와 ThreadGroup사이에는 다대일 관계가 있습니다.

이전에는 사용되지 않던 Thread 클래스의 group 매개변수는 해당 Thread가 속한 ThreadGroup을 지정하는 데 사용됩니다. 다른 스레드와 병렬로 실행할 수 있는 스레드를 만들려면 Thread(group = ThreadGroup(), ...)을 사용하십시오. group이 설정되지 않았거나 None일 때의 동작은 아래의 GIL을 참조하십시오.

ThreadGroup의 모든 스레드가 동일한 로컬 객체에 액세스할 수 있지만, 모든 잠금에 대해서는 각 스레드가 서로 다른 것으로 취급되며, 따라서 보호된 객체에 대해서도 서로 다른 것으로 취급됩니다.

현재 스레드 그룹은 threading.current_thread().group을 사용하여 확인할 수 있습니다.

병렬 처리를 위한 ThreadGroup 사용

Python “with GIL”용으로 개발된 프로그램을 기반으로 시작하여, 추가 ThreadGroup을 추가하면 병렬 처리를 구현할 수 있습니다. 프로그램이 이미 여러 스레드를 사용하는 경우, 이러한 스레드를 새 ThreadGroup으로 이동하여 코드가 안전하게 병렬로 실행되도록 할 수 있습니다.

잠금 및 보호

변경 가능한 Python 객체는 로컬 또는 보호된 객체일 수 있습니다. 변경 가능한 Python 객체를 ThreadGroup 간에 공유하려면 해당 객체는 보호된 객체여야 합니다. Lock 또는 RLockprotect 메서드를 호출하면 모든 로컬 객체로부터 보호된 객체를 만들 수 있습니다:

def protect(self: Lock | RLock, obj: T) -> Protected[T]

보호된 객체는 with 문 외부에서는 액세스할 수 없으며, 보호하는 뮤텍스가 컨텍스트 관리자인 with 문 내부에서 호출된 함수에서도 액세스할 수 없습니다.

LockRLock 클래스

threading.Lockthreading.RLock 클래스에는 객체를 보호하기 위한 protect 메서드가 추가됩니다. protect를 호출하면 해당 잠금은 보호용 잠금이 됩니다.

컨텍스트 관리자로 사용되는 잠금은 보호된 객체에 대한 접근을 경쟁 조건 없이 직렬화하여 제공합니다.:

m = Lock()
with m:
    l = m.protect([])

with m:
    l.append(0)
l.append(1) # Raises an exception as mutex is not held.

또한 잠금을 추가하여 복합 잠금을 구성할 수 있습니다. 덧셈은 교환법칙이 성립하므로:

def func1(a, b):
    with locka + lockb:
        ...

def func2(a, b):
    with lockb + locka:
        ...

func1func2를 동시에 호출해도 데드락이 발생하지 않습니다.

protective 잠금에서 acquire 또는 release를 호출하는 것은 오류입니다. 이러한 잠금은 해당 잠금 또는 이를 기반으로 구성된 복합 잠금을 컨텍스트 관리자로 사용하는 with 문을 통해서만 획득할 수 있습니다.

새로운 API

이 PEP에서는 다음을 추가할 것을 제안합니다.

  • 모든 Python 클래스에 추가되는 __freeze__() 메서드로, 객체를 동결하여 변경 불가능하게 만듭니다(확장 클래스는 __freeze__()를 구현할 수 있지만, 반드시 구현해야 하는 것은 아닙니다).
  • obj.__freeze__()를 호출하는 내장 freeze(obj) 함수
  • LockRLock에 추가되는 protect(obj) 메서드로, objprotected 사본을 반환합니다.
  • SynchronizedList, SynchronizedDictSynchronizedSet 클래스
  • list, setdict에 추가되는 synchronize() 메서드로, 해당 객체의 synchronized 버전을 반환하고 원래 객체를 비웁니다.
  • 모든 객체에 대한 읽기 전용 __shareable__ 속성
  • 객체를 한 ThreadGroup에서 다른 곳으로 전달하는 ChannelTransferBox 클래스
  • ThreadGroup 클래스
  • Threads 를 생성할 때 사용되는 group 매개변자가 이제 의미를 가지며 ThreadGroup으로 설정할 수 있습니다.
  • 스레드에 대한 읽기 전용 group 속성
  • 모듈 생성 시 해당 모듈을 가리키도록 설정되는 __module__ 전역 변수
  • 디버거 및 유사한 도구를 위한 sys.monitoring.StopTheWorld 컨텍스트 관리자 객체

freeze() 함수는 데코레이터로 사용할 수 있습니다.

동결

__freeze__() 메서드의 시그니처는 __freeze__(self: Self) -> Frozen[Self]이며, Frozen[T]T에 대한 동결된 클래스입니다. __freeze__가 반환하는 값은 원래 객체입니다. 즉, obj.__freeze__() is obj입니다. 서로 다른 타입의 반환 값을 사용하면 타입 검사기가 어떤 변수가 동결된 객체를 가리키는지 추적하는 데 도움이 될 수 있습니다.

모든 순수 Python 클래스와 일부 표준 라이브러리 내장 컬렉션에 __freeze__()가 추가됩니다. setdict 클래스에는 __freeze__() 메서드가 추가되어 객체를 각각 frozenset 또는 frozendict로 변환합니다.

객체를 동결하는 것은 얕은 연산이라는 점에 유의하십시오. x.__freeze__()x만 동결하며, x가 참조하는 객체는 동결하지 않습니다.

객체를 동결하면 해당 딕셔너리도 동결됩니다.

>>> type(x.__dict__)
<class 'dict'>
>>> freeze(x)
>>> type(x.__dict__)
<class 'frozendict'>

객체를 임의의 방식으로 동결하면 타입 검사기와 다른 개발자 모두를 혼란스럽게 할 수 있습니다. 따라서 일반적으로 클래스의 모든 인스턴스를 동결하거나 하나도 동결하지 않는 등, 원칙에 따른 방식으로 동결하는 것이 권장됩니다. 예를 들면 다음과 같습니다.:

class ImmutablePoint:

    def __init__(self, x, y):
        self.x = x
        self.y = y
        self.__freeze__()

서브클래싱에서는 몇 가지 어려움이 발생할 수 있습니다. 슈퍼클래스의 __init__는 서브클래스의 __init__ 메서드가 인스턴스 초기화를 완료하기 전에 인스턴스를 동결할 수 없기 때문입니다.

서브클래싱을 지원하려면 __init__ 메서드에 freeze 매개변수가 있어야 하며, 이를 통해 서브클래스가 초기화가 완료될 때까지 동결을 지연할 수 있습니다.:

def __init__(self, args, freeze=True):
    # initialize
    if freeze:
        self.__freeze__()

# subclass __init__
def __init__(self, args, freeze=True):
    super().__init__(args, freeze=False)
    # initialize
    if freeze:
        self.__freeze__()

Note

다양한 freeze 메서드는 VM에서 완전히 지원합니다. 불변성은 단순한 관례가 아니며, VM에서 강제됩니다. 객체가 한 번 동결되면 동결을 해제할 수 없습니다.

__deep_freeze__ 메서드는 future enhancement로 추가될 수 있습니다.

freeze 함수는 클래스를 동결하는 데코레이터로 사용할 수 있습니다.:

@freeze
class C:
    """This class cannot be modified once constructed.
       Instances of this class can still be mutated unless
       explicitly frozen
    """

동기화

synchronized 상태는 객체의 내부 상태를 보호하지만, 일부 내장 객체와 확장 객체에서만 사용할 수 있습니다.

병렬 스레드 간에 변경 가능한 값 전달

ThreadGroup 간에 local 객체를 전달하기 위한 두 클래스가 제공됩니다.

TransferBox 클래스는 한 ThreadGroup에서 다른 ThreadGroup으로 객체를 이동하기 위한 synchronized 컨테이너를 제공합니다.

local 객체에서 TransferBox를 생성할 때 객체가 박싱되기 전에 복사됩니다. 새 local 객체는 어떤 ThreadGroup에도 연결되지 않습니다.

박스에서 객체를 가져올 때, 박스의 sinkNone이거나 현재 ThreadGroup인 경우 현재 ThreadGroup이 객체의 소유자가 됩니다.

Immutable, protectedsynchronized 객체는 복사되지 않은 상태로 전달됩니다.:

EMPTY = sentinel('EMPTY')

class TransferBox[T]:

    def __new__(cls, obj: T, sink: ThreadGroup | None=None):
        self.sink = sink
        self._obj = copy(obj) if obj.__state__ == LOCAL else obj

    def claim(self) -> T:
        if self._obj is EMPTY:
            raise ValueError(...)
        if self.sink is not None and self.sink != current_ThreadGroup:
            raise ValueError(...)
        result = self._obj
        self._obj = EMPTY
        return result

Channel 클래스는 한 ThreadGroup에서 다른 ThreadGroup으로 객체를 전달하기 위한 더 높은 수준의 API를 제공합니다. Channel은 다음 Python 클래스와 동일합니다.:

class Channel:

    def __init__(self):
        self.mutex = Lock()
        with self.mutex:
            self.queue = self.mutex.protect(deque())
        self.__freeze__()

    def put(self, obj):
        box = TransferBox(del obj)
        with self.mutex:
            self.queue.append(box)

    def get(self):
        with self.mutex:
            return self.queue.popleft().claim()

충분한 수요가 있다면 “deep” put 메서드가 future enhancement로 추가될 수도 있습니다.

Main ThreadGroup

인터프리터가 시작될 때 “Main”이라는 이름의 ThreadGroup이 생성되어 sys.main_thread_group에 저장됩니다. sys.main_thread_group은 읽기 전용이며, sys 모듈이 삭제되더라도 “Main” ThreadGroup은 수명이 있는 모든 객체보다 오래 존속합니다. 주 스레드의 groupsys.main_thread_group이 됩니다:

>>> threading.current_thread()
<_MainThread(MainThread, started ...)>
>>> threading.current_thread().group
<ThreadGroup 'Main'>

Main ThreadGroup은 모든 스레드의 실행을 직렬화한다는 점에서 GIL과 유사합니다. 스레드가 다른 ThreadGroup에 속하는 것으로 명시적으로 표시된 경우에만 병렬성이 존재합니다.

명시적으로 또는 기본값으로 group=None으로 생성된 스레드의 경우 그룹 선택은 PYTHON_PARALLEL환경 변수로 결정됩니다:

  • PYTHON_PARALLEL이 0이 아닌 값으로 설정되면 해당 스레드를 위한 새 ThreadGroup이 생성됩니다.
  • 그렇지 않으면 스레드의 그룹은 sys.main_thread_group입니다.

객체 상태 및 허용되는 연산

허용되는 연산

객체 상태 불변 로컬 = 스레드 로컬 ≠ 스레드 보호됨 동기화됨
참조 획득 아니요 1
freeze() 효과 없음 2 해당 없음 아니요 2
protect() 아니요 2,3 해당 없음 아니요 아니요
synchronize() 아니요 2 해당 없음 아니요 아니요
기타 모든 연산 해당 없음
  1. 객체를 보호하는 뮤텍스가 스레드가 보유한 뮤텍스 집합에 포함된 경우입니다.
  2. 해당 클래스에서 지원되는 경우입니다.
  3. 인자는 객체에 대한 유일한 참조여야 합니다.

ABI 호환성 파괴

PyObject 구조체를 변경해야 하므로 이 PEP에서는 PEP 703과 마찬가지로 ABI를 한 번 중단해야 합니다.

지연된 회수

변경 불가능하고 동기화된 객체는 회수를 지연할 수 있습니다. 동기화된 리스트나 딕셔너리에 참조가 저장된 객체도 회수를 지연할 수 있습니다. 즉, 해당 객체에 대한 참조가 더 이상 없더라도 즉시 회수되지 않을 수 있습니다.

이는 이러한 객체가 여러 스레드에서 동시에 참조될 수 있으며, 참조 카운트 연산을 직렬화하는 오버헤드가 너무 클 수 있기 때문입니다. 구현은 PEP 703와 동일하게 동작합니다.

하나의 ThreadGroup에만 표시되는 로컬 객체는 더 이상 참조되지 않게 되면 여전히 즉시 회수됩니다.

새로운 예외

두 개의 새로운 예외 클래스가 추가됩니다.

  • 다른 ThreadGroup에 속한 local 객체에 대한 참조를 획득하려고 스레드가 시도하는 경우의 IllegalThreadAccessException입니다.
  • 필요한 잠금을 보유하지 않은 채 protected 객체에 대한 참조를 획득하려고 스레드가 시도하는 경우의 UnprotectedAccessException입니다.

병렬 처리 및 컨텍스트 전환

각 ThreadGroup은 독립적이며, 일부 또는 모든 ThreadGroup이 서로 병렬로 실행될 수 있습니다. ThreadGroup 내에서는 언제든 하나의 스레드만 실행될 수 있습니다.

ThreadGroup 내 스레드 간 전환은 다음 위치에서 발생할 수 있습니다.

  • 호출 지점에서
  • 모든 루프 본문의 끝(백 엣지)에서
  • 함수 진입 시
  • 위 항목 중 하나가 발생하는 호출 중에
  • 확장 함수 호출 중에
  • finally 블록을 포함한 모든 예외 처리기의 끝에서

Python의 많은 연산자는 호출을 수행합니다. 따라서 Primitive Types에 대해 연산하는 경우가 아니라면, 모든 수학 연산자나 인덱싱에서 컨텍스트 전환이 허용될 수 있다고 가정하는 것이 가장 안전합니다.

다음 연산자에서는 컨텍스트 전환이 허용되지 않습니다.

  • Primitive Types에 대한 수학 연산
  • list 또는 tuple에서 int 서첨자를 사용한 인덱싱
  • 모든 dict의 키가 str인 경우 str 키를 사용한 dict 인덱싱

인트로스펙션 및 디버거

일반적으로 local 객체는 다른 ThreadGroup에 속한 스레드가 액세스할 수 없으며, 관련 잠금을 보유하지 않고는 protected 객체에도 액세스할 수 없습니다. 그러나 이렇게 하면 디버거 및 이와 유사한 도구가 여러 실행 스레드를 introspect할 수 없게 됩니다.

이 특수한 경우를 허용하기 위해 특수 컨텍스트 관리자 sys.monitoring.StopTheWorld가 제공됩니다. 이 컨텍스트 관리자를 사용하는 with 문 내에서는 컨텍스트 관리자에 진입하는 스레드를 제외한 모든 스레드가 ContextSwitch 지점에서 중지되며, 컨텍스트 관리자 내부의 스레드는 모든 객체에 액세스하고 수정할 수 있습니다.

기본 타입

다음 타입은 기본 타입으로 정의됩니다.

  • bool
  • int
  • float
  • NoneType
  • str
  • bytes

기본 타입에는 다음과 같은 속성이 있습니다.

  • 변경할 수 없으므로 항상 ThreadGroup들 간에 공유할 수 있습니다.
  • 해당 타입에 대한 연산 중에는 컨텍스트 전환이 발생하지 않습니다.
  • 더 이상 참조되지 않게 된 후에도 회수가 지연될 수 있습니다.

C 확장 및 C API

Note

다음 절에서 “C 확장”이라는 용어는 Rust, C++, Fortran 또는 네이티브로 컴파일되는 기타 언어로 작성된 확장에도 적용됩니다.

기본적으로 모든 C 확장 모듈, 클래스 및 해당 인스턴스는 local입니다. PyObject_DeclareSynchronized() 또는 PyObject_DeclareImmutable()를 호출하여 객체를 각각 synchronized 또는 immutable로 선언할 수 있습니다.

객체를 synchronized로 선언할 때는 주의하십시오. 잘못 선언하면 경쟁 조건이 발생하여 충돌과 데이터 손실을 일으킬 수 있습니다. 불변성은 동기화보다 훨씬 올바르게 구현하기 쉽고 더 안전하며, 흔히 더 나은 성능을 제공합니다.

자유 스레딩에서 작동하도록 강화된 C 확장은 적절한 경우 객체를 synchronized 또는 immutable로 표시해야 합니다.

객체가 경쟁 조건 없이 안전하다는 확신이 없다면 local로 유지해야 합니다.

특정 확장 객체를 의도적으로 로컬로 유지하도록 선택하는 것은 전적으로 허용되는 설계 선택이며, VM이 이를 적용합니다. 예를 들어 모든 C 수준 데이터 경쟁을 해결한 후에도 객체 자체의 의미 체계로 인해 객체에 대한 동시 액세스가 불가피하게 비결정적 동작을 생성할 수 있는 경우가 이에 해당합니다.

C API 함수

모든 C API 함수는 반환되는 참조가 있는 경우 해당 참조가 현재 스레드에서 안전하게 액세스될 수 있는지 확인하도록 수정됩니다.

확장 API

액세스 가능성을 확인하는 것은 VM의 책임이므로, 확장 API의 일부로 C 확장이 구현한 C 함수는 수정할 필요가 없습니다. VM은 반환되는 모든 값에 대해 필요한 검사를 수행합니다.

하위 호환성

기본 빌드

기본 빌드와 비교하면, 호환되지 않는 변경 사항은 일부 객체(primitive types의 객체)의 수명이 연장되어 메모리 사용량이 증가할 수 있다는 점뿐입니다.

자유 스레딩 빌드

가장 명확한 변경 사항은 변경 가능한 객체를 공유할 때 데이터 경쟁을 허용하는 대신 IllegalThreadAccessException을 발생시킨다는 점입니다.

이는 사례별로 해결할 수 있습니다. 변경 가능한 공유 객체가 이미 잠금으로 보호되고 있다면 해당 객체를 protected로 만드십시오. 자세한 내용은 잠금 및 보호를 참조하십시오. (이렇게 하면 이러한 애플리케이션의 스레드 안전성도 보장하는 데 도움이 됩니다.) 새로운 synchronize() 메서드를 사용하여 변경 가능한 공유 리스트와 딕셔너리를 동기화된 버전으로 변환하십시오. 자세한 내용은 새로운 API를 참조하십시오. (동기화된 딕셔너리와 리스트는 자유 스레딩 빌드에서도 그러하듯 특정 경쟁 조건을 허용합니다. 이러한 조건이 이미 허용 가능했다면 다른 변경은 필요하지 않습니다.) 그렇지 않다면, 변경 가능한 공유 객체가 이미 동기화된 객체 범주에 속하는 경우 변경이 필요하지 않습니다.

또한 이 PEP는 스레드가 local 변경 가능한 객체에 대한 참조를 여러 스레드가 볼 수 있는 힙에 저장하는 것을 막지 않습니다(예를 들어 공유 리스트에 추가하는 방식). 그러나 소유하지 않은 스레드가 해당 객체에 대한 참조를 얻으려고 시도하면 예외가 발생합니다(예를 들어 공유 리스트에서 꺼내는 방식). 따라서 딕셔너리나 리스트를 동기화된 상태로 전환할 때는 주의해야 합니다.

각 새 스레드에 대해 ThreadGroup을 명시적으로 설정하지 않고 스레드를 병렬로 실행하려면 환경 변수 PYTHON_PARALLEL을 1로 설정해야 합니다.

안전성

Localimmutable 객체는 동시에 수정될 수 없으므로 경쟁 조건에 항상 안전합니다.

그러나 protectedsynchronized 객체를 사용할 때는 주의해야 합니다.

아래의 Examples에서 경쟁 조건이 없는 Counter 클래스와 그렇지 않은 Counter 클래스를 만드는 방법을 참조하십시오.

성능

Python을 포함한 모든 동적 언어에서 좋은 성능을 얻는 핵심은 가능성이 가장 높은 타입이나 값에 맞게 코드를 특수화하는 것입니다. 비용이 많이 드는 일반적인 연산을 수행하는 대신, 예상이 충족되는지 확인하는 저렴한 검사를 수행한 다음 효율적으로 맞춤화된 연산을 수행합니다.

리스트를 인덱싱하는 예를 들어 보겠습니다: l[x] GIL을 사용하는 경우 먼저 l이 리스트인지, x가 정수인지, x가 범위 내에 있는지 확인하여 이를 수행할 수 있습니다. 그러면 리스트의 배열에서 값을 직접 읽을 수 있습니다. 그러나 자유 스레딩 빌드에서는 인덱싱되는 동시에 다른 스레드가 리스트를 변경했을 수 있으므로 이 방식이 작동하지 않으며, 추가 동기화가 필요합니다. 추가 동기화는 성능을 저하시키지만 애플리케이션 수준의 경쟁 조건에 대해 유용한 보호를 제공하지는 않습니다.

이 PEP는 가드에 리스트가 local임을 확인하는 추가 검사를 넣어 병렬 코드에서 좋은 성능을 낼 수 있도록 합니다. l은 로컬 변수에 저장될 가능성이 높으므로 이미 local이어야 하며 추가 검사는 필요하지 않습니다.

그러나 추가 검사는 여전히 필요합니다. 스레드가 소유한 참조가 생성될 때마다 해당 참조가 유효한지 확인하는 검사가 필요합니다. 객체가 ThreadGroup에 대해 local인지, immutable인지, synchronized인지, 또는 protected상태이며 올바른 잠금이 유지되고 있는지를 확인해야 하므로 이러한 검사는 비교적 비용이 많이 들 수 있습니다. 그러나 특수화하는 적응형 인터프리터나 JIT는 이러한 연산을 특수화하거나 제거할 수 있습니다.

일반적인 검사는:

if obj.__state__ == LOCAL and obj.__owner__ == current_threadgroup_id:
    pass # Good
elif obj.__state__ == IMMUTABLE or obj.__state__ == SYNCHRONIZED:
    pass # Good
elif obj.__state__ == PROTECTED and obj.__owner__ in thread.held_mutexes():
    pass # Good
elif sys.monitoring.StopTheWorld.within:
    pass # Good
else:
    raise ... # Bad

비용이 많이 들지만, 예상되는 경우에 맞게 특수화하면 검사를 저렴하게 만들 수 있습니다. 예를 들어 local 객체를 예상한다면 훨씬 저렴한 검사를 수행할 수 있습니다.:

if obj.__owner__ == current_threadgroup_id:
    pass # Good
else:
    do_general_check(obj)

ThreadGroup ID와 lock ID가 서로 다르도록 보장한다면

병렬성이 성능에 미치는 영향입니다

모든 스레드가 하나의 ThreadGroup에 속한다면 JIT는 local 객체에 대한 검사를 제거할 수 있습니다(이러한 검사는 항상 통과하기 때문입니다). 그 결과 현재 with-gil 빌드와 매우 유사한 성능을 얻습니다.

필요한 잠금의 양에 따라 병렬성을 추가했을 때의 성능 영향은 불변 객체만 공유되고 다른 모든 객체가 로컬인 경우처럼 거의 0에 가까울 수도 있고, 잠금으로 인해 수 퍼센트에 이를 수도 있지만, 그래도 free-threading 빌드보다 성능이 좋습니다.

JIT가 수행할 수 있는 많은 최적화에는 객체의 상태가 최적화기에 보이지 않는 방식으로 변경되지 않아야 한다는 조건이 필요합니다. PEP PEP 703의 의미는 불명확하거나 이러한 종류의 최적화를 명시적으로 방지합니다. localimmutable 객체를 추가하면 많은 최적화를 다시 활성화할 수 있습니다.

보안 관련 영향입니다

이 PEP는 경쟁 조건을 줄이거나 제거하여 병렬 코드에 더 강력한 보안을 제공합니다.

이것을 가르치는 방법입니다

이 PEP는 protectedsynchronized 객체를 사용하는 복잡한 병렬성 접근 방식을 허용하지만, Sharing Xor Mutability(SXM) 모델이나 Actor 모델과 같은 더 단순한 접근 방식을 권장합니다. 이러한 더 단순한 모델을 사용하면 과도한 복잡성 없이 병렬성을 추가하는 데 도움이 됩니다.

Sharing Xor Mutability 모델입니다

SXM 모델에서는 모든 데이터가 변경 가능하거나 공유됩니다. 불변 데이터만 공유할 수 있습니다. 이 모델은 안전하고 이해하기 쉽습니다. 여러 인터프리터 또는 멀티프로세싱을 사용하는 모든 애플리케이션은 이미 이 모델의 더 제한적인 형태를 사용하고 있습니다.

애플리케이션을 이 모델을 사용하여 구현할 수 있다면 그렇게 해야 합니다. 안전하고, 추론하기 쉬우며, 우수한 성능을 제공할 수 있습니다.

SXM 모델은 모든 객체를 immutable(공유 가능) 또는 local(변경 가능)로 만들어 구현할 수 있습니다.

학술 문헌에서는 SXM 모델을 Aliasing Xor Mutability의 약자인 AXM이라고도 부릅니다. 정적으로 컴파일되는 언어에서는 앨리어싱이 공유 가능성을 의미하기 때문입니다.

통신 순차 프로세스입니다

this model에서는 병렬 “프로세스”(또는 Actor)가 메시지 전달을 통해서만 상호 작용합니다. 이는 ThreadGroupChannel를 사용하여 구현할 수 있습니다.

병렬성에 대한 다른 접근 방식입니다

더 정교한 병렬성 모델을 구현하려면 실행 모델을 명확히 이해해야 합니다. 안전하지 않은 코드를 작성하는 일은 PEP 703에서보다 훨씬 어렵지만, 새로운 예외가 사용자를 당황하게 할 수 있습니다. 광범위한 문서가 제공될 예정입니다.

예제입니다

이 PEP의 새로운 기능을 사용하는 방법을 보여 주는 다양한 예제가 examples appendix에 있습니다.

PEP 703과의 관계(CPython에서 전역 인터프리터 잠금을 선택 사항으로 만들기)

이 PEP는 PEP 703와 경쟁하기보다는 이를 기반으로 구축하는 것으로 생각해야 합니다. 이 PEP를 구현하는 데 필요한 많은 메커니즘은 PEP 703을 위해 개발되었습니다.

안전성

PEP 703에는 잘 정의된 의미론이 없지만, 대부분의 경우 순차 일관성 모델이 가정된 의미론인 것으로 보입니다. 안타깝게도 순차 일관성은 많은 경쟁 조건을 방지하기에는 지나치게 세분화되어 있습니다.

PEP 703은 리스트, 딕셔너리 및 기타 변경 가능한 객체에 대해 우수한 단일 스레드 성능을 제공하는 동시에 국소적으로 경쟁 조건이 없는 동작을 제공하려고 합니다.

안타깝게도 정확한 동작에 대한 공식적인 정의가 제공되지 않아 다음과 같은 문제가 발생합니다.

성능

동기화에는 많은 비용이 듭니다. CPU의 높은 클록 속도에 비해 CPU와 메모리의 물리적 크기가 크기 때문에 CPU 코어 간, 그리고 CPU와 메모리 간의 동기화에는 많은 비용이 듭니다. 객체 속성과 컬렉션에 대한 모든 액세스에서 동기화를 요구하면 성능에 상당한 영향을 줍니다. PEP PEP 703의 구현자들은 이러한 영향을 가능한 한 낮게 유지하는 데 탁월한 작업을 수행했지만, 물리적 한계를 초과할 수는 없습니다.

동기화가 필요하지 않은 localimmutable 객체 액세스와 동기화가 필요한 synchronizedprotected 액세스로 액세스를 나누면, 동기화 비용은 필요한 경우에만 지불하게 됩니다. 반면 PEP 703은 동기화가 필요한 경우에 대비하여 모든 곳에서 동기화 비용을 지불해야 합니다.

구현

이는 큰 변경 사항이며, 아직 구현은 존재하지 않습니다. 구현 계획과 더 복잡한 세부 사항에 대한 논의는 implementation appendix에 있습니다.

향후 가능한 개선 사항

서드파티 잠금 지원

현재는 LockRLock만 객체 보호를 지원합니다. 리더-라이터 잠금과 같은 서드파티 잠금 구현을 허용하는 API를 제공하면 유용할 것입니다. 그러나 이러한 구현의 정확성을 보장하고 VM을 유효한 상태로 유지하는 일은 복잡하므로, 이는 향후 개선 사항으로 남겨 둡니다.

깊은 동결 및 깊은 전송

단일 객체를 동결하면 동결된 객체에 변경 가능한 객체에 대한 참조가 남을 수 있으며, 단일 객체를 전송하면 해당 객체는 한 스레드에 로컬로 남아 있는 반면 해당 객체가 참조하는 다른 객체들은 다른 스레드에 로컬로 남을 수 있습니다. 이러한 시나리오 중 어느 하나라도 런타임 오류로 이어질 가능성이 높습니다. 이 문제를 방지하려면 “깊은” 동결이 필요합니다.

객체를 깊게 동결하면 해당 객체와 그 객체가 참조하는 다른 변경 가능한 객체들의 추이적 폐포를 동결하게 됩니다. 객체를 깊게 전송하면 해당 객체와 그 객체가 참조하는 다른 로컬 객체들의 추이적 폐포를 전송하지만, 그 객체들 중 하나라도 다른 스레드에 속해 있으면 예외를 발생시킵니다.

프리징과 마찬가지로, 한 스레드에서 다른 스레드로 객체의 전체 그래프를 이동하기 위한 “deep” put 메커니즘을 Channel들에 추가할 수 있습니다.

PEP 795도 참조하십시오. 이 PEP에서는 deep freezing 메커니즘을 제안하지만, 해당 PEP에서는 이를 단순히 “freezing”이라고 부릅니다.

거부된 아이디어

내부적으로 동기화된 객체에는 “trust me bro”라는 이름이 제안되었습니다. 주 저자는 “synchronized”가 더 나은 용어라고 생각합니다 😊

미해결 문제

del을 표현식으로 만들기

protect, Channel.put 함수와 TransferBox를 생성하는 작업은 인자로 전달된 객체의 복사본을 생성합니다.

del을 표현식으로 만들면 현재 스레드가 객체에 대한 작업을 마쳤다는 점을 더 명확하게 나타낼 수 있습니다.

인자로 del x를 사용하면 x가 지워져 현재 스레드가 객체에 대한 작업을 마쳤다는 점이 명확해집니다. 예를 들어:

channel.put(del x)

VM이 정적 분석이나 참조 카운팅을 통해 전달된 참조가 유일하다고 판단할 수 있다면 복사를 생략할 수 있으므로, 이렇게 하면 성능도 향상됩니다.

현재 이 작업을 수행하는 방식은 다소 번거롭습니다.:

channel.put((x, x:=None)[0])

SynchronizedList등의 이름 표기 방식

frozendictfrozenset이 소문자이므로, SynchronizedList, SynchronizedDictSynchronizedSet도 소문자 이름을 사용해야 합니까?

추가 헬퍼 클래스

병렬성을 추가할 때 유용할 수 있는 헬퍼 클래스가 여러 개 있으며, 이러한 클래스를 추가할 수 있습니다. 그러나 표준 라이브러리에 새로운 항목을 추가하여 개발자를 압도하는 것은 바람직하지 않습니다. 이러한 클래스 중 어떤 것을 추가해야 하는지는 아직 명확하지 않습니다.

  • frozenlist
  • AtomicRef
  • local 객체를 프록시하기 위한 SynchronizedProxy(객체를 protected 상태로 만듦)