Код
June 10, 2022

Как реализовать асинхронность в одном (!) потоке

Qt - кросс-платформенный фреймворк для C++ - управляет интерфейсом и всеми прикрученными к нему функциями через event loop'ы, причем интерфейс остается отзывчивым даже при отрисовке сложных компонентов. Но проблема заключается в том, что если вызвана какая-то функция, то в процессе её выполнения интерфейс не обновляется, и, по сути, приложение зависает, пока та функция не выполнится. И вот, в процессе написания чат-бота с поддержкой скриптов на Python, я подумал: что, если люди захотят написать такие скрипты, которые будут выполняться чуть дольше, чем нужно?

А можно ли этого избежать как-то?

Мне, в сущности, нужно было вызвать обработку ввода, которая выдавала ответ на экран через сигнал. Если в одном из вариантов ответа находился скрипт, я должен подождать его выполнения.

Официальный C API от Python поддерживал только прямой вызов функции из модуля, не асинхронный, поэтому пришлось подумать о том, как вынести этот вызов в отдельный поток. И даже здесь оказалось не всё так однозначно: при инициализации среды выполнения Python ориентировался только на главный поток, и чтобы получить доступ к интерпретатору в другом потоке, нужно было вызвать пару методов - и при этом гарантировать, что программа не пытается запустить такой же поток где-нибудь ещё.

Извне функции обертка приняла вид цикла, который каждые 0.3 секунды опрашивает результат, и если ответа нет, вызывает функцию обработки событий event loop'а...

И вот проблема!

  1. Если пользователь отправит ещё одно сообщение, которое вызовет скрипт, то либо произойдет ошибка segfault из-за повторного обращения к интерпретатору из другого потока, либо deadlock из-за ожидания разблокировки мьютекса на интерпретаторе, потому что он находится в том же потоке.
  2. А если функция не станет ждать разблокировки мьютекса, то данные будут утеряны.

Решение нашлось ровно в том месте, где ожидается от Qt. Ведь, как говорится, клин клином вышибают.

Сигналы и слоты для обработчика

Нам, так или иначе, нужен обработчик задач. Пусть запрос на выполнение скрипта будет записан в структуру PythonTask, тогда мы сможем создать метод, который делает две важные вещи:

  1. Если мьютекс интерпретатора заблокирован, мы добавляем задачу в список задач и завершаем выполнение функции.
  2. Если мьютекс разблокирован, то, пока список задач не пуст, мы обрабатываем их все и отправляем при помощи сигналов, причём каждый ожидаемый промежуток времени запускаем обработку событий Qt.

Как слотам понять, ответ на какую задачу прислал обработчик? Выход простой - добавить уникальный идентификатор к задаче перед отправкой в обработчик.

Общие черты разработки в Qt

Чтобы реализовать одновременно отзывчивость интерфейса и возможность обработки сложных задач, нужно:

  • выделять сложные задачи в потоки;
  • динамически опрашивать потоки о завершении;
  • в перерывах вызывать обработку событий Qt.

Отличие от Rust

Qt не пытается при помощи макросов уйти в метапрограммирование потоков - например, в том же tokio асинки имеют свою низкозатратную реализацию, причём на уровне программы мы пишем код, будто каждая функция будет выполняться в отдельном потоке, в то время как эта реализация просто прерывает выполнение функции в одном месте и потом возобновляет по мере получения результата. C++ так не делает, и поэтому все функции желательно завершать, передавая результаты event loop'у и используя для этого превосходный механизм сигналов и слотов.