반응형

파이썬에서 스레드를 사용하는 방법을 다루고 있습니다. 

 

1. 쓰레드 (Thread)

2. 스레드 생성 및 실행

3. join 함수

4. 데몬 쓰레드

5. Concurrent.futures 모듈

6. 전역 변수 공유

7. GIL(Global Interpreter Lock)

8. 프로세스 생성하여 실행하기

9. Thread vs Process



2022. 8. 6 최초작성




1. 쓰레드 (Thread)

 

파이썬 코드를 실행하면 보통 위에서 아래로 순차적으로 실행됩니다. 때로는 함수나 조건문, 반복문등에 의해서 실행 순서가 바뀔 수도 있습니다.  

 

하나의 작업이 오래 걸리는 작업이라면 이 작업을 백그라운드로 실행해두고 결과가 리턴되기 전까지 다른 작업을 하고 싶을 수 있습니다. 이렇게 하려면 코드를 병렬로 실행해야 하며 이를 가능하게 해주는 것이 스레드입니다. 스레드를 생성하여 실행하는 순간 코드는 두 개의 분기로 나누어져서 이론적으론 동시에 실행됩니다. 실제론 CPU의 시간을 나누어서 번갈아가며 실행되는 것입니다.

 

파이썬에서 스레드를 생성하기 위해 threading 모듈을 제공합니다. 



2. 스레드 생성 및 실행

 

스레드를 생성하기 위해 우선  Thread 객체를 생성하고 그리고나서 스레드를 실행하기 위해서 Thread 객체의 start() 메서드를 호출합니다.



스레드를 생성하는 방식은 두가지입니다. threading.Thread를 상속받은 클래스 또는 일반 함수를 사용할 수 있습니다. 



1. 쓰레드가 실행할 함수를 작성하고 그 함수명을 threading.Thread() 함수의 target 아큐먼트에 지정합니다. 

예를 들어, 아래 예제에서 count 함수를 쓰레드가 실행하도록 threading.Thread() 함수의 파라미터로 target=count 을 지정했습니다. 그리고 스레드가 실행하는 함수에 파라미터를 전달하기 위해서 args 키워드에 필요한 파라미터를 지정했습니다. 



import threading

def count(num):
   
    while num > 0:
        num = num - 1
        print(num)

    print('thread exit')



t = threading.Thread(target=count, args=(10, ))
t.start()
print('main exit')





2. Thread 클래스를 상속 받는 클래스를 생성하여 이 클래스의 run() 메서드에 스레드에서 실행할 코드를 추가합니다. 스레드 클래스의 start() 메서드를 실행하면  내부적으로 이 run() 메서드를 호출합니다. 

스레드에서 실행할 코드에 값을 전달하기 위해 init 메소드에 파리미터로 전달받을 인자를 추가한후. 클래스변수에 전달받은 값을 저장하면 run 메소드에 있는 코드에서 클래스 변수에 접근하여 사용할 수 있습니다. 

 

import threading

class Count(threading.Thread):
    def __init__(self, num):
        threading.Thread.__init__(self)
        self.num = num

    def run(self):

        while self.num > 0:
            self.num = self.num - 1

            print(self.num)

        print('thread exit')


t = Count(10)
t.start()
print('main exit')




3. join 함수

위에서 소개한 코드에는 문제가 있습니다. 스레드를 생성하는 두가지 방식 다 해당됩니다.  start 함수를 실행한 후, 다음 줄의 코드를 바로 실행하기 때문에 스레드가 종료되기 전에 메인 스레드가 먼저 종료될 수 있습니다. main이 종료된 후 스레드가 종료될 수도 있지만 대부분의 경우  스레드가 먼저 종료되는 것이 좋습니다. 



실행을 해보면 스레드보다 먼저 main이 종료된 적도 있고  스레드가 종료된 후 main이 종료되기도 합니다. 

 

 



start 함수를 호출한 다음 바로 join 함수를 호출하면 스레드가 종료될때 까지 대기하게 됩니다.  이젠 스레드보다 main이 먼저 종료되는 상황이 발생하지 않게 됩니다. 

import threading

