Двое из ларца

Предисловие


Эта статья является продолжением цикла статей про асинхронность:

  1. Асинхронность: назад в будущее.
  2. Асинхронность 2: телепортация сквозь порталы.

Спустя 3 года я решил расширить и обобщить имеющийся спектр асинхронного взаимодействия с использованием сопрограмм. Помимо этих статей также рекомендуется ознакомиться с универсальным адаптером:

  1. Универсальный адаптер

Введение


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

Если мы поместим сферический электрон в вакууме, то все, на что он будет способен — это двигаться равномерно и прямолинейно. 3 степени свободы да спин, и только равномерное прямолинейное движение. Ничего интересного и необычного.

Electron

Все меняется совершенно удивительным образом, если неподалеку окажутся другие частицы. Например, протон. Что мы про него знаем? Много чего. Нас будет интересовать массовость и наличие положительного заряда, по модулю равного в точности электрону, но с другим знаком. Это означает, что электрон и протон начнут друг с другом взаимодействовать электромагнитным образом.

Electron Proton

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

Bremsstrahlung

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

Hydrogen Atom

А теперь давайте взглянем на эту картинку под другим углом. Изначально у нас было две частицы: протон и электрон. Сами по себе частицы не излучают (просто не могут), никаких дискретностей они не проявляют, да и вообще ведут себя спокойно. Но картинка совершенно меняется, когда они находят друг друга. Появляются новые, совершенно уникальные свойства — непрерывный и дискретный спектр, стационарные состояния, минимальный уровень энергии системы. В реальности, конечно же, все гораздо сложнее и интереснее:

Hydrogen H-beta

Асимметрия штарковского уширения спектральной линии H? атома водорода, Phys. Rev. E 79 (2009)

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

Hydrogen Molecule

Как же так? Разве система не описывается её частями? Нет! В этом и суть, что при усложнении физической системы происходят качественные изменения, не описываемые каждой частью по отдельности.

Синергия взаимодействия проявляется во многих областях научного познания. Именно поэтому химия не редуцируется до физики, а биология — к химии. Несмотря на мощнейшие достижения квантовой механики, тем не менее химия как раздел научного знания существует отдельно от физики. Интересно отметить тот факт, что появляются области знания на стыке наук, например, квантовая химия. О чем это говорит? Что при усложнении системы появляются новые области исследований, которых не было на предыдущем уровне. Приходится учитывать новые обстоятельства, вводить дополнительные качественные факторы, усложняя каждый раз и без того непростую модель описания квантово-механической системы.

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

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

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

Объектно-ориентированное программирование


Рассмотрим ООП. Что мы про него знаем? Инкапсуляция, наследование, полиморфизм? SOLID принципы? А давайте спросим Алана Кея, который и ввел это понятие:
Когда я говорил про ООП, то я не имел в виду С++.

Алан Кей.

Это серьезный удар для программистов С++. Даже как-то обидно стало за язык. Но что же он имел в виду? Давайте разбираться.

Концепция объектов была введена в середине 1960-х с появлением языка Simula 67. Этот язык ввел такие понятия как объект, виртуальные методы и сопрограммы (!). Затем в 1970-х язык Smalltalk под влиянием языка Simula 67 развил идею объектов и ввел термин объектно-ориентированное программирование. Именно там были заложены основы того, что мы сейчас называем ООП. Сам Алан Кей так комментировал свою фразу:
Я жалею, что придумал термин «объекты» много лет назад, потому что он заставляет людей концентрироваться на мелких идеях. По-настоящему большая идея — это сообщения.

Алан Кей.

Если вспомнить Smalltalk, то становится понятно, что это означает. В этом языке использовалась посылка сообщений (см. также ObjectiveC). Этот механизм работал, но был тормозным. Поэтому в дальнейшем пошли по пути языка Simula и заменили посылку сообщений на обычные вызовы функций, а также вызовы виртуальных функций через таблицу этих самых виртуальных функций для поддержки позднего связывания.

Чтобы вернуться к истокам ООП, давайте по новому взглянем на классы и методы в С++. Для этого в качестве примера рассмотрим класс Reader, вычитывающий данные из источника и возвращающий объект типа Buffer:

class Reader
{
public:
    Buffer read(Range range, const Options& options);

    // и другие методы ...
};

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

Buffer read(Reader* this,
            Range range,
            const Options& options);

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

Как мы вызываем метод read? Например, так:

Reader reader;
auto buffer = reader.read(range, options);

Трансформируем вызов метода read следующим образом:

reader
    <- read(range, options)
    -> buffer;

Эта запись означает следующее. Объекту с именем reader подается на вход некий read(range, options), а на выходе он дает объект с именем buffer.

Что может представлять собой read(range, options)? Некоторое входное сообщение:

struct InReadMessage
{
    Range range;
    Options options;
};

struct OutReadMessage
{
    Buffer buffer;
};

reader
    <- InReadMessage{range, options}
    -> OutReadMessage;

Такая трансформация нам дает несколько иное понимание происходящего: вместо вызова функции мы синхронно отправляем сообщение InReadMessage и затем дожидаемся ответного сообщения OutReadMessage. Почему синхронно? Потому что семантика вызова подразумевает, что мы дожидаемся ответа. Однако, вообще говоря, ответного сообщения в месте вызова можно и не дожидаться, тогда это будет асинхронная отправка сообщения.

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

Перехват сообщений и трансформация действий


Поработаем над нашими сообщениями. Как же нам упаковать сообщение для последующей трансформации? Для этого будем использовать адаптер:

template<typename T_base>
struct ReaderAdapter : T_base
{
    Buffer read(Range range, const Options& options)
    {
        return T_base::call([range, options](Reader& reader) {
            return reader.read(range, options);
        });
    }
};

Теперь при вызове метода read происходит упаковка вызова в лямбду и передача этого вызова в метод базового класса T_base::call. В данном случае лямбда представляет собой функциональный объект, который будет передавать свое замыкание нашему объекту-наследнику T_base, автоматически его диспетчеризуя. Вот эта лямбда и есть наше сообщение, которое мы передаем дальше для трансформации действий.

Наиболее простой способ трансформации — это синхронизация доступа к объекту:

template<typename T_base, typename T_locker>
struct BaseLocker : private T_base
{
protected:
    template<typename F>
    auto call(F&& f)
    {
        std::unique_lock<T_locker> _{lock_};
        return f(static_cast<T_base&>(*this));
    }

private:
    T_locker lock_;
};

Внутри метода call происходит взятие блокировки lock_ и последующий вызов лямбды на экземпляре базового класса T_base, что позволяет производить дальнейшие трансформации при необходимости.

Давайте попробуем использовать такую функциональность:

// создаем экземпляр
ReaderAdapter<BaseLocker<Reader, std::mutex>> reader;

auto buffer = reader.read(range, options);

Что здесь происходит? Вместо использования непосредственно Reader мы теперь подменяем объект на ReaderAdapter. Этот адаптер при вызове метода read создает сообщение в виде лямбды и передает его дальше, где уже автоматически берется блокировка и отпускается строго на время выполнения этой операции. При этом мы в точности сохраняем исходный интерфейс класса Reader!

Этот подход можно легко обобщить и использовать универсальный адаптер. Про него я рассказывал на конференции С++, которая проходила в Москве в феврале 2017 года. Также можно почитать мою недавнюю статью про универсальный адаптер.

Соответствующий код с применением универсального адаптера будет выглядеть следующим образом:

DECL_ADAPTER(Reader, read)

AdaptedLocked<Reader, std::mutex> reader;

Здесь адаптер перехватывает каждый метод класса Reader, указанный в списке DECL_ADAPTER, в данном случае read, а затем AdaptedLocked уже перехваченное сообщение оборачивает в std::mutex. Более детально про это описано в указанной чуть выше статье, поэтому здесь я не буду подробно на этом останавливаться.

Сопрограммы


С ООП немного разобрались. Теперь зайдем с другой стороны и поговорим про сопрограммы.

Что такое сопрограммы? Если говорить вкратце, то это такие функции, которые можно прервать в любом месте, а затем продолжить с этого же места, т.е. заморозить исполнение и восстановить его с прерванной точки. В этом смысле они очень похожи на потоки: операционная система тоже в любой момент времени может их заморозить и переключить на другой поток. Например, из-за того, что мы съели слишком много процессорного времени.

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

Некоторые интересные способы взаимодействия можно почитать в моих предыдущих статьях про асинхронность.

CoSpinLock


Рассмотрим следующий кусок кода:

namespace synca {

struct Spinlock
{
    void lock()
    {
        while (lock_.test_and_set(std::memory_order_acquire)) {
            reschedule();
        }
    }

    void unlock()
    {
        lock_.clear(std::memory_order_release);
    }

private:
    std::atomic_flag lock_ = ATOMIC_FLAG_INIT;
};

} // namespace synca

Приведенный код выглядит как обычный спинлок. Действительно, внутри метода lock мы пытаемся атомарно выставить значение флага из false в true. Если это нам удалось, то блокировка взята, причем взята именно нами, и можно выполнять необходимые атомарные действия. При разблокировке просто сбрасываем флаг обратно в начальное значение false.

Все отличие кроется в реализации стратегии отлупа (backoff). Часто используется либо экспоненциальный рандомизированный отлуп, либо передача управления операционной системе через std::this_thread::yield(). В данном случае я поступаю хитрее: вместо прогрева процессора или передачи управления планировщику операционной системы я просто перепланирую нашу сопрограмму на более позднее исполнение через вызов synca::reschedule. При этом текущее исполнение замораживается, и планировщик запускает другую, готовую к исполнению сопрограмму. Это очень похоже на std::this_thread::yield() за исключением того, что вместо переключение в пространство ядра мы все время остаемся в пространстве пользователя и продолжаем делать какую-то осмысленную работу без пустого увеличения энтропии пространства.

