Thread-Safety in Python

얼마 전에 한 파이썬 커뮤니티에서 이런 질문이 올라왔습니다.

Python에서 사전에 하는 입출력 연산이 원자적 연산인가요?
동기화된 사전 쓰레드가 하나 필요한데 입출력 연산에도 락을 걸어야 하나 해서요

문맥상으로 볼 때, 입력 연산은 딕셔너리에서 Key로 Value를 조회하는 것을 뜻하고, 출력이란 Key-Value Pair 를 등록하는 것을 뜻하는 것으로 보입니다. 결국 다음과 같은 문장들의 thread-safety 에 관해 묻고 있는 것입니다.

d[key]
d[key] = value

단지 결론만 말하자면, 엄밀한 의미에서 두 문장 모두 원자적(atomic) 이지 않습니다. 즉 d[key] 가 실행되는 도중 다른 쓰레드로 제어가 넘어갈 가능성이 존재합니다. 딕셔너리에서 키를 사용해서 값을 조회하는 연산은 원자적이지만, 딕셔너리(d)와 키(key)의 값을 읽어오는 과정을 포함하면 원자적이지 않게 됩니다. d[key] = value 라는 문장도 마찬가지 입니다.

하지만 thread-safety 측면에서 본다면 어떨까요? 대부분의 경우 위의 두 문장은 thread-safe 합니다. 대부분의 경우라고 하는 것은 dkey 가 다른 쓰레드에 의해 대입되고 있는 상황이 아닐 경우를 뜻합니다.

가령 thread-safe 한 예로, 이런 경우를 가정해볼 수 있습니다. d 는 전역 변수이거나 전역적인 경로를 통해 접근 가능하지만 초기화 이후 고정되어 있는 상태고, key 는 다른 쓰레드가 접근할 수 없는 공간에 있습니다. 이런 경우는 충분히 일반적이고 자연스러운 상황입니다. 하지만 key 역시 전역 변수고 각 쓰레드가 이런 저런 값을 대입하고 있는 상황이라면 thread-safe 하지 않게 됩니다.

이런 시각에서 본다면 파이썬의 특정 문장을 원자적이냐고 묻는 것은 의미가 없습니다. 거의 모든 경우 대답은 No 가 됩니다. 대신, 주어진 문맥에서 thread-safe 하냐고 묻는 것은 아직 따져 볼만 합니다.

그럼 다른 문장을 한 번 살펴봅시다.

d[key] += value
d[key] = d[key] + value
d[key] = d[key2]

이 세 문장은 thread-safe 하지도 않습니다. 처음 두 문장은 파이썬에 의해 조금 다르게 취급되기는 하지만, 결과는 마찬가지 입니다. 딕셔너리의 조회와 대입 사이에 쓰레드 전환이 일어날 수 있습니다. 마지막 문장도 마찬가지 입니다.

예시한 두 문장 집합의 차이는 꽤 미묘할 수 있고, 그 차이에 따라 동기화 객체 사용여부를 결정하는 것은 오류에 취약한 전략입니다. 때문에 이런 전략을 권해드립니다.

자명한 경우가 아니라면 동기화 객체를 사용해서 안전하게 만드세요.

이 권고로 충분하지 않은 경우에 대비할 목적도 있지만, 단지 수면 아래에서 어떤 일이 일어나고 있는지 궁금하기 때문에 조금만 더 자세히 살펴보기로 합니다.

GIL

파이썬의 thread-safety 는 GIL (Global Interpreter Lock) 이라는 메커니즘에 많은 부분을 의존하고 있습니다. 파이썬의 가장 깊은 곳에서 일어나는 일들 중 상당부분은 성능을 유지하면서 thread-safety 하게 만들기가 쉽지 않습니다. 이 때문에 파이썬 인터프리터는 프로세스당 GIL 이라 불리는 Lock 을 하나 만들고, 이 Lock 을 얻은 쓰레드만 파이썬 코드를 실행할 수 있도록 하는, 우아하지는 않지만 실용적인 전략을 택했습니다. 각 쓰레드는 GIL 을 얻은 후에 일정 시간 동안 파이썬 코드를 실행한 후 Lock 을 반납합니다. 이런 과정을 모든 쓰레드들이 반복하는 것인데, 각 쓰레드에 할당된 시간 역시 전역 변수로 제어됩니다. 이 변수 값은 100으로 미리 설정되어 있지만 sys.setcheckinterval() 함수로 바꿀 수 있습니다. 이 값을 줄일 수록 쓰레드간 전환은 더 자주 일어나서, 반응 성이 좋아지는 반면, 전체적인 성능은 떨어지게 됩니다.

