[목차]
[1] Python : GIL 에 대하여
[2] Python 에서 멀티쓰레드 구현 가능?
[3]Thread 성능 비교
[4] Python의 CPU-bound 동작에서 Thread 병목 현상
[5] 사내 프로젝트 개선 결과
최근 프로젝트 유지보수를 진행하면서 에러는 아니지만 기능개선할 부분들이 눈에 들어왔다. 그중에서도 가장 눈에 띈 것은 기존 로직의 속도 개선이다.
속도개선하는 방법으로 먼저 멀티쓰레드에 대해 알아보았다. 하지만 프로젝트가 어떤 환경으로 구성되어있는지에 따라 적용할수 있는 방식이 다를수 있으므로 대상 프로젝트에 기반하여 작업 방법을 모색해보았다.
[프로젝트 스펙]
- 언어 : Python
- 프레임워크 : Flask
- DB : NoSQL (MongoDB)
- Server : EC2
[1] Python : GIL 에 대하여
python은 언어 그 자체에서 GIL 이 적용되어 있다. GIL은 Global Interpreter Lock의 약자로 쉽게 말해서 언어자체적으로 싱글 쓰레드만을 지원한다는 뜻이다.
GIL이 생겨나게 된 배경에는 Python의 GC 동작 방식이 Reference count 방식이기 때문이다.
> GC
Garbage collector의 약자로 사용하지 않는 메모리를 반환하는 방식이다. python에서는 Reference count를 확인하여 GC가 동작하는데 Java의 경우 객체가 "도달가능한지" 를 판별하여 도달불가능한 객체인 경우 메모리를 회수하는 방식으로 동작한다.
> Reference count
참조횟수 카운트 방식이다. python에서는 모든 것이 객체이고 각 객체는 이것이 참조되는 횟수에 대한 데이터를 저장해 놓는다. 참조가 될때마다 이 수는 차감되고 최종 값이 0이 되면 해당 객체에 대한 메모리를 반환한다.
> 그래서 python 은?
멀티 쓰레드 환경이 될 경우 공유자원에 대한 reference count 관리가 복잡해진다. 뮤텍스와 같은 다양한 방식으로 이를 처리해 주어야 하는데, 이런 복잡한 상황을 예방하고자 애초에 python에는 GIL이 적용되어 한개 인터프리터에서는 한개 쓰레드만이 바이트코드에서 동작하도록 설계된 것이다.
[2] Python에서 멀티쓰레드 구현 가능?
그렇다고 해서 아예 멀티쓰레드 구현이 안되는건 아니다. 위에서도 말했다싶이 python에도 Treading 라이브러리가 존재한다. GIL이 적용되는 것은 로직이 CPU-bound 일때 적용되는 것이도 파일의 읽고쓰기와 같이 I/O - bound 작업에서는 해당 작업중 발생하는 대기시간 동안 다른 쓰레드가 동작할 수 있다.
즉, 어떤 로직인지 그 유형에 따라 적절하게 멀티쓰레드를 구현하면 원하는 개선효율을 얻을 수 있다.
[3] Thread 성능 비교
(1) CPU - bound
먼저 단순히 for loop를 이용한 로직에서 멀티쓰레드를 적용했을때와, 적용하지 않았을때를 비교해 보았다.
def only_count(n):
while n > 0:
n -= 1
big_num = 100000000 # 1억
start = time.time()
t1 = Thread(target=only_count, kwargs={"n": big_num//2})
t2 = Thread(target=only_count, kwargs={"n": big_num//2})
t1.start()
t2.start()
t1.join()
t2.join()
end = time.time()
print(f"[쓰레드 | 소요시간] {end - start} 초")
>>> [쓰레드 | 소요시간] 4.3975160121917725 초
start = time.time()
only_count(big_num)
end = time.time()
print(f"[일반 | 소요시간] {end - start} 초")
>>> [일반 | 소요시간] 4.546915769577026 초
1억의 숫자를 while 문을 이용해 반복했을때 그냥 호출했을때와 쓰레드 2개로 했을때 소요시간에 유의미한 차이가 나지 않는다. 오히려 멀티쓰레드로 동작했을때가 더 오래 걸리는 경우도 있었다.
(2) I/O - bound : time.sleep()
python의 time 라이브러리를 사용해 sleep을 걸게 되면 I/O bound 동작이므로 쓰레드 적용에 대한 효과를 볼수 있다.
> time.sleep()
time.sleep() 메서드는 특정 시간 동안 프로세스를 중지시키는 함수이다 이 함수를 호출하면 현재 스레드의 실행이 지정된 시간 동안 일시 중지되는데 이는 CPU 자원을 사용하지 않고, 단순히 대기하고 있기 때문에 I/O 바운드 작업으로 분류된다.
def countdown_sleep(n, t_num=None):
while n > 0:
if t_num:
print(f"Thread {t_num} >>> {n}")
else:
print(f"일반 >>> {n}")
n -= 1
time.sleep(1)
sleep_num = 10
start = time.time()
t1 = Thread(target=countdown_sleep, kwargs={"n": sleep_num//2, "t_num": 1})
t2 = Thread(target=countdown_sleep, kwargs={"n": sleep_num//2, "t_num": 2})
t1.start()
t2.start()
t1.join()
t2.join()
end = time.time()
print(f"[쓰레드 | 소요시간] {end - start} 초")
>>> [쓰레드 | 소요시간] 5.0233330726623535 초
start = time.time()
countdown_sleep(sleep_num)
end = time.time()
print(f"[일반 | 소요시간] {end - start} 초")
>>> [일반 | 소요시간] 10.040925979614258 초
sleep 을 주어 I/O 바운드 작업이 있는 로직에서는 두개의 쓰레드로 동작했을때 단일 쓰레드 보다 소요시간이 절반으로 줄어드는 것을 확인할 수 있었다.
한가지 동작을 두개로 나누어 진행시켰을때 시간도 절반이 될것! 이라는 기대가 충족되었다.
쓰레드가 동작하는 과정을 보기 위해 countdown_sleep
함수에서 print를 찍어보았다.
# 쓰레드 동작
Thread 1 >>> 5
Thread 2 >>> 5
Thread 1 >>> 4
Thread 2 >>> 4
Thread 2 >>> 3
Thread 1 >>> 3
Thread 2 >>> 2
Thread 1 >>> 2
Thread 2 >>> 1
Thread 1 >>> 1
# 일반 동작
일반 >>> 10
일반 >>> 9
일반 >>> 8
일반 >>> 7
일반 >>> 6
일반 >>> 5
일반 >>> 4
일반 >>> 3
일반 >>> 2
일반 >>> 1
두개의 쓰레드가 동시에 진행된 것을 볼 수 있다.
[4] Python의 CPU-bound 동작에서 Thread 병목 현상
CPU-bound 로직에서 단일쓰레드와 멀티쓰레드 동작을 비교해 보면 두개 처리시간이 같을때도 있지만 심지어 멀티쓰레드로 했을때 더 오래 걸리는 경우도 있다.
5000의 숫자를 단일쓰레드로 처리할때와 절반씩 나눠 멀티쓰레드로 처리했을때 소요시간을 측정해보았다.
def only_count(n):
while n > 0:
n -= 1
big_num = 5000
start = time.time()
t1 = Thread(target=only_count, kwargs={"n": big_num//2})
t2 = Thread(target=only_count, kwargs={"n": big_num//2})
t1.start()
t2.start()
t1.join()
t2.join()
end = time.time()
print(f"[쓰레드 | 소요시간] {end - start} 초")
>>> [쓰레드 | 소요시간] 0.00043082237243652344 초
start = time.time()
only_count(big_num)
end = time.time()
print(f"[일반 | 소요시간] {end - start} 초")
>>> [일반 | 소요시간] 0.00023221969604492188 초
멀티쓰레드로 구현했을때 단일 쓰레드보다 약 2배의 시간이 소요된 것을 확인할수 있는데 이는 python 의 GIL 방식으로 인해 쓰레드간 경쟁이 일어나 오버헤드가 발생했기 때문이다. 따라서 CPU-bound 로직에서의 멀티쓰레드는 구현하지 않는게 좋다.
> 오버헤드 & 콘텍스트 스위칭
멀티프로세스 에서 cpu가 실행중인 프로세스를 변경할때 콘텍스트 스위칭이 발생한다. 이때 레지스터에 저장된 메모리를 변경하는데 시간이 소요되면서 오버헤드가 발생한다고 한다. 멀티쓰레드의 경우 한개 프로세스 안에서 스택영역을 제외한 다른 메모리는 모두 공유하기 때문에 멀티 프로세스의 오버헤드보단 덜 하지만, 그럼에도 멀티쓰레드에서도 오버헤드가 발생한다.
[5] 사내 프로젝트 개선 결과
개선하려는 로직은 외부 api를 호출해서 데이터를 컨버팅 하고, 그중 이미지 데이터는 바이트코드로 변환하여 다양한 이미지 폼 (jpg, png등)과 이미지 리사이징을 거쳐 AWS S3에 업로드까지 하는 기능이다.
이중에서 외부 api 호출과 이미지 리사이징 등 다양한 I/O-bound 로직이 존재하기에 멀티쓰레드로 구현했을때의 속도개선을 기대할 수 있었다.
실제로 데이터 6개 기준 단일 쓰레드로 동작 시 약 96초 걸리던 동작이 두개 쓰레드로 동작해보니 약 48초로 단축된 걸 확인할 수 있었다.
단, 멀티쓰레드 방식을 도입하기엔 현재 두가지 문제점이 존재한다.
첫번째, CPU 사용량
멀티쓰레드로 동작시 해당 서버의 CPU 사용량이 단일쓰레드 대비 2배로 올랐다. (원래 단일 쓰레드 동작시에 5%이하였음)
테스트에서는 한명의 유저가 6개의 데이터를 동작한 것이지만 실제 운영환경에서는 백명의 사용자가 100개의 데이터 처리를 한번에 요청할 경우 현재 서버 스펙이 감당하지 못할 수 도 있을꺼라는 생각이 들었다.
좀더 안정적인 서비스 운영을 위해 기존 서버를 오토스케일링하게 하거나, 보다 자세히 기능 이용률을 파악하여 적절한 쓰레드 분배를 하는것이 필요해 보인다.
두번째, 외부 API의 호출제한
멀티쓰레드로 동작하는 내부 소스중에서는 쇼핑몰 api 를 호출하는 과정이 있다. 해당 api는 초당 5회의 호출 제한이 있기 때문에 호출량 조절이 아주 중요한데, 현재 단일쓰레드일 때에도 과한 호출 시 호출제한에 걸리는 경우가 있다.
이 상태에서 멀티쓰레드까지 적용하고 나면 호출량이 동일 시간 대비 쓰레드 갯수만큼 늘어나기 때문에 더욱 불안정해 질 것이다. 외부 호출 로직을 제외한 나머지 부분에서 멀티쓰레드를 적용해야 할까 싶다.
위 두가지 문제점을 해결 하기 위해 전체 과정 중 유난히 긴 시간을 소요하는 일부 로직에서만 멀티쓰레드로 구현하거나, 아님 이 부분을 비동기로 처리하거나 하는 해결방안을 생각해 보았다.
'공부로그' 카테고리의 다른 글
[AWS 공부하기] SQS와 SNS (0) | 2024.03.11 |
---|---|
플로이드-워셜 풀이가 궁금하다 : i와 j노드가 k로 연결되어있으면 i와 j도 연결되어있다. (0) | 2024.03.10 |
[Python] timedit 사용하여 자료형 변수 선언 방식 비교하기 : dict() vs {} (0) | 2024.02.01 |