소프트웨어 트랜잭셔널 메모리

경고: 아래 내용은 비교적 오래된 정보이며, pypy-stm 변형은 더 이상 활발히 개발되고 있지 않습니다. 이 접근 방식의 기술적 세부 사항과 장단점에 대한 설명은 Remi Meier의 2019년 논문과 그의 박사 학위 논문을 참고하십시오.

이 페이지는 pypy-stm에 관한 것입니다. pypy-stm은 현재 개발 중인 PyPy의 특별한 버전으로, 같은 프로세스 안에서 CPU를 많이 사용하는 독립적인 스레드 여러 개를 병렬로 실행할 수 있습니다. 이는 Python 세계에서 “전역 인터프리터 락(GIL)” 문제로 알려진 것에 대한 해결책입니다 — 즉, GIL 없는 Python 구현입니다.

“STM”은 내부적으로 사용되는 기법인 Software Transactional Memory의 약자입니다. 이 페이지에서는 사용자 관점에서 pypy-stm을 설명하고, 진행 중인 작업을 소개하며, 마지막으로 더 자세한 구현 내용을 다루는 참고 자료를 안내합니다.

이 작업은 Remi Meier와 Armin Rigo가 수행했습니다. 지금까지 크라우드 펀딩으로 이 작업을 후원해 주신 모든 기부자분께 감사드립니다! 2nd call for donation을 살펴봐 주십시오.

pypy-stm의 용도

pypy-stm은 일반 PyPy 인터프리터의 변형입니다. (이 버전은 Python 2.7을 지원합니다. Python 3, CPython, and others에 대해서는 아래를 참조하십시오.) 아래에 나열된 caveats를 감안하면, 두 경우 모두 JIT 버전끼리 비교할 때 이론상 일반 PyPy보다 20%-50% 이내로 느린 수준이어야 합니다(하지만 아래를 참조하십시오!). 내부적으로 사용하는 기법인 소프트웨어 트랜잭셔널 메모리(Software Transactional Memory)의 약자를 따서 STM이라고 부릅니다(Reference to implementation details를 참조하십시오).

이렇게 만들어진 pypy-stm은 여러 스레드의 Python 코드를 병렬로 실행할 수 있다는 이점이 있습니다. 두 개 이상의 스레드를 병렬로 실행하는 프로그램은 이상적으로는 일반 PyPy에서보다 더 빠르게 실행되어야 합니다(지금 당장이든, 버그가 수정되는 대로 곧이든).

  • pypy-stm은 GIL 기반 PyPy와 완벽하게 호환됩니다. 기존 PyPy를 그대로 대체하여 사용할 수 있으며, 멀티스레드 프로그램은 여러 코어에서 실행됩니다.
  • pypy-stm은 순수 Python 모듈인 transaction을 통해 사용자에게 특별한 API를 제공합니다(다만 그 사용을 강제하지는 않습니다). 이 모듈은 더 저수준인 pypystm 모듈을 기반으로 하지만, STM을 사용하지 않는 PyPy나 CPython과도 어느 정도 호환성을 제공합니다.
  • GIL을 제거하는 방식을 바탕으로 How to write multithreaded programs: the 10‘000-feet view와 transaction.TransactionQueue에 대해 이야기하겠습니다.

…그리고 pypy-stm이 적합하지 않은 용도

pypy-stm은 GIL이 없는 Python을 제공합니다. 즉, 애초에 GIL이 문제가 되는 상황에서 유용합니다. (프로그램을 여러 스레드에서 실행되도록 쉽게 수정할 수 있는 경우도 여기에 포함됩니다. 이런 수정을 고려하지 않는 경우가 많은데, 바로 GIL 때문입니다.)

하지만 GIL이 문제가 되지 않는 경우도 많습니다. 이러한 경우에는 pypy-stm이 도움이 되리라고 기대하지 마십시오! 여기에는 여러 스레드를 사용하지만 실제로 Python 코드를 실행하는 데에는 많은 시간을 쓰지 않는 모든 프로그램이 포함됩니다. 예를 들어, 프로그램이 I/O가 일어나기를 기다리는 데 모든 시간을 쓰거나, 거대한 행렬에 대해 오래 걸리는 계산을 수행하고 있을 수 있습니다. 이는 CPU가 유휴 상태이거나 어차피 어떤 C/Fortran 라이브러리 안에 있는 경우이며, 두 경우 모두 인터프리터(CPython이든 일반 PyPy든)는 외부 호출 전후로 GIL을 해제해야 합니다. 따라서 스레드들이 결국 GIL을 두고 다투게 되지는 않습니다.

시작하기

pypy-stm은 현재 64비트 Linux에서만 동작합니다.

개발은 stmgc-c8 브랜치에서 진행됩니다. 한번 사용해 보는 데에만 관심이 있으시다면, 저희가 최신 사전 빌드 바이너리를 올릴 때까지 저희를 계속 재촉해 주십시오. 현재 버전은 네 개의 “세그먼트”를 지원하며, 이는 최대 네 개의 스레드를 병렬로 실행한다는 뜻입니다.

소스로부터 빌드하려면 먼저 수정된 버전의 gcc를 컴파일해야 합니다(!). 다음 안내를 참고하십시오: https://bitbucket.org/pypy/stmgc/src/default/gcc-seg-gs/ (이 패치들은 현재 gcc에 통합되고 있다는 점에 유의하십시오. 향후 버전의 gcc에서는 더 이상 패치가 필요하지 않을 가능성이 높습니다.)

