PySide, Multi-Threading

moredev·2025년 3월 19일

pyside

목록 보기
2/3

Thread?

프로세스(process)는 컴퓨터에서 연속적으로 실행되고 있는 프로그램이다. 스레드(thread)는 어떠한 프로그램 내에서, 특히 프로세스 내에서 실행되는 흐름의 단위를 말한다. 한 프로그램 혹은 프로세스는 하나의 스레드를 가지고 있으며, 둘 이상의 스레드를 동시에 가지고 실행할 수도 있다. 이러한 실행 방식을 멀티스레드라고 한다.
같은 프로세스 내의 스레드는 Heap, Data, Code 영역의 가상메모리를 공유한다. Stack, Program Counter(PC), Registers 영역의 메모리는 각각의 스레드마다 할당된다.

GIL(Global Interpreter Lock)

파이썬에서는 하나의 프로세스에서 하나의 스레드만 실행할 수 있도록 락(Lock)을 걸어 관리한다. GIL로 GC(Garbage Collertor)의 구현이 용이해졌지만, 스레드가 병렬적으로 실행되지않아 단일 스레드와 멀티 스레드의 성능이 큰 차이가 없다.
하지만 GIL은 CPU bounding에서 사용되고, I/O bounding은 영향을 받지 않아 I/O bounding작업에서는 Multi-Threading이 가능하다.

Python GUI

Multi-Thread

  • GUI 애플리케이션은 주 스레드는 GUI Thread로 GUI 반응성을 유지하기 위해 멀티 스레딩이 필요하다.
  • PyQt나 PySide에서는 두 가지 방식으로 멀티스레딩을 구현할 수 있다.

1. QThread

Qt의 Thread를 추상화한 클래스로 QThread 인스턴스는 자체 스레드를 생성한다.

  • QThread의 인스턴스를 직접 생성하고 시작, 종료 등을 명시적으로 관리해야 한다.
  • thread 클래스 안에서 signal을 정의하고 사용하며, custom signal을 사용할 수 있다.
  • Qt에서 제공하는 signal과 slot을 사용해 스레드에서 발생한 이벤트를 수신한다.

예시: APNG 파일 무한 재생 스레드

애니메이션 파일(APNG)를 GUI 화면에 무한 재생하는 예제.
pyside6에서는 QMovie를 지원해 애니메이션 파일을 재생할 수 있다. 하지만 사용할 수 없는 환경에서는 수동으로 프레임을 로드하면서, 주 스레드를 차단하지 않아야 하기 때문에 별도의 스레드에서 처리한다.

애니메이션 스레드

import imageio.v3 as iio
from PySide6.QtCore import QThread, Signal
from PySide6.QtGui import QImage, QPixmap


class AnimationThread(QThread):
    frame_ready = Signal(QPixmap)

    def __init__(self, path):
        super().__init__()
        self.running = True
        self.path = path
        self.frames = []

    def run(self):
        self.running = True

        self.frames = self.load_frames(self.path)
        self.total_frames = len(self.frames)

        frame_index = 0
        while self.running and self.total_frames > 0:
            frame = self.frames[frame_index]
            self.frame_ready.emit(frame) # UI 업데이트

            frame_index = (frame_index + 1) % self.total_frames
            self.msleep(100)  # FPS


    def load_frames(self, path):
        # APNG 파일 로드
        try:
            frames = []
            img_frames = iio.imread(path, plugin="pillow", index=None) # pillow 투명도 유지
            if img_frames is None:
                raise ValueError("Unable to invoke APNG frame.")

            for frame in img_frames:
                h, w, ch = frame.shape
                bytes_per_line = ch * w
                qimg = QImage(frame.data, w, h, bytes_per_line, QImage.Format.Format_RGBA8888)
                pixmap = QPixmap.fromImage(qimg)
                frames.append(pixmap)
        except Exception as e:
            print(f"{path} loading error: {e}")
            frames = []

        return frames


    def stop(self):
        try:
            if self.isRunning():
                self.running = False
                self.quit()
                self.wait()
        except Exception as e:
            print(f"APNG Thread safe stop Error: {e}")

메인

import sys

from PySide6.QtWidgets import (
    QApplication, QMainWindow, QGraphicsView, QGraphicsScene, QGraphicsPixmapItem
)

from mainwindow import Ui_MainWindow
from anim_thread import AnimationThread

class mainWindow(QMainWindow, Ui_MainWindow):
    def __init__(self):
        super(mainWindow, self).__init__()
        self.setupUi(self)
        self.graphicsView = QGraphicsView(self.centralwidget)

        self.scene = QGraphicsScene(self)
        self.graphicsView.setScene(self.scene)
        self.graphics_item = QGraphicsPixmapItem()
        self.scene.addItem(self.graphics_item)

        # animation 스레드 설정
        self.apng_thread = AnimationThread(path="file_path")
        self.apng_thread.frame_ready.connect(self.update_frame)
        self.apng_thread.start()


    def update_frame(self, pixmap):
        self.graphics_item.setPixmap(pixmap)
        self.scene.setSceneRect(0, 0, pixmap.width(), pixmap.height())


    def closeEvent(self, event):
        self.apng_thread.stop()
        event.accept()


