[TIL - 11 / Python] Thread

haejun-kim·2020년 7월 27일
1

[Python]

목록 보기
17/19
post-thumbnail

Thread

프로그램의 실행 흐름

쓰레드는 프로그램의 실행흐름이다. 하나의 프로세스 안에서 여러개의 쓰레드를 만들 수 있다. 즉 프로세스가 부여된 자원을 이용해서 같은 프로세스 내에서 여러 쓰레드들끼리 자원을 공유할 수 있다.

지금까지 로컬환경에서 작성한 프로그램들은 최소 하나의 프로세스단위와 하나의 쓰레드를 가지게 된다.

쓰레드는 동시성을 위해서 만들어진 개념이다. 하나의 프로세스 안에서 두개 이상의 쓰레드를 만들게 되면 두개 이상의 쓰레드가 동시에 일을 하게 된다.

동시성? 병렬성?

  • 동시성(Concurrency) : 논리적으로 여러 작업이 동시에 실행되는 것처럼 보이는 것
    ex) 한 작업의 연산이 완료되기를 기다리는 동안 다른 작업을 수행하여 유휴 시간을 활용하는것
  • 병렬성(Parallelism) : 물리적으로 여러 작업이 동시에 처리되는 것. 같은 작업을 병렬 처리하는 데이터 병렬성과 서로 다른 작업을 병렬처리하는 작업 병렬성으로 나뉜다.

본격적으로 쓰레드에 대한 실습을 하기전에

파이썬 쓰레드 관련 공식 문서

를 보면서 쓰레드의 사용법에 대한 내용을 조금 더 이해하고 넘어가자.

쓰레드는 다음과 같이 사용할 수 있다.

쓰레드의 사용

class threading.Thread(group=None, target=None, name=None, args=(), kwargs={}, *, daemon=None)

이 생성자는 항상 키워드 인자로 호출해야 한다. 인자는 다음과 같다.

  • group : None이어야 한다. ThreadGroup 클래스가 구현될 때 향후 확장을 위해 예약되어 있다.
  • target : run() 메소드에 의해 호출 될 callable 객체다. 기본값은 None이며, 아무것도 호출되지 않는다.
  • name : 쓰레드의 이름. 기본적으로 고유한 이름이 <<Thread-N>>형식으로 구성되는데, 여기서 N은 작은 십진수를 의미한다.
  • args : target호출을 위한 인자의 튜플이다. 기본값은 ()다.
  • kwargs : target 호출을 위한 인자의 딕셔너리. 기본값은 {}다.
  • None이 아니면, daemon은 쓰레드가 데몬인지를 명시적으로 설정한다. None(기본값)이면, 데몬 속성은 현재 쓰레드에서 상속된다.

Threading

쓰레드의 메소드

  • start : 쓰레드 활동 시작. 쓰레드 객체 당 최대 한 번 호출되어야하며, 객체의 run()메소드가 별도의 제어 쓰레드에서 호출되도록 배치한다.
  • run() : 쓰레드의 활동을 표현하는 메소드. 표준 run() 메소드는 target 인자로 객체의 생성자에 전달 된 callable 객체를 호출한다. 인자에 args, kwargs가 있다면 인자에서 각각 취한 위치와 키워드 인자로 호출한다.
  • join(timeout=None) :
    • 쓰레드가 종료될 때까지 기다린다. join() 메소드가 호출된 쓰레드가 정상적으로 혹은 처리되지 않은 예외를 통해 종료하거나 선택적 시간제한 초과가 발생할 때까지 호출하는 쓰레드를 블록한다.
    • timeout인자가 없거나 None이면, 쓰레드가 종료될 때까지 작업이 블록된다.
    • 쓰레드는 여러번 join()될 수 있다.
  • is_alive() : 쓰레드가 살아있는지를 반환한다. run() 메소드가 시작되기 직전부터 run() 메소드가 종료된 직후까지 True를 반환한다.
  • daemon :
    • 해당 쓰레드가 데몬 쓰레드인지(True) 아닌지(False)를 나타내는 boolean 값.
    • start()가 호출되기 전에 설정되어야한다. 그렇지 않으면 RuntimeError발생한다.
    • 일반 쓰레드는 프로그램이 종료되어도 백그라운드에서 실행되고 있지만, 데몬 쓰레드로 설정하면 프로그램이 종료되면 쓰레드도 함께 종료된다.

ex)

이제 쓰레드는 어떻게 사용되는지 확인해보자. 쓰레드를 생성하는 코드를 작성하기 전에 지금까지 작성된 프로그램을 보고 먼저 비교해보자.

import time

if __name__ == "__main__":
    
    increased_num = 0

    start_time = time.time()
    for i in range(100000000):
        increased_num += 1

    print("--- %s seconds ---" % (time.time() - start_time))

    print("increased_num=",end=""), print(increased_num)
    print("end of main")

