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

Python 개선 제안 한국어 번역

PEP 583 – Python을 위한 동시성 메모리 모델

Author:
Jeffrey Yasskin <jyasskin at google.com>
Status:
Withdrawn
Type:
Informational
Created:
22-Mar-2008
Post-History:


Table of Contents

번역·라이선스 안내

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

초록

이 PEP는 여러 스레드가 공유 변수를 동시에 읽고 쓸 때 Python 프로그램이 어떻게 동작할 수 있는지 설명합니다. 변수 접근의 순서가 정해져 있는지, 아니면 동시적인지를 정의하기 위해 happens before 관계를 사용합니다. 거의 모든 프로그램은 그저 잠금을 사용하여 공유 변수를 보호해야 하며, 이 PEP는 그렇게 하지 않을 때 일어날 수 있는 이상한 현상 몇 가지를 짚어 봅니다. 그러나 프로그래머는 “단순한” 작업은 잠금 없이 해도 괜찮다고 가정하는 경우가 많으며, 언어가 프로그래머를 놀라게 하는 것은 다소 파이썬답지 않습니다. 안타깝게도 놀라움을 피하는 것은 Python을 빠르게 실행하는 것과 충돌하는 경우가 많으므로, 이 PEP는 둘 사이에서 적절한 절충점을 찾고자 합니다.

근거

지금까지 주요 Python 구현으로는 CPython, Jython, IronPython, PyPy 네 가지가 있으며, 이 밖에도 소규모 구현이 많이 있습니다. 이 가운데 일부는 이미 공격적인 최적화를 수행하는 플랫폼에서 실행됩니다. 일반적으로 이러한 최적화는 단일 실행 스레드 안에서는 드러나지 않지만, 동시에 실행되는 다른 스레드에는 드러날 수 있습니다. CPython은 현재 다른 스레드가 예상한 결과를 볼 수 있도록 GIL을 사용하지만, 이 때문에 단일 프로세서로 제한됩니다. Jython과 IronPython은 각각 Java와 .NET의 스레딩 시스템 위에서 실행되므로 더 많은 코어를 활용할 수 있지만, 다른 스레드에 예상치 못한 값을 보여 줄 수도 있습니다.

스레드를 사용하는 Python 프로그램이 계속해서 구현 간에 이식 가능하도록, 구현자와 라이브러리 작성자는 몇 가지 기본 규칙에 합의해야 합니다.

몇 가지 정의

변수
객체를 가리키는 이름입니다. 변수는 일반적으로 값을 대입함으로써 도입되며, del에 전달하여 제거할 수 있습니다. 변수는 근본적으로 가변적이지만, 객체는 그렇지 않을 수도 있습니다. 변수에는 여러 종류가 있습니다. 모듈 변수(모듈 내부에서 접근할 때 흔히 “전역 변수”라고 부릅니다), 클래스 변수, 인스턴스 변수(필드라고도 합니다), 지역 변수가 그것입니다. 이들은 모두 스레드 간에 공유될 수 있습니다(지역 변수는 클로저에 저장된 경우에 한합니다). 변수의 스코프가 되는 객체는 개념적으로 변수 이름을 키로 하는 dict를 가집니다.
객체
인스턴스 변수(필드라고도 합니다)와 메서드의 모음입니다. 적어도 이 PEP에서는 그 정도면 충분합니다.
프로그램 순서
한 스레드 안에서 동작(읽기와 쓰기)이 일어나는 순서로, 텍스트에 나타나는 순서와 매우 비슷합니다.
충돌하는 동작
같은 변수에 대한 두 동작으로, 그중 적어도 하나는 쓰기입니다.
데이터 경쟁
충돌하는 두 동작이 같은 시간에 일어나는 상황입니다. “같은 시간”은 메모리 모델이 정의합니다.

두 가지 단순한 메모리 모델

데이터 경쟁의 세부 사항과 그것이 일으키는 놀라운 동작에 대해 이야기하기 전에, 두 가지 간단한 메모리 모델을 소개하겠습니다. 첫 번째 모델은 Python에는 아마 너무 강하고, 두 번째 모델은 아마 너무 약할 것입니다.

순차적 일관성