if __name__ == '__main__':
    app = QApplication(sys.argv) 
    Window = mainWindow() 
    Window.show()

    sys.exit(app.exec())
  • closeEvent(): gui 창의 x(close) 버튼을 클릭하면 호출되는 이벤트

QThread의 method

  • run(): 스레드가 시작될 때 호출되는 메서드. 스레드 작업 코드를 작성.
  • start(): 스레드의 실행 시작 메서드. run()을 호출하여 작업수행 로직을 실행
  • quit(): 스레드의 이벤트 루프 종료 요청 메서드.
  • wait(): 스레드의 작업이 종료될 때까지 기다리는 메서드.

2. QRunnable과 QThreadPool

QRunnable

작업을 추상화한 클래스로 자체 스레드가 아닌 QThreadPool을 사용해 스레드들이 관리된다.

  • QRunnable은 실제 작업이 이루어지는 run()메소드만을 정의한다.
  • 클래스 밖에서 정의한 signal을 사용한다.

QThreadPool

스레드 풀을 추상화한 클래스로, 스레드 풀 작업을 조정 및 관리한다.

  • 스레드 생성과 소멸에 따른 오버헤드를 줄여준다.

예시: APNG 파일 재생 스레드

재생 버튼을 누르면 애니메이션 파일(APNG)를 GUI 화면에 1회 재생하는 예제.

애니메이션 스레드

import time
import imageio.v3 as iio
from PySide6.QtCore import QRunnable
from PySide6.QtGui import QImage, QPixmap


class AnimationThread(QRunnable):
    def __init__(self, path, recognized_signal):
        super().__init__()
        self.path = path
        self.recognized_signal = recognized_signal


    def run(self):
        frames = self.load_frames(self.path)

        for frame in frames:
            h, w, ch = frame.shape
            bytes_per_line = ch * w
            qimg = QImage(frame.data, w, h, bytes_per_line, QImage.Format.Format_RGBA8888)
            pixmap = QPixmap.fromImage(qimg)
            self.recognized_signal.emit(pixmap)
            time.sleep(0.1)  # FPS


    def load_frames(self, path):
        # APNG 파일 로드
        try:
            frames = iio.imread(path, plugin="pillow", index=None) # pillow 투명도 유지
            if frames is None:
                raise ValueError("Unable to invoke APNG frame.")
        except Exception as e:
            print(f"{path} loading error: {e}")
            frames = []

        return frames

메인

import sys
import os

from PySide6.QtWidgets import QApplication, QMainWindow, QGraphicsScene, QGraphicsPixmapItem
from PySide6.QtCore import QThreadPool, Signal, Slot
from PySide6.QtGui import QPixmap

from mainwindow import Ui_MainWindow
from thread.anim_thread import AnimationThread

class indexWindow(QMainWindow, Ui_MainWindow):
    frame_signal = Signal(QPixmap)  # 애니메이션 프레임을 전달하는 신호

    def __init__(self):
        super(indexWindow, self).__init__()
        self.setupUi(self)

        self.pushButton.clicked.connect(self.start_video) # 버튼 클릭 이벤트 연결

        self.scene = QGraphicsScene(self)
        self.graphicsView.setScene(self.scene)
        self.graphics_item = QGraphicsPixmapItem()
        self.scene.addItem(self.graphics_item)

        self.thread_pool = QThreadPool()
        self.frame_signal.connect(self.update_frame)


    def start_video(self):
        animation_thread = AnimationThread(path="file_path", recognized_signal=self.frame_signal)
        self.thread_pool.start(animation_thread)  # animation 스레드 시작


    @Slot(QPixmap) # 애니메이션 프레임 신호를 수신
    def update_frame(self, pixmap):
        self.graphics_item.setPixmap(pixmap)
        self.scene.setSceneRect(0, 0, pixmap.width(), pixmap.height())


    def closeEvent(self, event):
        self.thread_pool.clear()
        self.thread_pool.waitForDone()
        event.accept()


if __name__ == '__main__':
    app = QApplication(sys.argv) 
    Window = indexWindow() 
    Window.show()

    sys.exit(app.exec())

QRunnable의 method

  • run(): 실제 수행될 작업. QThreadPool에 의해 QRunnable이 실행될 때 호출되는 메서드.

QThreadPool의 method

  • start(QRunnable, priority=0): QRunnable 객체를 스레드 풀에 추가하고 실행. priority 값으로 우선 순위를 설정할 수 있다. 현재 작업 중인 스레드의 수가 최댓값이면 QRunnable 객체는 대기 큐에 추가된다.
  • clear(): 대기 중인 모든 작업을 취소하고 삭제한다.
  • waitForDone(): 스레드 풀에 있는 모든 작업이 종료될 때까지 대기

0개의 댓글