그런 다음 PyPy의 stmgc-c8 브랜치를 받아서 다음을 실행하십시오.:

cd pypy/goal
../../rpython/bin/rpython -Ojit --stm

마지막으로 gcc-seg-gs를 호출하여 생성된 C 코드를 컴파일하려고 시도합니다. gcc-seg-gs는 위의 안내에 따라 설치한 스크립트여야 합니다.

현재 상태(stmgc-c7)

Warning

이 페이지는 오래된 문서입니다. 이하 내용은 STMGC-C7에 관한 것이며, 현재 개발 작업은 STMGC-C8에서 진행되고 있습니다.

  • NEW: 이제 더 이상 크래시 없이 잘 작동하는 것으로 보입니다. 크래시(또는 다른 버그)를 발견하시면 report any crash에 따라 보고해 주십시오.
  • “richards”와 같은 예제에서는 오버헤드가 20% 정도로 낮게 실행됩니다. 오버헤드가 더 큰 예제도 있으며(현재 “translate.py”의 경우 최대 2배), 그 원인은 아직 파악하는 중입니다. 원인으로 의심되는 것 중 하나는 GC를 일부만 구현한 점입니다. 아래를 참고하십시오.
  • 새 기능: PYPYSTM 환경 변수와 pypy/stm/print_stm_log.py 스크립트를 사용하면 정확히 어떤 “충돌”이 발생했는지 알 수 있습니다. 이에 대해서는 아래 transaction.TransactionQueue 절에서 설명합니다.
  • 새 기능: 트랜잭션 친화적인 특수 API(stmdict 등)가 추가되었으며, 아래 transaction.TransactionQueue 절에서 설명합니다. 기존 API가 다시 변경되었으며, 대부분 다른 모듈로 옮겨졌습니다. 불편을 드려 죄송합니다. 나중에 잘못된 API에 묶여 버리는 것보다 초기에 API를 바꾸는 편이 더 낫다고 생각합니다…
  • 현재 RAM은 1.5 GB로 제한되어 있습니다(이것은 core.h에 있는 매개변수 하나일 뿐입니다 – 이론상으로는 그렇습니다. 실제로는 이 값을 너무 많이 늘리면 clang이 또다시 충돌합니다). 메모리 오버플로는 올바르게 처리되지 않으며, 세그폴트를 일으킵니다.
  • NEW: JIT 워밍업 시간이 다시 개선되었지만, 여전히 비교적 깁니다. 기계어 코드를 생성하려면 JIT가 “inevitable” 모드에 진입해야 합니다. 이는 프로그램이 몇 초 이상 실행되지 않으면 성능 결과가 나쁘게 나온다는 뜻이며, 여기서 몇 초는 한참을 의미할 수도 있습니다. 벤치마크를 해 볼 때는 워밍업이 끝난 상태, 즉 성능이 더 이상 개선되지 않는 상태에 도달했는지 반드시 확인하십시오.
  • 이 GC는 새로 만든 것입니다. PyPy의 일반 GC에서 분명히 영감을 받았지만, 현재로서는 여러 최적화가 빠져 있습니다. 곧바로 소멸하지 않는 작은 객체를 대량으로 할당하는 프로그램(분명 흔한 상황입니다)은 이러한 최적화가 빠져 있어 성능이 떨어집니다. (최신 개발 버전인 stmgc-c8은 이 부분에서 더 낫습니다.)
  • 현재로서는 약한 참조(weakref)가 다소 이상하게 동작하는 것처럼 보일 수 있습니다. 때로는 gc.collect()를 거치고도 살아남거나, 심지어 죽었다가 잠시 되살아난 뒤 다시 죽기도 합니다. 비슷한 문제가 일부 외부 자원에 접근할 때 다른 곳에서도 가끔 나타날 수 있는데, 이는 (겉보기의) 직렬화된 순서가 실제 (멀티스레딩) 순서와 일치하지 않는 경우입니다. 이는 버그입니다(stmgc-c8에서 이미 일부 수정되었습니다). 또한 weakref.getweakrefcount() 같은 디버깅 도우미가 잘못된 결과를 반환할 수 있습니다.
  • STM 시스템은 매우 효율적인 읽기/쓰기 배리어를 기반으로 하며, 이 배리어는 대부분 완성되었습니다(JIT가 생성한 기계어 코드에서의 배리어 배치는 조금 더 개선할 수 있습니다).
  • 전체 메모리를 수동으로 복사해야 하므로 프로세스 포크가 느립니다. 이를 알리는 경고가 출력됩니다.
  • 며칠 단위로 매우 오래 실행되는 프로세스는 내부 28비트 카운터의 오버플로 처리가 구현되어 있지 않아 결국 어서션 오류로 중단됩니다.
  • 재귀 감지 코드는 다시 구현되지 않았습니다. 현재로서는 무한 재귀가 발생하면 그냥 세그폴트(segfault)가 납니다.

Python 3, CPython 및 기타

이 문서에서는 PyPy의 Python 2.7 인터프리터를 기반으로 하는 “pypy-stm”을 설명합니다. Python 3를 지원하는 데에는 대략 반나절 정도의 작업이 필요할 것입니다. 물론 내일이면 완성되고 다듬어진 “pypy3-stm” 제품을 손에 넣을 수 있다는 뜻은 아닙니다. 일반적인 py3k 작업이 아직 남아 있으며, 일반적인 stm 작업도 아직 남아 있습니다. 그러나 PyPy에서 늘 그렇듯이 이 둘은 서로 꽤 독립적입니다. 이제 내부 인터페이스가 안정되어 가는 것으로 보이므로, 필요한 반나절의 작업은 머지않아 분명히 이루어질 것입니다.