순차적으로 일관된 동시 실행에서는 동작들이 하나의 전역 전체 순서에 따라 일어나는 것처럼 보이며, 특정 변수에 대한 각 읽기는 그 변수에 영향을 준 마지막 쓰기가 기록한 값을 봅니다. 동작들의 전체 순서는 프로그램 순서와 일관되어야 합니다. 어떤 프로그램의 순차적으로 일관된 실행 중 하나가 서로 충돌하는 두 동작을 나란히 배치한다면, 그 프로그램은 주어진 입력에 대해 데이터 경쟁을 가집니다.

이것은 사람이 이해하기에 가장 쉬운 메모리 모델이지만, 연산이 예상치 못한 지점에서 나뉠 수 있으므로 모든 혼란을 없애 주지는 못합니다.

happens-before 일관성

프로그램에는 동기화 동작의 모음이 포함되어 있으며, 현재 Python에서는 잠금 획득과 해제, 그리고 스레드 시작과 조인이 여기에 해당합니다. 동기화 동작은 프로그램 순서와 일관된 하나의 전역 전체 순서에 따라 일어납니다(동기화 동작이 반드시 전체 순서에 따라 일어나야 하는 것은 아니지만, 그렇게 하면 모델을 설명하기가 간단해집니다). 잠금 해제는 같은 잠금에 대한 이후의 모든 획득과 동기화됩니다. 마찬가지로, t = threading.Thread(target=worker)가 주어졌을 때 다음이 성립합니다.

  • t.start() 호출은 worker()의 첫 번째 문장과 동기화됩니다.
  • worker()에서의 반환은 t.join()에서의 반환과 동기화됩니다.
  • t.start()에서의 반환이 False를 반환하는 t.isAlive() 호출보다 먼저 발생하면(아래 참조), worker()에서의 반환은 그 호출과 동기화됩니다.

synchronizes-with 간선의 출발점을 해당 변수에 대한 해제 연산이라고 부르고, 도착점을 획득 연산이라고 부릅니다.

happens before 순서는 프로그램 순서와 synchronizes-with 간선의 추이적 폐포입니다. 즉, 다음과 같은 경우에 동작 A가 동작 B보다 먼저 발생합니다.

  • 프로그램 순서에서 A가 B보다 앞에 옵니다(이는 두 동작이 같은 스레드에서 실행된다는 뜻입니다).
  • A가 B와 동기화됩니다.
  • A에서 happens-before 간선을 따라가서 B에 도달할 수 있습니다.

프로그램 실행에서 각 읽기 R이 같은 변수에 대한 쓰기 W의 값을 보고 그 쓰기가 다음 조건을 만족하면, 그 실행은 happens-before 일관적입니다:

  • R이 W보다 먼저 발생하지 않으며,
  • R이 그 값을 볼 기회를 얻기 전에 W를 덮어쓴 다른 쓰기 V가 없습니다. (즉, W, V, R 순으로 happens-before 관계가 이어지는 경우는 있을 수 없습니다.)

서로 충돌하는 두 동작이 happens-before 관계로 연결되어 있지 않다면 데이터 경쟁이 있는 것입니다.

예시

happens-before 모델의 규칙을 사용하여 다음 프로그램이 “[7]”을 출력한다는 것을 증명해 봅시다.:

class Queue:
    def __init__(self):
        self.l = []
        self.cond = threading.Condition()

    def get():
        with self.cond:
            while not self.l:
                self.cond.wait()
            ret = self.l[0]
            self.l = self.l[1:]
            return ret

    def put(x):
        with self.cond:
            self.l.append(x)
            self.cond.notify()

myqueue = Queue()

def worker1():
    x = [7]
    myqueue.put(x)

def worker2():
    y = myqueue.get()
    print y

