파이썬은 느리다.
우리가 파이썬을 처음 접했을때부터 많이 들어오던 말이다.
물론, 최근에는 파이썬 내부적으로도 많은 개선이 이뤄지고 있고, C++로 동작하는 다양한 라이브러리들이 많아지면서
속도에 대해 체감할 수 있는 수준의 발전이 있었다.
하지만, 여전히 저수준의 언어들 C, C++, Rust 에 비해서 느린것은 사실이다.
그렇다면, 파이썬은 왜 느리고, 어떻게 개선할 수 있을까?
물론, 다양한 원인과, 다양한 개선방안이 있겠지만,
그 중 대표적으로 I/O 바운드 로직에서 Python에서는 GIL의 존재로 인해,
병렬처리가 실질적으로 어려워 지연시간이 발생하는 문제를 그 중 하나로 꼽을 수 있다.
이때, 해결방법으로 보통 떠올리는것이 Threading을 활용한 해결일것이다.
다만, 모든 경우에 threading으로 해결할 수 있는것은 아니며,
Latency문제가 발생할 경우, Threading으로 해결할 수 있을지, 없을지 정확히 판단하는것이 우선일 것이다.
오늘은 Python에서 Thread를 효율적으로 활용하기 위해,
GIL(Global Interpreter Lock), Reference Count, GC(Garbage Collect)등의 개념을
이해하고, Thread를 어떤 경우에 잘 활용할 수 있는지에 대해 살펴보도록 하자.
Thread란?

