상위 목록: 하위 목록: 작성 날짜: 읽는 데 16 분 소요

쓰레드(Thread)

쓰레드(Thread)는 프로세스에서 실행되는 흐름의 단위를 의미합니다.

일반적으로 프로그램은 하나의 쓰레드(메인 쓰레드)를 갖고서 알고리즘이 진행됩니다.

만약, 한 번에 두 가지 이상의 작업을 처리하기 위해선 두 개 이상의 쓰레드를 구동해야 합니다.

이러한 실행 방식을 멀티쓰레드(Multi-Thread)라 부릅니다.

같은 프로세스의 쓰레드들은 메모리(변수)를 공유하므로, 데이터를 주고받기 쉽지만 동시에 같은 데이터를 변경할 때 주의해야 합니다.


프로세스(process)는 실행 중인 프로그램 하나를 의미하며, 운영체제로부터 독립된 메모리 공간을 할당받습니다.

쓰레드는 프로세스 안에서 코드를 실제로 실행하는 작업자에 비유할 수 있습니다. 지금까지 작성한 코드는 모두 작업자 한 명(메인 쓰레드)이 위에서 아래로 차례대로 실행했습니다.

작업자가 한 명이면, 파일을 내려받는 동안 다른 일을 할 수 없고 내려받기가 끝날 때까지 기다려야 합니다.

쓰레드를 여러 개 만들면, 한 작업자가 기다리는 동안 다른 작업자가 다른 일을 진행할 수 있습니다.


이번 강좌에서는 threading 모듈로 쓰레드를 만들고 실행하는 방법, 쓰레드의 실행 순서가 매번 달라지는 이유, 쓰레드가 끝나기를 기다리는 join, 데몬 쓰레드, Python 쓰레드의 성능을 결정하는 GIL, 여러 쓰레드가 같은 변수를 바꿀 때 생기는 경쟁 상태와 Lock, 그리고 ThreadPoolExecutor와 자유 쓰레드 빌드를 다룹니다.



서브 쓰레드(Sub-Thread)

메인 쓰레드 외에 추가로 만든 쓰레드를 서브 쓰레드라 부릅니다.

서브 쓰레드는 실행할 함수를 지정해 만들며, 시작하면 메인 쓰레드와 별개로 그 함수를 실행합니다.


import threading
import time


def first_task(data):
    for i in data:
        print("first_task :", i)
        time.sleep(0.1)


def second_task(data1, data2):
    for i, j in zip(data1, data2):
        print("second_task :", i, j)
        time.sleep(0.1)


task1 = threading.Thread(target=first_task, args=(range(5),))
task2 = threading.Thread(target=second_task, args=(range(5), range(5)))

print("START")
task1.start()
task2.start()
print("END")
결과
START
first_task : 0
second_task : 0 0
END
first_task : 1
second_task : 1 1
first_task : 2
second_task : 2 2
first_task : 3
second_task : 3 3
first_task : 4
second_task : 4 4

멀티 쓰레드를 구성하기 위해선 메인 쓰레드 외에 서브 쓰레드를 구성해야 합니다.

서브 쓰레드를 사용하면 기존의 직렬 구조에서 여러 작업을 동시에 진행하는 구조로 연산이 가능합니다.

쓰레드를 사용하기 위해선 threading 모듈을 포함시킵니다.

쓰레드는 threading 모듈의 Thread 클래스로 생성하고, start() 메서드로 실행시킬 수 있습니다.

쓰레드 클래스는 threading.Thread(target=함수, args=(함수의 매개변수))입니다.

args에는 튜플을 전달하며, 하나의 매개변수를 전달하는 경우, 쉼표(,)를 추가해 튜플로 설정합니다.

target에는 first_task()처럼 함수를 호출한 결과가 아니라 first_task처럼 함수 자체를 전달합니다. 괄호를 붙이면 메인 쓰레드에서 함수가 바로 실행되어 버립니다.


출력 결과에서 확인할 수 있듯이 print("END") 구문이 서브 쓰레드의 작업이 끝나기 전에 출력됩니다.

메인 쓰레드는 서브 쓰레드를 실행시킨 뒤 기다리지 않고 다음 코드를 진행하기 때문입니다.

또한 first_task 함수와 second_task가 별도의 쓰레드에서 번갈아 가며 연산되는 것을 알 수 있습니다.


