Python

스레드 안전성 보장

이 페이지는 파이썬의 free-threaded 빌드에서 기본 내장 타입에 대한 스레드 안전성 보장을 기술합니다. 여기에 기술된 보장 사항은 GIL 이 비활성화된 경우(free-threaded 모드) 파이썬을 사용할 때 적용됩니다. GIL이 활성화된 경우, 대부분의 연산은 암시적으로 직렬화됩니다.

free-threaded 파이썬에서 스레드 안전한 코드를 작성하는 방법에 대한 일반적인 지침은 무료 스레딩(free threading)에 대한 Python 지원 를 참조하십시오.

스레드 안전 수준

C API 문서에서는 각 함수의 스레드 안전성 보장을 설명하기 위해 다음 수준을 사용합니다. 수준은 가장 안전하지 않은 것부터 가장 안전한 것 순으로 나열됩니다.

호환되지 않음

외부 동기화가 있어도 동시 사용에 안전하게 만들 수 없는 함수 또는 연산입니다. 호환되지 않는 코드는 일반적으로 전역 상태에 동기화되지 않은 방식으로 접근하며, 프로그램의 생명 주기 동안 단일 스레드에서만 호출되어야 합니다.

예: 신호 처리기나 환경 변수와 같이 프로세스 전역 상태를 수정하는 함수로, 외부 락이 있더라도 어떤 스레드에서든 동시에 호출하면 런타임 또는 다른 라이브러리와 충돌할 수 있는 경우.

호환됨

호출자가 적절한 외부 동기화(예: 호출 기간 동안 lock 보유)를 제공하는 조건하에 여러 스레드에서 호출해도 안전한 함수 또는 연산입니다. 이러한 동기화가 없으면 동시 호출이 race conditions 또는 data races 를 발생시킬 수 있습니다.

예: 내부 상태가 락으로 보호되지 않는 객체에서 읽거나 쓰는 함수. 호출자는 두 개의 스레드가 동시에 동일한 객체에 접근하지 않도록 보장해야 합니다.

서로 다른 객체에 대해 안전함

각 스레드가 다른 객체에서 작동하는 한, 외부 동기화 없이 여러 스레드에서 호출해도 안전한 함수 또는 연산입니다. 두 스레드가 동시에 함수를 호출할 수 있지만, 동일한 객체(또는 하부 상태를 공유하는 객체)를 인자로 전달해서는 안 됩니다.

예: 원자적이지 않은 쓰기를 사용하여 구조체의 필드를 수정하는 함수. 두 스레드가 각각 자신의 구조체 인스턴스에 대해 해당 함수를 호출하는 것은 안전하지만, 동일한 인스턴스에 대해 동시 호출하는 것은 외부 동기화가 필요합니다.

공유 객체에 대해 안전함

동일한 객체에서 동시 사용에 안전한 함수 또는 연산입니다. 구현체에서 공유 가능한 가변 상태를 보호하기 위해 내부 동기화(예: per-object locks 또는 critical sections)를 사용하므로 호출자가 자체적인 락을 제공할 필요가 없습니다.

예: PyList_GetItemRef() 은 동일한 PyListObject 에 대해 여러 스레드에서 호출될 수 있습니다. 이 함수는 액세스를 직렬화하기 위해 내부 동기화를 사용합니다.

원자적

다른 스레드와 관련하여 atomic 으로 보이는 함수 또는 연산입니다. 즉, 다른 스레드의 관점에서 볼 때 즉각적으로 실행됩니다. 이는 가장 강력한 형태의 스레드 안전성입니다.

예: PyMutex_IsLocked() 은 뮤텍스 상태를 원자적으로 읽어 들이며, 언제든지 어떤 스레드에서도 호출될 수 있습니다.

리스트 객체의 스레드 안전성

list 에서 단일 요소를 읽는 것은 atomic 입니다.

lst[i]   # list.__getitem__

다음 메서드들은 리스트를 순회하며 각 항목의 atomic 읽기를 사용하여 기능을 수행합니다. 이는 동시 수정에 영향을 받은 결과를 반환할 수 있음을 의미합니다:

item in lst
lst.index(item)
lst.count(item)

