22장. 스레드(Thread)

45474 단어 pythonpython

스레드

프로세스와 스레드

운영체제에서 어떤 실행 프로그램이 실행된다는 것은 CPU, 메모리, SSD와 같은 컴퓨터 자원을 사용한다. 따라서 운영체제는 프로그램들이 마음껏 실행될 수 있도록 전용 ‘놀이터’와 같은 공간을 제공해주는데 이를 프로세스라고 한다.

  • 프로그램(Program) : 작업을 위해서 실행할 수 있는 파일, 설계도(코드)와 같은 역할
  • 프로세스(Process) : 실행되고 있는 컴퓨터 프로그램

응용 프로그램의 코드는 이 놀이터에서 마음껏 놀 수(실행 할 수) 있으며 외부 세계에 대해서 걱정할 필요가 없다. 하지만 만약 어떤 코드가 자신에게 부여 받은 놀이터 공간를 벗어나 다른 영역으로 가려면 하면 운영체제에 의해 종료된다.

즉 프로세스는 실행 중인 하나의 프로그램을 지칭하는 것이다.(예를 들어 '크롬'이라는 하나의 프로그램을 두번 실행시키면 2개의 프로세스가 실행된다.)

놀이터에는 응응 프로그램이 놀 수 있다. 운영체제 입장에서 놀이터에 있는 플레이어를 스레드라고 부른다. 어떤 응용 프로그램은 한 번에 여러 가지 작업을 수행해야 하는 경우도 있다. 이 경우 동일한 놀이터(프로세스)에 두 아이(스레드)가 있는데 놀이터에 있는 모든 장난감(컴퓨터 자원)은 공유한다고 생각하면 된다.

만일 한 작업실에서 여러 팀이 돌아가면서 일을 한다고 가정하자. 이때 TV, 칠판과 같은 자원들은 각 팀이 가지고 다니는 것보다 공유를 하는 편이 효율적이다.

  • 스레드(Thread) : 프로세스의 실행단위

스레드란?

사용자가 이용하는 PC에는 윈도우, macOS, 리눅스와 같은 운영체제가 설치되어 있다.

운영체제는 컴퓨터를 전체적으로 관리하는 매니저 역할을 한다. 우리가 프로그램이라고 부르는 것들은 운영체제 위에서 동작한다. 프로그램이 메모리에 올라가서 실행 중인 것을 프로세스(Process)라고 부른다. 프로세스의 실행 단위를 스레드(Thread)라고 한다.
프로세스는 최소 하나 이상의 스레드를 갖으며 경우에 따라 여러 스레드를 가질 수도 있다.즉, 하나의 프로그램을 실행해서, 내부적으로 여러가지 작업을 처리하는 것을 스레드라고 칭한다.(카카오톡-채팅스레드, 파일전송 스레드 등…)

만일 윈도우를 사용할 때를 가정해보자. 메신저도 사용하고 게임도 하고 문서 작성도하고 인터넷도 사용할 것이다. 윈도우는 동시에 실행되는 여러 프로그램들을 잘 관리해야 하는데 이런 작업을 CPU스케줄링이라고 한다. 이 때 운영체제는 스케쥴의 단위로 앞서 설명한 스레드를 사용한다. 만일 프로그램을 작성할 때 여러 개의 스레드를 사용할 수 있는데 이를 멀티스레드라고 부른다. 이처럼 프로그램을 작성할 때 멀티스레드 형태로 구현을 하면 운영체제에 의해서 동시에 스케줄링(동시처럼 느껴지는) 될 수 있기 때문에 보통 성능이 더 좋아진다.

메인 스레드

메인 스레드란 파이썬 인터프리터가 제일 먼저 시작하는 부분을 말한다.
메인 스레드를 통해 여러 개의 서브(작업)스레드를 생성할 수 있다. 이 때 스레드들을 병렬로 코드를 실행할 수 있으며, 그것이 곧, 멀티 테스킹을 수행하는 것이다.

만일 싱글 스레드가 아닌 멀티 스레드의 형태의 프로세스의 종료는 각 스레드가 독립적이다. 메인 스레드가 종료가 되었다고 하더라도 다른 실행중인 서브 스레드가 하나라도 있다면 프로세스는 종료되지 않는다.

threading 모듈

