POST

All
Product
Team
Tech
DocVLM: Make Your VLM an Efficient Reader
  • 최윤진
  1. Tech
Python 3.10 신규 문법 : Parenthesized context managers와 PEG Parser
  • S
    seunghoChoe
  1. Tech
UReader: Universal OCR-free Visually-situated Language Understanding with Multimodal Large Language Model
  • 최윤진
  1. Tech
[팀 소개편] KPMG Lighthouse는 어떤 팀인가요?
  • L
    Lighthouse
  1. Team
[챕터 소개편] Backend Chapter를 소개합니다
  • L
    Lighthouse
  1. Team
[챕터 소개편]Frontend Chapter를 소개합니다
  • L
    Lighthouse
  1. Team
[챕터 소개편] AI Chapter를 소개합니다
  • L
    Lighthouse
  1. Team

파이썬의 동시성 관리 : GIL 과 멀티 스레딩

Created by
  • 김원준
Created at
Category
  1. Tech

0. 들어가며

저번 시간에는 동시성. 즉, 제어권에 대한 Blocking/NonBolocking. 또 작업의 순서를 논하는 Sync/Async에 대해 간단하게 살펴보았다. 그렇다면 이번 시간에는 다음 순서로 파이썬의 GIL 및 스레드 환경에 대해 살펴보자.

0-1. 들어가기 앞서 !

오늘 발표를 조금 더 잘 이해할 수 있도록 하기 위해, 몇가지 용어에 대한 설명을 준비하였다.
💬
메모리란 ?
메모리는 컴퓨터가 프로그램과 데이터를 저장하고 처리하는 데 사용되는 저장 공간이다. 프로그램의 실행 중에 생성되는 데이터는 물리적인 메모리에 저장 되며(주로 RAM), 이 데이터는 스레드가 실행되면서 읽고 쓰게 된다.
💬
프로세스란 ?
실행 중에 있는 프로그램을 의미한다. 작업(Task)과 같은 의미로 쓰인다. 프로세스는 최소 하나의 스레드를 가지는데, 실제로 작업(Task)이 스레드 단위로 동작한다.
💬
스레드란 ?
스레드는 프로그램 내에서 실행되는 흐름의 단위. 일반적으로 한 프로그램은 하나의 스레드를 가지고 있지만, 프로그램의 환경에 따라 둘 이상의 스레드를 동시에 실행할 수 있다. 이것이 바로 멀티스레딩이다.
프로세스 구조
프로세스의 데이터와 명령어가 있는 영역은 Code(Text), Data, Stack, Heap이다.
💡각 프로세스는 별도의 공간(독립된 메모리)에서 실행되고 프로세스끼리는 자원의 공유를 하지 않는다. 그렇다보니, 프로세스 간의 자원을 공유하기 위해서는 별도의 통신이 필요하다.
스레드 구조
스레드는 Stack만 따로 할당받고, Code(Text), Data, Heap 영역은 프로세스의 자원을 공유한다.
💡자원을 공유하다보니, 시스템의 자원과 처리 비용이 멀티 프로세싱에 비해 적다. (통신의 부담X)
하지만 자원을 공유하고 있다 보니, 멀티 스레딩 환경에서 동기화의 문제가 발생할 수 있다.

1. GIL 이란?

1-1. GIL 의 정의

→ GIL은 Global Interpreter Lock의 약어로, 여러개의 스레드 중 단 1개의 스레드에서만 파이썬 코드를 사용 할 수 있게끔 잠금을 거는 것을 의미한다.
쉽게 말해서, 하나의 스레드만 파이썬 인터프레터를 제어할 수 있도록 하는 것을 의미한다.
더 쉽게말해서, 파이썬 프로그램은 오직 단 하나의 스레드에서만 실행된다.
이러한 GIL 때문에, 파이썬 멀티스레드 프로그래밍에서는 멀티스레드가 싱글스레드처럼 동작하는 성능 병목 현상을 발견할 수 있다.

1-2. GIL의 등장 배경

