Хабрахабр

Сказ об опасном std::enable_shared_from_this, или антипаттерн «Зомби» — разбор полётов

В настоящей статье приводится разбор вариантов устранения антипаттерна «Зомби», описанного в первой части: Сказ об опасном 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

Было:

SimpleZomby::Zomby::runOnce

void Zomby::runOnce(std::shared_ptr<Common::Listener> listener)
_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!

Стало:

SimpleZomby::Zomby::runOnce

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.

После удаления семафора:

SimpleZomby::Zomby::runOnce

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.

Устраняется как-то так:

SimpleZomby::Zomby::runOnce

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)); } } });
}

Или вот так:

SimpleZomby::Zomby::runOnce

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)); } });
}

Ну что, переходим к следующему примеру?
Как бы не так!
У нас тут гонка.
Проще всего её продемонстрировать с помощью переноса слипа в чувствительное место:

SimpleZomby::Zomby::runOnce

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

Вот что произошло:
Зомби остановился.
Но всё ещё слишком поздно — от уничтоженного зомби пришло одно сообщение.
В зависимости от применения, такое поведение может как являться приемлемым, так и послужить триггером для какой-либо неожиданной цепочки действий.

Уничтожение бизнес-логикой зомби может означать, что механизмы обработки данных такого типа также уничтожены. Почему приход данных в listener может быть неожиданным?
Зомби вряд ли является единственным игроком на поле.
Данные, прихода которых он ожидает, наверняка предполагают дальнейшую обработку (расшифровку, парсинг, отображение на экране и т.п.). Вряд ли пользователь обрадуется сообщению «Выполнено успешно» после нажатия кнопки «Отмена». Передача данных на обработку в уничтоженные механизмы грозит неопределённым поведением.
Также сообщение от зомби может предполагать высокоуровневую ответную реакцию.

SteppingZomby

Было:

SteppingZomby::Zomby::runOnce

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

Стало (ненужную проверку семафора сразу убираем):

SteppingZomby::Zomby::runOnce

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

Было:

BoozdedZomby::Zomby::runOnce

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

Стало:

BoozdedZomby::Zomby::runOnce

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

Было:

SimpleZomby::Zomby::runOnce

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!

Стало:

SimpleZomby::Zomby::runOnce

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

Было:

SteppingZomby::Zomby::runOnce

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

Стало:

SteppingZomby::Zomby::runOnce

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

Было:

BoozdedZomby::Zomby::runOnce

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

Стало:

BoozdedZomby::Zomby::runOnce

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-запрос, потери сетевого пакета и т.п.).

Было:

SimpleZomby.h

#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

SimpleZomby.cpp

#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!

Стало:

SimpleZomby.h

#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

SimpleZomby.cpp

#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-семантики по-прежнему важен.

Кому интересно — смотрите в ветке fixes/this репозитория. Модификации SteppingZomby и BoozdedZomby в данном случае полностью аналогичны, приводить их в статье нет смысла.

Вывод по this-способу

Способ хорош всем, кроме блокирующего деструктора. Блокировка GUI-потока на несколько секунд вполне может заставить операционную систему считать процесс зависшим. В таком виде способ вряд ли пригоден для практического применения.

Способ пригодный, или semaphore_done_right

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

Никакого захвата экземпляра класса в лямбду — ни в виде сырого указателя, ни в виде умного.
Для устранения гонки, возникаюшей в способах weak и use_count, придётся использовать синхронизацию с помощью мьютекса. Идея способа: правильное разграничение ответственностей между классом и потоком.
Обязанности класса:
— запуск потока;
— перевод семафора в запрещающее состояние в деструкторе.
Обязанности потока:
— не продолжать работу при запрещающем состоянии семафора.
Никакой связи по данным (кроме разделяемого семафора), никаких требований по времени жизни (особенно от нижележащего потока в сторону вышележащего класса). Деструктор захватывает мьютекс на время смены значения семафора, а отдельный поток — на время выполнения операции передачи данных в listener.
Деструктор вызывает std::thread::detach(), не дожидаясь завершения потока (вернёмся к этому вопросу позднее).

Было:

SimpleZomby.h

#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

SimpleZomby.cpp

#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!

Стало:

SimpleZomby.h

#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

SimpleZomby.cpp

#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

ветку fixes/semaphore_done_right) — настолько аналогичны, что объявление класса BoozdedZomby совпало с объявлением класса SimpleZomby, а в SteppingZomby присутствуют лишь допонительные статические функции, которые могут быть преобразованы в свободные функции. Модификации для SteppingZomby и BoozdedZomby аналогичны (см.

Свойства данного способа:
— время выполнения деструктора перестало зависеть от внешних факторов — осталась только конкуренция за мьютекс, защищающий потенциально очень быструю операцию передачи данных в обработку;
— аккуратная работа с мьютексом позволила избавиться от состояния гонки — корректно написанная лямбда-функция гарантированно не отправит данные в 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 будет блокировать выполнение до тех пор, пока не завершатся все потоки, ответственность за которые он принял.

Абстрактный интерфейс:

ThreadJoiner.h

#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

Конкретная асинхронная реализация:

ThreadJoinerAsync.h

#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

ThreadJoinerAsync.cpp

#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:

SimpleZomby.h

#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

SimpleZomby.cpp

#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

main.cpp

#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 может быть дополнен диагностическими сообщениями, упрощающими выявление менее критичных ошибок в условии выхода из отдельного потока — тех, которые приводят к некоторому конечному излишнему времени выполнения потока.

ветку fixes/semaphore_done_right_with_ThreadJoiner). Модификации SteppingZomby и BoozdedZomby — полностью аналогичны (см.

Пара слов об официальных примерах 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.

Чего и Вам желаю.

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»