파이썬의 스레드 사용은 thread , threading 모듈이 있다. 보통은 후자를 더 많이 사용한다.


import threading
import time

# 스레드 클래스 정의
# 스레드가 되기 위해서는 threading.Thread 클래스를  반드시 상속받아야한다.
class Worker(threading.Thread):
    # 생성자
    def __init__(self, name):
        super(Worker, self).__init__()  # 조상클래스 생성자 호출
        self.name = name                # 스레드 이름
    # 특정 스레드가 먼저 시작하였다 하더라도 CPU 스케줄러에 따라서
    # 종료하는 스레드의 순서가 얼마든지 바뀔수가 있다.
    def run(self):
        print("작업 스레드 시작 : ",threading.currentThread().getName())
        time.sleep(3)       # 3초간 스레드 일시정지
        print("작업 스레드 종료 : ",threading.currentThread().getName())

if __name__ == '__main__':
    print("메인 스레드 시작")
    for i in range(5):
        name = "스레드 -> {}번".format(i)
        t = Worker(name)
        t.start()               #작업 스레드 생성
    print("메인 스레드 종료")     # 작업 스레드에 구현되어 있는 run() 메서드를 자동호출

# 결과는 매번 달라진다.
첫 번째 출력 결과:
메인 스레드 시작
작업 스레드 시작 :  스레드 -> 0번
작업 스레드 시작 :  스레드 -> 1번
작업 스레드 시작 :  스레드 -> 2번
작업 스레드 시작 :  스레드 -> 3번
작업 스레드 시작 : 메인 스레드 종료
 스레드 -> 4번
작업 스레드 종료 : 작업 스레드 종료 :  스레드 -> 4번작업 스레드 종료 :   작업 스레드 종료 : 스레드 -> 1번 스레드 -> 3번

두 번째 출력 결과:
메인 스레드 시작
작업 스레드 시작 :  스레드 -> 0번
작업 스레드 시작 :  스레드 -> 1번
작업 스레드 시작 : 작업 스레드 시작 :  스레드 -> 3번
 스레드 -> 2번
작업 스레드 시작 :  스레드 -> 4번
메인 스레드 종료

클레스의 def run() 메서드는 start() 메서드를 호출할 때 호출된다.
위의 메인 스레드와 5개의 서브 스레드는 운영체제의 스케줄러에 의해 스케줄링 되면서 실행된다.
스케줄링은 사용자가 관여할 수 있는 부분이 아니기에 매번 실행결과가 달라지는 것이다.

위의 경우 메인 스레드가 끝났음에도 불구하고 프로그램이 종료되지 않았다. 이는 서브 스레드가 종료되지 않았기 때문이다. 만일 서브 클래스의 모든 작업이 종료된 후 메인 스레드로 넘어가고자 한다면 .join()을 사용해야한다.

Fork와 join()


위의 그림처럼 메인 스레드가 서브 스레드를 생성하는 것을 fork라고 칭한다. 위의 그림의 경우 메인 스레드 1개와 서브 스레드 5개이기에 총 6개의 스레드가 스케줄링된다.

join은 모든 스레드가 작업을 마칠때 까지 기다리라는 것을 의미한다.
보통 분할된 데이터가 모든 스레드에서 처리될 때까지 기다렸다가 그 결과를 메인 스레드가 다시 추후 작업을 하는 경우에 사용한다. 즉 데이터를 여러 스레드를 통해서 병렬로 처리한 후 그 값들을 다시 모아서 순차적으로 처리해야할 필요가 있을 때 사용된다.

import threading
import time

# 스레드 클래스 정의
# 스레드가 되기 위해서는 threading.Thread 클래스를  반드시 상속받아야한다.
class Worker(threading.Thread):
    # 생성자
    def __init__(self, name):
        super(Worker, self).__init__() # 조상클래스 생성자 호출
        self.name = name                # 스레드 이름
    # 특정 스레드가 먼저 시작하였다 하더라도 CPU 스케줄러에 따라서
    # 종료하는 스레드의 순서가 얼마든지 바뀔수가 있다.
    def run(self):
        print("작업 스레드 시작 : ",threading.currentThread().getName())
        time.sleep(5)       # 5초간 스레드 일시정지
        print("작업 스레드 종료 : ",threading.currentThread().getName())

