[CS/Python]Thread(1)-GIL과 Thread 구현/실행, Event

Jay·2023년 1월 19일
0
post-thumbnail

Thread

프로세스와 스레드에서 스레드가 무엇인지 알아봤습니다. 이번에는 파이썬에서 스레드를 구현하고 사용하는 기본적인 방법과 자원의 무결성과 동기화를 위한 처리에 관해서 알아보도록 하겠습니다.

GIL(Global Interpreter Lock)

파이썬 코드는 인터프리터가 코드를 번역하고 실행함으로써 실행됩니다. 즉, 파이썬 코드가 실행되기 위해서는 인터프리터라는 자원을 소유하고 있어야 하는 것입니다. GIL은 인터프리터가 한 시점에 하나의 스레드만 실행할 수 있도록 해주는 인터프리터 소유에 관한 Lock입니다.

Python의 스레드 동작

멀티코어 환경에서 여러개의 스레드를 동작시킨다면 각각의 스레드들이 병렬적으로 동작하는 것을 생각하실겁니다. 하지만 파이썬에서는 GIL로 인해 인터프리터가 하나의 스레드에 의해서만 소유될 수 있으므로 아래와 같은 방식으로 동작하게 됩니다.

각각의 스레드가 GIL을 획득하여 동작하고 GIL을 다른 스레드에게 소유권을 넘기면서 동작하게 됩니다. 이와 같은 동작 방식으로 인하여 파이썬의 경우 스레드를 병렬적으로 처리할 경우 오히려 GIL을 획득/반납하는 과정의 오버헤드로 인하여 오히려 실행시간이 느려지게 되는 문제가 발생할 수 있습니다.

왜 GIL을 사용하는 것인가?

파이썬에서는 객체를 reference counting을 통해 garbage collection을 실행합니다. 각 객체마다 참조되고 있는 횟수를 reference count에 저장하고, 이 횟수가 0이 되면 garbage collector가 메모리를 회수하는 것입니다. 따라서 이 reference count는 값이 더럽혀지게 되는 경우 사용 중인 객체가 사라지거나, 사용되지 않는 객체가 계속 메모리를 차지하게 되는 문제가 발생할 수 있기 때문에, Lock을 통해 동기화를 할 필요가 있습니다.

하지만 사용되는 모든 객체에 Lock을 사용하여 관리할 경우, Lock을 기다리고 Lock을 획득/반환하는 오버헤드와 Race condition 등 다양한 문제가 발생할 수 있습니다. 따라서 Python에서는 애초에 인터프리터에 전역적으로 Lock을 걸어 하나의 스레드만 실행될 수 있도록 하여 다른 자원에 대한 동기화 문제를 해결한 것입니다.

GIL은 CPU Bound에는 취약합니다. 앞서 말했듯이 여러 스레드가 인터프리터를 주고받으며 실행하기 때문에 자원을 획득/반환하는 과정의 오버헤드로 인하여 싱글 코어 환경에서보다 성능이 더욱 안좋아질 수 있기 때문입니다.

하지만 GIL으로 인한 장점도 분명히 존재합니다. I/O Bound의 경우 I/O 작업시에는 GIL을 반환하여 다른 스레드가 동작할 수 있도록 하여 성능이 개선될 수 있으며, 세세하게 Lock을 설계하지 않아도 되기 때문에 구현에서 훨씬 간편합니다.


Thread 구현

파이썬에서 스레드를 구현하는 방법에는 저수준 라이브러리, 고수준 라이브러리를 사용하는 방법이 있습니다. 저수준의 라이브러리를 사용하게 되면 thread pool이나 lock을 커스터마이징하여 사용할 수 있고, 스레드의 원시적인 기능들을 원하는대로 변경하여 사용할 수 있습니다. 하지만 구현이 어렵고 lock의 설계를 해주어야한다는 단점이 있습니다. 따라서 일반적으로 많이 사용하는 고수준의 라이브러리인 treading 모듈을 사용하여 Thread를 구현하는 방법을 알아보도록 하겠습니다.

함수를 사용한 Thread 구현

import threading
import time

def work(count):
    time.sleep(0.1)
    print("\nname : %s\nargument : %s\n"%(threading.currentThread().getName(), count))
    # 스레드 생성시 인자로 받은 name,count 출력

def main():
    for i in range(5):
        t = threading.Thread(target=work, name="thread %i"%i, args=(i,))
        t.start()

if __name__=="__main__":
    main()

