Pull to refresh

PyGTK: потоки и магия обёрток

Reading time 9 min
Views 5.1K
Всем хорош GTK+, но наблюдается большая проблема при работе с ним в многопоточных приложениях. Сам по себе GTK является thread-safe, но требуя принудительной блокировки со стороны пользователя. Вторая проблема заключается в том, что блокировка реализована через мутексы, и вы должны вызывать блокировку строго один раз, иначе ваш код «зависнет» на linux, прекрасно при этом работая на windows.

Опыт показал, что способ доступа «Любой поток обращается к GUI, главное вызвать блокировки» оказался провальным: через некоторое время можно поймать core dump в глубинах GTK с самыми разнообразными причинами, ловить которые бесполезно.

UP: Код залит на Github, и немного обновлён с последней версией кода «основного» проекта. Изменения косметические (Period вместо Seconds, и добавлено логгирование использования ошибочного потока для поиска проблемных мест.

В связи с чем, в своем небольшом проекте я пришел к следующему методу организации наиболее беспроблемной работы с потоками:
  1. Любые обращения к GTK производит только один поток. Все изменения в GUI вносят выделенные функции обновления состояния.
  2. Все блокировки ведутся через общий реентерабельный блокиратор.
  3. Блокиратор так же отслеживает обращение к GUI из «неправильного» потока, логгируя его.

Общая структура GUI части


Основная часть состоит из модуля gui.py, экспортирующего следующее «добро»:
GtkLocker = CGtkLocker() # Блокиратор
def GtkLocked(f): # Обертка для событий gui, отмечает факт наличия блокировки в момент вызова
def IdleUpdater(f): # Обертка для функции обновления, выполняющейся как только будет свободно gui
def SecondUpdater(f): # Обертка для функции обновления, вызывающейся не чаще раза в секунду
def GUIstop(): # Заканчивает работу программы и убивает всё GUI
def GuiCall(Func): # Вызов функции обновления GUI "высокого" приоритета
def GuiIdleCall(Func): # Вызов функции обновления GUI когда больше ничего более важного нет
def GuiSecondCall(Func): # Вызов указанной обновления в ближайшую секунду
def GUI(): # Запуск основного цикла работы


И работа приложения выглядит так:
import gui, gtk
def InitApp():
    """ Создаём и показываем все необходимые контролы """
    with GtkLocker:
        window = gtk.Window(gtk.WINDOW_TOPLEVEL)
        window.set_title(u"Вот такое приложение")
        window.connect("destroy", gui.GUIstop)
        window.realize()
        vbox = gtk.VBox()
        window.add(vbox)
        label = gtk.Label("Text label")
        vbox.add(label)
        window.show_all()

def __main__():
    gui.GuiCall( InitApp )
    gui.GUI()

if __name__ == '__main__':
    __main__()
    sys.exit(0)


Данное приложение не делает ничего, кроме показа окошка с текстом «Text label». Давайте сделаем по-человечески: вынесем весь код приложения в класс и добавим кнопочку.

class CMyApp(object):
    def __init__(self):
        self.label = None
        self.times = 0

    def CreateGui(self):
        with gui.GtkLocker:
            window = gtk.Window(gtk.WINDOW_TOPLEVEL)
            window.set_title(u"Вот такое приложение")
            window.connect("destroy", gui.GUIstop)
            window.realize()
            vbox = gtk.VBox()
            window.add(vbox)
            label = gtk.Label("Welcome to our coool app!")
            vbox.pack_start(label)
            label = gtk.Label("Here will be counter")
            self.label = label
            vbox.pack_start(label)
            button = gtk.Button("Press me!")
            button.connect("clicked", self.Click)
            vbox.pack_start(button)
            window.show_all()

    @gui.GtkLocked
    def Click(self, widget):
        self.times += 1
        self.label.set_text("You pressed button %d times" % self.times)

MyApp = CMyApp()
def InitApp():
    """ Создаём и показываем все необходимые контролы """
    MyApp.CreateGui()


Итак, что мы сделали? Создали объект, __init__ метод которого просто подготовил будущие поля метода, а всё фактическое создание выполняется в функции CreateGui, которая будет вызвана уже из общего цикла обработки гуи событий.

Теперь о магии, которой подвержен метод Click: обратите внимания, он помечен оберткой gui.GtkLocked. Это означает, что данный метод является методом обработки событий GUI, и вызывается строго через connect, это означает, что в момент вызова метода он уже имеет блокировку GTK. Данная обертка реализует состояние GUI блокиратора «Уже заблокировано», таким образом, использование блокировки внутри функции не вызовет никаких проблем.

Добавим вторую кнопку, которая так же делает море полезного:
class CMyApp(object):
    def CreateGui(self):
        with gui.GtkLocker:
            ....
            button = gtk.Button("Or me!")
            button.connect("clicked", gui.GtkLocked(self.Count))
            vbox.pack_start(button)
            ....

    @gui.GtkLocked
    def Click(self, widget):
        self.Count()
        gui.GuiSecondCall( self.Count )

    def Count(self, *args, **kwargs):
        with gui.GtkLocker:
            self.times += 1
            self.label.set_text("You pressed button %d times" % self.times)


Итак, мы поменяли метод Click предыдущей кнопки на вызов общего метода Count, и на отложенный вызов его же с задержкой до секунды, который считает и обновляет код вне зависимости от погоды на марсе, а на вторую кнопку повесили напрямик Count.
Так как метод Count предполагает вызов его не только через connect, мы не можем повесить на него @gui.GtkLocked — метод может быть вызыван из еще не блокированного контекста (например, просто вызван в idle событии), поэтому gui.GtkLocked мы помечаем прямо в момент connect'а. В результате, метод Count можно вызывать из неблокированного контекста и он захватит блокировку сам, но при этом он же привязан на событие и его же вызывает другой обработчик событий. За счет магии с GtkLocker и GtkLocked никакого дедлока не происходит, всё работает.

Теперь давайте добавим Его Высочество Прогресс Бар, и сложный фоновый процесс, который в процессе производит обновление его содержимого:

class CMyApp(object):
    def CreateGui(self):
        with gui.GtkLocker:
            ....
            progress = gtk.ProgressBar()
            self.progress = progress 
            vbox.pack_start(progress)
            T = threading.Thread(name="Background work", target=self.Generate)
            T.setDaemon(1)
            T.start()

    @gui.GtkLocked
    def UpdateProgress(self):
        self.progress.pulse()

    def Generate(self):
        while(True):
            time.sleep(0.3)
            gui.GuiIdleCall( self.UpdateProgress )


Итак, у нас метод Generate работает на фоне, и каждые 0.3 секунды желает обновить прогресс, для чего складывает в очередь исполнения UpdateProgress. Так как UpdateProgress запускается в контексте GUI потока, всё работает. Вот только что будет, если мы не знаем время, необходимое для выполнения? Обновлять накаждый чих? Замените 0.3 на 0.001 — и полюбуйтесь на результат. Нет, это не выход. Добавлять замеры времени и искуственно замедлять обновление? Вообще не вариант. Может, вместо GuiIdleCall сделать GuiSecondCall? Попробуем… М-да. Каждую секунду происходит резкое обновление всех, выполненных за эту секунду событий. Ужас.

Давайте, добавим еще один фоновый процесс, и к нему «умный» метод обновления:
class CMyApp(object):
    def CreateGui(self):
        with gui.GtkLocker:
            ....
            fastprogress = gtk.ProgressBar()
            self.fastprogress = fastprogress
            vbox.pack_start(fastprogress)
            T = threading.Thread(name="Heavy background work", target=self.GenerateFast)
            T.setDaemon(1)
            T.start()

    @gui.SecondUpdater
    def SingleUpdateProgress(self):
        self.fastprogress.pulse()

    def GenerateFast(self):
        while(True):
            time.sleep(0.001)
            self.SingleUpdateProgress()


Магия, восторг! Мы просто объявляем функцию обновления состояния и вешаем на неё нужную обертку: @gui.SecondUpdater или @gui.IdleUpdater, и метод будет автоматически вызван в контексте GUI потока не чаще раза в секунду или в свободное время. За счет оберток, двойной запуск метода подряд исключен, не требуется лишнего кода по учету был ли он вызван, и не нужно задумываться над складыванием в очередь исполнения.

Под капотом


Итак, давайте рассмотрим вблизи что же наворочено внутри gui.py.

Общий код не представляет ничего сложного, просто инициализация:
from __future__ import with_statement
import logging, traceback
logging.basicConfig(level=logging.DEBUG, filename='debug.log', filemode='a',
                    format='%(asctime)s %(levelname)-8s %(module)s %(funcName)s %(lineno)d %(threadName)s %(message)s')
import pygtk
pygtk.require('2.0')
import gtk
gtk.gdk.threads_init()
import gobject, gtk.glade, Queue, sys, configobj, threading, thread
from functools import wraps
import time, os.path
gtk.gdk.threads_enter()
IGuiCaller = Queue.Queue()
IGuiIdleCaller = Queue.Queue()
IGuiSecondsCaller = Queue.Queue()
IdleCaller = [ None ]
IdleCallerLock = threading.Lock()
gtk.gdk.threads_leave()
class CGtkLocker:
    .... # будет расписан отдельно
GtkLocker = CGtkLocker()

# Терминатор исполнения GUI потока -- это просто main_quit, вызываемый в GUI потоке
@IdleUpdater
def GUIstop(*args, **kwargs):
    gtk.main_quit()

# Функции, организующие постановку в очередь выполнения.
# Вместо использования системной (gobject) очереди выполнения,
# используется threading.Queue, что гарантирует работу в потоках.
def GuiCall(Func):
    IGuiCaller.put(Func)
    with IdleCallerLock:
        if IdleCaller[0] == False:
            gobject.idle_add(GUIrun)
            IdleCaller[0] = True

def GuiIdleCall(Func):
    IGuiIdleCaller.put(Func)
    with IdleCallerLock:
        if IdleCaller[0] == False:
            gobject.idle_add(GUIrun)
            IdleCaller[0] = True

def GuiSecondCall(Func):
    IGuiSecondsCaller.put(Func)

# Брокер вызова из GUI очередей
def GUIrun():
    # Сперва пытаемся обработать очередь GuiCaller
    try:
        Run = IGuiCaller.get(0)
        # При выполнении idle вызовов, блокировка GTK не получена
        # поэтому, для минимизации ошибок, организуем блокировку сами
        # в итоге будет работать и "with GtkLocker:" методы, и @GtkLocked
        with GtkLocker: Run()
    except Queue.Empty:
        # Если очередь пуста -- обрабатываем GuiIdleCaller
        try:
            Run = IGuiIdleCaller.get(0)
            with GtkLocker: Run()
        except Queue.Empty:
            # Если в очередях пусто -- стоп, удаляем обработчик
            with GtkLocker:
                IdleCaller[0] = False
                return False
    return True

# Брокер ежесекундного выполнения: запускаем всё из очереди
# и ждём следующего цикла
def GUIrunSeconds():
    try:
        with GtkLocker:
            while (True):
                Run = IGuiSecondsCaller.get(0)
                Run()
    except Queue.Empty:
        pass
    return True

# Основной цикл работы
def GUI():
    # Инициализируем брокеров 
    gobject.idle_add(GUIrun)
    IdleCaller[0] = True
    gobject.timeout_add(1000, GUIrunSeconds)
    # Входим в блокировку для gtk.main
    gtk.gdk.threads_enter()
    # Сбрасываем начальную блокировку GtkLocker,
    # так как gtk.main внутренне разблокирует очереди
    GtkLocker.FREE()
    # Уходим в цикл обработки GUI событий
    gtk.main()
    # После вызова main_quit, выходя из main блокировка снова стоит
    gtk.gdk.threads_leave()


Теперь же посмотрим на самое интересное: обёртки. Итак, самая важная из них — это реализация реентерабельной блокировки:
class CGtkLocker:
    def __init__(self):
        # Блокировка доступа до полей самого блокиратора
        self.lock = threading.Lock()
        self.locked = 1
        # Изначально, основной поток уже имеет блокировку
        self.thread = thread.get_ident()
        # Запоминаем начальный поток, для сообщения о использовании GUI из левых потоков
        self.mainthread = self.thread
        self.warn = True # Можно выключить ругань, например после N ошибок

    # Функция, вызывающаяся при входе в with блок
    def __enter__(self):
        # Блокированно проверяем равенство текущего потока активному
        with self.lock: DoLock = (thread.get_ident()!=self.thread)
        # Ругнёмся в лог, если по какой-либо причине вызваны не из основного потока
        if self.warn and self.mainthread != thread.get_ident():
            logging.error("GUI accessed from wrong thread! Traceback: "+"".join(traceback.format_stack()))
        # Если блокировку держит на момент входа в with другой поток, то нам нужно произвести захват
        if DoLock:
            # этот вызов блокирует нас до момента освобождения
            gtk.gdk.threads_enter()
            # А после освобождения мы спокойно помечаем что блокировка теперь в нашем потоке
            with self.lock: self.thread = thread.get_ident()
        # Каждый __enter__ увеличивает число блокировок
        self.locked += 1
        return None

    # В момент конца исполнения with блока (неважно как -- конец, исключение и т.д.)
    def __exit__(self, exc_type, exc_value, traceback):
        # с внутренним локом производим декремент блокировок, и освобождение если дошли до нуля
        with self.lock:
            self.locked -= 1
            if self.thread!=thread.get_ident():
                print "!ERROR! Thread free not locked lock!"
                logging.error("Thread free not locked lock!")
                sys.exit(0)
            else:
                if self.locked == 0:
                    self.thread = None
                    gtk.gdk.threads_leave()
        return None

    # Метод для одноразовой работы: выхода из начальной блокировки стартового потока.
    def FREE(self):
        self.locked -= 1
        self.thread = None
        if self.locked != 0:
            print "!ERROR! Main free not before MAIN!"
            logging.error("Main free not before MAIN!")
            sys.exit(0)
GtkLocker = CGtkLocker()


Следует понимать, что в "with GtkLocker" должен быть обернут любой кусок, работающий с GUI.
Причем в большинстве случаев, это будет работать, даже если все вызовы пойдут из разных потоков, для того threads_enter / threads_leave и созданы. Вот только иногда всё работает до поры до времени, и внезапно падает в корку где-нибудь в глубинах GTK.

Парная к GtkLocker обертка, позволяющая помечать методы событий, которые вызываются ядром GTK уже внутри блокировки. Будучи вызванной на нулевом уровне, увеличивает уровень блокировочного уровня, тем самым гарантируя что мы сами не вызовем threads_enter / threads_leave.
def GtkLocked(f):
    @wraps(f)
    def wrapper(*args, **kwds):
        with GtkLocker.lock:
            if GtkLocker.thread == None or GtkLocker.thread==thread.get_ident():
                GtkLocker.thread = thread.get_ident()
                GtkLocker.locked += 1
                WeHold = True
            else:
                print "!ERROR! GtkLocked for non-owned thread!"
                logging.error("GtkLocked for non-owned thread!")
                WeHold = False
        ret = None
        try:
            return f(*args, **kwds)
        finally:
            if WeHold:
                with GtkLocker.lock:
                    GtkLocker.locked -= 1
                    if GtkLocker.locked == 0:
                        GtkLocker.thread = None
    return wrapper


Ну и последний магический пасс: обертки IdleUpdater / SecondUpdater:
def IdleUpdater(f):
    @wraps(f)
    def wrapper(*args, **kwds):
        self = len(args)>0 and isinstance(args[0], object) and args[0] or f
        if '_idle_wrapper' not in self.__dict__: self._idle_wrapper = {}
        def runner():
            if self._idle_wrapper[f]:
                try: return f(*args, **kwds)
                finally: self._idle_wrapper[f] = False
            return None
        if f not in self._idle_wrapper or not self._idle_wrapper[f]:
            self._idle_wrapper[f] = True
            # Для SecondUpdater тут будет GuiSecondCall
            GuiIdleCall( runner )
    return wrapper


В объекте, метод которого вызываем, создаётся словарь _idle_wrapper, в котором идёт отслеживание был ли данный метод уже поставлен в очередь на исполнение или нет, и если нет — запоминаем что выставили и добавляем обертку-запускалку, которая выполнит данный метод и сбросит флаг запомненного исполнения. В результате, первый вызов метода добавит запуск его в GuiIdleCall (или *Seconds*) очередь, а повторные вызовы до момента его исполнения будут просто проигнорированы.

Коды на скачивание и полезная информация


Все исходники приведенного примера доступны: pygtk-demo.tar.bz2.
За полезной информацией по известным проблемам PyGTK в его официальном FAQ.
По вопросам написания под PyGTK я чаще всего обращаюсь к туториалу и мануалу.
Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
+25
Comments 9
Comments Comments 9

Articles