python 동시성 관리 (2) - GIL(Global Interpreter Lock)

6

Python 동시성관리

목록 보기
2/3
post-thumbnail

들어가기에 앞서

이번 시간에는 GIL(Global Interpreter Lock) 개념에 관하여 알아보도록 하겠습니다.

  • 프로세스(Process)와 스레드(Thread) 개념
  • Python GIL(Global Interpreter Lock)
  • 코루틴(Coroutine)
  • 동시성 관리 구현에 유용한 모듈 예제(Futures, asyncio)
                           [이 글은 Python 언어 기반으로 작성되었습니다.]

Python 동시성 관리를 이해하기 위하여 알아야 할 것들

먼저 동시성 관리를 설명하기 전에, 기본적으로 이해해야 할 것들이 존재합니다.
크게 3가지에 대해서 설명할텐데요! 3가지는 다음과 같습니다.

  • 프로세스와 스레드 개념
  • Python GIL(Global Interpreter Lock)
  • 코루틴(Coroutine)

세가지 개념 중 Python GIL(Global Interpreter Lock) 개념에 대해서 이해해보도록 하겠습니다.

multi-thread로 연산 수행해보기

먼저 GIL에 대해서 설명하기 전에 이전 시간에 배운 멀티스레딩을 Python 코드로 구현해보도록 하겠습니다.

랜덤으로 생성된 배열을 sorted 내장 함수를 사용하여 정렬하는 연산을 두 번 실행하는 것을 두 가지 방법으로 구현해보도록 하겠습니다.

첫 번째는
단일스레드로 두 개의 정렬 작업을 연속적으로 실행하는 작업과,

두 번째는
두 개의 스레드가 각각 하나의 작업을 담당하여 실행하도록 구현해 보겠습니다.

먼저 필요한 모듈을 import 하고 배열을 정렬하는 함수 working 을 만들어보겠습니다.

import time
import threading
import random

list_len = 10_000_000

def working():
	return sorted([random.random() for _ in range(list_len)])

먼저 단일 스레드에서 working 함수를 연속적으로 실행하고 실행된 시간을 측정해보겠습니다.

Python 프로그램은 기본적으로 하나의 메인 스레드가 파이썬 코드를 순차적으로 실행합니다.

start_time = time.time()
sorted_list1 = working()
print(f"first working --> {time.time() - start_time:.4f}s")

start_time = time.time()
sorted_list2 = working()
print(f"second working --> {time.time() - start_time:.4f}s")
# 결과
first working  --> 7.7641s
second working --> 7.7238s

두 작업이 모두 수행되는데 7.7641, 7.7238 초가 걸렸고, 연속적으로 실행되었기 때문에 총 15.4879초가 걸렸다는 것을 알 수 있습니다.

두 번째로는 두 개의 스레드를 통해 working 함수를 각각 스레드에 할당하여 수행시켜 보도록 하겠습니다.

파이썬에서 쓰레드를 실행시키기 위해서는 treading 모듈이나 thread 모듈을 사용할 수 있습니다.
threading 모듈은 고수준, thread 모듈은 저수준의 모듈이라는 차이를 가지고 있으며,
일반적으로 thread 모듈 위에서 구현된threading 모듈을 사용하고 있습니다.
thread 모듈은 deprecate 되어 거의 사용되고 있지 않습니다.

따라서 저희도 이번 시간에는 threading 모듈을 활용해 보도록 하겠습니다.

threading 모듈

Python에서 쓰레드를 실행시키기 위해서는 다음의 절차로 진행됩니다.

threading.Thread() 함수를 호출하여 Thread 객체를 얻는다.
② Thread 객체의 start() 메서드를 호출한다.

해당 스레드는 우리가 수행하고자 하는 함수 혹은 메서드를 실행하는데, 일반적인 구현방식은 크게

'1. 스레드가 실행할 함수 또는 메소드 작성하거나,
'2. threading.Thread 로부터 파생된 파생클래스를 작성하여

사용하는 방식이 있습니다.

