본문 바로가기
Data Science

[Python 공부] Threading, GIL, Futures 모듈, ThreadPoolExecutor

by 한입만쥬 2023. 6. 20.

오늘은 조금 복잡한 파이썬 동시성 관리라는 개념에 대해 배웠습니다. 그래서 오늘 포스트는 순서가 조금 뒤죽박죽일 수도? 

'프로세스'와 '스레드'라는 개념에 대한 이해부터 Python GIL(Global Interpreter Lock), Coroutine, 유용한 모듈 (Futures, Asyncio)까지 방대한 내용인데 최대한 정리해 보도록 하겠습니다.


목차

  • 스레드
  • 스레드 생성 및 실행 방법 
  • join 함수 
  • GIL
  • Coroutine

스레드 Thread

파이썬 코드는 실행했을 때 위에서 아래로 순차적으로 실행됩니다. 조건문이나 반복문 등으로 실행 순서가 바뀔 수는 있지만 기본적으로는 순차적으로 진행되는 것을 알 수 있죠. 

하지만 하나의 작업이 오래 걸리는 것이라면 그것을 백그라운드에서 실행해두고 결과가 나오기 전까지 다른 작업을 하고 싶을 수 있습니다. (내가 계란 삶는 것을 기다리면서 설거지를 하는 것처럼!) 그렇게 하려면 코드를 병렬로 실행해야 하고, 이걸 가능하게 해주는 것이 바로 스레드입니다!

간단하게 생각하면 우리가 작성하는 파이썬 코드는 기본적으로 하나의 Main Thread에서 순차적으로 실행됩니다. 거기에 병렬로 실행하기 위해서 별도의 Subthread를 생성해서 실행하면 코드가 두 갈래로 나뉘면서 동시에 실행된다는 것입니다. 이론적으로는 두 갈래로 나뉘지만 실제로는 CPU의 시간을 나누어서 사용합니다! 

 

스레드 생성 및 실행

from threading import Thread
def bark(name, msg):
    for i in range(3):
        print(f'{name} : {msg} ({i}번째)')


thread01 = Thread(target=bark, args=('멍멍이1', '멍멍!')) # Thread 클래스에 함수 지정
thread02 = Thread(target=bark, args=('멍멍이2', '왈왈!'))
thread03 = Thread(target=bark, args=('야옹이1', '야옹!'))
thread04 = Thread(target=bark, args=('야옹이2', '냐냐!'))

# thread 실행시키기 
thread01.start()
thread02.start()
thread03.start()
thread04.start()
  1. Thread() 함수의 target 부분에 쓰레드가 실행할 함수를 작성한다
  2. args 키워드에 파라미터를 지정한다 
  3. Thread.start() 호출해서 스레드를 실행한다 

실행해 보면 매번 거의 다른 결과가 나옵니다. 위의 코드는 아주 간단하게 스레딩의 개념을 보여준 것인데, 코드가 살짝 복잡해지면 join 함수의 중요성을 깨닫게 됩니다. 

 

from threading import Thread

value = 0


def calc(name, start, end):
    result = 0
    for i in range(start, end):
        result += 1
        print(f'{name} : {i}')
    print(f'{name}: {result}')

    global value
    value += result


if __name__ == '__main__':
    print('hello~')

    t01 = Thread(target=calc, args=['t01', 1, 50])
    t02 = Thread(target=calc, args=['t02', 50, 101])

    t01.start()
    t02.start()

    print(f'result : {value}')
    
    
''' 결과:
hello~
t01 : 1
t01 : 2
t01 : 3
t01 : 4
t01 : 5t02 : 50

t01 : 6
t02 : 51
t02 : 52
t02 : 53
t01 : 7
t01 : 8
t01 : 9t02 : 54

t01 : 10
t01 : 11t02 : 55
t02 : 56
t02 : 57
result : 0 # <- 여기 주목!!

t01 : 12
t01 : 13
t01 : 14
t01 : 15
t01 : 16
t01 : 17
t01 : 18
t01 : 19
t01 : 20

# 나머지 결과값은 생략 
'''

 

위 코드의 의도는 스레드가 끝난 후에 마지막 줄에 있는 result 값을 출력하는 것입니다. 그런데 실행해 보면 스레드가 끝나기도 전에 result가 출력되는 것을 볼 수 있죠. 메인이 할 일은 스레드를 실행하고 result를 출력하는 것이기 때문입니다.

 

비유로 생각해보면 메인 스레드가 사장님이고, 실행 스레드들은 직원들이라고 보면 쉽게 이해됩니다. 사장님은 일을 시키고 직원들이 알아서 마무리하도록 하는 것이죠!

join 함수 사용하기

이 때 아래의 join 함수를 써주면 원하는 대로 출력할 수 있습니다. 

    t01.join()  # main thread가 다른 thread가 종료될 때까지 기다림. 
    # join이 걸린 애들끼리 동작하고(걸린애들끼리는 섞이기도 함) 나머지는 대기함
    t02.join()

    print(f'result : {value}')

