Мьютексы в стиле Rust для C++

в 7:43, , рубрики: c++, c++11, multithreading, Mutex, Rust, synchronization, template, templates, Программирование, С++

Здравствуй!

Я часто разрабатываю программы на C++ и люблю этот язык, что бы о нём ни говорили. Наверное потому, что во многих областях замены ему пока что нет. Однако язык этот, как все мы знаем, не лишён недостатков, и поэтому я всегда с интересом слежу за новыми подходами, паттернами или даже языками программирования, призванными решить какую-то часть этих проблем.

Так, недавно, я с интересом просматривал презентацию Степана Кольцова о языке программирования Rust, и мне очень понравилась идея реализации мьютаксов в этом языке. Причём никаких препятствий для реализации подобного примитива в C++ я не увидел и сразу же открыл IDE, с целью реализовать подобное на практике.

Сразу предупреждаю, что писать я буду с использованием стандарта С++11, поэтому, если вы собираетесь компилировать предлагаемый код, то делать это следует с флагом -std=c++11. Также хочу сразу предупредить, что я не претендую на оригинальность и вполне допускаю, что подобный примитив уже существует в какой-либо библиотеке либо фреймворке.

Итак, приступим. Для начала, давайте представим себе, что мы хотим получить в итоге. Тот самый мьютекс в Rust, который и послужит нам прототипом, работает следующим образом: Это шаблонный класс, который параметризуется типом данных, которые он должен защищать. То есть, на самом деле, это не мьютекс в том смысле, к которому мы все привыкли, а, скорее, некий защищённый ресурс, который содержит не только сам мьютекс, но и защищаемое им значение. При этом данный тип спроектирован таким образом, чтобы доступ к защищаемым данным был в принципе невозможен без предварительного захвата мьютекса.

Таким образом, результатом должен стать шаблонный класс, параметризуемый типом, обладающий описанными выше свойствами. Назовём его условно SharedResource. Работа с ним должна в итоге выглядеть как-то так:

Показать код

SharedResource<int> shared_int(5);
// ...
// Какой-то логический блок
{
	// Захватываем мьютекс и одновременно получаем прокси, 
	// через которое можно получить доступ к защищаемому значению
	auto shared_int_accessor = shared_int.lock();
	*shared_int_accessor = 10;
	// Здесь блок заканчивается, прокси shared_int_accessor 
	// разрушается и мьютекс доступа к защищённому объекту 
	// автоматически освобождается
}

Как видите, всё просто, но безопасно. При таком подходе невозможно забыть захватить мьютекс при обращении к защищаемому ресурсу или освободить его после того, как все операции с разделяемым ресурсом выполнены. Давайте приступим к реализации. Итак, начнём с рутины: самого класса-заглушки, содержащего стандартные конструкторы, деструкторы и операторы.

Показать код

template<typename T>
class SharedResource
{
public:
	SharedResource() = default;
	~SharedResource() = default;
	SharedResource(SharedResource&&) = delete;
	SharedResource(const SharedResource&) = delete;
	SharedResource& operator=(SharedResource&&) = delete;
	SharedResource& operator=(const SharedResource&) = delete;
private:
};

Пока что всё банально. Копирование и перемещение пока что запретим, потом изменим это, если возникнет необходимость или желание. Но мы всё ещё не реализовали возможности, описанные даже вот в этой строчке нашего примера использования:

template<typename T>
class SharedResource
SharedResource<int> shared_int(5);

У нас отсутствует возможность инициализировать защищаемый нами ресурс. Давайте попытаемся исправить это. При использовании стандарта C++03 или ниже нам было бы это сделать довольно проблематично (хотя и возможно) по очевидной причине — конструкторы защищаемого ресурса могут принимать любое количество аргументов произвольных типов. Однако, с появлением в C++11 Variadic Templates эта проблема отпала. Вся необходимая нам функциональность реализуется легко и просто следующим образом:

Показать код

template<typename T>
class SharedResource
{
public:
    template<typename ...Args>
    SharedResource(Args ...args) : m_resource(args...) { }

    ~SharedResource() = default;
    SharedResource(SharedResource&&) = delete;
    SharedResource(const SharedResource&) = delete;
    SharedResource& operator=(SharedResource&&) = delete;
    SharedResource& operator=(const SharedResource&) = delete;
private:
    T   m_resource;
};