컴퓨터의 성능에 따라 차이는 있지만 결과는 다음과 같이 출력됐다.

1억번의 숫자가 찍혔고, 작업을 수행하는데 걸린 총 시간은 약 7.7초가 걸렸다.

이제 직접 쓰레드를 두개 만들어서 코드를 작성하고 실행해보자.

파이썬에서 멀티 쓰레드를 사용하려면 threading모듈을 임포트해서 사용한다. threading 모듈의 Thread 클래스의 target 인자에 쓰레드 함수를 지정해주고 args로 매개변수를 전달할 수 있다.

하나의 쓰레드에서 천만까지 증가시키는 코드를 두개의 쓰레드로 분리해서 오백만씩 증가시키면 프로그램의 동작 시간이 절반으로 줄어들까? 다음의 코드를 실행시켜보자.

import threading
import time

shared_number = 0

def thread_1(number):
    global shared_number
    print("number = ",end=""), print(number)
    
    for i in range(number):
        shared_number += 1

def thread_2(number):
    global shared_number
    print("number = ",end=""), print(number)
    for i in range(number):
        shared_number += 1

if __name__ == "__main__":

    threads = [ ]

    start_time = time.time()
    t1 = threading.Thread( target= thread_1, args=(50000000,) )
    t1.start()
    threads.append(t1)

    t2 = threading.Thread( target= thread_2, args=(50000000,) )
    t2.start()
    threads.append(t2)

    for t in threads:
        t.join()

    print("--- %s seconds ---" % (time.time() - start_time))

    print("shared_number=",end=""), print(shared_number)
    print("end of main")

다음과 같이 결과가 출력됐다.

Q-2)

기대와는 다르게 천만이라는 숫자가 출력되지도 않았고, 시간도 절반으로 줄지도 않았다. 왜그럴까?

위의 질문에 대한 답을 하기 위해선 파이썬의 GIL(Global Interpreter Lock)에 대해서 알아야한다.
GIL : 하나의 쓰레드에게 모든 자원의 점유를 허락하는것. 즉, 다른 쓰레드는 자원을 acquire하기 전에는 아예 실행조차 될 수 없다. 이런 GIL 하에서는 코어가 몇개든 상관없이 특정 시점에서 하나의 코어만 실행된다. 파이썬은 정수, 변수 하나까지 모두 객체로 다루기때문에 이렇게 객체마다 lock을 걸어야 하는 작업은 매우 비효율적이기 때문에 파이썬은 인터프리터를 lock을 건 것이다. 따라서 쓰레드 갯수, CPU의 코어와 관계없이 인터프리터를 사용하는 쓰레드는 오직 한개로 만들었다.

위의 내용을 토대로 결론을 지어보면, 두개의 CPU가 각각의 쓰레드를 실행시키길 바라면서 두개의 쓰레드를 실행시켰다.하지만 실제로는 파이썬의 GIL에 의해서 하나의 CPU에서 두개의 쓰레드를 실행시킨것이기 때문에 예상했던 성능 향상의 결과가 나오지 않은 것이다.

Q-3)

그럼 위의 코드를 기반으로 쓰레드에서 각각 50000000씩 증가시켜서 shared_number를 천만으로 증가시키는 코드를 구현해보자. ( with 조건변수, 뮤텍스 )

코드를 구현하기 전에 뮤텍스와 조건변수에 대해서 알고 넘어가자.

뮤텍스

Mutex(Mutual Exclusion object) : 쓰레드들 간에서 공유가 배제되는 객체

파일과 같은 공유 자원이 수행 중 오직 한 프로그램이나 쓰레드에게만 소유되어야 할 필요가 있을 때 그 자원에 대한 뮤텍스 객체를 생성시킨다. 뮤텍스가 비신호 상태이면 프로그램은 자원을 점유하여 사용한 후 이를 반환하고, 다른 프로그램 또는 다른 쓰레드가 자원을 사용중(뮤텍스가 신호대기상태면) 대기 상태로 들어가 끝나기를 기다린다.
뮤텍스는 다음과 같은 특징을 가지고 있다.

  • 하나의 쓰레드만 크리티컬 섹션에 들어갈 수 있다.(그 영역에 하나의 쓰레드가 있다면 들어갈 수 없다.)
  • 뮤텍스를 생성하고 난 뒤 파괴하고 싶다면, 반드시 뮤텍스를 unlock하고 파괴해야한다.

뮤텍스는 다음과 같은 순서로 동작한다.

  1. 뮤텍스를 통해 lock을 걸고 푼다.
  2. lock~unlock 부분을 모두 수행할 때 까지 다른 쓰레드가 수행이 되지 않고 wait상태로 있는다.
  • 뮤텍스의 개념으로 작성해 본 코드
import threading
import time

shared_number = 0
lock = threading.Lock()