Применим адаптер:

template <typename T>
using CoSpinlock = AdaptedLocked<T, synca::Spinlock>;

CoSpinlock<Reader> reader;
auto buffer = reader.read(range, options);

Как видим, код использования и семантика не изменились, зато изменилось поведение.

CoMutex


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

Mutex

Я здесь не буду приводить полный код реализации. Желающие могут с ним ознакомиться самостоятельно. Приведу лишь пример использования:

template <typename T>
using CoMutex = AdaptedLocked<T, synca::Mutex>;

CoMutex<Reader> reader;
auto buffer = reader.read(range, options);

Такой мьютекс имеет семантику обычного мьютекса, однако при этом не блокирует поток, заставляя планировщик сопрограмм выполнять полезную работу без переключения в пространство ядра. При этом CoMutex, в отличие от CoSpinlock, дает FIFO-гарантию, т.е. предоставляет справедливый конкурентный доступ к объекту.

CoSerializedPortal


В статье Асинхронность 2: телепортация сквозь порталы был подробно рассмотрен вопрос о переключении контекста между различными планировщиками через использование телепортации и портала. Вкратце опишу этот процесс.

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

Teleport

Это как раз и будет соответствовать переключению исполнения с одного потока на другой. Сопрограмма дает дополнительный уровень абстракции между кодом и потоком, позволяя манипулировать текущим исполнением и выполнять различные трюки и фокусы. Переключение между различными планировщиками — это телепортация.

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

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

template <typename T_base>
struct BaseSerializedPortal : T_base
{
    // создаем пул потоков с 1 потоком
    BaseSerializedPortal() : tp_(1) {}

protected:
    template <typename F>
    auto call(F&& f)
    {
        // в конструкторе портала перемещаемся в планировщик с 1 потоком
        synca::Portal _{tp_};
        return f(static_cast<T_base&>(*this));
        // а в деструкторе возвращаемся в исходный планировщик
    }
  
private:
    mt::ThreadPool tp_;
};

CoSerializedPortal<Reader> reader;

Понятно, что такой планировщик будет сериализовывать наши действия, а значит, синхронизировать их между собой. При этом, если пул потоков дает FIFO-гарантии, то и CoSerializedPortal будет обладать аналогичной гарантией.

CoAlone


Предыдущий подход с порталами можно использовать несколько иначе. Для этого воспользуемся другим планировщиком: synca::Alone.

Этот планировщик обладает следующим чудесным свойством: в любой момент времени может исполняться не более одной задачи данного планировщика. Таким образом, synca::Alone гарантирует, что ни один обработчик не будет запущен параллельно с другим. Есть задачи — будет выполняться только одна из них. Нет задач — ничего и не исполняется. Понятно, что при таком подходе происходит сериализация действий, а значит, доступ через этот планировщик будет синхронизирован. Семантически, это очень похоже на CoSerializedPortal. Стоит, однако, отметить, что такой планировщик запускает свои задачи на некотором пуле потоков, т.е. он самостоятельно не создает никаких новых потоков, а работает на уже существующих.

Для более подробной информации я отправляю читателя к оригинальной статье Асинхронность 2: телепортация сквозь порталы.

template <typename T_base>
struct BaseAlone : T_base
{
    BaseAlone(mt::IScheduler& scheduler)
        : alone_{scheduler} {}

protected:
    template <typename F>
    auto call(F&& f)
    {
        // т.к. Alone - планировщик, то снова используем портал
        synca::Portal _{alone_};
        return f(static_cast<T_base&>(*this));
    }

private:
    synca::Alone alone_;
};

CoAlone<Reader> reader;

Единственная разница в реализации по сравнению с CoSerializedPortal — замена планировщика mt::ThreadPool на synca::Alone.

CoChannel


Введем понятие канала на сопрограммах. Идеологически, он похож на каналы chan в языке Go, т.е. это очередь (не обязательно, кстати, ограниченного размера, как это сделано в Go), в которую могут одновременно писать несколько производителей данных и одновременно читать потребители без дополнительной синхронизации с их стороны. Проще говоря, канал — это просто труба, в которую можно добавлять и забирать сообщения без боязни состояния гонки.

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

template <typename T_base>
struct BaseChannel : T_base
{
    BaseChannel()
    {
        // создаем сопрограмму и запускаем цикл обработки сообщений
        synca::go([&] { loop(); });
    }

private:
    void loop()
    {
        // цикл обработки сообщений
        // автоматически обрывается при закрытии канала
        for (auto&& action : channel_) {
            action();
        }
    }

    synca::Channel<Handler> channel_;
};

CoChannel<Reader> reader;

Тут сразу возникает два вопроса: первый и второй.

  1. Что такое Handler?
  2. Где, собственно, диспетчеризация?

Handler — это всего лишь std::function<void()>. Вся магия происходит не здесь, а в том, как создается этот Handler для автоматической диспетчеризации.

template <typename T_base>
struct BaseChannel : T_base
{
protected:
    template <typename F>
    auto call(F&& f)
    {
        // перехватываем вызов метода с параметрами и записываем в fun
        auto fun = [&] { return f(static_cast<T_base&>(*this)); };
        // обложенный костылями результат вызова функции
        WrappedResult<decltype(fun())> result;
        channel_.put([&] {
            try {
                // если получилось вызвать без исключений - записываем результат
                result.set(wrap(fun));
            } catch (std::exception&) {
                // иначе записываем текущее перехваченное исключение
                result.setCurrentError();
            }
            // в конце обработчика возобновляем заснувшую сопрограмму
            synca::done();
        });
        // а здесь засыпаем и ожидаем результата
        synca::wait();
        // возвращаем результат либо кидаем пойманное исключение
        return result.get().unwrap();
    }
};

Здесь происходит достаточно простое действие: перехваченный вызов метода внутри функтора f мы оборачиваем во WrappedResult, помещаем этот вызов внутрь канала и засыпаем. Этот отложенный вызов мы позовем внутри метода BaseChannel::loop, тем самым заполнив результат и возобновив заснувшую исходную сопрограмму.

Стоит сказать несколько слов о классе WrappedResult. Данный класс служит нескольким целям:

  1. Он позволяет хранить либо результат вызова, либо пойманное исключение.
  2. Помимо этого, он решает следующую задачу. Дело в том, что если функция не возвращает никаких значений (т.е. возвращает тип void), то конструкция присвоения результата без обертки была бы некорректной. Действительно, нельзя просто так взять, и записать в тип void результат void. Однако его разрешено возвращать, что и делает специализация WrappedResult<void> через вызовы .get().unwrap().

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

Обычная асинхронность


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

Для этого рассмотрим реализацию асинхронного спинлока:

struct AsyncSpinlock
{
    void lock(std::function<void()> cb)
    {
        if (lock_.test_and_set(std::memory_order_acquire)) {
            // не получилось взять => перепланируем через планировщик
            currentScheduler().schedule(
                [this, cb = std::move(cb)]() mutable {
                    lock(std::move(cb));
                });
        } else {
            cb();
        }
    }

    void unlock()
    {
        lock_.clear(std::memory_order_release);
    }

private:
    std::atomic_flag lock_ = ATOMIC_FLAG_INIT;
};

Здесь поменялся стандартный интерфейс спинлока. Этот интерфейс стал более громоздким и менее приятным.

Теперь реализуем класс AsyncSpinlockReader, который будет использовать наш асинхронный спинлок:

struct AsyncSpinlockReader
{
    void read(Range range, const Options& options,
              std::function<void(const Buffer&)> cbBuffer)
    {
        spinlock_.lock(
            [this, range, options, cbBuffer = std::move(cbBuffer)] {
                auto buffer = reader_.read(range, options);
                // повезло, что unlock синхронный,
                // а то получилась бы прикольная лесенка из лямбд
                spinlock_.unlock();
                cbBuffer(buffer);
            });
    }

private:
    AsyncSpinlock spinlock_;
    Reader reader_;
}

Как мы видим на примере метода read, асинхронный спинлок AsyncSpinlock обязательно сломает существующие интерфейсы наших классов.

А теперь рассмотрим использование:

// вместо
//     CoSpinlock<Reader> reader;
//     auto buffer = reader.read(range, options);

AsyncSpinlockReader reader;
reader.read(buffer, options, [](const Buffer& buffer) {
    // и вот здесь у нас буфер
    // надо позаботиться о правильной передаче контекста внутрь лямбды
});

Давайте на минутку предположим, что Spinlock::unlock и вызов метода Reader::read тоже асинхронные. В это достаточно легко поверить, если предположить, что Reader тянет данные по сети, а вместо Spinlock используются, например, порталы. Тогда:

struct SuperAsyncSpinlockReader
{
    // здесь намеренно опущена обработка ошибок,
    // иначе мозг изменит свое агрегатное состояние
    void read(Range range, const Options& options,
              std::function<void(const Buffer&)> cb)
    {
        spinlock_.lock(
            [this, range, options, cb = std::move(cb)]() mutable {
                // первая неудача: read асинхронный
                reader_.read(range, options,
                    [this, cb = std::move(cb)](const Buffer& buffer) mutable {
                        // вторая неудача: спинлок асинхронный
                        spinlock_.unlock(
                            [buffer, cb = std::move(cb)] {
                                // конец прикольной лесенки
                                cb(buffer);
                            });
                    });
            });
    }

private:
    AsyncSpinlock spinlock_;
    AsyncNetworkReader reader_;
}

И так сойдет!

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

Естественно, что правильный подход с использованием сопрограмм делают такую схему синхронизации простой и понятной.

Неинвазивная асинхронность