Теперь у нас в классе появилось поле m_resource — это тот самый защищаемый нами ресурс. И теперь мы можем инициализировать его любым удобным нам способом. Осталось только реализовать возможность захвата контроля над ресурсом и получения доступа к нему — то есть самое интересное. Давайте приступим:

Показать код

#include <mutex>

template<typename T>
class SharedResource
{
public:
    template<typename ...Args>
    SharedResource(Args ...args) : m_resource(args...) { }

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

    class Accessor
    {
        friend class SharedResource<T>;
    public:
        ~Accessor()
        {
            m_shared_resource.m_mutex.unlock();
        }

    private:
        Accessor(SharedResource<T> &resource) : m_shared_resource(resource)
        {
            m_shared_resource.m_mutex.lock();
        }

        SharedResource<T> &m_shared_resource;
    };

    Accessor lock()
    {
        return Accessor(*this);
    }

private:
    T           m_resource;
    std::mutex  m_mutex;
};

Итак, как мы видим, у нас появился новый класс — SharedResource::Accessor. Этот класс — то самое прокси, которое предоставляет доступ к разделяемому ресурсу, пока он захвачен. Класс SharedResource объявлен для него дружественным для того чтобы этот класс мог вызывать его конструктор. Важным моментом является то, что никто, кроме родительского класса не может создавать экземпляры этого класса напрямую. Единственный способ сделать это — вызвать метод SharedResource::lock(). Мы также видим, что при конструировании экземпляра этого класса происходит захват мьютекса, а при разрушении — освобождение. Тут всё понятно — мы хотим, чтобы мьютекс для ресурса был захвачен всё время, пока у нас есть доступ к нему, наличие которого и должен обеспечивать класс SharedResource::Accessor.

Тем не менее, в текущем состоянии класс весьма небезопасен. Речь идёт о копировании или перемещении экземпляров этого класса. Ни первые не вторые не объявлены явно, а значит будут использоваться конструкторы и операторы по умолчанию. При этом работать они будут некорректно — например при копировании мьютекс не будет захвачен ещё раз (что правильно), но будет освобождён при разрушении. Таким образом, если экземпляр класса будет скопирован, то мьютекс будет освобождён на один раз больше, чем был захвачен, и мы получаем наш любимый Undefined Behavior. Давайте попытаемся исправить это:

Показать код

#include <mutex>

template<typename T>
class SharedResource
{
public:
    template<typename ...Args>
    SharedResource(Args ...args) : m_resource(args...) { }

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

    class Accessor
    {
        friend class SharedResource<T>;
    public:
        ~Accessor()
        {
            if (m_shared_resource)
            {
                m_shared_resource->m_mutex.unlock();
            }
        }

        Accessor(const Accessor&) = delete;
        Accessor& operator=(const Accessor&) = delete;

        Accessor(Accessor&& a) :
            m_shared_resource(a.m_shared_resource)
        {
            a.m_shared_resource = nullptr;
        }

        Accessor& operator=(Accessor&& a)
        {
            m_shared_resource = a.m_shared_resource;
            a.m_shared_resource = nullptr;
        }

    private:
        Accessor(SharedResource<T> *resource) : m_shared_resource(resource)
        {
            m_shared_resource->m_mutex.lock();
        }

        SharedResource<T> *m_shared_resource;
    };

    Accessor lock()
    {
        return Accessor(this);
    }

private:
    T           m_resource;
    std::mutex  m_mutex;
};

Мы запретили копирование, но разрешили перемещение. Отрицательным последствием такого решения стало то, что наш прокси теперь может быть невалидным (после перемещения), и его нельзя будет использовать для получения доступа к ресурсу. Это не очень хорошо, но не смертельно — перемещённые объекты и не предназначены для дальнейшего использования. К тому же падения при использовании таких объектов будут воспроизводиться в 100% случаях, благодаря nullptr, что делает обнаружение подобных ошибок делом не слишком сложным в большинстве случаях. Тем не менее, неплохо бы дать пользователю возможность проверить объект на валидность. Давайте сделаем это, добавив вот такой вот метод:

Показать код

bool isValid() const noexcept
{
	return m_shared_resource != nullptr;
}

Теперь пользователь всегда сможет проверить свою копию прокси на валидность. По желанию можно добавить operator bool, хотя я и не стал этого делать. Итак, осталось реализовать только само получение доступа к разделяемому ресурсу. Сделаем это, добавив следующие операторы для класса SharedResource::Accessor:

Показать код