실행 과정을 순서대로 보면, 메인 쓰레드가 START를 출력하고 task1.start()와 task2.start()로 두 쓰레드를 시작시킵니다.

start()는 쓰레드를 시작시키기만 하고 바로 반환하므로, 메인 쓰레드는 곧바로 END를 출력합니다.

그 사이 두 서브 쓰레드는 각자 첫 번째 값을 출력하고 0.1초 동안 대기합니다. 대기가 끝나면 다음 값을 출력하고 다시 대기하므로, 두 쓰레드의 출력이 번갈아 나타납니다.

메인 쓰레드의 코드는 END에서 끝났지만, 프로그램은 서브 쓰레드가 모두 끝날 때까지 종료되지 않습니다.

  • Tip : 예제의 time.sleep(0.1)은 작업 사이에 대기 시간을 두어 쓰레드가 번갈아 실행되는 모습을 확인하기 위해 사용했습니다. 대기 시간이 없다면 짧은 작업은 다른 쓰레드가 시작되기 전에 끝날 수 있습니다.


import threading


def task():
    current = threading.current_thread()
    print("작업 쓰레드 :", current.name)


print("메인 쓰레드 :", threading.current_thread().name)

t = threading.Thread(target=task, name="Worker-1")
print(t.is_alive())
t.start()
t.join()
print(t.is_alive())
print(threading.active_count())
결과
메인 쓰레드 : MainThread
False
작업 쓰레드 : Worker-1
False
1

threading.current_thread()는 지금 이 코드를 실행하고 있는 쓰레드를 반환합니다.

같은 함수라도 어느 쓰레드에서 호출했는지에 따라 결과가 달라지며, 메인 쓰레드의 이름은 MainThread입니다.

Thread(..., name="이름")으로 쓰레드에 이름을 지정할 수 있으며, 지정하지 않으면 Thread-1 (task)처럼 자동으로 이름이 붙습니다. 이름을 지정해 두면 출력이나 로그에서 어느 쓰레드의 작업인지 구분하기 쉽습니다.

is_alive()는 쓰레드가 실행 중인지 반환합니다. 시작하기 전과 작업이 끝난 뒤에는 False를 반환합니다.

threading.active_count()는 현재 실행 중인 쓰레드의 개수를 반환하며, 서브 쓰레드가 끝났으므로 메인 쓰레드 하나만 남아 1이 출력됩니다.


import threading


def task():
    print("실행")


t = threading.Thread(target=task)
t.start()
t.join()

try:
    t.start()
except RuntimeError as e:
    print("RuntimeError:", e)
결과
실행
RuntimeError: threads can only be started once

하나의 Thread 객체는 한 번만 시작할 수 있습니다.

작업이 끝난 쓰레드를 다시 start()하면 RuntimeError가 발생하므로, 같은 작업을 다시 실행하려면 Thread 객체를 새로 생성합니다.



쓰레드의 실행 순서(스케줄링)

앞의 예제를 여러 번 실행하면, first_task와 second_task의 출력 순서가 실행할 때마다 조금씩 달라지는 것을 확인할 수 있습니다.

코드는 같은데 결과가 달라지는 이유는, 어느 쓰레드를 언제 실행할지 프로그램이 아니라 운영체제가 결정하기 때문입니다.


CPU 코어 하나는 한 순간에 하나의 작업만 실행할 수 있습니다.

운영체제는 실행할 준비가 된 여러 쓰레드를 아주 짧은 시간씩 번갈아 실행하여, 사람이 보기에는 동시에 실행되는 것처럼 만듭니다.

이처럼 어떤 쓰레드를 언제, 얼마 동안 실행할지 정하는 일을 스케줄링(scheduling)이라 부르며, 이를 담당하는 운영체제의 기능을 스케줄러(scheduler)라 부릅니다.


스케줄러는 다른 프로그램의 쓰레드까지 포함한 컴퓨터 전체의 상황을 보고 순서를 정합니다.

그 순간 다른 프로그램이 CPU를 얼마나 사용하고 있는지, 대기(sleep)가 정확히 언제 끝났는지 등은 실행할 때마다 다르므로, 쓰레드가 실행되는 순서와 시점도 매번 달라집니다.

예를 들어, 두 쓰레드가 모두 0.1초 대기를 마쳤을 때 어느 쪽이 먼저 깨어나 출력할지는 정해져 있지 않습니다.

그러므로, 멀티 쓰레드 프로그램은 쓰레드의 실행 순서에 의존하지 않도록 작성해야 합니다.