RPython 프레임워크로 구현된 다른 언어에도 마찬가지로 적용됩니다. 다만 투입해야 할 작업량은 다를 수 있습니다. RPython 내부의 STM 프레임워크는 현재 PyPy 인터프리터를 대상으로 하고 있어서, 다른 인터프리터는 요구 사항이 조금 다를 수 있기 때문입니다. 하지만 일반적으로 번거로운 변환 작업은 모두 RPython이 처리하므로, 여러분에게는 (바라건대 많지 않을) 어렵고 흥미로운 부분만 남게 됩니다.

STM의 핵심은 C로 작성된 라이브러리 형태로 동작합니다(아래 Reference to implementation details를 참조하십시오). 따라서 RPython으로 만들어진 인터프리터가 아닌 다른 인터프리터에서도 이 라이브러리를 사용할 수 있습니다. Duhton이 그 초기 사례입니다. 여기까지 읽고 나면 이 라이브러리를 CPython에 맞게 고쳐 쓰는 방안을 떠올릴 수도 있습니다. 하지만 미리 경고해 둡니다. 제가 판단하기로는 이것은 실패할 수밖에 없는 아이디어입니다. 저는 Duhton을 디버깅하는 데에도 무척 애를 먹었는데, Duhton은 CPython보다 비교할 수 없을 만큼 단순합니다. 그 점을 제외하더라도, Duhton의 C 소스를 보면 핵심 설계 결정 중 많은 부분이 CPython과 다르다는 것을 알 수 있습니다. 참조 카운팅이 없고, 미리 만들어진 “정적” 객체를 제한적으로만 지원하며, 곳곳에 stm_read()와 stm_write() 매크로 호출이 들어가 있습니다(하나라도 빠뜨리면 매우 드물고 매우 알기 어려운 버그가 생깁니다). 이 밖에도 여러 차이가 있습니다. 특수한 목적에 맞게 C 언어를 직접 확장하고, 이를 전처리하여 일반 C로 바꾸는 방식을 생각해 볼 수도 있습니다. 제가 보기에 그것은 점점 RPython 자체와 상당히 비슷해지지만, 어쩌면 이 방식을 더 선호하실 수도 있습니다. 물론 필요한 C 확장 모듈 하나하나를 여전히 신경 써야 하지만, 어쩌면 나아갈 길을 찾을 수 있을지도 모릅니다.

사용자 가이드

멀티스레드 프로그램 작성법: 10,000피트 상공에서 내려다본 큰 그림

PyPy-STM은 멀티스레드 프로그램을 작성하는 두 가지 방법을 제공합니다.

  • thread 또는 threading 모듈을 사용하는 전통적인 방식으로, 먼저 설명합니다.
  • 스레드라는 저수준 개념을 감추는 방법으로, 다음에서 설명하는 TransactionQueue를 사용합니다.

저수준 스레드의 문제점은 (특히 GIL 기반 인터프리터가 없는 다른 언어에서) 잘 알려져 있습니다. 메모리 손상, 교착 상태(deadlock), 라이브락(livelock) 등이 그것입니다. OpenMP처럼 스레드를 직접 다루지 않는 대안적인 접근 방식들도 있습니다. 이러한 접근 방식은 보통 코드에 어느 정도의 구조를 강제합니다. TransactionQueue도 부분적으로는 비슷합니다. 이를 적용하려면 먼저 프로그램에 병렬화의 “어느 정도의 여지”가 있어야 합니다. 하지만 저는 TransactionQueue의 적용 범위가 다른 접근 방식보다 훨씬 넓다고 생각합니다. 보통은 기존 코드를 완전히 재구성하도록 강요하지 않고도 동작하며, 잠재적이고 불완전한 병렬성을 지닌 모든 Python 프로그램에서 동작합니다. 이상적으로는 최종 프로그래머가 이러한 병렬성이 어디에 있을지 파악하고, 간단한 API를 사용해 이를 시스템에 알려 주기만 하면 됩니다.

그대로 바꿔 쓸 수 있는 대체품

멀티스레드를 사용하는 CPU 집약적 Python 프로그램은 pypy-stm에서 수정 없이 동작할 것입니다. 이러한 프로그램은 여러 CPU 코어를 사용하여 병렬로 실행됩니다.

GIL(Global Interpreter Lock, 전역 인터프리터 락)의 기존 의미론은 바뀌지 않습니다. pypy-stm은 여러 코어에서 병렬로 실행되지만 스레드가 순차적으로 실행되는 것처럼 보이게 하며, 스레드 전환은 바이트코드 사이에서만 일어나고 바이트코드 실행 도중에는 일어나지 않습니다. 프로그램은 이 성질에 의존할 수 있습니다. shared_list.append()/pop()이나 shared_dict.setdefault()를 동기화 메커니즘으로 사용하는 방식은 여전히 예상대로 동작합니다.

이 방식은 표준 PyPy나 CPython이 GIL을 해제하는 지점들을 내부적으로 파악한 다음, 그 지점들을 “트랜잭션”의 경계로 대체하는 방식으로 동작합니다. 데이터베이스의 트랜잭션과 마찬가지로 여러 트랜잭션이 병렬로 실행될 수 있지만, 커밋은 어떤 직렬 순서에 따라 이루어집니다. 트랜잭션들은 마치 이 직렬화 순서대로 하나씩 온전히 실행된 것처럼 동작합니다.

