본문 바로가기

고민과 성장 일기

[Python] Multiprocessing과 Threading 에 관한 고찰

※ SW 마에스트로 수료 이후 기술 블로그 활동 시작을 통해 과거 부터 고민했던 내용이나 해결했던 내용, 이후 공부했던 내용 등을 합쳐, 꾸준히 작성하지 못한 내용들을 담고 있습니다.

 


시작하기에 앞서...코드를 보면 time.sleep(0.5) , time.sleep(0.1) 하는 부분이 보인다. 당시 병렬처리시 데이터가 꼬이거나 동시성 문제가 발생해서 넣었던 것으로 기억한다. 그만큼 나는 컴퓨터 과학에 대한 지식이 부족했고... 호기심만 많던 개발자였다.

 

1. 프로젝트 소개

과거, 2021년 4학년 1학기 때 League of Legends 전적 API를 통해 최근 30게임을 전적갱신 API를 개발[1]한 경험이 있다.  

컴퓨터 과학에 대한 지식도, 각종 IDLE 사용법도 익숙치 않던 터라 뭐든 부족한 상태에서 진행하였다.

정말 아무런 개발 공부도 배움도 받아본적 없이 개인적으로 보안 학술동아리와 병행하고 직접 공부하고 찾아보며 개발을 했기에 지금 보았을 때는 정말 어지럽다고 느껴지는 코드들이다.

 

테스트의 경우, 윈도우 서버에서 4Core 8GB RAM의 노트북으로 진행하였다.(사양은 정확히 기억이 안난다.)

 

테스트 예시:) 프론트 엔드 개발자가 API에 대한 전적에 대해 만들어 주었다.

잘 보면 빠진 부분이 군데군데 있지만 정말 깔끔한 하나의 전적검색 사이트 처럼 보인다!

 

2. 그러나 API를 호출하면서 문제가 생겼다.

여기서 말하는 API는 전적갱신 API를 말하는 것이며, 해당 API는 한번에 하나의 게임을 불러오고 데이터를 가공한다.
그래서 for문을 통해 해당 API를 30번 호출 하도록 만들었다.(최근 30게임)

아무리 윈도우 서버에 느린 속도로 최근 30게임의 API를 가져왔지만 거대한 데이터를 가져오는 만큼 30초의 시간이 걸렸다. 이를 해결하기 위해 병렬처리를 하기로 생각하였고 multiprocessing 모듈을 사용하였다. 당시 영감을 얻었던 블로그[2]이다.

from multiprocessing import Pool
#multiprocessing 모듈을 불러온다.

pool = Pool(os.cpu_count())
#돌릴 수 있는 프로세스 갯수를 선언한다.

renewal=[]
for q in range(allgamecount):
	renewal.append((resgame1["matches"][q]["gameId"],SummonerName,api_key,q,accountId))
    #갱신할 게임이 끝난 게임 고유의 번호 ID와 소환사 명, 요청할 api key, 몇번째 게임으로 갱신할지(q), 계정 고유 Id
results = pool.map(multi_run_wrapper,(renewal))
#map 함수를 통해 병렬 처리를 실행한다.

다음은 API 코드중 프로세싱을 해야하는 중요 코드들을 각각 가져오고 주석을 달았다.

 

간단하게 설명하자면

1. 4개의 코어(개발을 진행한 노트북의 코어 수)가 각각 프로세스를 할당 받아 게임을 불러오는 API를 호출한다.(처음은 1, 2, 3, 4번째의 게임)

2. API를 불러오는 일이 먼저 끝난 프로세스가 데이터를 돌려준다. 이후 다시 할당 받아 다음 게임을 불러오는 API를 수행한다.

3. 30게임을 모두 가져오게 되면 멀티 프로세싱 과정은 종료되고 불러온 모든 값들을 results 변수에 저장한다.

 

직렬 처리를 할 때에는 30초의 시간이 걸렸으나

병렬 처리를 할 때에는 15초의 시간이 걸렸다.

 

3. 1/4이 아닌 1/2만큼의 처리속도가 된 이유는?

먼저 1개의 프로세스가 30초의 시간이 발생하였다. 그렇다면 4개의 코어에서 완벽하게 병렬 처리가 가능하다면, 작업 시간이 7~8초 정도로 줄어들 줄 알았지만 15초가 발생하였다.

당시에는 이유를 몰랐으나 이후 여러 레퍼런스를 찾아보던 도중 도움이 되는 글[3]을 보게 되었다.


