GrabDuck

Обработка исключений в многопоточных приложениях

:

Рассказывает Пол Шарф, автор блога genericgamedev.com


В прошлом уроке мы использовали многопоточность для создания анимированных (или даже интерактивных) экранов загрузки, а также для уменьшения времени загрузки в целом.

И хотя мы рассмотрели множество вещей на примере Roche Fusion, кое-что мы не затронули вовсе — что, если что-то пойдет не так? Или, более техническим языком — что, если один из наших потоков вернет исключение?

Если мы ничего не будем с этим делать, то поток, который вернет исключение (например, из-за бага или испорченного файла), завершится без нашего ведома, и мы никогда об этом не узнаем.

Так происходит, потому что исключения постепенно накапливаются в стеке вызовов до тех пор, пока не будут обработаны или не заполнят весь стек. В последнем случае поток, в котором появилось исключение, завершает работу.

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

Заметьте, что ниже описанные решения этой проблемы применимы к любым многопоточным приложениям. Загрузочные экраны служат лишь в качестве примера.

Возможные решения

Существует несколько возможных способов обрабатывать исключения в потоках.

Например, главный поток может регулярно проверять, остановились ли другие и сделали ли они что-нибудь с возникшим исключением.

Проблема этого решения в том, что нам нужно быть в курсе всех выполняемых потоков, в ином случае какой-нибудь поток все равно экстренно завершится, не подав вида.

Альтернативно мы можем подписаться на глобальные события, происходящие во всем приложении, которые будут информировать нас о произошедших исключениях, так, что мы сможем реагировать на них.

Но и здесь не все так гладко. Такой подход не позволит нам «потерять» потоки, но из-за асинхронной природы приложения мы не можем быть уверены в том, когда исполнится код нашего обработчика, тем самым рискуя натолкнуться на другие проблемы многопоточности.

Используем то, что у нас уже есть

Ранее мы расписали архитектуру небольшого фреймворка, и я бы хотел представить вам решение, которое не приводит к вышеописанным проблемам.

В приложении, написанном нами в прошлом уроке, любой поток мог давать главному потоку работу на выполнение. Каждый поток может сам обрабатывать исключения таким образом, что все возникшие ошибки передавались бы главному потоку. В таком случае исключение либо заставит упасть все приложение, либо будет обработано должным образом. Вот простой пример реализации такой идеи:

void startThread(Action threadAction)
{
    new Thread(
        () => tryRun(threadAction)
        ).Start();
}

void tryRun(Action Action)
{
    try
    {
        threadAction();
    }
    catch (Exception e)
    {
        this.rethrow(e);
    }
}

void rethrow(Exception e)
{
    this.scheduleOnMainThread(
        () => throw e
        );
}

void scheduleOnMainThread(Action action)
{
    this.actionQueue.RunAndForget(action);
}

actionQueue – это объект класса, разработанного нами в этом посте, который позволяет перенаправлять из всех потоков какие-либо действия на главный поток.

Таким образом, когда threadAction возвращает исключение, оно не обрабатывается, а помещается в очередь на исполнение в главном потоке.

Когда главный поток доберется до исключения в очереди действий, он уже обработает его как надо, а если не сможет, то приложение просто закончит работу.

Данный подход отлично работает на первый взгляд, но есть одна проблема, которая очень быстро становится явной при отладке — при возвращении исключения, которое уже возвращалось ранее, его стек вызовов автоматически перезаписывается. И без начального стека вызовов мы даже не сможем определить, в какой части программы возникло исключение.

Сохраняем стеки вызовов

К счастью, у этой проблемы есть очень легкое решение.

Вместо того, чтобы возвращать то же исключение, что и раньше, мы можем просто создать новое, содержащее в себе старое, как это делает C#.

Внесем поправку в написанный выше код:

void rethrow(Exception e)
{
    this.scheduleOnMainThread(
        () => throw new Exception("A thread threw an exception.", e)
        );
}

И это все, что нам нужно, чтобы убедиться в том, что мы не теряем информацию о предыдущем исключении при возвращении такого же нового.

Дополнительно

Конечно, если возможно, то лучше проектировать ваше приложение так, что возможность возникновения фатального исключения стремится к нулю.

Например, мы в Roche Fusion оборачиваем каждый скриптовый файл в try-catch. В случае, если что-то пойдет не так, в игровую консоль выводится предупреждение и загрузка продолжается.

В большинстве случаев это никак не влияет на игру, разве что некоторый контент может отображаться не так, как надо.

Однажды мы все-таки не обратили внимание на одно из предупреждений и напоролись на ситуацию, в которой при определенных обстоятельствах приложение могло крашиться из-за того, что один скрипт не загружался. Но в основном такой подход намного облегчает разработку и моддинг.

Заключение

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

Если вас заинтересовали другие подходы, которые я упомянул, но о которых не рассказал подробнее, рекомендую поискать их онлайн.

Наслаждайтесь пикселями!