함수를 스레드로 만들어 실행하는 방법입니다. threading 모듈의 Thread 클래스를 사용하여 thread 객체를 만들어 start() 메소드로 실행하면 됩니다. target 인자로는 스레드로 구현할 함수를 인자로 전달하고, name 인자로 해당 스레드의 이름을 할당할 수 있습니다. 또한 args를 통해 스레드로 구현할 함수에 필요한 인자들을 전달할 수 있습니다.

name : thread 1 
argument : 1

name : thread 2 
argument : 2

name : thread 0 
argument : 0

name : thread 3 
argument : 3

name : thread 4 
argument : 4

위의 코드를 실행하게 되면 위처럼 thread로 구현한 메소드들이 비순차적으로 실행된 결과를 확인할 수 있습니다.

클래스를 사용한 Thread 구현

import threading


class Work(threading.Thread):				# Thread 상속

    def __init__(self, args, name=""):		# 생성자 구현
        threading.Thread.__init__(self, name=name)
        self.args = args

    def run(self):							# run 메소드 구현
        print("\nname : %s\nargument : %s\n"%(threading.currentThread().getName(), self.args[0]))

def main():
    for i in range(5):
        t = Work(name="thread % i"%i, args=(i,))
        t.start()

if __name__=="__main__":
    main()

클래스를 사용하여 thread를 구현하는 방법도 간단합니다. treading모듈의 Thread 클래스를 상속 받은 다음 run 메소드를 구현하면 됩니다. 생성자를 구현하는 경우에는 Thread로 구현할 클래스 생성자 내부에서 Thread 클래스의 생성자를 호출해야 합니다.

스레드 객체가 start 메소드를 호출하면 내부적으로 run 메소드를 호출하게 됩니다. 따라서 스레드에서 동작시킬 로직을 run 메소드에 구현하면 됩니다.

name : thread  0
argument : 0

name : thread  1
argument : 1

name : thread  4
argument : 4

name : thread  2
argument : 2

name : thread  3
argument : 3

Daemon Thread

스레드를 사용하여 백그라운드에서 동작하여 여러 작업을 수행하는 데몬을 구현하여 사용할 수 있습니다. 주요 작업들은 main 스레드에서 수행하고, 백그라운드로 실행할 작업을 스레드로 구현하여 데몬으로 동작시키는 것입니다.

import threading
import time
import logging

logging.basicConfig(level=logging.DEBUG, format="(%(threadName)s) %(message)s")

def daemon_work():
    logging.debug("Start")
    time.sleep(3)							# 3.main thread 로 Lock 넘김
    logging.debug("Exit")
    
def main():
    t = threading.Thread(name="daemon work", target=daemon_work)
    t.setDaemon(True)						# 1.Daemon thread로 설정
    
    t.start()								# 2.Daemon thread 실행
    logging.debug("Back to main thread")	# 4.main thread 종료
    
if __name__=="__main__":
	main()

위는 데몬 스레드를 구현하여 실행하는 코드입니다. 데몬 스레드를 구현하는 방법은 간단합니다. 스레드의 setDaemon(boolean)메소드를 사용하여 데몬으로 설정해주면 됩니다. 위의 코드를 실행하게 되면 아래와 같은 결과가 출력되게 됩니다.

(daemon work) Start
(MainThread) Back to main thread

Daemon Thread와 Thread의 차이

위의 코드의 출력 결과를 보시면 이상한 점이 있습니다. 데몬 스레드의 마지막 로깅이 실행되지 않았습니다. 그 이유는 데몬 스레드의 경우 메인 프로그램 로직이 종료되면 자동으로 종료되기 때문입니다.

스레드의 경우, 메인 프로그램의 로직이 종료되더라도 스레드가 종료되지 않는다면 스레드가 종료될 때까지 메인 프로그램은 종료되지 않고 기다리게 됩니다. 따라서 일정 주기바다 동작을 반복하는 작업을 구현할 때, 일반 스레드로 구현하게되면 작업을 적절히 종료시키는 작업을 별도로 구현해야합니다.

하지만 데몬 스레드는 메인 프로그램 로직이 종료된다면 데몬 스레드도 자동으로 종료됩니다. 따라서 데몬 스레드를 종료시키기 위한 별도의 처리를 신경쓰지 않아도 됩니다. 이러한 점으로 인하여 스레드에 접근하기 어렵고 의도대로 종료시키기 어려운 점이 있음에도 데몬 스레드를 사용하여 백그라운드 동작을 구현합니다.

Daemon Thread 작업 종료 기다리기

데몬 스레드는 메인 프로그램의 로직이 종료되면 자동으로 종료된다고 설명하였습니다. 하지만 프로그램에 따라서 데몬 스레드의 작업이 종료되어야 프로그램을 종료해야할 경우가 있습니다. 이러한 경우 데몬 스레드가 종료되면 프로그램을 종료할 수 있도록 구현할 수 있습니다.