글의 내용을 요약하면 다음과 같다.

해당 글의 테스트는 다음과 같이 하나의 Task를 두고 여러 코어가 협력해서 동시에 처리하는 프로그램을 작성하였다.

 

1. for문을 4만번 진행해야 하는 프로그램

singleProcessing 으로 4만번 처리한 속도가 multiProcessing 을 통해 여러 코어로 나누어 처리(예: 4core일 경우 4개의 코어가 1만번 연산)한 것 보다 훨씬 빨랐다고 한다.

 

그 이유는 다음과 같다.

Multiprocessing을 진행하기 위해서는 사전작업이 필요한데, 이를 Overhead라 부른다. 10000번 더하는 연산은 단일 process가 처리하기에 그리 큰 task가 아니기 때문에 Overhead로 인한 시간이 더 소요되었기 때문이 이런 결과가 나온 것이라 예상된다.

 

**오버헤드(overhead)는 어떤 처리를 하기 위해 들어가는 간접적인 처리 시간 · 메모리 등을 말한다.

 

2. for문을 100억번 진행해야 하는 프로그램

Core개수(2,3,4)만큼 성능이 좋아지는 것을 확인했다고 한다.

 

이를 통해 알게된 것은 무조건 빠른 연산을 위해 병렬처리를 하는게 좋은것이 아니다.

Core 수, 오버헤드, 어떤 작업인지 등을 종합적으로 고려하여 판단해야 한다.


내가 작성한 전적갱신 API도 이와 비슷한 상황이다.

 

막상 한 게임당 필요한 정보를 파싱할 때 for문은 100번이 채 돌지 않는다. 각 병렬 처리를 진행하는 함수에서 RIOT에 API 호출도 2번밖에 하지 않는다. 그 외에는 데이터를 가공하여 { Key : Value } 형식으로 리스트에 넣을 뿐이다.

그렇기에 오히려 멀티프로세싱의 효율이 최대로 나오지 않은 것이라고 생각한다.(오버헤드가 일어나는 3가지 이유는 밑에 쓰여있다.)

 

4. 그렇다면 또 궁금해진 것! 오버헤드는 왜 일어날까?

오버헤드는 다음과 같은 상황에서 일어난다.

  • 프로세스나 쓰레드 간에 일어나는 Context Switching
  • 프로그램 실행을 위한 메모리 할당
  • 입출력 발생시(네트워크, 디스크 등에 접근)

왜 일어나는지 다음 그림을 보자

다음 그림은 해당 블로그[4]에서 참고하였다.

프로세스는 다음과 같은 5가지 작업이 존재한다.

 

1. 생성(created) 상태: 프로세스가 생성된 직후의 상태입니다. 이 상태에서는 아직 실행이 시작되기 전이므로, 프로세스가 할당받을 자원이 부족한 경우에는 이 상태에서 대기하게 됩니다.

2. 준비(Ready) 상태: 프로세스가 실행을 위해 필요한 모든 자원을 할당받았지만, CPU를 사용할 수 없는 상태입니다. 이 상태에서는 다른 프로세스가 CPU를 사용하고 있으므로, CPU를 기다리는 상태입니다.

3. 실행(Running) 상태: CPU를 할당받아 실행 중인 상태입니다. 이 상태에서는 프로세스가 실제로 CPU를 사용하고 있는 상태이며, 해당 프로세스가 처리해야 하는 작업을 수행하고 있습니다.

4. 대기(Waiting) 상태: 실행 중인 프로세스가 입출력 작업 등의 이유로 CPU를 사용할 수 없는 상태입니다. 이 상태에서는 CPU를 기다리는 대신, 다른 작업을 수행하고 있습니다.

5. 종료(Terminated) 상태: 프로세스가 실행을 마치고 종료된 상태입니다. 이 상태에서는 프로세스가 사용하던 자원을 운영체제에 반환하고, 해당 프로세스가 사용한 메모리 공간 등을 정리합니다.

 

기본적으로 Context Switching은 프로세스가 "준비->실행" 상태로 바뀌거나 "실행->준비, 실행->대기" 상태로 바뀔 때 발생한다.

 

병렬처리 진행 과정을 보자.

 

1. 4개의 Process 1, Process 2, Process 3, Process 4를 이용해 병렬처리를 시작하도록 프로세스를 생성한다.(생성)

2. 프로세스가 생성되었고 작업을 시작하라는 함수를 호출한다. (admitted)