import threading


def work(name):
    for i in range(3):
        print(name, i)


threads = [threading.Thread(target=work, args=(name,)) for name in ["A", "B", "C"]]
for t in threads:
    t.start()
for t in threads:
    t.join()
결과
A 0
A 1
A 2
B 0
B 1
B 2
C 0
C 1
C 2

이번에는 대기 시간 없이 세 개의 쓰레드가 각각 세 번씩 출력합니다.

결과를 보면 쓰레드가 번갈아 실행되지 않고, A가 모두 끝난 뒤에 B, C가 실행되었습니다.

각 쓰레드의 작업이 매우 짧기 때문에, 다음 쓰레드가 시작되기 전에 앞의 쓰레드가 작업을 모두 마쳐 버린 것입니다.

쓰레드를 사용했다고 해서 항상 번갈아 실행되는 것은 아니며, 작업의 길이와 스케줄링에 따라 순서대로 실행된 것처럼 보일 수도 있습니다.

이 결과 역시 보장된 순서가 아니므로, 작업량이 많아지거나 컴퓨터의 상황이 달라지면 출력이 섞일 수 있습니다.


import sys

print(sys.getswitchinterval())
결과
0.005

일반적인 Python에서는 아래에서 다룰 GIL 때문에 한 번에 하나의 쓰레드만 Python 코드를 실행합니다.

그러므로, Python은 한 쓰레드가 오래 실행되면 일정 시간마다 다른 쓰레드에게 실행 차례를 넘기도록 요청합니다.

sys.getswitchinterval()은 이 간격을 초 단위로 반환하며, 기본값은 0.005초(5밀리초)입니다.

다만, 실행 차례를 넘겨받을 쓰레드를 실제로 고르는 것은 여전히 운영체제의 스케줄러이므로, 순서는 예측할 수 없습니다.

  • Tip : 쓰레드의 실행 순서는 운영체제가 결정하므로, 출력 순서는 실행할 때마다 달라질 수 있습니다. 여러 쓰레드가 동시에 print를 호출하면 한 줄 안에서 출력이 섞일 수도 있습니다.



쓰레드 대기(join)

메인 쓰레드는 서브 쓰레드를 시작시킨 뒤 기다리지 않고 다음 코드를 실행합니다.

하지만 서브 쓰레드의 작업 결과를 사용하거나, 모든 작업이 끝났다는 메시지를 출력하려면 서브 쓰레드가 끝날 때까지 기다려야 합니다.


import threading
import time


def first_task(data):
    for i in data:
        print("first_task :", i)
        time.sleep(0.1)


def second_task(data1, data2):
    for i, j in zip(data1, data2):
        print("second_task :", i, j)
        time.sleep(0.1)


task1 = threading.Thread(target=first_task, args=(range(5),))
task2 = threading.Thread(target=second_task, args=(range(5), range(5)))

print("START")
task1.start()
task2.start()

task1.join()
task2.join()
print("END")
결과
START
first_task : 0
second_task : 0 0
first_task : 1
second_task : 1 1
second_task : 2 2
first_task : 2
second_task : 3 3
first_task : 3
second_task : 4 4
first_task : 4
END

쓰레드.join() 메서드는 해당 쓰레드의 작업이 끝날 때까지 기다립니다.

모든 서브 쓰레드에 join()을 호출하면, 서브 쓰레드의 작업이 모두 끝난 다음 print("END")가 실행됩니다.

first_task와 second_task의 출력 순서는 실행할 때마다 달라질 수 있지만, END는 항상 마지막에 출력됩니다.

결과에서도 세 번째 줄부터 second_task가 먼저 출력되는 등 앞의 예제와 순서가 다른 것을 확인할 수 있습니다.


task1.join()은 task1이 끝날 때까지 메인 쓰레드를 멈추게 하며, 그동안 task2는 계속 실행됩니다.

task1이 끝난 뒤 task2.join()을 호출할 때 task2가 이미 끝났다면 바로 반환하고, 아직 실행 중이라면 끝날 때까지 기다립니다.

그러므로, 여러 쓰레드에 join()을 호출하는 순서는 결과에 영향을 주지 않습니다.


start()와 join()을 같은 반복문 안에서 번갈아 호출하면 안 됩니다.

t.start() 직후 t.join()을 호출하면 그 쓰레드가 끝날 때까지 다음 쓰레드를 시작하지 못하므로, 결국 하나씩 차례대로 실행하는 것과 같아집니다.