T* operator->()
{
	return &m_shared_resource->m_resource;
}

T& operator*()
{
	return m_shared_resource->m_resource;
}

Полностью класс будет выглядеть так:

Показать код

#include <mutex>

template<typename T>
class SharedResource
{
public:
    template<typename ...Args>
    SharedResource(Args ...args) : m_resource(args...) { }

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

    class Accessor
    {
        friend class SharedResource<T>;
    public:
        ~Accessor()
        {
            if (m_shared_resource)
            {
                m_shared_resource->m_mutex.unlock();
            }
        }

        Accessor(const Accessor&) = delete;
        Accessor& operator=(const Accessor&) = delete;

        Accessor(Accessor&& a) :
            m_shared_resource(a.m_shared_resource)
        {
            a.m_shared_resource = nullptr;
        }

        Accessor& operator=(Accessor&& a)
        {
            m_shared_resource = a.m_shared_resource;
            a.m_shared_resource = nullptr;
        }

        bool isValid() const noexcept
        {
            return m_shared_resource != nullptr;
        }

        T* operator->()
        {
            return &m_shared_resource->m_resource;
        }

        T& operator*()
        {
            return m_shared_resource->m_resource;
        }

    private:
        Accessor(SharedResource<T> *resource) : m_shared_resource(resource)
        {
            m_shared_resource->m_mutex.lock();
        }

        SharedResource<T> *m_shared_resource;
    };

    Accessor lock()
    {
        return Accessor(this);
    }

private:
    T           m_resource;
    std::mutex  m_mutex;
};

Готово. Вся базовая функциональность для данного класса реализована и класс готов к использованию. Конечно, неплохо было бы реализовать также аналог Rust'ового метода new_with_condvars, который при создании класса связывает мьютекс с передаваемым списком условных переменных (condvars). В C++ мьютексы и условные переменные связываются по-другому, при ожидании на экземпляре condvar. Для этого в метод condition_variable::wait передаётся экземпляр класса unique_lock, который представляет собой абстракцию владение мьютексом без предоставления доступа к ресурсу.

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

Показать код

#include <mutex>

template<typename T>
class SharedResource
{
public:
    template<typename ...Args>
    SharedResource(Args ...args) : m_resource(args...) { }

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

    class Accessor
    {
        friend class SharedResource<T>;
    public:
        ~Accessor() = default;

        Accessor(const Accessor&) = delete;
        Accessor& operator=(const Accessor&) = delete;

        Accessor(Accessor&& a) :
            m_lock(std::move(a.m_lock)),
            m_shared_resource(a.m_shared_resource)
        {
            a.m_shared_resource = nullptr;
        }

        Accessor& operator=(Accessor&& a)
        {
            m_lock = std::move(a.m_lock);
            m_shared_resource = a.m_shared_resource;
            a.m_shared_resource = nullptr;
        }

        bool isValid() const noexcept
        {
            return m_shared_resource != nullptr;
        }

        T* operator->()
        {
            return m_shared_resource;
        }

        T& operator*()
        {
            return *m_shared_resource;
        }

        std::unique_lock<std::mutex>& get_lock() noexcept
        {
            return m_lock;
        }

    private:
        Accessor(SharedResource<T> *resource) :
            m_lock(resource->m_mutex),
            m_shared_resource(&resource->m_resource)
        {
        }

        std::unique_lock<std::mutex>    m_lock;
        T                              *m_shared_resource;
    };

    Accessor lock()
    {
        return Accessor(this);
    }

private:
    T           m_resource;
    std::mutex  m_mutex;
};

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

  • Нельзя сохранять ссылки и указатели на защищаемый ресурс, чтобы не было потом возможности получить к нему доступ в обход прокси.
  • Нельзя более одного раза вызывать метод SharedResource::lock() в одном и том же блоке.
  • Нельзя использовать прокси после его перемещения.
  • Ну и если вы собираетесь использовать реализацию с поддержкой condvars, то крайне не рекомендуется использовать доступный через прокси unique_lock иначе как для передачи в методы wait класса std::condition_variable.

Большое спасибо всем за внимание.
Ссылка на код гитхаб: https://github.com/stack-trace/shared-resource.
Код опубликован под лицензией public domain, так что вы можете делать с ним всё, что вам только в голову взбредёт.
Буду рад, если статья окажется кому-то полезной.

Автор: stack_trace

Источник

* - обязательные к заполнению поля