Все рассмотренные примитивы синхронизации являются неявно асинхронными. Дело в том, что в случае уже заблокированного ресурса при конкурентном доступе наша сопрограмма засыпает, чтобы проснуться в момент освобождения блокировки предыдущей сопрограммой. Если бы мы при этом использовали так называемые stackless сопрограммы, которые до сих пор маринуются в новом стандарте, то нам бы пришлось использовать ключевое слово co_await. А это, в свою очередь, означает, что каждый (!) вызов любого метода, обернутого через синхронизирующий адаптер, должен добавлять вызов co_await, меняя семантику и интерфейсы:

// без синхронизации
Buffer baseRead()
{
    Reader reader;
    return reader.read(range, options);
}

// callback-style
// изменяется интерфейс и семантика вызова
void baseRead(std::function<void(const Buffer& buffer)> cb)
{
    AsyncReader reader;
    reader.read(range, options, cb);
}

// stackless coroutines
// изменяется интерфейс, добавляется явная асинхронность
future_t<Buffer> standardPlannedRead()
{
    CoMutex<Reader> reader;
    return co_await reader.read(range, options);
}

// stackful coroutines
// все остается неизменным
Buffer myRead()
{
    CoMutex<Reader> reader;
    return reader.read(range, options);
}

Здесь при использовании stackless подхода происходит слом всех интерфейсов в цепочке вызовов. В этом случае ни о какой прозрачности речи и быть не может, т.к. нельзя просто так взять и заменить Reader на CoMutex<Reader>. Такой инвазивный подход существенно ограничивает область применения stackless сопрограмм.

В то же время, такая проблема полностью отсутствует у подхода на основе stackful сопрограмм, используемых в настоящей статье.

Вам предоставляется уникальный выбор:

  1. Использовать инвазивный ломающий подход завтра (через 3 года, может быть).
  2. Использовать неинвазивный прозрачный подход сегодня (точнее, уже вчера).

Гибридные подходы


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

Рассмотрим синхронизацию через портал:

template <typename T_base>
struct BasePortal : T_base, private synca::SchedulerRef
{
    template <typename... V>
    BasePortal(mt::IScheduler& scheduler, V&&... v)
        : T_base{std::forward<V>(v)...}
        , synca::SchedulerRef{scheduler} // запоминаем планировщик
    {
    }

protected:
    template <typename F>
    auto call(F&& f)
    {
        // перепланируем вызов f(...) через сохраненный планировщик
        synca::Portal _{scheduler()};
        return f(static_cast<T_base&>(*this));
    }

    using synca::SchedulerRef::scheduler;
};

В конструкторе базового класса адаптера мы задаем планировщик mt::IScheduler, а затем перепланируем наш вызов f(static_cast<T_base&>(*this)) через портал сохраненного планировщика. Для использования такого подхода необходимо предварительно создать планировщик с 1 потоком для синхронизации исполнения:

// создаем единственный поток в пуле потоков для синхронизации
mt::ThreadPool serialized{1};
CoPortal<Reader> reader1{serialized};
CoPortal<Reader> reader2{serialized};

Таким образом оба экземпляра класса Reader будут сериализованы через один и тот же поток, принадлежащего пулу потоков serialized.

Подобный подход для изоляции исполнения можно использовать для CoAlone и CoChannel:

// т.к. CoAlone и CoChannel самостоятельно синхронизируют исполнение,
// то число потоков может быть произвольным
mt::ThreadPool isolated{3};

// здесь будет происходить синхронизация
// в пуле потоков isolated
CoAlone<Reader> reader1{isolated};

// будет создана сопрограмма для чтения из канала
// в пуле потоков isolated
CoChannel<Reader> reader2{isolated};

Субъектор


Итак, у нас есть 5 различных неблокирующих способов эффективной синхронизации действий над объектом в пользовательском пространстве исполнения:

  1. CoSpinlock
  2. CoMutex
  3. CoSerializedPortal
  4. CoAlone
  5. CoChannel

Все эти способы позволяют одинаковым образом взаимодействовать с объектом. Сделаем последний шаг для обобщения полученного кода:

#define BIND_SUBJECTOR(D_type, D_subjector, ...)             template <>                                              struct subjector::SubjectorPolicy<D_type>                {                                                            using Type = D_subjector<D_type, ##__VA_ARGS__>;     };

template <typename T>
struct SubjectorPolicy
{
    using Type = CoMutex<T>;
};

template <typename T>
using Subjector = typename SubjectorPolicy<T>::Type;

Здесь мы создаем тип Subjector<T>, который впоследствии можно будет переопределить на одно из 5 поведений. Например:

// допустим, что у класса Reader есть 3 метода: read, open, close
// сначала создаем адаптер для перехвата всех методов
DECL_ADAPTER(Reader, read, open, close)

// затем указываем, что Reader должен использовать CoChannel для синхронизации.
// если опустить эту строчку, то по умолчанию будет использован CoMutex,
// т.е. эта строка не является обязательной
BIND_SUBJECTOR(Reader, CoChannel)

// здесь используем уже настроенный субъектор - универсальный объект синхронизации
Subjector<Reader> reader;

Если в будущем мы захотим использовать Reader, например, в другом изолированном потоке, то нам необходимо всего лишь поменять одну строчку:

BIND_SUBJECTOR(Reader, CoSerializedPortal)

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

Алан Кей.

Асинхронный вызов


В рассмотренных примитивах синхронизации использовался синхронный неблокирующий вызов методов. Т.е. каждый раз мы дожидались окончания выполнения задачи и получения результата. Эта семантика соответствует обычному вызову методов объекта. Однако, в некоторых сценариях бывает полезно явно асинхронно запустить задачу, не дожидаясь результата, для распараллеливания исполнения.

Рассмотрим следующий пример:

class Network
{
public:
    void send(const Packet& packet);
};
DECL_ADAPTER(Network, send)
BIND_SUBJECTOR(Network, CoChannel)

Если мы будем использовать код:

void sendPacket(const Packet& packet)
{
    Subjector<Network> network;
    network.send(myPacket);
    // следующее действие не начнется,
    // пока не завершится предыдущее
    doSomeOtherStuff();
}

то действие doSomeOtherStuff() не начнется, пока не закончится выполнение network.send(). Для асинхронной отправки сообщения можно использовать следующий код:

void sendPacket(const Packet& packet)
{
    Subjector<Network> network;

    // запуск через .async()
    network.async().send(myPacket);

    // следующее действие начнется
    // параллельно с предыдущим
    doSomeOtherStuff();
}

И вуаля — синхронный код превратился в асинхронный!

Работает это следующим образом. Сначала создается специальная асинхронная обертка для адаптера BaseAsyncWrapper через использование странно рекурсивного шаблона:

template <typename T_derived>
struct BaseAsyncWrapper
{
protected:
    template <typename F>
    auto call(F&& f)
    {
        return static_cast<T_derived&>(*this).asyncCall(std::forward<F>(f));
    }
};

Т.е. вызов .async() перенаправляется в BaseAsyncWrapper, который перенаправляет вызов обратно в дочерний класс T_derived, но уже через использование метода asyncCall вместо call. Таким образом, для наших Co-объектов достаточно доопределить метод asyncCall в дополнении к call, чтобы такая функциональность заработала автоматически.

Для реализации asyncCall все способы синхронизации можно разделить на два класса:

  • Изначально синхронный вызов: CoSpinlock, CoMutex, CoSerializedPortal, CoAlone. Для этого необходимо просто создать новую сопрограмму и запустить в ней наше действие на заданном планировщике.


template <typename T_base>
struct Go : T_base
{
protected:
    template <typename F>
    auto asyncCall(F&& f)
    {
        return synca::go(
            [ f = std::move(f), this ]() {
                f(static_cast<T_base&>(*this));
            },
            T_base::scheduler());
    }
};


  • Изначально асинхронный вызов: CoChannel. Для этого надо убрать ожидание и оставить изначальный асинхронный вызов.


template <typename T_base>
struct BaseChannel : T_base
{
    template <typename F>
    auto asyncCall(F&& f)
    {
        channel_.put([ f = std::move(f), this ] {
            try {
                f(static_cast<T_base&>(*this));
            } catch (std::exception&) {
                // do nothing due to async call
            }
        });
    }
};

Характеристики


Различные характеристики описанных подходов сведены в следующую таблицу:

Table

1При использовании асинхронного вызова одновременно с гибридным подходом.
2При использовании гибридного подхода.

Рассмотрим более детально каждую колонку.

Легковесность


Самый легковесный примитив из рассматриваемых — это, безусловно, CoSpinlock. Действительно, он содержит только атомарные инструкции и перепланирование в случае уже заблокированного ресурса. CoSpinlock имеет смысл использовать в ситуациях кратковременных блокировок, т.к. в противном случае он начинает нагружать планировщик бесполезной работой для проверки атомарной переменной с последующем перепланированием сопрограммы. Другие рассматриваемые примитивы синхронизации используют более тяжеловесную синхронизацию своих объектов, однако в случае наличия конфликтов не нагружают планировщик.

FIFO


FIFO или first-in-first-out гарантия — это гарантия очереди, т.е. кто первее позвал .lock(), тот первее его и возьмет. Стоит отметить, что если в программе есть единственный планировщик и этот планировщик дает FIFO гарантию, то даже этом случае CoSpinlock не дает FIFO-гарантии.

Deadlock-free


Как понятно из названия, такой примитив синхронизации никогда не дает взаимных блокировок. Такую гарантию предоставляют примитивы на основе планировщика.

Неразрывность


Под неразрывностью я понимаю неразрывность взятой блокировки, как если бы синхронизация была через мьютекс. Оказывается, что неразрывность тесно связана со свойством deadlock-free. На этой теме я хотел бы остановиться более подробно чуть ниже, т.к. она важна для глубокого понимания способов синхронизации и представляет определенный практический интерес.

Изолированность


