파이썬 쓰레드

쓰레드(Thread)에 대해 알아보고 파이썬에서 쓰레드를 사용해봅시다.

written by tiaz0128

소프트웨어는 여러 작업을 동시에 처리해야 하는 경우가 많습니다. 이를 위해 프로그램, 프로세스, 쓰레드의 개념을 이해하는 것이 중요합니다.

프로그램(Program)


일반적으로 프로그램이란 하드 디스크(HDD), SSD 같은 디스크에 저장된 정적인 상태의 명령어 집합을 의미합니다. ‘정적인 상태’란 말은, 실행 중이지 않은 상태의 코드를 의미 합니다.

%%{
  init: {
    'theme': 'base',
    'themeVariables': {
      'lineColor': '#2196f3',
      'primaryTextColor': '#fff',
      'tertiaryColor': '#333'
    }
  }
}%%
flowchart LR
    subgraph "Hard Disk"
        Program[Program]
    end

그렇다면 ‘실행 중’이라는 상태는 어떤 것을 의미 할까요? 프로그램이 실행되면 어떻게 되는지 알아봅시다.

프로세스(Process)


프로그램이 실행되면 독립된 메모리 공간을 할당받고 CPU가 명령어를 처리 할 수 있는 상태가 됩니다. 그리고 이런 상태를 ‘프로세스(Process)’라 합니다.

%%{
  init: {
    'theme': 'base',
    'themeVariables': {
      'lineColor': '#2196f3',
      'primaryTextColor': '#fff',
      'tertiaryColor': '#333'
    }
  }
}%%
flowchart LR
    subgraph "Hard Disk"
        Program[Program]
    end

    subgraph "Memory"
        Process[Process]
    end

    Program --- Process

Chrome 브라우저

Chrome 브라우저는 하나의 탭이 하나의 프로세스로 동작하는 멀티 프로세스(Multi Process) 방식으로 동작합니다. 각 탭은 독립적으로 동작하기 때문에 하나의 탭에 오류가 발생해도 다른 탭은 안정적으로 사용 할 수 있습니다.

크롬 탭

> 탭 하나가 말썽이지만 크게 상관없다

프로세스 특징


프로세스의 메모리 구성

각 프로세스는 아래와 같이 독립된 메모리 공간을 할당 받습니다. 그래서 한 프로세스가 비정상 종료되어도 다른 프로세스에 영향을 주지 않고 안정적으로 동작 할 수 있습니다.

%%{
  init: {
    'theme': 'base',
    'themeVariables': {
      'lineColor': '#2196f3',
      'primaryTextColor': '#fff',
      'tertiaryColor': '#333'
    }
  }
}%%
flowchart LR
    subgraph "Hard Disk"
        Program[Chrome.exe]
    end

    subgraph "Memory"
        Process1[Chrome Tab A]
        Process2[Chrome Tab B]
    end

    Program --- Process1
    Program --- Process2

프로세스는 메모리를 크게 4가지의 영역으로 나누어 사용합니다.

%%{
  init: {
    'theme': 'base',
    'themeVariables': {
      'lineColor': '#2196f3',
      'primaryTextColor': '#000',
      'tertiaryColor': '#333'
    }
  }
}%%
flowchart TB
    subgraph "Memory"
        subgraph "Process"
            CS1[Code Section]
            DS1[Data Section]
            HS1[Heap Section]
            SS1[Stack Section]
        end
    end

    style Process fill:#555,stroke:#333,stroke-width:1px

    style CS1 fill:#bbf,stroke:#333,stroke-width:1px
    style DS1 fill:#ddf,stroke:#333,stroke-width:1px
    style HS1 fill:#ffd,stroke:#333,stroke-width:1px
    style SS1 fill:#dfd,stroke:#333,stroke-width:1px

IPC(Inter-Process Communication)