transaction.TransactionQueue

CPU를 많이 사용하는 프로그램에서는 어떤 자료 구조를 순회하는 가장 바깥쪽 루프나 그 밖의 반복적인 알고리즘을 쉽게 찾아낼 수 있는 경우가 많습니다. 이러한 구조에서 각 “블록”은 적지 않은 양의 데이터를 처리하며, 블록들은 서로 독립적일 “가능성이 꽤 높습니다”. 블록들이 실제로 독립적임을 증명할 필요는 없습니다. 자주 독립적이기만 하면 충분합니다. 더 정확히 말하면, 블록들이 자주 독립적이어야 한다고 우리가 생각하기만 하면 충분합니다.

전형적인 예는 다음과 같습니다. 여기서 함수 func()는 일반적으로 많은 양의 코드를 호출합니다:

for key, value in bigdict.items():
    func(key, value)

그런 다음 루프를 다음과 같이 바꾸기만 하면 됩니다.:

from transaction import TransactionQueue

tr = TransactionQueue()
for key, value in bigdict.items():
    tr.add(func, key, value)
tr.run()

이 코드의 동작은 동일합니다. 내부적으로 TransactionQueue 객체는 N개의 스레드를 시작하고 모든 스레드에서 func(key, value) 호출을 병렬로 실행하려고 시도합니다. 하지만 Python보다 저수준인 많은 언어에서 볼 수 있는 일반적인 스레드 풀링 라이브러리와의 차이점에 주목하십시오. 함수 호출이 병렬로 실행된다고 해서 서로 무작위로 뒤섞이지는 않습니다. TransactionQueue를 사용한다고 해서 동작이 바뀌지는 않았습니다. 모든 호출은 여전히 어떤 직렬 순서로 실행되는 것처럼 보입니다.

TransactionQueue의 일반적인 사용 과정은 다음과 같습니다. 처음에는 성능이 향상되지 않습니다. 실제로는 오히려 더 나빠질 가능성이 큽니다. 일반적으로 이는 전체 CPU 사용률에서 드러나며, 이때 CPU 사용률은 낮은 상태(N개 코어보다는 1에 가까운 수준)에 머뭅니다. 먼저 JIT 워밍업 단계에서는 CPU 사용률이 1을 크게 넘지 않는 것이 정상이라는 점에 유의하십시오. JIT가 충분히 워밍업될 기회를 주려면 프로그램을 몇 초 동안, 규모가 큰 프로그램이라면 최소 1분 동안 실행해야 합니다. 그러나 그 이후에도 CPU 사용률이 계속 낮다면, PYPYSTM 환경 변수를 사용하여 무슨 일이 일어나고 있는지 추적할 수 있습니다.

PYPYSTM=logfile로 프로그램을 실행하면 logfile이라는 로그 파일이 생성됩니다. 그런 다음 pypy/stm/print_stm_log.py 유틸리티를 사용하여 이 로그 파일의 내용을 살펴보십시오. 이 유틸리티는 다음과 같은 출력을 생성합니다(손실된 시간이 큰 것부터 순서대로 정렬됨):

10.5s lost in aborts, 1.25s paused (12412x STM_CONTENTION_WRITE_WRITE)
File "foo.py", line 10, in f
  someobj.stuff = 5
File "bar.py", line 20, in g
  someobj.other = 10

이는 두 개의 독립적인 단일 항목 트레이스백에 나타난 이유 때문에 중단된 트랜잭션을 실행하느라 10.5초가 손실되었다는 뜻입니다(이로 인해 일시 정지로 1.25초가 추가로 손실되었습니다). 그 이유는 한 스레드가 someobj.stuff = 5 줄을 실행하는 동안 다른 스레드가 같은 객체에 대해 someobj.other = 10 줄을 동시에 실행했다는 것입니다. 이 두 쓰기 작업은 같은 객체에 대해 수행됩니다. 이로 인해 충돌이 발생하며, 그 결과 두 트랜잭션 중 하나가 중단됩니다. 위 예시에서는 이러한 일이 12412번 발생했습니다.

나머지 두 충돌 원인은 STM_CONTENTION_INEVITABLE과 STM_CONTENTION_WRITE_READ입니다. STM_CONTENTION_INEVITABLE은 두 트랜잭션이 모두 출력, 소켓에서 읽기, 원시 데이터로 된 외부 배열에 접근하기와 같은 외부 연산을 시도했다는 뜻입니다. STM_CONTENTION_WRITE_READ는 한 트랜잭션은 객체에 썼지만 다른 트랜잭션은 그 객체에 쓰지 않고 읽기만 했다는 뜻입니다(이 경우에는 쓰기를 한 트랜잭션만 보고됩니다. 읽기 위치를 기록하려면 성능에 매우 큰 영향을 주어야 하므로 읽기 위치는 기록하지 않습니다).