Частично понятие изолированности уже было использовано при рассмотрении CoPortal. Изолированность — это свойство исполнять метод в изолированном пуле потоков. Этим свойством по умолчанию обладает лишь CoSerializedPortal, т.к. он внутри себя создает пул потоков из одного потока для синхронизации исполнения. При синхронном исполнении, как уже было рассмотрено ранее, таким свойством могут также обладать примитивы, использующие планировщик: CoAlone и CoChannel. При асинхронном вызове происходит разветвление исполнения. Эту задачу решает планировщик, а значит появляется возможность изоляции выполнения кода и для других способов.

Асинхронность


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

  1. Параллелиться: эффективно исполнять различные стадии обработки.
  2. Сохранять контекст объекта: минимум переключений контекста для синхронизируемого объекта, данные объекта не вымываются из кеша процессора, ускоряя обработку.

Взаимные блокировки, гонки и взаимосвязь.


Я люблю задавать следующую задачу.

Задача 1. Предположим, что все методы нашего класса синхронизированы через мьютекс. Внимание, вопрос: возможно ли состояние гонки?

Task race condition

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

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

Рассмотрим класс:

struct Counter
{
    void set(int value);
    int get() const;

private:
    int value_ = 0;
};

Обернем:

DECL_ADAPTER(Counter, set, get)

Subjector<Counter> counter;

Методы get и set будут обернуты асинхронным мьютексом, хотя асинхронность тут не принципиальна. Важна синхронизация.

А теперь хотим решить задачу:

Задача 2. Атомарно увеличить счетчик на единицу.

И тут многих внезапно осеняет:

counter.set(counter.get() + 1);

Приведенный код содержит состояние гонки, невзирая на то, что каждый вызов по отдельности синхронизирован!

Чтобы разобраться во всем многообразии состояний гонок имеет смысл ввести следующие категории.

Состояние гонки первого рода.


Или иначе data race, описанный в стандарте:
The execution of a program contains a data race if it contains two potentially concurrent conflicting actions, at least one of which is not atomic, and neither happens before the other, except for the special case for signal handlers described below. Any such data race results in undefined behavior.

C++17 Standard N4659, §4.7.1 (20.2)

Типичный пример этого случая — когда в двух различных потоках делаются действия по изменению состояния без какой-либо синхронизации, например std::vector::push_back(value). В лучшем случае программа упадет, в худшем — незаметно испортит данные (да, в этом случае падение — благо). Для отлова подобного рода проблем существуют специальные инструменты:

  1. ThreadSanitizer: позволяет детектировать проблемы в runtime.
  2. Helgrind: a thread error detector: инструмент Valgrind для обнаружения ошибок синхронизации.
  3. Relacy race detector: верифицирует многопоточные lock-free/wait-free алгоритмы с учетом модели памяти.

Состояние гонки второго рода.


Это все состояния гонки, которые не подпадают под первую категорию. Это более высокоуровневые условия, которые описываются логикой программы и ее инвариантами, поэтому они не могут быть задетектированы низкоуровневыми инструментами и верификаторами. Как правило, они возникают при разрыве атомарного поведения, как в приведенном выше примере со счетчиком counter.set(counter.get() + 1). Это происходит из-за того, что .get() и .set() синхронизируются раздельно.

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

  1. Node.fz: fuzzing the server-side event-driven architecture: исследователи находят баги, связанные с конкурентным взаимодействием, в однопоточном (!) асинхронном коде.
  2. An empirical study on the correctness of formally verified distributed systems: исследование корректности формально верифицированных систем. Т.е. они находят ошибки там, где их не должно быть в принципе!

Неразрывность и асинхронность


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

struct User
{
    void setName(const std::string& name);
    std::string getName() const;

    void setAge(int age);
    int getAge() const;
};
DECL_ADAPTER(User, setName, getName, setAge, getAge)
BIND_SUBJECTOR(User, CoAlone)

struct UserManager
{
    void increaseAge()
    {
        user_.setAge(user_.getAge() + 1);
    }

private:
    Subjector<User> user_;
};

UserManager manager;
// race condition 2-го рода
manager.increaseAge();

Здесь в строчке manager.increaseAge() уже известное нам состояние гонки 2-го рода, приводящее к неконсистентному поведению в случае конкурентного вызова метода increaseAge() в двух разных потоках.

Можно попытаться исправить такое поведение:

struct UserManager
{
    void increaseAge()
    {
        user_.setAge(user_.getAge() + 1);
    }

private:
    Subjector<User> user_;
};
DECL_ADAPTER(UserManager, increaseAge)
BIND_SUBJECTOR(UserManager, CoAlone)

Subjector<UserManager> manager;
manager.increaseAge();

Для синхронизации в обоих случаях мы используем CoAlone. Возникает вопрос: будет ли состояние гонки в этом случае?

Поджигаем

Будет! Несмотря на синхронизацию, этот пример также подвержен проблеме состояния гонки 2-го рода. Действительно, при синхронизации UserManager текущая сопрограмма запускается на планировщике Alone. Затем при вызове user_.getAge() происходит телепортация на другой планировщик Alone, принадлежащий User. Т.е. другая запущенная сопрограмма теперь в состоянии войти внутрь метода increaseAge() параллельно с текущей, которая в этот момент находится внутри user_.getAge(). Это возможно, т.к. Alone гарантирует лишь отсутствие параллельного исполнения в своем планировщике. В данном случае происходит параллельное исполнение в двух разных планировщиках: CoAlone<User> и CoAlone<UserManager>.

Age with alone

Таким образом, происходит разрыв атомарного исполнения в случае использования синхронизации на основе планировщиков: CoAlone и CoPortal.

Для исправления этой ситуации достаточно заменить:

BIND_SUBJECTOR(UserManager, CoMutex)

Age with mutex

Это предотвратит состояние гонки второго рода.

Ожидание выполнения


В некоторых случаях разрыв синхронизации при исполнении кода бывает крайне полезен. Для этого рассмотрим следующий пример:

struct UI
{
    // метод, срабатывающий при клике на карточке пользователя
    void onRequestUser(const std::string& userName);

    // обновляет в окне информацию о пользователе
    void updateUser(const User& user);
};
DECL_ADAPTER(UI, onRequestUser, updateUser)
// UI присоединяется к существующему главному UI потоку
BIND_SUBJECTOR(UI, CoPortal)

struct UserManager
{
    // запрашивает информацию о пользователе
    User getUser(const std::string& userName);

private:
    void addUser(const User& user);
    User findUser(const std::string& userName);
};
DECL_ADAPTER(UserManager, getUser)
BIND_SUBJECTOR(UserManager, CoAlone)

struct NetworkManager
{
    // загружает информацию о пользователе с сервера
    User getUser(const std::string& userName);
};
DECL_ADAPTER(NetworkManager, getUser)
// сетевые операции запускаются вне основных потоков изолированно
BIND_SUBJECTOR(NetworkManager, CoSerializedPortal)

// функции, возвращающие глобальные экземпляры классов
Subjector<UserManager>& getUserManager();
Subjector<NetworkManager>& getNetworkManager();
Subjector<UI>& getUI();

void UI::onRequestUser(const std::string& userName);
{
    updateUser(getUserManager().getUser(userName));
}

void UserManager::getUser(const std::string& userName)
{
    auto user = findUser(userName);
    if (user) {
        // если пользователь найден, то возвращаем его
        return user;
    }
    // если пользователь не найден,
    // то запрашиваем пользователя по сети
    user = getNetworkManager().getUser(userName);
    // добавляем пользователя, чтобы избежать повторного запроса
    addUser(user);
    return user;
}

UI Interaction

Все действие начинается с вызова UI::onRequestUsername. В этот момент в UI потоке происходит вызов UserManager::getUser через соответствующий субъектор. Этот субъектор сначала переключает исполнение с UI потока на другой планировщик Alone, а затем вызывает непосредственно сам метод. При этом UI поток разблокируется и может выполнять другие действия. Таким образом, вызов происходит асинхронно, а значит не блокирует UI, не заставляя его лагать.

Если UserManager уже содержит информацию о запрашиваемом пользователе — то задача решена, и мы тотчас его возвращаем. В противном случае мы запрашивает необходимую информацию по сети через NetworkManager. И вновь мы не блокируем UserManager на время выполнения долгого запроса по сети. Если в этот момент пользователь запросил какую-то другую информацию из UserManager параллельно, то мы сможем ее предоставить без ожидания завершения изначальной операции! Т.е. в данном случае разрыв исполнения только улучшает отзывчивость и обеспечивает параллельность выполнения запросов. При этом мы не прикладывали для этого никаких усилий, через субъекторы все синхронизируется и асинхронно перепланируется автоматически. Чудеса, да и только!

Взаимные блокировки


Еще одна уникальная возможность, которой обладают субъекторы на основе планировщиков — это отсутствие взаимных блокировок. Смотрим:

// forward declaration
struct User;
DECL_ADAPTER(User, addFriend, getId, addFriendId)

struct User
{
    void addFriend(Subjector<User>& myFriend)
    {
        auto friendId = myFriend.getId();
        if (hasFriend(friendId)) {
            // если друг уже отмечен - то ничего не делаем
            return;
        }
        addFriendId(friendId);
        auto myId = getId();
        myFriend.addFriendId(myId);
    }

    Id getId() const;
    void addFriendId(Id id);

private:
    bool hasFriend(Id id);
};

// подружиться
void makeFriends(Subjector<User>& u1, Subjector<User>& u2)
{
    u1.addFriend(u2);
}

Deadlock

Т.к. по умолчанию используется CoMutex, то makeFriends при параллельном вызове различных методов иногда будет виснуть наглухо. Как исправить эту ситуацию, не переписывая весь код с нуля? Нужно добавить одну единственную строчку:

BIND_SUBJECTOR(User, CoAlone)

Deadlock-Free

Теперь, как бы вы не издевались над вызовами, во сколько бы потоков и как часто бы не запускали эти методы, вам никогда не удастся получить взаимной блокировки. Могли ли вы когда-нибудь помыслить, что такое вообще возможно? Очевидно, нет.