프로세스는 독립적으로 동작하기 때문에 프로세스끼리 통신하기 위한 다양한 방식을 IPC(Inter-Process Communication)라 합니다. 파이프(Pipe), 소켓(Socket), 공유메모리(Shared Memory), 메시지 큐(Message Queue) 등의 방식을 사용합니다. 이런 통신 과정에서 약간의 오버헤드가 발생 할 수 있습니다.

PCB(Process Control Block)

프로세스 내부에 PCB(Process Control Block)라는 데이터 구조를 생성 합니다. 이 PCB에는 프로세스에 대한 다양한 정보가 저장됩니다.

그리고 이 정보를 기반으로 각 프로세스마다 CPU, 메모리 등 시스템 자원을 할당받아 사용합니다.

쓰레드(Thread)


프로세스는 메모리를 할당 받아 명령어를 실행할 수 있는 상태를 뜻했습니다. 그렇다면 프로세스에서 누가 명령어를 실행하는 걸까요? 바로 쓰레드(Thread)가 그 역할을 수행합니다.

thread


  1. (이야기 등의) 가닥[맥락]
  2. (실 등을) 꿰다

쓰레드는 프로세스 내에서 실행되는 작은 실행 단위를 의미합니다. 단어의 뜻처럼 “실행의 흐름” 또는 “제어의 흐름”라고 생각하면 됩니다. 그리고 프로세스는 최소 1개 이상의 쓰레드를 포함합니다.

%%{
  init: {
    'theme': 'base',
    'themeVariables': {
      'lineColor': '#2196f3',
      'primaryTextColor': '#000',
      'tertiaryColor': '#333'
    }
  }
}%%
flowchart BT
    subgraph "Memory"
        subgraph "Process"
            T11[Thread 1]
            CODE1[code]
            CODE2[code]
        end
    end

    T11 --- CODE1
    CODE1 --- CODE2

    style Process fill:#555,stroke:#333,stroke-width:1px

    style T11 fill:#fff,stroke:#333,stroke-width:1px
    style CODE1 fill:#bbf,stroke:#333,stroke-width:1px
    style CODE2 fill:#bbf,stroke:#333,stroke-width:1px

쓰레드의 자원 공유

하나의 프로세스 내에 두 개의 쓰레드가 있다고 가정해 봅시다. 각 쓰레드는 스택만 따로 할당받고 나머지 메모리 공간은 프로세스 내에서 공유하게 됩니다.

