В настоящей статье приводится разбор вариантов устранения антипаттерна «Зомби», описанного в первой части: Сказ об опасном std::enable_shared_from_this, или антипаттерн «Зомби».
Введение
Весь код, приведённый в статье, опубликован на гитхабе в ветках. В коде использованы несколько новшеств C++17 — weak_from_this(), if statement with init-statement, может что-то ещё по мелочи.
В примерах из первой части статьи цепочка возникновения проблемного кода выглядит следующим образом:
— задумывается класс асинхронного (неблокирующего) выполнения какого-то длительного процесса;
— для придания процессу асинхронности используется отдельный поток выполнения (std::thread);
— для удобства все нужные для работы процесса данные складываются в поля класса;
— в деструкторе класса предусматривается семафор для информирования потока о необходимости досрочного прекращения процесса в случае уничтожения экземпляра класса;
— поток выполнения нуждается в гарантии валидности экземпляра класса, для чего применяется захват сильной ссылки shared_from_this;
— объект потока выполнения является полем данных класса.
Возникает циклическая ссылка. Уничтожение вышестоящей бизнес-логикой всех сильных ссылок на экземпляр класса не приводит к вызову его деструктора. Неожиданно для разработчика попытка досрочного прекращения процесса в соответствии с RAII не срабатывает. Поток выполнения продолжает работу, что вызывает:
— труднодетектируемую утечку ресурсов (всегда);
— неопределённое поведение (в зависимости от применения);
— сбой высокоуровневой логики (в зависимости от применения).
Вот эту ситуацию и будем развязывать.
Способ очевидный, или weak
Одним из основных элементов антипаттерна «Зомби» является циклическая ссылка. Стандартной техникой её предотвращения является применение std::weak_ptr.
Идея способа: лямбда, запускающаяся в отдельном потоке, должна захватывать weak_from_this(), а не shared_from_this(). Модифицируем только зомби, и обходимся без масштабного рефакторинга.
SimpleZomby
Было:
void Zomby::runOnce(std::shared_ptr<Common::Listener> listener)
{
if (_semaphore) {
throw std::runtime_error("SimpleZomby::Zomby::runOnce() called twice");
}
_listener = listener;
_semaphore = true;
_thread = std::thread([shis = shared_from_this()](){
while (shis && shis->_listener && shis->_semaphore) {
shis->_listener->processData(std::make_shared<Common::Listener::Data>("SimpleZomby is alive!n"));
std::this_thread::sleep_for(std::chrono::seconds(1));
}
});
}
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
============================================================
| Zomby was killed |
============================================================
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
Стало:
void Zomby::runOnce(std::shared_ptr<Common::Listener> listener)
{
if (_semaphore) {
throw std::runtime_error("SimpleZomby::Zomby::runOnce() called twice");
}
_listener = listener;
_semaphore = true;
_thread = std::thread([whis = weak_from_this()](){
while (auto shis = whis.lock()) {
if (shis->_listener && shis->_semaphore) {
shis->_listener->processData(std::make_shared<Common::Listener::Data>("SimpleZomby is alive!n"));
std::this_thread::sleep_for(std::chrono::seconds(1));
}
}
});
}
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
============================================================
| Zomby was killed |
============================================================
N11SimpleZomby5ZombyE::~Zomby
N6Common22WriteToConsoleListenerE::~WriteToConsoleListener
Код вроде бы удалось починить — зомби корректно останавливается.
Кроме того, семафор стал лишним (хотя на самом деле он и до этого не работал) — функционал информирования потока о необходимости остановки удалось возложить на атомарный счётчик ссылок std::shared_ptr. Единственная польза от семафора — защита от повторного вызова функции runOnce, хотя и её можно переложить, например, на проверку _listener.
После удаления семафора:
void Zomby::runOnce(std::shared_ptr<Common::Listener> listener)
{
if (_semaphore) {
throw std::runtime_error("SimpleZomby::Zomby::runOnce() called twice");
}
_listener = listener;
_semaphore = true;
_thread = std::thread([whis = weak_from_this()](){
while (auto shis = whis.lock()) {
if (shis->_listener) {
shis->_listener->processData(std::make_shared<Common::Listener::Data>("SimpleZomby is alive!n"));
std::this_thread::sleep_for(std::chrono::seconds(1));
}
}
});
}
Однако при ближайшем рассмотрении становится понятно, что в теле лямбды сильная ссылка удерживается необоснованно долго — всё время выполнения длительной операции sleep_for.
Устраняется как-то так:
void Zomby::runOnce(std::shared_ptr<Common::Listener> listener)
{
if (_semaphore) {
throw std::runtime_error("SimpleZomby::Zomby::runOnce() called twice");
}
_listener = listener;
_semaphore = true;
_thread = std::thread([whis = weak_from_this()](){
while (auto shis = whis.lock()) {
if (shis->_listener) {
shis->_listener->processData(std::make_shared<Common::Listener::Data>("SimpleZomby is alive!n"));
shis.reset();
std::this_thread::sleep_for(std::chrono::seconds(1));
}
}
});
}
Или вот так:
void Zomby::runOnce(std::shared_ptr<Common::Listener> listener)
{
if (_semaphore) {
throw std::runtime_error("SimpleZomby::Zomby::runOnce() called twice");
}
_listener = listener;
_semaphore = true;
_thread = std::thread([whis = weak_from_this()](){
while (true) {
if (auto shis = whis.lock(); shis && shis->_listener) {
shis->_listener->processData(std::make_shared<Common::Listener::Data>("SimpleZomby is alive!n"));
} else {
break;
}
std::this_thread::sleep_for(std::chrono::seconds(1));
}
});
}
Ну что, переходим к следующему примеру?
Как бы не так!
У нас тут гонка.
Проще всего её продемонстрировать с помощью переноса слипа в чувствительное место:
void Zomby::runOnce(std::shared_ptr<Common::Listener> listener)
{
if (_semaphore) {
throw std::runtime_error("SimpleZomby::Zomby::runOnce() called twice");
}
_listener = listener;
_semaphore = true;
_thread = std::thread([whis = weak_from_this()](){
while (true) {
if (auto shis = whis.lock(); shis && shis->_listener) {
/*!!!*/ std::this_thread::sleep_for(std::chrono::seconds(1));
shis->_listener->processData(std::make_shared<Common::Listener::Data>("SimpleZomby is alive!n"));
} else {
break;
}
}
});
}
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
============================================================
| Zomby was killed |
============================================================
SimpleZomby is alive!
N11SimpleZomby5ZombyE::~Zomby
N6Common22WriteToConsoleListenerE::~WriteToConsoleListener
Вот что произошло:
main thread | zomby thread |
---|---|
вызывает runOnce | |
захватывает в лямбда-функцию слабую ссылку на экземпляр класса | |
запускает лямбда-функцию на выполнение в отдельном потоке | |
пытается взять сильную ссылку на экземпляр класса с проверкой результата; проверка проходит успешно | |
уничтожает единственную внешнюю сильную ссылку | |
передаёт сообщение полю данных экземпляра класса — listener'у |
Зомби остановился.
Но всё ещё слишком поздно — от уничтоженного зомби пришло одно сообщение.
В зависимости от применения, такое поведение может как являться приемлемым, так и послужить триггером для какой-либо неожиданной цепочки действий.
Почему приход данных в listener может быть неожиданным?
Зомби вряд ли является единственным игроком на поле.
Данные, прихода которых он ожидает, наверняка предполагают дальнейшую обработку (расшифровку, парсинг, отображение на экране и т.п.). Уничтожение бизнес-логикой зомби может означать, что механизмы обработки данных такого типа также уничтожены. Передача данных на обработку в уничтоженные механизмы грозит неопределённым поведением.
Также сообщение от зомби может предполагать высокоуровневую ответную реакцию. Вряд ли пользователь обрадуется сообщению «Выполнено успешно» после нажатия кнопки «Отмена».
SteppingZomby
Было:
void Zomby::runOnce(std::shared_ptr<Common::Listener> listener)
{
if (_semaphore) {
throw std::runtime_error("SteppingZomby::Zomby::runOnce() called twice");
}
_listener = listener;
_semaphore = true;
_thread = std::thread([shis = shared_from_this()](){
if (shis && shis->_listener && shis->_semaphore) {
shis->resolveDnsName();
}
if (shis && shis->_listener && shis->_semaphore) {
shis->connectTcp();
}
if (shis && shis->_listener && shis->_semaphore) {
shis->establishSsl();
}
if (shis && shis->_listener && shis->_semaphore) {
shis->sendHttpRequest();
}
if (shis && shis->_listener && shis->_semaphore) {
shis->readHttpReply();
}
});
}
N13SteppingZomby5ZombyE::resolveDnsName started
N13SteppingZomby5ZombyE::resolveDnsName finished
N13SteppingZomby5ZombyE::connectTcp started
============================================================
| Zomby was killed |
============================================================
N13SteppingZomby5ZombyE::connectTcp finished
N13SteppingZomby5ZombyE::establishSsl started
N13SteppingZomby5ZombyE::establishSsl finished
N13SteppingZomby5ZombyE::sendHttpRequest started
N13SteppingZomby5ZombyE::sendHttpRequest finished
N13SteppingZomby5ZombyE::readHttpReply started
N13SteppingZomby5ZombyE::readHttpReply finished
N13SteppingZomby5ZombyE::~Zomby
N6Common22WriteToConsoleListenerE::~WriteToConsoleListener
Стало (ненужную проверку семафора сразу убираем):
void Zomby::runOnce(std::shared_ptr<Common::Listener> listener)
{
if (_semaphore) {
throw std::runtime_error("SteppingZomby::Zomby::runOnce() called twice");
}
_listener = listener;
_semaphore = true;
_thread = std::thread([whis = weak_from_this()](){
if (auto shis = whis.lock(); shis && shis->_listener) {
shis->resolveDnsName();
}
if (auto shis = whis.lock(); shis && shis->_listener) {
shis->connectTcp();
}
if (auto shis = whis.lock(); shis && shis->_listener) {
shis->establishSsl();
}
if (auto shis = whis.lock(); shis && shis->_listener) {
shis->sendHttpRequest();
}
if (auto shis = whis.lock(); shis && shis->_listener) {
shis->readHttpReply();
}
});
}
N13SteppingZomby5ZombyE::resolveDnsName started
N13SteppingZomby5ZombyE::resolveDnsName finished
N13SteppingZomby5ZombyE::connectTcp started
============================================================
| Zomby was killed |
============================================================
N13SteppingZomby5ZombyE::connectTcp finished
N13SteppingZomby5ZombyE::~Zomby
N6Common22WriteToConsoleListenerE::~WriteToConsoleListener
Точно так же, как в примере SimpleZomby:
— вроде бы удалось остановить зомби;
— сильная ссылка удерживается слишком долго — функции resolveDnsName, connectTcp, establishSsl, sendHttpRequest, readHttpReply требуют валидного состояния экземпляра класса;
— присутствует состояние гонки.
Начатый шаг всегда отрабатывается до конца и всегда отправляет результат в listener, но цепочка шагов в большинстве случаев может быть прервана вышестоящей бизнес-логикой — кроме случая гонки, из-за которой следующий шаг может всё же начаться после уничтожения внешней сильной ссылки.
Рефакторинг private-методов класса может снизить время удержания сильной ссылки, но не позволит полностью устранить состояние гонки.
BoozdedZomby
Было:
void Zomby::runOnce(std::shared_ptr<Common::Listener> listener)
{
if (_semaphore) {
throw std::runtime_error("BoozdedZomby::Zomby::runOnce() called twice");
}
_listener = listener;
_semaphore = true;
_thread = std::thread([shis = shared_from_this()]() {
while (shis && shis->_semaphore && shis->_listener) {
auto handler = [shis](auto errorCode) {
if (shis && shis->_listener && errorCode == boozd::azzio::io_context::error_code::no_error) {
std::ostringstream buf;
buf << "BoozdedZomby has got a fresh data: ";
for (auto const &elem; : shis->_buffer)
buf << elem << ' ';
buf << std::endl;
shis->_listener->processData(std::make_shared<Common::Listener::Data>(buf.str()));
}
};
shis->_buffer.clear();
shis->_context.async_read(shis->_stream, shis->_buffer, handler);
shis->_context.run();
}
});
}
BoozdedZomby has got a fresh data: 1144108930 101027544 1458777923 1115438165 74243042
BoozdedZomby has got a fresh data: 143542612 1131570933
BoozdedZomby has got a fresh data: 893351816 563613512 704877633
BoozdedZomby has got a fresh data: 1551901393 1399125485 1899894091 937186357 590357944 357571490
============================================================
| Zomby was killed |
============================================================
BoozdedZomby has got a fresh data: 1927702196 130060903 1083454666 2118797801 2035308228 824938981
BoozdedZomby has got a fresh data: 2020739063 1635339425 34075629
BoozdedZomby has got a fresh data: 2146319451 500782188 1269406752 884936716 892053144
BoozdedZomby has got a fresh data: 330111137 1723153177 1070477904
BoozdedZomby has got a fresh data: 343098142 280090412 589673557 889688008 2014119113 388471006
Стало:
void Zomby::runOnce(std::shared_ptr<Common::Listener> listener)
{
if (_semaphore) {
throw std::runtime_error("BoozdedZomby::Zomby::runOnce() called twice");
}
_listener = listener;
_semaphore = true;
_thread = std::thread([whis = weak_from_this()]() {
while (auto shis = whis.lock()) {
if (shis->_semaphore && shis->_listener) {
auto handler = [whis](auto errorCode) {
auto shis = whis.lock();
if (shis && shis->_listener && errorCode == boozd::azzio::io_context::error_code::no_error) {
std::ostringstream buf;
buf << "BoozdedZomby has got a fresh data: ";
for (auto const &elem; : shis->_buffer)
buf << elem << ' ';
buf << std::endl;
shis->_listener->processData(std::make_shared<Common::Listener::Data>(buf.str()));
}
};
shis->_buffer.clear();
shis->_context.async_read(shis->_stream, shis->_buffer, handler);
shis->_context.run();
}
}
});
}
BoozdedZomby has got a fresh data: 1144108930 101027544 1458777923 1115438165 74243042
BoozdedZomby has got a fresh data: 143542612 1131570933
BoozdedZomby has got a fresh data: 893351816 563613512 704877633
BoozdedZomby has got a fresh data: 1551901393 1399125485 1899894091 937186357 590357944 357571490
============================================================
| Zomby was killed |
============================================================
BoozdedZomby has got a fresh data: 1927702196 130060903 1083454666 2118797801 2035308228 824938981
N12BoozdedZomby5ZombyE::~Zomby
N5boozd5azzio10io_contextE::~io_context
N5boozd5azzio13random_streamE::~random_stream
N6Common22WriteToConsoleListenerE::~WriteToConsoleListener
Зомби-поток отдаёт управление функции boozd::azzio::io_context::run(). При этом необходимо обеспечить валидность источника данных, буфера и коллбэк-функции до момента возврата из run(). Коллбэк-функция будет вызвана при валидном экземпляре класса — а значит, она сможет воспользоваться listener'ом.
В коде присутствуют те же самые недостатки, только выражены они ещё сильнее: BoozdedZomby дорабатывает начатую операцию до конца, и внешний код может лишь отменить очередной повтор. В данном случае вполне можно заявить, что способ не сработал. Останавливать надо текущую операцию, а не следующую.
Вывод по weak-способу
Модификация только зомби только в части замены захвата shared_from_this на захват weak_from_this:
— повышает риски обращения к инвалидированному состоянию (примеры приводить не буду, но на практике встречал);
— не способна устранить состояние гонки (взаимный порядок операций в двух потоках влияет на то, будут ли данные переданы в listener после уничтожения зомби бизнес-логикой).
Функционирование зомби требует захвата сильной ссылки хотя бы на короткое время — и в это время уничтожение всех внешних ссылок не приводит к его остановке. В некоторых случаях сильная ссылка удерживается настолько долго, что делает способ неприменимым на практике.
Валидность объекта вряд ли подходит на роль единственного условия продолжения работы.
В то же время, следует отметить, что проблема гонки связана с выбором способа передачи данных от зомби и может растворяться при выборе более подходящего способа передачи или при наличии более терпимого получателя (ещё вернёмся к этому вопросу).
Способ честный, или use_count
Идея способа: по-прежнему захватываем shared_from_this, но при этом проверяем std::shared_ptr::use_count().
Почему честный?
Использование weak_from_this создаёт иллюзию отказа от влияния на собственное время жизни. А в данном варианте — никакого самообмана, экземпляр класса по-прежнему управляет собственным временем жизни. Но при этом детектирует ситуацию отсутствия внешних сильных ссылок.
SimpleZomby
Было:
void Zomby::runOnce(std::shared_ptr<Common::Listener> listener)
{
if (_semaphore) {
throw std::runtime_error("SimpleZomby::Zomby::runOnce() called twice");
}
_listener = listener;
_semaphore = true;
_thread = std::thread([shis = shared_from_this()](){
while (shis && shis->_listener && shis->_semaphore) {
shis->_listener->processData(std::make_shared<Common::Listener::Data>("SimpleZomby is alive!n"));
std::this_thread::sleep_for(std::chrono::seconds(1));
}
});
}
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
============================================================
| Zomby was killed |
============================================================
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
Стало:
void Zomby::runOnce(std::shared_ptr<Common::Listener> listener)
{
if (_semaphore) {
throw std::runtime_error("SimpleZomby::Zomby::runOnce() called twice");
}
_listener = listener;
_semaphore = true;
_thread = std::thread([shis = shared_from_this()](){
while (shis && shis.use_count() > 1 && shis->_listener && shis->_semaphore) {
shis->_listener->processData(std::make_shared<Common::Listener::Data>("SimpleZomby is alive!n"));
std::this_thread::sleep_for(std::chrono::seconds(1));
}
});
}
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
============================================================
| Zomby was killed |
============================================================
N11SimpleZomby5ZombyE::~Zomby
N6Common22WriteToConsoleListenerE::~WriteToConsoleListener
Свойства данной модификации точно совпадают со свойствами weak-способа:
— семафор стал лишним в условии выхода;
— зомби удалось остановить, но присутствует состояние гонки (если проверка use_count() в зомби-потоке прошла успешно — данные будут отправлены даже после уничтожения последней внешней ссылки).
SteppingZomby
Было:
void Zomby::runOnce(std::shared_ptr<Common::Listener> listener)
{
if (_semaphore) {
throw std::runtime_error("SteppingZomby::Zomby::runOnce() called twice");
}
_listener = listener;
_semaphore = true;
_thread = std::thread([shis = shared_from_this()](){
if (shis && shis->_listener && shis->_semaphore) {
shis->resolveDnsName();
}
if (shis && shis->_listener && shis->_semaphore) {
shis->connectTcp();
}
if (shis && shis->_listener && shis->_semaphore) {
shis->establishSsl();
}
if (shis && shis->_listener && shis->_semaphore) {
shis->sendHttpRequest();
}
if (shis && shis->_listener && shis->_semaphore) {
shis->readHttpReply();
}
});
}
N13SteppingZomby5ZombyE::resolveDnsName started
N13SteppingZomby5ZombyE::resolveDnsName finished
N13SteppingZomby5ZombyE::connectTcp started
============================================================
| Zomby was killed |
============================================================
N13SteppingZomby5ZombyE::connectTcp finished
N13SteppingZomby5ZombyE::establishSsl started
N13SteppingZomby5ZombyE::establishSsl finished
N13SteppingZomby5ZombyE::sendHttpRequest started
N13SteppingZomby5ZombyE::sendHttpRequest finished
N13SteppingZomby5ZombyE::readHttpReply started
N13SteppingZomby5ZombyE::readHttpReply finished
N13SteppingZomby5ZombyE::~Zomby
N6Common22WriteToConsoleListenerE::~WriteToConsoleListener
Стало:
void Zomby::runOnce(std::shared_ptr<Common::Listener> listener)
{
if (_semaphore) {
throw std::runtime_error("SteppingZomby::Zomby::runOnce() called twice");
}
_listener = listener;
_semaphore = true;
_thread = std::thread([shis = shared_from_this()](){
if (shis && shis.use_count() > 1 && shis->_listener && shis->_semaphore) {
shis->resolveDnsName();
}
if (shis && shis.use_count() > 1 && shis->_listener && shis->_semaphore) {
shis->connectTcp();
}
if (shis && shis.use_count() > 1 && shis->_listener && shis->_semaphore) {
shis->establishSsl();
}
if (shis && shis.use_count() > 1 && shis->_listener && shis->_semaphore) {
shis->sendHttpRequest();
}
if (shis && shis.use_count() > 1 && shis->_listener && shis->_semaphore) {
shis->readHttpReply();
}
});
}
N13SteppingZomby5ZombyE::resolveDnsName started
N13SteppingZomby5ZombyE::resolveDnsName finished
N13SteppingZomby5ZombyE::connectTcp started
============================================================
| Zomby was killed |
============================================================
N13SteppingZomby5ZombyE::connectTcp finished
N13SteppingZomby5ZombyE::~Zomby
N6Common22WriteToConsoleListenerE::~WriteToConsoleListener
Всё точно как в weak-способе.
BoozdedZomby
Было:
void Zomby::runOnce(std::shared_ptr<Common::Listener> listener)
{
if (_semaphore) {
throw std::runtime_error("BoozdedZomby::Zomby::runOnce() called twice");
}
_listener = listener;
_semaphore = true;
_thread = std::thread([shis = shared_from_this()]() {
while (shis && shis->_semaphore && shis->_listener) {
auto handler = [shis](auto errorCode) {
if (shis && shis->_listener && errorCode == boozd::azzio::io_context::error_code::no_error) {
std::ostringstream buf;
buf << "BoozdedZomby has got a fresh data: ";
for (auto const &elem; : shis->_buffer)
buf << elem << ' ';
buf << std::endl;
shis->_listener->processData(std::make_shared<Common::Listener::Data>(buf.str()));
}
};
shis->_buffer.clear();
shis->_context.async_read(shis->_stream, shis->_buffer, handler);
shis->_context.run();
}
});
}
BoozdedZomby has got a fresh data: 1144108930 101027544 1458777923 1115438165 74243042
BoozdedZomby has got a fresh data: 143542612 1131570933
BoozdedZomby has got a fresh data: 893351816 563613512 704877633
BoozdedZomby has got a fresh data: 1551901393 1399125485 1899894091 937186357 590357944 357571490
============================================================
| Zomby was killed |
============================================================
BoozdedZomby has got a fresh data: 1927702196 130060903 1083454666 2118797801 2035308228 824938981
BoozdedZomby has got a fresh data: 2020739063 1635339425 34075629
BoozdedZomby has got a fresh data: 2146319451 500782188 1269406752 884936716 892053144
BoozdedZomby has got a fresh data: 330111137 1723153177 1070477904
BoozdedZomby has got a fresh data: 343098142 280090412 589673557 889688008 2014119113 388471006
Стало:
void Zomby::runOnce(std::shared_ptr<Common::Listener> listener)
{
if (_semaphore) {
throw std::runtime_error("BoozdedZomby::Zomby::runOnce() called twice");
}
_listener = listener;
_semaphore = true;
_thread = std::thread([shis = shared_from_this()]() {
while (shis && shis.use_count() > 1 && shis->_semaphore && shis->_listener) {
auto handler = [&shis;](auto errorCode) {
if (shis && shis.use_count() > 1 && shis->_listener && errorCode == boozd::azzio::io_context::error_code::no_error) {
std::ostringstream buf;
buf << "BoozdedZomby has got a fresh data: ";
for (auto const &elem; : shis->_buffer)
buf << elem << ' ';
buf << std::endl;
shis->_listener->processData(std::make_shared<Common::Listener::Data>(buf.str()));
}
};
shis->_buffer.clear();
shis->_context.async_read(shis->_stream, shis->_buffer, handler);
shis->_context.run();
}
});
}
BoozdedZomby has got a fresh data: 1144108930 101027544 1458777923 1115438165 74243042
BoozdedZomby has got a fresh data: 143542612 1131570933
BoozdedZomby has got a fresh data: 893351816 563613512 704877633
BoozdedZomby has got a fresh data: 1551901393 1399125485 1899894091 937186357 590357944 357571490
============================================================
| Zomby was killed |
============================================================
N12BoozdedZomby5ZombyE::~Zomby
N5boozd5azzio10io_contextE::~io_context
N5boozd5azzio13random_streamE::~random_stream
N6Common22WriteToConsoleListenerE::~WriteToConsoleListener
Вот тут уже значительно лучше, чем в weak-способе: валидность экземпляра класса не считается достаточным основанием для передачи данных в listener, требуется ещё и наличие хотя бы одной внешней сильной ссылки.
При реализации данного способа надо проявлять повышенную внимательность: очень просто ошибочно создать дополнительные сильные копии внутри зомби (обратите внимание — сейчас shis захватывается в коллбэк-функцию по ссылке, а не по значению). Также следует правильно выбирать места для проверок.
Состояние гонки по-прежнему присутствует.
Вывод по use_count-способу
Добавление в условие выхода проверки use_count при сохранении захвата shared_from_this снижает риск обращения к инвалидированному состоянию по сравнению с weak-способом. При этом по-прежнему сохраняется риск передачи данных в listener после уничтожения зомби вышестоящей бизнес-логикой.
Способы use_count и weak пригодны для применения на практике при соответствующей адаптации способа передачи данных.
Такими адаптациями могут быть:
— предоставление зомби прокси-listener'a, обеспечивающего надёжный разрыв соединения с конечным listener'ом (но не обольщайтесь — этот способ не является ни простым, ни изящным; пример можно посмотреть в репозитории, ветка fixes/weak_with_ProxyListener);
— использование сложных систем типа Qt Signals & Slots (однако тут легко получить иллюзию безопасности вместо безопасности);
— отказ от event-driven в пользу поллинга — в этом случае инициатива всегда принадлежит вышестоящему уровню, и возможность обращения к нижестоящему объекту естественным образом исчезает после уничтожения ссылки на него.
Способ синхронный, или this
Идея способа: отказываемся от наследования от std::shared_from_this. Никаких грязных трюков с управлением собственным временем жизни. Никаких узлов в коде.
Лямбда-функция захватывает this. Время жизни потока синхронизируется со временем жизни экземпляра класса. В деструкторе вместо std::thread::detach() вызывается std::thread::join().
Зомби растворяется.
Вместо него возникает псевдо-асинхронный класс: все его методы являются неблокирующими, кроме деструктора. Уничтожение может длиться неопределённо долго — порядка длительности того процесса, которому мы пытались придать свойство асинхронности. На практике это могут быть единицы и даже десятки секунд, и возможна зависимость от внешних факторов (размера ответа на HTTP-запрос, потери сетевого пакета и т.п.).
Было:
#pragma once
#include <memory>
#include <atomic>
#include <thread>
#include "Common/Manager.h"
namespace Common {
class Listener;
} // namespace Common
namespace SimpleZomby {
class Zomby final : public Common::Manager, public std::enable_shared_from_this<Zomby>
{
public:
static std::shared_ptr<Zomby> create();
~Zomby() override;
void runOnce(std::shared_ptr<Common::Listener> listener) override;
private:
Zomby();
using Semaphore = std::atomic<bool>;
std::shared_ptr<Common::Listener> _listener;
Semaphore _semaphore = false;
std::thread _thread;
};
} // namespace SimpleZomby
#include <sstream>
#include "SimpleZomby.h"
#include "Common/Listener.h"
namespace SimpleZomby {
std::shared_ptr<Zomby> Zomby::create()
{
return std::shared_ptr<Zomby>(new Zomby());
}
Zomby::Zomby() = default;
Zomby::~Zomby()
{
_semaphore = false;
if (_thread.joinable()) {
_thread.detach();
}
if (_listener) {
std::ostringstream buf;
buf << typeid(*this).name() << "::" << __func__ << std::endl;
_listener->processData(std::make_shared<Common::Listener::Data>(buf.str()));
}
}
void Zomby::runOnce(std::shared_ptr<Common::Listener> listener)
{
if (_semaphore) {
throw std::runtime_error("SimpleZomby::Zomby::runOnce() called twice");
}
_listener = listener;
_semaphore = true;
_thread = std::thread([shis = shared_from_this()](){
while (shis && shis->_listener && shis->_semaphore) {
shis->_listener->processData(std::make_shared<Common::Listener::Data>("SimpleZomby is alive!n"));
std::this_thread::sleep_for(std::chrono::seconds(1));
}
});
}
} // namespace SimpleZomby
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
============================================================
| Zomby was killed |
============================================================
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
Стало:
#pragma once
#include <memory>
#include <atomic>
#include <thread>
#include "Common/Manager.h"
namespace Common {
class Listener;
} // namespace Common
namespace SimpleZomby {
class Zomby final : public Common::Manager
{
public:
static std::shared_ptr<Zomby> create();
~Zomby() override;
void runOnce(std::shared_ptr<Common::Listener> listener) override;
private:
Zomby();
using Semaphore = std::atomic<bool>;
std::shared_ptr<Common::Listener> _listener;
Semaphore _semaphore = false;
std::thread _thread;
};
} // namespace SimpleZomby
#include <sstream>
#include "SimpleZomby.h"
#include "Common/Listener.h"
namespace SimpleZomby {
std::shared_ptr<Zomby> Zomby::create()
{
return std::shared_ptr<Zomby>(new Zomby());
}
Zomby::Zomby() = default;
Zomby::~Zomby()
{
_semaphore = false;
if (_thread.joinable()) {
_thread.join();
}
if (_listener) {
std::ostringstream buf;
buf << typeid(*this).name() << "::" << __func__ << std::endl;
_listener->processData(std::make_shared<Common::Listener::Data>(buf.str()));
}
}
void Zomby::runOnce(std::shared_ptr<Common::Listener> listener)
{
if (_semaphore) {
throw std::runtime_error("SimpleZomby::Zomby::runOnce() called twice");
}
_listener = listener;
_semaphore = true;
_thread = std::thread([this](){
while (_listener && _semaphore) {
_listener->processData(std::make_shared<Common::Listener::Data>("SimpleZomby is alive!n"));
std::this_thread::sleep_for(std::chrono::seconds(1));
}
});
}
} // namespace SimpleZomby
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
N11SimpleZomby5ZombyE::~Zomby
============================================================
| Zomby was killed |
============================================================
N6Common22WriteToConsoleListenerE::~WriteToConsoleListener
Отметим:
— это пока единственный пример, в котором деструктор зомби (хотя это уже и не зомби на самом деле) был вызван до вывода «Zomby was killed»;
— семафор наконец-то сыграл ту роль, которая отводилась ему изначально, хотя его можно заменить проверкой валидности _listener;
— требование хранения экземпляра класса исключительно в std::shared_ptr стало излишним, статическую функцию create() можно упразднить в пользу выноса конструктора в public-секцию класса;
— захват this в лямбда-функцию требует постоянного расположения экземпляра класса в памяти — унаследованный от Common::Manager запрет copy- и move-семантики по-прежнему важен.
Модификации SteppingZomby и BoozdedZomby в данном случае полностью аналогичны, приводить их в статье нет смысла. Кому интересно — смотрите в ветке fixes/this репозитория.
Вывод по this-способу
Способ хорош всем, кроме блокирующего деструктора. Блокировка GUI-потока на несколько секунд вполне может заставить операционную систему считать процесс зависшим. В таком виде способ вряд ли пригоден для практического применения.
Способ пригодный, или semaphore_done_right
Во всех рассмотренных до сих пор примерах класс использовался как агрегатор данных, необходимых для работы отдельного потока. Это приводило к необходимости обеспечения времени жизни экземпляра класса, превышающего время жизни запущенного потока.
Да, агрегировать данные в классе удобно.
Но только до тех пор, пока это не приводит к букету сложных проблем.
Идея способа: правильное разграничение ответственностей между классом и потоком.
Обязанности класса:
— запуск потока;
— перевод семафора в запрещающее состояние в деструкторе.
Обязанности потока:
— не продолжать работу при запрещающем состоянии семафора.
Никакой связи по данным (кроме разделяемого семафора), никаких требований по времени жизни (особенно от нижележащего потока в сторону вышележащего класса). Никакого захвата экземпляра класса в лямбду — ни в виде сырого указателя, ни в виде умного.
Для устранения гонки, возникаюшей в способах weak и use_count, придётся использовать синхронизацию с помощью мьютекса. Деструктор захватывает мьютекс на время смены значения семафора, а отдельный поток — на время выполнения операции передачи данных в listener.
Деструктор вызывает std::thread::detach(), не дожидаясь завершения потока (вернёмся к этому вопросу позднее).
Было:
#pragma once
#include <memory>
#include <atomic>
#include <thread>
#include "Common/Manager.h"
namespace Common {
class Listener;
} // namespace Common
namespace SimpleZomby {
class Zomby final : public Common::Manager, public std::enable_shared_from_this<Zomby>
{
public:
static std::shared_ptr<Zomby> create();
~Zomby() override;
void runOnce(std::shared_ptr<Common::Listener> listener) override;
private:
Zomby();
using Semaphore = std::atomic<bool>;
std::shared_ptr<Common::Listener> _listener;
Semaphore _semaphore = false;
std::thread _thread;
};
} // namespace SimpleZomby
#include <sstream>
#include "SimpleZomby.h"
#include "Common/Listener.h"
namespace SimpleZomby {
std::shared_ptr<Zomby> Zomby::create()
{
return std::shared_ptr<Zomby>(new Zomby());
}
Zomby::Zomby() = default;
Zomby::~Zomby()
{
_semaphore = false;
if (_thread.joinable()) {
_thread.detach();
}
if (_listener) {
std::ostringstream buf;
buf << typeid(*this).name() << "::" << __func__ << std::endl;
_listener->processData(std::make_shared<Common::Listener::Data>(buf.str()));
}
}
void Zomby::runOnce(std::shared_ptr<Common::Listener> listener)
{
if (_semaphore) {
throw std::runtime_error("SimpleZomby::Zomby::runOnce() called twice");
}
_listener = listener;
_semaphore = true;
_thread = std::thread([shis = shared_from_this()](){
while (shis && shis->_listener && shis->_semaphore) {
shis->_listener->processData(std::make_shared<Common::Listener::Data>("SimpleZomby is alive!n"));
std::this_thread::sleep_for(std::chrono::seconds(1));
}
});
}
} // namespace SimpleZomby
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
============================================================
| Zomby was killed |
============================================================
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
Стало:
#pragma once
#include <memory>
#include <atomic>
#include <thread>
#include "Common/Manager.h"
namespace Common {
class Listener;
} // namespace Common
namespace SimpleZomby {
class Zomby final : public Common::Manager
{
public:
static std::shared_ptr<Zomby> create();
~Zomby() override;
void runOnce(std::shared_ptr<Common::Listener> listener) override;
private:
Zomby();
using Semaphore = std::pair<std::mutex, bool>;
std::shared_ptr<Common::Listener> _listener;
std::shared_ptr<Semaphore> _semaphore;
std::thread _thread;
};
} // namespace SimpleZomby
#include <sstream>
#include "SimpleZomby.h"
#include "Common/Listener.h"
namespace SimpleZomby {
std::shared_ptr<Zomby> Zomby::create()
{
return std::shared_ptr<Zomby>(new Zomby());
}
Zomby::Zomby() = default;
Zomby::~Zomby()
{
if (_semaphore) {
if (std::this_thread::get_id() != _thread.get_id()) {
auto guard = std::lock_guard(_semaphore->first);
_semaphore->second = false;
} else {
_semaphore->second = false;
}
}
if (_thread.joinable()) {
_thread.detach();
}
if (_listener) {
std::ostringstream buf;
buf << typeid(*this).name() << "::" << __func__ << std::endl;
_listener->processData(std::make_shared<Common::Listener::Data>(buf.str()));
}
}
void Zomby::runOnce(std::shared_ptr<Common::Listener> listener)
{
if (_semaphore) {
throw std::runtime_error("SimpleZomby::Zomby::runOnce() called twice");
}
_listener = listener;
// two separate lines needed because of absence of std::mutex move c-tor
_semaphore = std::make_shared<Semaphore>();
_semaphore->second = true;
_thread = std::thread([listener, semaphore = _semaphore](){
while (listener && semaphore) {
{
auto guard = std::lock_guard(semaphore->first);
if (!semaphore->second) {
break;
}
listener->processData(std::make_shared<Common::Listener::Data>("SimpleZomby is alive!n"));
}
std::this_thread::sleep_for(std::chrono::seconds(1));
}
});
}
} // namespace SimpleZomby
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
N11SimpleZomby5ZombyE::~Zomby
============================================================
| Zomby was killed |
============================================================
N6Common22WriteToConsoleListenerE::~WriteToConsoleListener
Модификации для SteppingZomby и BoozdedZomby аналогичны (см. ветку fixes/semaphore_done_right) — настолько аналогичны, что объявление класса BoozdedZomby совпало с объявлением класса SimpleZomby, а в SteppingZomby присутствуют лишь допонительные статические функции, которые могут быть преобразованы в свободные функции.
Свойства данного способа:
— время выполнения деструктора перестало зависеть от внешних факторов — осталась только конкуренция за мьютекс, защищающий потенциально очень быструю операцию передачи данных в обработку;
— аккуратная работа с мьютексом позволила избавиться от состояния гонки — корректно написанная лямбда-функция гарантированно не отправит данные в listener после завершения деструктора;
— предусмотрена защита от deadlock'а на случай вызова деструктора из обработчика события прихода данных (уничтожение соединения в ответ на получение данных — вполне корректный сценарий);
— требование хранения экземпляра класса исключительно в std::shared_ptr стало излишним, статическую функцию create() можно упразднить в пользу выноса конструктора в public-секцию класса;
— запрет copy- и move-семантики, унаследованный от Common::Manager, стал излишним;
— listener по-прежнему хранится в поле класса — но только для диагностического вывода в деструкторе, он не связывает экземпляр класса с потоком выполнения;
— класс перестал зависеть от специфики данных, необходимых для работы отдельного потока, что открывает дополнительные возможности для обобщения;
— поток может продолжить выполнение в detached-состоянии после уничтожения экземпляра класса.
Последний пункт является потенциально проблемным по следующим причинам:
— маскирует ошибки в условии выхода из функции, выполняющейся в отдельном потоке;
— отдельный поток может дожить до завершения главного потока — тогда он будет аварийно зачищен системой, что приведёт к сбою деинициализации.
Способ финальный, или semaphore_done_right_with_ThreadJoiner
Если detach() потока может создавать проблемы — попробуем что-нибудь с этим сделать (запустим новый поток, который будет асинхронно ждать завершения старого потока).
Идея способа:
— нужна новая сущность (ThreadJoiner), способная взять на себя ответственность за отработавший поток (пожалуй, более подходящее названием для такой сущности — ThreadSanitizer, но оно уже занято);
— ThreadJoiner в отдельном служебном потоке будет ожидать отработки std::thread::join() последовательно для всех потоков, ответственность за которые он принял;
— деструктор ThreadJoiner будет блокировать выполнение до тех пор, пока не завершатся все потоки, ответственность за которые он принял.
Абстрактный интерфейс:
#pragma once
#include <thread>
namespace Common {
class ThreadJoiner
{
public:
ThreadJoiner() = default;
ThreadJoiner(const ThreadJoiner&) = delete;
ThreadJoiner(ThreadJoiner&&) = delete;
ThreadJoiner& operator=(const ThreadJoiner&) = delete;
ThreadJoiner& operator=(ThreadJoiner&&) = delete;
virtual ~ThreadJoiner() = default;
virtual void join(std::thread&&) = 0;
};
} // namespace Common
Конкретная асинхронная реализация:
#pragma once
#include <thread>
#include <queue>
#include <mutex>
#include <optional>
#include <condition_variable>
#include "Common/ThreadJoiner.h"
namespace Common {
class ThreadJoinerAsync final : public ThreadJoiner
{
public:
ThreadJoinerAsync();
ThreadJoinerAsync(const ThreadJoinerAsync&) = delete;
ThreadJoinerAsync(ThreadJoinerAsync&&) = delete;
ThreadJoinerAsync& operator=(const ThreadJoinerAsync&) = delete;
ThreadJoinerAsync& operator=(ThreadJoinerAsync&&) = delete;
~ThreadJoinerAsync() override;
void join(std::thread&&) override;
private:
using Data = std::queue<std::thread>;
using Semaphore = std::atomic<bool>;
std::mutex _mutex;
Data _data;
Semaphore _semaphore;
std::condition_variable _cv;
std::thread _thread;
void threadFn();
};
} // namespace Common
#include "ThreadJoinerAsync.h"
namespace Common {
ThreadJoinerAsync::ThreadJoinerAsync()
: _semaphore(true)
, _thread(&ThreadJoinerAsync::threadFn, this)
{
}
ThreadJoinerAsync::~ThreadJoinerAsync()
{
_semaphore = false;
_cv.notify_one();
if (_thread.joinable()) {
_thread.join();
}
}
void ThreadJoinerAsync::join(std::thread&& thread)
{
{
auto lock = std::lock_guard(_mutex);
_data.push(std::move(thread));
}
_cv.notify_one();
}
void ThreadJoinerAsync::threadFn()
{
while (true) {
auto lock = std::unique_lock(_mutex);
_cv.wait(lock, [this](){ return !_semaphore || !_data.empty(); });
if (!_semaphore && _data.empty()) {
return;
}
auto threadToJoin = std::move(_data.front());
_data.pop();
lock.unlock();
if (threadToJoin.joinable()) {
threadToJoin.join();
}
}
}
} // namespace Common
Применение ThreadJoiner в SimpleZomby:
#pragma once
#include <memory>
#include <atomic>
#include <thread>
#include "Common/Manager.h"
namespace Common {
class Listener;
class ThreadJoiner;
} // namespace Common
namespace SimpleZomby {
class Zomby final : public Common::Manager
{
public:
Zomby(std::shared_ptr<Common::ThreadJoiner>);
~Zomby() override;
void runOnce(std::shared_ptr<Common::Listener> listener) override;
private:
using Semaphore = std::pair<std::mutex, bool>;
std::shared_ptr<Common::Listener> _listener;
std::shared_ptr<Semaphore> _semaphore;
std::thread _thread;
const std::shared_ptr<Common::ThreadJoiner> _joiner;
};
} // namespace SimpleZomby
#include <sstream>
#include "SimpleZomby.h"
#include "Common/Listener.h"
#include "Common/ThreadJoiner.h"
namespace SimpleZomby {
Zomby::Zomby(std::shared_ptr<Common::ThreadJoiner> joiner)
: _joiner(joiner)
{
if (!_joiner) {
throw std::runtime_error("An empty joiner in SimpleZomby::Zomby::Zomby");
}
}
Zomby::~Zomby()
{
if (_semaphore) {
if (std::this_thread::get_id() != _thread.get_id()) {
auto guard = std::lock_guard(_semaphore->first);
_semaphore->second = false;
} else {
_semaphore->second = false;
}
}
if (_thread.joinable()) {
_joiner->join(std::move(_thread));
}
if (_listener) {
std::ostringstream buf;
buf << typeid(*this).name() << "::" << __func__ << std::endl;
_listener->processData(std::make_shared<Common::Listener::Data>(buf.str()));
}
}
void Zomby::runOnce(std::shared_ptr<Common::Listener> listener)
{
if (_semaphore) {
throw std::runtime_error("SimpleZomby::Zomby::runOnce() called twice");
}
_listener = listener;
// two separate lines needed because of absence of std::mutex move c-tor
_semaphore = std::make_shared<Semaphore>();
_semaphore->second = true;
_thread = std::thread([listener, semaphore = _semaphore](){
while (listener && semaphore) {
{
auto guard = std::lock_guard(semaphore->first);
if (!semaphore->second) {
break;
}
listener->processData(std::make_shared<Common::Listener::Data>("SimpleZomby is alive!n"));
}
std::this_thread::sleep_for(std::chrono::seconds(1));
}
});
}
} // namespace SimpleZomby
#include <chrono>
#include <thread>
#include <sstream>
#include "Common/Impl/WriteToConsoleListener.h"
#include "Common/Impl/ThreadJoinerAsync.h"
#include "SimpleZomby/SimpleZomby.h"
int main()
{
auto writeToConsoleListener = Common::WriteToConsoleListener::instance();
auto joiner = std::make_shared<Common::ThreadJoinerAsync>();
{
auto simpleZomby = SimpleZomby::Zomby(joiner);
simpleZomby.runOnce(writeToConsoleListener);
std::this_thread::sleep_for(std::chrono::milliseconds(4500));
} // Zomby should be killed here
{
std::ostringstream buf;
buf << "============================================================n"
<< "| Zomby was killed |n"
<< "============================================================n";
if (writeToConsoleListener) {
writeToConsoleListener->processData(std::make_shared<Common::Listener::Data>(buf.str()));
}
}
std::this_thread::sleep_for(std::chrono::milliseconds(5000));
return 0;
}
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
SimpleZomby is alive!
N11SimpleZomby5ZombyE::~Zomby
============================================================
| Zomby was killed |
============================================================
N6Common22WriteToConsoleListenerE::~WriteToConsoleListener
Предупреждение: код ThreadJoiner тестировался поверхностно; задумаете втащить его в production — я не виноват.
Преимущества способа:
— гарантированное корректное завершение всех потоков, ответственность за которые была возложена на ThreadJoiner;
— гарантированное обнаружение ошибки в условии выхода из отдельного потока, приводящей к бесконечному выполнению — главный поток зависнет при завершении;
— ThreadJoiner может быть дополнен диагностическими сообщениями, упрощающими выявление менее критичных ошибок в условии выхода из отдельного потока — тех, которые приводят к некоторому конечному излишнему времени выполнения потока.
Модификации SteppingZomby и BoozdedZomby — полностью аналогичны (см. ветку fixes/semaphore_done_right_with_ThreadJoiner).
Пара слов об официальных примерах boost
В первой части статьи приведены два примера из официальной документации boost:
Пример HTTP-клиента
Пример Websocket-клиента
Вызов ioc.run() в main() блокирует управление до наступления одного из событий:
— успешная отработка всей цепочки;
— ошибка.
Досрочное уничтожение вышестоящей бизнес-логикой экземпляра класса session в представленном коде невозоможно. Это само по себе гарантирует достаточное время жизни данных, агрегированных в полях класса session. Судя по всему, представленный код будет успешно работать даже при модификации его this-способом. Использование shared_from_this в данном случае избыточно, достаточно захвата сырого указателя this.
Откуда же тогда взялся shared_from_this?
Вероятно, из более реального кода, в котором были предусмотрены сценарии остановки соединения путём его уничтожения. И конечно, отсутствие контроля собственного времени жизни приводило к неопределённому поведению (читай «к крэшам»). Крэши разменяли на зомби, а зомби подарили копипастерам.
Вывод
Решения, приведённые в статье, специфичны. Они учитывают особенности изначального кода — в том числе способ передачи результатов работы асинхронного процесса.
Однако в результате разбора примеров стало понятно, какие архитектурные решения привели к проблемам:
— нарушение принципа single responsibility — на класс были возложены как функция информирования асинхронно выполняющегося процесса о необходимости остановки, так и функция хранения данных для этого процесса;
— отсюда возникло требование от нижележащей сущности к вышележащей — асинхронный процесс нуждался в гарантии валидности экземпляра класса в течение определённого времени;
— отсюда возникла необходимость контроля собственного времени жизни с помощью техники std::enable_shared_from_this.
Наиболее корректный код удалось получить без применения std::enable_shared_from_this.
Чего и Вам желаю.
Автор: Александр Дубовик