파이썬의 표준구현을 CPython이라고 합니다. CPython은 파이썬 프로그램을 두 단계로 실행합니다. 먼저 소스 텍스트를 바이트코드로 파싱하고 컴파일합니다. 그런 다음 스택 기반 인터프리터로 바이트코드를 실행합니다. 바이트코드 인터프리터는 파이썬 프로그램이 실행되는 동안 지속되고, 일관성 있는 상태를 유지합니다. 파이썬은 전역 인터프리터 잠금(GIL, Global Interpreter Lock)이라는 메커니즘으로 일관성을 유지합니다.

본질적으로 GIL은 상호 배제 잠금(mutex)이며 CPython이 선점형 멀티스레딩의 영향을 받지 않게 막아줍니다. 선점형 멀티스레딩(preemptive multithreading)은 한 스레드가 다른 스레드를 인터럽트해서 프로그램의 제어를 얻는 것을 말합니다. 이 인터럽트가 예상치 못한 시간에 일어나면 인터프리터 상태가 망가집니다. GIL은 이런 인터럽트를 막아주며 모든 바이트코드 명령어가 CPython구현과 C확장 모듈에서 올바르게 동작함을 보장합니다.

GIL은 중요한 부작용을 갖고 있습니다. C++나 자바 같은 언어로 작성한 프로그램에서 여러 스레드를 실행하는 건 프로그램이 동시에 여러 CPU코어를 사용함을 의미합니다. 파이썬도 멀티스레드를 지원하지만, GIL은 한 번에 한 스레드만 실행하게 합니다. 다시 말해 스레드가 병렬 연산을 해야 하거나 파이썬 프로그램의 속도를 높여야 하는 상황이라면 실항하게 될 것입니다. 


예를 들어 파이썬으로 연산 집약적인 작업을 한다고 가정합니다.  여기서는 단순 숫자 인수 분해 알고리즘을 대신 사용합니다.

from time import time


def factorize(number):
    for i in range(1, number + 1):
        if number % i == 0:
            yield i

# 오래걸림...
numbers = [2139079, 1214759, 1516637, 1852285]
start = time()
for number in numbers:
    list(factorize(number))
end = time()
print('Took %.3f seconds' % (end - start))

# 결과
# Took 0.933 seconds


다른언어에서는 당연히 이런 연산에 멀티스레드를 이용합니다. 멀티스레드를 이용하면 컴퓨터의 모든 CPU를 최대한 활용할 수 있기 때문입니다.그럼 이 작업을 파이썬으로 실행해보겠습니다. 여기서는 같은 연산을 파이썬 스레드로 정의합니다.

from time import time
from threading import Thread


class FactorizeThread(Thread):
    def __init__(self, number):
        super().__init__()
        self.number = number

    def run(self):
        self.factors = list(factorize(self.number))


def factorize(number):
    for i in range(1, number + 1):
        if number % i == 0:
            yield i


# 각 숫자를 인수 분해할 스레드를 병렬로 시작
numbers = [2139079, 1214759, 1516637, 1852285]
start = time()
threads = []
for number in numbers:
    thread = FactorizeThread(number)
    thread.start()
    threads.append(thread)

# 마지막으로 스레드들이 모두 완료하기를 기다림
for thread in threads:
    thread.join()
end = time()

print('Took %.3f seconds' % (end - start))

# 결과
# Took 0.915 seconds


여기서 순서대로 인수 분해할 때보다 시간이 비슷하게 걸리거나 더 걸린다는 것을 알 수 있습니다.( 예제에서 이상하게 적게나왔네..)

숫자별로 스레드 하나를 사용하면 스레드를 생성하고 실행 순서를 조율하는 부담을 감안할 때 4배 미만의 속도 향상을 기대했을 것입니다. 이 코드를 듀얼코어 머신에서 실행한다면 2배 정도의 속도향상만 기대할 것입니다. 하지만 활용 가능한 CPU가 여러 개인데도 이 스레드들의 성능이 더 나쁠 것이라고는 절대 예상하지 못했을 것입니다. 이로부터 GIL이 표준 CPython 인터프리터에서 실행하는 프로그램에 미치는 영향 을 알 수 있습니다.

CPython이 멀티코어를 활용하게 하는 방법은 여러 가지지만, 표준 Thread클래스에는 동작하지 않으므로 노력이 필요합니다. 이런 제약을 알게되면 파이썬이 스레드를 왜 지원하는지 의문이 들지도 모릅니다. 여기엔 두 가지 좋은 이유가 있습니다.

첫 번째 이유는 멀티스레드를 사용하면 프로그램이 동시에 여러 작업을 하는것처럼 보이게 만들기가 용이합니다. 동시에 동작하는 테스크를 관리하는 코드를 직접 구현하기가 어렵습니다. 스레드를 이용하면 함수를 마치 병렬로 실행하는 것처럼 해주는 일을 파이썬에 맡길 수 있습니다. 비록 GIL때문에 한 번에 한 스레드만 진행하지만, CPython은 파이썬 스레드가 어느 정도 공평하게 실행됨을 보장하기 때문입니다.

