Python은 하나의 Thread(Main Thread)로 시작한다. Main Thread는 혼자서 순차적으로 코드를 실행하게 된다. 하지만 실행되던 중간에 Blocking Function, 예를 들어서, Input과 같은 함수를 만나면 그 함수의 실행이 끝날 때까지 기다리게 된다. Main Thread가 멈추게 되면 다른 함수를 실행할 수가 없게 되는데 이때 우리는 Thread를 하나 더 만들어서 다른 함수를 병렬적으로 Blocking Function과 같은 함수와 함께 실행할 수 있다. 하지만 Thread를 여러 개를 사용할 때 주의해야 할 것들이 많다. 이번 글에서는 Thread를 사용할 때 주의해야 할 점과 생각해야 할 문제들을 소개해 보고자 한다.
1. 전역 변수를 공유한다
문제의 상황: 서로 다른 Thread에서 동시에 같은 변수에 접근하면 어떻게 될까?
어떤 문제가 일어나는지 간단한 예제로 확인해 보자.
Main Thread를 이용해서 변수 totalCount에 10,000,000개의 숫자를 1씩 세고 싶은데 너무 느릴 것 같다. 그래서 일을 분할해서 Thread를 4개 만든 다음, 각각의 Thread가 변수 totalCount에 2,500,000개를 1씩 더해서 카운트를 하게 해 보자.
import threading
# CounterThread
class CounterThread(threading.Thread):
def __init__(self):
threading.Thread.__init__(self, name='Timer Thread')
# CounterThread가 실행하는 함수
def run(self):
global totalCount
# 2,500,000번 카운트 시작
for _ in range(2500000):
totalCount += 1
print('2,500,000번 카운팅 끝!')
if __name__ == '__main__':
# 전역 변수로 totalCount를 선언
global totalCount
totalCount = 0
# totalCount를 1씩 더하는
# Counter Thread를 4개 만들어서 동작시킨다.
for _ in range(4):
timerThread = CounterThread()
timerThread.start()
print('모든 Thread들이 종료될 때까지 기다린다.')
mainThread = threading.currentThread()
for thread in threading.enumerate():
# Main Thread를 제외한 모든 Thread들이
# 카운팅을 완료하고 끝날 때 까지 기다린다.
if thread is not mainThread:
thread.join()
print('totalCount = ' + str(totalCount))
|
cs |
이 코드를 실행하면 4개의 Thread가 totalCount변수를 2,500,000번 더했으니 4 * 2,500,000인 10,000,000이 나와야 정상이다. 실행해보자.
위의 실행 결과 totalCount 변수 안에는 10,000,000이 아니라 4,827,031이 들어가 있다는 것을 확인할 수 있다.
왜? 같은 변수를 동시에 접근했기 때문이다.
실제 a = a + 1은 보통 3가지의 Instruction으로 실행된다. 그리고 CPU는 Instruction을 하나씩 실행한다.
- a의 값을 메모리에서 레지스터로 불러온다.
- 레지스터에서 더한다.
- 더한 값을 실제로 a가 있는 메모리에 저장한다.
a의 진짜 메모리에 1이 더해지려면 3번째 Instruction까지 실행되야지 된다.
하지만 이 3가지의 Instruction을 4개의 Thread가 거의 동시에 실행하려다 보면 한 Thread에서 3번째 Instruction이 다 끝나기 전에 또 다른 Thread에서 덧셈을 시작하게 된다. 이러한 현상 때문에 여러 Thread에서 a에 1씩 더했다 하더라도 최종적으로 더해진 변수의 값을 살펴보면 더 작아져서 나오게 된다. 예시를 보면 이해가 더 쉽게 된다. 아래의 예시를 보며 천천히 이해해보자.
예를 들어서 다음과 같은 상황이 일어날 수 있다.
전역 변수 a가 0으로 초기화되어 있을 때, Thread A와 Thread B가 서로 동시에 a = a + 1을 실행하려고 한다고 해보자.
이때 CPU는 동시에 실행되는 것처럼 보이게 하려고 각각의 Thread의 Instruction을 번갈아가면서 실행한다고 해보자.
- CPU는 Thread A의 1번 Instruction을 실행했다. a가 0이므로 레지스터A에 값은 0이다.
- 그다음 Thread B의 1번 Instruction을 실행했다. 아직 a가 0이므로 레지스터B에 들어간 값은 0이다.
- 그다음 Thread A의 2번 Instruction을 실행했다. 레지스터A의 값은 1이 됐다.
- 그다음 Thread B의 2번 Instruction을 실행했다. 레지스터B의 값은 1이 됐다.
- 그다음 Thread A의 3번 Instruction을 실행했다. 레지스터A의 값을 a에 저장한다. 그래서 a의 값이 1로 변했다.
- 그다음 Thread B의 3번 Instruction을 실행했다. 레지스터B의 값을 a에 저장한다. 그래서 a의 값이 다시 1로 유지가 됐다.
그렇기 때문에 Thread A에서도 a = a + 1을 실행하고 Thread B에서도 a = a + 1을 실행했지만 a의 값은 2가 아니라 1이 된다.
해결법: Thread를 동기화한다.
동기화하는 방법 중에 Lock을 살펴보자.
Lock은 특정 Thread에서 변수를 사용하기 시작했으면 다른 Thread가 사용하지 못하도록 막는 역할을 한다. 마치 변수를 잠구는 것과 같아서 Lock이라고 부른다. 변수를 다 사용했으면 그 Thread는 변수에 대한 Lock을 풀어줘야 한다. 잠금을 푸는 것은 영어로 Release라고 부른다.
이런 간단한 잠금으로 변수를 서로 다른 Thread가 동시에 접근하지 못하게 막으면 위의 원치 않는 상황은 해결할 수 있다.
Lock 사용은 아래와 같이 한다.
- Lock.aquire() = 잠금 - 다른 Thread은 접근 못하게 막는다
- 여기 안에 있는 Code들은 무조건 한 Thread에 의해서 순차적으로 실행되게 된다.
- Lock.release() = 잠금 해제 - 다른 Thread들에게 접근 가능하도록 잠금을 푼다
Lock을 사용해서 위의 전역 변수 공유 문제를 해결해보자.
먼저 Lock을 사용하기 편하게 ThreadVariable Class를 만들었다. 값에 접근할 때 무조건 Lock을 하고 작업이 끝나면 무조건 Lock을 풀도록 만들었다.
import threading
# 공유된 변수를 위한 클래스
class ThreadVariable():
def __init__(self):
self.lock = threading.Lock()
self.lockedValue = 0
# 한 Thread만 접근할 수 있도록 설정한다
def plus(self, value):
# Lock해서 다른 Thread는 기다리게 만든다.
self.lock.acquire()
try:
self.lockedValue += value
finally:
# Lock을 해제해서 다른 Thread도 사용할 수 있도록 만든다.
self.lock.release()
# CounterThread
class CounterThread(threading.Thread):
def __init__(self):
threading.Thread.__init__(self, name='Timer Thread')
# CounterThread가 실행하는 함수
def run(self):
global totalCount
# 2,500,000번 카운트 시작
for _ in range(2500000):
totalCount.plus(1)
print('2,500,000번 카운팅 끝!')
if __name__ == '__main__':
# 전역 변수로 totalCount를 선언
global totalCount
# totalCount를 ThreadVariable 오브젝트로 초기화한다
totalCount = ThreadVariable()
# totalCount를 1씩 더하는
# Counter Thread를 4개 만들어서 동작시킨다.
for _ in range(4):
timerThread = CounterThread()
timerThread.start()
print('모든 Thread들이 종료될 때까지 기다린다.')
mainThread = threading.currentThread()
for thread in threading.enumerate():
# Main Thread를 제외한 모든 Thread들이
# 카운팅을 완료하고 끝날 때 까지 기다린다.
if thread is not mainThread:
thread.join()
print('totalCount = ' + str(totalCount.lockedValue))
|
cs |
실행해보자.
위의 실행 결과 totalCount 변수 안에는 10,000,000이 잘 들어가 있는 것을 확인할 수 있다.
추가로 생각해 볼만한 문제들
- 위의 ThreadVariable Class의 plus함수에 왜 finally에서 Lock을 Release 했을까? 중간에 오류가 나더라도 그 변수를 무조건 Release하기 위함이다.
- 작은 수를 여러 Thread로 Counting하려고 하면 Lock을 하지 않더라도 정상적 나온다. 왜 일까? 다음 Thread가 실행되기 전에 그 Thread가 모두 주어진 Counting을 다하고 종료돼서 애초에 Thread들이 변수에 동시 접근을 하지 않았기 때문이다.
- 우리는 빨리 숫자를 세기 위해서 Thread를 사용했지만 저렇게 순차적으로 접근하게 되면 느려진다.
- Dead Lock을 주의하자. Dead Lock은 언제 발생할까?
2. Daemon인가? 아닌가?
- Daemon Thread는 Main Thread가 종료되면 즉시 종료되는 Thread이다.
- Daemon Thread가 아니면 Main Thread가 종료돼도 종료되지 않고 자기의 일을 묵묵히 한다.
Daemon 여부를 올바르게 설정해야 한다. 잘못하면 내 프로그램이 종료돼도 내가 중간에 만든 Thread가 묵묵히 실행될 수도 있다.
아래의 예시를 보고 이해해보자.
변수를 더하는 프로그램을 만들었다고 해보자. 그리고 덧셈 프로그램을 실행하면서 10초마다 얼마나 시간이 경과했는지 출력하는 타이머도 만들고 싶어서 Thread로 간단히 구현했다고 해보자.
Thread가 Daemon인지 아닌지는 Thread를 start()하기 전에 thread.setDaemon() 함수로 설정할 수 있다.
Daemon으로 만들고 싶은 경우 thread.setDaemon(True) 아닌 경우 thread.setDaemon(False)로 실행하면 된다.
setDaemon() 함수는 그저 Thread Object의 daemon 속성을 바꿔주는 역할을 한다. setDaemon(True) 대신 thread.daemon = True를 실행해도 된다.
1. Timer Thread가 Daemon Thread가 아닌 경우
import threading
import time
# TimerThread
class TimerThread(threading.Thread):
def __init__(self):
threading.Thread.__init__(self, name='Timer Thread')
self.currentTime = 0 # TimerThread가 실행하는 함수
def run(self):
# 10초마다
while True:
# 10초 기다린다
time.sleep(10)
# 시간을 10초 더한다
self.currentTime += 10
print("프로그램을 실행한 시간(초): " + str(self.currentTime))
if __name__ == '__main__':
timer = TimerThread()
# Daemon Thread로 설정하지 않음, 기본값임
timer.setDaemon(False)
# 타이머용 Thread 실행
timer.start()
# 허술한 덧셈 프로그램
while True:
a = int(input("a = "))
b = int(input("b = "))
print("a + b = " + str(a + b))
|
cs |
위의 프로그램을 실행을 하다가 내가 실수로 변수 a에 숫자가 아니라 문자를 입력해서 오류가 난 경우 어떻게 될까?
프로그램이 오류가 나서 종료됐는데도 계속 뭔가가 실행되는 이상한 프로그램이 탄생했다. Main Thread는 오류가 나서 종료되는데 우리가 만들어준 Timer Thread는 Daemon으로 설정하지 않기 때문에 종료되지 않는다.
2. Timer Thread가 Daemon Thread인 경우
import threading
import time
# TimerThread
class TimerThread(threading.Thread):
def __init__(self):
self.currentTime = 0
threading.Thread.__init__(self, name='Timer Thread')
# TimerThread가 실행하는 함수
def run(self):
# 10초마다
while True:
# 10초 기다린다
time.sleep(10)
# 시간을 10초 더한다
self.currentTime += 10
print("프로그램을 실행한 시간(초): " + str(self.currentTime))
if __name__ == '__main__':
timer = TimerThread()
# Daemon Thread로 설정함
timer.setDaemon(True)
# 타이머용 Thread 실행
timer.start()
# 허술한 덧셈 프로그램
while True:
a = int(input("a = "))
b = int(input("b = "))
print("a + b = " + str(a + b))
|
cs |
타이머가 Daemon인 위의 프로그램을 실행을 하다가 내가 실수로 a에 숫자가 아니라 문자를 넣어서 오류가 난 경우 어떻게 될까?
프로그램이 오류가 나서 종료되는 동시에 Timer Thread도 종료가 됐다. 이번의 Timer Thread는 Daemon으로 설정되었기 때문에 Main Thread와 함께 종료되었다.
이렇기 때문에 Daemon 여부 또한 잘 결정해야 한다.
Multi-Threading의 문제점들
Thread를 사용할 때 가장 기본적으로 실수할 수 있는 문제점들에 대해서 알아봤다. 내가 제시한 것 말고도 Race Condition, Resource Starvation, ... 등의 문제를 겪을 수 있다. 까다롭지만 이러한 문제들은 잘 알려져 있기 때문에 미리 학습해서 예방하거나 해결할 수 있다고 생각한다.
말고도 Multi-Threading을 하면 Context Switching이 자주 일어나서 부하가 발생할 수 있다. 그렇기 때문에 비동기 프로그래밍이라는 것이 나왔는데 비동기 프로그래밍을 하면 이 모든 문제를 쉽게 해결할 수 있다.
이러한 문제점들과 해결법들에 대해서 운영체제를 공부하면 도움이 된다. 관심이 있는 분들은 OS ThreeEasyPieces(줄여서 OSTEP)을 읽어보는 것을 추천한다. 그 어렵던 운영체제를 쉽게 풀어썼고 무료이고 게다가 한국어 번역판도 있다.
무료로 공개한 OSTEP 한국어 저장소의 링크를 남긴다.
https://github.com/remzi-arpacidusseau/ostep-translations/blob/master/korean/README.md
개인적으로 모아두고 보기 위해 해당 블로그 내용을 그대로 따왔습니다. 문제가 될 시 삭제하도록 하겠습니다.