thread1 = threading.Thread(target=worker1)
thread2 = threading.Thread(target=worker2)
thread2.start()
thread1.start()
  1. myqueue는 thread1이나 thread2가 시작되기 전에 메인 스레드에서 초기화되므로, 그 초기화는 worker1과 worker2가 실행을 시작하기 전에 발생합니다. 따라서 어느 쪽도 NameError를 일으킬 수 없으며, myqueue.l과 myqueue.cond는 모두 최종 객체로 설정되어 있습니다.
  2. worker1에서 x의 초기화는 myqueue.put() 호출보다 먼저 발생하고, 이는 myqueue.l.append(x) 호출보다 먼저 발생하며, 이는 다시 myqueue.cond.release() 호출보다 먼저 발생합니다. 이는 모두 이들이 같은 스레드에서 실행되기 때문입니다.
  3. worker2에서는 myqueue.l에 값(x)이 들어갈 때까지 myqueue.cond가 해제되었다가 다시 획득됩니다. worker1의 myqueue.cond.release() 호출은 worker2의 마지막 myqueue.cond.acquire() 호출보다 먼저 발생합니다.
  4. 그 마지막 myqueue.cond.acquire() 호출은 myqueue.get()이 myqueue.l을 읽는 것보다 먼저 발생하고, 이는 myqueue.get()이 반환하는 것보다 먼저 발생하며, 이는 다시 print y보다 먼저 발생합니다. 이 역시 모두 같은 스레드에서 실행되기 때문입니다.
  5. happens-before는 추이적이므로, thread1에서 처음에 x에 저장된 리스트는 thread2에서 출력되기 전에 초기화됩니다.

보통은 사용이 안전하다는 것을 증명하기 위해 스레드 안전한 큐의 구현을 끝까지 들여다볼 필요가 없습니다. 큐의 인터페이스에 put이 get보다 먼저 일어난다고(happen before) 명시되어 있을 것이므로, 그로부터 곧바로 추론하면 됩니다.

경쟁 상태에서 나타나는 놀라운 동작

코드에 데이터 경쟁이 있으면 이상한 일이 많이 일어날 수 있습니다. 공유 변수를 잠금으로 보호하기만 하면 이러한 문제를 모두 쉽게 피할 수 있습니다. 이것은 경쟁 위험의 완전한 목록이 아니며, Python과 관련이 있어 보이는 것들을 모아 놓은 것일 뿐입니다.

이 모든 예제에서 r로 시작하는 변수는 지역 변수이고, 그 밖의 변수는 스레드 간에 공유됩니다.

좀비 값

이 예제는 Java memory model에서 가져온 것입니다:

처음에는 p is q이고 p.x == 0입니다.
스레드 1 스레드 2
r1 = p r6 = p
r2 = r1.x r6.x = 3
r3 = q
r4 = r3.x
r5 = r1.x

r2 == r5 == 0이지만 r4 == 3인 결과가 나올 수 있으며, 이는 p.x가 0에서 3이 되었다가 다시 0으로 돌아갔음을 증명합니다.

좋은 컴파일러라면 r5를 초기화할 때 이미 r2에 로드된 값을 재사용하여 p.x의 중복 로드를 최적화로 제거하려 할 것입니다. 스레드 1이 메모리를 다음 순서로 보게 되면 이상한 결과가 나옵니다:

평가 계산 결과 이유
r1 = p
r2 = r1.x r2 == 0
r3 = q r3 is p
p.x = 3 스레드 2의 부수 효과
r4 = r3.x r4 == 3
r5 = r2 r5 == 0 r2 == r1.x이므로 r5 = r1.x를 최적화한 것입니다.

일관되지 않은 순서

N2177: Sequential Consistency for Atomics에서 가져온 예로, 독립 쓰기의 독립 읽기(Independent Read of Independent Write, IRIW)라고도 합니다.

처음에는 a == b == 0입니다.
스레드 1 스레드 2 스레드 3 스레드 4
r1 = a r3 = b a = 1 b = 1
r2 = b r4 = a

r1 == r3 == 1과 r2 == r4 == 0을 얻을 수 있는데, 이는 a가 b보다 먼저 쓰였다는 것(스레드 1의 데이터)과 b가 a보다 먼저 쓰였다는 것(스레드 2의 데이터)을 동시에 증명합니다. 실제 세계의 예는 특수 상대성 이론을 참고하십시오.

이런 일은 스레드 1과 스레드 3이 서로 가까이 있는 프로세서에서 실행되지만 스레드 2와 4가 실행되는 프로세서와는 멀리 떨어져 있고, 쓰기가 기계 전체에 완전히 전달되기 전에 가까운 스레드에 보이게 될 때 발생할 수 있습니다.