충돌이 발생하는 흔한 원인은 다음과 같습니다:

  • 우선, 모든 I/O나 메모리에 대한 저수준 조작은 트랜잭션을 불가피한(inevitable, “중단되어서는 안 되는”) 상태로 만듭니다. 어느 시점에서든 실행 중인 불가피한 트랜잭션은 하나만 존재할 수 있습니다. 흔한 예는 각 트랜잭션이 로그 파일에 데이터를 보내는 것으로 시작하는 경우입니다. 이런 경우에는 해당 작업이 트랜잭션의 끝부분 근처에서 일어나도록 리팩터링하거나(그러면 트랜잭션의 대부분을 불가피하지 않은 모드로 실행할 수 있습니다), 별도의 트랜잭션이나 아예 별도의 스레드에 위임해야 합니다.

  • 리스트나 딕셔너리에 쓰는 작업은 같은 리스트나 딕셔너리에서 읽는 모든 작업과 충돌합니다. 다른 키로 읽는 경우에도 마찬가지입니다. 딕셔너리와 집합에는 transaction.stmdict 타입과 transaction.stmset 타입을 사용해 볼 수 있습니다. 이 타입들은 대부분 dict와 set처럼 동작하지만, 서로 다른 키에는 동시에 접근할 수 있습니다. (아직 지연 반복(lazy iteration)은 지원하지 않습니다. 예를 들어 stmdict.iterkeys()는 iter(stmdict.keys())로 구현되어 있습니다. 또한 PyPy의 딕셔너리 및 집합과 달리 STM 버전은 순서를 유지하지 않습니다.) 키의 동일성(identity)을 사용하는 실험적인 stmiddict 클래스와 stmidset 클래스도 있습니다.

  • time.time()과 time.clock()은 나중에 호출된 것으로 보이는 호출이 실제로 더 큰 숫자를 반환하도록 보장하기 위해 트랜잭션을 불가피(inevitable) 트랜잭션으로 만듭니다. 결과의 순서가 약간 어긋나도 괜찮다면 transaction.time() 또는 transaction.clock()을 사용하십시오. 이 두 연산은 두 호출이 특정한 순서로 일어났음을 “증명”할 수 있는 경우(예를 들어 두 호출이 모두 같은 스레드에서 이루어진 경우)에만 증가하는 결과를 반환하도록 보장합니다. 그러한 증명이 불가능한 경우에는 무작위로 뒤섞인 값을 얻을 수도 있습니다. (독립적인 트랜잭션이 두 개 있다면, 보통은 한쪽이 완전히 실행된 뒤에 다른 쪽이 실행된 것처럼 동작합니다. 그러나 transaction.time()을 사용하면 실제로는 두 트랜잭션이 뒤섞여 실행된다는 “숨겨진 진실”을 보게 될 수도 있습니다.)

  • transaction.threadlocalproperty를 클래스 수준에서 사용할 수 있습니다:

    class Foo(object):     # must be a new-style class!
        x = transaction.threadlocalproperty()
        y = transaction.threadlocalproperty(dict)
    

    이것은 Foo의 인스턴스가 스레드 로컬인 두 속성 x와 y를 가진다고 선언합니다. 동시에 실행되는 트랜잭션에서 이 속성들을 읽거나 쓰면 서로 독립적인 결과가 반환됩니다. (Foo 인스턴스의 그 밖의 속성은 평소와 같이 모든 스레드에서 전역적으로 보입니다.) 이는 다음 두 가지 경우에 TransactionQueue와 함께 사용하면 유용합니다:

    • 한 트랜잭션 동안 값이 바뀌지만 트랜잭션 전후로 항상 어떤 초기값으로 재설정되어야 하는, 오래 살아 있는 객체의 속성에 사용합니다(예를 들어, 트랜잭션을 시작할 때 0으로 초기화되는 경우입니다. 또는 이 트랜잭션 안에서 처리할 대기 중인 작업 목록으로 사용되는 경우, 한 트랜잭션이 끝날 때는 항상 비어 있게 됩니다).
    • 여러 트랜잭션에 걸친 범용 캐시에 사용합니다. TransactionQueue를 사용하면 고정된 개수 N개의 스레드로 이루어진 풀을 얻게 되며, 각 스레드는 트랜잭션을 순차적으로 실행합니다. 스레드 로컬 속성은 같은 스레드가 마지막으로 저장한 값을 가지며, 이 값은 임의의 이전 트랜잭션에서 온 것일 수 있습니다. 기본적으로 속성 값의 사본이 N개 생기며, 각 트랜잭션은 그중 임의의 사본에 접근합니다. 캐시 용도로는 잘 작동합니다.

    좀 더 자세히 설명하면, threadlocalproperty()의 선택적 인자는 기본값 팩토리입니다. 현재 스레드에서 아직 값이 할당되지 않은 경우 팩토리가 호출되고, 그 결과가 해당 스레드의 값이 됩니다(collections.defaultdict와 같습니다). 기본값 팩토리를 지정하지 않으면 초기화되지 않은 값을 읽을 때 AttributeError가 발생합니다.

  • 이상의 모든 경우 외에도, 속성에 같은 값을 반복해서 쓰는 바람에 쓰기-쓰기 충돌이 발생하는 경우가 있습니다. 예를 들어 ea2e519614ab를 보십시오. 이 커밋은 이미 썼는지 먼저 확인하지 않고 객체 필드에 값을 쓰는 문제 두 가지를 수정합니다. dont_change_any_more 필드는 코드의 해당 부분에서 True로 설정되는 플래그이지만, 보통 이 rtyper_makekey() 메서드는 같은 객체에 대해 여러 번 호출됩니다. 예전 코드는 이 플래그를 반복해서 True로 설정했지만, 이제는 먼저 확인한 뒤 값이 False일 때만 씁니다. 마찬가지로, 체크인의 후반부에서 setup_block_entry() 메서드는 원래 concretetype 필드를 할당하는 일과 리스트를 반환하는 일을 모두 했지만, 이 메서드의 두 호출자는 서로 달랐습니다. 한쪽은 concretetype 필드가 정말로 초기화되어 있어야 했던 반면, 다른 쪽은 결과 리스트만 얻으면 되었습니다. 이 경우 concretetype 필드는 이미 설정되어 있을 수도 있고 아닐 수도 있지만, 그것은 문제가 되지 않았습니다.

