CUPS, GTK+, Python, and threading

I’ve been trying to prevent a Python GTK+ application (the system-config-printer printing troubleshooter) from appearing to freeze when performing CUPS operations such as fetching a list of available devices.

Let me describe the problem.  In the libcups API the main worker function is cupsDoRequest() and this function blocks until the request is complete.  It may need to collect a password from the caller, in which case it will do this with a password callback function set using cupsSetPasswordCB().

The libcups API is used through a set of Python bindings called pycups, and GTK+ is used for the user interface.  The object of this game is to allow the GTK+ main loop to continue to run even while the CUPS operation is in progress.  I think I’ve got a workable system now.

The solution is to use threads.  There are two locks to be aware of when using threads in this environment: the Python global interpreter lock, and the GDK lock.

Step one: allow a separate worker thread for CUPS operations

Python has a single global interpreter lock that ordinarily prevents two threads from running at the same time.  In my Python extension module I had to wrap every call to cupsDoRequest() with some code to allow other threads to run.  In normal circumstances it would have been sufficient to use the convenience macros, like this:

  ...
  Py_BEGIN_ALLOW_THREADS;
  answer = cupsDoRequest (self->http, request, "/");
  Py_END_ALLOW_THREADS;
  ...

However, in this case the cupsDoRequest() function can call into our callback function if it needs to collect a password.  The macros save (and restore) the thread state into a local variable as well as releasing (and taking) the global interpreter lock, so in this case that thread state needs to be stored somewhere else instead, somewhere that my callback function can access it.  I chose to store it in the data structure associated with the CUPS connection.

Ideally, the password callback function would be given a pointer to the CUPS connection, with which it could look up the data structure associated with it and retrieve the thread state.  Unfortunately this is not the case, at least not yet.  As a result, we need to set a global variable to point to the “current” connection.  This is safe as long as only one CUPS operation is performed at a time.  For the moment that is sufficient, as the task at hand was to have a CUPS operation running in parallel with the GTK+ main loop, not with other CUPS operations.

Instead of Py_BEGIN_ALLOW_THREADS, then, the result of PyEval_SaveThread() is stored in the connection data structure, and a global pointer is set to point to that structure.  Instead of Py_END_ALLOW_THREADS, PyEval_RestoreThread() is called, giving it the previously saved thread state.

In the password callback function this is done in reverse: first the saved thread state is restored, then the Python callable object is invoked, and finally the thread state is saved again.

So the full sequence of events looks like this:

  • caller invokes pycups method
  • pycups saves the thread state and releases the GIL
  • pycups calls cupsDoRequest()
  • cupsDoRequest calls the pycups password callback
  • pycups restores the thread state and re-acquires the GIL
  • pycups invokes the caller’s registered password callback (a Python function)
  • pycups saves the thread state and releases the GIL, again
  • cupsDoRequest may call the password callback several times before returning
  • pycups restores the thread state and re-acquires the GIL

That’s the Python global interpreter lock taken care of.  Next is the GDK lock.

Step two: deal with the GDK lock

Having dealt with the Python locking, the next stage was to deal with the GDK locking.  Any calls to the GTK+ or GDK interfaces must hold the GDK lock.  To prepare the printing troubleshooter for using threads, I added a call to gtk.gdk.threads_init(), and then changed the call to gtk.main() so that it acquires the lock:

  gtk.gdk.threads_init ()
  gtk.gdk.threads_enter ()
  gtk.main ()
  gtk.gdk.threads_leave ()

Any signal callbacks from GTK+ widgets will be called with the lock already held, but functions called via the GSource mechanism (for example, with g_timeout_add) must perform their own locking.

The next stage was to audit the program to see which functions are called by g_timeout_add(), and add locking to them (i.e. calls to gtk.gdk.threads_enter() and gtk.gdk.threads_leave() whenever GTK+ functions are used).

Finally, the only tricky part left was the authentication dialog.  This is a GTK+ dialog that is shown when a password is required for a CUPS operation.  The dialog is shown from within a class (authconn.Connection) that is used both by the printing troubleshooter and by the main system-config-printer application, which is not yet ready to use threads.  The troubleshooter would use this class from its worker thread when it needed to talk to CUPS, and would need the class to perform GDK locking — but the system-config-printer application would call it from its main thread when the lock was already held.  To overcome this I added a method to the authconn.Connection class to instruct it whether to perform locking or not.  If locking is required, calls to gtk.gdk.threads_enter/leave() are made when the authentication dialog is shown.  The troubleshooter instructs it to do such locking.

Step three: a convenience class

Finally I wanted to make it really easy to start a worker thread, run some Python code, and collect the result.  The troubleshooter would do this in many places.  The idea was to have a general purpose “long-running operation that can be cancelled if necessary”.

I used the Python “threading” module to do this.  By deriving a class from the threading.Thread class, I defined a thread that runs some Python function and stores either the result or the exception if an exception is raised.

Then I created a TimedOperation class which starts the thread running when the class is instantiated.  Its “run” method waits for the thread to finish and then returns the result, or raises the exception if an exception was raised.

It currently does this by setting a 50ms timer to check whether the thread is still running, and running a GTK+ main loop until the thread has stopped, although I’m sure there are better ways to do this.

The code

Here are some links to the code:

  • pycups 1.9.44 — the relevant file is cupsconnection.c
  • system-config-printer at the point where this work was merged into the master branch — the relevant files are authconn.py, timedops.py and the various files in the troubleshoot directory

Posted

in

by

Comments

6 responses to “CUPS, GTK+, Python, and threading”

  1. ignacio avatar

    And this is why no one that wants to retain their sanity uses threads in Python 😛

  2. Colin Walters avatar

    Don’t call into GTK+ from a non-main thread! It’s a lot better to have your worker thread queue an idle handler with the result.

    1. tim avatar

      Colin: yes, maybe I could have the authconn.Connection class queue an idle handler to display the authentication dialog and wait for the result on a Queue. Of course, the authconn.Connection class is also used from within the main thread so I’ll need to adjust it for both cases (as I already do with whether or not to perform GDK locking).

  3. theads avatar
    theads

    Well i always queues with wrappers around.
    queues are such perfect threading tools. garantees a perfect locking. no waiting, all clearly organised etc.

    Just wrap around if you don’t need a worker-consumer model and it works too!

    much better than regular threads+mutex/etc

  4. Patrick avatar
    Patrick

    You could also do all printer manipulation in C and expose the printer configuration over the system-bus (DBUS). Then the python UI just has to wrap the DBUS API. You can also tie in PolicyKit so that printer administration can be delegated …

    1. tim avatar

      Patrick: It isn’t possible to completely move the printer administration into a D-Bus service, as we need to be able to configure remote printers as well. Work is going on though to look at using PolicyKit for local configuration.