Multi-Threading Do's and Don'ts

Based on the QRunnable example (many thanks) I’ve written a statemachine driven multi-threaded Test-framework script that tests a commands sent over WiFi from an iOS App. The script simulates an instrument to which the iOS App connects. The framework enables me to delay responses to ensure that the iOS App handle timeouts and stuff.

I have a serial port write thread and a read thread (talking to a WiFi dongle) & do some inter thread comms using queue.Queue()
In my view it is a big mess and requires refactoring which I am in the process of tackling.

One thing I that I discovered (among many!) is that I was unable start a timer from a QRunnable - I had to use a signal to the main event loop which then started the timer there.

I was just wondering if there were any rules that one should keep in mind when writing stuff involving threads, timers and signals.

All wisdom gratefully accepted.

Hey @Mr_S_J_Childerley welcome the forum!

Yep QTimer can only be started on a thread which has an event loop, because when the timer fires (timeout or interval) there needs to be an event queue to put that event onto. Another odd peculiarity of QTimer is that when you create an interval timer you need to keep a reference to it. But when you create a .singleShot timer you don’t (the static methods return None) since the event is just pushed to the queue.

For threads, there are the usual rules – e.g. when you have two threads communicating like you do, be careful of deadlocks, where both threads end up waiting on data from each other.

Be careful about passing data between threads to avoid segmentation errors. You can send data between threads using signals, but signals send a reference to the same object (not a copy) – this is also true when communicating from the main thread. You’re using a Queue which is a good solution, you can also create a new object by copying before send – this must be a deepcopy, otherwise the internal elements will still be pointing to the same objects –

>> from copy import copy, deepcopy
>> my_dict = {'a': [1,2,3], 'b': [4,5,6]}
>> my_dict2 = copy(my_dict)

>>> my_dict is my_dict2
False

>>> my_dict['a'] is my_dict2['a']
True

>> my_dict3 = deepcopy(my_dict)
>>> my_dict['a'] is my_dict3['a']
False

Other than that, most of the specific PyQt5 weirdness is around performance and handling the output/signals returning data from a thread.

The goal of putting things in threads is often to keep the GUI responsive. To achieve this it is important that the worker threads don’t flood your main thread with “work” – particularly large amounts of result data that needs to be handled by the main thread.

Each signal coming back from the worker thread to the main thread is an interruption. For GUI responsiveness it’s better to have many short interruptions than few long interruptions. But that can go too far, e.g. if you’re triggering signals hundreds of times a second with a small task. In extreme cases you can flood the event queue and crash the app.

Remember that, if your slots are in Python –

  1. each of these interruptions requires a switch to Python (from the Qt event loop) which takes time
  2. no other events can be handled until your slot returns
  3. if your thread work isn’t releasing the GIL, this actually interrupts the thread too

Where the exact balance is depends on your application, so take some time to profile how often (and how long) your app is spending in slots.

Thanks for these pointers.

I’m also trying to refresh my patterns knowledge to find a cleaner architecture. One win appears tobe to break the long running write thread, blocked on a queue, into many short lived write threads.

When I’m happier with it a code review could be useful.

That reminds me of another point – when you use the QRunner and QThreadPool architecture, threads are re-used if there are workers waiting when one finishes. This means you avoid the overhead of shutting down & starting up a new thread. If you are working with a lot of small tasks this can be significant.

Likewise, if you have a long-running job and want to break it down into many small jobs, you can be fairly confident there won’t be much additional overhead.