Python Virtual Instructions

각 쓰레드에 주어진 시간의 정확한 의미는

  • GIL 반납 없이 실행할 수 있는 Virtual Instruction 의 최대 개수

입니다.

파이썬은 인터프리터이기는 하지만, 컴파일 단계를 갖고 있습니다. 소스 코드가 중간단계의 바이트코드로 컴파일 되는데, *.pyc*.pyo 에는 이 바이트코드들이 저장되어 있습니다. 이 바이트코드는 파이썬이 정한 가상이 기계가 알아듣고 실행할 수 있는 명령들입니다. 이 각각의 명령들을 Python Virtual Instruction 이라고 합니다.

GIL 메커니즘이 thread-safety 에 대해 보장하는 것은, Python Virtual Intruction 들 사이에서 쓰레드 전환이 일어날 뿐 Python Virtual Instruction 이 수행되는 도중에는 쓰레드 전환이 없다는 것입니다.

이런 일련의 과정은 깊이 감춰져 있는 것이 아니라 내장 함수와 표준 라이브러리를 통해 투명하게 제공됩니다. 파이썬의 내장 함수 compile() 은 코드를 컴파일 해서 바이트코드를 돌려줍니다. 또 dis.dis() 함수는 이 코드를 역어셈블 해서 사람이 읽을 수 있는 수준으로 번역해줍니다.

앞에서 소개한 딕셔너리 구문을 살펴봅시다.

>>> import dis
>>> dis.dis(compile('d[key]', '<string>', 'eval'))
  1           0 LOAD_NAME                0 (d)
              3 LOAD_NAME                1 (key)
              6 BINARY_SUBSCR
              7 RETURN_VALUE

이 예제는 eval('d[key]') 를 실행할 때 수행되는 Virtual Instruction 을 보여주고 있습니다. 전형적인 스택 기반의 가상 기계임을 알 수 있는데, 처음 두 개의 LOAD_NAME 은 두 변수 dkey 의 레퍼런스를 스택에 올리는 명령입니다. 그 다음에 나오는 BINARY_SUBSCRd[key] 를 실행하고 그 결과를 스택에 올리라는 명령입니다. 마지막 RETURN_VALUE 는 스택 위에 있는 값을 return 하라는 뜻이지요. 이 글의 처음 부분에서 d[key] 의 원자성에 대해 논의한 부분이 기억나시나요?

이제 thread-safe 하지도 않았던 문장을 살펴봅시다.

>>> dis.dis(compile('d[key] += value', '<string>', 'exec'))
  1           0 LOAD_NAME                0 (d)
              3 LOAD_NAME                1 (key)
              6 DUP_TOPX                 2
              9 BINARY_SUBSCR
             10 LOAD_NAME                2 (value)
             13 INPLACE_ADD
             14 ROT_THREE
             15 STORE_SUBSCR
             16 LOAD_CONST               0 (None)
             19 RETURN_VALUE

+= 연산자로 인해 DUP_TOPX 라는 명령이 등장합니다. 스택 위에 있는 값들을 복사해서 스택에 추가하는 명령입니다. INPLACE_ADD 는 덧셈을 뜻하고, ROT_THREE 는 위에 나오는 명령의 인자 순서를 맞춰주기 위해 스택의 순서를 바꾸는 명령입니다. STORE_SUBSCR 이 딕셔너리에 값을 대입하는 명령입니다. 마지막 두 명령은 return None 에 해당합니다. exec 모드로 컴파일 했기 때문에 붙는 것이지요.

파이썬 코드의 thread-safety 를 따지려면 Virtual Instruction 수준에서 따져줘야 합니다. 불가능한 작업은 아니지만, 많은 경우 이런 노력을 들일만한 가치는 없습니다. 이제 바닥을 보았으니, 다시 위로 올라가서 애초의 권고안을 따르시도록 재차 권고합니다. 그리고 지금까지의 내용은 여러 파이썬 구현들 중에서 오직 CPython에 만 해당된다는 것을 알려드려야 할 것 같군요. 또 이 글에서 다루지 않은 C 확장 수준까지 내려가면, 앞의 문장들 일부를 재고할 필요가 있습니다. 논의를 단순화하기 위한 선택이니 양해 바랍니다.