DEV Community

Cover image for Benchmark of inter-thread communication options in a concurrent Python GUI
Michael Salaverry
Michael Salaverry

Posted on

Benchmark of inter-thread communication options in a concurrent Python GUI

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.

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
Enter fullscreen mode Exit fullscreen mode

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

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')
Enter fullscreen mode Exit fullscreen mode

The following is the essence of the previous flask version:

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)
Enter fullscreen mode Exit fullscreen mode

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:

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'])
Enter fullscreen mode Exit fullscreen mode

I also added a queue version of the benchmark:

############# 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())
Enter fullscreen mode Exit fullscreen mode

Benchmarking

Finally, I used the timeit module to benchmark the performance of the blinker and flasker functions.

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]} |")
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)