Python은 복잡한 언어라는 점에 유의하십시오. 미리 예상하지 못한 곳에서 (종류를 불문하고) 충돌을 일으킬 수 있는 흔치 않은 경우가 여럿 있습니다. 이러한 경우 중 상당수는 고칠 수 있으므로, 이해되지 않는 경우가 있으면 무엇이든 보고해 주십시오.

원자적 섹션

앞에서 설명한 TransactionQueue 클래스는 원자적 섹션(atomic section)을 기반으로 합니다. 원자적 섹션은 “GIL을 해제하지 않고” 실행하려는 코드 블록입니다. STM 용어로는 트랜잭션이 도중에 중단되지 않도록 보장하면서 실행되는 코드 블록을 의미합니다. Software lock elision이 언젠가 구현된다면 이 기능은 실험적이며 향후 제거될 수 있습니다.

다음은 직접 사용하는 예시입니다:

with transaction.atomic:
    assert len(lst1) == 10
    x = lst1.pop(0)
    lst1.append(x)

이 예제에서는 리스트의 한쪽 끝에서 꺼낸 항목이 반대쪽 끝에 원자적으로 다시 추가된다는 것이 보장됩니다. 즉, 다른 스레드는 특별한 동기화 없이도 len(lst1) 또는 x in lst1을 실행할 수 있으며, 항상 각각 10과 True라는 동일한 결과를 보게 됩니다. 다른 스레드는 lst1에 원소가 9개만 들어 있는 중간 상태를 결코 보지 않습니다. 원자적 섹션은 재진입 가능한 락(re-entrant lock)과 비슷하지만(중첩될 수 있습니다), 추가로 다른 스레드에서 마침 같은 락으로 보호되는 코드만이 아니라 어떤 코드의 동시 실행으로부터도 보호합니다.

원자적 구역(atomic section)이라는 개념은 매우 강력하다는 점에 유의하십시오. 다음과 같은 코드를 작성하면:

with __pypy__.thread.atomic:
    time.sleep(10)

그렇다면 GIL이 있다고 생각해 보면, GIL을 전혀 해제하지 않은 채 10초 동안 이어지는 원자적 트랜잭션을 실행하고 있는 셈입니다. 이는 다른 모든 스레드가 전혀 진행하지 못하도록 막습니다. pypy-stm에서는 엄밀히 말해 그렇지는 않지만, 다른 스레드가 언제 진행할 수 있고 언제 진행할 수 없는지에 대한 정확한 규칙은 상당히 복잡합니다. 따라서 이러한 코드 조각이 결국에는 어차피 다른 모든 스레드를 막게 될 가능성이 높다고 생각해야 합니다.

atomic을 실험해 보려면 원자적 블록 바로 앞에 트랜잭션 분리를 직접 추가해야 할 수도 있다는 점에 유의하십시오. 블록의 경계가 트랜잭션의 경계와 일치한다는 보장이 없기 때문입니다. 트랜잭션은 적어도 블록만큼 크지만, 그보다 더 클 수도 있습니다. 따라서 큰 원자적 블록을 실행한다면 바로 앞에서 트랜잭션을 분리하는 것이 좋습니다. 이는 transaction.hint_commit_soon()을 호출하여 할 수 있습니다. (이 문제는 언젠가 수정될 수도 있습니다.)

일반 락과 원자적 블록 간의 상호 작용에도 문제가 있습니다. 이 문제는 표준 출력으로 print하는 경우를 포함하여 (락을 가진) 파일에 쓸 때 확인할 수 있습니다. 한 스레드가 원자적 블록 안에서 실행되는 동안 락을 획득하려 하고, 그 시점에 다른 스레드가 같은 락을 이미 획득한 상태라면, 앞의 스레드는 thread.error를 내며 실패할 수 있습니다. (이 동작에 의존하지 마십시오. 교착 상태에 빠질 수도 있습니다.) 그 이유는 원자적 블록 안에서 실행되는 동안 어떤 조건이 참이 되기를 “기다리는” 것이 사실상 말이 되지 않기 때문입니다. 당분간은 예를 들어 모든 print가 atomic 블록 안에 있거나 아니면 전부 그 밖에 있도록 하여 이 문제를 우회할 수 있습니다. (이러한 종류의 문제는 이론적으로 해결하기 어려우며, 결국 원자적 블록 지원이 제거되는 이유가 될 수도 있습니다.)

락

아직 구현되지 않음

thread 모듈의 락은 기본 의미론이 바뀌지 않았습니다. 그러나 락을 사용하면(예: with my_lock: 블록에서) Software lock elision이라는 대체 실행 모드가 시작됩니다. 이는 PyPy가 락이 해제되는 지점까지 트랜잭션이 이어지도록 하려고 시도하며, 이에 성공하면 락의 획득과 해제가 “생략(elided)”된다는 뜻입니다. 따라서 이 경우 전체 트랜잭션은 엄밀히 말해 락 객체에 어떠한 쓰기도 일으키지 않습니다. 락은 트랜잭션 전에도 획득되지 않은 상태였고, 트랜잭션 후에도 여전히 획득되지 않은 상태입니다.