→ GIL의 등장(설계)배경은 간단하게 말해서 메모리 안전성을 보장하기 위함이다.
파이썬의 메모리 관리 방법 중 하나로 레퍼런스 카운트(Reference Counts)가 있다.
💬
레퍼런스 카운트 ?
레퍼런스 카운트란, 파이썬에서 생성된 객체가 객체를 가리키는 참조의 수를 추적하는 참조 카운트 변수를 가진다는 것을 의미한다.
즉, 레퍼런스 카운트 전략은 파이썬의 모든 객체에 카운트를 포함하고, 이 카운트는 객체가 참조될 때 증가하고, 참조가 삭제될 때 감소시키는 방식으로 작동된다. 이때 카운터가 0이 되면 가비지 컬렉터가 해당 객체를 메모리에서 삭제 시킨다.
import sys target = ['KPMG', 'Lighthouse', 'Center'] print(f'1 - reference count : {sys.getrefcount(target)}') copy_target1 = target print(f'2 - reference count : {sys.getrefcount(target)}') copy_target2 = target print(f'3 - reference count : {sys.getrefcount(target)}') ---output--- 1 - reference count : 2 2 - reference count : 3 3 - reference count : 4
이 레퍼런스 카운트 변수가 멀티 스레드 환경에서 두 스레드가 동시에 값을 늘리거나 동시에 값을 줄여버리는 Race Condition(경쟁 상태)이 발생할 수 있는 여지가 있다.
문제는, 이러한 상황이 발생하면 메모리의 누수가 발생하거나 객체의 대한 참조가 남아있는 데에도 메모리를 잘못 해제해버릴 수 있다.
💬
아 !! 파이썬의 GIL은 레퍼런스 카운트에 대한 Race Condition을 미리 방지하기 위해 도입 되었구나 !
멀티 스레딩 환경에서 동기화의 문제를 미리 방지하여 메모리 안정성을 보장하기 위한 디자인 이구나 !!
즉, 메모리를 취하고 성능을 잃는 것이다.

2. 싱글 스레드 vs 멀티 스레드

❓그렇다면, Python언어로 멀티 스레드 프로그래밍은 아예 불가능할까?
→ No ! 가능하다. threading 모듈을 활용하면 된다.
❓오잉, 멀티 스레드 프로그래밍이 가능하다면 무조건 멀티 스레드를 이용해야하는 거 아닌가 ??!
→ No ! 어떤 작업을 하느냐에 따라 다르다.
아래는 CPU Bound와 I/O Bound를 표현하기 위해 임의로 작성한 코드이다.
💬
CPU Bound Program
CPU 연산량이 많은 프로그램을 의미한다.
💬
I/O Bound Program
데이터 베이스 쿼리, 파일 입출력, 네트워크 통신 등과 같은 입출력 작업이 많은 프로그램을 의미한다.
❗해당 예시에서는, I/O작업을 한다고 가정하겠다. I/O작업 대기시간의 예시로 time.sleep(1) 함수를 이용하여 물리적으로 1초를 대기하도록 하겠다. (입출력 하는 시간)
❓time.sleep()은 I/O bound 작업이 아닌데요 ?!!!
→ 맞다. 둘 다 아니다. 아래에서 설명하겠다.

2-1. CPU Bound Program

import time import threading COUNT = 300000000 def countdown(n): while n>0: n -= 1 ----------단일 스레드------------- start_time1 = time.time() countdown(COUNT) end_time1 = time.time() print(f'1st Working : {end_time1 - start_time1:.5f}') start_time2 = time.time() countdown(COUNT) end_time2 = time.time() print(f'2nd Working : {end_time2 - start_time2:.5f}') ----------멀티 스레드------------- threads = [] start_time = time.time() for i in range(2): threads.append(threading.Thread(target=countdown, args=(COUNT,))) threads[i].start() for j in threads: j.join() end_time = time.time() print(f'Multi Working : {end_time - start_time:.5f}') ---output--- 1st Working : 13.41886 2nd Working : 13.12927 Multi Working : 26.65582
위/아래 프로그램 둘 다, 입력 받은 수를 1씩 감소시켜 0까지 도달하게 하는 프로그램이다. 처음부터 끝까지 CPU연산으로 이뤄지니 CPU Bound 프로그램이라고 할 수 있다. 이 두 프로그램의 시간을 각각 측정해보면 다음과 같다.
싱글 스레드 = 26.54809초
멀티 스레드 = 26.65582초
연산이 매우 간단하여, 근소한 차이지만 그래도 싱글 스레드의 속도가 빠르다.

2-2. I/O Bound Program