획득/해제 의미론도, 명시적 메모리 배리어도 이 문제에는 도움이 되지 않습니다. 잠금 없이 순서를 일관되게 만들려면 아키텍처의 메모리 모델에 대한 상세한 지식이 필요하지만, Java는 volatile에 대해 이를 요구하므로 Java 구현자를 대상으로 한 문서를 활용할 수 있습니다.

순차 일관성 경쟁은 아닌 happens-before 경쟁

Java 메모리 모델에 관한 POPL 논문 [1]에서 가져온 예입니다.

처음에는 x == y == 0입니다.
스레드 1 스레드 2
r1 = x r2 = y
if r1 != 0: if r2 != 0:
y = 42 x = 42

r1 == r2 == 42가 될 수 있습니까???

순차적으로 일관된 실행에서는 같은 변수에 대한 읽기와 쓰기가 인접하게 일어날 수 없으므로, 이 프로그램은 (취약하기는 하지만) 올바르게 동기화된 것으로 간주되어야 하며 r1 == r2 == 0만을 만들어 내야 합니다. 그러나 다음 실행은 happens-before 일관성을 만족합니다:

문장 값 스레드
r1 = x 42 1
if r1 != 0: true 1
y = 42 1
r2 = y 42 2
if r2 != 0: true 2
x = 42 2

도대체 이게 무슨 일이냐고 스스로 묻고 계실 것입니다. 원래 프로그램에는 스레드 간 happens-before 간선이 없었으므로, 스레드 1에서의 x 읽기는 스레드 2의 어떤 쓰기든 볼 수 있습니다. 설령 그 쓰기가 오직 그 읽기가 그것을 보았기 때문에 일어난 것이라 하더라도 마찬가지입니다. happens-before 모델에는 데이터 경쟁이 존재합니다.

우리는 이것을 허용하고 싶지 않으므로, happens-before 모델만으로는 Python에 충분하지 않습니다. 이러한 실행을 막기 위해 happens-before에 추가할 수 있는 규칙 하나는 다음과 같습니다:

프로그램의 어떤 순차적으로 일관된 실행에도 데이터 경쟁이 없다면, 그 프로그램은 순차적으로 일관된 의미론을 가져야 합니다.

Java는 이 규칙을 정리로서 얻지만, Python은 이를 증명하는 데 필요한 장치를 모두 갖추고 싶어 하지 않을 수도 있습니다.

자기 정당화 값

이 예제 역시 Java 메모리 모델에 관한 POPL 논문 [1]에서 가져온 것입니다.

처음에는 x == y == 0입니다.
스레드 1 스레드 2
r1 = x r2 = y
y = r1 x = r2

x == y == 42가 될 수 있습니까???

순차적 일관성을 갖춘 실행에서는 그럴 수 없습니다. happens-before 일관성을 갖춘 실행에서는 그럴 수 있습니다. 스레드 사이에 happens-before 관계가 없으므로 스레드 1의 x 읽기는 스레드 2가 쓴 값을 볼 수 있습니다. 컴파일러나 프로세서가 코드를 다음과 같이 변환하면 이런 일이 일어날 수 있습니다:

스레드 1 스레드 2
y = 42 r2 = y
r1 = x x = r2
if r1 != 42:
y = r1

추측된 값이 비밀 객체이거나 어떤 객체가 예전에 차지하던 메모리를 가리키는 경우, 보안 허점이 생길 수 있습니다. Java는 이러한 보안 허점을 매우 중요하게 여기지만, Python은 그렇지 않을 수도 있습니다.

초기화되지 않은 값 (직접)

여러 고전적인 이중 검사 잠금(double-checked locking) 예제에서 가져온 것입니다.

처음에는 d == None입니다.
스레드 1 스레드 2
while not d: pass d = [3, 4]
assert d[1] == 4

이 코드는 IndexError를 일으키거나 단언에 실패할 수 있으며, 구현에서 주의를 기울이지 않으면 크래시나 그 밖의 정의되지 않은 동작을 일으킬 수도 있습니다.