우리는 '1. 스레드가 실행할 함수 또는 메소드 작성 방법을 사용하여 스레드를 구현해보도록 하죠.
(위의 working 함수를 사용할 예정입니다.)

사용법은 매우 간단한데,
threading.Threadtarget 파라미터에 해당 함수를 지정하면 됩니다.

def working():
	return sorted([random.random() for _ in range(list_len)])

threads = []
start_time = time.time()
for i in range(2):
	threads.append(threading.Thread(target=working))
	threads[i].start()
 
 for t in threads:
 	t.join()        # join 메소드 사용시, thread 수행될 때까지 기다림
    
print(f"multi-threading --> {time.time() - start_time:.4f}s")
# 결과
multi-threading --> 15.9612s

Threadjoin 메소드를 사용하여, 스레드가 종료될 때까지 기다린 후 수행시간을 측정해본 결과,
두 개의 스레드를 통해 수행된 연산 시간이 총 15.9612초가 걸린 것을 확인할 수 있습니다.

두가지 케이스 수행 시간을 비교해보면,
'1. 단일 스레드 순차 실행 --> 7.7641 + 7.7238 = 15.4879초
'2. 멀티 스레드 실행 --> 15.9612초

기대와는 다르게 멀티 스레딩을 통해 병렬로 연산이 처리되어 훨씬 적은 시간이 걸릴 것으로 기대했지만
오히려 기존 단일 스레드에서 순차적으로 실행한 연산한 결과보더 더 오랜 시간이 걸린 것을 확인했습니다.

어떠한 이유 때문에 이러한 결과가 도출되었을까요?

결론부터 말씀드리면 보통 우리가 Python을 설치하여 사용시 CPython이라는 파이썬 구현체를 사용하는데요,
해당 구현체(인터프리터)는 GIL이라는 특징을 가지고 있습니다.
때문에 멀티 스레드가 병렬로 연산을 수행하지 못하여 더 오랜시간이 걸린 것입니다.
(동일한 시간이 아니라 왜 더 오랜시간이 걸렸을까요?)

GIL이란, Global Interpreter Lock의 약자로 여러 개의 스레드가 파이썬 코드를 동시에 실행하지 못하도록 하는 것을 의미합니다.

그렇다면 왜 CPython은 여러 개의 스레드가 동시에 실행되지 못하도록 막아두었을까요?

GIL을 정확히 이해하려면 CPython을 먼저 이해해야 할 필요성이 있어, 먼저 CPython에 대해서 설명하고 넘어가도록 하겠습니다.

CPython이란?

아마 많은 분들이 파이썬은 인터프리터 언어라는 이야기를 많이 들어보셨을 텐데요,
인터프리터(interpreter)란 코드를 한 줄씩 읽으면서 실행하는 프로그램을 말합니다.
이는 컴파일 언어와 비교하였을 때 프로그램 수정이 간단하고 생산성, 유지 보수 측면에서 좋다는 장점이 있습니다.

하지만 실행 시마다 소스 코드를 한 줄씩 번역해야하므로 컴파일 언어보다 느리다는 단점도 가지고 있죠.
특히 Python은 동적 프로그래밍 언어 형태를 따르고 있습니다.
특히 변수의 타입을 동적으로 변형하고 있기 때문에 매번 변수의 타입을 확인해야 하므로 이 또한 속도의 성능을 저하시킵니다.

이러한 Python이 가지고 있는 단점을 보완하고자
Python 인터프리터의 표준 구현체로 받아들여지고 있는 것이 CPython입니다.

보통 싸이썬이라고 읽는데요!
Python을 C언어로 구현한 구현체를 의미하며, 실제 CPython은 인터프리터이면서 컴파일러라고 볼 수 있습니다.
그 이유는 Python 코드를 bytecode로 컴파일한 후에 해당 bytecode를 interpreter가 실행하기 때문입니다.

만약 CPython 구현체를 기반으로 된 python script를 실행했다면 .pyc 라는 파일이 생성되는데요,
해당 파일에 CPython이 컴파일한 bytecode가 들어있습니다.
.pyc 를 interpret 하여 실행하는 것이죠.