import time import threading COUNT = 30 def countdown(n): while n>0: n -= 1 time.sleep(1) while n>0: n -= 1 time.sleep(1) while n>0: n -= 1 time.sleep(1) while n>0: n -= 1 time.sleep(1) while n>0: n -= 1 time.sleep(1) ----------단일 스레드------------- start_time1 = time.time() countdown(COUNT) end_time1 = time.time() print(f'1st Working : {end_time1 - start_time1:.5f}') start_time2 = time.time() countdown(COUNT) end_time2 = time.time() print(f'2nd Working : {end_time2 - start_time2:.5f}') ----------멀티 스레드------------- threads = [] start_time = time.time() for i in range(2): threads.append(threading.Thread(target=countdown, args=(COUNT,))) threads[i].start() for j in threads: j.join() end_time = time.time() print(f'Multi Working : {end_time - start_time:.5f}') ---output--- 1st Working : 5.43227 2nd Working : 5.50752 Multi Working : 5.02122
해당 프로그램은 I/O Bound 프로그램의 예시로 위의 코드와 같은 코드 이지만, 함수 중간중간 time.sleep을 주어 I/O작업을 하는것으로 가정했다. 측정한 결과는 다음과 같다. (반복 작업이 많아 COUNT를 줄였다.)
싱글 스레드 = 10.93979초
멀티 스레드 = 5.02122초
CPU 작업에서는 아주 근소한 차이로 싱글스레드 작업이 더 빨랐는데, 이번에는 멀티 스레드가 더욱 빠르게 작업이 끝난걸 볼 수 있다. 게다가 차이도 꽤나 난다 !
❓왜 이럴까 ???
→ 이건 파이썬 인터프리터에서, I/O작업을 맞이할 때(다른 하드웨어 장치에 의존하는 작업)에 GIL을 해제하기 때문이다.(스레드 전환 가능)
잘 와닿지가 않는다. 한번 눈으로 확인해보자.
조금은 무식한 방법이지만, 눈으로 확인하고 싶기에 I/O Bound 코드에 현재 스레드를 print하는 코드를 추가해보았다.
def countdown(n): print(f"중간 체크 지점 0 - 현재 스레드: {threading.current_thread().name}") while n>0: n -= 1 print(f"중간 체크 지점 1 - 현재 스레드: {threading.current_thread().name}") time.sleep(1) print(f"중간 체크 지점 2 - 현재 스레드: {threading.current_thread().name}") while n>0: n -= 1 print(f"중간 체크 지점 3 - 현재 스레드: {threading.current_thread().name}") time.sleep(1) print(f"중간 체크 지점 4 - 현재 스레드: {threading.current_thread().name}") while n>0: n -= 1 print(f"중간 체크 지점 5 - 현재 스레드: {threading.current_thread().name}") time.sleep(1) print(f"중간 체크 지점 6 - 현재 스레드: {threading.current_thread().name}") while n>0: n -= 1 print(f"중간 체크 지점 7 - 현재 스레드: {threading.current_thread().name}") time.sleep(1) print(f"중간 체크 지점 8 - 현재 스레드: {threading.current_thread().name}") while n>0: n -= 1 print(f"중간 체크 지점 9 - 현재 스레드: {threading.current_thread().name}") time.sleep(1) print(f"중간 체크 지점 10 - 현재 스레드: {threading.current_thread().name}")
Single Thread
중간 체크 지점 0 - 현재 스레드: MainThread
중간 체크 지점 1 - 현재 스레드: MainThread
중간 체크 지점 2 - 현재 스레드: MainThread
중간 체크 지점 3 - 현재 스레드: MainThread
중간 체크 지점 4 - 현재 스레드: MainThread
중간 체크 지점 5 - 현재 스레드: MainThread
중간 체크 지점 6 - 현재 스레드: MainThread
중간 체크 지점 7 - 현재 스레드: MainThread
중간 체크 지점 8 - 현재 스레드: MainThread
중간 체크 지점 9 - 현재 스레드: MainThread
중간 체크 지점 10 - 현재 스레드: MainThread
1st Working : 5.43227
중간 체크 지점 0 - 현재 스레드: MainThread
중간 체크 지점 1 - 현재 스레드: MainThread
중간 체크 지점 2 - 현재 스레드: MainThread
중간 체크 지점 3 - 현재 스레드: MainThread
중간 체크 지점 4 - 현재 스레드: MainThread
중간 체크 지점 5 - 현재 스레드: MainThread
중간 체크 지점 6 - 현재 스레드: MainThread
중간 체크 지점 7 - 현재 스레드: MainThread
중간 체크 지점 8 - 현재 스레드: MainThread
중간 체크 지점 9 - 현재 스레드: MainThread
중간 체크 지점 10 - 현재 스레드: MainThread
2nd Working : 5.50752
Multi Thread
중간 체크 지점 0 - 현재 스레드: Thread-1
중간 체크 지점 1 - 현재 스레드: Thread-1
중간 체크 지점 0 - 현재 스레드: Thread-2
중간 체크 지점 1 - 현재 스레드: Thread-2

중간 체크 지점 2 - 현재 스레드: Thread-1
중간 체크 지점 3 - 현재 스레드: Thread-1
중간 체크 지점 2 - 현재 스레드: Thread-2
중간 체크 지점 3 - 현재 스레드: Thread-2

중간 체크 지점 4 - 현재 스레드: Thread-1
중간 체크 지점 5 - 현재 스레드: Thread-1
중간 체크 지점 4 - 현재 스레드: Thread-2
중간 체크 지점 5 - 현재 스레드: Thread-2

중간 체크 지점 6 - 현재 스레드: Thread-1
중간 체크 지점 7 - 현재 스레드: Thread-1
중간 체크 지점 6 - 현재 스레드: Thread-2
중간 체크 지점 7 - 현재 스레드: Thread-2

