Как реализовать асинхронность в одном (!) потоке
Qt - кросс-платформенный фреймворк для C++ - управляет интерфейсом и всеми прикрученными к нему функциями через event loop'ы, причем интерфейс остается отзывчивым даже при отрисовке сложных компонентов. Но проблема заключается в том, что если вызвана какая-то функция, то в процессе её выполнения интерфейс не обновляется, и, по сути, приложение зависает, пока та функция не выполнится. И вот, в процессе написания чат-бота с поддержкой скриптов на Python, я подумал: что, если люди захотят написать такие скрипты, которые будут выполняться чуть дольше, чем нужно?
А можно ли этого избежать как-то?
Мне, в сущности, нужно было вызвать обработку ввода, которая выдавала ответ на экран через сигнал. Если в одном из вариантов ответа находился скрипт, я должен подождать его выполнения.
Официальный C API от Python поддерживал только прямой вызов функции из модуля, не асинхронный, поэтому пришлось подумать о том, как вынести этот вызов в отдельный поток. И даже здесь оказалось не всё так однозначно: при инициализации среды выполнения Python ориентировался только на главный поток, и чтобы получить доступ к интерпретатору в другом потоке, нужно было вызвать пару методов - и при этом гарантировать, что программа не пытается запустить такой же поток где-нибудь ещё.
Извне функции обертка приняла вид цикла, который каждые 0.3 секунды опрашивает результат, и если ответа нет, вызывает функцию обработки событий event loop'а...
- Если пользователь отправит ещё одно сообщение, которое вызовет скрипт, то либо произойдет ошибка segfault из-за повторного обращения к интерпретатору из другого потока, либо deadlock из-за ожидания разблокировки мьютекса на интерпретаторе, потому что он находится в том же потоке.
- А если функция не станет ждать разблокировки мьютекса, то данные будут утеряны.
Решение нашлось ровно в том месте, где ожидается от Qt. Ведь, как говорится, клин клином вышибают.
Сигналы и слоты для обработчика
Нам, так или иначе, нужен обработчик задач. Пусть запрос на выполнение скрипта будет записан в структуру PythonTask, тогда мы сможем создать метод, который делает две важные вещи:
- Если мьютекс интерпретатора заблокирован, мы добавляем задачу в список задач и завершаем выполнение функции.
- Если мьютекс разблокирован, то, пока список задач не пуст, мы обрабатываем их все и отправляем при помощи сигналов, причём каждый ожидаемый промежуток времени запускаем обработку событий Qt.
Как слотам понять, ответ на какую задачу прислал обработчик? Выход простой - добавить уникальный идентификатор к задаче перед отправкой в обработчик.
Общие черты разработки в Qt
Чтобы реализовать одновременно отзывчивость интерфейса и возможность обработки сложных задач, нужно:
- выделять сложные задачи в потоки;
- динамически опрашивать потоки о завершении;
- в перерывах вызывать обработку событий Qt.
Отличие от Rust
Qt не пытается при помощи макросов уйти в метапрограммирование потоков - например, в том же tokio асинки имеют свою низкозатратную реализацию, причём на уровне программы мы пишем код, будто каждая функция будет выполняться в отдельном потоке, в то время как эта реализация просто прерывает выполнение функции в одном месте и потом возобновляет по мере получения результата. C++ так не делает, и поэтому все функции желательно завершать, передавая результаты event loop'у и используя для этого превосходный механизм сигналов и слотов.