Share
Sign In
Lighthouse Dev
파이썬의 동시성 관리 : GIL 과 멀티 스레딩
김원준
❤️
2
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 제약을 없애버리는 작업에 착수 했다고 파이썬 개발 팀이 발표했다.
출처
Kp
Subscribe to 'kpmg-lighthouse'
Welcome to 'kpmg-lighthouse'!
By subscribing to my site, you'll be the first to receive notifications and emails about the latest updates, including new posts.
Join SlashPage and subscribe to 'kpmg-lighthouse'!
Subscribe
❤️
2
김원준
Python의 Decorator
0. 들어가며 이번 시간에는, 파이썬의 데코레이터에 대해서 발표해보도록 하겠다. 1. Decorator 란? 파이썬을 사용하다보면, 함수 혹은 메서드 위에 “@” 골뱅이가 붙은 특이한 이름을 본 적이 있을 것이다. 이러한 특이한 문법을 decorator라고 하는데, 영어 사전에서 decorator는 “장식자”이라는 뜻을 가지고 있다. 파이썬의 decorator 역시 동일한 의미로 사용된다. 어떤 함수가 있을 때, 해당 함수를 직접 수정하지 않고, 함수에 기능을 추가 /변경 / 확장 하고자 할 때 decorator를 사용한다. decorator는 함수를 인자로 받고, 또 다른 함수를 반환하는 고차함수 이다. 고차함수 ? 함수를 인자로 받아서, 함수를 반환하는 함수 파이썬에서 함수는 일급 객체로 취급되기 때문에 가능하다. 일급객체 ? → 아래에서 살펴보자. 그렇다면, 도대체 어떤 기능 이길래 추가 /변경 / 확장 모두 가능한 것일까? 지금부터 알아보자. 1.1. 데코레이터 기본 예시
😀👍🏻
2
donggyun_woo
SQLAlchemy 알아보기
시작 하면서 최근 KRM ver2.0 프로젝트를 시작하면서 웹서버를 구현하기 위해 SQLAlchemy를 사용하면서 모델링을 했습니다. 작업을 하면서 SQLAlchemy에 대해 공부한 내용을 공유해 보겠습니다. SQLAlchemy? SQLAlchemy는 파이썬을 위한 SQL 툴킷 및 Object-Relational Mapping(ORM) 라이브러리로, 데이터베이스와의 상호작용을 보다 쉽고 직관적으로 할 수 있게 도와줍니다. SQLAlchemy는 데이터베이스 연결 관리, 트랜잭션 관리, 스키마 정의 및 관리 등 다양한 기능을 제공합니다. SQLAlchemy 구조 크게 Core와 ORM으로 나뉘어져 있습니다. Core (SQL Expression Language): SQLAlchemy의 Core는 SQL 표현 언어를 사용하여 데이터베이스 쿼리를 생성하고 실행하는 데 중점을 둡니다. SQLAlchemy Core는 SQL을 직접 작성하고 데이터베이스와 상호작용할 수 있는 저수준 인터페이스를 제공합니다. Engine 데이터베이스와의 연결을 관리하는 핵심 구성 요소입니다. Engine 객체는 데이터베이스와의 상호작용을 위한 연결 풀(Connection Pool) 및 데이터베이스 드라이버를 포함하여 SQLAlchemy의 다른 부분과의 통신을 조정 Engine의 주요 기능 데이터베이스 연결 설정: Engine은 데이터베이스 URL을 통해 데이터베이스 연결 정보를 설정합니다. 연결 풀 관리: Engine은 연결 풀을 관리하여 데이터베이스 연결의 효율성을 높이고 성능을 최적화합니다. 연결 풀은 데이터베이스 연결을 재사용하여 연결 생성 및 소멸에 따른 오버헤드를 줄입니다. 트랜잭션 관리:
👍🏻
1
Lighthouse_JSH
Airflow 트러블슈팅 공유
안녕하세요! 오늘은 제가 Airflow를 사용하면서 겪었던 몇 가지 트러블슈팅 경험을 공유하고자 합니다. 제 경험이 여러분의 Airflow 작업에 도움이 되기를 바랍니다. 디렉토리 구조 제가 구성한 Airflow의 디렉토리 구조는 다음과 같습니다. 추가한 디렉토리에서 import 시 에러 발생 새로운 디렉토리인 config와 db에서 import하려고 시도했을 때 import 오류가 발생했습니다. 이는 프로젝트 경로를 path에 추가하여 해결했습니다. path를 추가하며 운영 환경과 개발 환경의 경로가 다르기 때문에 다른 문제가 발생했는데 이는 아래에서 다시 설명드리겠습니다. plugins와 dags/utils 처음에는 azure_conn과 slack_message 두 개의 스크립트를 plugins 디렉토리에 저장했습니다. 하지만 GCP Composer 서비스에서는 dags 폴더 하위에 폴더를 생성하도록 권장하기 때문에 코드 재사용성을 위해 두 스크립트를 dags/utils 디렉토리로 이동했습니다. plugins 디렉토리는 Airflow 시스템의 기본 기능 확장에 사용합니다. 출처: https://cloud.google.com/composer/docs/how-to/using/installing-python-dependencies?hl=ko#use_packages_that_depend_on_shared_object_libraries API 호출 에러 사례 스케줄러나 웹 UI를 통해 DAG를 실행할 때 Negsignal.SIGSEGV 오류가 발생하여 requests 호출이 중단되었습니다. 로컬에서 테스트 실행해 보니 문제 없이 동작했지만, 이 오류는 이미 GitHub 디스커션에 등록되어 있었습니다. 출처: https://github.com/apache/airflow/discussions/24463#discussioncomment-4404542 원인은 명확하게 밝혀지지 않았지만, macOS에서는 프록시 정보를 가져오는 시스템 호출이 특정 조건 하에서 예상치 못한 방식으로 작동하여 메모리 접근 오류인 SIGSEGV(세그먼테이션 폴트)를 발생시키는 것으로 추측됩니다. 해결방법은 dag에 아래의 코드를 추가해 프록시 설정을 초기화하였습니다. 실행 환경 차이로 인한 코드 불일치 위 에러들로 인해 실행 환경과 개발환경 간 코드가 불일치했습니다. 이를 해결하고자 config/airflow_config/config.json을 두어 실행 환경을 읽어 환경에 따라 같은 코드로 다르게 동작하도록 수정했습니다. Connections 등록 후 PW를 까먹은 경우