Транзакционность и взаимосвязь


Если внимательно присмотреться к таблице сравнительных характеристик различных способов взаимодействия, то можно увидеть взаимосвязь: если присутствует неразрывность, то отсутствует deadlock-free, и наоборот. В целом, можно показать, что в случае описанных способов синхронизации нельзя одновременно выполнить оба свойства. Тем не менее, при применении особых методик транзакционного исполнения с учетом планирования и отслеживания зависимостей такое поведение оказывается возможным. Однако это тянет на отдельную статью и здесь рассмотрено не будет.

Обсуждение


Мы получили 5 различных способов синхронизации синхронного исполнения. Добавив к этому 3 гибридных подхода и удвоив полученный результат для учета явных асинхронных вызовов в результате получаем 16 различных вариантов неблокирующей асинхронной синхронизации в пространстве пользователя без необходимости переключения в пространство ядра. До тех пор, пока есть какая-либо работа, она будет выполняться, максимально нагружая все ядра процессора. При этом пользователь вправе выбирать тот способ синхронизации, который ему необходим, исходя из логики приложения и взаимосвязей между данными.

Среди всех этих 16 способов особое место занимает один, снискавший на сегодня немалую популярность: CoChannel<T>.async().someMethod(...). Такое взаимодействие, примененное для всех участников процесса, называют моделью акторов. Действительно, в качестве mailbox выступает канал, который автоматически диспетчеризует входящие сообщения, соответствующие методам класса, используя всю мощь статической типизиции С++, ООП и шаблонного метапрограммирования на макросах. При этом, модель акторов, хотя и не требует дополнительной синхронизации, тем не менее остается подверженной гонкам второго рода, что, в свою очередь, полностью исключает взаимные блокировки.

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

Заключение: субъекторная модель


Кратко перечислим основные достижения:

  1. Интеграция синхронизации. Всё необходимое для синхронизации находится внутри нашей сущности, которая превращается из пассивного объекта в активный участник процесса — субъектор.
  2. Глубокая абстракция исполнения. Предложенная модель вводит единую абстракцию через обобщенное понимание принципов объектно-ориентированного программирования. В ней соединяются разнообразные примитивы воедино, порождая универсальную модель.
  3. Неинвазивность. Субъектор не меняет интерфейсы исходного класса, а значит позволяет прозрачно добавлять объект синхронизации без какого-либо рефакторинга кода.
  4. Защищенность от ошибок многопоточного проектирования. Универсальный адаптер гарантированно и автоматически выполняет все требуемые шаги для синхронизации исполнения. Это избавляет от необходимости аннотировать или комментировать поля и методы класса, описывая требуемый контекст использования и способ вызова. Субъектор изолирует все доступные поля и методы класса, предотвращая огромный класс наиболее коварных ошибок, связанных с параллельным и конкурентным программированием, отдавая задачу синхронизации доступа к объекту компилятору.
  5. Поздняя оптимизация. На основе локальности данных и текущего паттерна использования можно переключать различные типы синхронизации для достижения наилучших результатов на конечном этапе написания приложения.
  6. Эффективность. Подход реализует переключения контекста исключительно в пользовательском пространстве через использование сопрограмм, что дает минимальные накладные расходы и максимальное использование вычислительных ресурсов.
  7. Ясность, чистота и простота кода. Нет необходимости в явном отслеживании использования корректного контекста синхронизации и рассуждать о том, когда и какие переменные окружать защитниками guard, мьютексами, какие использовать планировщики, потоки и т.д. Это дает более простую компонуемость различных частей, позволяя разработчику сконцентрироваться на решении конкретной задачи, а не на борьбу по нахождению трудноуловимых проблем.

Как известно, модель упрощается при обобщении. Философия объектно-ориентированного программирования заставляет по-другому взглянуть на традиционные вещи и увидеть потрясающие абстракции. Синергия таких, казалось бы несовместимых вещей, как ООП, сопрограммы, каналы, мьютексы, спинлоки, потоки и планировщики порождает новую модель конкурентных взаимодействий, обобщая всем известную модель акторов, традиционную модель на основе мьютексов и модель взаимодействующих последовательных процессов, стирая грань между двумя различными способами взаимодействия: через разделяемую память и через обмен сообщениями.

Имя этой обобщенной модели конкурентного взаимодействия: субъекторная модель.

https://github.com/gridem/Subjector

Новое корыто готово!

Литература


[1] Асинхронность: назад в будущее
[2] Асинхронность 2: телепортация сквозь порталы
[3] Blog: God Adapter
[4] Универсальный адаптер
[5] Доклад: универсальный адаптер
[6] Видео: универсальный адаптер
[7] S. Djurovic, M. Cirisan, A.V Demura, G.V Demchenko, D. Nikolic, M.A. Gigosos, et al., Measurements of H? Stark central asymmetry and its analysis through standard theory and computer simulations, Phys. Rev. E 79 (2009) 46402.
[8] Квантовая химия
[9] ООП: SOLID
[10] Странно рекурсивный шаблон
[11] C++17 Standard N4659
[12] ThreadSanitizer
[13] Helgrind: a thread error detector
[14] Relacy race detector
[15] Неблокирующая синхронизация
[16] J.Davis, A.Thekumparampil, D.Lee, Node.fz: fuzzing the server-side event-driven architecture. EuroSys '17 Proceedings of the Twelfth European Conference on Computer Systems, pp 145-160
[17] P.Fonseca, K.Zhang, X.Wang, A.Krishnamurthy, An empirical study on the correctness of formally verified distributed systems. EuroSys '17 Proceedings of the Twelfth European Conference on Computer Systems, pp 328-343
[18] Модель акторов
[19] Взаимодействующие последовательные процессы
[20] Разделяемая память
[21] Обмен сообщениями

