2025, Dec 30 18:01
Как избежать взаимной блокировки в Tkinter при показе кадров из потока
Почему Tkinter не потокобезопасен: ImageTk.PhotoImage из рабочего потока вызывает дедлок. Безопасный шаблон с after() для предпросмотра и корректного выхода.
Когда вы передаёте в интерфейс Tkinter живые кадры с камеры, возникает соблазн создавать ImageTk.PhotoImage и планировать обновления UI прямо из рабочего потока. Пока приложение запущено, всё выглядит нормально, но как только вы закрываете окно, можно получить взаимную блокировку: поток не завершается через join, окно не закрывается корректно, а код очистки так и не срабатывает. Это проявляется даже в простом цикле, который превращает изображения PIL в ImageTk.PhotoImage и возвращает их в главный цикл через after().
Минимальный воспроизводимый пример
Ниже — код, который воспроизводит проблему. Он создаёт ImageTk.PhotoImage в фоновом потоке и передаёт его виджету Label с помощью after(). При закрытии окна join зависает.
import tkinter as tk
from PIL import Image, ImageTk
import threading
import time
SAMPLE_PATH = 'test_img.jpg'
class LivePreview:
def __init__(self, win):
self.win = win
self.pix = Image.open(SAMPLE_PATH)
self.widget = tk.Label(win)
self.widget.pack()
self.active = True
self.worker = threading.Thread(target=self.frame_loop, daemon=True)
self.worker.start()
self.win.protocol("WM_DELETE_WINDOW", self.shutdown)
def frame_loop(self):
while self.active:
frame_obj = ImageTk.PhotoImage(image=self.pix)
self.widget.after(0, self.apply_frame, frame_obj)
time.sleep(0.001)
def apply_frame(self, frame_obj):
self.widget._imgref = frame_obj
self.widget.config(image=frame_obj)
def shutdown(self):
self.active = False
self.worker.join()
self.win.destroy()
if __name__ == '__main__':
root = tk.Tk()
ui = LivePreview(root)
root.mainloop()
Что на самом деле происходит
Tkinter не является потокобезопасным. Создание или изменение объектов Tkinter вне главного потока приводит к неопределённому поведению. В данном случае взаимная блокировка возникает из‑за создания ImageTk.PhotoImage в рабочем потоке и синхронизации с циклом событий через after(). Какое‑то время всё может работать, но при закрытии окна GUI попадает в состояние, когда вызовы к интерфейсу из рабочего потока могут блокироваться, а главный поток ждёт завершения рабочего — из‑за этого join застывает.
Если вызывать set_image напрямую из потока, без after(), можно избежать «залипания», но это приводит к мерцанию или отсутствию изображения. Однако это всё равно небезопасно. Надёжный подход — изолировать всю работу с Tk в главном потоке, а фоновый поток оставить только для ввода‑вывода и обработки данных.
Решение
Используйте поток лишь для получения или обработки кадров, храня их в виде PIL.Image. В главном потоке по таймеру after() обновляйте UI: конвертируйте последний PIL‑кадр в ImageTk.PhotoImage и меняйте изображение в Label. При завершении — сбросьте флаг работы, дождитесь join, затем уничтожьте окно. Это убирает взаимные блокировки и мерцание.
import tkinter as tk
from PIL import Image, ImageTk
import threading
import time
SAMPLE_PATH = 'test_img.jpg'
class StreamUI:
def __init__(self, win):
self.win = win
self.view = tk.Label(win)
self.view.pack()
self.alive = True
self.latest_frame = Image.open(SAMPLE_PATH)
self.fetcher = threading.Thread(target=self.pull_frames, daemon=True)
self.fetcher.start()
self.win.protocol("WM_DELETE_WINDOW", self.handle_exit)
self.win.after(1, self.render_frame)
def pull_frames(self):
"""Выполняется в фоновом потоке: только чтение/обработка кадров."""
while self.alive:
self.latest_frame = Image.open(SAMPLE_PATH)
# self.latest_frame = ... получить изображение с камеры ...
# ... необязательная обработка ...
time.sleep(0.001)
def render_frame(self):
"""Выполняется в главном потоке: весь код Tk — только здесь."""
self._tkbuf = ImageTk.PhotoImage(image=self.latest_frame)
self.view.config(image=self._tkbuf)
if self.alive:
self.win.after(1, self.render_frame)
def handle_exit(self):
self.alive = False
self.fetcher.join()
self.win.destroy()
if __name__ == '__main__':
root = tk.Tk()
gui = StreamUI(root)
root.mainloop()
Почему это важно
Корректное завершение — критично для камерных приложений. Часто нужно остановить цикл получения кадров, дождаться завершения потоков отображения/захвата и только затем освободить ресурсы камеры. Пропуск join или отметка потока как daemon рискуют оставить драйверы и дескрипторы в некорректном состоянии. Если держать Tk вне рабочих потоков, join стабильно отрабатывает, и последовательность освобождения ресурсов выполняется в самом конце — как и задумано.
Главные выводы
Не вызывайте Tkinter из потоков. Ограничьте рабочий поток захватом кадров и любой не‑GUI обработкой. В главном потоке планируйте обновление интерфейса через after(), конвертируйте кадр в ImageTk.PhotoImage, храните ссылку на объект PhotoImage и обновляйте Label. При закрытии — сбросьте флаг, дождитесь завершения рабочего потока, затем уничтожьте окно. Такой подход избегает взаимных блокировок, убирает мерцание и обеспечивает предсказуемое завершение на Python 3.10.11 с Pillow 11.2.1.
Вывод
Достаточно разделить обязанности между рабочим потоком и главным циклом Tk, чтобы получить отзывчивый живой предпросмотр, который при этом корректно закрывается. Относитесь к Tkinter как к однопоточному инструменту, передавайте между потоками только «сырые» данные, а управление UI доверьте after(). Тогда окно закрывается, поток успешно объединяется, а ресурсы камеры освобождаются надёжно.