if __name__ == '__main__':
    print("메인 스레드 시작")

    # 2개의 스레드가 생성되고 시작된다.
    t1 = Worker("1")    # 작업스레드를 생성
    t1.start()          # run메소드를 호출
    t2 = Worker("2")   # 작업스레드를 생성
    t2.start()         # run메소드를 호출
    t3 = Worker("3")   # 작업스레드를 생성
    t3.start()         # run메소드를 호출

    # 작업스레드가 join메서드를 호출하는 지점에서 메인스레드가 기다린다.
    t1.join()           # t1 스레드가 종료될 때까지 기다려라라는 의미
    t2.join()          # t2 스레드가 종료될 때까지 기다려라라는 의미
    t3.join()          # t3 스레드가 종료될 때까지 기다려라라는 의미
    print("메인 스레드 종료")    # 작업 스레드에 구현되어 있는 run() 메서드를 자동호출

출력 결과:
메인 스레드 시작
작업 스레드 시작 :  1
작업 스레드 시작 :  2
작업 스레드 시작 :  3
작업 스레드 종료 : 작업 스레드 종료 :  2작업 스레드 종료 :  1
 3

메인 스레드 종료

위의 코드에서 join() 메서드를 통해 메인 스레드의 "메인 스레드 종료"는 여러번 프로그램을 실행한다 해도 마지막에 실행됨을 확인할 수 있다.

스레드의 재사용과 winsound를 사용한 예제.

아래의 코드는 멀티 스레드로 "삐~~~"라는 문자열과 비프음이 동시에 실행되는 코드이다.

import winsound
import time
import threading

class BeepSound(threading.Thread):
    def __init__(self):
        super(BeepSound, self).__init__()

    def run(self):
        fr = 1500	# 주파수(37 ~ 32767)
        du = 1000	# 지속시간, 1000ms -> 1초 	
        # 윈도우의 비프음을 발생시크는 코드
        winsound.Beep(fr, du)

if __name__ == '__main__':
    for i in range(3):
        beep = BeepSound()
        beep.start()
        print('삠~~~~')
        beep.join()

# 비프음을 내는 스레드 클래스 정의
class BeepThread(threading.Thread):
    def __init__(self, name):
        super().__init__()
        self.name = name
    def run(self):
        print(threading.currentThread().getName())
        for _ in range(3):
            fr = 2000
            du = 1000
            winsound.Beep(fr, du)
            time.sleep(1)

# 메인스레드 코드 작성
if __name__ == '__main__':
    thread = BeepThread('비프스레드')
    thread.start()  # run() 자동호출
    print(threading.currentThread().getName())

    # "삐~~~" 문자열을 출력시키는 코드
    for i in range(3):
        print('삐~~~')
        time.sleep(2)

    # 위와 같이 두 개의 스레드로 병렬성과 동시성을 이용하여 '삐~~~"라는
    # 문자열 출력과 비프음을 동시에 출력을 할 수 있음을 알 수가 있다.

   
    #thread.start()

메인 스레드의 마지막에 주석처리된 'thread.start() 문장이 있다. 이는 얼핏보기에는 run() 메서드를 다시 호출시키는 결과를 낳는것 같으나, 실제로는 오류를 발생시킨다.
즉 스레드는 재사용이 안된다. 오직 한번만 실행을 한다. 그렇기에 다시 비프음을 내고자 한다면

	thread_1 = BeepThread("비프스레드1")
    thread_1.start()

와 같이 다시 스레드 인스턴스를 생성하여 start() 호출해야한다.

데몬(daemon)스레드(종속 스레드)

데몬(daemon) 스레드는 메인 스레드가 종료될 때 자신의 실행상태와 상관없이 종료되는 서브 스레드들을 의미한다.
보통의 서브 스레드들은 메인 스레드의 작업 종료여부와 관계없이 독자적으로 종료된다.
하지만 데몬스레드로 설정하게되면 메인 스레드에 종속되어 자신의 작업에 관계없이 메인 스레드가 종료될 때 같이 종료된다.
예를 들어 블로그 글을 작성할 때, 특정 시간이 지나면 독자적으로 임시저장이된다. 이 때 글 작성을 종료하면 임시저장기능도 같이 종료된다. 임시저장기능이 데몬스레드로 되어있음을 확인할 수 있다.