이는 두 스레드가 같은 락으로 with my_lock: 블록을 실행할 때 특히 유용합니다. 각 스레드가 블록 전체를 담을 만큼 충분히 긴 트랜잭션을 실행한다면, 락에 대한 모든 쓰기가 생략되고 두 트랜잭션은 서로 충돌하지 않습니다. 여느 때와 마찬가지로 두 트랜잭션은 어떤 순서로 직렬화됩니다. 즉, 둘 중 하나가 다른 하나보다 먼저 실행된 것처럼 보입니다. 간단히 말해, 각 스레드는 같은 트랜잭션 안에서 “획득(acquire)”을 실행한 다음 “해제(release)”를 실행합니다. 앞에서 설명했듯이 락 상태는 “미획득(unacquired)”에서 “미획득(unacquired)”으로 바뀌므로 변경하지 않은 채로 둘 수 있습니다.

이 접근 방식은 우아하게 실패할 수 있습니다. 원자적 구역(atomic section)과 달리 트랜잭션이 블록 끝까지 실행된다는 보장이 없습니다. 락을 보유한 상태에서 입출력을 수행하면 트랜잭션은 입출력 연산 직전에 평소처럼 종료됩니다. 이런 일이 발생하면 락 생략(lock elision) 모드가 취소되고 락의 “획득됨” 상태가 실제로 기록됩니다.

락이 실제로 이미 획득된 상태이더라도, 트랜잭션은 락이 다시 해제될 때까지 기다릴 필요가 없습니다. 트랜잭션은 그와 상관없이 생략 모드(elision-mode)에 진입하여 블록의 내용을 잠정적으로 실행할 수 있습니다. 스레드가 멈추는 것은 마지막에 커밋을 시도할 때뿐입니다. 락에 저장된 실제 값이 “획득되지 않음(unacquired)”으로 다시 바뀌는 즉시, 스레드는 계속 진행하여 이미 실행한 트랜잭션의 커밋을 시도할 수 있습니다(이 커밋은 평소와 마찬가지로 실패하여 중단되고 처음부터 다시 시작될 수 있습니다).

이 모든 것은 아직 구현되지 않았지만, 여러 개의 락을 획득하고 해제하는 경우에도 동작할 것으로 예상합니다. 생략 모드(elision-mode) 트랜잭션은 처음 획득한 락이 해제될 때까지, 또는 코드가 입출력이나 대기 연산(예를 들어 현재 사용 중인 다른 락을 기다리는 경우)을 수행할 때까지 이어집니다. 여러 락을 중첩된 순서로 획득하는 일반적인 경우에는 모든 락이 같은 트랜잭션에 의해 생략됩니다.

기타 함수

  • 먼저, transaction 모듈은 lib_pypy/transaction.py 파일에 있다는 점에 유의하십시오. 이 파일을 복사해 가면 같은 프로그램을 CPython이나 STM이 아닌 PyPy에서도 대체(fall-back) 동작으로 실행할 수 있습니다. (동작이 달라지는 한 가지 경우는 atomic입니다. 이 대체 동작에서 atomic은 일반 락(lock)에 불과합니다. 따라서 with atomic은 다른 스레드가 다른 with atomic 구간에 들어가는 것만 막을 뿐, 다른 스레드가 atomic이 아닌 코드를 실행하는 것은 막지 않습니다.)
  • transaction.getsegmentlimit(): 이 pypy-stm에 있는 “세그먼트”의 개수를 반환합니다. 이 값을 넘어서면 스레드를 더 늘려도 더 많은 코어에서 실행할 수 없습니다. (현재는 세그먼트 간 오버헤드 때문에 4로 제한되어 있지만, 앞으로 늘려야 합니다. 또한 이 값을 설정할 수 있어야 하며, 기본값은 실제 CPU 개수에 따라 정해져야 합니다.) STM을 사용할 수 없으면 1을 반환합니다.
  • __pypy__.thread.signals_enabled: 시그널이 활성화된 상태에서 코드 블록을 실행하는 컨텍스트 매니저입니다. 기본적으로 시그널은 메인 스레드에서만 활성화되며, 메인 스레드가 아닌 스레드는 시그널을 받지 않습니다(CPython과 같습니다). 메인 스레드가 아닌 스레드에서 시그널을 활성화하는 기능은 스레드가 감춰져 있어서 최종 사용자가 자신의 코드가 메인 스레드 외의 곳에서 실행되리라고 예상하지 않는 라이브러리에 유용합니다.
  • pypystm.exclusive_atomic: transaction.atomic과 비슷하지만 중첩되면 오류를 내는 컨텍스트 매니저입니다.
  • transaction.is_atomic(): 원자적(atomic) 컨텍스트에서 호출된 경우 True를 반환합니다.
  • pypystm.count(): 호출될 때마다 서로 다른 양의 정수를 반환합니다. 이 함수는 충돌을 일으키지 않고 동작합니다. 반환되는 정수는 대략적으로만 증가하는 순서를 따르며, 이 순서에 의존해서는 안 됩니다.

충돌에 관한 자세한 내용

소프트웨어 트랜잭셔널 메모리(Software Transactional Memory)에 기반한 pypy-stm 방식은 “충돌”이 일어나기 쉽습니다. 기본 아이디어를 다시 설명하면, 스레드는 자신의 코드를 투기적으로 실행하고, 정해진 지점(예: 바이트코드 사이)에서 서로 조율하여 각자의 동작을 어떤 순서로 “커밋”할지, 즉 전역적으로 보이게 할지를 합의합니다. 두 커밋 지점 사이의 각 시간 구간을 트랜잭션이라고 부릅니다.