예제처럼 모든 쓰레드를 먼저 start()한 다음, 모든 쓰레드에 join()을 호출합니다.

  • Tip : join(timeout=초)로 최대 대기 시간을 설정할 수 있습니다. 시간이 지나도 쓰레드가 끝나지 않았다면 join은 그냥 반환되므로, is_alive()로 종료 여부를 확인합니다.


import threading

result = []


def square(n):
    result.append(n * n)


t = threading.Thread(target=square, args=(5,))
t.start()
t.join()
print(result)
결과
[25]

Thread의 target 함수가 값을 return해도, 그 반환값을 Thread 객체에서 가져올 방법이 없습니다.

그러므로, Thread 클래스를 사용할 때는 쓰레드들이 공유하는 변수에 결과를 저장하고, join()으로 작업이 끝나기를 기다린 다음 결과를 읽습니다.

join() 없이 result를 바로 출력하면, 쓰레드가 아직 값을 추가하지 않아 빈 목록이 출력될 수 있습니다.

  • Tip : 반환값을 쉽게 받으려면 아래의 ThreadPoolExecutor를 사용합니다.



데몬 쓰레드

로그를 주기적으로 기록하거나, 상태를 계속 확인하는 쓰레드처럼 프로그램이 살아 있는 동안 계속 실행되는 작업이 있습니다.

이런 쓰레드는 끝나지 않으므로 일반 쓰레드로 만들면 프로그램이 종료되지 않습니다. 이때 사용하는 것이 데몬 쓰레드입니다.


import threading
import time


def first_task(data):
    for i in data:
        print("first_task :", i)
        time.sleep(0.1)


def second_task(data1, data2):
    for i, j in zip(data1, data2):
        print("second_task :", i, j)
        time.sleep(0.1)


task1 = threading.Thread(target=first_task, args=(range(5000),), daemon=True)
task2 = threading.Thread(target=second_task, args=(range(5), range(5)))

print("START")
task1.start()
task2.start()
print("END")
결과
START
first_task : 0
second_task : 0 0
END
first_task : 1
second_task : 1 1
first_task : 2
second_task : 2 2
first_task : 3
second_task : 3 3
first_task : 4
second_task : 4 4
first_task : 5

데몬 쓰레드(Daemon-Thread)란 데몬이 아닌 쓰레드가 모두 종료되면 함께 종료되는 쓰레드를 의미합니다.

일반 쓰레드는 메인 쓰레드의 코드가 끝나더라도 연산을 끝까지 진행하며, 프로그램은 데몬이 아닌 쓰레드가 모두 종료될 때까지 기다립니다.

하지만, 데몬 쓰레드는 기다리지 않으므로, 프로그램이 종료될 때 작업이 끝나지 않았더라도 연산을 중단합니다.

threading.Thread(..., daemon=True)로 생성하거나, start() 호출 전에 daemon 속성의 값을 True로 설정하면 데몬 쓰레드가 됩니다.


예제의 task1은 5000번 반복해야 하는 데몬 쓰레드이며, task2는 5번 반복하는 일반 쓰레드입니다.

메인 쓰레드는 END를 출력한 뒤 일반 쓰레드인 task2가 끝나기를 기다립니다.

task2가 종료되면 프로그램이 종료되므로, 데몬 쓰레드인 task1은 first_task : 5 근처에서 중단됩니다.

중단되는 위치는 실행할 때마다 달라질 수 있습니다.

task1을 일반 쓰레드로 만들었다면, 5000번의 출력이 모두 끝날 때까지 약 500초 동안 프로그램이 종료되지 않습니다.

  • Tip : daemon의 기본값은 False입니다. (데몬 쓰레드에서 생성한 쓰레드는 True를 물려받습니다.)

  • Tip : 데몬 쓰레드는 강제로 중단되므로, 파일 쓰기와 같이 정리 작업이 필요한 작업에는 사용하지 않습니다.



전역 인터프리터 잠금(GIL)

쓰레드를 여러 개 사용하면 작업이 더 빨리 끝날 것이라 기대하기 쉽습니다.

하지만 Python의 쓰레드는 작업의 종류에 따라 빨라지기도 하고 전혀 빨라지지 않기도 합니다. 그 이유가 GIL입니다.


import sys
import threading
import time


def count(n):
    total = 0
    for i in range(n):
        total += i
    return total


