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는 두 가지 사이에서 적절한 절충점을 찾고자 합니다.
근거
지금까지는 CPython, Jython, IronPython, PyPy_라는 4개의 주요 Python 구현과 수많은 사소한 구현이 존재합니다. 이들 중 일부는 이미 공격적인 최적화를 수행하는 플랫폼에서 실행됩니다. 일반적으로 이러한 최적화는 단일 실행 스레드 안에서는 보이지 않지만, 동시에 실행되는 다른 스레드에는 보일 수 있습니다. 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()의 반환은 해당 호출과 동기화됩니다.
동기화 엣지의 출발점을 해당 변수에 대한 릴리스 연산이라고 하고, 도착점을 획득 연산이라고 합니다.
happens before 순서는 프로그램 순서와 동기화 엣지의 추이적 폐쇄입니다. 즉, 동작 A가 동작 B보다 먼저 발생하는 경우는 다음과 같습니다.
- A가 프로그램 순서에서 B보다 앞섭니다(즉, 같은 스레드에서 실행됩니다).
- A가 B와 동기화됩니다.
- A에서 happens-before 엣지를 따라가면 B에 도달할 수 있습니다.
프로그램의 실행은 각 읽기 R이 다음 조건을 만족하는 동일한 변수에 대한 쓰기 W의 값을 볼 때 happens-before 일관성을 가집니다.
- R이 W보다 먼저 발생하지 않습니다.
- R이 값을 볼 기회를 얻기 전에 W를 덮어쓴 다른 쓰기 V는 없습니다. (즉, W가 발생한 후 V가 발생하고 그 후 R이 발생하는 경우일 수 없습니다.)
서로 충돌하는 두 동작이 happens-before 관계로 연결되지 않으면 데이터 경합이 발생합니다.
예시
다음 프로그램이 “[7]”을 출력한다는 것을 happens-before 모델의 규칙을 사용하여 증명해 보겠습니다.:
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()
myqueue가thread1또는thread2가 시작되기 전에 메인 스레드에서 초기화되므로, 해당 초기화는worker1과worker2가 실행을 시작하기 전에 발생합니다. 따라서 어느 쪽도 NameError를 발생시킬 수 없으며,myqueue.l과myqueue.cond는 모두 최종 객체로 설정됩니다.worker1에서x의 초기화는myqueue.put()을 호출하기 전에 발생하고, 이는myqueue.l.append(x)를 호출하기 전에 발생하며, 이는myqueue.cond.release()호출 전에 발생합니다. 이는 모두 같은 스레드에서 실행되기 때문입니다.worker2에서는myqueue.l에 값(x)이 포함될 때까지myqueue.cond가 해제되었다가 다시 획득됩니다.worker1의myqueue.cond.release()호출은worker2의 마지막myqueue.cond.acquire()호출보다 먼저 발생합니다.- 마지막
myqueue.cond.acquire()호출은myqueue.get()이myqueue.l을 읽기 전에 발생하고, 이는myqueue.get()이 반환되기 전에 발생하며, 이는print y보다 먼저 발생합니다. 이 역시 모두 같은 스레드에서 실행되기 때문입니다. - happens-before가 추이적이므로, thread1의
x에 처음 저장된 리스트는 thread2에서 출력되기 전에 초기화됩니다.
일반적으로 사용이 안전하다는 것을 입증하기 위해 스레드 안전 큐의 구현을 끝까지 살펴볼 필요는 없습니다. 해당 인터페이스는 put이 get보다 먼저 발생한다고 명시하며, 이를 바탕으로 직접 추론할 수 있습니다.
경쟁 조건에서의 놀라운 동작
코드에 데이터 경쟁이 있으면 많은 이상한 일이 발생할 수 있습니다. 공유 변수를 잠금으로 보호하기만 하면 이러한 문제를 모두 쉽게 방지할 수 있습니다. 이는 경쟁 조건 위험의 완전한 목록이 아니며, 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를 초기화할 때 p.x를 중복해서 로드하지 않고, r2에 이미 로드된 값을 재사용하여 최적화하고자 할 것입니다. 스레드 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의 데이터)을 모두 증명합니다. 실제 세계의 예는 Special Relativity를 참조하십시오.
스레드 1과 스레드 3이 서로 가까운 프로세서에서 실행되지만 스레드 2와 스레드 4가 실행되는 프로세서와는 멀리 떨어져 있고, 쓰기 작업이 인접한 스레드에 표시되기 전에 머신 전체를 가로질러 끝까지 전송되지 않는 경우 이러한 일이 발생할 수 있습니다.
획득/해제 의미 체계나 명시적 메모리 장벽으로도 이 문제를 해결할 수 없습니다. 잠금 없이 순서를 일관되게 만들려면 아키텍처의 메모리 모델에 대한 자세한 지식이 필요하지만, 자바는 volatile에 이를 요구하므로 해당 구현자를 대상으로 작성된 문서를 사용할 수 있습니다.
순차적으로 일관된 경합이 아닌 happens-before 경합
자바 메모리 모델에 관한 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
WTF,라고 자문하게 됩니다. 원래 프로그램에는 스레드 간 happens-before 간선이 없었으므로, 스레드 1의 x 읽기는 스레드 2의 쓰기 중 어느 것이든 볼 수 있습니다. 해당 쓰기가 읽기가 그것을 보았기 때문에만 발생한 경우에도 마찬가지입니다. happens-before 모델에는 데이터 경쟁이 있습니다.
이를 허용하고 싶지는 않으므로, Python에는 happens-before 모델만으로는 충분하지 않습니다. 이러한 실행을 방지하도록 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은 그렇지 않을 수도 있습니다.
초기화되지 않은 값(직접)
여러 고전적인 이중 확인 잠금 예제에서 가져왔습니다.
처음에는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]이 동시에 실행될 수 없다는 것을 알 수 있습니다. CPython에서(GIL이 없는 경우) 이러한 경쟁 조건은 정의되지 않은 동작을 일으킵니다.
읽기 측에도 d[1]의 값이 최신 상태가 아니게 만들 수 있는 미묘한 문제가 있습니다. list의 구현 어딘가에서 해당 내용이 메모리의 배열로 저장됩니다. 이 배열이 스레드 1의 캐시에 있을 수도 있습니다. 스레드 1의 프로세서가 값 3과 4를 포함해야 하는 메모리를 다시 읽지 않고 주 메모리에서 d를 다시 읽으면, 대신 오래된 값을 볼 수 있습니다. 제가 아는 한 이는 실제로 Alphas와 어쩌면 Itaniums에서만 발생할 수 있으며, 충돌을 방지하려면 어쨌든 이를 막아야 할 것입니다.
초기화되지 않은 값(플래그)
몇 가지 추가적인 이중 확인 잠금 예제에서 가져온 것입니다.
처음에는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 variable)를 통해 접근할 때만 작동합니다. 다른 경로를 통해 동일한 객체에 접근하는 것조차 안전하지 않습니다. 안타깝게도 프로세서의 작동 방식 때문에 final의 보장은 약한 경우에만 비용이 적게 듭니다.
파이썬의 규칙
첫 번째 규칙은 사용자 코드의 경합 조건 때문에 Python 인터프리터가 충돌해서는 안 된다는 것입니다. CPython의 경우, 이는 경합 조건이 C 코드까지 내려갈 수 없다는 의미입니다. Jython의 경우, 이는 NullPointerException이 인터프리터 밖으로 빠져나갈 수 없다는 의미입니다.
또한 동시 큐와 스레드 시작 및 조인이 작동하는 방식을 간단하게 설명할 수 있으므로, 적어도 happens-before 일관성만큼 강력한 모델을 원할 것입니다.
다른 규칙들은 논쟁의 여지가 더 크므로, 각각의 장단점을 함께 제시하겠습니다.
데이터 경합이 없는 프로그램은 순차적으로 일관됩니다
프로그래머가 자신의 프로그램을 순차적으로 일관된 것처럼 추론할 수 있기를 바랍니다. happens-before 경합을 작성했는지 판단하기 어렵기 때문에, 프로그래머에게 순차적 경합을 방지하도록만 요구하고자 합니다. Java 모델은 인과 관계를 복잡하게 정의하여 이를 구현하지만, 이를 포함하고 싶지 않다면 이 속성을 직접 주장하면 됩니다.
근거 없이 발생한 읽기로 인한 보안 허점 없음
프로그램이 자기 정당화 값을 생성하면, 사용자가 프로그램에 노출되기를 원하지 않을 객체에 대한 접근을 노출할 수 있습니다. 다시 말해, 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)중 하나일 수 있지만 다른 값은 될 수 없습니다. 쓰기가 릴리스이고 읽기가 획득인 경우와 달리, 스레드 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 코드보다 상당히 비싸지 않으므로, 이것이 중요할 수도 있고 중요하지 않을 수도 있습니다. 이러한 보장 수준을 되찾고 싶다면 다음과 같이 할 수 있습니다.
순차적 일관성
Python에 순차적 일관성을 그대로 채택할 수 있습니다. 이렇게 하면 위에서 언급한 hazards를 모두 피할 수 있지만, 많은 최적화도 금지됩니다. 제가 아는 한 이는 현재 CPython의 모델이지만, CPython이 일부 변수 읽기를 최적화하여 제거할 수 있게 되면 이 속성을 잃게 됩니다.
이를 채택하면 Jython의 dict 구현은 더 이상 ConcurrentHashMap을 사용하지 못할 수 있습니다. ConcurrentHashMap은 적절한 happens-before 엣지를 생성할 뿐, 순차적으로 일관적이라고 보장하지 않기 때문입니다(다만 Java volatile이 완전히 순서화된다는 사실이 적용될 수도 있습니다). Jython과 IronPython은 모두 __slots__ 배열에 대해 AtomicReferenceArray 또는 이에 상응하는 것을 사용해야 할 가능성이 높습니다.
x86 모델 적용
x86 모델은 다음과 같습니다.
- 로드는 다른 로드와 순서가 재배치되지 않습니다.
- 저장은 다른 저장과 순서가 재배치되지 않습니다.
- 저장은 이전 로드와 순서가 재배치되지 않습니다.
- 로드는 서로 다른 위치에 대한 이전 저장과는 순서가 재배치될 수 있지만, 동일한 위치에 대한 이전 저장과는 순서가 재배치되지 않습니다.
- 다중 프로세서 시스템에서 메모리 순서는 인과 관계를 따릅니다(메모리 순서는 전이적 가시성을 존중합니다).
- 다중 프로세서 시스템에서 동일한 위치에 대한 저장에는 전체 순서가 있습니다.
- 다중 프로세서 시스템에서 잠금 명령어에는 전체 순서가 있습니다.
- 로드와 저장은 잠금 명령어와 순서가 재배치되지 않습니다.
획득/해제 용어로 표현하면, 이는 모든 저장이 해제이고 모든 로드가 획득이라는 의미로 보입니다. 이는 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에서 진정한 동시성을 제공하며, 상당히 강력한 순서 보장 기능을 제공하는 ConcurrentHashMap에 __slots__?에 있는 필드를 제외한 모든 객체 필드를 저장합니다. 함수의 지역 변수에는 더 적은 보장이 적용될 수 있으며, 해당 변수가 클로저에 캡처된 후 다른 스레드에 전달되면 이러한 차이가 드러납니다.
IronPython
CLR 메모리 모델에 따라 진정한 동시성을 제공하며, 이는 아마도 uninitialized values 로부터 보호해 줍니다. IronPython은 객체 필드를 저장하기 위해 잠금 맵을 사용하므로, Jython 이상 수준의 보장을 제공합니다.
참고 자료
- SC의 대안, cpp-threads 메일링 리스트의 한 스레드,
- 좋은 예제가 많이 포함되어 있습니다. (http://www.decadentplace.org.uk/pipermail/cpp-threads/2007-January/001287.html)
- CPython용 Adam Olsen의 패치인 python-safethread
- GIL을 제거하고 스레드 간에 공유되는 모든 객체가 일관되게 잠긴다는 것을 정적으로 보장합니다. (http://code.google.com/p/python-safethread/)
- N2480: 다음에 대한 덜 형식적인 설명
- 제안된 C++ 동시성 메모리 모델, Hans Boehm (http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2007/n2480.html)
감사의 말
이 PEP가 어떤 모습이어야 하는지에 대해 자세히 논의해 준 Jeremy Manson과 Alex Martelli에게 감사드립니다.
Copyright
This document has been placed in the public domain.