본문 바로가기
Python

Python - GIL, 멀티 쓰레드(thread) vs 멀티 프로세스(multiprocessing, subprocess)

by 올엠 2024. 3. 25.
반응형

Python 코드를 작성하다보면 시스템 I/O를 효과적으로 사용하는 병령 처리 프로세스를 고민하게된다.

병렬 처리란, 특정 Task를 동시에 실행함으로써 순차적으로 처리하는 기본 처리 프로세스 보다 이점을 가져갈 수 있다.

특히 HTTP와 같은 네트워크 요청이나 Disk등 시스템 자원을 사용하는 I/O 가 발생하는 경우 자원 I/O를 기다리지 않고 다른 Task로 실행할 수 있어 보다 효과적으로 프로세스를 처리할 수 있다.

그렇다면 Python에는 어떤 방식으로 병렬처리를 진행해볼 수 있을까?

필자가 생각하는 방안은 총 2가지 정도이다.

멀티 쓰레드(thread)를 사용하는 방법과 멀티 프로세스(process)를 사용하는 방법이다.

다만 이 멀티 쓰레드(thread)와 멀티 프로세스(process)를 시작하기 전에 Python GIL에 대해서 먼저 이해를 해보자.

GIL(Global Interpreter Lock)

GIL(Global Interpreter Lock)는 모든 자원의 락(Lock)을 글로벌(Global)하게 관리한다는 의미로 

Python은 접근 위반(Access violation)이라고 할 수 있는 데이터 접근 오류가 발생하지 않는다. 이 오류는 보통 스레드가 동시에 데이터에 접근할 때 무결성에 문제가 발생하여 나타나는 오류로 멀티, 쓰레드, 프로세스에서 흔히 발생하는 오류이다.

이유를 데이터를 보호하기 위해 하나의 프로세스에서 동시에 동작하는 스레드를 하나만 사용하도록 락(Lock) 정책을 사용한다. 

이에 대한 장점으로 동시 접근 데이터에 대한 접근 위반을 신경을 쓸 필요가 없고, 접근 오류로 인한 데드락 발생이 없다.

그리고 스레드간 자원 경쟁에 따른 문제도 발생하지 않기 때문에, 코드가 간결해지고 쉬워진다.

단점으로는 하나의 스레드만 실행이 되기 때문에 I/O 작업이 많은 경우 느리다.

그리고 멀티쓰레드를 실행하면 GIL이 동작하여 실행을 위한 새로운 경쟁이 발생하게 된다. 이때 처리되는 리소스가 추가로 사용되며, 병렬 처리의 속도가 빠르지 않다. 오히려 느린 경우도 발생한다.

이런 이유로 GIL를 회피할 수 있는 방안으로 멀티 프로세스를 사용하기도 한다.

 

멀티 쓰레드(thread) 과 멀티 프로세스(process)

우리는 멀티 쓰레드 혹은 프로세스로 작업할 때 각각의 장점을 명확히 이해하는 것이 좋다.

멀티 쓰레드를 가장 잘 사용하는 방안은 CPU 작업이 적고 I/O 작업이 많은 병렬 처리 프로그램에 효과적이다.

멀티 프로세스는 GIL을 회피해야 하는 상황(동시 작업이 중요하고 CPU 작업이 많은 경우)에 유리하다고 할 수 있다.

멀티 쓰레드 장점

프로세스는 생성할 때 OS에서 관리에 필요한 자원을 함께 생성하게 된다. 따라서 기본적으로 무겁다. 이에 비해 쓰레드는 실행하면 기존에 생성된 프로세스의 공간을 활용하기 때문에 OS 자원 낭비가 없다.

또 자원(변수, 전역 변수등)은 프로세스 단위로 공유가 된다. 따라서 하나의 파이썬 프로그램에 여러 자원을 접근해야 한다면, 쓰레드로 작업하면 보다 효율적인 자원 관리가 가능하다. 그리고 GIL 을 통해 동시에 하나의 쓰레드만 처리된다 하더라도 유휴 시간에 쓰레드별로 실행이 가능하기 때문에 I/O 호출이 많은 경우 멀티 스레드가 보다 빠르게 동작한다.

GIL 이외에 코루틴을 통해 유휴타임일 때 다른 스레드를 실행(혹 다른 EventLoop 처리)등으로 활용할 수 있다.

멀티 프로세스 단점

별도의 작업공간을 만든것과 같기 때문에 파이썬의 GIL 영향을 받지 않고 동작한다.

프로세스를 생성하면서 발생하는 리소스 낭비는 생성하는데 필요한 시간과 메모리 공간 낭비가 발생한다.

하지만 요즘은 메모리도 넉넉한 경우가 많기 때문에 메모리 부분의 걱정은 할 필요가 없지만,  생성 및 OS 관리 자원 낭비를 고려하여 가벼운 작업은 쓰레드를 활용하는 것이 효율적이다.

멀티 쓰레드(thread)

멀티 쓰레드는 GIL로 인해 자원 손실이 존재하지만 기본적으로 I/O 대기 시간을 이용해서 실행하는 구조라고 할 수 있다.

Thread를 활용할 수 있는 방법은 threading 라이브러리를 이용할 수 있으며, 가장 기본적인 방법은 다음과 같다.