join 함수를 써주면 메인 스레드가 자기 할 일을 바로 수행하는 것이 아니라, 실행시킨 스레드들이 일을 마칠 때까지 기다리게 됩니다. 그래서 이 상태로 다시 수행해 보면 원하는 대로 result를 마지막에 출력함으로 스레드가 몇 번 수행되었는지 셀 수 있게 됩니다!

 

daemon thread 데몬 쓰레드

daemon 속성을 True로 지정하면 데몬 스레드가 됩니다. 

    daemon = Thread(target=calc, args=(10000))

    # daemon thread : thread 를 보조하는 thread
    daemon.daemon = True    # daemon 지정하기
	daemon.start()

실행해 보면 데몬 스레드의 작업이 종료되지 않았을 때에도 다른 스레드가 종료되면 같이 종료된다는 것을 볼 수 있습니다. 

 


 

스레드 개념을 더 쉽게 이해할 수 있게 해 준 비유는 사무실을 생각하는 거였는데요, 아직 개념 이해가 안 되신 분들을 위해 공유드립니다: 

  • main thread = 사장님 (일을 시키고 자기 할 일을 다 하면 퇴근)
  • sub thread = 직원 (번갈아 가면서 일을 수행)
  • daemon thread = 사무실 불을 켜고 끄는 일을 하는 리셉션 지킴이 (다른 스레드가 하나라도 종료되지 않았으면 같이 남고, 자기 일이 끝나지 않았어도 다른 스레드가 모두 종료되었으면 같이 종료됨)

Concurrency = 동시적, 병렬적

  • context switching 은 동시성 프로세스 하나가 여러 개의 프로세스를 가지고 a를 조금 하다가, b를 조금 하다가, c를 조금 하는 것을 반복하면서 일부분씩 진행하는 것을 의미합니다. 그런데 이게 워낙 빠르게 흘러가다 보니 사람의 시각에서는 동시에 일어난다고 보이게 되는 것이죠. 

이렇게 컴퓨터는 여러 개의 프로세스를 함께 돌릴 수 있습니다.

우리가 컴퓨터로 게임을 다운로드하는 동시에 다른 프로그램들을 돌리고 다른 페이지들을 돌아다닐 수 있어야 하는 것처럼 한 프로그램 안에서도 여러 갈래의 작업들이 동시에 진행될 필요가 있습니다. 이게 바로 스레드인 거죠. 

 

프로세스(Process)를 이해하기 위한 예시 

비유로 생각해 보자면:

  • 프로세스 = 대량 주문이 들어오는 식당의 요리사
  • 프로세스 = 끊임없이 주문이 들어오는 메뉴 하나하나 (라면, 김밥, 햄버거 등)
  • 컴퓨터는 프로세스마다 자원을 분할해서 할당을 합니다 (예: 라면, 김밥, 햄치즈토스트 각각의 주방 공간(테이블)이 있는 거죠.)
  • 요리사가 1명이던 여러 명이던, 각각의 조리대마다 돌아다니면서 요리를 합니다.
  • 한 메뉴의 스레드들은 같은 조리대에서 진행됩니다. (토스트 조리대에서는 계란을 굽는 스레드가 진행되는 동안 빵에 잼과 소스를 뿌리는 스레드도 진행될 수 있겠죠! 왜냐하면 계란을 한 테이블에서 굽고 빵은 저쪽에서 준비하면 비효율적이니까요.)
  • 프로세스는 컴퓨터의 자원을 분할해서 사용하고 스레드는 프로세스마다 주어진 전체 자원을 함께 사용하는 것이라고 생각하면 됩니다!

프로세스 vs 스레드

global 변수를 지정해서 스레드가 진행될 때마다 1씩 증가한다고 생각해 보면?

스레드 두 개가 10번씩 진행되면 결과가 20이어야 하는데, 그보다 적은 숫자가 나오게 됩니다. 프로세스 안에서 공유되는 변수에 스레드 두 개가 동시에 손을 댈 수 있기 때문이죠. 


GIL (global interpreter lock)

  • 프로세스 당 한 번에 한 개의 기본 스레드만 실행될 수 있다!
  • 멀티 코어 프로세서에서 실행되더라도 하나의 스레드만 실행되도록 허용한다!

GIL의 개념은 이렇게 요약할 수 있는데요, 파이썬은 GIL으로 메모리 관리의 비효율을 막기 때문에 multi-threading을 할 경우에도 동시에 진행되는 것이 아니라 하나씩 번갈아 가면서 멈췄다가 실행되었다가를 반복한다고 볼 수 있습니다. 


내용이 많아 concurrent.futures를 활용하는 방법은 생략했습니다. 어려운 내용을 공부한 것이라 잘못되었거나 미흡한 설명은 댓글로 지적 부탁드립니다 :)