파이썬이 스레드를 지원하는 두 번째 이유는 특정 유형의 시스템 호출을 수행할 때 일어나는 블로킹 I/O를 다루기 위해서입니다. 시스템 호출은 파이썬 프로그램에서 외부 환경과 대신 상호 작용하도록 컴퓨터 운영체제에 요청하는 방법입니다. 블로킹 I/O로는 파일 읽기/쓰기, 네트워크와의 상호작용, 디스플레이 같은 장치와의 통신 등이 있습니다. 스레드는 운영체제가 이런 요청에 응답하는데 드는 시간을 프로그램과 분리하므로 블로킹 I/O를 처리할 때 유용합니다.


예를 들어 원격 제어가 가능한 헬리콥터에 직렬 포트로 신호를 보내고 싶다고 가정합니다. 예제에서는 이 작업을 느린 시스템 호출(select)에 위임합니다. 이 함수는 동기식 직렬 포트를 사용할 때 일어나는 상황과 비슷하게 하려고 운영체제에 0.1초간 블록한 후 제어를 프로그램에 돌려달라고 요청합니다.

import select
from time import time


def slow_systemcall():
    select.select([], [], [], 0.1)


# 이 시스템 호출을 연속해서 실행하면 시간이 선형으로 증가

start = time()
for _ in range(5):
    slow_systemcall()
end = time()
print('Took %.3f seconds' % (end - start))

# 결과
# Took 0.508 seconds


문제는 slow_systemcall 함수가 실행되는 동안에는 프로그램이 다른 일을 할 수 없다는 점입니다. 프로그램의 메인 스레드는 시스템 호출 select 때문에 실행이 막혀 있습니다. 실제로 벌어진다면 끔찍한 상황입니다. 신호를 헬리콥터에 보내는 동안 헬리콥터의 다음 이동을 계산해야 합니다. 그렇지 않으면 헬리콥터가 충돌할 것입니다. 블로킹 I/O를 사용하면서 동시에 연산도 해야 한다면 시스템 호출을 스레드로 옮기는 방안을 고려해야 합니다.

다음 코드는 slow_systemcall 함수를 별도의 스레드에서 여러 번 호출하여 실행합니다. 이렇게 하면 동시에 여러 직렬 포트(및 헬리콥터)와 통신할 수 있게 되고, 메인스레드는 필요한 계산이 무엇이든 수행하도록 남겨둘 수 있습니다.

import select
from time import time
from threading import Thread


def slow_systemcall():
    select.select([], [], [], 0.1)


def compute_helicopter_location(index):
    # 스레드가 시작하면 시스템 호출 스레드가 종료할 때 까지 기다리기 전에 헬리콥터의 다음 이동을 계산
    print(index)


# 이 시스템 호출을 연속해서 실행하면 시간이 선형으로 증가

start = time()
threads = []
for _ in range(5):
    thread = Thread(target=slow_systemcall)
    thread.start()
    threads.append(thread)

for i in range(5):
    compute_helicopter_location(i)

for thread in threads:
    thread.join()
end = time()
print('Took %.3f seconds' % (end - start))

# 결과
# 0
# 1
# 2
# 3
# 4
# Took 0.102 seconds


병렬 처리 시간은 직렬 처리 시간보다 5배나 짧습니다. 이 예제는 시스템 호출이 GIL의 제약을 받지만 여러 파이썬 스레드를 모두 병렬로 실행할 수 있음을 보여줍니다. GIL은 파이썬 코드가 병렬로 실행하지 못하게 합니다. 하지만 시스템 호출에서는 이런 부정적인 영향이 없습니다. 이는 파이썬 스레드가 시스템 호출을 만들기 전에 GIL을 풀고 시스템 호ㅜㄹ의 작업이 끝나는 대로 GIL을 다시 얻기 때문입니다.

스레드 이외에도 내장모듈 asyncio처럼 블로킹 I/O를 다루는 다양한 수단이 있고, 이런 대체 수단엔 중요한 이점이 있습니다. 하지만 이런 옵션을 선택하면 실행 모델에 맞춰서 코드를 재작성해야 하는 추가 작업이 필요합니다. 스레드를 이용하는 방법은 프로그램의 수정을 최소화하면서도 블로킹 I/O를 병렬로 수행하는 가장 간단한 방법입니다.

요약

파이썬 스레드는 전역 인터프리터 잠금(GIL, Global Interpreter Lock) 때문에 여러 CPU코어에서 병렬로 바이트코드를 실행할 수 없음

GIL에도 불구하고 파이썬 스레드는 동시에 여러 작업을 하는 것처럼 보여주기 쉽게 해주므로 여전히 유용

여러 시스템 호출을 병렬로 수행하려면 파이썬 스레드를 사용. 이렇게 하면 계산을 하면서도 블로킹 I/O를 수행할 수 있음

+ Random Posts