위의 모든 연산은 per-object locks 을 획득하지 않습니다. 따라서 동시 수정을 차단하지 않습니다. 락을 보유하는 다른 연산도 이러한 연산이 중간 상태를 관찰하는 것을 차단하지 않습니다.

이후의 모든 다른 연산은 per-object lock 을 사용하여 차단됩니다.

lst[i] = x 를 통한 단일 항목 쓰기는 여러 스레드에서 호출해도 안전하며 리스트를 손상시키지 않습니다.

다음 연산들은 새로운 객체를 반환하며 다른 스레드에 대해 atomic 으로 보입니다:

lst1 + lst2    # 두 리스트를 결합하여 새 리스트 생성
x * lst        # lst를 x번 반복하여 새 리스트 생성
lst.copy()     # 리스트의 얕은 복사본 반환

이동이 필요 없이 단일 요소에 대해서만 작동하는 다음 메서드들은 atomic 입니다.

lst.append(x)  # 리스트 끝에 추가, 이동 필요 없음
lst.pop()      # 리스트 끝에서 요소 팝, 이동 필요 없음

clear() 메서드도 atomic 입니다. 다른 스레드는 요소가 제거되는 것을 관찰할 수 없습니다.

sort() 메서드는 atomic 이 아닙니다. 다른 스레드는 정렬 중인 중간 상태를 관찰할 수 없지만, 정렬이 수행되는 동안 리스트는 비어 있는 것처럼 보입니다.

다음 연산들은 여러 요소를 제자리에서 수정하기 때문에 lock-free 연산이 중간 상태를 관찰하도록 허용할 수 있습니다:

lst.insert(idx, item)  # 요소 이동
lst.pop(idx)           # 인덱스가 리스트 끝이 아님, 요소 이동
lst *= x               # 제자리에서 요소 복사

The remove() method may allow concurrent modifications since element comparison may execute arbitrary Python code (via __eq__()).

extend() 은 여러 스레드에서 호출해도 안전합니다. 하지만 그 보장 수준은 전달되는 반복 가능(iterable) 객체에 따라 달라집니다. 해당 객체가 list, tuple, set, frozenset, dict 또는 dictionary view object 인 경우(하위 클래스 제외), extend 연산은 반복 가능 객체의 동시 수정으로부터 안전합니다. 그렇지 않은 경우, 다른 스레드에 의해 동시 수정될 수 있는 반복자(iterator)가 생성됩니다. 이는 lst += iterable 을 사용하여 리스트를 다른 반복 가능 객체와 제자리에서 결합할 때도 동일하게 적용됩니다.

마찬가지로, lst[i:j] = iterable 을 사용하여 리스트 슬라이스에 할당하는 것은 여러 스레드에서 호출해도 안전하지만, iterablelist 인 경우에만(하위 클래스 제외) 잠금이 적용됩니다.

반복(iteration)뿐만 아니라 여러 번의 접근을 포함하는 연산은 절대로 원자적이지 않습니다. 예를 들어:

# 원자적이지 않음: 읽기-수정-쓰기
lst[i] = lst[i] + 1

# 원자적이지 않음: 확인 후 동작
if lst:
    item = lst.pop()

# 스레드 안전하지 않음: 수정 중 반복
for item in lst:
    process(item)  # 다른 스레드에서 lst를 수정할 수 있음

스레드 간에 list 인스턴스를 공유할 때는 외부 동기화를 고려하십시오.

dict 객체의 스레드 안전성

Creating a dictionary with the dict constructor is atomic when the argument to it is a dict or a tuple. When using the dict.fromkeys() method, dictionary creation is atomic when the argument is a dict, tuple, set or frozenset.

다음 연산과 함수들은 lock-free 이며 atomic 입니다.

d[key]       # dict.__getitem__
d.get(key)   # dict.get
key in d     # dict.__contains__
len(d)       # dict.__len__

이후의 모든 다른 연산은 per-object lock 을 유지합니다.

단일 항목을 쓰거나 제거하는 것은 여러 스레드에서 호출해도 안전하며 딕셔너리를 손상시키지 않습니다:

