Thursday, August 09, 2007

Using threads in PyGTK

Well, all this thought of asynchronicity is getting to me. But since we
mentioned how to use subprocesses and Twisted, I think it is only fair to talk a
bit about threads.

So, what will we be doing?

  1. Create an abstraction for running threads in PyGTK
  2. Use it

Now the thing which we will be wanting to run is a generator, call it
"take_really_long". And it will surely block, and might be a bit intensive, and
well we obviously need something asynchronous. A generator is a bit nicer to use
than a single plain long-running function, because we can update the user
interface every time it returns, making it much much easier to do things like
Progress Bars.

You must remember two things when using threads with PyGTK:

1. GTK Threads must be initialised with gtk.gdk.threads_init:

import gtk
gtk.gdk.threads_init()


2. Any code that modifies the UI (basically any code) that is called from
outside the main thread must be pushed into the main thread and called
asynchronously in the main loop, with gobject.idle_add:

import gobject, gtk

def set_progress_bar_fraction(fraction):
progress_bar.set_fraction(0.2)

gobject.idle_add(set_progress_bar_fraction, 0.2)
gtk.main()


This Gobject/GTK "idle_time" is something like "as soon as the main loop can
reasonably get around to it".

So, to reiterate, when writing threaded code, I expect to see lots of calls to
gobject.idle_add.

Here is a silly example, that I urge you not to use, as we will concentrate on
abstracting out the boilerplate later.


from threading import Thread
import time
import gtk, gobject
gtk.gdk.threads_init()

class MainWindow(gtk.Window):
def __init__(self):
super(MainWindow, self).__init__()
vb = gtk.VBox()
self.add(vb)
self.progress_bar = gtk.ProgressBar()
vb.pack_start(self.progress_bar)
b = gtk.Button(stock=gtk.STOCK_OK)
vb.pack_start(b)
b.connect('clicked', self.on_button_clicked)
self.show_all()

def on_button_clicked(self, button):
self.count_in_thread(5)

def count_in_thread(self, maximum):
Thread(target=self.count_up, args=(maximum,)).start()

def count_up(self, maximum):
for i in xrange(maximum):
fraction = (i + 1) / float(maximum)
time.sleep(1)
gobject.idle_add(self.set_progress_bar_fraction, fraction)

def set_progress_bar_fraction(self, fraction):
self.progress_bar.set_fraction(fraction)

w = MainWindow()
gtk.main()

I say this is silly, but it is not really, it's a nearly a real-world example,
and its probably how you would do it. Now imagine an application where you had
to do this a lot, it would eventually wear you down.

So we can move on to making an abstraction that allows us to forget about this
boilerplate and concentrate on integrating it smoothly with your code. This will
take the form of a class that is responsible for:

  • starting a generator in a thread
  • calling a callback with every result (yield) of the generator
  • possibly calling a callback on completion

And here it is (modified from PIDA work by Tiago Cogumbrerio: Thanks Tiago!):

import threading, thread
import gobject, gtk

gtk.gdk.threads_init()


class GeneratorTask(object):

def __init__(self, generator, loop_callback, complete_callback=None):
self.generator = generator
self.loop_callback = loop_callback
self.complete_callback = complete_callback

def _start(self, *args, **kwargs):
self._stopped = False
for ret in self.generator(*args, **kwargs):
if self._stopped:
thread.exit()
gobject.idle_add(self._loop, ret)
if self.complete_callback is not None:
gobject.idle_add(self.complete_callback)

def _loop(self, ret):
if ret is None:
ret = ()
if not isinstance(ret, tuple):
ret = (ret,)
self.loop_callback(*ret)

def start(self, *args, **kwargs):
threading.Thread(target=self._start, args=args, kwargs=kwargs).start()

def stop(self):
self._stopped = True


So this does all of those things, you pass the generator, the callback for each
result and the callback for completion to the constructor, and the arguments to
call the generator with to the start method. This way you can call the same
GeneratorTask instance many times.

For an example of its use, lets modify our version above:


class MainWindow(gtk.Window):
def __init__(self):
super(MainWindow, self).__init__()
vb = gtk.VBox()
self.add(vb)
self.progress_bar = gtk.ProgressBar()
vb.pack_start(self.progress_bar)
b = gtk.Button(stock=gtk.STOCK_OK)
vb.pack_start(b)
b.connect('clicked', self.on_button_clicked)
self.show_all()

def on_button_clicked(self, button):
GeneratorTask(self.count_up,
self.set_progress_bar_fraction).start(5)

def count_up(self, maximum):
for i in xrange(maximum):
fraction = (i + 1) / float(maximum)
time.sleep(1)
yield fraction

def set_progress_bar_fraction(self, fraction):
self.progress_bar.set_fraction(fraction)

w = MainWindow()
gtk.main()


One thing we have done is convert the count_up method to a generator so that we
can use it. You will notice that there is no real impact on the code that would make you
think "oh nasty, threads". The idle calls are taken care of for you, and the
generator task takes care of calling your UI function for each result.

7 comments:

Franwerst said...

Thanks for the class and the examples. I'm new with PyGTK and threads and after some days looking for some info about the topic in the internet, your approach has been really useful.

Michal Pryc said...

Nice HowTo :)

One thing, in the last code snip, there is:

def count_up(self, maximum):
  for i in xrange(maximum):
    fraction = (i + 1) / float(maximum)
    time.sleep(1)
    yield fraction

But before that we should import also time, so:


def count_up(self, maximum):
  import time
  for i in xrange(maximum):
    fraction = (i + 1) / float(maximum)
    time.sleep(1)
    yield fraction

Bandoine said...

Nice :-D

I've been looking for something like that for hour

Thanks

Andrew Z said...

Nice. This may be the solution to porting BleachBit from Linux to Microsoft Windows, where it currently freezes because I update the GUI from a thread.

msdark said...

This work great... but i have a little problem...

when the window (the all program) terminate... the execution still in background... i mean i destroy de windows but some threads created for the TaskGenerator still running... how can i kill this threads to terminate the program?

(Sorry for my english)

Anonymous said...

to msdark: use sys.exit()

Marc said...

This is a great piece of code. Very tight and well implemented!

By the way: Don't forget to attach the window to the destroy event. Otherwise, you get the background thread behaviour described by MSDark. Adding "w.connect("destroy", lambda _: gtk.main_quit())" right after the "w = MainWindow()" line should do the trick.