CPython은 형 타입도 정적으로 선언 할 수 있는 기능도 제공합니다.

제가 이 CPython을 설명드린 이유는, CPython 의 대표적인 단점으로 GIL 특성이 있기 때문입니다.
즉, CPython을 내부 구현체로 사용되는 모듈이나 함수 등은 GIL 특성을 가지고 있다고 볼 수 있는 것이죠.

자, GIL을 설명드리기 위한 부연설명이 많이 길어졌는데요!
이제 본격적으로 GIL에 대해서 이해해보도록 하겠습니다 :)

참고로, CPython 외의 구현체로 Jython이나 PyPy 등 있습니다.
이번 시간에는 모두 다루기가 어려워 넘어가지만,
혹시 더 궁금하신 분들은 검색을 통해서 찾아보시는 것도 추천합니다.

GIL이란?

우선 위키피디아에서 정의한 GIL에 대한 내용을 확인해보도록 하죠.

A global interpreter lock (GIL) is a mechanism used in computer-language interpreters to synchronize the execution of threads so that only one native thread (per process) can execute at a time.[1] An interpreter that uses GIL always allows exactly one thread to execute at a time, even if run on a multi-core processor. Some popular interpreters that have GIL are CPython and Ruby MRI.

우선 그대로 번역해보면,

GIL은 프로세스당 한 번의 하나의 기본 스레드만 실행될 수 있도록 스레드 실행을 동기화하기 위해 인터프리터에서 사용되는 매커니즘입니다.
GIL을 사용하는 인터프리터는 멀티코어 프로세서에서 실행되는 경우에도 항상 하나의 스레드가 한 시에 실행되도록 허용합니다.
GIL을 사용하는 잘 알려진 인터프리터로 CPython과 Ruby MRI가 있습니다.

위의 정의를 기반으로 다시 정리해보면,
GIL은 프로세스당 하나의 기본 스레드만 실행될 수 있으며, 멀티코어 프로세서에서 실행되는 경우에도 하나의 스레드가 한 시에 실행되는 매커니즘입니다.

따라서 GIL 매커니즘이 적용되면, 멀티스레딩을 통한 병렬 작업이 제한되며 이를 통해 메모리 같은 하위 수준 세부 정보를 단순화하는데 도움이 됩니다.

아래 그림은 GIL 매커니즘을 적용했을 때, 3개의 스레드가 수행되는 흐름을 쉽게 나타낸 그림입니다.

수행 흐름을 차례차례 확인해보도록 하죠.

① 먼저 Thread1이 GIL을 획득한 채로 작업을 수행합니다.
② Thread1이 쓰기, 읽기 등의 I/O 작업 완료 후 GIL을 해제합니다.
③ Thread2가 GIL을 획득합니다.
④ Thread2가 쓰기, 읽기 등의 I/O 작업 완료 후 GIL을 해제합니다.
⑤ Thread3이 GIL을 획득합니다.
⑥ Thread3가 쓰기, 읽기 등의 I/O 작업 완료 후 GIL을 해제합니다.

해당 주기는 각 스레드의 작업이 완료될 때까지 주기적으로 반복되는 것이 GIL의 매커니즘입니다.
중요한 포인트 중 하나는 I/O 시점에 GIL이 해제된다는 것입니다.
(무조건 I/O 시점에 GIL이 해제되는 것일까요? 이는 끝까지 읽어보시면 아실 수 있습니다 :) )

GIL의 목적

CPython에서의 GIL 적용의 목적은 메모리 관리 시 발생할 수 있는 문제점을 해결하고자 함인데요,

파이썬은 메모리 관리 방법으로 reference counting을 사용합니다.
파이썬에서의 모든 것들은 객체(object)이며 각 객체마다 레퍼런스로 사용된 횟수를 저장하는데, 이를 reference counting 이라고 합니다.
0이 되는 경우에 메모리에서 해제합니다.

import sys
a = []
b = a
sys.getrefcount(a)

# 결과
3