d[key] = value        # 쓰기
del d[key]            # 삭제
d.pop(key)            # 제거 및 반환
d.popitem()           # 마지막 항목 제거 및 반환
d.setdefault(key, v)  # 누락 시 삽입

이 연산들은 __eq__() 를 사용하여 키를 비교할 수 있으며, 이 과정에서 임의의 파이썬 코드가 실행될 수 있습니다. 이러한 비교가 수행되는 동안 딕셔너리가 다른 스레드에 의해 수정될 수 있습니다. C에서 __eq__() 를 구현하는 str, int, float 와 같은 내장 유형의 경우, 비교 중에 기본 락이 해제되지 않으므로 이 문제는 발생하지 않습니다.

다음 연산들은 새로운 객체를 반환하며 연산이 수행되는 동안 per-object lock 을 유지합니다:

d.copy()      # 딕셔너리의 얕은 복사본 반환
d | other     # 두 딕셔너리를 새로운 딕셔너리로 병합
d.keys()      # 새로운 dict_keys 뷰 객체 반환
d.values()    # 새로운 dict_values 뷰 객체 반환
d.items()     # 새로운 dict_items 뷰 객체 반환

clear() 메서드는 실행되는 동안 록을 유지합니다. 다른 스레드는 요소가 제거되는 것을 관찰할 수 없습니다.

다음 연산은 두 딕셔너리 모두에 록을 겁니다. update()|= 의 경우, 다른 피연산자가 표준 딕셔너리 반복자를 사용하는 dict 인 경우에만 적용됩니다(반복을 재정의하는 서브클래스는 제외). 동일성 비교의 경우, 이는 dict 및 그 서브클래스에 적용됩니다.

d.update(other_dict)  # other_dict가 dict인 경우 둘 다 록이 걸림
d |= other_dict       # other_dict가 dict인 경우 둘 다 록이 걸림
d == other_dict       # dict 및 서브클래스의 경우 둘 다 록이 걸림

모든 비교 연산은 또한 __eq__() 를 사용하여 값을 비교하므로, 내장되지 않은 타입의 경우 비교 중에 록이 해제될 수 있습니다.

fromkeys() 는 이터러블이 정확히 dict, set, 또는 frozenset 인 경우(서브클래스 제외) 새 딕셔너리와 이터러블 모두에 록을 겁니다.

dict.fromkeys(a_dict)      # 둘 다 록이 걸림
dict.fromkeys(a_set)       # 둘 다 록이 걸림
dict.fromkeys(a_frozenset) # 둘 다 록이 걸림

딕셔너리가 아닌 이터러블에서 업데이트할 때는 대상 딕셔너리만 록이 걸립니다. 이터러블은 다른 스레드에 의해 동시에 수정될 수 있습니다.

d.update(iterable)        # iterable이 dict가 아님: d만 록이 걸림
d |= iterable             # iterable이 dict가 아님: d만 록이 걸림
dict.fromkeys(iterable)   # iterable이 dict/set/frozenset이 아님: 결과만 록이 걸림

여러 번의 접근을 포함하는 연산이나 반복은 절대 원자적이지 않습니다.

# 원자적이지 않음: 읽기-수정-쓰기
d[key] = d[key] + 1

# 원자적이지 않음: 확인 후 동작(TOCTOU)
if key in d:
    del d[key]

# 스레드 안전하지 않음: 수정 중 반복
for key, value in d.items():
    process(key)  # 다른 스레드가 d를 수정할 수 있음

확인 시점과 사용 시점 간의 문제(TOCTOU)를 방지하려면 원자적 연산을 사용하거나 예외를 처리하십시오.

# 확인 후 삭제 대신 기본값과 함께 pop() 사용
d.pop(key, None)

# 또는 예외 처리
try:
    del d[key]
except KeyError:
    pass

다른 스레드에 의해 수정될 수 있는 딕셔너리를 안전하게 반복하려면 복사본을 반복하십시오.

# 안전하게 반복하기 위해 복사본 생성
for key, value in d.copy().items():
    process(key)

여러 스레드 간에 dict 인스턴스를 공유할 때는 외부 동기화를 고려하십시오.

set 객체의 스레드 안전성

