четверг, 20 ноября 2014 г.

Как устроен weak_ptr

Для чего нужен.

В предыдущем посте я рассказывал про устройство shared_ptr. К сожалению, он подвержен классической проблеме циклических ссылок. Для решения этой проблемы используется weak_ptr.

Как решить проблему циклических ссылок.

В объект счётчика ссылок добавляется счётчик слабых ссылок. weak_ptr увеличивает этот счётчик при создании и копировании. При этом объект счётчика ссылок не удаляется до тех пор, пока есть хотя бы один shared_ptr или один weak_ptr, т.к. weak_ptr необходимо обращаться к счётчику ссылок даже если нет ни одного живого shared_ptr. Но сам объект, на который указывает shared_ptr, может быть удалён, если больше не осталось живых shared_ptr.
Код объекта счётчика ссылок теперь будет выглядеть так:
template <typename T>
class sp_counted {
public:
    explicit sp_counted(T *p) noexcept
        : shared_count(1),
          weak_count(1),
          ptr(p) {}

    void add_ref() noexcept {
        ++shared_count;
    }

    void release() noexcept {
        if (!--shared_count) {
            // Если последний shared_ptr удалился, удаляем объект
            delete ptr;
            if (!--weak_count) {
                // Если нет слабых ссылок, удаляем объект счётчика
                destroy();
            }
        }
    }

    void add_ref_weak() noexcept {
        ++weak_count;
    }

    void release_weak() noexcept {
        if (!--weak_count) {
            // Если последний weak_ptr удалился, удаляем объект счётчика.
            // Т.к. shared_ptr тоже увеличивает weak_count при создании,
            // нет необходимости проверять значение shared_count
            destroy();
        }
    }

    size_t use_count() const noexcept {
        return shared_count.load();
    }

    // Попытка увеличить счётчик shared_count из weak_ptr
    // Потокобезопасен, lock-free
    void add_ref_lock() {
        // Сохраняем текущее значение shared_count
        size_t cur_value(shared_count.load());
        do {
            // Если счётчик сильных ссылок равен нулю (т.е. нет больше живых shared_ptr),
            // то новый shared_ptr создавать не из чего.
            if (!cur_value) {
                throw std::bad_weak_ptr();
            }
          // Пытаемся увеличить счётчик shared_count на единицу
          // Если в промежутке между сохранением shared_count в cur_value, shared_count изменился,
          // то операция compare_exchange_weak вернёт false, запишет новое значение shared_count в cur_value,
          // и цикл повторится
        } while (shared_count.compare_exchange_weak(cur_value, cur_value + 1));
    }

private:
    void destroy() noexcept {
        delete this;
    }

private:
    // Счётчик ссылок shared_ptr
    std::atomic<size_t> shared_count;
    // Счётчик ссылок weak_ptr
    std::atomic<size_t> weak_count;
    T *ptr;
};
Вопрос: почему вызов add_ref_lock безопасен? Ведь в другом потоке shared_ptr в деструкторе может удалить объект sp_counted, и обращение к переменной shared_count будет некорректным.
Ответ: потому что деструктор shared_ptr не удалит объект sp_counted, т.к. есть живая слабая ссылка (weak_count != 0).
Код weak_ptr будет выглядеть так:

template <typename T>
class weak_ptr {
    friend class shared_ptr<T>;
public:
    weak_ptr() noexcept : ptr(nullptr), counted(nullptr) {}

    weak_ptr(const weak_ptr &other) noexcept : ptr(other.ptr), counted(other.counted) {
        add_ref_weak();
    }

    weak_ptr(const shared_ptr<T> &p) noexcept : ptr(p.ptr), counted(p.counted) {
        add_ref_weak();
    }

    weak_ptr &operator=(const weak_ptr &other) noexcept {
        release_weak();

        ptr = other.ptr;
        counted = other.counted;

        add_ref_weak();

        return *this;
    }