sys.getrefcount 함수로 reference counting을 확인할 수 있는데요,
위 코드에서 빈 배열([])은 a, b, sys.getrefcount 에 의해 참조되고 있으니 refcount가 3인 것을 확인할 수 있습니다.

만약 여러 스레드가 동시에 refcount를 수정할 시에 race condition이 발생할 수 있습니다.
(race condition 설명은 다음 기회가 된다면 자세히 다뤄보도록 하겠습니다)

이 문제를 해결하기 위하여 전역 인터프리터의 Lock을 걸어 동시 접근할 수 없도록 제약을 걸어둔 것입니다.

GIL을 적용해둔다면 race condition 을 방지할 수 있는 장점이 있지만,
모든 cpu를 활용하는 프로그램이 멀티스레드로 병렬 처리를 할 수 없다는 단점이 있습니다.

오히려 멀티 스레드 적용시의 성능이 단일 스레드에서 수행했을 때보다 성능이 떨어지게 되는데요,

그 이유는 단일 스레드로 수행시 발생하지 않았던
스레드 간의 데이터나 정보를 주고 받는(context switching) 비용이 더 발생하게 되기 때문입니다.

Python에서 multi-thread는 무조건 성능을 낮출까?

그렇다면, GIL이 적용된 환경에서 멀티 스레딩은 단일 스레딩보다 무조건 성능이 낮아지는 것일까요?

결과적으로 그렇지는 않습니다.

I/O bound한 작업에서는 멀티 스레딩이 성능을 향상시켜 줄 수 있습니다.
I/O bound한 작업이란, I/O 작업이 주를 이루는 작업을 말합니다.
위에서 I/O 작업이 수행될 시에 GIL이 해제되고 다른 스레드에 할당된다고 말씀드렸는데요,

즉 I/O 작업이 주를 이루는 상황에서는 멀티 스레딩으로 I/O 작업 수행시에 다른 스레드에 작업을 할당할 수 있으므로, 성능이 향상될 수 있습니다.

반대로 단일 스레드 환경에서는 I/O 작업 수행시에도 다른 작업을 수행할 수 없으므로 성능이 향상되지 않는 것이죠.

아래 코드를 통해 쉽게 이해하실 수가 있습니다.
time.sleep 이 I/O 작업을 수행하는 것이라고 가정하고 시간을 측정해 보면 멀티스레드 환경에서 성능이 크게 개선된 것을 확인할 수 있습니다.

import time
import threading
import random

list_len = 10_000_000

def working():
	[random.random() for _ in range(list_len)].sort()
    time.sleep(0.1)
    [random.random() for _ in range(list_len)].sort()
    time.sleep(0.1)
    [random.random() for _ in range(list_len)].sort()
    time.sleep(0.1)
    [random.random() for _ in range(list_len)].sort()
    time.sleep(0.1)
    [random.random() for _ in range(list_len)].sort()
    time.sleep(0.1)
	return True

# 1. 단일 스레드 환경
start_time = time.time()
working()
working()
print(f"one thread --> {time.time() - start_time:.4f}s")

# 2. 멀티 스레딩 환경
threads = []
start_time = time.time()
for i in range(2):
	threads.append(threading.Thread(target=working))
	threads[i].start()
 

 for t in threads:
 	t.join()        # join 메소드 사용시, thread 수행될 때까지 기다림
    
print(f"multi-threading --> {time.time() - start_time:.4f}s")
# 결과
one thread --> 54.0949s
multi-threading --> 51.7987s

Python2 vs Python3 GIL 수행 매커니즘 비교

GIL 수행 매커니즘에 대해서 좀 더 알아보기 위해서
python2.7과 python3에서의 차이를 비교해가면서 이해해보도록 하겠습니다.

먼저 python2.7에서의 GIL 작동 매커니즘입니다.

먼저 특정 스레드가 GIL을 보유한 상태로 I/O 작업이 없는 순수 CPU 작업만이 있는 경우에는
Lock이 해제되지 않은 상태로 작업이 종료될 때까지 계속 진행됩니다.
다른 스레드는 계속 대기상태에 있는 것이죠.