def count(num):
   
    while num > 0:
        num = num - 1
        print(num)
    print('thread exit')

t = threading.Thread(target=count, args=(10, ))
t.start()
t.join() # join으로 스레드가 종료되길 기다립니다.
print('main exit')







4. 데몬 쓰레드

데몬 스레드로 지정하면 main이 종료될때 스레드도 같이 종료됩니다. daemon 속성을 True로 지정하면 데몬 쓰레드가 되며 daemon 속성을 지정하지 않을때 디폴트값은 False입니다.  갑자기 스레드가 종료되기 때문에  경우에 따라서는 아래 예제의 print 문 같은 이유로 에러가 발생할 수 있어 보입니다. 

import threading

def count(num):
   
    while num > 0:
        num = num - 1
        # print(num)
    print('thread exit')


t = threading.Thread(target=count, args=(10000000, )) # 피시에 따라선 더 큰 값을 사용해야 합니다.
t.daemon = True
t.start()
print('main exit')




실행해보면 main이 먼저 종료되면서 스레드도 같이 종료되버리기 때문에 스레드에서 종료시 출력되던 메시지가 보이지 않습니다.

 



스레드에서 print 문을 실행하는 중에 main이 종료되어 스레드도 같이 종료되면 다음과 같은 에러가 발생합니다. 

그래서 print 문을 주석처리 해두었습니다. 

 

Fatal Python error: _enter_buffered_busy: could not acquire lock for <_io.BufferedWriter name='<stdout>'> at interpreter shutdown, possibly due to daemon threads
Python runtime state: finalizing (tstate=0x125e0a580)

Current thread 0x00000001044d8580 (most recent call first):
<no Python frame>
zsh: abort      /opt/homebrew/bin/python3 /Users/webnautes/Python_Example/t.py




5. Concurrent.futures 모듈

python 3.2 버전 이상부터 concurrent.futures 모듈이 제공되었습니다. 스레드에서 리턴값을 받아 올 수 있습니다. 

 

from concurrent import futures

def sum(i, num):
   
    sum = 0
   
    for n in range(num):
        sum = sum + n

        print(f'[{i}] {n}')

    print(f'thread {i} exit')

    return i, sum

with futures.ThreadPoolExecutor() as executor:

    # list comprehension 을 통한 병렬 실행하고 리턴값을 받아옵니다
    results = [executor.submit(sum, i, 10) for i in range(3)]


//스레드의 리턴값을 출력합니다.
for f in futures.as_completed(results):
    print(f.result())


print('main exits')




실행 결과입니다. 스레드가 모두 종료된 후에 main이 종료됩니다. 

 




6. 전역 변수 공유

두개의 스레드가 하나의 전역변수를 사용하려고 경쟁하는 상황을 만들었습니다. 

 

count_plus 함수를 실행하는 스레드는 전역 변수 number에 1을 더하고, count_minus 함수를 실행하는 스레드는 전역 변수 number에서 1을 뺍니다. 

 

같은 개수 num만큼 연산을 수행하기 때문에 두 개의 스레드가 종료 후에 전역 변수 number의 값은 0이 되어야 하는데 다른 값이 출력됩니다.( 만약 0이 출력된다면 args 파라미터의 숫자를 더 증가시켜 보세요.)  

 

한 스레드가 전역 변수의 값을 바꾸는 도중에 다른 스레드도 전역 변수의 값을 바꾸려고 시도하기 때문에 문제가 발생하는 것입니다. 

 

import threading

def count_plus(num):
    global number

    for i in range(num):
        number = number + 1 # 공유 변수에서 1을 더합니다.

    print('thread 1 exit')


def count_minus(num):
    global number

    for i in range(num):
        number = number - 1 # 공유 변수에서  1을 뺍니다.

    print('thread 2 exit')



number = 0 # 전역 변수입니다. 스레드간에 공유합니다.


t1 = threading.Thread(target=count_plus, args=(1000000, ))
t1.start()
t2 = threading.Thread(target=count_minus, args=(1000000, ))
t2.start()

t1.join()
t2.join()

print(number)
print('main exit')


 

 




