π English Abstract
This technical article addresses main UI thread bottleneck and screen freezing issues encountered when building high-frequency real-time measurement data visualization systems using PyQt6. It explores architectural redesign using QThreadPool and QRunnable to decouple heavy I/O and data processing from the GUI event loop. Readers will gain actionable insights into implementing thread-safe signals, memory-efficient ring buffers, pyqtgraph optimization, and resolving real-world race conditions in Python multi-threading environments.
μ΄λΉ μλ§ κ° μ΄μμ λμ©λ μ νΈ λ°μ΄ν°λ₯Ό μ€μκ°μΌλ‘ μμ§νκ³ νλ©΄μ μκ°ν(Visualization)νλ κ·Έλν½ μ¬μ©μ μΈν°νμ΄μ€(GUI) μ ν리μΌμ΄μ μ κ°λ°ν λ κ°μ₯ νν μ§λ©΄νλ λ¬Έμ λ λ°λ‘ 'λ©μΈ UI μ€λ λ λ©μΆ€(UI Thread Freezing/Lagging)' νμμ λλ€. μμ λλ λμ©λ λ°μ΄ν° νμ±(Data Parsing), νν°λ§, μ°μ° μμ μ΄ UI μ²λ¦¬μ λμΌν μ€λ λμμ μ€νλλ©΄ Qtμ μ΄λ²€νΈ 루ν(Event Loop)κ° μ°¨λ¨λμ΄ λ²νΌ ν΄λ¦ λΆλ₯, νλ μ λλ, μλ΅ μμ μ€λ₯κ° λ°μν©λλ€.
λ³Έ κΈμμλ **PyQt6** κΈ°λ° λ°μ΄ν° μκ°ν μμ€ν κ°λ° μ€ λ°μν λ³λͺ© νμμ μ§λ¨νκ³ , QThreadPoolκ³Ό QRunnableμ νμ©νμ¬ μμ§-μ°μ°-λ λλ§ νμ΄νλΌμΈμ λΉλκΈ°λ‘ μμ λΆλ¦¬ν κ³ μ±λ₯ νΈλ¬λΈμν κ³Όμ μ κΉμ΄ μκ² λ€λ£Ήλλ€.
1. λ¬Έμ μ§λ¨: λ©μΈ UI μ€λ λ λ³λͺ©κ³Ό μ΄λ²€νΈ 루ν μ°¨λ¨
PyQt6 μ ν리μΌμ΄μ
μ ν΅μ¬μ QApplication.exec()μ μν΄ κ΄λ¦¬λλ λ©μΈ μ΄λ²€νΈ 루ν(Main Event Loop)μ
λλ€. μ΄ μ΄λ²€νΈ 루νλ λ§μ°μ€ ν΄λ¦, ν€λ³΄λ μ
λ ₯, μλμ° λ¦¬μ¬μ΄μ¦λΏλ§ μλλΌ νλ©΄μ κ·Έλν½ μμ μ¬κ·Έλ¦¬κΈ°(Repaint/Redraw) μ΄λ²€νΈλ₯Ό μΌκ΄ μ²λ¦¬ν©λλ€.
1.1. κΈ°μ‘΄ λ¨μΌ μ€λ λ ꡬ쑰μ λ¬Έμ μ
μ΄κΈ° μ€κ³μμλ μ§λ ¬ ν¬νΈ(Serial Port) λλ TCP/IP μμΌμΌλ‘λΆν° λ°μ΄ν°λ₯Ό μμ (Data Ingestion)λ°μ μ§ν, λμΌν μ€λ λ λ΄μμ λ€μκ³Ό κ°μ μμ μ μ°λ¬μ μννμ΅λλ€:
- λ°μ΄ν° μμ λ° λμ½λ©: λ°μ΄νΈ μ€νΈλ¦Όμ νμ΄μ¬ λμ λ리 λλ λνμ΄(NumPy) λ°°μ΄λ‘ μ ν
- μ νΈ μ²λ¦¬(Signal Processing): μ΄λ νκ· νν°(Moving Average Filter) λ° FFT(Fast Fourier Transform) λ³ν
- μ°¨νΈ μ λ°μ΄νΈ: μ°¨νΈ λΌμ΄λΈλ¬λ¦¬(Graph Canvas)μ μλ‘μ΄ ν¬μΈνΈλ₯Ό κ°κ³΅νμ¬ κ·Έλ¦¬κΈ°
λ°μ΄ν° μνλ§ λ μ΄νΈκ° 100Hz μμ€μΌ λλ λ¬Έμ κ° λλ¬λμ§ μμμΌλ, κ³μΈ‘ μ₯λΉμ μ€νμ΄ νμ₯λμ΄ 10kHz ~ 100kHz μ΄μμ μ΄κ³ μ λ©ν°μ±λ μ°μ°μ΄ νμν΄μ§μ νμ΄μ¬μ GIL(Global Interpreter Lock)κ³Ό CPU μ°μ° λΆνκ° κ²°ν©νλ©΄μ μ΄λ²€νΈ 루νκ° μλ°± λ°λ¦¬μ΄(ms) μ΄μ μ°¨λ¨λμμ΅λλ€. κ²°κ³Όμ μΌλ‘ UIλ 'μλ΅ μμ(Not Responding)' μνμ λΉ μ‘μ΅λλ€.
1.2. λ³λͺ© μ§μ νλ‘νμΌλ§ (Profiling)
νμ΄μ¬μ cProfile λ° PyQt6.QtCore.QElapsedTimerλ₯Ό μ΄μ©ν΄ κ° λ¨κ³λ³ μμ μκ°μ κ³μΈ‘ν κ²°κ³Όλ λ€μκ³Ό κ°μμ΅λλ€.
| νμ΄νλΌμΈ λ¨κ³ | μ΄λΉ μ²λ¦¬ νμ | μ€λ λμ μ μκ°(100k μν κΈ°μ€) | μ£Όμ λ³λͺ© μμΈ |
|---|---|---|---|
| λ°μ΄ν° μμ (Network/Serial I/O) | 1,000 packets/sec | 12ms | I/O λκΈ° λ° λμ©λ ν¨ν· λκΈ° |
| λ°μ΄ν° νμ± λ° DSP μ°μ° | 1,000 packets/sec | 45ms | NumPy λ°°μ΄ μ¬ν λΉ λ° νν° μ°μ° CPU λΆν |
| GUI Chart λ λλ§ | 60 fps λͺ©ν | 85ms | λ¨μ Point λ°μ΄ Point μ°μ° λ° λ©μΈ μ€λ λ μ°¨λ¨ |
κ³μΈ‘ κ²°κ³Ό λ°μ΄ν° μ°μ°κ³Ό UI μ λ°μ΄νΈκ° λ©μΈ μ€λ λλ₯Ό 90% μ΄μ μ μ νκ³ μμμΌλ©°, λΉλκΈ° νμ΄νλΌμΈ(Asynchronous Pipeline)μΌλ‘μ μ νμ΄ νμμ μ΄μμ΅λλ€.
2. μν€ν μ² μ¬μ€κ³: QThreadPool λ° Ring Buffer λμ
μ€λ λλ₯Ό λΆλ¦¬ν λ 무λΆλ³ν QThread μμ±μ μ€λ λ μμ±/νκ΄΄μ λ°λ₯Έ μ€λ²ν€λμ λΉν¨μ¨μ μΈ λ©λͺ¨λ¦¬ κ΄λ¦¬λ₯Ό μ΄λν©λλ€. μμ€ν
μμμ μ΅μ μΌλ‘ μ¬μ¬μ©νκΈ° μν΄ **QThreadPool** κΈ°λ°μ μ€λ λ νλ§ ν¨ν΄μ μ±ννμ΅λλ€.
2.1. μ QThread λμ QThreadPoolμΈκ°?
- μμ μ¬μ¬μ©: λ°λ³΅μ μΌλ‘ λ°μνλ λ°μ΄ν° μ²λ¦¬ μμ (Task)μ λν΄ μ΄λ―Έ μμ±λ μ컀 μ€λ λ(Worker Thread)λ₯Ό μ¬μ¬μ©νμ¬ μμ± λΉμ©μ μ κ°ν©λλ€.
- λμμ± μ μ΄:
setMaxThreadCount()λ₯Ό ν΅ν΄ μμ€ν μ 물리 μ½μ΄ μμ λ§μΆ° κ³Όλν λ¬Έλ§₯ κ΅ν(Context Switching)μ λ°©μ§ν μ μμ΅λλ€. - μμ
λΆλ¦¬: λ¨λ°μ± λλ μ£ΌκΈ°μ μ°μ° μμ
μ
QRunnableλ‘ κ΅¬ννμ¬ μ€λ λ κ΄λ¦¬ μ± μμ νλ μμν¬μ μμν©λλ€.
2.2. μμ°μ-μλΉμ ν¨ν΄κ³Ό Ring Buffer ꡬν
λ°μ΄ν° μμ μλ(μμ°μ)μ νλ©΄ λ λλ§ μλ(μλΉμ)μ λΆμΌμΉλ₯Ό ν΄κ²°νκΈ° μν΄, μ€κ° λ§€κ°μ²΄λ‘ λ©λͺ¨λ¦¬ ν¨μ¨μ μΈ μν λ²νΌ(Ring Buffer/Circular Buffer)λ₯Ό λμ
νμ΅λλ€. Pythonμ collections.deque(maxlen=N) λλ κ³ μ±λ₯ NumPy C-extension bufferλ₯Ό νμ©νμ¬ λ©λͺ¨λ¦¬ ν λΉ λ° ν΄μ μ€λ²ν€λλ₯Ό μ΅μννμ΅λλ€.
3. μ€λ¬΄ μ΅μ ν μ½λ ꡬν λ° νΈλ¬λΈμν
λ€μμ `QThreadPool`κ³Ό custom `QObject` signalμ κ²°ν©νμ¬ UI λ©μΆ€μ μλ²½ν ν΄κ²°ν νλ‘λμ λ 벨μ λΉλκΈ° νμ΄νλΌμΈ ꡬ쑰μ λλ€.
3.1. Thread-safeν QRunnable λ° Signal μ€κ³
QRunnableμ μ체μ μΌλ‘ Signalμ κ°μ§ μ μμΌλ―λ‘, λ³λμ QObject λνΌ ν΄λμ€λ₯Ό ν΅ν΄ λ©μΈ μ€λ λλ‘ μμ νκ² λ°μ΄ν°λ₯Ό μ μ‘(Thread-Safe Communication)ν΄μΌ ν©λλ€.
from PyQt6.QtCore import QRunnable, QObject, pyqtSignal, pyqtSlot, QThreadPool
import numpy as np
import time
# 1. μκ·Έλ μ λ¬μ© λνΌ ν΄λμ€ μ μ
class WorkerSignals(QObject):
data_ready = pyqtSignal(np.ndarray, np.ndarray) # time, value μ λ¬
error = pyqtSignal(str)
# 2. μ°μ° μ μ© Worker ν΄λμ€ (QRunnable μμ)
class DataProcessingTask(QRunnable):
def __init__(self, raw_bytes):
super().__init__()
self.raw_bytes = raw_bytes
self.signals = WorkerSignals()
@pyqtSlot()
def run(self):
try:
# [λΉλκΈ° μ°μ°] λμ©λ λ°μ΄λ리 νμ± λ° DSP μ°μ° μν
parsed_data = np.frombuffer(self.raw_bytes, dtype=np.float32)
# μ΄λ νκ· νν°λ§ (Heavy CPU Bound Task)
window_size = 10
filtered_data = np.convolve(parsed_data, np.ones(window_size)/window_size, mode='valid')
time_axis = np.linspace(0, len(filtered_data)*0.0001, len(filtered_data))
# λ©μΈ μ€λ λλ‘ κ²°κ³Ό μμ νκ² μ λ¬ (Queued Connection μλ μ μ©)
self.signals.data_ready.emit(time_axis, filtered_data)
except Exception as e:
self.signals.error.emit(str(e))
3.2. λ©μΈ UI λ° PyQtGraph μκ°ν μ΅μ ν
PyQt6 κΈ°λ³Έ QPainter κ³μ΄ λ λλ§μ μ€μκ° λμ©λ λ°μ΄ν° μκ°νμ μ ν©νμ§ μμ΅λλ€. C++ μ°μ° κΈ°λ°μ pyqtgraphλ₯Ό μ¬μ©νκ³ λ€μ΄μνλ§(Downsampling) μ΅μ
μ νμ±νν΄μΌ ν©λλ€.
from PyQt6.QtWidgets import QMainWindow, QVBoxLayout, QWidget
import pyqtgraph as pg
class RealtimePlotWindow(QMainWindow):
def __init__(self):
super().__init__()
self.thread_pool = QThreadPool.globalInstance()
# UI λ° μ°¨νΈ μ΄κΈ°ν
self.central_widget = QWidget()
self.layout = QVBoxLayout(self.central_widget)
self.setCentralWidget(self.central_widget)
# pyqtgraph μ±λ₯ μ€μ μ΅μ ν
pg.setConfigOptions(useOpenGL=True, antialias=False) # OpenGL κ°μ νμ±ν
self.plot_widget = pg.PlotWidget()
self.layout.addWidget(self.plot_widget)
self.curve = self.plot_widget.plot(pen='g')
self.plot_widget.setDownsampling(ds=True, auto=True, mode='peak') # νΌν¬ ν½μ
λ€μ΄μνλ§
self.plot_widget.setClipToView(True) # νλ©΄μ 보μ΄λ μμλ§ λ λλ§
def on_raw_data_received(self, raw_bytes):
# λ€νΈμν¬/μμΌ μμ λΆμμ λΌμ°ν
λλ μ΄λ²€νΈ
task = DataProcessingTask(raw_bytes)
task.signals.data_ready.connect(self.update_chart)
self.thread_pool.start(task)
@pyqtSlot(np.ndarray, np.ndarray)
def update_chart(self, x_data, y_data):
# μ΄ μ¬λ‘―μ λ©μΈ GUI μ€λ λμμ μμ νκ² μ€νλ¨
self.curve.setData(x_data, y_data)
3.3. νμ νΈλ¬λΈμν μ£μ§ μΌμ΄μ€ (Edge Cases)
- λ©λͺ¨λ¦¬ λμ(Memory Leak) λ°©μ§:
QRunnableκ°μ²΄λ μ€ν μλ£ ν μλμΌλ‘ λ©λͺ¨λ¦¬μμ ν΄μ (Auto-deletion)λμ§λ§,WorkerSignalsμ κ°μQObjectμ°κ²°μ΄ ν΄μ λμ§ μμΌλ©΄ μ°λ κΈ° μμ§κΈ°(Garbage Collector)κ° μ΄λ₯Ό μκ±°νμ§ λͺ»ν μ μμ΅λλ€. μκ·Έλ μμ ν μ²λ¦¬ μλ£ μμ μ μ μ νdisconnect()μ²λ¦¬λ₯Ό 보μ₯ν΄μΌ ν©λλ€. - Qt.ConnectionType μ΄μ: λ©μΈ μ€λ λμ μ컀 μ€λ λ κ°μ μκ·Έλ μ λ¬μ λ°λμ
Qt.ConnectionType.QueuedConnectionλ°©μμΌλ‘ μ λ¬λμ΄μΌ κ΅μ°© μν(Deadlock) λ° UI μ€λ λ κ°μ μ€λ¨μ λ°©μ§ν μ μμ΅λλ€. PyQt6 μκ·Έλ μμ€ν μ μ€λ λ κ²½κ³λ₯Ό κ°μ§νμ¬ μ΄λ₯Ό μλ μ²λ¦¬νμ§λ§, 볡μ‘ν μΈμ€ν΄μ€ μ°Έμ‘° μ λͺ μμ μ§μ μ΄ μμ ν©λλ€. - GUI κ°±μ μ£Όκ³Όλ€(Over-rendering): μ컀 μ€λ λμμ μ΄λΉ 1,000λ² μκ·Έλμ λ°μμμΌλ λͺ¨λν°μ μ£Όμ¬μ¨(60Hz~144Hz) μ΄μμΌλ‘ νλ©΄μ λ€μ 그리λ κ²μ 무μλ―Έν CPU/GPU μμ λλΉμ λλ€. λ©μΈ UIμμλ νμ΄λ¨Έ(QTimer) κΈ°λ°μΌλ‘ λ²νΌ λ°μ΄ν°μ μ΅μ μνλ§ 60 FPS μ£ΌκΈ°λ‘ κ°νμ κ°±μ (Throttling)νλ νλ μ μ ν κΈ°λ²μ μ μ©ν΄μΌ ν©λλ€.
4. μ΅μ ν κ²°κ³Ό λ° μ±λ₯ λΉκ΅
QThreadPool λΉλκΈ° νμ΄νλΌμΈκ³Ό λ€μ΄μνλ§ λ λλ§μ μ μ©νκΈ° μ κ³Ό νμ μμ€ν μ±λ₯μ μ λμ μΌλ‘ μΈ‘μ ν κ²°κ³Όλ λ€μκ³Ό κ°μ΄ κ·Ήμ μΈ κ°μ μ 보μ¬μ€λλ€.
| μΈ‘μ μ§ν (Metrics) | μ΅μ ν μ (Single Thread) | μ΅μ ν ν (QThreadPool + GPU Acceleration) |
|---|---|---|
| μ΅λ λ°μ΄ν° μμ μ²λ¦¬λ | 10 kHz (νλ©΄ λ©μΆ€ λ°μ) | 500 kHz μ΄μ (μμ μ λμ) |
| UI Frame Rate (FPS) | 12 ~ 18 FPS (λλ μ¬ν¨) | κ³ μ 60 FPS (Drop-free) |
| λ©μΈ μ€λ λ CPU μ μ μ¨ | 88% ~ 99% (λ³λͺ© λ°μ) | 12% ~ 15% (μΎμ ν¨ μ μ§) |
| μ¬μ©μ μ λ ₯μ μν λ°μμ± | μ λ ₯ μ§μ° 2~5μ΄ λ° λ¨Ήν΅ | μ€μκ° λλ μ΄ μμ (< 10ms) |
5. κ²°λ‘ λ° μμ½
PyQt6 νκ²½μμ λμ©λ μ€μκ° κ³μΈ‘ λ°μ΄ν°λ₯Ό λ€λ£° λ νλ©΄ λ©μΆ€ νμμ ν΄κ²°νλ €λ©΄ λ€μ μμλ₯Ό λ°λμ κ³ λ €ν μν€ν μ²λ₯Ό μ€κ³ν΄μΌ ν©λλ€.
- μν λΆλ¦¬: λ©μΈ μ€λ λλ μ€μ§ μ¬μ©μ μ΄λ²€νΈ λ° νλ©΄ λ λλ§ μ μ©μΌλ‘λ§ μ¬μ©ν©λλ€.
- QThreadPool νμ©: λ°μ΄ν° μμ , νμ±, νν° μ°μ°μ `QRunnable` κΈ°λ° μ€λ λ νλ‘ λ겨 λμμ±μ κ·Ήλνν©λλ€.
- PyQtGraph μ΅μ μ΅μ ν: OpenGL κ°μ, Downsampling, ClipToView μ€μ μ μ΄μ©ν΄ μ°¨νΈ κ·Έλ¦¬κΈ° μ°μ°λμ μ§λ₯μ μΌλ‘ μ€μ λλ€.
- λ λλ§ μ€λ‘―νλ§: UI κ°±μ μ `QTimer` κΈ°λ°μ 60 FPS μ μ½μ λμ΄ λͺ¨λν° λ λλ§ νκ³λ₯Ό λμ΄μλ λΉν¨μ¨μ μμ μ μ°¨λ¨ν©λλ€.
μ μ΅μ ν ν¨ν΄μ μ μ©νλ©΄ κ³ μ±λ₯ μ°μ μ© μ₯λΉ κ³μΈ‘ μ μ΄ νλ‘κ·Έλ¨ μμ€ν μμ€μ λ§€λλ½κ³ κ²¬κ³ ν PyQt6 GUI μ ν리μΌμ΄μ μ μ±κ³΅μ μΌλ‘ ꡬμΆν μ μμ΅λλ€.