먼저 Thread란 프로세스가 가진 실행 흐름의 여러 갈래중 하나를 의미한다.
쉽게 말해, 한 프로그램(프로세스) 안에서 동시에 여러 작업(실행 흐름)을 진행하기 위해 사용하는 기술을 말한다.
예를들어, 주방에서 여러 요리를 준비하는 상황에 빗대어 설명하면,
* 프로세스 : 식당 주방 전체를 하나의 ‘프로세스’라고 가정할 수 있다.
* 스레드 : 주방에서 일하는 여러 요리사 각각을 ‘스레드’로 비유할 수 있다.
즉, 하나의 주방(프로세스)에서 여러 요리사(스레드)가 동시에 다른 작업을 처리하니, 전체적인 조리 시간이 단축된다.
이때, 요리사(스레드)들은 동일한 재료나 도구(메모리 자원 등)를 공유해서 사용해야 하므로 충돌(race condition)을 피하고 위한
규칙(동기화, mutex, lock)이 필요하다.
아래는 스레드를 구현한 간단한 코드이다.
import threading
import time
# 스레드로 실행할 함수
def worker(name):
for i in range(3):
print(f"{name} 스레드, i={i}")
time.sleep(1)
# 메인 스레드
if __name__ == "__main__":
# 두 개의 스레드를 생성
t1 = threading.Thread(target=worker, args=("A",))
t2 = threading.Thread(target=worker, args=("B",))
# 스레드 시작
t1.start()
t2.start()
# 메인 스레드는 t1, t2가 끝날 때까지 기다림
t1.join()
t2.join()
print("모든 스레드 종료")
worker() 함수가 하는일은 단순히 메시지를 1초 간격을 3번 출력하는것인데, t1스레드와 t2스레드를 생성한 후 실행하면
worker(“A”)와 woker(“B”)가 동시에(또는 시분할) 실행되는것 처럼 동작한다.
이와 같이 Thread는 첫번째 작업의 완료를 기다리지 않고, 동시에 여러 작업을 수행할 수 있어 CPU 자원을 좀 더 효율적으로 사용할 수 있다는 장점을 가지고 있다.
(다만, 위와 같은 Python에서는 GIL로 인해, 실제로 동시 실행(병렬동작)은 아니다.)
자 그럼, Thread의 특징을 몇가지 살펴보자.
1) 메모리 공유
- 같은 프로세스 내부의 스레드들은 프로세스의 메모리 공간(힙, 전역변수 등)을 공유한다.
- 이는 스레드 간 데이터를 주고 받기 쉽다는 장점은 있지만, 서로 공유 자원을 동시에 수정하면 문제(동기화 이슈 등)가 발생할 수 있다
2) 독립된 실행 흐름
- 각 스레드는 자신만의 프로그램 카운터, 레이스터 세트, 스택을 가진다.
- 덕분에 동시에(또는 시분할 방식으로)다른 코드를 실행할 수 있다.
3) 가벼운 생성/소멸 비용
- 프로세스 하나를 새로 띄우는 것보다 스레드 하나를 생성하는 것이 일반적으로 자원 소모가 적고 빠르다.
- 여러 스레드를 활용하면, 프로세스를 여러 개 띄우는 것보다 효율적인 경우가 많다.
스레드가 사용되는 예시는 어떤것들이 있을까?
1) 동시처리(Concurrency) & 병렬 처리(Parallelism)
- 동시 처리 : 예를 들어, 서버가 동시에 여러 클라이언트 요청을 받을 때, 각 요청을 따로 처리하도록 스레드를 사용할 수 있다.
- 병렬 처리 : 멀티코어 CPU 환경에서 여러 스레드가 실제로 같은 시점에 병렬로 수행될 수 있다. 이를 통해 성능을 높일 수 있다.
* 예시 : 웹 서버
- Main 스레드가 새로운 클라이언트 요청이 들어오면, 스레드를 새로 생성하거나 스레드 풀에서 하나를 가져와서 요청을 처리하게 한다.
- 동시에 여러 요청이 들어와도, 각각이 독립된 스레드로 처리되므로, 대기 시간이 단축되고 빠른 응답이 가능하다.
2) 백 그라운드 작업
“서비스에서 중요한 작업이지만, 즉각적인 응답을 줄 필요가 없는 경우”
- 영상 인코딩 : 사용자가 동영상을 업로드하면, 웹 서버는 “업로드 완료”플래그를 바로 전달하고, 실제 동영상 인코딩 작업은 백그래운드 스레드에서 별도로 실행
- 데이터 집계/분석 : 시간마다 통계를 내거나 로그를 수집/분석하는 작업은 별도의 스레드로 처리할 수 있다.
3) 비동기 I/O 처리
- 네트워크 I/O나 디스크 I/O등 입출력 작업은 대기시간이 길수있는데, 이때, 하나의 스레드가 I/O 대기 때문엔 가만히 멈춰있으면 전체 시스템 효율이 떨어진다. 이때, 여러 스레드를 운용하면, 다른 스레드는 CPU 작업을 계속 이어서 수행할 수 있어 시스템 자원을 효율적으로 활용할 수 있다.
Thread의 장점은 병렬계산. 하지만, Python에의 Thread는 실제로 병렬적으로 수행되지 않는다?
파이썬에서는 GIL(Global Interpreter Lock)이 적용되는데, GIL은 한마디로 “동일한 시점에 둘 이상의 스레드가 Python 바이트코드를 동시에 실행하지 못하도록(동시에 2개 연산이 CPU에서 진행되지 않도록) Lock하는 장치를 의미한다.
결국 GIL로 인해, CPU연산이 많은 코드를 멀티 스레드로 작성하더라도 “실제로는 하나의 스레드만 CPU 연산을 수행하게 되기때문에 100%를 활용한 병렬 계산이 되지않는다.
"응? 그렇다면, Thread의 병렬계산 자체가 불가능하다는 의미가 아닌가? 그렇다면 multi Thread의 의미가 있는가?"
라고 생각할 수 있다.
조금 더 설명해보겠다.
GIL은 주로 파이썬 내부 구조와 메모리 관리(reference count)매커니즘 때문에 사용된다.
여기서 Reference Count란, 예를들어 어떤 객체(정수, 리스트, 클래스 인스턴스 등)를 참조하는 ‘변수’가 생기면 reference count 가
1증가하고, 변수가 사라지면(del) 1이 감소한다.
만약 reference count가 0이 되면, 해당 객체를 메모리에서 해제한다.
이와 같이 해당 방식을 통해 파이썬에서는 자동으로 메모리를 관리를 수행한다.
다만, 이러한 메모리 관리 방식에서도 순환참조(Circular Reference)의 경우에는 해결이 어려운데,
예를들어 A가 B를 참조하고, B가 A를 참조할 경우, reference count는 0이 될 수 없다.
이때에는 직접 garbage collect(gc.collect()) 매서드 호출을 통해 참조 관계를 초기화할 수 있다.
다시 돌아와서, 병렬 스레딩을 사용하면 위와 같은 Reference Count방식을 꼬이게 만들 수 있다.
만약, 객체에 대해 여러 스레드에서 동시 참조를 진행하게 될 경우, reference count가 잘못된 순서로 늘어나거나, 적어지는(데이터손상) 현상이 발생할 수 있는것이다.
이러한 문제을 예방하기 위해 "GIL이 동일 시점에 오직 하나의 스레드만이 파이썬 바이트코드를 실행하도록 제한하여,
문제를 예방하는 역할을 한다."
결국, 이러한 역할 및 특성 때문에, Python에서 실제로 병렬 스레딩 동작은 GIL로써 제한되는것이다.
(다만, C의 확장 라이브러리(Numpy, Pytorch, SciPy 등)를 활용할 경우 내부적으로 GIL을 해제한 채로 작업하기때문에 병렬계산이 가능하다)
다만, 그럼에도 병렬 연산이 제한되는 Python에서도 멀티 스레딩이 가지는 효과는 존재한다.
효과에 대해 이해하기 위해 먼저 아래에서 I/O바운드 작업, CPU바운드 작업에 대해서 이해해보자.
I/O 바운드 작업 - CPU 바운드 작업
- I/O바운드 작업 :
- 프로그램의 성능이 “입출력(I/O) 대기시간”에 의해 주로 결정되는 작업을 의미한다.
- CPU 바운드 작업 :
- 프로그램의 성능이 “CPU 연산”에 의해 주로 결정되는 작업으로 계산 자체가 복잡하고 많아서, CPU 사용이 100%에 가까울 정도로 바쁠때, 이를 CPU바운드 작업이라고 한다.
먼저, Python에서의 멀티 스레딩은 병렬 계산으로 진행되지 않기때문에, CPU바운드 작업의 케이스에 대해서는 개선효과가 없다.
앞서 설명했듯, Python에서는 GIL로 인해 멀티스레딩 이라 하더라도, 직렬로 처리된다.
예를들어, 1부터 10억까지 더하는 Python 반복문 프로세스를 2개의 스레드로 나눠서 작업하더라도 GIL때문에
한번에 하나의 스레드만 사용되고, 번갈아가며 작업이 수행된다. 결국, 싱글 스레드와 큰 차이가 없게되는 것이다.
다만, I/O바운드 작업에서는 효과가 있다.
파일 읽기/쓰기, 소켓 네트워크 통신, DB접근, time.sleep() 와 같이 I/O대기나 블로킹이 길다면,
해당 스레드는 GIL을 잠시 해제하거나, 최소한 CPU를 점유하지 않고, 대기 상태가 된다.
그사이 다른 스레드로 전환을 통해 CPU 를 잡고 작업할 수 있으므로, 동시성(Concurrency)을 사용할 수 있게 된다.
예를들어, 웹서버를 구현할떄, 파이썬으로 멀티스레드를 활용하게 되면, 하나의 요청이 DB의 응답을 기다리는 동안,
다른 요청을 처리할 수 있기 때문에 단일 스레드 보다 훨씬 응답성이 좋아질 수 있다.
Thread 전환은 언제되는가?(작업 분배)
마지막으로, 그렇다면 직렬 방식으로 계산되는 Python의 멀티스레드에서 각 스레드간의 스위칭은 어떤 방식, 기준으로 이루어지는걸까?
실제 실행은 시분할(time-slicing)방식으로 이루어진다. 즉, 하나의 스레드가 잠시 실행되었다가, 다른 스레드로 전환되는데,
1) 일정시간(또는 일정 바이트코드 수)이 지났을때,
2) 스레드가 I/O대기 이거나, time.sleep()과 같은 블로킹 동작으로 GIL을 해제할때,
3) 스레드가 명시적으로 threading이나, C확장 라이브러리 수준에서 GIL을 잠시 풀때가
그에 해당한다.
위의 시점을 기준으로 GIL이 다른 스레드로 넘어가게되고, 그 결과 스레드의 전환(Thread Switching)이 일어난다.
참고로, 스레드 전환의 기준시간은 sys.getswitchinterval(), sys.setswitchinterval(value) 으로 확인하고 변경할 수 있다.
기본 값은 0.005s정도이며, 해당 시간을 초과할 경우 다른 스레드가 GIL을 획득하게 된다.
Thread를 사용함에 있어 주의해야할 점
반면, Thread를 사용함에 있어, 단점 또는 주의할 점도 존재한다.
- 동기화 문제(Synchronization)
- 각 스레드가 하나의 자원(변수, 리스트, 파일, DB 등)에 대해 동시 참조를 통한 수정이 발생할 경우, 접근/수정의 순서가 엉켜 예상치 못한 결과가 발생할 수 있다.
- 동기화 기법
- Lock 또는 뮤텍스(Mutex) : 동시에 한 스레드만 임계 구역(critical section)에 들어가도록 보장하는 방법
- 세마포어(Semaphore) : 카운팅이 가능한 락 개념(동시에 N개 스레드만 접근 가능)
- 조건 변수(Condition Variable) : 특정 조건을 만족할 때까지 스레드가 대기하고, 조건이 만족되면 알림(notifiy)으로 깨운다.
- 이외에도 Event, Pipeline, Queue 기반의 병렬 처리 등의 기법이 존재한다.
- 데드락(Deadlock)
- 데드락이란, 서로 다른 스레드가 서로가 가진 자원을 기다리며 무한 대기 상태에 빠지는 현상이다.
- 예를들어 스레드 A가 락1을 획득하고, 락2를 기다리는 중이고, 스레드 B가 락2를 획득하고, 락1을 기다리는 상황("교착 상태")
- 이 상태에선 양쪽 모두 락을 얻지 못해 진행이 불가능하므로, 프로그램이 멈춰버린다.
lockA = threading.Lock()
lockB = threading.Lock()
def thread_a():
with lockA:
time.sleep(0.1) # 일부러 딜레이를 줘서 스레드B가 lockB를 먼저 잡게 만듬
with lockB:
print("A 작업 수행")
def thread_b():
with lockB:
time.sleep(0.1)
with lockA:
print("B 작업 수행")
thread_a는 LockA를 잡은 뒤 LockB를 잡으려 하고, thread_b는 lockB를 잡은 뒤 lockA를 잡으려한다. 이때, 둘다 상대방이 가진 락을 기다리는 상태가 돼 무한정 정지가 된다.
- 스레드 수 관리
- 스레드가 너무 많을 때의 문제
- 문맥 전환(Context Switch) 오버헤드 : 스레드가 늘어날수록 CPU가 실행 스레드를 전환하는 데 드는 비용이 커지는 문제
- 메모리 사용량 증가 : 각 스레드는 고유 스택 메모리를 가지고, 스레드 구조체 등의 관리 리소스를 차지하는데, 이로 인한 리소스 비용
- 데드락*동기화 문제가 복잡해짐 : 스레드가 많아지면 동기화 로직이 늘어나고, 디버깅이 어려워진다.
- 해결방법
- Thread Pool
- 일정 개수의 스레드를 미리 만들어두고, 작업이 들어올 때 스레드 풀에서 유휴 스레드를 가져와 일을 처리하는 방식
- 작업이 많아도 스레드 수를 무작정 늘리지 않고, 적절한 개수로 제한하는 방식
- 비동기 I/O나 이벤트 기반
- I/O가 많은 작업은 스레드 대신 비동기(Async/Await), 코루틴 방식으로 동시성을 확보하는 경우도 많음.
- Thread Pool
- 스레드가 너무 많을 때의 문제
- 디버깅의 복잡성
- 멀티 스레드 디버깅의 어려움
- 실행 순서가 매번 다름 : 스레드 간 실행 타이밍이 예측 불가능하므로, 특정 타이밍에서만 발생하는 버그(타이밍 버그)가 재현이 어렵습니다.
- 경쟁상태(Race Condition) 재현 : 로컬 환경에서 수십 번 테스트해도 안 나타나다가, 실제 환경(고사양 서버)에서만 발생할 수도 있음
- Lock 및 공유 자원 추적 : 어떤 스레드가 어떤 자원을 언제 잡고 놓는지 추적하기가 복잡합니다.
- 개선방안
- 로깅(Logging)강화 : 각 스레드가 자원 획득/해제 시점을 자세히 로그로 남겨, 문제가 생겼을 때 역추적이 가능하도록 한다.
- 단위+스테레스 테스트 : 스레드 관련 코드를 가능한 작은 단위로 테스트하고, 동시 접속/스트레스 테스트툴을 이용해 버그 조기 발견
- Deadlock Detection : 특정 시점에 어떤 락이 누구에게 점유되어 있는지 확인하는 모니터링(디버거, 프로파일러 툴 사용)
- 알고리즘적 해결 : 불변 객체(immutable)나 Lock-Free)알고리즘을 통해 동기화 부담을 줄이는 방식
- 멀티 스레드 디버깅의 어려움
오늘은 Python에서의 Thread의 역햘 및 효과에 대해 관련 개념들과 함께 알아보았다.
Thread는 CPU자원을 효율적으로 사용하여, 프로그램의 성능을 향상시킬 수 있는 좋은 수단이 되지만,
실제 체감할 수 있는 성능 향상을 위해서는 Python하에서의 Thread의 동작 원리를 이해하고, 실제로 효과를 거둘 수 있는 케이스에 적절히 활용할 수 있어야 하겠다.
'DEVELOP_NOTE > Python' 카테고리의 다른 글
| Garbage Collection(가비지 컬렉션, GC)은 어떻게 동작할까? (0) | 2024.02.22 |
|---|---|
| Python Decorator @ 사용방법 완벽 이해하기! (0) | 2024.02.07 |
| Python Generator (a.k.a. 'yield') (2) | 2024.02.06 |
| [REFACTORING] dictionary에 'key' 존재 유무에 따른 데이터 채우기 (0) | 2024.02.05 |