def daemon_work():
    logging.debug("Start")
    time.sleep(3)							# 3.main thread 로 Lock 넘김
    logging.debug("Exit")
    
def main():
    t = threading.Thread(name="daemon work", target=daemon_work)
    t.setDaemon(True)						# 1.Daemon thread로 설정
    
    t.start()								# 2.Daemon thread 실행
    logging.debug("Back to main thread")	# 4.main thread 종료
    t.join()								# 5.Daemon thread 종료 대기
    
if __name__=="__main__":
	main()

위와 똑같은 프로그램에 마지막 join()메소드만 추가하였습니다. 이처럼 join()메소드를 사용하여 의도한대로 데몬 스레드가 종료되고 메인 프로그램을 종료하도록 구현하였습니다.

(daemon work) Start
(MainThread) Back to main thread
(daemon work) Exit

Thread Event

파이썬 threading 모듈에서는 스레드 간의 간단한 통신을 위해 Event를 사용합니다. 스레드의 Event는 이벤트를 설정, 초기화, 기다리는 동작을 제공합니다. 이러한 동작들을 스레드에서 사용하여 특정 조건에 따라 스레드를 동작시키도록 제어하는데 사용할 수 있습니다.

import time
import logging
import threading


logging.basicConfig(level=logging.DEBUG, format="(%(threadName)s) %(message)s")

def thread1(e1, e2):
    while not e1.isSet():
        event = e1.wait(1)
        logging.debug("Event status : (%s)", event)

        if event:
            logging.debug("e1 is set.")
            time.sleep(3)
            logging.debug("setting event e2")
            e2.set()

def thread2(e2):
    while not e2.isSet():
        event = e2.wait(1)
        logging.debug("Event status : (%s)", event)

        if event:
            logging.debug("e2 is set.")

def main():
    e1 = threading.Event()
    e2 = threading.Event()

    t1 = threading.Thread(name="First Thread", target=thread1, args=(e1, e2))
    t1.start()
    t2 = threading.Thread(name="Second Thread", target=thread2, args=(e2,))
    t2.start()

    logging.debug("wait ...")
    time.sleep(5)                   # 다른 스레드로 Lock 반환
    logging.debug("set event e1")
    e1.set()
    time.sleep(5)
    logging.debug("Exit")

if __name__=="__main__":
    main()

위 프로그램의 동작을 흐름에 따라 정리하면 아래와 같습니다.

메인 스레드에서 이벤트인 e1, e2와 스레드인 t1, t2를 각각 생성하고, "wait ..."문을 로깅하는 코드를 동작하게 됩니다. 그리고 time.sleep(5)에서 다른 스레드가 동작하게 되고, t1과 t2가 반복하여 동작하지만 e1과 e2가 설정되지 않아 isSet()문에서 False를 반환하여 if event 블록을 실행하지 못하며 t1,t2가 번갈아가며 동작하게 됩니다.

그러던 중, 메인 스레드의 time.sleep(5) 동작이 끝나고 다시 Lock을 대기하여 획득한 후, e1을 set한 후 다시 sleep문을 만나 Lock을 반납하게 됩니다. 그러면 다시 t1에서 Lock을 획득하여 동작하게 되고, isSet()에서 True를 반환하여 e2의 이벤트를 set하는 로직을 수행하게 됩니다.

다음으로는 t2에서도 isSet()이 True를 반환하고, 스레드를 종료하며 메인 스레드가 실행되며 프로그램이 종료되게 됩니다.

(MainThread) wait ...
(First Thread) Event status : (False)
(Second Thread) Event status : (False)
...
(MainThread) set event e1
(First Thread) Event status : (True)
(First Thread) e1 is set.
(Second Thread) Event status : (False)
...
(First Thread) setting event e2
(Second Thread) Event status : (True)
(Second Thread) e2 is set.
(MainThread) Exit

이처럼 threading 모듈에서 제공하는 Event를 사용하여 여러 스레드가 통신하며 각 스레드들의 실행 흐름을 제어할 수 있습니다. 이와 같은 이벤트는 프로그램의 흐름을 제어하여 잘못 사용하는 경우 프로그램이 루프에 빠지거나 잘못된 흐름대로 실행될 수 있으므로 면밀히 분석하고 설계하여 사용해야 합니다.







reference

https://ssungkang.tistory.com/entry/python-GIL-Global-interpreter-Lock%EC%9D%80-%EB%AC%B4%EC%97%87%EC%9D%BC%EA%B9%8C
https://it-eldorado.tistory.com/160

0개의 댓글