Python

스레드 안전성 보장

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

프리 스레드 파이썬에서 스레드 안전한 코드를 작성하는 방법에 대한 일반적인 지침은 무료 스레딩(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()     # 리스트의 얕은 복사본을 반환

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

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

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

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

다음 작업들은 여러 요소를 제자리에서 수정하기 때문에, lock-free 작업이 중간 상태를 관찰할 수 있게 할 수 있습니다.

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

remove() 메서드는 요소 비교 시 임의의 파이썬 코드가 실행될 수 있으므로( __eq__() 를 통해) 병행 수정이 가능할 수도 있습니다.

extend() is safe to call from multiple threads. However, its guarantees depend on the iterable passed to it. If it is a list, a tuple, a set, a frozenset, a dict or a dictionary view object (but not their subclasses), the extend operation is safe from concurrent modifications to the iterable. Otherwise, an iterator is created which can be concurrently modified by another thread. The same applies to inplace concatenation of a list with other iterables when using lst += iterable.

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

반복(iteration)을 포함하여 여러 번의 액세스가 포함되는 작업은 결코 원자적이지 않습니다. 예를 들어:

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

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

# 스레드 안전하지 않음: 수정 중 이터레이션
for item in lst:
    process(item)  # 다른 스레드가 lst를 수정할 수 있음

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

dict 객체의 스레드 안전성

인자로 dict 또는 tuple 이 전달될 때 dict 생성자를 사용하여 딕셔너리를 만드는 것은 원자적입니다. dict.fromkeys() 메서드를 사용할 경우, 인자가 dict, tuple, set 또는 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가 딕셔너리인 경우 둘 다 록이 걸림
d |= other_dict       # other_dict가 딕셔너리인 경우 둘 다 록이 걸림
d == other_dict       # 딕셔너리 및 하위 클래스에 대해 둘 다 록이 걸림

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

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

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

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

d.update(iterable)        # 이터러블이 딕셔너리가 아님: d만 록이 걸림
d |= iterable             # 이터러블이 딕셔너리가 아님: d만 록이 걸림
dict.fromkeys(iterable)   # 이터러블이 dict/set/frozenset이 아님: 결과값만 록이 걸림

반복(iteration)과 더불어 여러 번의 액세스가 포함되는 연산은 결코 원자적이지 않습니다:

# 원자적이지 않음: 읽기-수정-쓰기
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 객체의 스레드 안전성

len() 함수는 lock-free이며, atomic 입니다.

다음 읽기 연산은 lock-free입니다. 이 연산은 동시 수정을 차단하지 않으며, per-object lock을 유지하는 연산의 중간 상태를 관찰할 수 있습니다:

elem in s    # set.__contains__

This operation may compare elements using __eq__(), which can execute arbitrary Python code. During such comparisons, the set may be modified by another thread. For built-in types like str, int, and float, __eq__() does not release the underlying lock during comparisons and this is not a concern.

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

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

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

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

copy() 메서드는 새로운 객체를 반환하며 항상 원자적으로 수행되도록 연산 기간 동안 per-object lock을 유지합니다.

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()set.difference() 는 여러 개의 이터러블을 인자로 받을 수 있습니다. 이들은 전달된 모든 이터러블을 순회하며 다음을 수행합니다:

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

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

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

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

반복(iteration)과 더불어 여러 번의 액세스가 포함되는 연산은 결코 원자적이지 않습니다:

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

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

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

bytearray 객체의 스레드 안전성

len() 함수는 lock-free이며, atomic 입니다.

결합 및 비교는 크기 조절을 방지하는 버퍼 프로토콜을 사용하지만 per-object lock을 유지하지 않습니다. 이러한 연산은 동시 수정에 따른 중간 상태를 관찰할 수 있습니다:

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

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

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

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  # 둘 다 록이 걸림

다음 연산은 새로운 객체를 반환하며 해당 기간 동안 per-object lock을 유지합니다:

ba.copy()     # 얕은 복제본 반환
ba * n        # 새 bytearray로 반복하여 확장

멤버십 테스트는 실행되는 동안 록을 유지합니다:

x in ba       # bytearray.__contains__

다른 모든 bytearray 메서드(예: find(), replace(), split(), decode() 등)는 실행되는 동안 per-object lock을 유지합니다.

반복(iteration)과 더불어 여러 번의 액세스가 포함되는 연산은 결코 원자적이지 않습니다:

# 원자적이지 않음: 확인 후 실행
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를 통해 접근하는 실제 데이터는 하부 객체가 소유합니다. 이 데이터에 대한 동시 접근은 오직 하부 객체가 이를 지원할 때만 안전합니다.

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

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

# 안전하지 않음: 동일한 버퍼에 대한 동시 쓰기
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 가 발생합니다. 이는 스레딩 여부와 관계없이 적용됩니다.