    weak_ptr &operator=(const shared_ptr<T> &p) {
        release_weak();

        ptr = p.ptr;
        counted = p.counted;

        add_ref_weak();

        return *this;
    }

    // Пытаемся сделать shared_ptr. Для этого вызывается конструктор shared_ptr(const weak_ptr &amp;);
    // В случае невозможности создать shared_ptr возвращается пустой объект
    shared_ptr<T> lock() noexcept {
        try {
            return shared_ptr<T>(*this);
        } catch (const std::bad_weak_ptr &) {
            return shared_ptr<T>();
        }
    }

    size_t use_count() const noexcept {
        return counted != nullptr ? counted->use_count() : 0;
    }

private:
    void add_ref_weak() noexcept {
        if (counted) {
            counted->add_ref_weak();
        }
    }

    void release_weak() noexcept {
        if (counted) {
            counted->release_weak();
        }
    }

private:
    T *ptr;
    sp_counted<T> *counted;
};
В код shared_ptr добавляется конструктор shared_ptr(const weak_ptr &p):
template <typename T>
class weak_ptr;

template <typename T>
class shared_ptr {
    friend class weak_ptr<T>;
public:
    shared_ptr() noexcept : ptr(nullptr), counted(nullptr) {}

    // excaption safe конструктор
    explicit shared_ptr(T *p) {
        std::unique_ptr<T> holder(p);
        // new может кинуть исключение, и если p не передать в unique_ptr,
        // память под p потеряется
        counted = new sp_counted<T>(holder.get());
        ptr = holder.release();
    }

    ~shared_ptr() noexcept {
        release();
    }

    shared_ptr(const shared_ptr<T> &other) noexcept : ptr(other.ptr), counted(other.counted) {
        add_ref();
    }

    shared_ptr(const weak_ptr<T> &p) : ptr(p.ptr), counted(p.counted) {
        if (counted) {
            // Пытаемся увеличить счётчик ссылок объекта
            // В случае неудачи сгенерируется исключение std::bad_weak_ptr()
            counted->add_ref_lock();
        } else {
            throw std::bad_weak_ptr();
        }
    }

    shared_ptr &operator=(const shared_ptr<T> &other) noexcept {
        // Освобождаем владение предыдущим указателем
        release();

        // Выполняем присваивание
        ptr = other.ptr;
        counted = other.counted;

        // Устанавливаем владение новым указателем
        add_ref();

        // Ура! Я не забыл вернуть *this!
        return *this;
    }

    T *get() const noexcept {
        return ptr;
    }

    size_t use_count() const noexcept {
        return counted != nullptr ? counted->use_count() : 0;
    }

private:
    void add_ref() noexcept {
        if (counted) {
            counted->add_ref();
        }
    }

    void release() noexcept {
        if (counted) {
            counted->release();
        }
    }

private:
    T *ptr;
    sp_counted<T> *counted;
};

А что с потокобезопасностью?

Гарантии потокобезопасности такие же как и у shared_ptr. Но на всякий случай повторю: любая операция с weak_ptr потокобезопасна, если каждый поток хранит копию weak_ptr. При этом каждая копия может хранить сслыку на один shared_ptr. Хранить ссылки на один weak_ptr в разных потоках непотокобезопасно.

среда, 19 ноября 2014 г.

Как устроен shared_ptr

Для чего нужен

shared_ptr нужен для ситуации, когда имеются несколько ссылок на объект, созданных в динамической памяти. При этом освобождать объект нужно тогда, когда удаляется последняя ссылка. И делать это нужно безопасно даже в многопоточном коде.

Как отслеживать ссылки на объект

Каждый объект shared_ptr содержит созданный в динамической памяти счётчик ссылок, который увеличивается на единицу, когда shared_ptr завладевает объектом, и уменьшается на единицу, когда shared_ptr перестаёт владеть объектом.
Код объекта счётчика ссылок может выглядеть так:

template <typename T>
class sp_counted {
public:
    explicit sp_counted(T *p) noexcept : count(1), ptr(p) {}

    void add_ref() noexcept {
        ++count;
    }

    void release() noexcept {
        if (!--count) {
            delete ptr;
            delete this;
        }
    }

    size_t use_count() const noexcept {
        return count.load();
    }

private:
    std::atomic<size_t> count;
    T *ptr;
};
При этом код shared_ptr может выглядеть так:
template <typename T>
class shared_ptr {
public:
    shared_ptr() noexcept : ptr(nullptr), counted(nullptr) {}

    // excaption safe конструктор
    explicit shared_ptr(T *p) {
        std::unique_ptr<T> holder(p);
        // new может кинуть исключение, и, если p не передать в unique_ptr,
        // память под p потеряется
        counted = new sp_counted<T>(holder.get());
        ptr = holder.release();
    }

    ~shared_ptr() noexcept {
        release();
    }

    shared_ptr(const shared_ptr &other) noexcept : ptr(other.ptr), counted(other.counted) {
        add_ref();
    }

    shared_ptr &operator=(const shared_ptr &other) noexcept {
        // Освобождаем владение предыдущим указателем
        release();

        // Выполняем присваивание
        ptr = other.ptr;
        counted = other.counted;

        // Устанавливаем владение новым указателем
        add_ref();

        // Ура! Я не забыл вернуть *this!
        return *this;
    }

    T *get() const noexcept {
        return ptr;
    }

    size_t use_count() const noexcept {
        return counted != nullptr ? counted->use_count() : 0;
    }

private:
    void add_ref() {
        if (counted) {
            counted->add_ref();
        }
    }

    void release() {
        if (counted) {
            counted->release();
        }
    }

private:
    T *ptr;
    sp_counted<T> *counted;
};

А что с потокобезопасностью?

Любая операция с shared_ptr потокобезопасна, если каждый поток хранит копию shared_ptr. При этом каждая копия может хранить указатель на один объект.
Хранить ссылки на один shared_ptr в разных потоках непотокобезопасно.

вторник, 18 ноября 2014 г.

Разработка модулей для nginx

Отличное руководство по разработке модулей для nginx - здесь

воскресенье, 9 ноября 2014 г.

Алгоритмы кэширования

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

OPT

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

FIFO

Если искомый элемент не находится в кеше, он вставляется в хвост очереди. Если нужно освободить место, удаляются элементы из головы очереди. Таким образом вытесняется элемент, находящийся в кэше дольше всех.
Реализация.

LFU

Каждый элемент имеет счётчик обращений. Новый элемент вставляется в кэш со значением счётчика равным 1. При попадании в кэш счётчик найденного элемента увеличивается на 1. Если нужно освободить место, нужно найти элемент с самым маленьким значением счётчика. Таким образом вытесняется тот элемент, которые запрашивался реже всего.
Главный недостаток LFU состоит в том, что некогда частотные элементы могут присутствовать в кэше очень долго, даже если они уже давно не запрашивались.
Реализация.

LRU

Новый элемент вставляется в голову списка. При попадании в кэш элемент перемещается в голову списка. Если нужно освободить место, вытесняется элемент из хвоста списка. Таким образом вытесняется тот элемент, которые не запрашивался дольше всех.
Главный недостаток LRU состоит в том, что он не устойчив к линейному чтению, т.к. оно вымывает все "прогретые" данные.
Реализация.

Сегментный LRU (SNLRU)