이 때 GIL은 스레드가 대기 상태인지, I/O 작업을 수행 중인지 등을 모니터링하기 위하여 검사를 수행하는데, 모든 시간에 스레드를 검사하는 것이 아니라 tick 컨셉을 사용하여 모니터링을 합니다.

tick은 바이트 코드 명령어로써, 100 바이트 코드 명령어가 완료되면 GIL에서 스레드가 실행 중인지, 대기 상태인지를 확인합니다.

중요한 것은 tick은 바이트 코드 명령어이기 때문에 시간과 관련이 없습니다.
따라서 각 tick은 다른 tick에 비해서 실행 시간이 길거나 더 짧을 수 있는 것이죠.

100 tick마다 주기적으로 스레드를 모니터링 하는 것은 I/O작업이 없는 CPU 바운드 작업에서는 필수적입니다.

하지만 이러한 매커니즘은 몇가지 단점을 내포하고 있는데요,
첫 번째로는 특정 스레드의 작업이 끝날 때까지 무작정 기다려야 한다는 점입니다.
예를 들어 thread3은 thread1과 thread2가 모두 GIL을 해제할 때까지 기다려야합니다.

두 번째로는 어떤 스레드가 GIL을 획득할 것인지에 대한 우선순위가 없어
특정 스레드가 계속적으로 GIL을 획득하지 못해 문제가 발생할 수 있습니다.

예를 들어 처음에는 Thread1이 GIL을 얻은 다음, Thread2 -> Thread3 로 차례로 GIL을 얻을 수 있지만, 그 이후에는 모든 스레드에 알림을 보내며, 이는 누가 먼저 GIL을 가질지 모르는 상황이 됩니다.

이렇게 되면 Thread3만 계속 GIL을 획득하지 못해 작업이 진행되지 못하는 현상이 발생합니다.(이를 Starving 하다고 합니다)

이러한 단점을 해결하기 위하여 python3 이후에는 각 스레드에 고정 시간 5ms의 실행 시간이 할당됩니다.

즉 특정 스레드가 I/O 작업이 수행되지 않아도 5ms가 지나면 자동으로 GIL이 해제되고, 다른 스레드가 GIL을 가져갈 수 있는 것이죠.
스레드에 대해서 동일하게 적용되기 때문에 특정 스레드가 자원 할당이 부족하게 되는 현상을 막아줄 수 있습니다.

GIL을 우회하는 법

그렇다면 GIL을 우회하여 병렬 처리를 하는 방법은 어떤 것들이 있을까요?
멀티프로세싱을 사용하여 병렬 처리를 적용하거나,
Jython, PyPy 등의 GIL을 사용하지 않는 파이썬 구현체를 사용하면 GIL을 우회할 수 있습니다.

요약

지금까지 이해했던 내용을 간단히 요약해보면,

GIL은 프로세스당 하나의 기본 스레드만 실행될 수 있으며, 멀티코어 프로세서에서 실행되는 경우에도 하나의 스레드가 한 시에 실행되는 매커니즘입니다.
GIL이 적용된 환경에서 멀티 스레딩은 단일 스레딩보다 무조건 성능이 낮아지는 것은 아니며,
I/O bound한 작업에서는 멀티 스레딩이 성능을 향상시켜 줄 수 있습니다.

정도로 요약해 볼 수 있겠네요^^

이번 시간에는 GIL이 무엇인지 이해해 보았습니다.

다음 시간에는 본격적으로 코루틴(Couroutine)에 대한 내용을 이해해보면서 동시성 관리의 개념에 대해 이해해보도록 하겠습니다.

참조 블로그

이 글을 작성하기에 참조한 블로그는 다음과 같습니다.
Python Global Interpreter Lock Tutorial
GIL, Global interpreter Lock은 무엇일까?
파이썬 GIL이란?

profile
재빅의 빅데이터 여행 기록 저장소

1개의 댓글

comment-user-thumbnail
2023년 11월 27일

cpython( 씨파이썬) 과 cython(싸이썬) 은 다른건데 혹시 착오가 있었던 것은 아니신지

답글 달기