Skip to content

Latest commit

 

History

History
164 lines (128 loc) · 13.2 KB

ownership_and_exceptions.md

File metadata and controls

164 lines (128 loc) · 13.2 KB

Владение, исключения и ошибки

Команде однажды завели баг-репорт: "Сервис упал c segmentation fault, в core dump стэк-трейс указывает как последнюю функцию перед падением что-то из вашей библиотеки. Разберитесь!". Упал сервис ровно один раз за полгода.

Этим чем-то был вызов free где-то глубоко-глубоко внутри библиотери Protobuf. И несколько последующих стэк-фреймов указывали на вызов деструктора уже в нашей библиотеке. Потратив некоторое время на анализ кода деструктора, дежурный инженер не нашел ничего подозрительного и предположил, что это похоже на какую-то ранее встреченную проблему в Protobuf. И как воспроизвести никто не представлял. Тупик...

Я заинтересовался этой загадочной историей и залез в core dump поглубже.

На пару десятков стэк-фреймов выше, уже принадлежащих чужому сервису, засветилась функция lru_insert. Это интересно. Это оказалась фукнция вставки в LRU-кэш. И уже можно было заподозрить, что, возможно, вызов деструктора как-то связан с вытеснением объекта из кэша.

Я решил найти конкретный код того сервиса и что же там происходит при вставке. Обнаруженный код привел меня сначала в замешательство, а потом в восхищение:

auto metadata = new Metadata(...);
metadata->cached = true;
lru_insert(cache, key, metadata);
// check if insert successfull!
if (auto item_handle = lru_get(cache, key)) {
   ...
} else {
    // not found -> it's not cached
   metadata->cached = false;
}

Если есть голый new, то где-то должен быть и delete... И я нашел его. Целых два

Один при создании кэша

auto cache = lru_create(n, [](void* data){ delete static_cast<Metadata*>(data); });

А второй где-то в другом месте

if (!metadata->cached) {
    delete metadata;
}

Дело пахнет повторным удалением. Но что же пошло не так здесь?

lru_insert(cache, key, metadata);
// check if insert successfull!
if (auto item_handle = lru_get(cache, key)) {
   ...
} else {
    // not found -> it's not cached
   metadata->cached = false;
}

Код восхтитителен тем, что явно выполняет целых два обращения к кэшу: на вставку и на проверку. Но ведь можно было бы ограничиться только одной вставкой, если lru_insert может предоставить необходимую информацию об успехе... Может ли дело быть в этом? Нет ли в этом сервисе случайно гонок, которые могут вклиниться между вставкой и проверкой? Но меня уверили, что процесс однопоточный.

Наверное, стоит углубиться в функцию lru_insert. Ее написали 10 лет назад и больше не трогали. Ее протестировали. Она надежна. Как я могу в ней сомневаться?

void lru_insert(Cache* c, const char* key, void* data) 
{
   try {
       c->cache.insert(std::string(key), 
                       boost::intrusive_pointer(new LRUItem(data,
                                                c->deleter)));
   } catch (...) {}
}

От увиденного мне стало дурно. Ведь здесь опять голый new, который может привести к утечке в самом редком и очень часто игнорируемом случае: если конструктор std::string бросит исключение (bad_alloc). И, как мы видим, эта функция полностью игнорирует пойманные исключения.

Но, как утверждает Rust, утечка это совершенно безопасно! И к use-after-free или double-free привести не может. А мы получили segfault с очень большой вероятностью именно из-за double-free.

Есть, конечно, еще вариант, что cache.insert может бросить исключение, не выполнить вставку и заставить intrusive_pointer удалить объект. Но этот сценарий не согласуется с увиденным core dump: повторное удаление (и падение) какого-то старого объекта произошло внутри insert. Если бы новый объект был удален, падение произошло бы в другом месте... Или бы не произошло вовсе. Неопределенное поведение!

У нас есть еще один подозреваемый в этом фрагменте кода. Давайте-ка глянем функцию lru_get. Ее тоже написали 10 лет назад, протестировали, и нет ни малейшего повода в ней сомневаться!

