Представьте себе архитектуру типичного приложения:
Есть рабочий поток движка, выполняющий какую-то функциональность, допустим копирование файлов (архивирование, поиск простых чисел). В общем что-то длительное.
Данный поток должен периодически сообщать информацию о текущем копируемом файле, а также уметь обрабатывать ошибки, допустим ошибка нехватки места на диске.
Графический интерфейс такого приложения должен позволять запускать процесс копирования файлов, уметь приостановить копирование, а также, в случае ошибки, отобразить соответствующий диалог с вопросом к пользователю.
Казалось бы, как можно допустить ошибку в такой простой ситуации?
Когда в программе появляется дополнительный поток — сразу же возникает проблема взаимодействия между потоками. Даже если поток ничего не делает и ни с кем не общается, всегда есть проблема правильной остановки потока.
Даже при работе с высокоуровневыми классами-обертками над потоками, легко сделать что-то не так, если до конца не понимать правильность работы с потоками. По этому в данной статье будет идти речь о работе с потоками на уровне WinAPI.
И так, вернемся к нашему примеру.
Рабочий поток движка должен каким-то образом сообщать потоку GUI о своем состоянии (текущий копируемый файл), уведомлять о постановке на паузу, а так-же инициировать с��общение об ошибке.
Асинхронный способ — рабочий поток уведомляет о своем состоянии асинхронными сообщениями (PostMessage).
После посылки такого сообщения, поток, как правило, не дожидается ответа и продолжает свою работу.
А в случае невозможности продолжать, поток ожидает вызова управляющей команды от GUI.
Синхронный способ — рабочий поток уведомляет о своем состоянии синхронными вызовами (SendMessage), с ожиданием завершения обработки таких вызовов.
Такой способ удобен тем, что рабочий поток, в момент обработки сообщений, находится в заранее известном состоянии. Нет необходимости в излишней синхронизации.
Также не мало важен третий вариант — опрос состояния движка по таймеру. Таким образом лучше всего реализовывать часто меняющиеся состояния, например прогресс выполнения. Но этот способ не относится к теме данной статьи.
В асинхронном способе есть и свои преимущества, но речь пойдет о синхронных сообщениях, основная выгода которых — простота.
Когда я вижу рабочий поток, то сразу задаюсь вопросом как он взаимодействует с GUI и как его при этом останавливают.
Будьте внимательны, если рабочий поток прямым или косвенным образом вызывает блокирующую функцию SendMessage для GUI потока. На примере WinAPI, это может быть что-нибудь совсем безобидное, например какой нибудь вызов SetText, который внутри вызывает SendMessage WM_SETTEXT. В этом случае нужно быть особо внимательным при попытке остановки потока в обработчиках нажатия на кнопки и при закрытии приложения (в случае если GUI поток является основным потоком приложения). Это не совсем очевидно, дальше я попытаюсь объяснить.
Правильный способ завершить поток — это дождаться завершения, с использованием одной из функций WaitFor, передав параметром HANDLE потока. Притом дожидаться полной остановки потока обязательно — никаких таймаутов с последующим вызовом TerminateThread. Например:
Если этого не сделать, возможны непредсказуемые последствия (зависания и падения программы).
Особенно, если поток находится внутри динамически подключаемой библиотеки, которая должна тут-же выгрузиться.
И так, еще раз о проблеме SendMessage: если мы будем в обработчике оконных сообщений ждать завершения потока, то мы таким образом заблокируем эту самую обработку оконных сообщений. А рабочий поток, в свою очередь, пошлет сообщение и будет ждать пока его обработают. Таким образом мы гарантированно получим взаимную блокировку потоков (deadlock).
Один из вариантов решения в случае синхронных сообщений — не просто ждать завершение потока, а прокручивать оконные сообщения, пока поток не завершиться (костыль конечно, но тоже имеет право на существование)
Вторая архитектурная проблема — если рабочий поток вызывает напрямую код GUI, то необходимо позаботиться о синхронизации. Синхронизация потоков получается размазанной по всей программе.
Рабочий поток должен быть изолирован внутри интерфейса движка.
Все уведомления от движка должны приходить синхронно и в контексте клиентского потока, по принципу COM Single-Threaded Apartments.
Вызовы должны происходить синхронно и прозрачно: рабочий поток вызывает некую функцию, которая не возвращает управление до тех пор, пока поток GUI не обработает этот вызов.
Но при этом рабочий поток должен иметь возможность завершиться, даже в момент вызова такой функции.
В итоге интерфейс движка для GUI будет однопоточным, что существенно упростит работу с таким движком.
Для реализации этого поведения можно создать повторно используемый объект, который будет обеспечивать переключение контекста потоков при вызове кода GUI.
Я назвал такой объект — синхронизационный канал.
И так, делаем некий синхронизационный канал, при помощи которого рабочий поток движка будет вызывать функции обратного вызова, реализуемые GUI.
Канал будет иметь функцию Execute, с параметром boost::function, куда можно передать функтор, созданный boost::bind. Таким образом, с использованием данного канала, можно будет вызвать функцию обратного вызова с любой сигнатурой, например:
Функция Execute, как говорилось раньше, синхронная — она не завершается до тех пор, пока функция обратного вызова не будет завершена. Кроме исключения, описанного ниже.
Канал также должен иметь функцию Close, действие которой следующее: все вызовы функции Execute завершаются, новые вызовы функции Execute не проходят. Рабочий поток освобождается и, таким образом, решается проблема остановки рабочего потока — можно использовать функцию WaitFor без необходимости прокрутки оконных сообщений.
Для переключения контекста потоков в примере используется стандартная очередь сообщений Win32 потока и функция PostThreadMessage.
Принцип работы следующий: рабочий поток посылает сообщение при помощи PostThreadMessage, и далее ждет, пока это сообщение не будет обработано, для этого в примере используется отдельный объект событие.
В это время GUI поток должен обрабатывать свои оконные сообщения, одним из которых должно б��ть сообщение от рабочего потока, которое он обязан обработать и известить рабочий поток о завершении обработки, используя объект событие.
Данная реализация предполагает функцию ProcessMessage, которую необходимо вызывать из цикла обработки оконных сообщений или оконной процедуры. Возможна реализации и без такой функции, например канал может создавать себе невидимое окно, и обрабатывать все сообщения внутри. Кроме того, возможны реализации без использования оконных сообщений в принципе.
Хотелось бы еще сказать, что пример несет лишь ознакомительный характер, и не является готовым решением.
Пример использования класса CSyncChannel смотрите в следующей статье — Организация рабочих потоков: управление состоянием движка.
Есть рабочий поток движка, выполняющий какую-то функциональность, допустим копирование файлов (архивирование, поиск простых чисел). В общем что-то длительное.
Данный поток должен периодически сообщать информацию о текущем копируемом файле, а также уметь обрабатывать ошибки, допустим ошибка нехватки места на диске.
Графический интерфейс такого приложения должен позволять запускать процесс копирования файлов, уметь приостановить копирование, а также, в случае ошибки, отобразить соответствующий диалог с вопросом к пользователю.
Казалось бы, как можно допустить ошибку в такой простой ситуации?
Проблемы многопоточности
Когда в программе появляется дополнительный поток — сразу же возникает проблема взаимодействия между потоками. Даже если поток ничего не делает и ни с кем не общается, всегда есть проблема правильной остановки потока.
Даже при работе с высокоуровневыми классами-обертками над потоками, легко сделать что-то не так, если до конца не понимать правильность работы с потоками. По этому в данной статье будет идти речь о работе с потоками на уровне WinAPI.
И так, вернемся к нашему примеру.
Рабочий поток движка должен каким-то образом сообщать потоку GUI о своем состоянии (текущий копируемый файл), уведомлять о постановке на паузу, а так-же инициировать с��общение об ошибке.
Два основных способа уведомлений — асинхронный и синхронный
Асинхронный способ — рабочий поток уведомляет о своем состоянии асинхронными сообщениями (PostMessage).
После посылки такого сообщения, поток, как правило, не дожидается ответа и продолжает свою работу.
А в случае невозможности продолжать, поток ожидает вызова управляющей команды от GUI.
Синхронный способ — рабочий поток уведомляет о своем состоянии синхронными вызовами (SendMessage), с ожиданием завершения обработки таких вызовов.
Такой способ удобен тем, что рабочий поток, в момент обработки сообщений, находится в заранее известном состоянии. Нет необходимости в излишней синхронизации.
Также не мало важен третий вариант — опрос состояния движка по таймеру. Таким образом лучше всего реализовывать часто меняющиеся состояния, например прогресс выполнения. Но этот способ не относится к теме данной статьи.
В асинхронном способе есть и свои преимущества, но речь пойдет о синхронных сообщениях, основная выгода которых — простота.
Подводные камни: SendMessage + остановка потока
Когда я вижу рабочий поток, то сразу задаюсь вопросом как он взаимодействует с GUI и как его при этом останавливают.
Будьте внимательны, если рабочий поток прямым или косвенным образом вызывает блокирующую функцию SendMessage для GUI потока. На примере WinAPI, это может быть что-нибудь совсем безобидное, например какой нибудь вызов SetText, который внутри вызывает SendMessage WM_SETTEXT. В этом случае нужно быть особо внимательным при попытке остановки потока в обработчиках нажатия на кнопки и при закрытии приложения (в случае если GUI поток является основным потоком приложения). Это не совсем очевидно, дальше я попытаюсь объяснить.
Правильный способ завершить поток — это дождаться завершения, с использованием одной из функций WaitFor, передав параметром HANDLE потока. Притом дожидаться полной остановки потока обязательно — никаких таймаутов с последующим вызовом TerminateThread. Например:
// INFINITE означает, что - функция не вернет управление до тех пор, пока поток не завершится WaitForSingleObject(hThread, INFINITE);
Если этого не сделать, возможны непредсказуемые последствия (зависания и падения программы).
Особенно, если поток находится внутри динамически подключаемой библиотеки, которая должна тут-же выгрузиться.
И так, еще раз о проблеме SendMessage: если мы будем в обработчике оконных сообщений ждать завершения потока, то мы таким образом заблокируем эту самую обработку оконных сообщений. А рабочий поток, в свою очередь, пошлет сообщение и будет ждать пока его обработают. Таким образом мы гарантированно получим взаимную блокировку потоков (deadlock).
Один из вариантов решения в случае синхронных сообщений — не просто ждать завершение потока, а прокручивать оконные сообщения, пока поток не завершиться (костыль конечно, но тоже имеет право на существование)
Вторая архитектурная проблема — если рабочий поток вызывает напрямую код GUI, то необходимо позаботиться о синхронизации. Синхронизация потоков получается размазанной по всей программе.
Вариант решения перечисленных проблем
Рабочий поток должен быть изолирован внутри интерфейса движка.
Все уведомления от движка должны приходить синхронно и в контексте клиентского потока, по принципу COM Single-Threaded Apartments.
Вызовы должны происходить синхронно и прозрачно: рабочий поток вызывает некую функцию, которая не возвращает управление до тех пор, пока поток GUI не обработает этот вызов.
Но при этом рабочий поток должен иметь возможность завершиться, даже в момент вызова такой функции.
В итоге интерфейс движка для GUI будет однопоточным, что существенно упростит работу с таким движком.
Вариант реализации и пример на C++
Для реализации этого поведения можно создать повторно используемый объект, который будет обеспечивать переключение контекста потоков при вызове кода GUI.
Я назвал такой объект — синхронизационный канал.
И так, делаем некий синхронизационный канал, при помощи которого рабочий поток движка будет вызывать функции обратного вызова, реализуемые GUI.
Канал будет иметь функцию Execute, с параметром boost::function, куда можно передать функтор, созданный boost::bind. Таким образом, с использованием данного канала, можно будет вызвать функцию обратного вызова с любой сигнатурой, например:
class IEngineEvents { public: virtual void OnProgress(int progress) = 0; ... }; //где-то в движке... IEngineEvents* listener; //указатель на объект, реализуемый GUI syncChannel.Execute(boost::bind(&IEngineEvents::OnProgress, listener, 30));
Функция Execute, как говорилось раньше, синхронная — она не завершается до тех пор, пока функция обратного вызова не будет завершена. Кроме исключения, описанного ниже.
Канал также должен иметь функцию Close, действие которой следующее: все вызовы функции Execute завершаются, новые вызовы функции Execute не проходят. Рабочий поток освобождается и, таким образом, решается проблема остановки рабочего потока — можно использовать функцию WaitFor без необходимости прокрутки оконных сообщений.
Для переключения контекста потоков в примере используется стандартная очередь сообщений Win32 потока и функция PostThreadMessage.
Принцип работы следующий: рабочий поток посылает сообщение при помощи PostThreadMessage, и далее ждет, пока это сообщение не будет обработано, для этого в примере используется отдельный объект событие.
В это время GUI поток должен обрабатывать свои оконные сообщения, одним из которых должно б��ть сообщение от рабочего потока, которое он обязан обработать и известить рабочий поток о завершении обработки, используя объект событие.
Данная реализация предполагает функцию ProcessMessage, которую необходимо вызывать из цикла обработки оконных сообщений или оконной процедуры. Возможна реализации и без такой функции, например канал может создавать себе невидимое окно, и обрабатывать все сообщения внутри. Кроме того, возможны реализации без использования оконных сообщений в принципе.
Хотелось бы еще сказать, что пример несет лишь ознакомительный характер, и не является готовым решением.
// SyncChannel.h class CSyncChannel { public: typedef boost::function<void()> CCallback; public: CSyncChannel(void); ~CSyncChannel(void); public: bool Create(DWORD clientThreadId); void Close(); bool Execute(CCallback callback); bool ProcessMessage(MSG msg); private: DWORD m_clientThreadId; CCallback m_callback; HANDLE m_deliveredEvent; volatile bool m_closeFlag; };
// SyncChannel.cpp UINT WM_SYNC_CHANNEL_COMMAND = WM_APP + 500; CSyncChannel::CSyncChannel(void) : m_closeFlag(true) {} CSyncChannel::~CSyncChannel(void) {} bool CSyncChannel::Create(DWORD clientThreadId) { if (!m_closeFlag) { return false; } m_clientThreadId = clientThreadId; m_deliveredEvent = CreateEvent(NULL, TRUE, FALSE, NULL); if (!m_deliveredEvent) { return false; } m_closeFlag = false; return true; } void CSyncChannel::Close() { m_closeFlag = true; if (m_deliveredEvent) { CloseHandle(m_deliveredEvent); m_deliveredEvent = NULL; } } bool CSyncChannel::Execute(CCallback callback) { // Эта функция может быть вызвана с любого потока. // Дело в том, что некоторые функции движка могут вызываться клиентским потоком. // Например функция Pause(), в которой может быть // тут-же вызвана функция обратного вызова, // изменяющая состояние движка на что нибудь вроде "pause pending" if (m_closeFlag) { return false; } if (GetCurrentThreadId() == m_clientThreadId) { // Если вызывающий поток - это клиентский поток, // то мы должны вызвать колбек напрямую, без переключения контекста потоков. // Иначе поток сам себе пошлет сообщение, и будет ждать пока он сам его обработает, // что привело бы к блокировке потоков - поток будет ждать сам себя. callback(); } else { // Функция Execute была вызвана из рабочего потока, // по этому мы должны послать сообщение клиентскому потоку, // и вызвать функцию обратного вызова уже в нем. // Сохраняем функцию обратного вызова для того, // чтобы она была вызвана в клиентском потоке. // Данная реализация предполагает один рабочий поток, и один клиентский, // если рабочих потоков будет сразу несколько, // то здесь необходимо добавить синхронизацию. m_callback = callback; // Сбрасываем объект событие для того, чтобы клиентский поток // мог нам его установить после того, как он обработает вызов ResetEvent(m_deliveredEvent); // Уведомляем клиентский поток о том, что необходимо // вызвать функцию обратного вызова. // Для этого посылаем в клиентский поток сообщение, получив которое, // клиентский поток должен будет вызвать функцию CSyncChannel::ProcessMessage() if (!PostThreadMessage(m_clientThreadId, WM_SYNC_CHANNEL_COMMAND, NULL, NULL)) { return false; } // Ждем, пока клиенсткий поток вызовет функцию CSyncChannel::ProcessMessage(), // в которой установится событие m_deliveredEvent, // либо пока не будет установлен флаг m_closeFlag // Можно заменить флаг m_closeFlag на объект событие // и использовать WaitForMultipleObjects, но канал будет закрываться не часто, // и моментальной реакции на это не требуется. DWORD waitResult = WAIT_TIMEOUT; while (waitResult == WAIT_TIMEOUT && !m_closeFlag) { waitResult = WaitForSingleObject(m_deliveredEvent, 100); } if (waitResult != WAIT_OBJECT_0) { // Мы не дождались сообщения о доставке, а значит мы дождались флага закрытия return false; } } // Функция обратного вызова была успешно вызвана в клиентском потоке return true; } bool CSyncChannel::ProcessMessage(MSG msg) { // Эта функция вызывается только из клиентского потока if (msg.message != WM_SYNC_CHANNEL_COMMAND) { // Клиентский код вызывает эту функцию для всех сообщений потока, // фильтруем не наши сообщения return false; } if (!m_closeFlag) { // Мы переключились в контекст клиентского потока, // и теперь мы можем вызвать функцию обратного вызова m_callback(); // После обработки вызова, отпускаем рабочий поток. // Для этого необходимо установить объект событие SetEvent(m_deliveredEvent); } return true; }
Пример использования класса CSyncChannel смотрите в следующей статье — Организация рабочих потоков: управление состоянием движка.