스레드 2는 실제로 다음과 같이 구현될 수 있습니다:

r1 = list()
r1.append(3)
r1.append(4)
d = r1

d에 대한 할당과 항목 할당은 서로 독립적이므로, 컴파일러와 프로세서는 이를 다음과 같이 최적화할 수 있습니다.:

r1 = list()
d = r1
r1.append(3)
r1.append(4)

이는 명백히 잘못된 것이며, IndexError가 발생하는 이유를 설명해 줍니다. 여기서 r1.append(3)의 구현을 더 깊이 살펴보면, 이것과 d[1]이 자체적인 경쟁 상태를 일으키지 않고서는 동시에 실행될 수 없다는 것을 알게 될 수도 있습니다. (GIL이 없는) CPython에서는 이러한 경쟁 상태가 정의되지 않은 동작을 일으킬 것입니다.

읽는 쪽에도 d[1]의 값이 최신이 아니게 될 수 있는 미묘한 문제가 있습니다. list는 구현 어딘가에서 그 내용을 메모리에 배열로 저장합니다. 이 배열이 우연히 스레드 1의 캐시에 들어 있을 수 있습니다. 스레드 1의 프로세서가 값 3과 4를 담고 있어야 할 메모리는 다시 읽어 들이지 않은 채 d만 주 메모리에서 다시 읽어 들이면, 대신 오래된 값을 보게 될 수 있습니다. 제가 아는 한, 이런 일은 실제로는 알파와 어쩌면 Itanium에서만 일어날 수 있으며, 어차피 크래시를 피하려면 이를 막아야 할 것입니다.

초기화되지 않은 값 (플래그)

이중 검사 잠금(double-checked locking) 예제 몇 가지에서 더 가져온 것입니다.

처음에는 d == dict()이고 initialized == False입니다.
스레드 1 스레드 2
while not initialized: pass d[‘a’] = 3
r1 = d[‘a’] initialized = True
r2 = r1 == 3
assert r2

이는 KeyError를 발생시키거나, 어서션을 실패시키거나, 구현에서 어느 정도 주의를 기울이지 않으면 크래시나 기타 정의되지 않은 동작을 일으킬 수 있습니다.

d와 initialized는 (프로그래머의 머릿속을 제외하면) 서로 독립적이므로, 컴파일러와 프로세서는 스레드 1의 어서션이 루프 뒤에 있어야 한다는 점만 빼면 이들의 순서를 거의 임의로 재배치할 수 있습니다.

데이터 의존성에 기대는 데서 오는 일관성 없는 보장

이는 Java의 final 변수와 C++0x에서 제안된 data-dependency ordering의 문제입니다.

먼저 다음을 실행합니다.:
g = []
def Init():
    g.extend([1,2,3])
    return [1,2,3]
h = None

그런 다음 두 스레드에서:

스레드 1 스레드 2
while not h: pass r1 = Init()
assert h == [1,2,3] freeze(r1)
assert h == g h = r1

h가 Java의 final 변수와 비슷한 의미를 가진다면(한 번만 쓸 수 있다는 점은 제외하고), 첫 번째 단언은 반드시 성공하더라도 두 번째 단언은 실패할 수 있습니다.

final이 제공하는 것과 같은 데이터 의존적 보장은 final 변수를 통해 접근할 때만 작동합니다. 같은 객체라도 다른 경로를 통해 접근하는 것은 안전하지 않습니다. 안타깝게도 프로세서의 작동 방식 때문에 final의 보장은 약할 때만 비용이 적게 듭니다.

Python을 위한 규칙

첫 번째 규칙은 사용자 코드의 경쟁 조건 때문에 Python 인터프리터가 비정상 종료되어서는 안 된다는 것입니다. CPython의 경우 이는 경쟁 조건이 C 수준까지 내려갈 수 없다는 뜻입니다. Jython의 경우 이는 NullPointerException이 인터프리터 밖으로 빠져나갈 수 없다는 뜻입니다.

또한 최소한 happens-before 일관성만큼 강한 모델도 필요할 것입니다. 그런 모델이 있으면 동시성 큐와 스레드의 시작 및 조인이 어떻게 작동하는지를 간단하게 기술할 수 있기 때문입니다.