N = 5_000_000

start = time.perf_counter()
count(N)
count(N)
print(f"순차 실행 : {time.perf_counter() - start:.2f}초")

start = time.perf_counter()
threads = [threading.Thread(target=count, args=(N,)) for _ in range(2)]
for t in threads:
    t.start()
for t in threads:
    t.join()
print(f"쓰레드 2개 : {time.perf_counter() - start:.2f}초")

print(sys._is_gil_enabled())
결과
순차 실행 : 0.91초
쓰레드 2개 : 0.99초
True

일반적인 Python(CPython)에는 전역 인터프리터 잠금(GIL, Global Interpreter Lock)이 존재합니다.

GIL은 한 번에 하나의 쓰레드만 Python 코드(바이트코드)를 실행하도록 제한하는 잠금 장치입니다.

그러므로, 쓰레드를 여러 개 생성하더라도 Python 코드는 번갈아 가며 실행될 뿐, 여러 CPU 코어에서 동시에 실행되지 않습니다.


예제는 반복 계산(CPU 연산)을 순차적으로 두 번 실행한 시간과, 두 개의 쓰레드로 동시에 실행한 시간을 비교합니다.

GIL로 인해 쓰레드를 사용하더라도 실행 시간이 거의 줄어들지 않는 것을 확인할 수 있습니다.

두 쓰레드는 GIL을 주고받으며 번갈아 실행되므로 전체 계산량은 순차 실행과 같고, 쓰레드를 전환하는 비용 때문에 오히려 조금 더 느려질 수도 있습니다.

sys._is_gil_enabled()는 현재 GIL이 활성화되어 있는지 반환하며, 일반 빌드의 Python 3.14.6에서는 True를 반환합니다. (Python 3.13 이상)

  • Tip : 실행 시간은 컴퓨터 성능과 실행 환경에 따라 달라지며, 같은 컴퓨터에서도 실행할 때마다 조금씩 달라집니다.


GIL이 필요한 이유는 Python 인터프리터 내부의 데이터를 보호하기 위해서입니다.

Python은 객체마다 몇 곳에서 사용 중인지(참조 횟수)를 기록하여, 더 이상 사용하지 않는 객체의 메모리를 해제합니다.

여러 쓰레드가 이 기록을 동시에 바꾸면 값이 틀어져 메모리가 잘못 해제될 수 있으므로, GIL로 한 번에 하나의 쓰레드만 인터프리터를 사용하도록 하여 이런 문제를 간단하게 막습니다.


import threading
import time


def download(name):
    time.sleep(1)
    return name


start = time.perf_counter()
for name in ["A", "B", "C"]:
    download(name)
print(f"순차 실행 : {time.perf_counter() - start:.2f}초")

start = time.perf_counter()
threads = [threading.Thread(target=download, args=(name,)) for name in ["A", "B", "C"]]
for t in threads:
    t.start()
for t in threads:
    t.join()
print(f"쓰레드 3개 : {time.perf_counter() - start:.2f}초")
결과
순차 실행 : 3.00초
쓰레드 3개 : 1.00초

반면, 파일 읽기 & 쓰기, 네트워크 요청, 대기(sleep)와 같은 입출력(I/O) 작업은 기다리는 동안 GIL을 해제합니다.

그러므로, 한 쓰레드가 응답을 기다리는 동안 다른 쓰레드가 실행될 수 있어 쓰레드의 이득이 큽니다.

예제는 1초가 걸리는 작업 3개를 순차적으로 실행하면 약 3초가 걸리지만, 쓰레드로 실행하면 약 1초만에 완료됩니다.

예제의 time.sleep(1)은 네트워크 응답을 기다리는 상황을 흉내 낸 것입니다. 기다리는 동안에는 CPU를 사용하지 않으므로, 세 쓰레드가 동시에 기다릴 수 있습니다.


정리하면, 쓰레드는 입출력(I/O) 위주의 작업에 적합하며, CPU 연산 위주의 작업은 제 46강의 프로세스 기반 병렬 처리(multiprocessing)를 사용하는 것이 유리합니다.



경쟁 상태(Race Condition)

같은 프로세스의 쓰레드들은 변수를 공유하므로, 여러 쓰레드가 하나의 변수에 결과를 모을 수 있습니다.

하지만 여러 쓰레드가 같은 변수를 동시에 읽고 바꾸면, 실행 순서에 따라 결과가 달라지는 문제가 생깁니다. 이를 경쟁 상태(Race Condition)라 부릅니다.


