Всем привет, хочу вот поделится наработками, интересно имеет ли право на жизнь такая статья, или смысла нет ибо "еще один велосипед"? Многопоточный Observer на C++ (практика)
Есть много вариаций на тему данного паттерна, но большинство примеров, найденных мною, не подходит для многопоточных приложений.
В этой статье я хочу поделится опытом применения паттерна в многопоточных приложениях и опишу основные проблемы, с которыми мне пришлось столкнуться. Словарь предметной области.
Для начала давайте разберемся со словарем предметной области.
И так, действующие лица
Издатель, рассылающий уведомления — NotificationsDispatcher
Подписчик, получающий уведомления — NotificationsListener
Взаимодействие учасников
Подписка — Subscribe
Прекращение подписки — Unsubscribe
Отправка и получение сообщений — SendNotification и OnNotification
Также необходимо учитывать время жизни объектов-подписчиков и объекта-издателя. Простейшая реализация для однопоточной среды.
class CNotificationListener:
public INotificationListener
{
virtual void OnNotification(void* pContext)
{
wprintf(L"%d\n", *((int*)pContext));
}
};
int _tmain(int argc, _TCHAR* argv[])
{
CNotificationDispatcher aDispatcher;
CNotificationListener aListener1;
CNotificationListener aListener2;
CNotificationListener aListener3;
aDispatcher.Subscribe(&aListener1);
aDispatcher.Subscribe(&aListener2);
aDispatcher.Subscribe(&aListener3);
for(int i = 0; i < 5; ++i)
{
aDispatcher.SendNotification(&i);
}
aDispatcher.Unsubscribe(&aListener2);
aDispatcher.Unsubscribe(&aListener1);
aDispatcher.Unsubscribe(&aListener3);
return 0;
}
Переходим к многопоточной среде.
С одним потоком такой код будет работать довольно стабильно.
Давайте посмотрим что будет при работе нескольких потоков.
Если запустить такой код на выполнение, то рано или позно произойдет креш.
Проблема заключается в добавлении/удалении подписчиков и одновременной рассылке уведомлений (многопоточный доступ к CNotificationDispatcher::m_vListeners в нашем примере).
Здесь необходима синхронизация доступа к списку подписчиков CNotificationDispatcher::m_vListeners. Синхронизация доступа к списку подписчиков.
При такой реализации данный код не будет падать при использовании в предыдущем примере, но нас поджидает еще одна неприятная ситуация. Борьба с взаимной блокировкой потоков (deadlock).
Допустим у нас есть некий поток, выполняющий какую-то фоновую задачу и есть окно, где отображается ход выполнения этой задачи.
Как правило поток посылает уведомление классу окна, который в свою очередь вызывает SendMessage и делает какие-то действия в оконной процедуре.
Функция SendMessage является блокирующей, и работает она следующим образом — посылает уведомление потоку окна и ждет пока тот его обработает.
Если подключение/отключение подписчика будет происходить так-же в оконной процедуре (в контексте потока окна) возможна взаимная блокировка потоков, так называемый deadlock.
Такой deadlock может воспроизоводится крайне редко (в момент вызова Subscribe/Unsubscribe и одновременном вызове OnNotification в отдельном потоке)
Следующий код эмулирует данный deadlock.
Проблема заключается в том, что главный поток захватывает глобальную критическую секцию g_cs (при аналогии с оконной процедурой — выполняется в контексте оконного потока), и затем вызывает метод Subscribe/Unsubscribe, который внутри захватывает CNotificationDispatcher::m_cs.
В этот момент рабочий поток посылает уведомление, захватив CNotificationDispatcher::m_cs, и затем пытается захватить глобальный g_cs (при аналогии с оконом — вызывает SendMessage).
Поток окна A -> B
Рабочий поток B -> A
Это можно назвать класическим deadlock-ом.
Проблема скрывается в реализации метода CNotificationDispatcher::SendNotification
Мы не должны отсылать уведомления (вызывать callback), захватив при этом какой-либо синхронизационный объект, котрый может быть захвачен в обработчике уведомления (напрямую или косвенно). В реальном проекте таких ситуаций может быть множество, и разобраться с ними порой довольно сложно. И так, убираем блокировку при вызове уведомлений
Контроль времени жизни подписчиков.
После того, как мы убрали deadlock при вызове функции OnNotification у нас появилась другая проблема — время жизни объектов-подписчиков.
У нас больше нет гарантии, что метод OnNotification не будет вызван после вызова Unsubscribe, и по этому мы не можем удалить объект-подписчик непосредственно после вызова Unsubscribe.
В данной ситуации проще всего контролировать время жизни объектов-подписчиков с использованием счетчика ссылок.
Для этого можно исползовать технологию COM — унаследовать интерфейс INotificationListener от IUnknown и использовать ATL CComPtr для списка подписчиков внутри класса CNotificationDispatcher, тоесть заменить std::vector<INotificationListener*> на std::vector<CComPtr<INotificationListener>>.
Но такая реализация чревата дополнительными расходами на реализацию классов-подписчиков, так как в каждом из них должны быть реализованы методы AddRef/Release.
Для контроля времени жизни подписчиков с исползованием счетчика ссылок хорошо подойдут умные указатели. Финальная версия.
Нижележащий код будет финальной версией в данном обзоре, но это далеко не идеальная реализация, так как нет пределу совершенства.
Также в каждом конкретном случае возможны вариации, универсальное решение не всегда лучше.
Я заменил "голый" указатель INotificationListener* на "умный" указатель со счетчиком ссылок, такой оказался в библиотеке boost.
В функции Unsubscribe входной параметр используется исключительно как идентификатор отключаемого объекта, по этому там можно оставить просто указатель.
Также в функции Unsubscribe я добавил переменную toRelease для того, чтобы вызвать деструктор подписчика уже после вызова Unlock
Стоит обратить внимание на то, что в функции SendNotification происходит копирование списка умных указателей (после копирования все указатели увеличивают свои счетчики ссылок, а при выходе из функции уменьшают, что и контролирует время жизни подписчиков) Тестируем.
Здравствуйте, Ryadovoy, Вы писали:
R>Всем привет, хочу вот поделится наработками, интересно имеет ли право на жизнь такая статья, или смысла нет ибо "еще один велосипед"? R>...
Общая идея понятна. Детально код не изучал, но что сразу резануло по глазам — небезопасность кода с точки зрения исключений — повсеместно в коде встречаются фрагменты:
EnterCriticalSection();
//Какие-то операции
LeaveCriticalSection();
Что если во время выполнения "каких-то операций" возникнет исключение? Почему бы в данном случае не использовать RAII — например, мьютексы и локи из boost::thread?
--
Не можешь достичь желаемого — пожелай достигнутого.
Здравствуйте, rg45, Вы писали:
R>Общая идея понятна. Детально код не изучал, но что сразу резануло по глазам — небезопасность кода с точки зрения исключений — повсеместно в коде встречаются фрагменты: R>
R>EnterCriticalSection();
R>//Какие-то операции
R>LeaveCriticalSection();
R>
R>Что если во время выполнения "каких-то операций" возникнет исключение? Почему бы в данном случае не использовать RAII — например, мьютексы и локи из boost::thread?
Вы конечно-же со мной не согласитесь, но я считаю что c++ исключения это зло.
Я работаю в команде, где не особо ими пользуются (только при необходимости), а необходимости из под колбека выбрасывать исключение нет никакой.
bools и stl может выбросить исключение, но оно возможно лишь когда имеет место ошибка программиста
(ну или закончилась память в системе, что тоже критично), тогда нужно падать с креш репортом, дабы не скрывать ошибку.
Здравствуйте, Alexander G, Вы писали: AG>ну, когда мне такое понадобится, я скорее посмотрю на boost::signals2
Спасибо за наводку, я обязательно обращу внимание на signals2.
Моя статья направлена не на то, чтобы дать готовое решение,
она объясняет основные принципы, которые применимы там, где boost бывает недоступен (драйвера, нейтивные приложения и т.п.)
Здравствуйте, Ryadovoy, Вы писали:
R>Всем привет, хочу вот поделится наработками, интересно имеет ли право на жизнь такая статья, или смысла нет ибо "еще один велосипед"?
статья актуальна, поэтому я поддерживаю вашу инициативу
есть ряд замечаний, которые, как мне кажется, вполне уместны
1) в коде много Windows-specific кода, мне кажется, что это отвлекает от сути подхода
общеупотребительные (типа буста) или ваши собственные абстракции, скрывающие взаимодействие с операционкой были бы уместны.
пример:
class Mutex
{
public:
void Lock();
void Unlock();
};
class MutexLock //: non_copyable
{
public:
explicit MutexLock(Mutex& m)
: M(m)
{
M.Lock();
}
~MutexLock()
{
M.Unlock();
}
private:
Mutex& M;
};
//usage:
Mutex m; // guard for data
MutexLock lock(m); //RAII
modify data
2) вам посоветовали исключить ручные Lock\Unlock, потому что это усложняет поддержку кода и может привести к проблемам в случае исключений имхо (хотя в конкретном коде я проблем не увидел)
3) передача this изнутри класса может привести к непредсказуемым результатам и вы это совсем не сразу обнаружите. если класс регистрировали, имея один указатель, то он может не совпадать с this внутри этого класса (представьте множественные и виртуальные наследования). тут можно опираться на некие ID в самом классе (гуиды или автоинкременты), либо ограничить использование. просто об этом не стОит забывать
4) в паттерне observer еще есть такой подход: класс, за которым наблюдают хранит в себе список weak_ptr на обзерверы. не всегда логично, что этот объект должен управлять временем жизни наблюдателя. таким образом алгоритм усложняется тем, что надо иногда из списка удалять мертвые ссылки и Unregister становится ненужным\необязательным.
успехов
Здравствуйте, remark, Вы писали:
R>Это, конечно, достаточно печально. Выделять память, копировать N памяти, атомарно инкрементировать N счётчиков под мьютексом не айс.
ничего печального нет в этом
просто вы озабочены лок-фри алгоритмами и оптимизациями
статья далека от ваших заумностей, но вполне подходит для решения простых задач даже в корпоративном софте
Здравствуйте, uzhas, Вы писали:
U>Здравствуйте, Ryadovoy, Вы писали: U>успехов
совсем забыл
5) никогда не забывайте объявлять виртуальный деструктор в интерфейсах
U>2) вам посоветовали исключить ручные Lock\Unlock, потому что это усложняет поддержку кода и может привести к проблемам в случае исключений имхо (хотя в конкретном коде я проблем не увидел)
Теоритически эти проблемы могут возникнуть при возбуждении std::bad_alloc при вставке в вектор (CNotificationDispatcher::Subscribe) :
Правильно понял, что signals2 впервые появились в мае 2009-го года,
и до этого были лишь signals, которые были не устойчивы к многопоточности?
Есть ли еще какие-нибудь альтернативы?
Здравствуйте, uzhas, Вы писали:
R>>Это, конечно, достаточно печально. Выделять память, копировать N памяти, атомарно инкрементировать N счётчиков под мьютексом не айс. U>ничего печального нет в этом U>просто вы озабочены лок-фри алгоритмами и оптимизациями U>статья далека от ваших заумностей, но вполне подходит для решения простых задач даже в корпоративном софте
Это не повод заниматься предварительной пессимизацией и неправильно проектировать.
Один подписчик обычно обрабатывает множество уведомлений. Отсюда следует, что копировать контейнер и захватывать ссылки лучше при добавлении/удалении подписчиков, а не при нотификациях. Соотв. ты просто делаешь что-то типа такого. И никакой тебе лок-фри магии. Нотификации всегда O(1).
Здравствуйте, Юрий Жмеренецкий, Вы писали:
ЮЖ>Теоритически эти проблемы могут возникнуть при возбуждении std::bad_alloc при вставке в вектор (CNotificationDispatcher::Subscribe) :
Если необходима обработка нехватки памяти, это делается, но не для примера в статье, вы со мной не согласны?
Наши user-level приложения просто падают когда заканчивается память, и создается креш дамп, ибо странно что закончилась память, неправда-ли?
В большинстве случаев это случается из-за нашей внутренней ошибки (например heap corraption или что-то в этом роде).
В таком случае лучше сразу-же увалить программу и пусть сгенерирует креш дамп, который мы проанализируем и пофиксим баг.
Какой смысл от неотпущенного лока если мы получили критическую ошибку?
Здравствуйте, Ryadovoy, Вы писали:
R>Наши user-level приложения просто падают когда заканчивается память, и создается креш дамп, ибо странно что закончилась память, неправда-ли? R>В большинстве случаев это случается из-за нашей внутренней ошибки (например heap corraption или что-то в этом роде). R>В таком случае лучше сразу-же увалить программу и пусть сгенерирует креш дамп, который мы проанализируем и пофиксим баг. R>Какой смысл от неотпущенного лока если мы получили критическую ошибку?
Если ты в фотошопе редактируешь большую картинку, и начинаешь применять какой-то сложный фильтр, какой вариант развития событий ты выберешь?
1. Фотошоп падает и теряет твои изменения.
2. Фотошоп откатывает применение фильтра, как будто он не начинался, и выдаёт окошко, что операция прервана из-за нехватки памяти.
Здравствуйте, remark, Вы писали:
R>Это не будет работать. Достаточно в обработчике добавить/удалить несколько подписчиков, как всё крякнется.
Идея была сделать устойчивую финальную версию,
промежуточные версии нужны лишь для объяснения проблеммы, и они не тестировались в различных ситуациях.
Или вы имели в виду финальную версию?
Здравствуйте, remark, Вы писали:
R>Здравствуйте, Ryadovoy, Вы писали:
R>>Наши user-level приложения просто падают когда заканчивается память, и создается креш дамп, ибо странно что закончилась память, неправда-ли? R>>В большинстве случаев это случается из-за нашей внутренней ошибки (например heap corraption или что-то в этом роде). R>>В таком случае лучше сразу-же увалить программу и пусть сгенерирует креш дамп, который мы проанализируем и пофиксим баг. R>>Какой смысл от неотпущенного лока если мы получили критическую ошибку?
R>Если ты в фотошопе редактируешь большую картинку, и начинаешь применять какой-то сложный фильтр, какой вариант развития событий ты выберешь? R>1. Фотошоп падает и теряет твои изменения. R>2. Фотошоп откатывает применение фильтра, как будто он не начинался, и выдаёт окошко, что операция прервана из-за нехватки памяти.
И дальше подварианты:
2.1. Фотошоп потом наглухо задедлочится при следующей операции
2.2. Можно будет нормально сохранить изображение, и продолжить работу, когда память освободится
?
Здравствуйте, remark, Вы писали:
R>Это не повод заниматься предварительной пессимизацией и неправильно проектировать.
вместо одного push_back вы сделали три операции, на осознание которых (честно) у меня ушло 3 минуты
имхо, это называется предварительная оптимизация
да и употребление .px вместо .get() тоже намекает на вашу озабоченность
просто вы так долго варитесь в этом, что считаете это естественным, тогда как другие чаще всего работают на другом уровне
но я не хочу спорить об этом R>Один подписчик обычно обрабатывает множество уведомлений. Отсюда следует, что копировать контейнер и захватывать ссылки лучше при добавлении/удалении подписчиков, а не при нотификациях. Соотв. ты просто делаешь что-то типа такого. И никакой тебе лок-фри магии. Нотификации всегда O(1).
я понял вашу идею, она мне тоже кажется оптимальнее
R>
Здравствуйте, remark, Вы писали:
R>Если ты в фотошопе редактируешь большую картинку, и начинаешь применять какой-то сложный фильтр, какой вариант развития событий ты выберешь? R>1. Фотошоп падает и теряет твои изменения. R>2. Фотошоп откатывает применение фильтра, как будто он не начинался, и выдаёт окошко, что операция прервана из-за нехватки памяти.
Программы и ситуации бывают разными.
Мне попадался проект в котором сплошь и рядом встречались конструкции try{}catch(...){} которые прятали реальные баги.
программа не падала, но глючила страшно.
Удалось ее нам привести к более менее стабильному состоянию лишь убрав все эти перехватчики.