GIL (Global Interpreter Lock), Multi-Threading
이전 포스트에서 GIL을 살펴봤는데 GIL 자체가 하나의 스레드가 리소스를 점유하는 것이니 멀티 스레드를 사용할 때 더 이상 상호 배제 락 (mutext)를 사용하지 않아도 되는 것으로 생각할 수 있습니다. GIL이 파이썬에서 멀티 스레딩을 막는다면 당연히 프로그램의 데이터 구조에 동시에 접근할 수 없게끔 (Thread-safe) 구현되있지 않을까 하는 것이죠. 하지만 GIL은 스레드 세이프를 보장해주지 못합니다. 파이썬 스레드는 한 번에 단 하나만 실행되지만 여러 스레드가 같은 데이터 구조에 동시에 접근하는 것은 막지 못하며 안타깝게도 스레드끼리 언제 인터럽트될지 알 수가 없습니다.
예를 들어 병렬적으로 여러 가지의 개수를 세는 프로그램을 작성한다고 합시다. 다음과 같이 카운터 클래스를 정의해서 increment 메소드가 불리울 때마다 숫자를 하나씩 증가시키는 코드를 작성합니다.
class Counter:
def __init__(self):
self.count = 0
def increment(self, offset):
self.count += offset
def worker(index, how_many, counter):
for _ in range(how_many):
counter.increment(1)
그리고 이를 동시적으로 처리하기 위해 "threading" 모듈을 사용하여 5개의 스레드를 사용해 증가시켜 보겠습니다.
from threading import Thread
how_many = 100000
counter = Counter()
threads = []
for i in range(5):
thread = Thread(target=worker, args=(i, how_many, counter))
thread.start()
threads.append(thread)
for thread in threads:
thread.join()
expected = 5 * how_many
found = counter.count
print(f'Expected {expected}, but found {found}')
5개의 스레드에 대해서 100000씩 증가시켰으니 총 500000이 나올 것 같지만 실제로는 돌릴 때마다 값이 다릅니다.
파이썬 인터프리터는 실행되는 모든 스레드를 강제로 공평하게 취급해서 각 스레드의 실행 시간을 거의 균등하게 만들고 이를 위해 실행 중인 스레드를 일시 중단시키고 다른 스레드로 context switching을 반복합니다. 하지만 문제는 파이썬이 스레드를 언제 일시 중단시킬지 알 수 없다는 점입니다. Counter 객체의 increment 메소드는 "counter.count += 1"이고 이것을 스레드 입장에서는 다음 순서로 실행합니다.
- value = getattr(counter, 'count')
- result = value + 1
- setattr(counter, 'count', result)
하지만 파이썬 인터프리터가 스레드를 언제 일시 중단시킬지 알 수가 없기에 세 연산 사이에서 언제든지 일시 중단될 수 있습니다. 이렇게 되면 스레드 간 연산 순서가 뒤섞이면서 "value"의 이전 값을 카운터에 대입하는 일이 생길 수 있습니다. 다음과 같이 스레드 A가 완전히 끝나기 전에 인터럽트가 일어나서 스레드 B가 실행되고 이것이 끝나면 다시 스레드 A가 중간부터 실행되는데 이로 인해 스레드 B가 카운터를 증가시켰던 결과가 사라집니다.
- value_a = getattr(counter, 'count') # 스레드 A
- value_b = getattr(counter, 'count') # 스레드 B로 context switch
- result_b = value_b + 1 # 스레드 B
- setattr(counter, 'count', result_b) # 스레드 B
- result_a = value_a + 1 # 스레드 A로 context switch
- setattr(counter, 'count', result_a)
이와 같은 상황을 race condition (경합상황) 이라고 하며 파이썬에서는 이러한 데이터 구조 오염을 해결하기 위해 "threading" 모듈 안에 대표적으로 상호배제 "Lock" 클래스를 제공합니다. 이러한 락을 사용하면 한 번에 단 하나의 스레드만 락을 획득할 수 있어 Counter 객체가 여러 스레드의 동시 접근으로부터 자신의 현재 값을 보호할 수 있습니다. 다음 코드에서는 with 문을 사용해 락을 획득하고 해제하며 락을 획득한 순간에 increment 함수를 실행하면서 다른 스레드의 인터럽트를 막습니다.
from threading import Lock
class LockingCounter:
def __init__(self):
self.lock = Lock()
self.count = 0
def increment(self, offset):
with self.lock:
self.count += offset
이제 다시 실행시키면 제대로된 값이 나옵니다.
how_many = 100000
counter = LockingCounter()
threads = []
for i in range(5):
thread = Thread(target=worker, args=(i, how_many, counter))
thread.start()
threads.append(thread)
for thread in threads:
thread.join()
expected = 5 * how_many
found = counter.count
print(f'Expected {expected}, but found {found}')
따라서 여러 스레드 사이에 일어나는 데이터 경합으로부터 데이터 구조를 보호하기 위해서는 threading 모듈의 Lock 클래스를 활용해야 합니다. 파이썬에서는 Queue, PriorityQueue 내장 모듈의 자료구조에 대해 스레드 세이프를 위한 락킹 기능을 제공합니다. 특히, PriorityQueue 모듈은 실질적으로 heapq 모듈로 구현되어있어 사실상 동일한 기능을 하면서도 스레드 세이프한 특징을 지닙니다. (heapq 모듈은 스레드 세이프하지 않습니다.) 하지만 GIL로 인해 연산을 위한 병렬 처리는 멀티 프로세싱을 활용하므로 PriorityQueue 모듈의 멀티 스레딩 지원은 사실상 큰 의미가 없고 락킹에 따른 오버헤드가 발생하게 됩니다. 따라서 굳이 멀티 스레드를 사용할 것이 아니라면 heapq 모듈을 사용하는 것이 낫습니다.
'Computer > Python' 카테고리의 다른 글
Public, Private Attributes (0) | 2021.06.27 |
---|---|
변수 영역과 클로저 (0) | 2021.06.27 |
GIL (Global Interpreter Lock), Multi-Threading (0) | 2021.06.20 |
제너레이터와 yield (1) (0) | 2021.06.14 |
Numba (1) (0) | 2021.06.02 |