3. Process 1을 scheduler dispatch 한다. running 상태가 되었다. 되는 동안 Process 2도 시작하라고 명령을 해야 한다.

4. 이 때 Context Switching이 발생되고 프로세스나 스레드의 현재 상태를 저장하고 다음 실행할 프로세스의 상태를 읽거나 복원한다.

5. Process 2도 scheduler dispatch 한다. 이렇게 남은 Process 까지 진행한다.

6. Process 중 하나가 작업이 끝났다. exit하고 다시 새로 프로세스를 만든다.

( 프로젝트에서는 API 호출 후 파싱이 끝나서 데이터를 넘겨주는 상태)

 

7. 그러나 multiprocessing 모듈에서는 끝나면 exit 하지 않고 프로세스 풀에 남아 준비상태가 된다.(이를 통해 같은 작업을 여러 번 처리할 때, 매번 프로세스를 생성하지 않고 재사용하여 성능을 향상시킨다.)

 

 


5. 보통은 하나의 프로세스 내에서 멀티 Threading을 사용할텐데 왜 multiprocessing을 사용하였나

부끄럽지만, 앞서 말했듯 컴퓨터 과학에 대한 지식이 부족한 상태라고 하였다. 어떤 방법을 쓰든 병렬처리로 빠르게 가져올 수 있는 방법만 생각하였다. 그래서 가장 눈에 띄였던 도움이 되는 글[3]만 보고 프로세싱을 사용하였다.

 

이 후 공부하여 알게 된 것[5]은 파이썬은 GIL(Global Interpreter Lock) 이라는 것이 있고 GIL은 인터프리터가 한번에 하나의 스레드만 수행할 수 있도록 lock을 거는 기능이라는 것이다.

이는 파이썬의 메모리 관리 및 객체 참조에 대한 안정성을 보장 때문이라고 한다.

 

파이썬 객체는 GC(Garbage Collection)를 위해, reference count를 가지고 있는데, 해당 객체를 참조할 때마다 reference count를 바꾸어야 한다.

파이썬 GC 그림 예제[6]

 

파이썬 GC 그림을 잘 보면 ref count 와 Link가 보인다. 그리고 자신의 객체를 가리키고 있는 화살표 갯수만큼 카운트가 증가되어 있다.

맞다. 레퍼런스 카운팅(Reference Counting) 방식을 사용하고 있다.

 

레퍼런스 카운팅이란, 객체가 참조될 때마다 해당 객체의 참조 횟수를 증가시키고 참조가 해제되면 감소 시킨다. 이를 통해 참조가 모두 해제 되어 0이 되면 메모리에서 해제 되는 방식으로 이는 메모리에서 해제되는 시점을 정확히 파악할 수 있어서, 메모리 누수 방지 및 객체의 생명주기를 정확히 추적할 수 있는 장점이 있다.

그러나 레퍼런스 카운팅은 순환 참조 발생시 메모리 누수가 발생한다.(그림에서는 link 4)

 

따라서, 레퍼런스 카운팅과 순환 참조 검사(Cycle Detection) 방식을 같이 사용함으로써 메모리를 관리한다고 한다.

 

아마, GIL을 사용하는 이유는 위와 같은 간단하고 빠른 방법으로 GC 관리를 하고 있기에 만약 동시 접근을 통한 메모리 참조시 Thread - safe 하지 않은 GC에 메모리 누수가 발생할 수 있어서 라고 생각한다...

 


그러나 Python에서도 멀티 스레딩이 가능하다고 한다. 이는 CPU bound 작업과 IO bound 작업으로 나뉜다.

수행되어야 하는 어떤 작업이 python runtime과 상호작용을 해서 CPU 작업을 집중적으로 사용해야 할 때는 GIL의 영향을 받고

상호작용을 하지 않으면 GIL의 영향을 받지 않는다고 한다.

 

CPU bound - CPU가 빠르면 더 빨라질 수 있는 작업 혹은 프로그램(주로 CPU 자원을 많이 사용하는 작업, 연산 처리 등)

IO bound - IO 대기 시간이 많은 작업 혹은 프로그램(데이터를 읽고 쓰는 동안 대기시간이 발생하는 작업들, File, DB, Network 등)

 


6. 위 내용들을 종합적으로 보았을 때 어떻게 개선할 수 있었을까?

 

