Benchmark of inter-thread communication options in a concurrent Python GUI
Tags: python, performance
Published: 2023-05-24 11:32:39

In this blog post, we'll discuss a benchmarking process I used while refactoring the software for a microscope. The experiment compares the performance of two methods for sending and receiving images between a tkinter frontend and a python backend controlling the microscope: using the blinker package and a Flask-based web server.

## Context
We have a custom built microscope at the startup I work at which has a custom built tkinter GUI for controlling it. The architecture when I got there was that the tkinter gui would communicate with the microscope control module via flask / http calls. The idea was to allow future remote control of the microscope from other programs. Additionally, the microscope images needed to be displayed in the UI in real time for focusing the microscope, and verifying the images looked good before saving them to disk. This was previously happening via a python queue which contained PIL images.

There were issues sharing the queue across the various modules. Each module needed to be aware of queue full/empty logic. Each module needed to get a reference to the queue, which was owned by the flask server module. We needed to display the most recent image even if the queue was full of old images that hadn't been displayed yet. The queue was in memory, and the images were each quite large, so there were concerns around memory / RAM capacity if the queue filled up.

I decided to benchmark an alternative I thought of: using the blinker library as an event emitter architecture rather than queues.

## Code

Both version of inter-thread communication will check the received image at the end to mimic the save to disk process. And both share the same scan function.

```python
from PIL import Image

def checker(image: Image.Image):
    assert image.size == (4096, 3000)

def microscope_scan() -> Image.Image:
    image = Image.open('./example_thorlabs.png')
    return image
```

For the benchmark, I start the flask uWSGI server below with `waitress-serve --host 127.0.0.1 --port 5000 flask_server:app`

```python
from io import BytesIO

from flask import Flask, send_file
app = Flask(__name__)

from scan import microscope_scan

@app.route("/get_latest_image")
def get_latest_image():
    bytes_io = BytesIO()
    microscope_scan().save(bytes_io, format='PNG')
    bytes_io.seek(0)
    return send_file(bytes_io, mimetype='image/png')
```

The following is the essence of the previous flask version:
```python
from requests import get
from io import BytesIO
from concurrent.futures import ThreadPoolExecutor as Pool
from os import cpu_count

def flasker():
    with Pool(cpu_count()) as p:
        p.map(flask_receiver, range(repititions))

def flask_receiver(x):
    res = get('http://localhost:5000/get_latest_image', timeout=10)
    if res.ok:
        with Image.open(BytesIO(res.content)) as im:
            checker(im)
```
This get's the latest image from the backend over http calls and checks the size of the received image.

The following is the blinker version:
```python
from blinker import signal

def send_via_blink(image: Image.Image):
    scanned = signal('scanned')
    scanned.send('microscope', image=image)

def blinker():
    for x in range(96):
        send_via_blink(microscope_scan())

scanned = signal('scanned')

@scanned.connect
def blink_receiver(sender, **kwargs):
    checker(kwargs['image'])
```

I also added a queue version of the benchmark:
```python
############# Queue #############
import queue

q = queue.Queue()

def queueer():
    for x in range(repititions):
        q.put(microscope_scan())

def queue_receiver():
    while True:
        checker(q.get())
```

### Benchmarking

Finally, I used the timeit module to benchmark the performance of the blinker and flasker functions.
```python
from timeit import timeit
times = [
    ("queueer", timeit("queueer()", setup="from __main__ import queueer", number=10)),
    ("blinker", timeit("blinker()", setup="from __main__ import blinker", number=10)),
    ("flasker", timeit("flasker()", setup="from __main__ import flasker", number=10))
]
print("| Method | Time (seconds) |")
print("| ------ | ------ |")
for time in times:
    print(f"| {time[0]} | {time[1]} |")
```
Based on the results from the timeit benchmark, we can see a significant difference in the time taken by both methods:

| Method | Time (seconds) |
| ------ | ------ |
| queueer | 0.1009 |
| blinker | 0.0301 |
| flasker | 1573.2531 |
| async_flasker | 1618.7829 |

The Blinker method is much faster compared to the Flask method. This is likely because Flask introduces additional overhead due to HTTP requests and responses, while Blinker is a lightweight library designed for in-process communication using signals.

Given the substantial difference in performance, it would be advisable to use the Blinker method for sending and receiving images in the refactored microscope software. This will help to ensure faster processing times and a more responsive system. However, it's important to consider other factors such as ease of implementation, maintainability, and any other specific requirements for your project.


## Conclusion

This code demonstrates a benchmark experiment comparing the performance of sending and receiving images using the blinker library and a Flask-based web server. By analyzing the results, we can make informed decisions on which approach to use for the refactored microscope software.

The code in executable form is available at https://gist.github.com/barakplasma/ea7ed66109a14d597c24e2374ed975ad