Комментарии (58)


  1. StarMarine
    23.10.2017 12:48
    -2

    Всё это очень умно, но связи с реальностью в этой статье очень слабые. Здесь пропущен важный пункт:

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

    В связи с этим, чем лучше выбранный язык программирования может описать исходную задачу, без всяких абстракций и накруток, тем лучше решение. Если кому-то потребуется написать сложное асинхронное приложение, то вряд-ли он станет разбираться в подобных абстракциях, а скорее возьмет Go или node.js, которые автоматически конвертируют исходный код в 'сопрограммы'.

    Я подозреваю статья родилась следующим образом: человек пришел в Яндекс, поглядел, удивился, «вау, как тут всё круто!» Нужно не отставать от уровня! И без опыта, полученного потом и кровью, решил: дай-ка напишу какую-нибудь заумную статью, чтобы все сразу сказали «вау!»


    1. gridem Автор
      23.10.2017 13:12

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

      Только почему-то оказывается, что голому машинному коду не все равно на количество абстракций. И, как показывает практика, чем больше абстракций, тем тормознее код. Часто для производительности приходится их выпиливать.


      Если кому-то потребуется написать сложное асинхронное приложение, то вряд-ли он станет разбираться в подобных абстракциях, а скорее возьмет Go или node.js, которые автоматически конвертируют исходный код в 'сопрограммы'.

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


      Я подозреваю статья родилась следующим образом: человек пришел в Яндекс, поглядел, удивился, «вау, как тут всё круто!»

      Вы просто не представляете, как сильно вы ошибаетесь. Причем везде.


      Статья родилась как результат анализа и обобщения различных подходов, кодовых баз и опыта. А заниматься шапкозакидательством — много ума не надо.


      1. StarMarine
        23.10.2017 13:36

        Только почему-то оказывается, что голому машинному коду не все равно на количество абстракций. И, как показывает практика, чем больше абстракций, тем тормознее код. Часто для производительности приходится их выпиливать.
        Если что, Вы только что опубликовали статью про то как увеличить количество абстракций. Лично мне не хватило когнитивного ресурса их всех понять, а главное понять зачем они.
        Код не может автоматически конвертироваться в сопрограммы.
        Разумеется, если этого не знать, то можно написать целый цикл статей про то, как попытаться состряпать это на C++. Но, например, в JS есть generator functions.
        Статья родилась как результат анализа и обобщения различных подходов, кодовых баз и опыта.
        И Путин её одобрил, как я понимаю?


        1. gridem Автор
          23.10.2017 15:27

          Лично мне не хватило когнитивного ресурса их всех понять, а главное понять зачем они.

          Писать проще, чем читать, я понимаю.


          Разумеется, если этого не знать, то можно написать целый цикл статей про то, как попытаться состряпать это на C++. Но, например, в JS есть generator functions.

          А в Хаскеле есть монады!


          И Путин её одобрил, как я понимаю?

          Без одобрения Путина данная статья, безусловно, не появилась бы на свет.


  1. reversecode
    23.10.2017 13:28

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


    1. gridem Автор
      23.10.2017 15:25

      Надо подумать, как это лучше всего сделать. Это непростой вопрос, т.к., фактически, он затрагивает аспекты дизайна и проектирования систем вообще. Как правило, это достигается на опыте написанием своего прототипа или модуля с нуля. Хотя есть и толковые книжки, которые разъясняют это подробно.


      Здесь же мы имеем дело со взаимодействием объектов в многопоточной среде. Возникающие проблемы осознаются только тогда, когда с ними непосредственно сталкиваешься. Соответственно, статья как раз о том, как справляться с проблемами взаимодействия объектов.


      Тут можно посоветовать лишь брать и пробовать. Но я подумаю о том, чтобы описать стандартные паттерны и подходы к такому снаряду.


      1. reversecode
        23.10.2017 16:09

        Ваша цель понятна. И ваши кубики в этой статье на мой взгляд будут понятны 1 из 10, или 10 из 100 сетевиков. Но если уж браться то охватывать все и сразу. А именно стать известным наставником в сетевом программировании и архитектуре. :) Раз уж вы в этом направлении двигаетесь. Таких кстати в рунете пока не видно.

        У вас в предыдущих докладах и статьях рассматривается пример прозрачного проксика. Т.е. создаем аксептор. Крутим цикл прочитал с сокета-записал в сокет.
        Дальше это все переводим в корутины с объяснениями итд.
        К сожалению на практике это все не так просто. И вот рассказать, возможно показать какие то цифры в производительности, разных подходов, показать в разных задачах как лучше начать или уже переделать существующее, заполнило бы пробелы в общем.

        Вот смотрите. В самом простом приближении, любая архитектура сетевых приложений выглядит из трех кубиков. Первый «Принимаем в буфер». Второй «Обрабатываем буфер». Третий «Отправляем с буфера». Это серверные приложения. И клиентские почти те же три кубика. Только вместо отправки из буфера, данные забираются дальше до программной логики.

        Попробуйте рассмотреть эти варианты в своих возможных следующих статьях или докладах.


        1. gridem Автор
          23.10.2017 18:19

          Ваша цель понятна. И ваши кубики в этой статье на мой взгляд будут понятны 1 из 10, или 10 из 100 сетевиков. Но если уж браться то охватывать все и сразу. А именно стать известным наставником в сетевом программировании и архитектуре. :) Раз уж вы в этом направлении двигаетесь. Таких кстати в рунете пока не видно.

          Сейчас непонятно, в какую сторону я движусь. Я написал то, что хотел написать еще примерно 2 года назад. К текущему моменту накопилось множество интересных моментов, поэтому статья и получилась такой.


          У вас в предыдущих докладах и статьях рассматривается пример прозрачного проксика. Т.е. создаем аксептор. Крутим цикл прочитал с сокета-записал в сокет. Дальше это все переводим в корутины с объяснениями итд.
          К сожалению на практике это все не так просто.

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


          Вот смотрите. В самом простом приближении, любая архитектура сетевых приложений выглядит из трех кубиков. Первый «Принимаем в буфер». Второй «Обрабатываем буфер». Третий «Отправляем с буфера». Это серверные приложения. И клиентские почти те же три кубика. Только вместо отправки из буфера, данные забираются дальше до программной логики.

          Это сильно упрощенное понимание процесса. Во второй статье, кстати, приведен пример решения реальной задачи. Стоит отталкиваться от нее.


          В следующий раз больше подумаю про введение в проблематику на конкретных примерах.


  1. StarMarine
    23.10.2017 13:36

    Только почему-то оказывается, что голому машинному коду не все равно на количество абстракций. И, как показывает практика, чем больше абстракций, тем тормознее код. Часто для производительности приходится их выпиливать.
    Если что, Вы только что опубликовали статью про то как увеличить количество абстракций. Лично мне не хватило когнитивного ресурса их всех понять, а главное понять зачем они.
    Код не может автоматически конвертироваться в сопрограммы.
    Разумеется, если этого не знать, то можно написать целый цикл статей про то, как попытаться состряпать это на C++. Но, например, в JS есть generator functions.
    Статья родилась как результат анализа и обобщения различных подходов, кодовых баз и опыта.
    И Путин её одобрил, как я понимаю?


    1. gridem Автор
      23.10.2017 15:29

      Ошиблись с сайтом. Бывает.


  1. mayorovp
    23.10.2017 16:12

    The execution of a program contains a data race if it contains two potentially concurrent conflicting actions, at least one of which is not atomic, and neither happens before the other, except for the special case for signal handlers described below. Any such data race results in undefined behavior.

    Почему вы считаете что ваш пример с counter.set(counter.get()+1) не подходит под это определение?


    counter.set(counter.get()+1) — это и есть non-atomic potentially concurrent action. Как только в программе появляется второй вызов этой же строчки без отношения happens before между ними — появляется гонка в соответствии с этим определением.


    1. gridem Автор
      23.10.2017 16:30

      Потому что это не приводит к undefined behavior. Если запустить 2 операции параллельно, то возможно лишь 2 исхода:


      1. Обе выполнятся.
      2. Одна перетрет действие другой.

      Других исходов нет.


      Можно подойти с другой стороны. Внутри каждой конструкции присутствует мьютекс, который реализует отношение happens-before при обращении к общей памяти. А значит, никакого data race с точки зрения модели памяти там нет.


  1. forester11
    23.10.2017 16:47

    Я тоже не очень понимаю о чем статья/труд.
    Можно как-то обозначить проблему которую решает автор? Или это просто полуобеденная дрема? :)
    Я вижу что было создано семейство классов-помощников, которые что-то делают, но для чего и чем они лучше существующих API из статьи абсолютно непонятно. Истории про электроны и цитаты комьютерных деятелей это конечно замечательно, но с таким же успехом можно было бы привести цитату Степанова о том что ООП — миф и на этом оборвать всю серию размышлений…

    Если говорить о практических вещах, то для комуникации сейчас есть такие популярные модели как boost::asio, node.js, ZeroMQ, и много других, каждая из них предлагает решение каких-то проблем и асинхронность. Для паралельных вычислений опять же сложность не в примитивах синхронизации, а в эффективном разбитии задачи/алгоритма на потоки, и есть решения которые распаралеливают код автоматически (у Intel и что то в GCC). Если мы говорим о низко-уровневых примитивах синхронизации (обертках над примитивами OS) — то и Mutex/SpinLock уже тоже немного устарели в угоду condition variables… Опять же в C++ новом стандарте есть опять же свои обертки (std::future). Зачем выдуманы эти все классы?


    1. StarMarine
      23.10.2017 17:08
      -2

      Именно. У меня создалось впечатление, что чувак создал велосипед и пиарится из-за незнания этих технологий.

      ReadAdapter — это Promise или future. А все остальные пассажи — это попытка создать Promise, который масштабируется по нескольким потокам (телепортация) с автоматической синхронизацией (субъектор).

      Так бы и написал «Библиотека промисов с автоматическим распределением выполнения по потокам и синхронизацией», а то «телепортация», «субъектор»…


      1. gridem Автор
        23.10.2017 18:04

        ReadAdapter — это Promise или future.

        Так promise или future?


        А все остальные пассажи — это попытка создать Promise, который масштабируется по нескольким потокам (телепортация) с автоматической синхронизацией (субъектор).

        Если внимательно прочитать, то окажется, что promise и future нигде не создаются. И что значит "масштабируется по нескольким потокам"? Не могли бы раскрыть этот интересный термин?


        1. StarMarine
          23.10.2017 18:38
          -3

          Смотри, если хорошенько задуматься, то проблемы написать асинхронное сетевое приложение особой нет. И дело не в том, где promise, а где future.

          Хочешь высокопроизводительное приложение — пиши на C, C++, там будут коллбаки. Ну и что? Хочешь сложное приложение со сложной логикой — пиши на node.js, там есть и промисы, и коллбаки, и корутины. Потребление процессора в эквивалентном приложении будет выше из-за частично интерпретируемого кода, а так же будет сложно балансировать нагрузку между отдельными процессами если у тебя их несколько. Ну не беда, используй Docker и переплати немного за хостинг. Хочешь высокопроизводительное и сложное приложение — пиши на Go. Ещё есть Erlang, Java, Scala, Python со своими решениями.

          А ты пытаешься запихнуть всё вышеперечисленное в один фрейморк да ещё на языке, который для этого не предназначен.

          Теперь доходит?

          По-сути, ты пытаешься руками решать проблемы, которые может решать компилятор.

          И возникает вопрос: а зачем?

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


          1. Antervis
            23.10.2017 21:55

            вы предлагаете либо разводить callback hell либо тратить 90% ресурсов ПК на те же самые абстракции которые с++ предлагает по аскетично минимальной цене. Может быть, вам стоит пересмотреть взгляд на мир?


            1. StarMarine
              24.10.2017 00:09

              Это Вам стоит пересмотреть взгляд на мир: Go предлагает и отсутсвие callback hell и высокую производительность :D


      1. mayorovp
        23.10.2017 18:45

        Нет, ReadAdapter не является ни Promise ни future. Хотя бы потому что Promise/future — это паттерн безстековой асинхронности, а тут рассматривается стековая (stackfull coroutine).


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


    1. gridem Автор
      23.10.2017 17:52

      Можно как-то обозначить проблему которую решает автор?

      Решается задача взаимодействия объектов в многопоточной среде.


      Если говорить о практических вещах, то для комуникации сейчас есть такие популярные модели как boost::asio, node.js, ZeroMQ, и много других, каждая из них предлагает решение каких-то проблем и асинхронность.

      boost::asio — это про асинхронность на callback. Чтобы понять, о чем статья, надо сначала пописать достаточно немало на этих самых колбеках, а затем подумать о том, как сделать проще и лучше. И вообще, boost::asio, node.js, ZeroMQ — не модели взаимодействий, а способ решения конкретных задач. Поэтому получается сравнение апельсинов с телевизорами. Чтобы это понять, достаточно ответить на вопрос: "зачем node.js если есть акторная модель?" Думаю, вы поймете всю абсурдность претензий.


      Для паралельных вычислений опять же сложность не в примитивах синхронизации, а в эффективном разбитии задачи/алгоритма на потоки, и есть решения которые распаралеливают код автоматически (у Intel и что то в GCC).

      Для параллельных вычислений есть свои замечательные фреймворки. Однако они начинают плохо работать в условиях асинхронного взаимодействия: UI, сеть, диск и др.


      Если мы говорим о низко-уровневых примитивах синхронизации (обертках над примитивами OS) — то и Mutex/SpinLock уже тоже немного устарели в угоду condition variables…

      Этот оксюморон может и имел место быть, если бы condition variables не использовали мьютексы в своих интерфейсах.


      Опять же в C++ новом стандарте есть опять же свои обертки (std::future). Зачем выдуманы эти все классы?

      std::future — это недоразумение. Там даже нельзя запустить задачу в своем пуле потоков.


      1. StarMarine
        23.10.2017 18:14
        -2

        std::future — это недоразумение. Там даже нельзя запустить задачу в своем пуле потоков.
        А зачем, собственно?

        Это и резюмирует сомнительность мотивации за этой статьей: хотел создать велосипед и попиарится за счет Яндекса.


      1. mayorovp
        23.10.2017 18:49

        std::future — это недоразумение. Там даже нельзя запустить задачу в своем пуле потоков.

        Простите, а кто вам мешает это сделать?


        Реальная проблема std::future в другом: нет возможности зарегистрировать продолжение, из всех способов получения значения доступно только синхронное ожидание.


        1. gridem Автор
          23.10.2017 20:16

          Простите, а кто вам мешает это сделать?

          Ну я имел в виду std::async, который возвращает std::future.


          Реальная проблема std::future в другом: нет возможности зарегистрировать продолжение, из всех способов получения значения доступно только синхронное ожидание.

          Все так.


      1. forester11
        23.10.2017 18:55
        -1

        boost::asio — это про асинхронность на callback. Чтобы понять, о чем статья, надо сначала пописать достаточно немало на этих самых колбеках, а затем подумать о том, как сделать проще и лучше

        Асинхронность на callback не отменяет возможности корутин. Они кстати есть в boost::asio, посмотрите.
        По существу, отличие асинхронности на callback от асинхронности на OS-потоках только в том что в первом случае мы пишем свой собственный scheduler, а во втором пытаемся подстроится под scheduler в OS. А поскольку специализированный scheduler под конкретную задачу обычно и проще, и быстрее, чем универсальный, практически все современные асинхронные комуникационные фреймворки работают на модели каллбэков. А поверх этой штуки уже надстраивается даже корутины (Python 3 asyncio, boost::asio coroutines), если кому-то так важен этот syntax sugar, вот в Node.JS людей устраивают promisе (меня кстати тоже).

        И вообще, boost::asio, node.js, ZeroMQ — не модели взаимодействий, а способ решения конкретных задач.

        Именно, они сделаны для конкретных задач, но при этом изза своей гибкости эти фреймворки так легко интегрируются в существующие приложения что становятся моделями взаимодействия. Например я могу спокойно взять win32/QT/какое-то еще приложение и внедрить в него boost::asio комуникацию пользуюясь одним тредом для всего (UI, сеть, даже работа с файлами, кроме каких-то CPU-intensive вычислений) (встроив boost::asio::service (scheduler) в обычный UI цикл)… и строить эффективную асинхронную работу всего приложения. А что с вашими классами? Практически все UI фреймворки/API — однопоточны. Пытатся из UI работать с многопоточным API по своей удобности напоминает работу с радиоактивными отходами — обязательно полный цикл мер и защит от смертельной радиации (асинхронности OS schedulerа).

        Для параллельных вычислений есть свои замечательные фреймворки. Однако они начинают плохо работать в условиях асинхронного взаимодействия: UI, сеть, диск и др.

        Да. Только не забывайте что практически в любой OS работу с сетью, диск и так далее можно делать асинхронно без OS потоков (win32 completionports например), и это работает эффективней чем вызывать блокирующие API через потоки. Т.е. многопоточность на OS thread для сети и файлов попросту ненужна… и абсолютно все UI API что я знаю — в принципе однопоточны. Т.е. получается создание абстрактной модели под несуществующие задачи.

        Этот оксюморон может и имел место быть, если бы condition variables не использовали мьютексы в своих интерфейсах.

        Между прочим, считается что заимплементить корректные condition variables на базе mutex и event невероятно сложно (без возможности вмешатся в работу OS scheduler). Не хотите проверить свои силы?
        И еще простая задача — попробуйте заимплементить multiple-read single-write синхронизацию на какой-то обьект (достаточно кстати реалистичная задача) на mutex и event, а потом на condition variable и сравните сложность имплементации а так же в какой случае вы допустили больше ошибок.

        std::future — это недоразумение. Там даже нельзя запустить задачу в своем пуле потоков.

        Я мало пользовался std::future но не вижу проблемы почему на базе этого api нельзя сделать свой thread pool? Почти всегда выгодней расширить существующее решение чем пытатся городить 100% новое просто потому что в существующем нехватает высоко-уровневой надстройки.


        1. mayorovp
          23.10.2017 19:46

          Да. Только не забывайте что практически в любой OS работу с сетью, диск и так далее можно делать асинхронно без OS потоков (win32 completionports например), и это работает эффективней чем вызывать блокирующие API через потоки. Т.е. многопоточность на OS thread для сети и файлов попросту ненужна… и абсолютно все UI API что я знаю — в принципе однопоточны. Т.е. получается создание абстрактной модели под несуществующие задачи.

          Вот только вы забыли, что оконные сообщения в completionport не направить. А значит, в программе появляются как минимум два потока — поток UI и сетевой поток...


          И еще сбоку пул потоков для вычислительных задач.


          1. forester11
            23.10.2017 20:40

            Можно наоборот, обработку completionports (или boost::asio::service) добавить в GetMessage UI цикл, почти в любом UI есть такой цикл. Главное чтобы обработчики UI сообщения не блокировались, а это не всегда возможно. Поэтому со 2ым потом может выйти даже проще.

            Но если вернутся к статье… Она сейчас просто очень академическая… Поэтому и у практикующего читателя сразу возникает вопрос а чем оно лучше X/Y/Z? Ну и еще в корпоративной IT культуре помоему принято концепцию начинать с мотивационной части отвечающей на вопрос «зачем»… Поэтому возможно и получается что коментарии выглядят немного недружелюбными, надо снизить темп.


            1. mayorovp
              23.10.2017 20:41

              И как вы будете ее добавлять?


              PS желание написать свой велосипед не нуждается в обоснованиях!


              1. forester11
                23.10.2017 21:26

                И как вы будете ее добавлять?

                Самое простое — запустить completion ports/asio::service и обрабатывать сетевые евенты в приоритете (т.е. без задержки), а UI эвенты поллить по таймеру в сетевом фреймворке например 50 раз в секунду (цель получить стабильные 50 FPS). :)
                Если есть свободное время то конечно можно и без поллинга через MsgWaitForMultipleObjectsEx/Overlapped/APC. Но вообще тупо проще создать 2ой поток для сети и не мудрить, оптимизация 1го треда не стоит потраченного времени на это.


                1. mayorovp
                  23.10.2017 21:28

                  Но вообще тупо проще создать 2ой поток для сети и не мудрить, оптимизация 1го треда не стоит потраченного времени на это.

                  А я о чем говорил?


  1. eao197
    23.10.2017 19:18
    +3

    Статья большая и, хотя написана тщательно, воспринимается непросто. Возможно, она бы заходила лучше, если бы была разбита на несколько статей поменьше, в которые были бы включены примеры использования различных концепций. В текущем же своем виде она требует еще, как минимум, одной статьи, в которой будет разобран какой-то пример из реальной жизни (или с претензией на оную). Типа: вот традиционный подход и вот какой код вот с какими недостатками. А вот подход на субъекторах и вот как он устраняет эти недостатки.

    Пока таких примеров нет, сложно судить о достоинствах предложенной модели. Да и прикидывать ее на задачи, с которыми сам имеешь дело, не так-то просто :(

    Чисто с точки зрения здравого смысла возникает несколько вопросов:
    — как программисту прикидывать, к каким накладным расходам приводит используемая им (или его коллегами) кухня? Например, написали некий класс Foo, обернули его в адаптер, потом накрутили вокруг него еще субъекторов. Как понять, насколько дорогим будет вызов Foo::bar?
    — насколько просто будет работать с кодом, в котором Foo::bar может приостанавливать текущую короутину, запихивать функтор в канал, из которого кто-то что-то вычитает на другой рабочей нити, начнет обработку, телепортируется куда-то и т.д.? При том, что изменение одного слова в определении субъектора для Foo может кардинально поменять способ выполнения Foo::bar. Т.е. речь о том, что C++ и так ругают за то, что код на C++ отличается неочевидностью, здесь же неочевидность становится главной движущей силой.

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

    PS. Отрадно, что разговоры в кулуарах конференции не прошли даром и появилось упоминание Симулы.


    1. gridem Автор
      23.10.2017 19:57

      как программисту прикидывать, к каким накладным расходам приводит используемая им (или его коллегами) кухня? Например, написали некий класс Foo, обернули его в адаптер, потом накрутили вокруг него еще субъекторов. Как понять, насколько дорогим будет вызов Foo::bar?

      Понять можно одним способом: профилированием кода. В этом контексте было бы неплохо сделать замеры на конкретных примерах, но это тоже потребует отдельной статьи.


      насколько просто будет работать с кодом, в котором Foo::bar может приостанавливать текущую короутину, запихивать функтор в канал, из которого кто-то что-то вычитает на другой рабочей нити, начнет обработку, телепортируется куда-то и т.д.? При том, что изменение одного слова в определении субъектора для Foo может кардинально поменять способ выполнения Foo::bar. Т.е. речь о том, что C++ и так ругают за то, что код на C++ отличается неочевидностью, здесь же неочевидность становится главной движущей силой.

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


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

      Субъектор появился в результате решения практической задачи про реплицированный объект. Когда есть большое число состояний, которое необходимо синхронизировать и следить за контекстом исполнения — то тут подобные абстракции сильно упрощают код.


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


      1. eao197
        23.10.2017 21:33

        Тут надо смотреть на характеристику того или иного способа синхронизации.
        Не, речь не про то. Вот когда человек видит что-то вроде foo.deferCall(bla-bla-bla), то он может предположить, что за этим скрывается какая-то непростая машинерия. А вот когда это выглядит как foo.call(bla-bla-bla), то догадаться о хитром поведении вызова не просто.
        В целом, исходя из опыта могу сказать, что лучше написать меньше кода, чем больше.
        Ок. Принято.
        Субъектор появился в результате решения практической задачи про реплицированный объект.
        Ну вот и надо бы рассказать про эту задачу, уверяю, далеко не все занимаются на C++ репликацией объектов по сети.


        1. gridem Автор
          23.10.2017 23:09

          Не, речь не про то. Вот когда человек видит что-то вроде foo.deferCall(bla-bla-bla), то он может предположить, что за этим скрывается какая-то непростая машинерия. А вот когда это выглядит как foo.call(bla-bla-bla), то догадаться о хитром поведении вызова не просто.

          Это философский вопрос, что считать за основание. И в том и другом случае происходит вызов метода и передача управления, а как она происходит — это подробности реализации. Кому-то может понравиться эта явность и многословность, мне — нет. Если же необходимо сохранить семантику, например, не прерывать исполнение, то для этого существуют другие примитивы и об этом надо говорить явно. Иначе у нас абстракция начнет протекать.


          Ну вот и надо бы рассказать про эту задачу, уверяю, далеко не все занимаются на C++ репликацией объектов по сети.

          Ну вот здесь и рассказано: https://habrahabr.ru/post/267509/


          1. eao197
            24.10.2017 09:06

            Ну вот здесь и рассказано: habrahabr.ru/post/267509
            Как по мне, там не рассказано. Ты там просто говоришь, что можно сделать репликацию объектов и она в коде будет выглядеть вот так. Но что за репликация, какие к ней требования? Как приложение, в котором репликация требуется, должно реагировать на ошибки?

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


            1. gridem Автор
              24.10.2017 12:13

              Т.е. хотелось бы видеть не абстрактную репликацию в вакууме, а более конкретное описание того, что нужно было сделать, как было сделано, почему было сделано именно так.

              Ты не поверишь, но мне тоже этого хочется. А пока есть то, что есть.


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


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


              1. eao197
                24.10.2017 12:17

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

                Понятное дело, что на более подробное описание требуется много сил и времени. Но что ж поделать-то, продвигать «в народ» что-либо всегда сложно.


                1. gridem Автор
                  24.10.2017 12:49

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

                  Всему свое время.


                1. StarMarine
                  24.10.2017 12:56
                  -2

                  Именно!


  1. StrausMG
    24.10.2017 12:06

    Ничего подобного пока не использовал, поэтому интересно: а не приводят ли такие гонки за производительностью к коду, который очень трудно дебажить?


    1. gridem Автор
      24.10.2017 12:08

      Если у тебя что-то сложнее hello world, то в любом случае будет трудно дебажить. А дебажить гонки — это на любителя, поэтому лучше их вообще не допускать. Этот подход выпиливает целый класс трудноуловимых проблем.


      1. StrausMG
        24.10.2017 12:40

        Я скорее об отладчиках, насколько адекватно они переваривают происходящее :)
        Но возможно отсутствие инструментов — действительно меньшее зло, чем data race.


        1. gridem Автор
          24.10.2017 12:48

          Сопрограммы показываются как обычные потоки с нормальным стек трейсом. Единственный момент: отладчик может показать только те сопрограммы, которые активны, т.е. сидят на потоках. Замороженные сопрограммы не видны, если не предпринимать специальных действий.


  1. proydakov
    24.10.2017 14:46

    Отличная статья.

    Было бы интересно увидеть как выглядит график стеков собранный через perf record для приложения использующего такую модель. Какое количество от общего времени работы будут занимать используемые примитивы синхронизации?

    Так же интересно как отслеживать такие вещи как длины очередей. Когда мы хотим выполнять много задач на каком-то конкретном потоке или пулле потоков. Те делаем много телепортов в 1 поток?


    1. gridem Автор
      24.10.2017 16:17

      Было бы интересно увидеть как выглядит график стеков собранный через perf record для приложения использующего такую модель.

      Вполне нормально выглядит. Так же, как и обычные потоки.


      Какое количество от общего времени работы будут занимать используемые примитивы синхронизации?

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


      Так же интересно как отслеживать такие вещи как длины очередей.

      Через метрики, статистики и логи. Всё как всегда.


      Когда мы хотим выполнять много задач на каком-то конкретном потоке или пулле потоков. Те делаем много телепортов в 1 поток?

      Не понял вопроса.


  1. khizmax
    24.10.2017 19:18

    Замечательная статья!
    В защиту автора (хотя, мне кажется, он не нуждается в защите) выражу свое мнение: подход, основанный на истинных stackful сопрограммах, который на протяжении уже нескольких лет разрабатывает автор, мне кажется очень перспективным. И намного более глубоким и красивым, чем то, что предлагают ввести в стандарт C++. Стандарты всегда запаздывают, так как описывают вещи для всех уже привычные и тысячу раз испробованные.
    Недостатками этого подхода мне видится две вещи:
    1. Отдельный стек для каждой сопрограммы. На самом деле, это достоинство (все данные — на стеке, всегда под рукой, сколько бы нас не прерывали), но за которым надо внимательно следить, иначе при чрезмерном увлечении можно всю память сожрать. Быть может, стоит ограничить размер стека сопрограмм, но это уже детали.
    2. Необычность. Этот «недостаток» лечится только опытом. Во-первых, надо попробовать все другие методы асинхронного/многопоточного программирования, начиная от тривиальных mutex/condvar, наплодить 100500 потоков на 4-ядерной машинке, удивиться «а чё оно так медленно работает и падает/лочится», затем пройти через callback hell, попробовать переписать callback на stackless сопрограммах, понять, что просто это не удастся, надо много переписывать. Во-вторых, надо научиться думать в терминах модели stackful сопрограмм; это бывает весьма нелегко, — заставить свой мозг работать по-другому, в терминах новой модели. Например, синхронизировать вовсе без примитивов синхронизации. Или: сделать многопоточную программу, в которой многопоточная логика не вылезает наверх, не затеняет логику самой программы, так что, читая код, вы видите, что делает программа, а не потроха взаимодействия потоков, future, callback и прочую машинерию, за которой леса не видно.

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

    PS: сам я модель stackful сопрограмм не применял по причинам, описанным выше, — негде было. Но искренне завидую тем, кто это попробовал на реальных задачах.


    1. gridem Автор
      24.10.2017 19:36

      Спасибо! Этот комментарий должен был быть первым.


    1. mayorovp
      25.10.2017 08:36
      +1

      В стандарт C++ предлагается включить stackless сопрограммы не потому что они "уже привычные и испробованные", а потому что они требуют обязательной поддержки со стороны компилятора. Вариант же stackfull может быть добавлен в язык сторонней библиотекой, что и демонстрирует boost::context


      1. gridem Автор
        25.10.2017 09:32

        Это крайне слабый аргумент. По этой причине тогда не нужно было включать boost::filesystem, т.к. не нужно изменять компилятор. Тем не менее, это было сделано.


        Стандарт — он не только для компилятора, но и для расширения существующей библиотеки и интеграции разных кусков воедино.


      1. gridem Автор
        25.10.2017 12:22

        Я считаю, что надо и stackless, и stackful. Дело в том, что для простых однопоточных генераторов нет ничего лучше stackless сопрограмм, т.к. их компилятор при желании может превратить в обычные циклы без аллокаций памяти. И это действительно круто и стоит того.


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


        Зачем нужен стандарт для stackful? Для того, чтобы различные инструменты научились их поддерживать без танцев с бубнами. Чтобы дебагеры их обнаруживали, чтобы санитайзеры из видели и т.д. Т.е. чтобы все работало из коробки так, как будто это нативные инструменты. Когда есть единый инструмент, тогда он будет вылизан и отточен. Поэтому его тоже надо в стандарт.


        1. mayorovp
          25.10.2017 12:28

          Но начинать-то надо все равно со stackless. Хотя бы для того чтобы стандартное API по два раза не переделывать. Потому что stackless в stackful можно без труда превратить, а вот обратное превращение съест все выгоды stackless-подхода.


          1. gridem Автор
            25.10.2017 12:33

            Потому что stackless в stackful можно без труда превратить
            Это каким образом?


            1. mayorovp
              25.10.2017 13:04

              stackless сопрограмма возвращает std::future<...>. Достаточно вставить внутрь методов .get и .wait поставить проверку не вызваны ли они из stackful сопрограммы, с адекватной реакцией на этот факт — и все, любые stackless сопрограммы можно вызывать изнутри stackful сопрограммы. А вот в обратную сторону так не работает.


              Механизмы же для асинхронного ввода-вывода требуются одни и те же, и там stackless-версия получается даже проще чем stackful-версия.


              1. gridem Автор
                25.10.2017 17:38

                Это звучит несколько странно, конечно. С тем же успехом можно сказать, что можно написать программу на колбеках, а затем переписать ее на корутины. Только зачем?


                И это говорит лишь о том, что подход на stackful сопрограммах более полновесный, т.к. на него можно легко переписать, и код получится проще. А с простого на сложное переписывать — то еще занятие. Именно поэтому подход на stackful сопрограммах рулит и бибикает.


                1. mayorovp
                  25.10.2017 17:46

                  На него легко переписать верхние слои, но не нижние.


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


                  Поэтому имеет смысл переписать на stackless сопрограммы чтобы не делать одну и ту же работу два раза.


                  1. gridem Автор
                    25.10.2017 19:05
                    -1

                    А я вообще не вижу смысла использовать разные подходы, когда можно использовать один подход, и уже сегодня.


                    В чем профит?


        1. Antervis
          25.10.2017 14:02

          Я считаю, что надо и stackless, и stackful

          опять же, stackful доступны в виде библиотек. Чтобы получить и те и другие, необходимо добавить stackless в стандарт


          1. gridem Автор
            25.10.2017 17:33

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


            1. Antervis
              26.10.2017 08:48
              +1

              Чтобы получить и те и другие, необходимо и достаточно* добавить stackless в стандарт