* 이렇게 공부하고 더 알고보니 내가 만들었던 전적검색 API는 CPU bound(데이터 파싱)도 IO bound(request.get을 통한 API 호출, 데이터 가공하는 작업 위주)였으니 멀티 쓰레딩을 사용 할 수 있었다!

 

* 공부를 하고 나니 종합적으로 보았을 때 조금 더 효율적으로 처리할 수 방법은 다음과 같다.

1. 멀티 프로세싱 대신 멀티 쓰레딩을 사용한다.

2. 쓰레드(예: 4개)를 사용한다고 하면 불러오는 전적갯수(30)을 쓰레드 갯수로 나눠 (8개,8개,7개,7개)로 모두 처리하게 한 뒤 return 한다. 

3. API는 각 쓰레드에서 한번(8개,8개,7개,7개)에 불러온 뒤 가공한다.

4. 전적갯수에 따라 쓰레드가 처리하는 갱신 횟수도 잘 조절(예 99개의 전적을 불러와야 할 경우 25개,25개,25개,24개 등)해준다.


그 외...

추가 사항

오랜만에 api key를 발급 받고 실행해 보았는데 account id는 발급 되었으나 이후 api 호출시 403 상태 코드가 발생한다. 그래서 내부 로직에서 500에러가 뜬다. 왜 이런지 모르겠다..

 

문제 사항

1. 위 코드에서 보았듯 하나도 예외 처리가 안되있음.

2. 변수명 이름을 코딩테스트 하는 사람 처럼 규칙 없이 지어 놓음(협업시 문제 발생, 변수 네이밍 규칙 공부 필요)

3. 각 함수별 단일 책임화 혹은 해놔야함.(하나의 함수에 로직이 길게 늘여져 있음)

4. 더 간결한 코드 작성 가능해야함

예:)

def match_id(accountid):
    if (accountid == ''):
        return 1
    else:
        return 0

위 코드를 아래 코드로 변경하면 더 깔끔하다.

def match_id(accountid):
    return not accountid == ""

그 외 등등...

 

마치며

주저리 주저리 말이 많았다. 옛날에 개발해 보았던 프로그램인데 지금 생각해보니 코드가 정말 엉망이다 라고 느끼고 당시에는 모르던 내용도 많이 공부하고 또 모르는건 더 찾아보고 알아가는 과정을 겪어 가며 많이 성장했음을 느낀다.

 


[1] https://github.com/fourjae/LoL-RecordSearch-API

 

GitHub - fourjae/LoL-RecordSearch-API: 롤 전적검색 알고리즘 구현

롤 전적검색 알고리즘 구현. Contribute to fourjae/LoL-RecordSearch-API development by creating an account on GitHub.

github.com

[2] https://yganalyst.github.io/data_handling/memo_17_parallel/

 

[Python] 병렬처리(Multiprocessing)를 통한 연산속도 개선

Pandas dataframe의 연산속도 개선을 위해 병렬처리를 활용해보자

yganalyst.github.io

[3] https://devocean.sk.com/blog/techBoardDetail.do?ID=163779

 

Python Multiprocessing에 대한 고찰

 

devocean.sk.com

[4] https://doorbw.tistory.com/26

 

운영체제 #1_ 스레드와 프로세스, 멀티프로그래밍,멀티태스킹,멀티스레딩,멀티프로세싱

안녕하세요. 문범우입니다.이번 포스팅에서는 스레드(Thread) 와 프로세스(Process) 에 대해서 알아보겠습니다.스레드에 대해 좀 더 명확히 이해하기 위해서는 먼저 프로세스에 대한 개념이 필요합

doorbw.tistory.com

[5] https://hwanseok-dev.github.io/python/python3-GIL/

 

[Python] GIL은 CPU-Bound I/O-Bound의 문제가 아니다.

Python runtime과 상호작용을 하는지 여부가 중요하다.

hwanseok-dev.github.io

[6] https://devguide.python.org/internals/garbage-collector/index.html

 

Garbage Collector Design

Author, Pablo Galindo Salgado,. Abstract: The main garbage collection algorithm used by CPython is reference counting. The basic idea is that CPython counts how many different places there are that...

devguide.python.org

 

공부하면 좋을 자료

https://learn.microsoft.com/ko-kr/dotnet/standard/parallel-programming/potential-pitfalls-in-data-and-task-parallelism#9 운영체제 | 컴퓨터 프로세스간 통신(IPC) | Inter-Process Communication

'고민과 성장 일기' 카테고리의 다른 글

[Git] 프로젝트 git flow 전략  (0) 2023.06.12