파이썬에서 제공하는 뮤텍스(Mutex)인 Lock을 사용하면 이 문제를 해결할 수 있습니다. 이제 전역변수 number의 값은 항상 0이 됩니다. 

import threading

def count_plus(num):
    global number

    for i in range(num):
        lock.acquire() # 전역 변수 접근을 금지합니다.
        number = number + 1 # 공유 변수에서 1을 더합니다.
        lock.release() # 이제 전역 변수 접근을 할 수 있습니다.

    print('thread 1 exit')


def count_minus(num):
    global number

    for i in range(num):
        lock.acquire()  # 전역 변수 접근을 금지합니다.
        number = number - 1 # 공유 변수에서  1을 뺍니다.
        lock.release()  # 이제 전역 변수 접근을 할 수 있습니다.

    print('thread 2 exit')



number = 0  # 전역 변수입니다. 스레드간에 공유합니다.
lock = threading.Lock()  # 뮤텍스 객체를 전역으로 선언하여 스레드간에 공유하도록 합니다.


t1 = threading.Thread(target=count_plus, args=(1000000, ))
t1.start()
t2 = threading.Thread(target=count_minus, args=(1000000, ))
t2.start()

t1.join()
t2.join()

print(number)
print('main exit')




7. GIL(Global Interpreter Lock)

 

파이썬에서는 하나의 프로세스 안에 모든 자원의 락(Lock)을 글로벌(Global)하게 관리함으로써 한번에 하나의 쓰레드만 자원을 컨트롤하여 동작합니다. 

 

GIL때문에 CPU를 사용하는 위주의 처리를 스레드로 바꾼다고 해서 큰 이득이 없습니다. 동시에 여러 개의 스레드가 CPU를 사용할수 없고 특정 시간에는 하나의 스레드만 CPU를 사용할 수 있기때문입니다. 하지만 I/O 처리가 있다면 스레드가 I/O 처리를 하는 동안 다른 스레드가 CPU를 사용하여 처리할 수 있으므로 성능 향상을 얻을 수 있습니다.  




8. Thread vs Process

 

쓰레드는 가볍지만 GIL로 인해 CPU를 사용하여 계산 처리를 하는 작업은 한번에 하나의 쓰레드에서만 작동하기때문에 CPU를 사용한 작업이 적고 I/O 작업이 많은 경우에 병렬처리 효과를 볼 수 있습니다. 

 

반면 프로세스는 각자가 고유한 메모리 영역을 가지기 때문에 스레드에 비해 더 많은 메모리를 필요로 하지만, 각각 프로세스에서 병렬로 CPU 작업을 할 수 있기 때문에 성능이 좋아지는 효과를 볼 수 있습니다.

 

각각의 프로세스가 자신만의 메모리 공간을 사용하기 때문에 프로세스와 데이터 교환을 하려면 multiprocessing.Queue 객체를 사용해야 합니다. 




9. 프로세스 생성하여 실행하기 

 

GIL의 영향을 받지않는 프로세스(process)를 생성하여 실행하기 위해  multiprocessing 모듈을 사용할 수 있습니다. 



from multiprocessing import Process

def count(num):
   
    while num > 0:
        num = num - 1
        print(num)

    print('process exit')


if __name__ == '__main__': # Process를 사용하려면 꼭 적어줘야 합니다.
   
    t = Process(target=count, args=(10,))
    t.start()
    t.join()

    print('main exit')





실행 결과는 스레드와 동일합니다.

 





참고

http://pythonstudy.xyz/python/article/24-쓰레드-Thread

 

https://yeonfamily.tistory.com/4

 

https://zephyrus1111.tistory.com/111 

 

https://monkey3199.github.io/develop/python/2018/12/04/python-pararrel.html 

 

https://velog.io/@soojung61/Python-Thread 

 

https://stackoverflow.com/a/58829816 



반응형

문제 발생시 지나치지 마시고 댓글 남겨주시면 가능한 빨리 답장드립니다.

도움이 되셨다면 토스아이디로 후원해주세요.
https://toss.me/momo2024


제가 쓴 책도 한번 검토해보세요 ^^

+ Recent posts