import threading
import time

count = 0


def increase(n):
    global count
    for _ in range(n):
        value = count
        time.sleep(0)
        count = value + 1


threads = [threading.Thread(target=increase, args=(1000,)) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print(count)
결과
1063

네 개의 쓰레드가 각각 count를 1000번씩 1 증가시키므로, 기대하는 결과는 4000입니다.

하지만 실제 결과는 4000보다 훨씬 작으며, 실행할 때마다 다른 값이 출력됩니다.

예제는 값을 읽는 value = count와 값을 저장하는 count = value + 1 사이에 time.sleep(0)을 넣어, 그 사이에 쓰레드 전환이 일어나기 쉽게 만들었습니다.


값이 사라지는 과정을 두 쓰레드 A와 B로 나누어 보면 다음과 같습니다.

순서 쓰레드 A 쓰레드 B count
1 value = count (0을 읽음)   0
2 (전환) value = count (0을 읽음) 0
3   count = value + 1 (1을 저장) 1
4 count = value + 1 (1을 저장)   1


A와 B가 모두 1을 더했으므로 count는 2가 되어야 하지만, 두 쓰레드가 같은 값(0)을 읽은 뒤 각자 1을 저장했기 때문에 결과는 1입니다.

B가 저장한 값을 A가 덮어써서, B의 증가가 사라진 것입니다.

이런 일이 수천 번 반복되면서 많은 증가가 사라지고, 언제 전환이 일어나는지는 스케줄링에 따라 매번 다르므로 결과도 매번 달라집니다.


count += 1처럼 한 줄로 작성해도 같은 문제가 생길 수 있습니다.

import dis


def increase():
    global count
    count += 1


for instr in dis.get_instructions(increase):
    if instr.opname != "RESUME":
        print(instr.opname, instr.argval)
결과
LOAD_GLOBAL count
LOAD_SMALL_INT 1
BINARY_OP 13
STORE_GLOBAL count
LOAD_CONST None
RETURN_VALUE None

dis 모듈은 Python 코드가 실제로 실행되는 바이트코드 명령어를 보여 줍니다.

count += 1 한 줄은 count를 읽고(LOAD_GLOBAL), 1을 준비하고(LOAD_SMALL_INT), 더하고(BINARY_OP), 저장하는(STORE_GLOBAL) 여러 단계로 실행됩니다.

쓰레드 전환은 이 단계 사이에서 일어날 수 있으므로, 앞의 표와 같은 일이 count += 1에서도 발생할 수 있습니다.

  • Tip : 바이트코드 명령어의 이름과 구성은 Python 버전마다 다를 수 있습니다. 결과는 Python 3.14.6의 결과입니다.

  • Tip : 잠금 없이 실행해도 우연히 올바른 결과가 나올 수 있지만, 결과가 보장되지 않습니다. 경쟁 상태는 실행할 때마다 나타나지 않을 수 있어 발견하기 어려운 오류입니다.



잠금(Lock)

경쟁 상태는 “값을 읽고, 바꾸고, 저장하는” 과정 도중에 다른 쓰레드가 끼어들기 때문에 발생합니다.

그러므로, 이 과정을 한 번에 하나의 쓰레드만 실행하도록 보호하면 문제를 막을 수 있습니다. 이때 사용하는 것이 잠금(Lock)입니다.


import threading
import time

count = 0
lock = threading.Lock()


def increase(n):
    global count
    for _ in range(n):
        with lock:
            value = count
            time.sleep(0)
            count = value + 1


threads = [threading.Thread(target=increase, args=(1000,)) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print(count)
결과
4000

앞의 경쟁 상태 예제에 with lock:만 추가하자, 항상 4000이 출력됩니다.

한 쓰레드가 with lock: 안에 들어가 있으면, 다른 쓰레드는 그 쓰레드가 with 구문을 벗어날 때까지 입구에서 기다립니다.

그러므로, time.sleep(0)에서 쓰레드 전환이 일어나더라도 다른 쓰레드가 count를 읽을 수 없어, 앞의 표처럼 같은 값을 읽는 일이 생기지 않습니다.


import threading

count = 0
lock = threading.Lock()


def increase(n):
    global count
    for _ in range(n):
        with lock:
            count += 1


threads = [threading.Thread(target=increase, args=(100_000,)) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print(count)
결과
400000

여러 쓰레드가 같은 변수를 동시에 변경하면, 값이 의도와 다르게 변경되는 경쟁 상태(Race Condition)가 발생할 수 있습니다.

count += 1은 값을 읽고, 더하고, 저장하는 여러 단계로 실행되므로 중간에 다른 쓰레드가 끼어들 수 있습니다.

GIL이 있더라도 쓰레드의 전환은 이 단계 사이에서 일어날 수 있으므로, GIL이 경쟁 상태를 막아주지 않습니다.

GIL은 인터프리터 내부를 보호할 뿐, 작성한 코드의 여러 단계를 하나로 묶어 주지는 않습니다.


threading.Lock()으로 잠금 객체를 생성하고, with lock: 구문으로 한 번에 하나의 쓰레드만 들여쓰기 구문을 실행하도록 보호합니다.

with 구문을 벗어나면 잠금이 자동으로 해제됩니다.

lock.acquire()와 lock.release()로 직접 잠그고 해제할 수도 있지만, 오류가 발생해도 잠금이 해제되도록 with 구문을 사용하는 것을 권장합니다.


잠금으로 보호한 구간은 한 번에 하나의 쓰레드만 실행하므로, 그 구간은 쓰레드의 이득을 얻지 못합니다.

그러므로, 잠금 안에는 공유 변수를 읽고 바꾸는 꼭 필요한 코드만 넣고, 시간이 오래 걸리는 작업은 잠금 밖에서 처리합니다.

  • Tip : 같은 Lock을 이미 잠근 쓰레드가 다시 잠그려고 하면 스스로를 기다리느라 영원히 멈춥니다. 같은 쓰레드에서 여러 번 잠가야 한다면 threading.RLock()을 사용합니다.



쓰레드 풀(ThreadPoolExecutor)

작업마다 Thread 객체를 만들고 start(), join()을 직접 호출하는 것은 번거롭고, 반환값을 받기도 어렵습니다.

또한 작업이 수천 개라면 쓰레드를 수천 개 만들게 되어 오히려 느려질 수 있습니다.

쓰레드 풀(Thread Pool)은 정해진 개수의 쓰레드를 미리 만들어 두고, 작업을 차례대로 나누어 맡기는 방식입니다.


from concurrent.futures import ThreadPoolExecutor
import time


def download(name):
    time.sleep(1)
    return f"{name} 완료"


start = time.perf_counter()
with ThreadPoolExecutor(max_workers=3) as executor:
    results = list(executor.map(download, ["A", "B", "C"]))

print(results)
print(f"{time.perf_counter() - start:.2f}초")
결과
[‘A 완료’, ‘B 완료’, ‘C 완료’]
1.00초

concurrent.futures 모듈의 ThreadPoolExecutor는 미리 정해진 개수의 쓰레드(쓰레드 풀)를 만들어 작업을 나누어 실행합니다.

ThreadPoolExecutor(max_workers=쓰레드 개수)로 쓰레드 풀을 생성합니다.

executor.map(함수, 반복 가능한 객체)는 각 요소를 함수에 전달해 쓰레드에서 실행하고, 입력 순서대로 결과를 반환합니다.

with 구문을 벗어나면 모든 작업이 끝날 때까지 기다린 뒤 쓰레드 풀을 정리합니다.

Thread 클래스와 달리 함수의 반환값을 쉽게 받을 수 있다는 장점이 있습니다.


세 작업이 세 개의 쓰레드에서 동시에 대기하므로 전체 시간은 약 1초입니다.

작업이 쓰레드 개수보다 많다면, 먼저 끝난 쓰레드가 남은 작업을 이어서 맡습니다. 예를 들어, max_workers=3으로 6개의 작업을 실행하면 약 2초가 걸립니다.

작업이 끝나는 순서는 매번 다를 수 있지만, map의 결과는 항상 입력한 순서와 같습니다.


from concurrent.futures import ThreadPoolExecutor, as_completed
import time


def work(seconds):
    time.sleep(seconds)
    return seconds


with ThreadPoolExecutor(max_workers=3) as executor:
    futures = [executor.submit(work, s) for s in [0.3, 0.1, 0.2]]

    for future in as_completed(futures):
        print(future.result())
결과
0.1
0.2
0.3

executor.submit(함수, 인자)는 작업 하나를 제출하고, 실행 결과를 담을 Future 객체를 반환합니다.

as_completed(Future 목록)은 먼저 완료된 작업부터 Future 객체를 반환합니다.

Future.result()로 함수의 반환값을 가져올 수 있으며, 함수에서 오류가 발생했다면 이때 오류가 다시 발생합니다.

작업은 0.3, 0.1, 0.2 순서로 제출했지만, 대기 시간이 짧은 작업부터 끝나므로 0.1, 0.2, 0.3 순서로 출력됩니다.

결과를 입력 순서대로 받아야 하면 map을, 끝나는 대로 바로 처리하고 싶으면 submit과 as_completed를 사용합니다.

  • Tip : max_workers를 생략하면 min(32, 사용 가능한 CPU 개수 + 4)개의 쓰레드를 사용합니다. (Python 3.13 이상은 os.process_cpu_count() 기준)



자유 쓰레드 빌드(free-threaded)

python3.14t -c "import sys; print(sys._is_gil_enabled())"

GIL의 제약을 없애기 위해, GIL을 비활성화한 자유 쓰레드(free-threaded) 빌드가 도입되었습니다.

Python 3.13에서 실험적 기능(PEP 703)으로 추가되었으며, Python 3.14부터는 공식 지원(PEP 779) 빌드가 되었습니다.

자유 쓰레드 빌드는 일반 빌드와 별도로 설치하는 실행 파일이며, python3.14t처럼 이름 뒤에 t가 붙습니다.

자유 쓰레드 빌드에서는 여러 쓰레드가 Python 코드를 여러 CPU 코어에서 동시에 실행할 수 있으므로, CPU 연산에서도 쓰레드의 이득을 얻을 수 있습니다.

GIL이 비활성화된 상태라면 sys._is_gil_enabled()가 False를 반환합니다.


일반 빌드에서는 GIL을 끌 수 없으며, 일반 빌드에서 python -X gil=0으로 실행하면 Fatal Python error: config_read_gil: Disabling the GIL is not supported by this build 오류가 발생합니다.

sysconfig.get_config_var("Py_GIL_DISABLED")로 현재 Python이 자유 쓰레드 빌드인지 확인할 수 있으며, 일반 빌드에서는 0을 반환합니다.

  • Tip : Python 3.14에서도 기본 빌드는 여전히 GIL이 있는 빌드입니다. 자유 쓰레드 빌드는 단일 쓰레드 성능이 다소 낮을 수 있고, 일부 확장 모듈은 아직 지원하지 않을 수 있습니다.

  • Tip : GIL이 없는 환경에서는 경쟁 상태가 더 쉽게 발생하므로, 공유 데이터에는 반드시 Lock 등의 동기화 도구를 사용합니다.



정리

이름 설명 반환값/특징
threading.Thread(target, args) 쓰레드 생성 args는 튜플
쓰레드.start() 쓰레드 시작 기다리지 않고 바로 반환, 한 번만 호출 가능
쓰레드.join(timeout) 쓰레드 종료 대기 시간 초과 시에도 그냥 반환
daemon=True 데몬 쓰레드 일반 쓰레드가 모두 끝나면 함께 종료
threading.current_thread() 현재 쓰레드 메인 쓰레드 이름은 MainThread
sys.getswitchinterval() 쓰레드 전환 요청 간격 기본값 0.005초
GIL 한 번에 하나의 쓰레드만 Python 코드 실행 I/O 작업에 유리, CPU 작업은 이득 적음
경쟁 상태 공유 변수를 동시에 변경해 값이 틀어짐 실행할 때마다 결과가 다름
threading.Lock() 잠금 with lock:으로 사용
ThreadPoolExecutor 쓰레드 풀 map은 입력 순서, as_completed는 완료 순서
sys._is_gil_enabled() GIL 활성화 여부 3.13 이상, 일반 빌드는 True


쓰레드는 하나의 프로세스 안에서 여러 작업을 번갈아 또는 동시에 실행하는 방법이며, 실행 순서는 운영체제의 스케줄링에 따라 매번 달라집니다.

일반 빌드의 Python에서는 GIL 때문에 입출력을 기다리는 작업에서 쓰레드의 이득이 크고, CPU 연산 위주의 작업은 프로세스 기반 병렬 처리를 사용합니다.

여러 쓰레드가 같은 데이터를 바꾼다면 Lock으로 보호하고, 반환값이 필요한 작업은 ThreadPoolExecutor로 간단하게 작성할 수 있습니다.

댓글 남기기