Введение
Основной цикл статей подробно разбирал многопоточность 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-приложениях.