다른 규칙들은 논란의 여지가 더 크므로 각 규칙을 장단점과 함께 제시하겠습니다.

데이터 경쟁이 없는 프로그램은 순차적으로 일관적입니다

프로그래머가 자신의 프로그램이 순차적으로 일관적인 것처럼 추론할 수 있으면 좋겠습니다. happens-before 경쟁을 작성했는지 판단하기는 어려우므로, 프로그래머에게는 순차적 경쟁을 방지하는 것만 요구하려고 합니다. Java 모델은 복잡한 인과성 정의를 통해 이를 달성하지만, 그것을 포함하고 싶지 않다면 이 속성을 직접 명시하기만 하면 됩니다.

허공에서 나온(out-of-thin-air) 읽기로 인한 보안 허점 없음

프로그램이 스스로를 정당화하는 값을 만들어 내면, 사용자가 프로그램에 보여 주고 싶지 않은 객체에 대한 접근이 노출될 수 있습니다. 이번에도 Java의 모델은 인과성 정의로 이 문제를 처리합니다. 공유 변수에 대한 투기적 쓰기를 금지하면 이러한 보안 문제를 방지할 수 있을지도 모르지만, 저는 그에 대한 증명을 가지고 있지 않으며, 어차피 Python에는 그러한 보안 보장이 필요하지 않을 수도 있습니다.

happens-before를 정의하는 대신 재배치를 제한하기

.NET [2] 및 x86 [3] 메모리 모델은 컴파일러가 어떤 재배치를 허용할 수 있는지를 정의하는 데 기반합니다. 저는 프로그램에서 가능한 모든 재배치를 추론하는 것보다 happens-before 모델에 맞춰 프로그래밍하는 편이 더 쉽고, 같은 일을 하기 위해 충분한 메모리 펜스를 삽입하는 것보다 프로그램을 올바르게 만들 만큼 충분한 happens-before 간선을 삽입하는 편이 더 쉽다고 생각합니다. 따라서 happens-before 기반 위에 일부 재배치 제한을 얹을 수는 있겠지만, Python의 메모리 모델이 전적으로 재배치 제한으로만 이루어져야 한다고는 생각하지 않습니다.

원자적이고 순서가 없는 대입

기본 타입의 대입은 이미 원자적입니다. 변수에 3<<72 + 5를 대입하면, 어떤 스레드도 그 값의 일부만 볼 수는 없습니다. Jeremy Manson은 이를 모든 객체로 확장하자고 제안했습니다. 이렇게 하면 컴파일러가 최적화를 위해 연산을 재배치할 수 있으면서도, 더 혼란스러운 uninitialized values 중 일부는 허용하지 않게 됩니다. 여기서의 기본 아이디어는 공유 변수에 대입할 때, 읽는 쪽에서는 대입 이전에 새 값에 가해진 변경이나 대입 이후에 이전 값에 가해진 변경을 볼 수 없다는 것입니다. 따라서 다음과 같은 프로그램이 있다면:

처음에는 (d.a, d.b) == (1, 2)이고, (e.c, e.d) == (3, 4)입니다. 또한 class Obj(object): pass가 정의되어 있습니다.
스레드 1 스레드 2
r1 = Obj() r3 = d
r1.a = 3 r4, r5 = r3.a, r3.b
r1.b = 4 r6 = e
d = r1 r7, r8 = r6.c, r6.d
r2 = Obj()
r2.c = 6
r2.d = 7
e = r2

(r4, r5)는 (1, 2) 또는 (3, 4)일 수 있을 뿐 그 밖의 값은 될 수 없으며, (r7, r8)은 (3, 4) 또는 (6, 7) 중 하나일 수 있을 뿐 그 밖의 값은 될 수 없습니다. 쓰기가 릴리스(release)이고 읽기가 획득(acquire)인 경우와 달리, 스레드 2가 (e.c, e.d) == (6, 7) and (d.a, d.b) == (1, 2)를 (순서가 뒤바뀐 채로) 관찰하는 것이 허용됩니다.