일관된 순서가 존재하지 않을 때 충돌이 발생합니다. 대표적인 예는 두 스레드가 모두 같은 전역 변수의 값을 변경하려고 하는 경우입니다. 이 경우 둘 중 하나만 계속 진행하도록 허용할 수 있으며, 다른 하나는 일시 중지되거나 중단(트랜잭션 재시작)되어야 합니다. 이런 일이 너무 자주 발생하면 병렬화는 실패합니다.

멀티스레드 프로그램이 실제로 얼마나 병렬화될 수 있는지는 다소 미묘한 문제입니다. 기본적으로 transaction.atomic을 사용하지도 않고 락을 생략하지도 않는 프로그램, 또는 그렇게 하더라도 아주 짧은 시간 동안만 하는 프로그램은 거의 자유롭게 병렬화됩니다(예를 들어 모든 스레드가 같은 전역 카운터를 증가시키려고만 하고 다른 일은 전혀 하지 않는 식의 인위적인 예제가 아닌 한 그렇습니다).

그러나 프로그램에 더 긴 트랜잭션이 필요한 경우에는 덜 명확한 규칙이 적용됩니다. 또한 정확한 세부 사항은 좀 더 안정화되기 전까지 버전마다 달라질 수 있습니다. 다음은 그 개요입니다.

병렬화는 두 가지 원칙이 지켜지는 한 제대로 동작합니다. 첫 번째 원칙은 트랜잭션끼리 서로 충돌해서는 안 된다는 것입니다. 가장 명백한 충돌 원인은 여러 스레드가 모두 하나의 전역 공유 카운터를 증가시키거나 모두 자신의 계산 결과를 같은 리스트에 저장하는 경우입니다 — 좀 더 미묘하게는 모든 스레드가 같은 리스트에서 처리할 작업을 pop()하는 경우도 있는데, 이 역시 리스트를 변경하는 연산이기 때문입니다. (transaction.stmdict를 사용하여 이 문제를 우회할 수 있지만, 이 특정한 예에 대해서는 궁극적으로 STM을 고려한 큐가 설계되어야 할 것입니다.)

충돌은 다음과 같이 발생합니다. 한 트랜잭션이 커밋되면(즉, 성공적으로 완료되면) 아직 진행 중인 다른 트랜잭션이 중단되고 재시도될 수 있습니다. 이는 CPU 시간의 낭비이지만, 최악의 경우에도 GIL보다 나쁘지는 않습니다. 적어도 하나의 트랜잭션은 성공하기 때문입니다(따라서 최악의 경우라도 N-1개의 CPU가 쓸모없는 작업을 하고 1개의 CPU가 성공적으로 커밋되는 작업을 수행합니다).

물론 충돌은 실제로 발생하며, 이를 모두 피하려고 하는 것은 무의미합니다. 예를 들어 어떤 워밍업 단계 동안에는 충돌이 매우 많을 수 있습니다. 중요한 것은 전체적으로 충돌을 충분히 드물게 유지하는 것입니다.

또 다른 문제는 오래 실행되는 이른바 “불가피한(inevitable)” 트랜잭션을 피하는 것입니다(여기서 “불가피한”은 “피할 수 없는”이라는 의미로, 더 이상 중단(abort)될 수 없는 트랜잭션을 뜻합니다). 그러한 트랜잭션은 atomic을 사용하는 경우에만 발생해야 하며, 대개 원자적 블록 안의 I/O 때문에 발생합니다. 이러한 트랜잭션도 동작은 하지만, I/O가 수행되기 전에 트랜잭션이 불가피한 상태로 전환됩니다. 원자적 블록의 남은 실행 시간 내내 이러한 트랜잭션은 병렬 작업을 방해합니다. 가장 좋은 방법은 그러한 연산이 완전히 atomic 바깥에서 수행되도록 코드를 구성하는 것입니다.

(이는 Twisted에서는 블로킹 I/O 연산을 권장하지 않으며, 정말로 필요하다면 별도의 스레드에서 수행해야 한다는 사실과 무관하지 않습니다.)

lock elision이 결국 atomic 섹션을 대체하게 되면 오래 실행되는 불가피한(inevitable) 트랜잭션은 생기지 않겠지만, 같은 문제가 다른 방식으로 발생합니다. I/O를 수행하면 lock elision이 취소되고 락이 실제 락으로 바뀝니다. 그러면 이 락이 필요한 다른 스레드는 커밋할 수 없게 됩니다. (자세한 내용은 lock elision을 구현하고 테스트한 뒤에 다루겠습니다.)

구현

XXX 이 섹션은 현재 대부분 비어 있습니다

기술 보고서

STMGC-C7에 대한 자세한 설명은 기술 보고서에 있습니다.

별도의 position paper에서 STM 전반에 대한 저희의 입장을 개괄적으로 설명합니다.

구현 세부 사항 참조

구현의 핵심은 stmgc라는 별도의 C 라이브러리에 있으며, c7 하위 디렉터리(pypy-stm의 현재 버전)와 c8 하위 디렉터리(최신 개발 버전)에 들어 있습니다. 자세한 내용은 README.txt를 참고하십시오. 특히 세그먼트(segment) 개념은 그 문서에서 설명합니다.

PyPy 자체는 그 위에 read 및 write 배리어와 “becomes-inevitable-now” 배리어의 자동 배치, RPython 변환(transformation)으로 구현한 트랜잭션 시작/중지 로직과 이를 지원하는 C 코드, 그리고 JIT에서의 지원(주로 트레이스에 대한 변환(transformation) 단계와 assembler.py에서의 사용자 정의 어셈블러 코드 생성)을 추가합니다.