중간 체크 지점 8 - 현재 스레드: Thread-1
중간 체크 지점 9 - 현재 스레드: Thread-1
중간 체크 지점 8 - 현재 스레드: Thread-2
중간 체크 지점 9 - 현재 스레드: Thread-2

중간 체크 지점 10 - 현재 스레드: Thread-1
중간 체크 지점 10 - 현재 스레드: Thread-2
Multi Working : 5.02122
싱글 스레드는 곁눈질로만 훑어봐도 대충 어떻게 흘러갈지 예상이간다. 멀티 스레드는 뭔가 감이 잘 안온다.
해당 결과 한 눈에 볼수 있도록 흐름도를 만들어보았다.
싱글 스레드 흐름도
먼저 싱글스레드의 흐름도이다. 예상대로 메인스레드 단일로 작업이 수행되고, 2번째 작업은 1번째 작업이 끝난 뒤에야 작업을 시작한다.
멀티 스레드 흐름도
그 다음으로, 멀티스레드의 흐름도이다. 빨간색 선이 I/O (예제 코드 Sleep)이고, 해당 작업이 진행되는 시점에 스레드의 전환 (GIL 해제)이 이뤄져서 Thread-2가 작업을 수행한다.

time.sleep()은 I/O Bound가 아니라메 !!!

time.sleep()은 I/O작업이 아니다. 그럼에도 I/O라고 가정한 시점에 스레드가 변경이 될까?? 그걸 알아보기 위해서는 Python2와 Python3의 GIL이 해제되는(스레드가 전환되는) 매커니즘에 대한 이해가 필요하다.

2-3. Python2 / Python3 GIL 수행 매커니즘 비교

파이썬2 버전의 GIL 메커니즘
파이썬 2버전의 GIL 수행 매커니즘이다. 제어권을 가지고 있는 스레드에서 I/O작업이 발생하기 전까지 GIL을 해제하지 않고, 그 말은 즉슨 다른 스레드들은 무한정 대기를 해야한다는 뜻이다.
그렇기에, 코드 수행에서의 병목현상이 많이 발생하였고, 이 점은 파이썬 3버전에서 부터 개선되었다.
파이썬3 버전의 GIL 메커니즘
파이썬 2버전의 이러한 단점을 개선하기 위하여 파이썬 3버전에서는 멀티스레드 환경에서 제어권을 가진 스레드에 고정 5ms의 기본 실행 시간이 할당된다. (5ms가 지나면 GIL 해제 - 스레드 전환)
💬
우측 사진은 예제 코드에서 I/O 작업이라고 가정한 sleep을 제거한 코드이다.
오로지 CPU Bound 코드만이 존재하는데에도, 스레드가 전환되는 것을 확인할 수 있다.
💬
위에서 보여준 흐름도에서 sleep하는 시점에 스레드 전환이 이뤄진것도, countdown 함수를 5ms안에 수행하고 1초를 sleep하면서 5ms가 지나 스레드가 전환된 것이다.

❓그럼 단점도 개선되었으니, 멀티 스레드가 좋은거 아니야?
위에 sleep이 없는 코드의 결과에서 볼 수 있듯이, 멀티스레드 환경이 시간이 더 오래 걸렸다. I/O환경에서의 어쩔 수 없는 대기 시간에 스레드를 전환해서 다른 일을 수행한다면 당연히 효율적이겠지만, 내부 로직만 수행되는 CPU Bound 위주의 코드에서는 5ms의 기본 시간 뒤, 스레드를 전환하는 과정에서 컨텍스트 스위칭으로 인한 비용 발생으로 오히려 동작시간이 증가했다.

3. 결론

파이썬은 GIL라는 제약 때문에, 단 한개의 스레드에서만 코드가 수행된다.
GIL은 메모리의 안정성을 위해 도입되었다.
몇몇 개발자들은 GIL 제약이 너무 가혹 하다며 혀를 찬다고 한다.
그럼에도 개발된 Thread 모듈 덕분에 멀티 스레드 환경에서의 동작 수행이 가능하다.
하지만, “병렬성”이라는 알맹이가 빠진 “동시성”만 지닌….
I/O Bound 위주의 코드에서는 멀티 스레드가 유리하다.
CPU Bound 위주의 코드에서는 싱글 스레드가 유리하다.
과거 (파이썬 2버전)에는 스레드의 작업이 완료되거나 I/O로 인한 GIL해제가 되지 않았다면 다른 스레드들은 무한정 대기를 해야했다.
파이썬 3버전에서 5ms뒤에 스레드 전환으로 개선되었다. (기본시간. 조정 가능)
작년 하반기 GIL 제약을 없애버리는 작업에 착수 했다고 파이썬 개발 팀이 발표했다.

출처