이 방식은 사용자가 이상한 값을 보지 않도록 하면서도 컴파일러에 최적화를 위한 상당한 유연성을 제공합니다. 그러나 데이터 의존성에 기대기 때문에 이 방식 자체도 몇 가지 예상치 못한 결과를 낳습니다. 예를 들어 컴파일러는 위의 예제를 다음과 같이 자유롭게 최적화할 수 있습니다:

스레드 1 스레드 2
r1 = Obj() r3 = d
r2 = Obj() r6 = e
r1.a = 3 r4, r7 = r3.a, r6.c
r2.c = 6 r5, r8 = r3.b, r6.d
r2.d = 7
e = r2
r1.b = 4
d = r1

단, e의 초기화가 r2의 멤버 초기화 중 어느 것보다도 앞으로 옮겨지지 않아야 하며, d와 r1에 대해서도 마찬가지입니다.

이는 happens-before 일관성의 근거를 마련하는 데에도 도움이 됩니다. 문제를 이해하기 위해, 사용자가 객체에 대한 참조를 얻자마자 그 참조를 안전하지 않은 방식으로 공개한다고 가정해 봅시다. 모델은 그 참조를 통해 어떤 값을 읽을 수 있는지를 제한해야 합니다. Java는 누군가가 객체를 처음 보기 전에 모든 필드가 0으로 초기화된다고 규정하지만, Python에서는 “모든 필드”를 정의하기가 어려울 것입니다. 대신 공유 변수에 대한 대입이 적어도 대입이 일어난 시점만큼 최신인 값을 보아야 한다고 규정한다면, 조기 공개로 인한 문제는 전혀 발생하지 않습니다.

두 단계의 보장

잠금 없이 접근하는 변수에 대해 어떤 보장이라도 제공하는 대부분의 다른 언어는 일반 변수와 volatile/atomic 변수를 구별합니다. 이들 언어는 volatile 변수에 대해 훨씬 더 많은 보장을 제공합니다. Python에서는 변수를 선언하지 않기 때문에 이렇게 하기가 쉽지 않습니다. Python의 잠금은 일반 Python 코드보다 비용이 크게 높지 않으므로, 이것이 문제가 될 수도 있고 그렇지 않을 수도 있습니다. 이러한 단계 구분을 되찾고 싶다면, 다음과 같이 할 수 있습니다.

  1. Java의 [4] 또는 C++의 [5]와 유사한 원자적 타입 집합을 도입합니다. 안타깝게도, 이 타입들에는 =로 대입할 수 없을 것입니다.
  2. 변수 선언을 요구하지 않고, 특정 객체의 모든 필드가 원자적이라고 지정할 수도 있습니다.
  3. __slots__ 메커니즘 [6]을 이와 나란한 __volatiles__ 리스트, 그리고 경우에 따라 __finals__ 리스트로 확장합니다.

순차적 일관성

Python에 순차적 일관성을 그대로 채택할 수도 있습니다. 이렇게 하면 위에서 언급한 모든 위험을 피할 수 있지만, 많은 최적화도 금지됩니다. 제가 아는 한 이것이 현재 CPython의 모델이지만, CPython이 일부 변수 읽기를 최적화로 제거하게 된다면 이 속성을 잃게 될 것입니다.

이를 채택하면 Jython의 dict 구현은 더 이상 ConcurrentHashMap을 사용하지 못할 수도 있습니다. ConcurrentHashMap은 적절한 happens-before 에지를 만드는 것만 약속할 뿐, 순차적 일관성을 약속하지는 않기 때문입니다(다만 Java의 volatile 변수가 전순서를 이룬다는 사실이 그대로 적용될 수도 있습니다). Jython과 IronPython 모두 __slots__ 배열에 대해 AtomicReferenceArray 또는 그에 상응하는 것을 사용해야 할 것입니다.

x86 모델 적용

