Python Thread
파이썬의 스레드에 대해 다루는 글이다.
스레드란, 프로세스내 작업의 단위이다. 프로세스는 하나 이상의 스레드를 가지고 있으며, 스레드 끼리는 프로세스의 힙공간을 공유하지만, 각 스레드별 스택을 가지고 있다.
이 포스팅은 전반적인 스레드에 대한 포스팅이 아닌 파이썬에서 스레드가 어떠한지와, threading 모듈에 관한 포스팅이다.
파이썬의 스레드
파이썬에서 스레드는 GIL이라는 정책 때문에, 동시에 두개 이상의 스레드가 병렬로 실행될 수 없다. 개발자는 이로인해 파이썬에서 제약을 받게된다. 그렇다면 GIL이 있으므로 파이썬 자체는 thread-safe하다고 볼 수 있는가?
불행히도 아니다.
atomic 연산과 thread-safe
우선 thread-safe에 대해 알기전 연산의 atomic을 우선 알아야 한다.
연산이 atomic하다는 것은 연산이 중간에 끊기지 않고 수행된다는 의미이며, cpu가 해당 작업을 수행하는 중에 끊기지 않음을 보장받을 수 있다.
자신에게 필요한 연산이 atomic한지 안한지는 검색을 통해 알 수 있다.
ex) += 연산의 원자성
Is the += operator thread-safe in Python?
I want to create a non-thread-safe chunk of code for experimentation, and those are the functions that 2 threads are going to call. c = 0 def increment(): c += 1 def decrement(): c -= 1 Is ...
stackoverflow.com
+= 연산은 atomic 하지 않아 thread-safe하지 않다.
ex) print는 atomic하지 않지만, sys.stdout.write은 atomic하다.
그렇다면 atomic한 연산 자체는 thread-safe할까? 이 물음의 답은 "맞다" 다. 이 작업 도중에는 context-switch되니 않는다.
하지만 atomic 연산들이 여러개 모이게 되면 그 자체는 atomic하지 않다.
연산중에는 끊기지 않지만 연산이 끝나고 다음 연산이 시작되기전에 context-switch가 일어날 수 있기 때문이다.
thread-safe하지 않은 코드
thread-safe하지 않을때 어떤 일이 일어나는지 확인해본다.
파이썬 연산들은 여러개의 바이트코드로 이루어져 있고, 이 바이트코드는 atomic하다.
바이트코드 : 파이썬 인터프리터는 소스코드를 바이트 코드로 만들고 이 바이트코드를 "가상기계" 에서 실행시킴
Thread Atomic Operations in Python
Operations like assignment and adding values to a list or a dict in Python are atomic. In this tutorial you will discover thread atomic operations in Python. Let’s get started. Atomic Operati…
superfastpython.com
파이썬은 GIL 때문에 한번에 하나의 스레드만 실행될 수 있지만, context-switch가 바이트 코드 단위로 이루어지기 때문에 해당 스레드가 안전하게 실행될꺼라는 보장은 하지 못한다.
그렇다면 thread-safe 하지 않은 코드는 어떤 결과가 나오게 될까?
예시코드
from threading import Thread
x = 0
def f1():
global x
for i in range(1000000):
x = x + 1
def f2():
global x
for i in range(1000000):
x = x + 1
if __name__ == "__main__":
t1 = Thread(target=f1)
t2 = Thread(target=f2)
t1.start()
t2.start()
t1.join()
t2.join()
print(x)
아직 Thread에 대해 얘기하지 않았지만, 각 스레드에서 전역변수를 100만번 더하는 쉽게 이해할 수 있는 예제 코드다.
위에서 언급한 GIL 때문에 두 스레드는 동시에 수행될 수 없다. 한번에 하나의 스레드만 실행되는것이다.
그렇다면 각 스레드가 100만번씩 수행하기 때문에 최종 x의 결과는 200만이 되어야 할것처럼 보인다.
하지만 직접 수행해보면 수행해볼때마다 다른 결과가 나오게 되며 결과는 200만보다 훨씬 낮은 숫자가 나오게 된다.
1708910, 1367852, 1705450, 1382865, ...
스레드는 한번에 하나씩만 실행되지만 왜 이런 결과가 나오게 될까?
이유는 atomic하지 않은 연산에 있다.
위 f1 함수의 바이트 코드를 확인하면 다음과 같다.
바이트코드 확인하는 방법
import dis
def f():
global x
x = x + 1
dis.dis(f)
스레드에서는 아래의 일들이 일어나게 된다.
1. Thread1에서 전역변수를 가져온다. (ex 1)
2. Thread1에서 덧셈할 상수를 가져온다. (ex 1)
3. Thread1에서 덧셈을 수행한다. (ex 1 + 1)
4. Thread1에서 덧셈의 결과를 전역변수에 저장한다. (ex 2)
이 작업 하나하나는 중간에 중단되지 않는 atomic한 성질을 가지고 있지만, 이 연산 중간에는 얼마든지 끊길 수 있다.
예시로 Thread1에서 덧셈의 수행 결과를 아직 전역변수에 저장하지 않았는데, 그때 Thread2에서 작업이 수행된다면??
Thread1에서는 덧셈의 수행 결과인 2를 전역 변수에 저장해야 한다.
하지만 그전에 Thread2로 context-switch가 일어나므로 전역변수는 1 인 상태로 Thread2가 작업을 수행한다.
이때 Thread2역시 1을 더해 2라는 값을 전역 변수에 저장한다.
이제 다시 context-switch가 일어나 Thread1이 자신이 저장할 결과인 2를 전역 변수에 저장한다.
이렇게 되면 덧셈은 두번 일어나게 되었지만, 전역변수에는 2라는 결과가 저장되게 된다.
threading 모듈
threading — Thread-based parallelism — Python 3.10.6 documentation
threading — Thread-based parallelism Source code: Lib/threading.py This module constructs higher-level threading interfaces on top of the lower level _thread module. Changed in version 3.7: This module used to be optional, it is now always available. See
docs.python.org
이제 파이썬의 threading 라이브러리에 대해 살펴본다. 역시 공식 문서가 가장 잘 나와있으며, 여기서는 각 모듈의 간단한 사용법에 대해 다룬다.
Thread 객체
from threading import Thread
x = 0
def f1():
global x
for i in range(1000000):
x = x + 1
def f2():
global x
for i in range(1000000):
x = x + 1
if __name__ == "__main__":
t1 = Thread(target=f1)
t2 = Thread(target=f2)
t1.start()
t2.start()
t1.join()
t2.join()
print(x)
Thread(target=, args=)
스레드를 생성한다. target : 스레드가 실행시킬 함수, args : 함수에게 전달할 파라미터.
start(), join()
start() : 스레드를 실행시킨다
join() : 스레드가 종료될때까지 기다린다.
Lock 객체
위에서 말한 thread-safe를 지키기 위해서는 일종의 보호장치가 필요하다. threading.Lock은 그중 하나로써 thread-safe한 코드를 작성하도록 도움을 준다.
import threading
from threading import Thread, Lock
x = 0
def f1(l: Lock):
global x
for i in range(1000000):
l.acquire()
try:
x = x + 1
except Exception as e:
print(e)
finally:
l.release()
def f2(l: Lock):
global x
for i in range(1000000):
with l:
x = x + 1
if __name__ == "__main__":
l = Lock()
cd = threading.Condition(l)
t1 = Thread(target=f1, args=(l,))
t2 = Thread(target=f2, args=(l,))
t1.start()
t2.start()
t1.join()
t2.join()
print(x)
이제 f1는 공유변수인 x에 접근하기 전 lock을 획득, critical-section에 안전하게 들어가 작업 후, 작업이 끝나면 lock을 반환하여 상호 배제를 통해 thread-safe 하도록 한다. 파이썬에서 acquire, release 사이에 있는 코드는 atomic하게 실행된다.
f2역시 f1와 같은 의미의 코드다.
결론
파이썬은 GIL이라는 정책 덕분에 멀티 스레딩이 연산 속도면에서는 이득을 보지 못한다. (I/O는 또 다르다)
한번에 하나의 스레드만 실행될 수 있지만, 스레드 안전하지는 않다.
스레드끼리 공유 변수를 사용해 critical-section이 존재한다면, lock, semaphore등을 사용해 접근을 제어해야 한다.