스레드의 개념과 역할
프로세스는 운영체제에서 실행되는 프로그램의 인스턴스로, 자체적인 메모리 공간을 갖습니다. 각 프로세스는 최소 하나의 스레드(주 스레드)를 포함하며, 이 스레드가 실제로 코드를 실행합니다. 이를 비유하면, 프로세스는 자재를 모아서 제품을 생산하는 공장이며, 스레드는 그 안의 생산 라인입니다. 여러 스레드는 동일한 프로세스 내에서 공유된 리소스를 사용하므로, 동시에 접근 시 데이터 경쟁 문제가 발생할 수 있습니다.
스레드 생성: 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 때문에 멀티코어 연산은 제한되지만, 입출력 중심 작업에서는 매우 유용합니다. 따라서 웹 서버, 데이터베이스 클라이언트, 파일 처리 등에서 다중 스레딩은 여전히 강력한 도구입니다.