Один из недостатков LRU в том, что если мы вставили новый элемент в кэш, и этот элемент больше не будет никогда запрошен, ему придётся пройти через весь список, пока он не будет вытеснен. Сегментный LRU решает эту проблему. Кэш организован ввиде нескольких (N) LRU кэшей. Новый элемент вставляется в нулевой LRU кэш. При попадании в кэш элемент перемещается в следующий LRU кэш, либо на MRU (Most Recently Used) позицию последнего LRU кэша, если выше уже идти некуда. При вытеснении элемента из k-го LRU кэша он перемещается в k-1 LRU кэш. По достижению нулевого LRU кэша элемент удаляется.
Алгоритм устойчив к линейному чтению, т.к. новые элементы не могут вытеснить прогретые. При этом он обладает свойством LFU алгоритма - элементы перемещается в следующий список при каждом попадании в кэш. Более того, если взять N достаточно большим, то получится LFU кэш. При этом сегментный LRU обладает тем же недостатком LFU - частотные в прошлом элементы будут долго сидеть в кэше прежде чем вымоются оттуда.
Реализация.

Mid point LRU

Является вариацией сегментного LRU, в котором количество сегментов - 2, и эти два сегмента делят кэш не пополам, а в некоторой пропорции. Размеры сегментов подбирается эмпирически и зависит от характера потока данных. Этот алгоритм очень просто реализуется и на деле показывает один из лучших результатов. Обладает всеми достоинствами сегментного LRU.
Реализация.

2Q

Кэш разделяется на три части:
  • In - FIFO кэш, в который попадают все новые элементы;
  • Out - FIFO кэш, в который попадают элементы, вытесненные из In. При этом этот кэш хранит ключ и не хранит значение, поэтому его можно сделать достаточно большим;
  • Main - главный LRU кэш, в который попадают новые элементы найденные в Out. При вытеснении элемента из главного кэша он удаляется.
Алгоритм устойчивость к линейному чтению.
Оригинальная статья с описаниемреализация.

MQ

По сути является сегментным LRU но с некоторыми существенными улучшениями.
Для каждого элемента ведётся счётчик его запросов. Номер LRU кэша, в который стоит поместить элемент вычисляется используя некоторую функцию от этого счётчика, например логарифм.
Возможна ситуация, когда элемент, который вытесняется из кэша, в ближайшем будущем может стать очень популярным, поэтому имеет смысл как-то запоминать позицию, с которой он был вытеснен, и при следующем добавлении в кэш этого элемента сразу ставить его на позицию с учётом его "прошлых заслуг". Для этого по аналогии с 2Q алгоритмом заводится FIFO кэш, который хранит ключ элемента и позицию, с которой элемент был вытеснен. Если новый добавляемый элемент находится в этом FIFO кэше, он сразу добавляется в позицию, с который был вытеснен в прошлом.
Алгоритм обладает также свойсвами LFU. Чтобы решить проблему элементов, которые часто запрашивались в прошлом, а сейчас просто занимают место, каждый элемент хранит время, когда к нему было обращение. Далее в какие-то моменты времени проверяются элементы на LRU позиции всех сегментов, и если к этим элементам давно не было оращения, они перемещаются в сегмент нижнего уровня, либо удаляются из кэша если находятся на нулевом сегменте.

Графики:


В легенде алгоритмы отсортированы от лучшего к худшему.
MQ* - MQ алгоритм, в котором не выполняется проверка времени жизни объекта в кэше.
На графике видно, что все существующие алгоритмы можно ещё оптимизировать, т.к. до теоретически оптимального есть ещё около 10%.

пятница, 7 ноября 2014 г.

Специальные функции-члены в C++

Здесь есть хорошее описание специальных функции-членов С++ от Howard Hinnant (один из основных комиттеров в Clang STL). Специальные функции-члены - это те, которые компилятор генерирует, если они не объявлены явно:

  • Конструктор по умолчанию (Default constructor);
  • Деструктор (Destructor);
  • Конструктор копирования (Copy constructor);
  • Оператор копирующего присваивания (Copy assignment);
  • Конструктор переноса (Move constructor);
  • Оператор переносящего присваивания (Move assignment);