flowchart TB
    subgraph CPU["CPU"]
        Registers["레지스터"]
    end

    subgraph Process["Process"]
        subgraph Shared["공유 메모리 영역"]
            direction TB
            Code["Code 영역
(프로그램 코드)"] Data["Data 영역
(전역/Static 변수)"] Heap["Heap 영역
(동적 할당 메모리)"] end subgraph ThreadStacks["쓰레드별 스택"] Stack1["Thread 1 Stack"] Stack2["Thread 2 Stack"] end end %% 스타일 정의 style Process fill:#555,stroke:#333,stroke-width:1px,color:#bbb classDef cpu fill:#ffcdd2,stroke:#d32f2f,stroke-width:2px classDef shared fill:#e1f5fe,stroke:#0288d1,stroke-width:2px classDef thread fill:#e8f5e9,stroke:#4caf50,stroke-width:2px classDef process fill:#f5f5f5,stroke:#333,stroke-width:2px class CPU cpu class Shared shared class ThreadContexts,ThreadStacks thread class Process process

명령을 처리하는 CPU 코어 하나는 한 시점에 하나의 쓰레드만 실행 가능합니다. 때문에 여러 쓰레드가 동시에 실행하는 것처럼 보여도 사실은 여러 쓰레드를 번갈아가면서 빠르게 동작합니다.

컨텍스트 스위칭(context switching)

컨텍스트 스위칭(context switching)은 현재 실행 중인 프로세스/쓰레드를 중단하고 다른 프로세스/쓰레드를 실행하는 것을 뜻합니다. 이때마다 이전의 상태(컨텍스트)를 보관하고 새로운 상태로 전환하는 작업을 수행합니다.

성능

> 출처 : Akka in Action

쓰레드가 많아지면 처리도 빨라질까요? 정답일 수도 있고 아닐수도 있습니다. 왜냐하면 컨텍스트 스위칭도 하나의 작업이기 때문에, 너무 자주 발생하면 오버헤드가 됩니다. 따라서 적절한 균형을 맞추는 것이 중요합니다.

파이썬 쓰레드


파이썬에서는 기본적으로 하나의 프로세스에 하나의 쓰레드가 생성됩니다. 그리고 이 쓰레드를 메인 쓰레드(MainThread)라고 합니다. 메인 쓰레드에서 다른 쓰레드를 생성하고 사용하는 방법을 알아 보겠습니다.

%%{
  init: {
    'theme': 'base',
    'themeVariables': {
      'lineColor': '#2196f3',
      'primaryTextColor': '#000',
      'tertiaryColor': '#333'
    }
  }
}%%
flowchart BT
    subgraph "Memory"
        subgraph "Process"
            T11[Main Thread]
            T12[Sub Thread]

            CODE1[code]
            CODE2[code]
        end
    end

    T11 --- CODE1
    T12 --- CODE2

    style Process fill:#555,stroke:#333,stroke-width:1px

    style T11 fill:#fff,stroke:#333,stroke-width:1px
    style T12 fill:#fff,stroke:#333,stroke-width:1px
    style CODE1 fill:#bbf,stroke:#333,stroke-width:1px
    style CODE2 fill:#bbf,stroke:#333,stroke-width:1px

threading 모듈

파이썬에서는 threading 내장 모듈에 있는 Thread 클래스를 통해 쓰레드를 만들 수 있습니다. start 메서드로 쓰레드를 실행 할 수 있습니다.

import threading

thread = threading.Thread(target=lambda x: print(f"Hello, {x}"), args=("Thread",))

thread.start()
Hello, Thread

threading.Thread 클래스 상속

Thread 클래스를 상속하여 쓰레드 클래스를 정의 할 수 있습니다. target 인자값을 사용하는 대신, run 메서드를 오버라이드하여 쓰레드가 실행할 작업 정의합니다.

import threading

class MyThread(threading.Thread):
    def __init__(self, name: str):
        super().__init__()        
        self.name = name
        
    def run(self):
        print(f"Hello, {self.name}")

if __name__ == "__main__":
    thread = MyThread("MyThread")
    thread.start()

현재 쓰레드 정보

current_thread 메서드를 통해 현재 쓰레드의 정보를 확인 할 수 있습니다.

import threading as th

def sub_thread():
    print(f"{th.current_thread().name} : {th.current_thread().ident}")

thread1 = th.Thread(target=sub_thread)
thread2 = th.Thread(target=sub_thread)

if __name__ == "__main__":
    thread1.start()
    thread2.start()

    print(f"{th.current_thread().name} Done")
Thread-1 (sub_thread) : 139696827188800
Thread-2 (sub_thread) : 140045886248512
MainThread Done

쓰레드의 동시성(Concurrency)


쓰레드는 서로 번갈아가면서 실행되는 동시성(Concurrency) 특징이 있습니다. 이는 실제로 동시에 실행되는 것이 아니라, CPU가 빠르게 쓰레드를 전환하면서 실행하기 때문입니다.

아래의 코드를 실행하면 메인 쓰레드와 서브 쓰레드의 실행 순서가 매번 달라질 수 있으며, 이는 운영체제의 스케줄링에 따라 결정됩니다.

import threading as th

def print_numbers(i: int):
    for j in range(1, i + 1):
        print(f"Sub Thread : {j}")

thread1 = th.Thread(target=print_numbers, args=(100,))

if __name__ == "__main__":
    thread1.start()
    print("MainThread Done")
Sub Thread : 6
Sub Thread : 7
...
MainThread Done
...
Sub Thread: 100

join 메서드


쓰레드가 다른 쓰레드의 실행이 끝날때 까지 대기하는 join 메서드에 대해서 알아봅시다. 작업의 순서를 제어하거나 의존성 있는 작업을 처리할 때 유용하게 사용할 수 있습니다.

import threading as th

def print_numbers(i: int):
    for j in range(1, i + 1):
        print(f"Sub Thread : {j}")

sub_thread = th.Thread(target=print_numbers, args=(100,))

if __name__ == "__main__":
    sub_thread.start()
    sub_thread.join()

    print("MainThread Done")

join 메서드를 사용하면 메인 쓰레드(MainThread)에서 서브 쓰레드(Sub Thread)의 작업이 끝날때까지 기다리고 Done 메세지가 출력되는 것을 볼 수 있습니다.

...
Sub Thread : 99
Sub Thread : 100
MainThread Done

여러개의 쓰레드를 순서적으로 실행하고 싶은 경우는 다음과 같이 사용 할 수 있습니다. thread1의 작업이 모두 끝난 뒤에 thread2의 작업이 시작 되는 것을 확인 할 수 있습니다.

if __name__ == "__main__":
    thread1.start()
    thread1.join()

    thread2.start()
    thread2.join()
    print("MainThread Done")

데몬(daemon) 쓰레드


데몬(daemon) 쓰레드는 메인 쓰레드가 종료되면 함께 종료되는 백그라운드 쓰레드입니다. 주로 메인 쓰레드의 보조 역할을 하는 작업에 사용됩니다. 기존 쓰레드를 daemon 인자값으로 설정하여 만들수 있습니다.

daemon_thread = th.Thread(target=background_task, daemon=True)
import threading as th
import time

def background_task():
    while True:
        print("Daemon thread is running...")
        time.sleep(1)

daemon_thread = th.Thread(target=background_task, daemon=True)

if __name__ == "__main__":
    daemon_thread.start()
    print("Main thread is running...")
    time.sleep(3)
    print("Main thread is ending...")
Main thread is running...
Daemon thread is running...
Daemon thread is running...
Daemon thread is running...
Main thread is ending...
Daemon thread is running...

is_alive 메서드


is_alive 메서드를 사용하여 스레드가 현재 실행 중인지 확인 할 수 있습니다. 하나의 쓰레드에서 다른 쓰레드가 동작을 모니터링하는데 유용합니다. 특히 디버깅이나 로깅 시에 유용하게 활용할 수 있습니다.

예시 : 모니터링

import threading as th
import time

def monitor_thread(thread_to_monitor: th.Thread):
    while thread_to_monitor.is_alive():
        print(f"모니터링: {thread_to_monitor.name} 실행 중")
        time.sleep(0.5)
    print(f"모니터링: {thread_to_monitor.name} 종료됨")

def worker():
    print(f"{th.current_thread().name} 시작")
    time.sleep(1)
    print(f"{th.current_thread().name} 종료")

if __name__ == "__main__":
    work_thread = th.Thread(target=worker, name="WorkerThread")

    monitor = th.Thread(
        target=monitor_thread, args=(work_thread,), name="MonitorThread", daemon=True
    )

    work_thread.start()
    monitor.start()

    work_thread.join()
  1. work_thread를 먼저 생성하여 monitor 쓰레드에 전달합니다.
  2. monitor에서 is_alive()를 통해 work_thread가 동작 중인지 확인 합니다.
  3. work_thread가 종료되면 메인 쓰레드(MainThread)가 종료 되면서 데몬 쓰레드인 monitor도 종료 됩니다.
WorkerThread 시작
모니터링: WorkerThread 실행 중
모니터링: WorkerThread 실행 중
WorkerThread 종료
모니터링: WorkerThread 종료됨

마무리


지금까지 우리는 다음과 같은 중요한 개념들을 살펴보았습니다.

더 해보기


더 효율적이고 안전한 멀티쓰레드 프로그래밍을 위해서 아래의 심화 주제들을 학습해보면 좋을듯 합니다. 😊

python 쓰레드

tiaz0128

Eat Sleep Coding.

Never Never Giveup.

💫 Python  |  BackEnd  |  Cloud Architect  |  DevOps