The len() function is lock-free and atomic.

다음 읽기 연산은 록이 걸리지 않습니다. 이 연산은 동시 수정을 차단하지 않으며, 객체별 록을 보유하는 연산으로부터의 중간 상태를 관찰할 수 있습니다.

elem in s    # set.__contains__

이 연산은 임의의 파이썬 코드를 실행할 수 있는 __eq__() 를 사용하여 요소를 비교할 수 있으며, 이 과정에서 세트가 다른 스레드에 의해 수정될 수 있습니다. str, int, float 와 같은 내장 타입의 경우, __eq__() 는 비교 중에 기본 록을 해제하지 않으므로 문제가 발생하지 않습니다.

이후의 모든 다른 연산은 객체별 록을 유지합니다.

단일 요소를 추가하거나 제거하는 것은 여러 스레드에서 호출해도 안전하며 세트를 손상시키지 않습니다.

s.add(elem)      # 요소 추가
s.remove(elem)   # 요소 제거, 없으면 예외 발생
s.discard(elem)  # 존재하면 요소 제거
s.pop()          # 임의의 요소를 제거하고 반환

이 연산들도 요소를 비교하므로, 위에서 언급한 동일한 __eq__() 고려 사항이 적용됩니다.

copy() 메서드는 새 객체를 반환하며, 항상 원자적이기 위해 실행 기간 동안 객체별 록을 유지합니다.

clear() 메서드는 실행 기간 동안 록을 유지합니다. 다른 스레드는 요소가 제거되는 것을 관찰할 수 없습니다.

다음 연산은 피연산자로 set 또는 frozenset 만 수락하며 항상 두 객체 모두에 록을 겁니다.

s |= other                   # other는 set/frozenset이어야 함
s &= other                   # other는 set/frozenset이어야 함
s -= other                   # other는 set/frozenset이어야 함
s ^= other                   # other는 set/frozenset이어야 함
s & other                    # other는 set/frozenset이어야 함
s | other                    # other는 set/frozenset이어야 함
s - other                    # other는 set/frozenset이어야 함
s ^ other                    # other는 set/frozenset이어야 함

set.update(), set.union(), set.intersection() and set.difference() can take multiple iterables as arguments. They all iterate through all the passed iterables and do the following:

set.symmetric_difference() 는 두 객체 모두에 록을 시도합니다.

위 메서드들의 update 변형 버전들 사이에도 몇 가지 차이점이 있습니다.

다음 메서드들은 항상 두 객체 모두에 록을 시도합니다.

s.isdisjoint(other)          # 둘 다 록이 걸림
s.issubset(other)            # 둘 다 록이 걸림
s.issuperset(other)          # 둘 다 록이 걸림

여러 번의 접근을 포함하는 연산이나 반복은 절대 원자적이지 않습니다.

# 원자적이지 않음: 확인 후 동작
if elem in s:
      s.remove(elem)

# 스레드 안전하지 않음: 수정 중 반복
for elem in s:
      process(elem)  # 다른 스레드가 s를 수정할 수 있음

여러 스레드 간에 set 인스턴스를 공유할 때는 외부 동기화를 고려하십시오. 자세한 내용은 무료 스레딩(free threading)에 대한 Python 지원 를 참조하십시오.

bytearray 객체의 스레드 안전성

The len() function is lock-free and atomic.

연결 및 비교는 버퍼 프로토콜을 사용하며, 이는 크기 조절은 방지하지만 객체별 록을 유지하지 않습니다. 이러한 연산은 동시 수정으로 인한 중간 상태를 관찰할 수 있습니다.

ba + other    # 동시 쓰기를 관찰할 수 있음
ba == other   # 동시 쓰기를 관찰할 수 있음
ba < other    # 동시 쓰기를 관찰할 수 있음

이후의 모든 다른 연산은 객체별 록을 유지합니다.

단일 요소 또는 슬라이스를 읽는 것은 여러 스레드에서 호출해도 안전합니다.

ba[i]        # bytearray.__getitem__
ba[i:j]      # slice

다음 연산은 여러 스레드에서 호출해도 안전하며 bytearray를 손상시키지 않습니다.