Или в коде:
struct MyStruct {
    MyStruct() = default;
    ~MyStruct() = default;
    MyStruct(const MyStruct &) = default;
    MyStruct &operator=(const MyStruct &) = default;
    MyStruct(MyStruct &&) = default;
    MyStruct &operator=(MyStruct &&) = default;
};
Каждая из этих функций может быть:
  • Не объявлена;
  • Может быть сгенерирована компилятором:
    • Как default - вызывает соответствующие функции для членов. Для конструктора - конструкторы членов, для оператора присваивания - операторы присваивания членов и т.д.
    • Как delete - если соответствующая функция у члена объявлена как delete или функция не может быть сгенерирована. Например для оператора присваивания член может иметь оператор присваивания объявленный как delete, или объект не может копироваться, т.к. содержит ссылку.
  • Может быть объвлена явно программистом:
    • Как default;
    • Как delete;
    • С телом функции;
Разница между "не объявлена" и объявлена с delete в том, что не объявленная функция не участвует в разрешении перегрузки.

Например класс:
struct MyStruct {
    template <typename... Args>
    MyStruct(Args...) {
    }
};
может быть создан вызовом конструктора без параметров, т.к. конструктор по умолчанию не объявлен, и не участвует в разрешении перегрузки.
Но такой класс:

struct MyStruct {
    template <typename... Args>
    MyStruct(Args...) {
    }
    MyStruct() = delete;
};
создать вызовом конструктора без параметров не получится, т.к. конструктор по умолчанию объявлен и участвует в разрешении перегрузки, и при этом он объявлен как delete.
При этом стоить помнить одно правило - если перемещающие функции объявленны компиляторм как delete, они не участвуют в разрешении перегрузки, не смотря на факт объвления.
Когда и какие функции компилятор генерирует сам, а когда объявляет их как delete?
Объявленный конструктор по умолчанию - остальные 5 функций объявляются компилятором как default:
struct MyStruct {
    MyStruct() = default;
    ~MyStruct() = default;
    MyStruct(const MyStruct &) = default;
    MyStruct &operator=(const MyStruct &) = default;
    MyStruct(MyStruct &&) = default;
    MyStruct &operator=(MyStruct &&) = default;
};
Объявленный деструктор - конструктор перемещения и перемещающий оператор присваивания становятся не объявленными, остальные функции объявляются компилятором как default:
struct MyStruct {
    MyStruct() = default;
    ~MyStruct() = default;
    MyStruct(const MyStruct &) = default;
    MyStruct &operator=(const MyStruct &) = default;


};
Объявленный конструктор копирования - конструктор по умолчанию, конструктор перемещения и перемещающий оператор присваивания становятся не объявленными, остальные функции объявляются компилятором как default:
struct MyStruct {

    ~MyStruct() = default;
    MyStruct(const MyStruct &) = default;
    MyStruct &operator=(const MyStruct &) = default;


};
Объявленный копирующий оператор присваивания - конструктор перемещения и перемещающий оператор присваивания становятся не объявленными, остальные функции объявляются компилятором как default:
struct MyStruct {
    MyStruct() = default;
    ~MyStruct() = default;
    MyStruct(const MyStruct &) = default;
    MyStruct &operator=(const MyStruct &) = default;


};
Объявленный конструктор перемещения - конструктор по умолчанию и перемещающий оператор присваивания становятся не объявленным, деструктор объявляется компилятором как default, остальные функции объявляются компилятором как delete.
struct MyStruct {

    ~MyStruct() = default;
    MyStruct(const MyStruct &) = delete;
    MyStruct &operator=(const MyStruct &) = delete;
    MyStruct(MyStruct &&) = default;

};
Объявленный перемещающий оператор присваивания - конструктор по умолчанию и деструктор объявляется компилятором как default, конструктор копирования и оператор копирующего присваивания объявляются компилятором как delete, конструктор перемещения становится не объявленным.
struct MyStruct {
    MyStruct() = default;
    ~MyStruct() = default;
    MyStruct(const MyStruct &) = delete;
    MyStruct &operator=(const MyStruct &) = delete;

    MyStruct &operator=(MyStruct &&) = default;
};