Сигналы, reentrancy, стратегии обработки ошибок
Сигналы
Сигналы - механизм "подписки" нескольких функций на события в другой. Иногда их называют listeners или observers.
В совсем наивном виде можно делать это так:
struct signal {
using slot_t = std::function<void()>;
signal();
void connect(slot_t slot) {
slots.push_back(std::move(slot));
}
void operator()() const {
for (slot t const& slot : slots) {
slot();
}
}
private:
std::vector<std::function<void()>> slots;
};
Как удалять подписку на событие? Есть два подхода: возвращать id
или использовать linked_list
.
Посмотрим на первый:
struct signal {
using id_t = uint64_t;
using slot_t = std::function<void()>;
signal();
struct connection {
connection(signal* sig, id_t id)
: sig(sig),
id(id) {}
void disconnect() {
sig->slots.erase(id);
}
private:
signal* sig;
id_it id;
}
connection connect(slot_t slot) {
next_id++;
slots.insert({id, std::move(slot)});
return connection(this, id);
}
void operator()() const {
for (slot t const& p : slots) {
p.second();
}
}
private:
id_t next_id = 0;
std::unordered_map<id_t, slot_t> slots;
};
Проблема такой реализации, что не будет работать такой пример:
struct timer {
timer(unsigned timeout);
signal::connection connect_on_timeout(signal::slot_t slot) {
return on_timeout.connect(slot);
}
private:
signal on_timeout;
};
struct user {
user(timer& global_timer)
: global_timer(global_timer) {}
void foo() {
conn = global_timer.connect_on_timeout([this] {timer_elapsed()});
}
void timer_elapsed() {
conn.disconnect();
// ...
}
private:
timer& global_timer;
connection conn;
};
Проблема в том, что в operator()
в цикле мы вызовем disconnect
и удалим слот, что инвалидирует итератор в range-based for'e.
Как это фиксить? Можно удалять disconnected
не сразу, а после прохода по мапе. Можно воспользоваться тем, что std::function
имеет пустое состояние.
void operator()() const {
for (auto i = slots.begin(), i != slots.end; ++i) {
if (i->second) {
i->second();
}
}
for (auto i = slots.begin(); i != slots.end;) {
if (i->second) {
++i;
} else {
i = slots.erase(i);
}
}
}
Тогда нужно пометить slots
модификатором mutable
, так как operator()
у нас const
.
Чтобы map
чистился не только если вызывается operator()
, добавим поле mutable bool inside_emit = false;
и модифицируем operator()
и функцию disconnect
:
void operator()() const {
inside_emit = true;
for (auto i = slots.begin(), i != slots.end; ++i) {
if (i->second) {
i->second();
}
}
inside_emit = false;
for (auto i = slots.begin(); i != slots.end;) {
if (i->second) {
++i;
} else {
i = slots.erase(i);
}
}
}
void disconnect() {
auto it = sig->slots.find(id);
if (sig->inside_emit) {
it->second = slot_t();
} else {
sig->slots.erase(it);
}
}
Теперь проблема в том, что operator()
получился небезопасным - в случае исключений, не возвращается inside_emit = false
, поэтому нужно ещё поймать исключение и проставить ему значение false
, а в catch
почистить мапу.
Такая реализация близка к тому, что нужно в 95% случаев, но есть случай, когда это не будет работать. Если в примере выше таймер не был глобальным, а был заведён нами:
struct user {
user() = default;
void foo() {
timer.reset(new::timer(100));
conn = global_timer.connect_on_timeout([this] {timer_elapsed()});
}
void timer_elapsed() {
timer.reset();
}
private:
std::unique_ptr<timer> timer;
connection conn;
};
В time_elapsed()
удаляем таймер, но внутри таймера был signal
, который тоже удалится.
Ещё одна проблема - рекурсивные вызовы.
// emit
// slot1
// slot2
// slot3
// ...
// emit
// slot1
// slot2
// slot3
// slot4
// slot5
// leave_emit
// erase
// slot4
// slot5
// leave_emit
Проблема в том, что внутренний emit
сделает erase
, а снаружи мы всё ещё итерируемся по слотам. Одно из решений такой проблемы - сделать счётчик mutable size_t inside_emit = 0
.
Осталась проблема с тем, когда signal
удаляется. Одна из вещей, которая это фиксит - та часть данных, которая должна переживать сам класс, хранится отдельно через shared_ptr
. Второй вариант - поле mutable bool* destroyed = nullptr;
void operator()() const {
++inside_emit;
bool* old_destroyed = destroyed;
bool is_destroyed = false;
destroyed = &is_destroyed;
try {
for (auto i = slots.begin(); i != slots.end(); ++i) {
if (*i) {
(*i)();
if (is_destroyed) {
*old_destroyed = true;
return;
}
}
}
} catch (...) {
destroyed = old_destroyed;
leave_emit();
throw;
}
destroyed = old_destroyed;
leave_emit();
}
~signal() {
if (destroyed) {
*destroyed = true;
}
}
void leave_emit() const noexcept {
--inside_emit;
if (inside_emit != 0)
return;
for (auto i = slots.begin(); i != slots.end();) {
if (*i) {
++i;
} else {
i = slots.erase(i);
}
}
}
Весь код можно посмотреть на gist.
Как можно заметить, писать свои сигналы (а особенно читать) - не самая тривиальная задача. Поэтому, на самом деле, лучше пользоваться Boost.Signals
, про них есть статья на хабре. Если пользуетесь сигналами из библиотек, то нужно внимательно проверять гарантии, например, на удаления и рекурсивные emit'ы.
Замечание: семейство проблем, которые мы фиксили, называется reentrancy. Это многозначный термин, в основном объединяющий проблемы с глобальными или статичными данными. Программа в целом или её отдельная процедура называется реентераабельной, если она разработана таким образом, что одна и та же копия инструкций программы в памяти может быть совместно использована несколькими пользователями или процессами. Например, написанный выше signal
- реентерабельный.
Стратеги обработки ошибок
Часто встречаются ошибки нескольких видов:
- hardware errors
- compilation errors
- runtime errors
Предположим, что есть функция, которая ожидает, что массив на входе отсортирован.
int binary_search(...) {
if (!is_sorted(...)) {
throw std::runtime_error("");
}
}
Делать так не очень полезно, так как проверка на отсортированность асимптотически занимает больше времени, чем сам алгоритм. Возможно, мы хотим проверку, которую можно уметь отключать (например, если код собирается в релизе, убрать проверки). Кроме того, throw
в коде выше тоже под вопросом - как вызывающая сторона должна реагировать на такое исключение? Вместо него лучше использоваться std::abort
.
В сишной библиотеке есть специальный макрос assert
, который проверяет свой аргумент, и если он false
, то вызывается std::abort
. Кроме того, так как это макрос, то его можно включать и отключать. Например, включать только в DEBUG.
С помощью assert
можно проверять только какие-то внутренние свойства программы, это можно назвать internal consistency errors, а не ситуации типа "файл не открылся", которые, на самом деле, являются не ошибками, а rare/exceptional situations.
Как можно обрабатывать внутренние ошибки?
- Игнорировать
- abort программы
- Сообщить вызывающей стороне
- Логгировать такие ошибки и продолжать работу
Хоть вариант игнорирования и кажется глупым, на самом деле, часто на практике получается именно так, как минимум, из-за того, что некоторые ситуации тяжело предположить и обработать.
Abort программы тоже спорный подход. С одной стороны, это плохо, когда программа завершается, например, из-за ошибки в библиотеке, которая не очень важная для остальной части программы. Но аргумент за abort - "а что вместо него?".
Сообщение вызывающей стороне - это исключения или возврат кодов ошибок. Проблема бросания исключений в том, что код, через который оно пролетает, ломается. Поэтому часто, если библиотека бросает исключение при какой-то внутренней ошибке, то усложняется весь код по пути пробрасывания исключения, так как он должен стать exception-безопасным (например, как мы писали в векторе и прочем).
Логирование - неплохая стратегия, так видно как ошибку (в отличие от игнорирования), так и то, как программа вела себя, если бы она игнорировалась.
Забавный аргумент - если ошибка делает abort программы, то на такую ошибку обращают внимание и бегут чинить её, в отличие от логов, которые могут игнорировать разработчики. Часто подход к обработке ошибок различный у разработчиков с разным бэкграундом и зависит от применений.
Обработка редких/исключительных ситуаций
Чаще всего такие ошибки сообщаются вызывающей стороне, но в некоторых случаях такое не работает. Пример такого случая - исключение, если бы оно произошло в вызове слота в signal
. Должны ли после исключения вызываться оставшиеся слоты? Должно ли оно пробрасываться наверх? Если оно пробрасывается в вызывающую сторону, то не понятно, от какого из слотов оно. На самом деле, стратегия report to caller имеет смысл только в том случае, когда вызывающая сторона "заинтересована в успехе операции", чем не является случай с сигналом, поэтому если в слоте происходит ошибка, то он должен сам её поймать и сделать что-то разумное.
Имеет ли смысл для таких ситуаций abort? Пример такого - случай нехватки памяти, который не понятно, как разумно обработать. Ещё один пример - ошибка открытия файла, который является внутренней частью программы (например, какой-нибудь конфиг).