// LRUItemHandle protects data from deletion, if it's evicted from the cache
LRUItemHandle* lru_get(Cache* c, const char* key) {
    try {
        auto item_ptr = c->cache.get(std::string(key));
        if (!item_ptr) {
            return nullptr;
        } 
        return new LRUItemHandle(item_ptr);
    } catch (...) {
        return nullptr;
    }
}

Я был в ужасе. Надеюсь, вы тоже. Вернемся обратно к злосчастному фрагменту

lru_insert(cache, key, metadata);
// допустим, что insert отработал успешно.
if (auto item_handle = lru_get(cache, key)) {
   ...
} else {
   // У lru_get есть как минимум две возможности соврать нам 
   // о наличии элемента в кэше. И похоже что нас обманули
   metadata->cached = false;
}

Как выяснилось, в экстремально редком случае, раз в полгода, в зависимости от нагрузки, у сервиса заканчивалась память. Но вместо падения по out-of-memory он продолжал пытаться работать. Таковы требования. И часто ему это удавалось делать успешно. Пока однажды bad_alloc не был выброшен в этой надежной и протестированной библиотеке c LRU кэшем.


Теперь, когда все улики собраны и преступление реконструировано, надо сделать шаг назад и обдумать произошедшее.

  1. Разработчики сервиса проиграли в игру с ручным разделением владения данными между разными компонентами и динамическим определением, кто эти данные будет освобождать. Это сложная игра.

  2. У библиотеки с LRU-кэшем, с которым и хотели разделить владение, оказалось чудовищное API. Почему это C-API — обоснование тому есть и оно не касается нашей истории. Но это чудовищное C-API

// Эта фунция не сообщает о возможно ошибке вставки
// Эта фукнция пытается завладеть data, но в случае ошибки
// Data может быть удалена или нет — зависит от типа ошибки:
// - Не удалена при ошибке аллокации до входа в c->cache.insert
// - Удалена при любой ошибке внутри c->cache.insert
void lru_insert(Cache* c, const char* key, void* data)

// Эта функция может упасть с ошибкой, либо не найти элемент
// Но различить эти два исхода пользователю не предлагается возможности
LRUItemHandle* lru_get(Cache* c, const char* key)

Передача и разделение владения через границы C-API с помощью сырых указателей и с учетом ошибок и исключений — непростая задача на внимательность, которая может стать кошмаром, если писать в стиле C, игнорируя возможности C++.

API библиотеки с «надежным и протестированным» LRU кэшем я расширил новыми функциями, более устойчивыми к ошибкам. И постарался исправить старые насколько это возможно: например, проблему с владением в lru_insert нельзя было исправлять полностью... потому что нашелся пользователь, который полагался на неправильное поведение.

А функцию стоило написать так с самого начала:

// Эта функция принимает владение data и передает его кэшу
// В случае любой ошибки data НЕ будет освобождена
// В случае успеха контролировать время жизни data будет кэш
ErrorCode lru_try_insert(Cache* c, const char* key, void* data) try {
    // Подготавливаем слот для сырого указателя.
    auto slot = boost::intrusive_pointer(new LRUItem(nullptr, c->deleter));
    // Вставляем пустой слот в кэш. 
    // Слот должен быть пуст, чтоб не удалить данные при ошибке вставки
    c->cache.insert(std::string(key), slot);
    // Передаем владение в слот. На этом этапе никакой больше ошибки произойти не может.
    // деструктор LRUItem гарантирует вызов deleter(data)
    slot->data = data 
    return ErrorCode::LRU_OK;
} catch (...) {
    return ErrorCode::LRU_ERROR;
}

Но лучше бы, конечно, пересмотреть зависимости зависимостей и использовать C++ библиотеку для LRU кэша с более безопасными RAII-типами


Исключения (или паники, как в Rust) всегда осложняют ручное управление ресурсами. Это специфично не только для низкоуровневых языков. Например, программы на Go, Java, С#, Python и других также страдают от не закрытых файлов или соединений с базами данных, если программист забыл использовать try-with-resources, finally или defer блоки.

Ручное управление ресурсами крайне рекомендуется сводить к минимуму:

Также крайне рекомендую C++ разработчикам пробовать Rust как минимум как тьюториал по владению и его передаче и разделению. Строгий borrow checker раскроет вам много интересных паттернов, в которых при полностью ручном контроле можно легко получить double-free.