파이썬에서의 다중 스레딩 기초 및 실용적 활용

스레드의 개념과 역할

프로세스는 운영체제에서 실행되는 프로그램의 인스턴스로, 자체적인 메모리 공간을 갖습니다. 각 프로세스는 최소 하나의 스레드(주 스레드)를 포함하며, 이 스레드가 실제로 코드를 실행합니다. 이를 비유하면, 프로세스는 자재를 모아서 제품을 생산하는 공장이며, 스레드는 그 안의 생산 라인입니다. 여러 스레드는 동일한 프로세스 내에서 공유된 리소스를 사용하므로, 동시에 접근 시 데이터 경쟁 문제가 발생할 수 있습니다.

스레드 생성: threading 모듈 사용

스레드는 threading.Thread 클래스를 통해 생성할 수 있으며, 두 가지 주요 방식이 있습니다.

from threading import Thread
import time

def worker():
    print(f"작업 스레드 시작: {Thread.current_thread().name}")
    time.sleep(1)
    print(f"작업 스레드 종료: {Thread.current_thread().name}")

# 방법 1: 직접 인스턴스화
if __name__ == '__main__':
    t = Thread(target=worker)
    t.start()
    print("메인 스레드 작업 중...")
    t.join()  # 메인 스레드가 서브 스레드 완료를 대기
    print("메인 스레드 종료")
# 방법 2: 상속을 통한 커스텀 스레드 클래스
class CustomThread(Thread):
    def run(self):
        print(f"{self.name}가 작업을 시작했습니다.")
        time.sleep(2)
        print(f"{self.name}가 작업을 완료했습니다.")

if __name__ == '__main__':
    t = CustomThread()
    t.start()
    t.join()
    print("메인 스레드 종료")

실제 응용: 동시 접속 처리 서버

네트워크 서버에서는 하나의 연결마다 별도의 스레드를 생성해 동시 처리를 가능하게 합니다.

import socket
from threading import Thread

server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.bind(('127.0.0.1', 8080))
server_socket.listen(5)

def handle_client(client_conn):
    try:
        while True:
            data = client_conn.recv(1024)
            if not data:
                break
            client_conn.send(data.upper())
    except Exception as e:
        print(f"클라이언트 처리 오류: {e}")
    finally:
        client_conn.close()

# 클라이언트 요청 수신 및 스레드 생성
while True:
    conn, addr = server_socket.accept()
    client_thread = Thread(target=handle_client, args=(conn,))
    client_thread.start()

join 메서드: 스레드 종료 대기

메인 스레드가 하위 스레드의 완료를 기다리도록 할 수 있습니다.

from threading import Thread
import time

def slow_task(name):
    print(f"{name} 시작")
    time.sleep(3)
    print(f"{name} 완료")

if __name__ == '__main__':
    t = Thread(target=slow_task, args=("작업 1",))
    t.start()
    t.join()  # 메인 스레드는 이 스레드 종료까지 대기
    print("메인 스레드 진행")

공유 데이터와 스레드 안전성

동일한 프로세스 내 스레드는 전역 변수 등 공유 자원을 사용할 수 있어, 동시 수정 시 문제 발생 가능합니다.

from threading import Thread

balance = 1000

def withdraw():
    global balance
    temp = balance
    time.sleep(0.01)
    balance = temp - 100
    print(f"출금 후 잔액: {balance}")

if __name__ == '__main__':
    threads = []
    for _ in range(5):
        t = Thread(target=withdraw)
        t.start()
        threads.append(t)
    
    for t in threads:
        t.join()
    
    print(f"최종 잔액: {balance}")  # 예상: 500, 실제: 900 (경쟁 조건)

데몬 스레드: 메인 종료 시 함께 종료

메인 스레드 종료 시, 데몬 스레드는 자동으로 종료됩니다.

from threading import Thread
import time

def daemon_worker():
    print("데몬 스레드 시작")
    time.sleep(3)
    print("데몬 스레드 종료")

if __name__ == '__main__':
    d_thread = Thread(target=daemon_worker)
    d_thread.daemon = True  # 데몬 설정
    d_thread.start()
    print("메인 스레드 종료")
    # 출력: 데몬 스레드 시작 → 메인 스레드 종료 → 데몬 스레드 종료 미반영

스레드 간 경합 제어: 뮤텍스 락

공유 자원에 대한 동시 접근을 방지하기 위해 뮤텍스 락을 사용합니다.

from threading import Thread, Lock
import time

counter = 0
lock = Lock()

def increment():
    global counter
    with lock:  # 락 획득 및 자동 해제
        temp = counter
        time.sleep(0.001)
        counter = temp + 1

if __name__ == '__main__':
    threads = []
    for i in range(100):
        t = Thread(target=increment)
        t.start()
        threads.append(t)
    
    for t in threads:
        t.join()
    
    print(f"최종 카운터 값: {counter}")  # 정확히 100 출력

사례 분석: 데드락과 재진입 락

두 개 이상의 락을 서로 다른 순서로 획득하면 데드락이 발생할 수 있습니다. 이를 해결하기 위해 RLock(재진입 락)을 사용할 수 있습니다.

from threading import Thread, RLock

lock_a = RLock()
lock_b = RLock()

def task(name):
    with lock_a:
        print(f"{name} A 락 획득")
        with lock_b:
            print(f"{name} B 락 획득")
            time.sleep(0.5)
            print(f"{name} B 락 해제")
    print(f"{name} A 락 해제")

if __name__ == '__main__':
    for i in range(5):
        t = Thread(target=task, args=(f"스레드-{i+1}",))
        t.start()

GIL: 파이썬의 전역 해석기 락

CPython 해석기에는 GIL(Global Interpreter Lock)이 존재하여, 한 번에 하나의 스레드만 실행되도록 제한합니다. 이는 메모리 관리의 스레드 안정성을 보장하기 위한 설계입니다.

하지만 이로 인해 계산 중심 작업은 멀티코어를 효과적으로 활용하지 못합니다. 반면, 입출력 중심 작업(예: 네트워크 요청, 파일 읽기/쓰기)에서는 스레드가 대기 상태에 있을 때 다른 스레드가 실행될 수 있으므로, 오히려 효율적입니다.

Lock vs join: 성능 차이

join()을 사용하면 모든 작업이 순차적으로 실행되지만, Lock은 공유 자원 접근 부분만 제한되므로 더 효율적입니다.

from threading import Thread, Lock
import time

count = 100
lock = Lock()

def decrement():
    global count
    with lock:
        temp = count
        time.sleep(0.001)
        count = temp - 1

if __name__ == '__main__':
    start_time = time.time()
    for i in range(100):
        t = Thread(target=decrement)
        t.start()
        t.join()  # 여기서 모든 작업이 순차화됨
    end_time = time.time()
    print(f"총 소요 시간: {end_time - start_time:.2f}s, 최종 값: {count}")

결과: 약 30초 이상 소요. 이는 join이 모든 작업을 순차화하기 때문입니다. 반면, Lock은 공유 데이터 접근 시점만 제한되므로, 전체 성능 손실은 훨씬 적습니다.

결론: 파이썬 다중 스레딩의 적절한 사용 사례

CPython의 GIL 때문에 멀티코어 연산은 제한되지만, 입출력 중심 작업에서는 매우 유용합니다. 따라서 웹 서버, 데이터베이스 클라이언트, 파일 처리 등에서 다중 스레딩은 여전히 강력한 도구입니다.

태그: python threading GIL Multithreading RLock

7월 8일 22:24에 게시됨