전역 인터프리터 잠금을 배우고 나면, 많은 신참 파이썬 프로그래머가 코드에서 상호 배제 잠금(뮤텍스)을 사용하지 않아도 될 것이라고 생각할지도 모릅니다. 파이썬 스레드가 여러 CPU 코어에서 병렬로 실행하는 것을 GIL이 이미 막았다면 플그램의 자료 구조에도 잠금이 설정되었을 것이라고 생각하기 때문입니다. 리스트나 딕셔너리 같은 타입에서 테스트해보면 이런 가정을 따라도 될 것처럼 보입니다.

하지만 실제로는 그렇지 않습니다. GIL은 이러한 환경을 보호해주지 못합니다. 비록 파이썬 스레드가 한 번에 하나만 실행되지만, 파이썬 인터프리터에서 자료구조를 다루는 스레드 연산은 두 바이트코드 명령어 사이에서 인터럽트될 수 있습니다. 여러 스레드에서 동시에 같은 객체에 접근한다면 이런 가정은 위험합니다. 자료구조의 불변성이 인터럽트 때문에 언제든지 깨질 수도 있다는 의미이며, 그러면 프로그램은 오류가 있는 상태로 남습니다.

예를 들어 전체 센서 네트워크에서 밝기 단계를 샘플링하는 경우처럼 병렬로 여러 대상을 카운트하는 프로그램을 작성한다고 가정합니다. 시간에 따른 밝기 샘플의 전체 개수를 알고 싶다면 새 클래스로 개수를 모으면 됩니다.

from threading import Thread


class Counter:
    def __init__(self):
        self.count = 0

    def increment(self, offset):
        self.count += offset


# 센서에서 읽는 작업에서는 블로킹 I/O가 필요하므로 각 센서별로 고유한 작업 스레드가 있다고 가정
# 각 센서 측정값을 읽고 나면 작업 스레드는 읽으려는 최대 개수에 이를 때까지 카운터 증가
def worker(sensor_index, how_many, counter):
    for _ in range(how_many):
        # 센서에서 읽어옴
        # ....
        counter.increment(1)


# 센서별로 작업 스레드를 시작하고 읽기를 모두 마칠 때까지 기다리는 함수
def run_threads(func, how_many, counter):
    threads = []
    for i in range(5):
        args = (i, how_many, counter)
        thread = Thread(target=func, args=args)
        threads.append(thread)
        thread.start()
    for thread in threads:
        thread.join()


# 스레드 다섯 개를 병렬로 실행하는 일은 간단해 보이므로 결과가 명확함

how_many = 10 ** 5
counter = Counter()
run_threads(worker, how_many, counter)
print('Counter should be %d, found %d' % (5 * how_many, counter.count))

# 결과
# Counter should be 500000, found 398819


하지만 결과가 예상과는 다릅니다. 이렇게 간단하고 특히 파이썬 인터프리터 스레드가 한 번에 단 하나만 동작하는데 어떻게 오류가 날 수 있을까요?

파이썬 인터프리터는 모든 스레드가 거의 동등한 처리 시간 동안 실행하게하려고 실행 중인 모든 스레드 사이에서 공평성을 유지합니다. 파이썬은 공평성을 유지하려고 실행 중인 스레드를 잠시 중지하고 차례로 다른 스레드를 재개합니다. 문제는 파이썬이 스레드를 정확히 언제 중지할지 모른다는 점입니다. 스레드는 심지어 원자적(atomic)연산으로 보이는 작업 중간에서 멈출 수 있습니다. 앞의 예제에서 일어난게 바로 이 상황입니다.

Counter 객체의 increment 메서드는 간단해 보입니다.

counter.count += offset



하지만 객체의 속성에 사용한 += 연산자는 사실 파이썬이 보이지 않게 별도의 연산 세 개를 수행하게 합니다. 위의 문장은 다음과 같이 표현할 수 있습니다.

value = getattr(counter, 'count')
result = value + offset
setattr(counter, 'count', result)


카운터를 증가시키는 파이썬 스레드는 이 연산들 사이에서 중지될 수 있습니다. 만약 연산이 끼어든 상황 때문에 value의 이전 값이 카운터에 할당되면 문제가 됩니다. 다음은 두 스레드 A와 B의 안 좋은 상호작용을 보여주는 예입니다.

# 스레드 A에서 실행함
value_a = getattr(counter, 'count')
# 스레드 B로 컨텍스트를 전환함
value_b = getattr(counter, 'count')
result_b = value_b + 1
setattr(counter, 'count', result_b)
#스레드 A로 컨텍스트를 되돌림
result_a = value_a + 1
setattr(counter, 'count', result_a)


스레드 A는 스레드B에서 카운터 증가를 실행하는 모든 작업을 없애버립니다. 이게 바로 앞의 밝기 센서 예제에서 발생한 일입니다. 

파이썬은 이와 같은 데이터 경쟁(race)과 다른 방식의 자료 구조 오염을 막으려고 내장 모듈 threading에 강력한 도구들을 갖춰놓고 있습니다. 가장 간단하고 유용한 도구는 상호 배제 잠금(뮤텍스) 기능을 제공하는 Lock 클래스입니다.

잠금을 이용하면 여러 스레드가 동시에 접근하더라도 Counter 클래스의 현재 값을 보호할 수 있습니다. 한 번에 한 스레드만 잠금을 얻을 수 있습니다. 다음 코드에서 with문으로 잠금을 얻고 해제합니다. 덕분에 잠금이 설정된 동안 실행되는 코드를 쉽게 파악할 수 있습니다. 

from threading import Thread
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


def worker(sensor_index, how_many, counter):
    for _ in range(how_many):
        # 센서에서 읽어옴
        # ....
        counter.increment(1)


# 센서별로 작업 스레드를 시작하고 읽기를 모두 마칠 때까지 기다리는 함수
def run_threads(func, how_many, counter):
    threads = []
    for i in range(5):
        args = (i, how_many, counter)
        thread = Thread(target=func, args=args)
        threads.append(thread)
        thread.start()
    for thread in threads:
        thread.join()


how_many = 10 ** 5
counter = LockingCounter()
run_threads(worker, how_many, counter)
print('Counter should be %d, found %d' % (5 * how_many, counter.count))

# 결과
# Counter should be 500000, found 50000

요약

파이썬에 전역 인터프리터 잠금이 있다고 해도 프로그램안에서 실행되는 스레드 간의 데이터 경쟁으로부터 보호할 책임은 프로그래머에게 있음

여러 스레드가 잠금없이 같은 객체를 수정하면 프로그램의 자료 구조가 오염됨

내장 모듈 threading의 Lock 클래스는 파이썬의 표준 상호 배제 잠금(뮤텍스) 구현


+ Random Posts