def thread_1(number):
    global shared_number
    print("number = ",end=""), print(number)
    for i in range(number):
        lock.acquire()
        shared_number += 1
        lock.release()

def thread_2(number):
    global shared_number
    print("number = ",end=""), print(number)
    for i in range(number):
        lock.acquire()
        shared_number += 1
        lock.release()
        

if __name__ == "__main__":
    threads = [ ]

    start_time = time.time()
    t1 = threading.Thread( target= thread_1, args=(50000000,) )
    t1.start()
    threads.append(t1)

    t2 = threading.Thread( target= thread_2, args=(50000000,) )
    t2.start()
    threads.append(t2)

    for t in threads:
        t.join()

    print("--- %s seconds ---" % (time.time() - start_time))

    print("shared_number=",end=""), print(shared_number)
    print("end of main")
  • 실행 결과

원하는 출력을 얻을 수 있게 됐다! 그렇지만 시간이 조금 오래걸려 만족스럽지 못하다. 시간이 이렇게 오래걸린 이유는 뮤텍스는 Lock을 확인하고, Lock에 관련된 내용을 수행하고, Unlock을 확인하고, Unlock에 관련된 내용을 수행한다.
위의 코드가 단순히 숫자 1씩만 증가시켜 1억이 되는 숫자까지 반복시키는 코드인데 1씩 1억까지의 숫자를 누적하고, Lock, unlock의 동작을 계속 반복하면서 시간이 오래 걸렸다고 생각한다.

조건변수

다른 쓰레드에서 신호를 줄 때까지 기다릴 수 있는 컨디션 변수 객체

조건변수(Condition Variable)는 내부에 쓰레드 대기 큐를 갖고 있다. wait() 메소드가 호출된 쓰레드는 이 대기 큐에 들어가게 되고 sleep 상태가 되며, notify()notifyAll() 메소드에의해 깨어나게 된다. 처음에는 이 내용이 이해하기 쉽지 않았는데 이런 예시를 보고 조금 더 이해할 수 있었다.

내가 어떤 방으로 들어갈 수 있는 키(acquire)를 가지고 있다. 그리고 그 방에 들어가서 커피를 다섯잔 마시려고 한다. 방에 막상 들어가니 커피가 세잔밖에 없어서 커피 세잔을 마시고 두잔이 더 만들어지기를 wait()한다. 이제 커피를 만들어 줄 직원이 내가 내려놓은 키(acquire)를 가지고 들어가서 커피를 만든다. 커피를 다 만들면 다 만들었다고 알려주고(notify), 갖고 있던 키를 내려놓는다(release). 그러면 이제 기다리고 있었던 나는 키를 가지고 다시 진행을 하게 되는거다.

조건변수를 사용하여 위의 코드를 조건에 맞게 실행시키기 위해서 다음과 같이 작성해봤다.

  • 조건변수 사용으로 작성해 본 코드
import threading
import time

shared_number = 0

def thread_1(number):
    global shared_number
    print("number = ",end=""), print(number)
    cv.acquire()
    for i in range(number):
        shared_number += 1
    cv.wait()

def thread_2(number):
    global shared_number
    print("number = ",end=""), print(number)
    cv.acquire()
    for i in range(number):
        shared_number += 1
    cv.notify()
    cv.release()
        

if __name__ == "__main__":
    threads = [ ]
    cv = threading.Condition()

    start_time = time.time()
    t1 = threading.Thread( target= thread_1, args=(50000000, ) )
    t1.start()
    threads.append(t1)

    t2 = threading.Thread( target= thread_2, args=(50000000, ) )
    t2.start()
    threads.append(t2)

    for t in threads:
        t.join()

    print("--- %s seconds ---" % (time.time() - start_time))

    print("shared_number=",end=""), print(shared_number)
    print("end of main")
  1. cv = threading.Condition() 객체를 생성해준다.
  2. thread_1이 먼저 실행되고 실행을 모두 끝마치면 wait()시킨다.
  3. thread_2acquire()로 키를 얻어오고, 로직을 실행한다. 모든 작업이 끝나면 notify()하고, release()시켜줬다.

실행 결과는 다음과 같다.

  • 실행 결과

동기화를 시켰기 때문에 시키기 전과 비교했을 때 원하던 값이 제대로 출력됐음을 확인할 수 있다. 또한, Mutex를 사용했을 때보다 시간도 더 단축되었다. 조건변수는 여러가지의 쓰레드가 동작할 때 조건으로 쓰레드를 동작시킬 수 있기 때문에 어떤 조간하에서 쓰레드를 동작시켜야하는 프로그램의 로직을 구현할 때 사용된다.


Reference

Python mutex example
파이썬 공식 문서
Condition Variable 활용한 Producer/Consumer 예제
Python Multithread
뮤텍스 : https://docs.python.org/3/library/threading.html#lock-objects
조건변수 : https://docs.python.org/3/library/threading.html#threading.Condition

0개의 댓글