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 703과 PEP 734를 모두 기반으로 통합 실행 모델을 제공합니다. 이 모델은 PEP 703보다 안전하고, PEP 734보다 공유가 쉬우며, 두 PEP 모두보다 성능이 뛰어납니다.

이 PEP는 각 객체에 상태를 조금 더 추가하여, 연산이 안전한지를 런타임에 적은 비용으로 검사하고 안전하지 않으면 예외를 발생시킬 수 있게 합니다.

동기

전통적으로 CPython은 한 번에 하나의 스레드에서만 실행되었습니다. 이는 늘 Python의 한계로 여겨졌으며, Python이 병렬 실행을 지원하기를 바라는 요구가 여러 해 동안 있었습니다.

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

언어와 런타임이 강력하게 지원하지 않으면 병렬성을 올바르게 구현하기는 극도로 어렵습니다:

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

—John Carmack (Functional Programming in 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 Mode와 Pyrona project에서 영감을 받았습니다. 편향 참조 카운팅과 지연 참조 카운팅 등 필요한 기술 중 상당수는 PEP 703을 위해 개발되었습니다.

명세

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

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

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

Note

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

객체 상태

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

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

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

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

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

클래스, 함수 및 모듈

모든 클래스는 로컬 상태로 생성되지만, 동기화됨 또는 불변 상태로 만들 수 있습니다. 병렬 프로그램에서 최상의 안전성과 성능을 얻으려면 가능한 한 클래스를 불변 상태로 만들어야 합니다.

수정 가능한 자유 변수를 가진 함수와 내부 함수가 수정할 수 있는 변수를 가진 함수는 로컬 상태가 됩니다. 그 밖의 모든 함수는 동기화됨 상태가 됩니다. __kwdefaults__ 속성은 frozendict가 됩니다. __kwdefaults__ 속성은 여전히 변경할 수 있지만, 객체를 직접 변경하는 방식이 아니라 객체 전체를 다시 할당하는 방식으로만 가능합니다. 함수의 __code__, __closure__, __defaults__, __kwdefaults__ 속성을 수정하는 것은 폐지 예정(deprecated)이 됩니다.

대부분의 함수는 동기화됨 상태입니다. 예를 들면 다음과 같습니다:

def egg():
    print("egg")

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

하지만 클로저를 변경하는 내부 함수는 로컬 상태입니다. 예를 들면 다음과 같습니다:

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'

모듈은 로컬 상태로 생성되며, 클래스와 마찬가지로 명시적으로 동결하거나 동기화된 상태로 만들 수 있습니다. 모듈을 원칙에 맞는 방식으로 동기화됨 또는 불변 상태로 만들 수 있도록, 모든 모듈은 전역 변수 __module__을 갖게 됩니다. __module__은 모듈 객체를 가리키며, 모듈이 생성될 때 초기화됩니다.

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

freeze(__module__)

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

__module__.synchronize()

확장 모듈은 C API를 사용하여 스스로를 불변 또는 동기화됨 상태로 선언할 수 있습니다.

가능하면 모듈을 동결해야 합니다.

기타 객체

뷰, 이터레이터 등 다른 변경 가능한 객체의 내부 상태에 의존하는 객체는 해당 객체의 상태를 물려받습니다. 예를 들어, 로컬 list의 listiterator는 로컬 상태가 되지만, 보호됨 list의 listiterator는 보호됨 상태가 됩니다. 불변 객체의 뷰와 이터레이터는 생성될 때 로컬 상태가 됩니다.

튜플이나 문자열처럼 본질적으로 불변인 객체를 제외한 다른 모든 객체는 로컬 상태로 생성됩니다. 이러한 로컬 객체는 나중에 불변 상태로 만들거나 보호됨 상태로 만들 수 있습니다.

새 클래스 세 개, SynchronizedList, SynchronizedDict, SynchronizedSet이 추가됩니다. 이들은 각각 list, dict, set의 동기화된 버전입니다. 이 클래스들은 Python과 C 양쪽 모두에서 원래 클래스와 동일한 API를 가집니다. 동기화된 모듈의 __dict__는 SynchronizedDict가 되며, sys.modules도 마찬가지입니다. sys.path는 SynchronizedList가 됩니다.

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

객체 딕셔너리

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 사용

“GIL이 있는” Python용으로 개발된 프로그램에서 시작하여, ThreadGroup을 더 추가함으로써 병렬성을 더할 수 있습니다. 프로그램이 이미 여러 스레드를 사용하고 있다면, 이 스레드들을 새 ThreadGroup으로 옮겨 코드가 안전하게 병렬로 실행되도록 할 수 있습니다.

잠금과 보호

가변 Python 객체는 지역 객체이거나 보호된 객체일 수 있습니다. ThreadGroup 간에 공유할 수 있으려면 가변 Python 객체는 보호된 상태여야 합니다. Lock 또는 RLock의 protect 메서드를 호출하여 모든 지역 객체로부터 보호된 객체를 만들 수 있습니다:

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

보호된 객체는 보호하는 뮤텍스가 컨텍스트 관리자인 with 문, 또는 그러한 with 문 안에서 호출된 함수 바깥에서는 접근할 수 없습니다.

Lock 및 RLock 클래스

threading.Lock 및 threading.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:
        ...

func1과 func2가 동시에 호출되더라도 교착 상태에 빠지지 않습니다.

보호용 잠금에 acquire나 release를 호출하는 것은 오류입니다. 이러한 잠금은 해당 잠금이나 그로부터 만들어진 복합 잠금을 컨텍스트 관리자로 사용하는 with 문을 통해서만 획득할 수 있습니다.

새 API

이 PEP는 다음을 추가할 것을 제안합니다:

  • 모든 Python 클래스에 추가되며 객체를 동결하여 불변으로 만드는 __freeze__() 메서드(확장 클래스는 __freeze__()를 구현할 수 있지만 반드시 구현해야 하는 것은 아닙니다)
  • obj.__freeze__()를 호출하는 내장 freeze(obj) 함수
  • Lock과 RLock에 추가되며 obj의 보호된 복사본을 반환하는 protect(obj) 메서드
  • SynchronizedList, SynchronizedDict, SynchronizedSet 클래스
  • list, set, dict에 추가되며 해당 객체의 동기화된 버전을 반환하고 원래 객체를 비우는 synchronize() 메서드
  • 모든 객체에 대한 읽기 전용 __shareable__ 속성
  • 한 ThreadGroup에서 다른 ThreadGroup으로 객체를 전달하기 위한 Channel 및 TransferBox 클래스
  • ThreadGroup 클래스
  • Thread를 생성할 때 사용하는 group 매개변수가 이제 의미를 가지며 ThreadGroup으로 설정할 수 있습니다
  • 스레드에 대한 읽기 전용 group 속성
  • 모듈 생성 시 해당 모듈을 가리키도록 설정되는 __module__ 전역 변수
  • 디버거 및 유사한 도구를 위한 sys.monitoring.StopTheWorld 컨텍스트 관리자 객체

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

동결

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

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

객체를 동결하는 것은 얕은 연산이라는 점에 유의하십시오. 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__ 메서드는 향후 개선 사항으로 추가될 수 있습니다.

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

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

동기화

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

병렬 스레드 간에 가변 값 전달하기

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

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

로컬 객체로부터 TransferBox를 생성할 때, 객체는 박싱되기 전에 복사됩니다. 새 로컬 객체는 어떤 ThreadGroup에도 연결되지 않습니다.

상자에서 객체를 꺼낼 때, 상자의 sink가 None이거나 현재 ThreadGroup이면 현재 ThreadGroup이 객체의 소유자가 됩니다.

불변, 보호된, 동기화된 객체는 복사되지 않고 전달됩니다:

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()

충분한 수요가 있다면 “깊은” put 메서드가 향후 개선 사항으로 추가될 수도 있습니다.

Main ThreadGroup

인터프리터 시작 시 “Main”이라는 이름의 ThreadGroup이 생성되어 sys.main_thread_group에 저장됩니다. sys.main_thread_group은 읽기 전용이며, “Main” ThreadGroup은 sys 모듈이 삭제되더라도 모든 필멸(mortal) 객체보다 오래 존속합니다. 메인 스레드의 group은 sys.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에 속한 지역 객체에 대한 참조를 얻으려고 할 때 발생하는 IllegalThreadAccessException
  • 스레드가 필요한 잠금을 보유하지 않은 채 보호된 객체에 대한 참조를 얻으려고 할 때 발생하는 UnprotectedAccessException

병렬성과 컨텍스트 전환

각 ThreadGroup은 독립적이며, 그중 일부 또는 전부가 서로 병렬로 실행될 수 있습니다. 하나의 ThreadGroup 안에서는 어느 시점에든 하나의 스레드만 실행될 수 있습니다.

ThreadGroup 안에서 스레드 간 전환은 다음 위치 중 어디에서든 일어날 수 있습니다.

  • 호출 지점
  • 모든 루프 본문의 끝(백 에지)
  • 함수 진입 시점
  • 위 경우 중 하나가 발생하는 호출 도중
  • 확장 함수 호출 도중
  • finally 블록을 포함한 모든 예외 핸들러의 끝에서

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

다음 연산자에서는 컨텍스트 전환이 일어나지 않습니다.

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

인트로스펙션과 디버거

일반적으로 local 객체는 다른 ThreadGroup에 속한 스레드가 접근할 수 없습니다. 또한 관련 잠금을 보유하지 않은 상태에서는 protected 객체에 접근할 수 없습니다. 그러나 이렇게 하면 디버거를 비롯한 유사한 도구가 여러 실행 스레드를 내부적으로 검사할 수 없게 됩니다.

이러한 특수한 경우를 허용하기 위해 특별한 컨텍스트 관리자인 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의 책임입니다. 따라서 C 확장이 확장 API의 일부로 구현한 C 함수는 수정할 필요가 없습니다. VM은 반환되는 모든 값에 대해 필요한 검사를 수행합니다.

하위 호환성

기본 빌드

기본 빌드와 비교했을 때 유일하게 호환되지 않는 변경 사항은 일부 객체(원시 타입의 객체)의 수명이 연장될 수 있으며, 이로 인해 메모리 사용량이 늘어날 수 있다는 점입니다.

자유 스레딩 빌드

가장 눈에 띄는 변경 사항은 가변 객체를 공유하면 데이터 경쟁을 허용하는 대신 IllegalThreadAccessException이 발생한다는 점입니다.

이 문제는 경우에 따라 개별적으로 해결할 수 있습니다. 가변 공유 객체가 이미 잠금으로 보호되고 있다면, 그 객체를 보호된 상태로 만드십시오. 잠금과 보호 절을 참조하십시오. (이는 그러한 애플리케이션의 스레드 안전성을 보장하는 데에도 도움이 됩니다.) 새로운 synchronize() 메서드를 사용하여 가변 공유 리스트와 딕셔너리를 동기화된 버전으로 바꾸십시오. 새 API 절을 참조하십시오. (동기화된 딕셔너리와 리스트는 자유 스레딩 빌드에서와 마찬가지로 특정 경쟁 조건을 허용한다는 점에 유의하십시오. 이러한 경쟁 조건이 이미 수용 가능한 것이었다면 다른 변경은 필요하지 않습니다.) 그렇지 않고 가변 공유 객체가 이미 동기화된 객체의 범주에 속한다면, 변경할 필요가 없습니다.

또한 이 PEP는 스레드가 로컬 가변 객체에 대한 참조를 여러 스레드가 볼 수 있는 힙에 저장하는 것(예: 공유 리스트에 추가하는 것)을 막지 않지만, 해당 객체를 소유하지 않은 스레드가 그 객체에 대한 참조를 획득하려고 하면(예: 공유 리스트에서 꺼내는 경우) 예외가 발생한다는 점에 유의하십시오. 따라서 딕셔너리나 리스트를 동기화된 상태로 전환할 때는 주의를 기울여야 합니다.

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

안전성

로컬 객체와 불변 객체는 동시 수정이 일어날 수 없으므로 경쟁 조건에 대해 항상 안전합니다.

하지만 protected 및 synchronized 객체는 주의해서 다루어야 합니다.

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

성능

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

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

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

하지만 여전히 추가 검사가 필요합니다. 스레드가 소유하는 참조가 생성될 때마다 그 참조가 적법한지 확인하는 검사가 필요합니다. 객체가 ThreadGroup에 로컬인지, 불변인지, 동기화된 상태인지, 또는 보호된 상태이면서 올바른 잠금이 유지되고 있는지 확인해야 하므로, 이러한 검사는 비교적 비용이 클 수 있습니다. 하지만 특수화 적응형 인터프리터나 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

비용이 크지만, 예상되는 경우에 맞춰 특수화하면 검사 비용을 낮출 수 있습니다. 예를 들어 로컬 객체를 예상한다면 훨씬 저렴한 검사를 수행할 수 있습니다:

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

단, ThreadGroup ID와 잠금 ID가 서로 구별되도록 보장한다는 전제하에서입니다.

병렬성이 성능에 미치는 영향

모든 스레드가 단일 ThreadGroup에 속한다면 JIT는 로컬 객체에 대한 검사를 제거할 수 있으며(이러한 검사는 항상 통과하기 때문입니다), 그 결과 현재의 GIL 포함 빌드에 매우 가까운 성능을 얻을 수 있습니다.

필요한 잠금의 양에 따라, 병렬성을 추가했을 때의 성능 영향은 불변 객체만 공유되고 다른 모든 객체는 지역적인 경우의 0에 가까운 수준부터 잠금으로 인한 몇 퍼센트 수준까지 다양할 수 있지만, 그래도 자유 스레딩 빌드보다는 낫습니다.

JIT가 수행할 수 있는 많은 최적화는 객체의 상태가 최적화기에 보이지 않는 방식으로 변경되지 않을 것을 요구합니다. PEP 703의 의미론은 불명확하거나, 이러한 종류의 최적화를 명시적으로 막습니다. 로컬 객체와 불변 객체를 추가하면 많은 최적화를 다시 사용할 수 있게 됩니다.

보안 영향

이 PEP는 경쟁 상태를 줄이거나 제거함으로써 병렬 코드에 더 강력한 보안을 제공합니다.

교육 방법

이 PEP는 보호된 객체와 동기화된 객체를 사용하는 복잡한 병렬성 접근 방식을 허용하지만, 공유 배타 가변성(Sharing Xor Mutability, SXM) 모델이나 액터 모델과 같은 더 단순한 접근 방식을 권장합니다. 이러한 더 단순한 모델을 사용하면 과도한 복잡성 없이 병렬성을 추가하는 데 도움이 됩니다.

공유 배타 가변성 모델

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

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

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

정적으로 컴파일되는 언어에서는 앨리어싱이 공유 가능성을 의미하므로, 학술 문헌에서 SXM 모델은 앨리어싱 배타 가변성(Aliasing Xor Mutability), 즉 AXM으로도 알려져 있습니다.

통신 순차 프로세스

이 모델에서 병렬 “프로세스”(또는 액터)는 메시지 전달을 통해서만 상호 작용합니다. 이는 ThreadGroup과 Channel을 사용하여 구현할 수 있습니다.

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

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

예시

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

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

이 PEP는 PEP 703과 경쟁하는 것이 아니라 그것을 기반으로 하는 것으로 보아야 합니다. 이 PEP를 구현하는 데 필요한 메커니즘 중 상당수는 PEP 703을 위해 개발되었습니다.

안전성

PEP 703에는 잘 정의된 의미론이 없지만, 대부분의 경우 순차적 일관성 모델을 의미론으로 가정하는 것으로 보입니다. 안타깝게도 순차적 일관성은 너무 세밀한 수준이어서 많은 경쟁 조건을 막지 못합니다.

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

안타깝게도 정확한 동작에 대한 형식적인 정의가 제공되지 않으며, 이로 인해 다음과 같은 이슈가 발생합니다:

성능

동기화는 비용이 많이 듭니다. CPU의 높은 클럭 속도에 비해 CPU와 메모리의 물리적 크기가 크기 때문에, CPU 코어 간 그리고 CPU와 메모리 간의 동기화는 비용이 많이 듭니다. 객체 속성과 컬렉션에 대한 모든 접근에 동기화를 요구하면 성능에 상당한 영향을 미칩니다. PEP 703의 구현자들은 그 영향을 가능한 한 낮게 유지하는 훌륭한 일을 해냈지만, 물리적 한계를 넘어설 수는 없습니다.

접근을 동기화가 필요 없는 지역 객체 접근 및 불변 객체 접근과, 동기화가 필요한 동기화된 접근 및 보호된 접근으로 나누면, 동기화 비용은 필요할 때에만 치르게 됩니다. 반면 PEP 703은 필요할 경우에 대비하여 모든 곳에서 동기화 비용을 치러야 합니다.

구현

이는 큰 변경이며, 아직 구현은 없습니다. 구현 계획과 더 복잡한 세부 사항 일부에 대한 논의는 구현 부록에 있습니다.

향후 가능한 개선 사항

서드파티 잠금 지원

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

깊은 동결과 깊은 이전

단일 객체를 동결하면 동결된 객체가 가변 객체에 대한 참조를 갖게 될 수 있으며, 단일 객체를 이전하면 어떤 객체는 한 스레드에 지역적이지만 그 객체가 참조하는 다른 객체들은 다른 스레드에 지역적인 상태가 될 수 있습니다. 이러한 시나리오는 어느 쪽이든 런타임 오류로 이어질 가능성이 높습니다. 이 문제를 피하려면 “깊은” 동결이 필요합니다.

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

동결과 마찬가지로, 객체 그래프 전체를 한 스레드에서 다른 스레드로 옮기는 “깊은(deep)” put 메커니즘을 Channel에 추가할 수 있습니다.

PEP 795도 참고하십시오. 이 PEP는 깊은 동결 메커니즘을 제안하지만, 해당 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 등의 이름 대소문자

frozendict, frozenset이 소문자라는 점을 고려하면, SynchronizedList, SynchronizedDict, SynchronizedSet도 소문자 이름을 가져야 합니까?

추가 헬퍼 클래스

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

  • frozenlist
  • AtomicRef
  • 지역(local) 객체를 프록시하여 보호(protected) 상태로 만드는 SynchronizedProxy