from threading import Thread
import time


# 전역 변수
ACCESS_VAL = 'Allmnet'


def thread_task(number):
    print('thread start', ACCESS_VAL)
    time.sleep(int(number))
    print('thread end', ACCESS_VAL)


start = time.time()
for x in range(1, 10):
    thread = Thread(target = thread_task, args = [x])
    thread.start()
end = time.time()
runtime = end - start
print(f'실행 시간: {runtime}')

기본 실행 시간은 1초가 걸리지 않고 이후 쓰레드에서 작업을 처리하면 전체가 완료되는 시간이 10초가 걸리게 된다.


보다 자세한 내용은 여기에서 확인이 가능하다.
 
파이썬에서 쓰레드를 활용하는 방법으로 쓰레드를 생성하는 방법도 있지만, 하나의 스레드를 통해 코루틴를 활용 하는 것도 가능하다. Python 3.5 버전에 추가된 코루틴은 무엇일까?코루틴 관련해서는 아래에서 확인이 가능하다.
 

멀티 프로세스(process)

프로세스를 통해서 진행하는 방법은 multiprocessing을 활용하는 방법이 있다.

기본적으로 함수 단위로 실행이 가능하며, 프로세스 내 다른 함수나 변수 접근도 부분적으로 가능하다.(원래 프로세스는 프로세스 별로 접근이 불가능 하지만, 라이브러리에서 해당 함수만 독립적으로 가져가 실행하는 구조가 아닌 현재 실행 코드 전체를 가지고 가서 실행하는 구조로 판단된다). 

그리고 메인 모듈이 의도하지 않은 부작용(가령 새 프로세스 시작)을 일으키지 않고 새 파이썬 인터프리터가 안전하게 임포트 할 수 있도록, 기본 라인이 아닌 if __name__ == '__main__': 내 혹은 함수 에서 활용할 것을 추천하고 있다.

from multiprocessing import Process
import time


# 전역 변수
ACCESS_VAL = 'Allmnet'


def proc_task(number):
    print('thread start', ACCESS_VAL)
    time.sleep(int(number))
    print('thread end', ACCESS_VAL)


if __name__ == '__main__':
    start = time.time()
    for x in range(1, 10):
        proc = Process(target=proc_task, args=(x,))
        proc.start()
    end = time.time()
    runtime = end - start
    print(f'실행 시간: {runtime}')

위 코드로 실행한 경우 아래와 같이 비슷한 결과를 얻을 수 있지만, 프로세스 실행을 위해 자원을 생성하는 시간이 스레드 보다 많이 걸리는 것을 알 수 있다.

위 방법은 별도의 Process이기는 하지만, 완벽한 독립 실행이라고 하기는 어렵다. 따라서 많은 작업을 구성해야 한다면, 별도의 python 파일을 만들어서 subporcess 라이브러리로 실행하는 방법이 유용하다.

프로세스 실행(subprocess)

subprocess는 완벽하게 별도의 프로세스를 실행하는 방식을 의미한다.

따라서 python 파일도 실행할 파일을 별도로 구분해야 한다. 보통 subprocess를 이용이 필요할 때에는 파이썬이외의 다른 프로세스 실행에도 사용할 수 있기 때문에(예 netstat 또는 bash와 같은 운영체제의 프로세스 실행과 동일하다.)  매우 유용하게 활용할 수 있다.

먼저 앞서 proc_task를 별도의 파일로 구분하도록 하자.

이때 실행시 number 인자를 받기 위해 sys 라이브러리를 추가하였다.

그리고 sys에서 인자값을 가져오는 argv를 활용하여 프로세스 실행시 추가 인자값을 확인 할 수 있도록 아래처럼 코드를 작성한다.

proc_task.py

import time
import sys


ACCESS_VAL = 'Allmnet'


def proc_task(number):
    print('thread start', ACCESS_VAL)
    time.sleep(int(number))
    print('thread end', ACCESS_VAL)


if __name__ == '__main__':
    number = sys.argv[1]
    proc_task(number)

이후 main.py에 프로세스를 실행, 관리할 수 있는 subprocess를 추가한다.

그리고 실행을 할때, subprocess는 실행 내용을 리스트로 전달 받으며 스트링(str)만 받을 수 있기 때문에 int인 x를 str로 변환하고 추가로 Shell에 내용을 출력할 수 있도록, shell=True를 선언해주도록 하자.

Popen은 별도의 실행 옵션이 없어도 바로 프로세스를 시작하게 된다.

main.py

import subprocess
import time


# 전역 변수

if __name__ == '__main__':
    start = time.time()
    for x in range(1, 10):
        subprocess.Popen(['python','proc_task.py', str(x)], shell=True) # Popen을 호출한 시점에 실행된다.
        # Popen.wait 를 하거나 프로세스 관리가 가능하다. 필요하지 않는 경우 객체 생략가능
    end = time.time()
    runtime = end - start
    print(f'실행 시간: {runtime}')

프로세스 실행 자체를 OS에 맡기기 때문에 실행 속도는 multiprocessing 라이브러리보다 훨씬 빠르다고 할 수 있다.


실행 이후 관리나 추가적으로 조절할 수 있는 옵션이 많으므로 공식 문서도 참고해보면 좋을 것이다.






반응형