파이썬에서 데몬 스레드를 만들고자 한다면 threading 모듈에서 daemon 속성에 True 값(deFalut = False)을 넣어줘야한다.

import time
import threading

class Worder(threading.Thread):
    # 생성자
    def __init__(self, name):
        super(Worder, self).__init__()
        self.name = name
    def run(self):
        print("작업스레드의 시작: ",threading.currentThread().getName())
        time.sleep(3)
        # daemon 속성으로 인해 메인 스레드가 종료됨에 따라 아래의 코드는 실행되지 않는다.
        print("작업스레드의 종료: ",threading.currentThread().getName())
if __name__ == '__main__':
    print('메인 스레드의 시작')
    for i in range(5):
        name = '{}'.format(i)
        thread = Worder(name)
        # print("thread가 데몬인가1? : ", thread.isDaemon())
        # daemon 변수에 저장되어 있는 스레드 인스턴스를 데몬 스레드로 바꾼다
        # dammon 속성의 디폴트 값은 False 이지만, 데몬 스레드로 만들기 위해서는 True 값을 줘야한다.
        thread.daemon = True
        # print("thread가 데몬인가2? : ", thread.isDaemon())
        thread.start()
    print("메인 스레드의 종료")

출력 결과:
메인 스레드의 시작
작업스레드의 시작:  0
작업스레드의 시작:  1
작업스레드의 시작:  2
작업스레드의 시작:  3
작업스레드의 시작: 메인 스레드의 종료

동시성과 병렬성

프로그램을 구현하였을 때 성능이 부족한 경우가 발생한다. 이 때는 알고리즘 개선을 통해 성능을 개선할 수도 있지만 한계가 있다. 이럴 때는 동시성과 병렬성을 고려해봐야한다.

동시성(concurrency)
만일 한 사람 A, B, C 라는 작업이 있다고 할 때, 각 작업을 잘게 나누어 조금씩 번갈아 가며 하는 방법이 있다. 이를 굉장히 빠른 속도로한다면 A, B, C를 동시에 하는 것처럼 보일 것이다.
CPU에서 멀티테스킹을 하는 것처럼 보이는 작업들은 실제로는 수많은 프로세스, 스레드를 실행한 그 순간, 하나씩 번갈아가며 처리하는 것이다.
하나의 코어가 스레드를 실행 상태, 실행 대기 상태를 번갈아가며 일을 처리하는 방식이다.

병렬성(parallelsim)
동시성은 한 사람이서 작업을 번갈아가며 하는 것을 말하면 병렬성은 그 사람이 여러명인 것을 말한다.
멀티 개의 코어가 하나씩 스레드를 맡아서 독립적으로 진행하여 방해를 받지 않는다는 것이다. 요즘은 대부분 코어가 4개지만 8개짜리 16개짜리도 지금 현재 나오고 있으니 코어가 많으면 많을수록 그만큼 스레드를 빨리 처리하므로 처리 속도가 빠르다고 보면 된다.

스레드의 동기화

스레드의 문제점

Thread는 모든 자원을 공유한다. 이러한점으로 인해 문제가 발생할 수 있다.
만일 서로 다른 스레드가 동시에 공유 자원(객체)에 접근한다면 의도치않은 결과를 초래한다.
예를 들어 두 명의 사람이 한 개의 버튼을 동시에 누르면 한번 눌러진 결과와 같은 것이다.

# Lock 클래스를 사용하지 않은 예제.
import threading
totalCount = 0

class CounterThread(threading.Thread):
	# 생성자 
    def __init__(self):
        super(CounterThread, self).__init__()
    def run(self) -> None:
        global totalCount # global 키워드로 메소드 내의 전역 변수 선언
        for _ in range(2500000):
            totalCount += 1
        # thread는 이름을 주지 아니할 경우 1부터 시작한다.
        print(threading.currentThread().getName(), "스레드 2,500,000 누적 종료")

