Различные статьи по Qt и c++

Многопоточность в PySide6: QThread, GIL и его влияние на Qt-приложения

Введение

Основной цикл статей подробно разбирал многопоточность Qt на C++ — в этой статье разберём специфику многопоточности в PySide6, включающую важную, специфичную для Python деталь: GIL (Global Interpreter Lock), существенно влияющий на то, какую реальную выгоду даёт многопоточность для Python-кода по сравнению с C++.

Концепция

GIL — механизм CPython (стандартной реализации интерпретатора Python), гарантирующий, что в любой момент времени только один поток может выполнять Python-байткод, даже если создано несколько потоков — это означает, что чисто вычислительный, CPU-интенсивный Python-код не получает реального параллелизма от многопоточности (в отличие от C++ кода, статья о многопоточности основного цикла), хотя GIL освобождается во время операций ввода-вывода (сетевые запросы, файловые операции) и во время выполнения кода внутри многих C-расширений (включая большую часть самого Qt API, реализованного как обёртка над C++), что даёт реальную выгоду от многопоточности именно для таких, не чисто Python-вычислительных задач. QThread используется из Python синтаксически похоже на C++ версию, и остаётся правильным выбором для задач, где сама работа (не Python-код, а вызываемые из него Qt/C++ операции) реально выполняется параллельно благодаря освобождению GIL на время этой работы.

Пример кода: Многопоточность в PySide6

# QThread для операции, реально выигрывающей от параллелизма (сетевой запрос, освобождающий GIL)
from PySide6.QtCore import QThread, Signal
import requests  # сетевые запросы — операция ввода-вывода, освобождающая GIL во время ожидания ответа

class DownloadWorker(QThread):
    finished_with_result = Signal(str)

    def __init__(self, url: str, parent=None):
        super().__init__(parent)
        self._url = url

    def run(self):
        response = requests.get(self._url)  # GIL освобождён на время ожидания сетевого ответа —
                                               # главный поток приложения остаётся отзывчивым
        self.finished_with_result.emit(response.text)


worker = DownloadWorker("https://example.com/api/data")
worker.finished_with_result.connect(lambda text: print(f"Получено {len(text)} символов"))
worker.start()
# Демонстрация ОТСУТСТВИЯ реального параллелизма для CPU-интенсивного чистого Python-кода
from PySide6.QtCore import QThread

class CpuIntensiveWorker(QThread):
    def run(self):
        total = 0
        for i in range(100_000_000):  # чистые Python-вычисления — GIL НЕ освобождается
            total += i * i
        print(f"Результат: {total}")

# Запуск ДВУХ таких worker'ов параллельно НЕ даст ускорения в 2 раза, как ожидалось бы в C++ —
# GIL гарантирует, что фактическое выполнение Python-байткода обоих потоков
# происходит последовательно, переключаясь между потоками, а не одновременно
# multiprocessing как альтернатива для реального параллелизма CPU-интенсивных задач
from multiprocessing import Pool

def cpu_intensive_calculation(n):
    return sum(i * i for i in range(n))

def run_in_parallel():
    with Pool(processes=4) as pool:  # ОТДЕЛЬНЫЕ процессы, каждый со своим интерпретатором и GIL —
                                       # реальный параллелизм для CPU-интенсивных задач
        results = pool.map(cpu_intensive_calculation, [10_000_000] * 4)
    return results

Пояснения к коду

DownloadWorker показывает случай, реально выигрывающий от QThread — requests.get() выполняет сетевой запрос, и во время ожидания ответа от сервера GIL освобождается (поскольку сам интерпретатор Python в этот момент не выполняет байткод, ожидая внешнее событие), позволяя главному потоку приложения оставаться полностью отзывчивым для пользовательского интерфейса, как и ожидалось бы от правильного использования фонового потока. CpuIntensiveWorker явно демонстрирует противоположный, важный для понимания случай — чисто вычислительный цикл на чистом Python не освобождает GIL, и запуск нескольких таких потоков одновременно не даёт реального ускорения вычислений, в отличие от аналогичного кода на C++ (основной цикл статей), где несколько потоков действительно выполнялись бы параллельно на разных ядрах процессора. multiprocessing.Pool показывает альтернативное решение именно для CPU-интенсивных задач — отдельные процессы, каждый со своим собственным интерпретатором Python и, соответственно, собственным GIL, дают реальный параллелизм там, где QThread для чистого Python-кода его не дал бы, хотя интеграция multiprocessing с остальной архитектурой Qt-приложения требует более внимательного, явного проектирования по сравнению с относительно прямолинейной интеграцией QThread.

Подводные камни

  • Ожидание реального параллельного ускорения CPU-интенсивного, чисто Python-вычислительного кода через QThread, аналогично C++ паттернам основного цикла статей про многопоточность — это одна из самых частых, фундаментальных ошибок понимания при переходе с C++ на Python-разработку, и разработчик, ожидающий, что простое оборачивание вычислительного кода в QThread даст пропорциональное ускорение (как это работало бы в C++), вместо этого обнаружит, что реального ускорения нет из-за GIL — для таких задач нужен либо multiprocessing, либо перенос вычислительно интенсивной части в C-расширение/Cython (статья 507), либо использование библиотек (NumPy и аналогичных), внутренне реализованных в C и освобождающих GIL на время своих вычислений.
  • Обращение к Qt-объектам, созданным в главном потоке, непосредственно из кода, выполняемого в QThread, без использования предназначенного для этого механизма сигналов/слотов — аналогично C++ версии этого подводного камня из основного цикла статей, прямое обращение к виджетам или другим объектам пользовательского интерфейса из фонового потока недопустимо и в PySide6, и единственный безопасный способ взаимодействия — через сигналы, автоматически доставляемые в поток объекта-получателя (если используется стандартное, неявное соединение, статья 501).
  • Использование multiprocessing без понимания накладных расходов на межпроцессное взаимодействие (передача данных между процессами требует сериализации через pickle, что не бесплатно по времени и для некоторых типов данных, включая многие Qt-объекты, может оказаться вовсе невозможным) — для эффективного использования multiprocessing стоит явно проектировать передаваемые между процессами данные как простые, легко сериализуемые структуры (числа, строки, списки, словари из базовых типов), а не пытаться напрямую передавать сложные Qt-объекты между независимыми процессами.
  • Игнорирование того, что многие операции внутри самого Qt API (через PySide6) реализованы в C++ и потому освобождают GIL на время своего выполнения, что means что многопоточность с использованием самих Qt-классов (сетевые классы Qt, файловые операции Qt) может реально выигрывать от параллелизма даже без выхода за пределы чистого Qt API, без необходимости в multiprocessing — разделение задач на «Python-вычисления» (не получающие выгоды от QThread) и «Qt/C++-операции, вызываемые из Python» (потенциально получающие реальную выгоду) — ключевое понимание для эффективного применения многопоточности в PySide6-приложениях.