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(). Тогда окно закрывается, поток успешно объединяется, а ресурсы камеры освобождаются надёжно.