# 메인코드 작성
if __name__ == '__main__':
    for _ in range(4):
        counterThread = CounterThread()
        counterThread.start()
    print('모든 스레드들이 종료 될 때까지 기다립니다.')
    # currnetThread() 를 통해 현재 실행되고 있는 thread를 받는다.
    mainThread = threading.currentThread()
    # threading 모듈의 enumerate() 메소드는 현재 active 활동하는
    # 모든스레드를 리스트로 반환한다.
    print("현재 Active 한 스레드 이름: ", threading.enumerate())
    for thread in threading.enumerate():
        # mainThread 를 제외한 모든 스레드들이 작업을 완료하고 끝날때 까지 기다리게하는 코드
        if thread is not mainThread:
            thread.join()
    totalCount = format(totalCount,",")
    print("totalCount의 값: ",totalCount)

출력 결과:
모든 스레드들이 종료 될 때까지 기다립니다.
Thread-2 스레드 2,500,000 누적 종료
Thread-1 스레드 2,500,000 누적 종료
Thread-4 스레드 2,500,000 누적 종료
Thread-3 스레드 2,500,000 누적 종료
totalCount의 값:  5,496,295

위의 코드의 결과는 예상한 값, 10,000,000이 나오지 않았다. 이러한 결과의 이유는 5개의 스레드가 동시에 전역 변수, totalCount에 접근하는 경우가 발생했기 때문이다.
위에서 예를 빗대어서 설명하자면 5명이서 하나의 버튼가지고 작업중인데, 서로를 신경쓰지고 않고 버튼을 누르는 것이다.

이러한 문제를 해결하기 위해서는 스레드의 동기화 처리가 필요하다.

스레드의 동기화 처리

동기화하는 방법 중 하나는 threading 모듈의 Lock() 클래스를 이용하는 것이다.
Lock 은 특정스레드에서 변수를 사용하기 시작했다면 다른 스레드는 접근하지 못하게 막는 역할을 한다. 이는 변수를 잠그는 것과 유사하여 Lock 이라 칭한다.

다른 스레드의 접근을 막는 것은 acquire를 통해 잠근다. 이후 작업이 끝났으면 접근을 허용하기 위해 release를 통해 해제한다. 이 때 이 둘의 사이에 있는 영역을 임계영역이라 한다. 이는 둘 이상의 스레드가 동시에 접근해서는 안되는 공유자원을 지정하느 코드의 일부를 말한다.

이러한 잠금을 통해 하나의 변수에 서로 다른 스레드가 동시에 접근하는 것을 막는다. 하지만 속도가 느리다는 단점이 존재한다. 허나 현시대에서 가장 중요한 데이터의 신뢰성을 보장받을 수 있다.

  • name = threading.Lock() # Lock() 클래스 생성
  • name.acquire() # 잠금 - 다른 스레드의 접근을 막는다.
  • 여기 안에 있는 코드들은 무조건 하나의 스레드에 의해서 순차적으로 실행된다.
  • name.release() # 잠금 해제 - 다른 스레드의 접근이 가능하도록 잠금을 푼다.
import threading
totalCount = None
# 공유된 변수를 위한 클래스를 작성
class ThreadVariable():
    def __init__(self):
        self.lock = threading.Lock()    # 멤버변수로 Lock 클래스의 인스턴스를 생성함
        self.lockvalue = 0
    # 누적시키는 메소드 정의(동기화 처리 메소드)
    def plus(self, value):
        self.lock.acquire()     # 다른 스레드들의 접근을 막음
        self.lockvalue += 1
        self.lock.release()     # 다른 스레드들의 접근을 허용

class CounterThread(threading.Thread):
    def __init__(self):
        super(CounterThread, self).__init__()
    def run(self):
        global totalCount
        for _ in range(2500000):
            totalCount.plus(1)
        print('2,500,000 누적 완료!')
if __name__ == '__main__':
    totalCount = ThreadVariable()
    for _ in range(4):
        lockThread = CounterThread()
        lockThread.start()
    print("모든 스레드들이 종료될 때까지 기다립니다.")
    mainThread =threading.currentThread()
    for thread in threading.enumerate():
        if thread is not mainThread:
            thread.join()
    total = format(totalCount.lockvalue, ',')
    print("누적된 총 값(totalCount.lockvalue): ",total)
    
 출력 결과:
 모든 스레드들이 종료될 때까지 기다립니다.
2,500,000 누적 완료!
2,500,000 누적 완료!
2,500,000 누적 완료!2,500,000 누적 완료!

누적된 총 값(totalCount.lockvalue):  10,000,000

좋은 웹페이지 즐겨찾기