x86 모델은 다음과 같습니다.

  1. 로드는 다른 로드와 순서가 바뀌지 않습니다.
  2. 스토어는 다른 스토어와 순서가 바뀌지 않습니다.
  3. 스토어는 그보다 앞선 로드와 순서가 바뀌지 않습니다.
  4. 로드는 다른 위치에 대한 앞선 스토어와는 순서가 바뀔 수 있지만, 같은 위치에 대한 앞선 스토어와는 순서가 바뀌지 않습니다.
  5. 다중 프로세서 시스템에서 메모리 순서는 인과성을 따릅니다(메모리 순서는 추이적 가시성을 존중합니다).
  6. 멀티프로세서 시스템에서 같은 위치에 대한 저장(store)은 전순서(total order)를 가집니다.
  7. 멀티프로세서 시스템에서 잠금 명령어(locked instruction)는 전순서를 가집니다.
  8. 적재(load)와 저장은 잠금 명령어와 순서가 바뀌지 않습니다.

획득/해제(acquire/release) 용어로 말하면, 이는 모든 저장이 해제이고 모든 적재가 획득이라는 뜻으로 보입니다. 이는 Inconsistent Orderings를 허용한다는 점에서 순차적 일관성보다 약간 약하지만, Zombie values 및 이를 만들어 내는 컴파일러 최적화는 허용하지 않습니다. 컴파일러가 중복된 변수 읽기를 제거하는 것을 명시적으로 허용하도록 어떤 식으로든 이 모델을 약화하는 편이 좋을 것입니다. 또한 x86 모델은 다른 플랫폼에서 구현하는 데 비용이 많이 들 수 있지만, x86이 워낙 흔하기 때문에 그 점은 크게 중요하지 않을 수도 있습니다.

다른 모델로 강화하거나 약화하기

향후 구현을 완전히 제약하지 않으면서도 초기 메모리 모델을 채택할 수 있습니다. 약한 모델로 시작했다가 나중에 더 강하게 만들고 싶다면, 프로그램이 아니라 구현만 변경하면 됩니다. 개별 구현이 언어가 요구하는 것보다 더 강한 메모리 모델을 보장할 수도 있지만, 이는 상호 운용성을 해칠 수 있습니다. 반면에 강한 모델로 시작했다가 나중에 약화하고 싶다면, from __future__ import weak_memory 문을 추가하여 일부 모듈이 안전하다고 선언할 수 있습니다.

구현 세부 사항

요구되는 모델은 어떤 특정 구현보다도 약합니다. 이 절은 각 구현이 실제로 제공하는 보장을 문서화하려는 것이며, 구현이 변경됨에 따라 갱신되어야 합니다.

CPython

GIL을 사용하여 다른 스레드가 이상한 순서 재배치를 보지 않도록 보장하며, 최적화를 충분히 적게 수행하므로 바이트코드 수준에서는 실제로 순차적 일관성을 가진다고 생각합니다. 스레드는 (문 사이에서만이 아니라) 임의의 두 바이트코드 사이에서 전환될 수 있으므로, 다음을 동시에 실행하는 두 스레드는:

i = i + 1

i가 처음에 0일 때 기대한 i==2 대신 i==1로 끝나기 쉽습니다. 대신 다음을 실행하면:

i += 1

CPython 2.6은 항상 올바른 답을 내지만, 이 문이 원자적이지 않은 다른 구현을 상상하기는 쉽습니다.

PyPy

역시 GIL을 사용하지만, 아마도 순차적 일관성을 위반할 만큼의 최적화를 수행할 것입니다. 저는 이 구현에 대해 아는 것이 거의 없습니다.

Jython

Java memory model 하에서 진정한 동시성을 제공하며, 모든 객체 필드(__slots__에 있는 것은 제외?)를 ConcurrentHashMap에 저장하는데, 이는 상당히 강한 순서 보장을 제공합니다. 함수의 지역 변수는 보장이 더 적을 수 있으며, 이는 지역 변수가 클로저에 캡처된 후 그 클로저가 다른 스레드로 전달되는 경우에 드러날 것입니다.

IronPython

CLR 메모리 모델 하에서 진정한 동시성을 제공하며, 이 모델이 아마도 uninitialized values로부터 보호해 줄 것입니다. IronPython은 객체 필드를 저장하기 위해 잠금 맵을 사용하므로, 적어도 Jython만큼의 보장을 제공합니다.

참고 문헌

감사의 글

이 PEP가 어떤 모습이어야 하는지 자세히 논의해 준 Jeremy Manson과 Alex Martelli에게 감사드립니다.