ba[i] = x         # 단일 바이트 쓰기
ba[i:j] = values  # 슬라이스 쓰기
ba.append(x)      # 단일 바이트 추가
ba.extend(other)  # 이터러블로 확장
ba.insert(i, x)   # 단일 바이트 삽입
ba.pop()          # 마지막 바이트 제거 및 반환
ba.pop(i)         # 인덱스의 바이트 제거 및 반환
ba.remove(x)      # 첫 번째 발생 사례 제거
ba.reverse()      # 제자리에서 뒤집기
ba.clear()        # 모든 바이트 제거

valuesbytearray 인 경우 슬라이스 대입 시 두 객체 모두에 록이 걸립니다.

ba[i:j] = other_bytearray  # 둘 다 록이 걸림

다음 연산은 새 객체를 반환하며 실행 기간 동안 객체별 록을 유지합니다.

ba.copy()     # 얕은 복사 반환
ba * n        # 새 bytearray로 반복

소속 여부 테스트는 실행 기간 동안 록을 유지합니다.

x in ba       # bytearray.__contains__

다른 모든 bytearray 메서드(예: find(), replace(), split(), decode() 등)는 실행 기간 동안 객체별 록을 유지합니다.

여러 번의 접근을 포함하는 연산이나 반복은 절대 원자적이지 않습니다.

# 원자적이지 않음: 확인 후 동작
if x in ba:
    ba.remove(x)

# 스레드 안전하지 않음: 수정 중 반복
for byte in_ba:
    process(byte)  # 다른 스레드가 ba를 수정할 수 있음

다른 스레드에 의해 수정될 수 있는 bytearray를 안전하게 반복하려면 복사본을 반복하십시오.

# 안전하게 반복하기 위해 복사본 생성
for byte in ba.copy():
    process(byte)

여러 스레드 간에 bytearray 인스턴스를 공유할 때는 외부 동기화를 고려하십시오. 자세한 내용은 무료 스레딩(free threading)에 대한 Python 지원 를 참조하십시오.

memoryview 객체의 스레드 안전성

memoryview 객체는 복사 없이 하부 객체의 내부 데이터에 접근할 수 있게 합니다. 스레드 안전성은 memoryview 자체와 하부 버퍼 내보내기(exporter) 모두에 달려 있습니다.

memoryview 구현은 free-threaded build 에서 자체 내보내기를 추적하기 위해 원자적 연산을 사용합니다. memoryview를 생성하고 해제하는 것은 스레드 안전합니다. 속성 접근(예: shape, format)은 memoryview의 수명 동안 변경되지 않는 필드를 읽으므로, memoryview가 해제되지 않는 한 동시 읽기가 안전합니다.

하지만 memoryview를 통해 접근하는 실제 데이터는 기본 객체(underlying object)가 소유합니다. 이 데이터에 대한 동시 접근은 기본 객체가 이를 지원하는 경우에만 안전합니다:

  • bytes 와 같은 불변(immutable) 객체의 경우, 여러 memoryview를 통한 동시 읽기는 안전합니다.

  • bytearray 와 같은 가변(mutable) 객체의 경우, 외부 동기화 없이 여러 스레드에서 동일한 메모리 영역을 읽고 쓰는 것은 안전하지 않으며 데이터 손상을 초래할 수 있습니다. 가변 객체의 읽기 전용 memoryview라 하더라도 다른 스레드에서 기본 객체가 수정되면 데이터 레이스가 발생할 수 있음에 유의하십시오.

# 안전하지 않음: 동일한 버퍼에 대한 동시 쓰기
data = bytearray(1000)
view = memoryview(data)
# Thread 1: view[0:500] = b'x' * 500
# Thread 2: view[0:500] = b'y' * 500
# 안전함: 동시 접근을 위해 잠금(lock) 사용
import threading
lock = threading.Lock()
data = bytearray(1000)
view = memoryview(data)

with lock:
    view[0:500] = b'x' * 500

memoryview가 노출된 동안 기본 객체의 크기를 조정하거나 재할당(예: bytearray.resize() 호출)하면 BufferError 가 발생합니다. 이는 스레드 여부와 관계없이 적용됩니다.