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

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

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

Часть 1: обработка ошибок без ошибок

Уж давно отшумели холивары между консерваторами, возвращающими коды ошибок из функций и модернистами, что смело кидаются эксепшенами. Поэтому не будем заострять на этом внимания – все современные языки программирования умеют метать исключения и отлавливать их. Это позволяет хоть как-то «отделять зерна от плевел», бизнес-логику от обработки ошибочных ситуаций. Но, даже воспользовавшись этим несомненным благом, остается много вопросов: а как же коды ошибок? На свалку истории? Когда кидать исключения? Где ловить? Какие типы исключений нужны? Нужны ли проверяемые исключения на уровне языка?

Давайте попробуем разобраться. Самое простое это так называемы проверяемые исключения – быстрый ответ «в топку». Эксперимент в Java показал, что «овчинка выделки не стоит», загрязнение сигнатуры метода списком возможных исключений, кроме набора лишних букв не приносит никакого ощутимого профита.

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

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

Многие современные языки отказались от практики проверяемых исключений (например, Kotlin, Scala и пр.) и изначально не включали в дизайн языка их поддержку.
Значит, на каком бы современном языке вы не остановили свой выбор, у вас, скорее всего, будут под рукой непроверяемые исключения (или далее просто исключения) и механизмы их обработки. Это инструмент, осталось понять, как им ловчее пользоваться.

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

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

Практически в любом языке уже существуют готовые исключения для этих случаев:

С++: std::invalid_argument и std::logic_error
Java: IllegalArgumentException и IllegalStateException
C#: ArgumentException и InvalidOperationException

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

Я считаю, что любой публичный метод класса (т. е. его API для общения с внешним миром) должен проверять корректность переданных ему параметров, причем делать это в первых же строчках кода. Что касается проверки состояния объекта, то его нужно проводить непосредственно перед началом работы с этим состоянием, которое должно отвечать определенным ожиданиям. Например, вызван некий метод, который работает с приватным списком и ожидает, что он не пуст – иначе запускать алгоритм не имеет смысла. Если этот список пуст значит, вероятно, не был вызван другой метод, который заполняет его данными. Эта серьезная ошибка в логике программы, так не должно было быть по замыслу автора. Бросаем исключение ошибочного состояния и пусть там «наверху» разбираются что пошло не так.

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

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

Каким же образом передать подробную информацию о сути ошибки? Можно создать иерархию исключений на каждую ситуацию, но, во-первых, это большое количество шаблонного кода, во-вторых, это всегда лень делать ????. Лучше обратим наши взоры к старым добрым кодам ошибок (все-таки не будем их выкидывать). С помощью них можно передать конкретную причину ошибки, на основе которой приложение сможет выбрать необходимое локализованное сообщение, которое уже можно показывать пользователю. Если помимо самого кода ошибки требуется какая-то дополнительная информация (например, номер строки в котором произошла ошибка парсера ) можно отнаследоваться от базового доменного исключения с кодами ошибок и добавить все необходимые поля. Лучше всего иллюстрирует эту идею std::system_error в C++. Описание этого класса немного запутанно, поэтому ниже приведу пример, того, что я обычно делаю для домена (библиотеки).

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

if (что-то пошло не так) throw нечто

Но с помощью if можно проверять не только ошибки, а ради чистоты кода хотелось бы отделить «мух от котлет». Проверки на ошибки должны выглядеть как утверждения – такие проверки приятно писать и легко выделять глазами в коде. Например, в стандартной библиотеке Kotlin есть две замечательные функции для проверки логических ошибок require, которую уместно применять к аргументам функции/метода. В случае провала условия она выбрасывает исключение IllegalArgumentException. И вторая check, которая делает тоже самое только выкидывает исключение IllegalStateException и очевидно предназначена для проверки состояния данных объекта.

Если в стандартной библиотеке нет таких функция написать их совсем не сложно, вот что я использую на C++ в своих проектах:

/**
 * @brief Throws exception T if the condition is `false`.
 * @param condition condition to check.
 * @param args arguments to pass into exception constructor.
 */
template<typename T, typename... A>
inline void check(bool condition, A&&... args)
{
    if (!condition) throw T(std::forward<A>(args)...);
}

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

checkArg(condition)
checkArgMsg(condition, message)
checkLogic(condition)
checkLogicMsg(condition, message)

Проверки реализованы в виде макросов, раскрывающихся в итоге в вышеприведённый check(), для того чтобы автоматически добавлять к сообщению имя файла, номер строки и условие проверки.

Полную реализацию можно посмотреть здесь:

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

Error.hpp:

namespace CppPack {

/**
 * @brief Error codes.
 */
enum class Error {
    Success,                   ///< The operation is success.
    Unknown,                   ///< The reason is unknown.
    UnknownContainerFormat,    ///< The archive container is not recognized.
    UnsupportedCodec,          ///< The codec is not supported.
    UnsupportedEncryption,     ///< The encryption is not supported.
    ChecksumMismatched,        ///< The checksum of data is mismatched.
    FailedCreateDecoderStream, ///< Failed to create decoder stream.
    FailedCreateEncoderStream, ///< Failed to create encoder stream.
    FailedDecode,              ///< Failed to decode encoded data.
    FailedEncode,              ///< Failed to encode data.
    InvalidSignature,          ///< Invalid signature
    IncompleteRecord,          ///< Incomplete data record.
    InconsistantData,          ///< The data is corrupted, wrong or inconsistant.
    UnsupportedVersion,        ///< The version is not supported (e.g. version of container format).
    IndexingFailed,            ///< Failed during indexing.
    FileChanged                ///< The file has been changed after reopen.
};

/**
 * @brief Represents error category for library.
 */
class CPP_PACK_API ErrorCategory: public std::error_category
{
public:
    const char *name() const noexcept override;
    std::string message(int errorCode) const override;

    /**
     * @brief Returns singleton instance of error category.
     * @return singleton instance of error category.
     */
    static const ErrorCategory& instance();
};

/**
 * @brief Makes std::error_code from Error.
 * @param error the source error code.
 * @return new instance of std::error_code.
 */
std::error_code make_error_code(Error error);

/**
 * @brief Checks for domain specific error.
 * @param condition condition to check.
 * @param error error code to create std::error_code.
 * @param message optional 'what' message.
 */
inline void checkDomain(bool condition, Error error, const char* message = "")
{
    CppMate::checkError(condition, error, ErrorCategory::instance(), message);
}

} // namespace CppPack

namespace std {
    template<> struct is_error_code_enum<CppPack::Error>: public true_type {};
} // namespace std

Error.cpp:

#include "CppPack/Error.hpp"

namespace CppPack {

const char* ErrorCategory::name() const noexcept
{
    return "CppPack";
}

std::string ErrorCategory::message(int errorCode) const
{
    switch (static_cast<Error>(errorCode)) {
    case Error::Success:
        return "Success.";
    case Error::UnknownContainerFormat:
        return "The archive container is not recognized.";
    case Error::UnsupportedCodec:
        return "The codec is not supported.";
    case Error::UnsupportedEncryption:
        return "The encryption is not supported.";
    case Error::ChecksumMismatched:
        return "The checksum of data is mismatched.";
    case Error::FailedCreateDecoderStream:
        return "Failed to create decoder stream.";
    case Error::FailedCreateEncoderStream:
        return "Failed to create encoder stream.";
    case Error::FailedDecode:
        return "Failed to decode encoded data.";
    case Error::FailedEncode:
        return "Failed to encode data.";
    case Error::InvalidSignature:
        return "Invalid signature";
    case Error::IncompleteRecord:
        return "Incomplete data record.";
    case Error::InconsistantData:
        return "The data is corrupted, wrong or inconsistant.";
    case Error::UnsupportedVersion:
        return "The version is not supported.";
    case Error::IndexingFailed:
        return "Failed during indexing.";
    case Error::FileChanged:
        return "The file has been changed after reopen.";
    default:
        break;
    }
    return "Unknown";
}

const ErrorCategory& ErrorCategory::instance()
{
    static ErrorCategory instance;
    return instance;
}

std::error_code make_error_code(Error error)
{
    return std::error_code(static_cast<int>(error), ErrorCategory::instance());
}

Полный код примера можно посмотреть здесь:

Или здесь:

Доменная проверка выглядит примерно так:

checkDomain(inflateInit2(&_stream, -MAX_WBITS) == Z_OK, Error::FailedCreateDecoderStream);

Теперь пора порассуждать, когда стоит исключения перехватывать. Первое и главное место перехвата любых исключений — это точка входа в приложение (например, функция main).

На псевдокоде это должно выглядеть так:

try {
    application.run(args)
} catch (все-все-все) {
    <сформировать подробный отчет для разработчиков о случившемся>
    <уведомить пользователя о том, что в приложение произошла ошибка>
    <предложить перезапустить приложение (возможно с последним сохраненным состоянием)>
}

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

Еще можно перехватывать исключения сторонних библиотек и подменять их своими, чтобы подчеркнуть контекст. Ну например, пусть даже не сторонняя библиотека, а стандартная библиотека: вам нужно обратиться к первому элементу списка. Можно не проверять что список элементов пуст, а положиться на то, что стандартная библиотека выбросит исключение «индекс за пределами» или что-то вроде. Но такое исключение будет оторвано от контекста. Гораздо лучше явно проверить:

check(agents.isNotEmpty, “Список агентов не должен быть пустым”)

Подытожим основные моменты:

  • Используйте исключения для обработки ошибочных ситуаций.

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

  • Используйте информацию из текстового поля исключения для логирования.

  • Передавайте специфичные для домена коды ошибок внутри исключения.

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

  • Перехватывайте исключения только там, где это действительно нужно.

Часть 2: многопоточность - в поисках священного Грааля.

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

Для начала остановимся на трех «китах», на которых все и держится:

  • Создание нового потока.

  • Механизм эксклюзивного доступа к общим данным: мьютекс.

  • Возможность ожидания некоего события потоком.

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

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

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

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

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

В восьмой Java представили встроенное распараллеливание операций над коллекциями – удобно и безопасно. Какие-то добрые гении уже все продумали и оградили от прямого общения с потоками, мьютексами и прочими страшными вещами.

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

И все же, что у нас есть по стратегии и тактике боя, как правильно готовить многопоточное блюдо чтобы кастрюля не взорвалась и больно не ударила по голове?
Адепты функциональной парадигмы, несомненно, скажут – пользуйтесь неизменяемыми объектами и будет вам счастье. Действительно с этим трудно спорить, неизменяемые объекты — это прекрасно, только… в идеальном мире, где нет низменных ограничений на объем оперативной памяти и можно бесконечно плодить новые копии коллекций для добавления очередного элемента. Но даже если абстрагироваться от конечности ресурсов: представьте, ребенку говорят, смотри, малыш, сколько замечательных игрушек мы тебе купили. Они твои. Только ими нельзя играть и нельзя их трогать – они неизменяемые, можно только смотреть глазками. Или предложат жениться на женщине, которая никогда не потеряет свою красоту, потому что она резиновая Зина, неизменяемая так сказать. Заманчиво? По мне так не очень.

В дело вступают «акторы»: эти сущности, согласно стратегии, выполняются параллельно, как и потоки, но не имеют общего состояния и могут только асинхронно обмениваться сообщениями произвольного типа. Как говориться, «нет человека – нет проблемы». Исключили общее состояние – решили проблему. Эта идея настолько понравилась создателям Scala, что они даже внесли ее реализацию прямо в язык (как, к примеру, и поддержку xml на уровне языка, а не системных библиотек).

А еще ранее, та же стратегия, только без громкого названия, была добавлена в D:

void main() {
    auto low = 0, high = 100;
    auto tid = spawn(&writer);  // Запуск нового потока
    foreach (i; low high) {
        writeln("Основной поток:», i);
        tid.send(thisTid, i);
        enforce(receiveOnly!Tid() == tid);
    }
}

void writer() {
    for (;;) {
        auto msg = receiveOnly!(Tid, int)();
        writeln(«Дочерний поток: », msg[1]);
        msg[0].send(thisTid);
    }
}


Похожий принцип лежит и в основе многопоточной модели библиотеки Qt. Там каждому объекту при рождении сохраняется идентификатор потока, в контексте которого ему посчастливилось появиться на свет. И для любых метавызовах (связывание сигнала со слотом или вызове через invokeMethod при автоматическом, используемом по умолчанию, типе связывания) происходит сравнение идентификаторов потоков вызываемой и вызывающей стороны и если они совпадают, осуществляется прямой вызов, если нет (т.е. относятся к разным потокам), вызов происходит через очередь сообщений принимающей стороны. По умолчанию, если не переопределять метод QThread::Run(), он будет «крутить» очередь сообщений и преобразовывать все входящие в вызовы соответствующих методов. Таким образом, если не нарушать принятую стратегию, методы объекта всегда вызываются только потоком его породившим. Поэтому необходимость в синхронизации данных отпадает. Можно писать код так же как в однопоточном режиме. По сути это те же «акторы», только в «профиль», немного под другим углом так сказать. Обмен сообщений скрыт внутри системы и происходит оптимизация вызовов относящихся к одному потоку (прямой вызов вместо посылки сообщения).

Казалось бы вот он Грааль. Что еще нужно? Но давайте все-таки попробуем порассуждать, может и на солнце отыщутся пятна?

Во-первых, далеко не каждая задача хорошо ляжет на абстракцию с обменом сообщениями.

Во-вторых, даже если решение вашей задачи хорошо распараллеливается с помощью обмена сообщений, вам придется поискать хороший фреймворк, который реализует модель акторов (если только вы не пишете на Scala, D или на другом языке, где уже существует поддержка акторов).

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

В-четвертых, это скорее идеологическое возражение-комментарий. Исключая общее состояние мы как бы отказываемся от многопоточности вместе с его недостатками и достоинствами. Рассуждая так, мы логически приходим к тому, что треды теперь вообще не нужны. Достаточно оставить процессы и системный механизм обмена сообщений между ними. А все почему? Потому что нам легче и привычнее мыслить «однопоточно». Мы хотим играть на «своем поле». Принимая модель акторов, мы идем на поводу у привычного мышления и выбираем путь наименьшего сопротивления.

Однако прежде, чем мы пойдем дальше, мне хотелось бы сказать пару слов о пресловутом volatile. Читая описание этого ключевого слова в разных книгах, я не перестаю удивляться фантазии авторов, которые такое сочиняют. Даже многоуважаемый Скотт Мейерс пишет какую-то дичь про «особую память». Друзья мои, ну какая к лешему «особая память»?

void worker() {
    
    // Если exit не объявлен как volatile особо ретивый компилятор
    // может проверить, что внутри while переменной quit не
    // присваивается значение и «оптимизировать» while в
    // бесконечный while(true). Ключевое слово volatile не даст этого сделать.
    // Вот и все, добавить больше нечего.
    while(!quit) {
        <трудимся>
    }
}

void stop() {
    quit = true;
}

volatile bool quit = false;

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

Призвав на помощь всю мощь объектно-ориентированной парадигмы, поразмыслив, я решил, раз уж корень зла это «общее состояние», то нужно сделать ее отдельным объектом. Пусть это будет не разрозненные переменные, рассыпанные по всему проекту, а лежащие рядом и объединенные логически данные. Следующим шагом логического построения стало осознание необходимости строго регламентировать доступ к объекту с общими данными, включая создание самого объекта (чтобы не было соблазна сохранить на него ссылку и «подшаманить» данные в обход регламента ????).

Говоря в терминах языков программирования, лучше всего такая схема ложиться на шаблонный класс, назовем его здесь, например, SharedState, где в качестве шаблона служит пользовательский класс, представляющий общее состояние (условимся, что общие данные — это пользовательский класс, а все это вместе с SharedState будем называть общее состояние). И вот тут нам на помощь приходит вся мощь функциональной парадигмы - лямбды замечательно подходят, когда речь заходит о доступе к общему состоянию. К примеру, мы хотим прочитать что-то из данных, для этого у SharedState есть метод access(), которому передается лямбда, где единственный параметр — это константная ссылка (если мы говорим о C++) на объект с общими данными. Таким образом момент доступа к общим данным для чтения четко контролируется классом SharedState. Для модификации общих данных происходит примерно тоже самое только ссылка будет не константной. Плюс в том, что SharedState «знает» что собираются делать с данными и может использовать «хитрые» мьютексы с разделением типа блокировки на чтение или запись. Однако функции SharedState не исчерпываются только чтением или модификацией общих данных, бывают случаи, когда необходимо дождаться определенного состояния общих данных и только тогда начать работать с ними. Например, в общих данных содержится некая очередь с задачами, и оперировать ей нет смысла пока она пуста. Для этих целей была придумана дополнительная сущность, внутренний класс SharedState, названная Action. Получив от SharedState экземпляр Action можно выполнять разные манипуляции с общими данными: вызвать метод when() которому передается лямбда-предикат, который определяет готовы ли общие данные для работы, при этом when() возвращает тот же Action для того, чтобы можно было строить цепочку вызовов в одной строке:

sharedState->modify().when([](auto& data) { return !data.queue.empty(); })
                     .access([](auto& data) {
    <сделать что-то с данными>
});

Использую такой подход, отпадает необходимость в создании примитивов синхронизации (мьютексы, условные переменные) вручную, что снижает риски забыть «где-нибудь» «что-нибудь» обложить блокировкой. Мы вообще перестаем мыслить в категории блокировать/разблокировать, не должны запоминать к каким данным класса какой из мьютексов относится, мы начинаем воспринимать общие данные, объединённые логически не как разрозненные переменные, а как единый отдельный класс, к которому можно получить доступ на чтение или запись или подождать, когда он достигнет нужного нам состояния. Даже чисто визуально в коде будут четко видны намерения автора, что в данном конкретном случае он решил сотворить с общими данными. Однако иллюзия отсутствия явных блокировок может привести к неприятным эффектам, если в лямбдах доступа к общих данным мы начнем делать «что-то не, то».

Строго говоря, чтобы все работало как задумано, в функциях доступа нужно делать ровно то, для чего они создавались – запросили чтение общих данных, прочитайте, верните нужное значение и на этом все. Не нужно вызывать тяжеловесные методы каких-то «левых» классов, которые вы зачем-то решили захватить в замыкание и которые будут подключаться к удаленному хосту, а потом высчитывать факториал. Лучше ограничится прямым доступом к общим данным, которые видны внутри лямбды, а всю остальную работу проводить потом. Важно не упускать из вида, что данные, которые вернул метод доступа, сохраненные в локальной переменной уже не связаны с общим состоянием т.е. общее состояние сразу же после выхода из метода доступа может быть изменено каким-то другим потоком:

Entry getEntry(const std::string& key) {
    auto entry = sharedState->access([&key](const auto& data) {
         return data.cache.contains(key) ? data[key] : Entry.Invalid;
    });
    if (entry.isValid()) { return entry; }
 
    // Здесь, к примеру, мы решаем сконструировать Entry для заданного
    // ключа и положить его в кэш. Однако следует иметь в виду, что кэш
    // между вызовами access и modify мог уже обновиться.
    sharedState->modify().access([&newEntry, &key](auto& data) {
        
        // Здесь кэш уже мог быть изменен другим тредом и key там
        // уже существует.
        if (!cache.contains(key))
            data.cache[key] = newEntry;
    });
}

Осталось обсудить особенности объекта, который инкапсулирует в себе общие данные (т.е. пользовательский класс). В моем понимании это должен быть очень простой класс и «вещь в себе», для C++, думаю, вполне допустим обычный struct, для Java POJO и т.д. В любом случае, даже если будут использоваться методы доступа к данным, класс не должен содержать сложной/скрытой/неочевидной логики и не должен «общаться с внешним миром» (т.е. хранить ссылки на другие классы и вызывать их методы). Его назначение хранить общее состояние и не более того.

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

Вот полная реализация SharedState для C++:

А вот несложная реализация Thread Pool на C++, использующая SharedState «под капотом»:

Подытожим основные моменты:

  • Постарайтесь минимизировать явное использование в своем коде таких сущностей как Thread, Mutex и ConditionVariable, а лучше обойтись вообще без них.

  • Вместо Thread лучше мыслить в категориях задач (Task). Даже полезную абстракцию пула потоков лучше оставить за скобками и не создавать/конфигурировать их вручную, а пользоваться системным пулом, просто «подкидывая» свои ему задачи, предоставив системной библиотеке разруливать все тонкости самой.

  • Выстраивайте цепочки задач, если системная библиотека поддерживает подобные изыски. Например, C#:

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

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

  • Не увлекайтесь атомарными типами – в погоне за производительностью легко наделать неочевидных ошибок, которые «всплывут» под большой нагрузкой и будут сложно воспроизводиться впоследствии.

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


  1. oleg-m1973
    22.12.2021 21:33
    +9

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


    1. akornilov Автор
      22.12.2021 21:58
      -8

      Какая-то "злобная отрыжка" из серии "писать статьи не умею, так хоть тупой комментарий оставлю" :)
      Так какие же замечания удостоились вашего благосклонного одобрения? :)


  1. amarao
    22.12.2021 21:41
    -2

    Как в C++ всё сложно. Почему бы не возвращать из функции результат, который либо успех (и возвращаемое значение), либо ошибка (и её описание)? В этом случае по сигнатуре функции мы всегда знаем, возвращает ли функция просто значение, или она может возвращать результат или ошибку.

    Утверждение, о том, что мьютекс - это базовый примитив многозадачности, мягко говоря, преувеличение. Базовый примитив многозадачности - это accure/release семантика для атомиков. А уже поверх них можно колхозить (или брать готовыми) мьютексы. А можно не колхозить, а делать lockless алгоритмы, либо оптимистические блокировки.


    1. picul
      22.12.2021 21:52

      Как в C++ всё сложно. Почему бы не возвращать из функции результат, который либо успех (и возвращаемое значение), либо ошибка (и её описание)?

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

      Утверждение, о том, что мьютекс - это базовый примитив многозадачности, мягко говоря, преувеличение. Базовый примитив многозадачности - это accure/release семантика для атомиков.

      Как с помощью атомиков перевести поток в состояния сна? Атомики безупречно важны, но и без мьютекса/ивента/критической секции тоже не обойтись.


      1. amarao
        23.12.2021 12:09

        Мьютекс реализуется через атомики. Тред спит через std::thread::yield_now или как он там в С++ называется, и это часть реализации мьютекса.


    1. akornilov Автор
      22.12.2021 22:04

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

      acquire/release, lock/unlock все это суть одно и то же, захват и освобождение ресурса. Гарантия эксклюзивного доступа в конкретный момент времени. Называют по разному мьютекс, критическая секция и т.д. Я чтобы не путаться выбрал мьютекс, как наиболее распространенный. И да, это базовая сущность :)


      1. amarao
        23.12.2021 12:11

        А в С++ '?' не завезли? В Rust, если err-часть сигнатуры совпадает, то можно просто сказать foo()?, и это будет означать, дай результат Ok из foo, а если там err, то return его.


        1. akornilov Автор
          23.12.2021 12:18

          Походу "не завезли" :) но думаю макросом можно решить вопрос :)


      1. amarao
        23.12.2021 12:13
        +1

        Да, aquire/release - это не захват ресурса, это уровни видимости в модели памяти.

        https://en.cppreference.com/w/cpp/atomic/memory_order


        1. akornilov Автор
          23.12.2021 12:26
          -1

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

          Приведенная ссылка относится к стандартной библиотеке C++, есть ли нечто подобное в других ЯП не уверен (и насколько эта абстракция полезна в многопоточном программировании тоже).


          1. amarao
            23.12.2021 15:26

            Ссылка описывает модель памяти С++. Я не плюсовик, а учу раст, но там постоянно на этот документ ссылаются.

            И эта штука куда более фундаментальна, чем просто "стандартная библиотека". Семантика доступа к атомикам (relaxed/accure/relese/sequential consitency) - это то, что различает программиста на С++ от ванильного мира уютных песочниц, типа python или go.

            // Мне странно видеть пост от человека, который, вроде бы, знает С++ (и пишет статьи про него), который про memory ordering не знает.


            1. akornilov Автор
              23.12.2021 17:23

              А в каком контексте там на этот документ ссылаются? Может это вообще не имеет никакого отношения к обсуждаемому здесь вопросу?

              Ядро системы это тоже более фундаментальная вещь чем "стандартная библиотека", только вот что нам это дает не ясно.

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


              1. amarao
                23.12.2021 18:48

                В контексте "У Rust'а нет своей модели памяти, используется модель памяти С++". И это не "ядро системы" (что бы вы под этим не подразумевали), это memory model, или, ordering model.


    1. 0xd34df00d
      22.12.2021 22:07
      +4

      Почему бы не возвращать из функции результат, который либо успех (и возвращаемое значение), либо ошибка (и её описание)?

      Потому что нет монадического синтаксического сахара (или даже костылей-частных случаев а-ля Rust) для того, чтобы можно было прямо писать код в таком стиле, будто всё всегда выполняется успешно.


      1. moncruist
        22.12.2021 22:21
        -2

        А так ли этот синтаксический сахар нужен? В C++ ничего не мешает использовать алгебраические типы данных ошибок и без него (Boost.Outcome, реализации планируемого std::expected или std::variant если уж прямо совсем лень что-то подключать).


        1. 0xd34df00d
          23.12.2021 02:27
          +2

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


        1. ncr
          23.12.2021 06:16
          +2

          Без сахара стек сам себя не раскрутит и надо после каждого вызова ручками городить if (result.error) return result;, что ничем не отличается от if (error_code != 0) goto cleanup; в C.


          Планируемый std::expected мог бы немного улучшить ситуацию, если бы его предлагали в авторском дизайне, но увы:


          Using the indirection operator for an object that does not contain a value is undefined behavior. This behavior offers maximum runtime performance.


          1. moncruist
            23.12.2021 09:17

            Без сахара стек сам себя не раскрутит и надо после каждого вызова ручками городить if (result.error) return result;, что ничем не отличается от if (error_code != 0) goto cleanup; в C.

            Справедливости ради, это работает только в самом простом случае, когда типы Result совпадают у функции и у тех, кого она вызывает. Для конвертации условного IOResult в MyResult синтаксического сахара нет и это всё также надо делать ручками.

            Планируемый std::expected мог бы немного улучшить ситуацию, если бы его предлагали в авторском дизайне, но увы:

            Using the indirection operator for an object that does not contain a value is undefined behavior. This behavior offers maximum runtime performance.

            Это стандартный дизайн типов в стандартной библиотеке C++. Например, std::optional тоже имеет UB в операторе разыменовывания, если значение не было ранее сохранено, но есть более безопасный .value(), который кидает в этом случае исключение (считай аналог unwrap() в Rust). Уверен, что и с std::expected в конце концов сделают также.


            1. 0xd34df00d
              23.12.2021 10:31
              +2

              Справедливости ради, это работает только в самом простом случае, когда типы Result совпадают у функции и у тех, кого она вызывает. Для конвертации условного IOResult в MyResult синтаксического сахара нет и это всё также надо делать ручками.

              Есть: достаточно писать код в терминах чуть более общих ограничений. Условно, не


              foo :: ... -> Either MyError MyResult

              а


              foo :: (MonadError e m, HasError MyError e) => ... -> m MyResult


            1. ncr
              23.12.2021 13:38
              +1

              Например, std::optional

              В этом и проблема — авторы пропозала не понимают его сути и натягивают сову на глобус, подавая expected как улучшенный optional.


              Вся суть expected была в unify local and centralized error handling (именно на этот момент ссылается и пропозал, что иронично): нужна локальная обработка ошибки — проверяй и обрабатывай, не нужна — просто используй. Eсли там было исключение, то оно взлетит и будет обработано выше по стеку там, где его ждут.


              В том, что предлагается к стандартизации, централизованная обработка безнадежно сломана: разыменование — это UB, а .value() — это не throw E, это throw bad_expected_access<E>, которого выше никто не ждет. Но зато похоже на optional, да.


      1. akornilov Автор
        23.12.2021 09:23

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


        1. 0xd34df00d
          23.12.2021 10:31
          +1

          Только исключения в типах не видно, в отличие от.


    1. Akon32
      23.12.2021 00:20

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

      Невелико отличие от проверяемых исключений. И синтаксис последних кажется мне несколько более удобным. Код в rust бывает прямо таки обвешан "?". В языках с исключениями "?" как бы есть по умолчанию везде.


  1. OlegZH
    22.12.2021 21:51

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

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

    Наверное, нужен какой-то компромисс между кодами ошибок и исключениями. Но тут нас пожидает другой вопрос: есть функции, которые возвращают некоторый нужный объект, а это значит, что какой-то код ошибки здесь вернуть не удастья. Если возвращается объект (ну, или, ссылка на объект, не суть важно), то этот объект может содержать три элемента: объект-источник, объект-результат и состояние операции. (Можно, даже, базу данных заполнять такиеми тройками. Это почти RDF-тройки! Можно иметь целую онтологию ошибочных состояний в виде подключаемого к проекту файла иметь. При этом, в различных ситуациях будут требоваться различные онтологии.)


    1. SergeyProkhorenko
      23.12.2021 00:32

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

      Совершенно согласен. Я даже описал такой компромисс для многострадальной (уже 103 отвергнутых предложений по улучшению) обработки ошибок в Gоlang. Конечно, моя идея тоже была отвергнута как радикальная (too drastic), как дженерики еще несколько лет назад. Смысл в том, что ошибки не должны передаваться через возвращаемое значение функции вместо нормального значения или вместе с ним, с последующей упаковкой и передачей вверх по стеку вызовов (error wrapping), а должны становиться доступными мгновенно и везде через специальный канал сообщений и через подробный лог (и то, и другое, параллельно).

      Краткое описание:

      1. Когда функция вызывается, указатель на ее экземпляр (instance) сохраняется в указанной программистом переменной для будущего доступа.

      2. Когда генерится ошибка, ее тип связывается с указателем на экземпляр функции.

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

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


      1. akornilov Автор
        23.12.2021 09:28
        +1

        Это получается что-то вроде асинхронного вызова? Типа фьючерса где можно узнать результат?

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

        Насколько удобно писать в таком стиле?


        1. SergeyProkhorenko
          23.12.2021 20:50

          "Это получается что-то вроде асинхронного вызова?"

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

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

          Это предложение о существенном изменении языка Golang. Имеющимися средствами без изменений компилятора и рантайма это реализовать, наверное, невозможно. Хотя для других языков - кто знает?

          "Насколько удобно писать в таком стиле?"

          Синтаксис минималистичен, никаких навороченных абстракций. Гораздо удобнее, чем то, что сейчас есть в Golang, а главное, появляются возможности легкого управления горутинами, что сейчас достигается большим количеством программного кода (каналы, пакет context). Посмотрите сами: https://github.com/golang/go/issues/50280


  1. OlegZH
    22.12.2021 21:58

    Например:

    Operation& Function(args...)
    {
      rezult=...
    	return new FunctionOperation(args,rezult);
    }
    ...
    op=Function(Source,Destianation);
    ...
    if (op.IsCorrect()) { ... } 
    ...


    1. Playa
      23.12.2021 01:54

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


  1. OlegZH
    22.12.2021 22:03

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


  1. eao197
    23.12.2021 09:04
    +2

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

    Касательно многопоточного программирования хотелось бы уточнить. У меня сложилось впечатление, что вы "свой" (не очень хорошо описанный) подход противопроставляете всем остальным существующим подходам (правда, из остальных в вашем тексте можно найти разве что два). Вроде того, что у вас есть собственная "серебряная пуля", которая вам нравится больше, чем все остальное. Правильно я понял смысл второй части вашей статьи?


    1. akornilov Автор
      23.12.2021 09:36
      -1

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

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


      1. eao197
        23.12.2021 11:09
        +1

        только почему свой в кавычках?

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

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

        Еще по поводу статьи. Если вы сделали упор на C++, то слишком много отсылок к другим языкам, а вот эта рекомендация:

        Выстраивайте цепочки задач, если системная библиотека поддерживает подобные изыски. Например, C#:

        К C++ вообще 1-в-1 не применима, т.к. в C++ "системная библиотека" не поддерживает подобные изыски. Ссылка на C# выглядит как троллинг.

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


        1. akornilov Автор
          23.12.2021 11:28

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

          Пожалуйста ссылку на класс из какой-нибудь системной библиотеки, который реализует схожую функциональность с SharedState.

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

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

          Если вы сделали упор на C++, то слишком много отсылок к другим языкам

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

          Ссылка на C# выглядит как троллинг.

          Да нет, и в мыслях не было троллить :) задачи есть в C++, а реализовать continueWith и waitAll можно и самому. Это просто был пример удобного API, который в C# уже есть "из коробки".

          Как и непонятно будет как task-и сочетать с вашей серебряной пулей

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


          1. eao197
            23.12.2021 11:43

            Пожалуйста ссылку на класс из какой-нибудь системной библиотеки

            Зачем?

            Не говоря уже про то, что само понятие "системная библиотека" вами никак не определено.

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

            И этого, прямо скажем, недостаточно.

            Просто так уж получилось что реализация написана на C++. Но она совсем несложная и перенести ее в другой язык вполне возможно.

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

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

            При использовании task based пересечение по разделяемым данным должно быть минимальным, его суть в том, чтобы новый task автоматически запускался на выходных данных предыдущего task-а. Соответственно, надобность в доступе к разделяемым данным (да еще мутабельным) внутри task-ов может возникнуть, но как раз на распараллеливаемость эти разделяемые данные (да еще мутабельные) скажутся отрицательно.


            1. akornilov Автор
              23.12.2021 11:58
              -1

              Зачем?

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

              Не говоря уже про то, что само понятие "системная библиотека" вами никак не определено.

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

              И этого, прямо скажем, недостаточно.

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

              Вы пытались написать как упростить работу с многопоточностью вообще, но с примерами на C++?

              Да.

              При использовании task based пересечение по разделяемым данным должно быть минимальным

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

              его суть в том, чтобы новый task автоматически запускался на выходных данных предыдущего task-а

              Если все таски будут выполняться последовательно друг за другом, то где здесь многозадачность? ;)


              1. eao197
                23.12.2021 12:13
                +2

                Затем, что вы голосовно утверждаете будто бы я изобрел "велосипед"

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

                Прямых аналогов сейчас указать не могу. Но посмотрите, например, на Mutex из Rust-а: там объединение mutex-а и защищаемого им объекта T происходит на уровне типа Mutex.

                Еще ваш подход заставляет вспомнить RCU -- Read-Copy-Update (хотя им и не является).

                Однако для меня не очевидно чего именно не достаточно.

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

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

                В своем примере вы показали что-то подобное, но в light-варианте, когда исправить ситуацию просто. Но вот в общем случае вам потребуется делать либо повторы, либо откаты.

                У тупых mutex-ов, при всех их недостатков, такой проблемы нет. Если вы mutex захватили, то результаты ваших вычислений устареть не могут, т.к. исходные данные никто не может модифицировать.

                Отдельный вопрос -- это как быть с вашим подходом в случае, если требуется синхронно модифицировать несколько разных StaredState объектов.

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

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

                Если все таски будут выполняться последовательно друг за другом, то где здесь многозадачность? ;)

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


                1. akornilov Автор
                  23.12.2021 12:49

                  Но посмотрите, например, на Mutex из Rust-а: там объединение mutex-а и защищаемого им объекта T происходит на уровне типа Mutex.

                  Спасибо, хороший пример. Действительно здравая идея объединить mutex и защищаемые данные. Только еще condition variable не прикрутили :)

                  У тупых mutex-ов, при всех их недостатков, такой проблемы нет. Если вы
                  mutex захватили, то результаты ваших вычислений устареть не могут, т.к.
                  исходные данные никто не может модифицировать.

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

                  Отдельный вопрос -- это как быть с вашим подходом в случае, если
                  требуется синхронно модифицировать несколько разных StaredState
                  объектов.

                  Вложенные SharedState.

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

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

                  Кто это сказал?

                  Я, но это была шутка :) ваша мысль мне понятна.


                  1. eao197
                    23.12.2021 12:56

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

                    Как я понимаю, ваш SharedState дает пользователю две полезные вещи:

                    1. Объединение защищаемых данных и объекта синхронизации "под одной крышей". Что защищает от того, что какую-то часть данных мы модифицируем не захватив объект синхронизации.

                    2. Упрощается использование разделяемых данных в режиме read-calculate-modify.

                    Причем в своей статье вы акцентируете штуку номер 2. Но если вы ее акцентируете, то нужно бы и сказать, что за этим последует. А если не обращать на нее внимания, используя SharedState как простой mutex, то значит этой полезной штуки как бы и нет.

                    Вложенные SharedState.

                    Не понятно. Вы что-то такое подразумеваете:

                    struct DataA {
                      ...;
                    };
                    struct DataB {
                      ...
                      SharedState<DataA> a_;
                    };
                    SharedState<DataB> b;

                    И работать с DataB::a_ можно только когда захвачен b?


                    1. akornilov Автор
                      23.12.2021 14:22

                      Как я понимаю, ваш SharedState дает пользователю две полезные вещи

                      Еще можно дожидаться определенного состояния данных и нотифицировать об этом.

                      Помимо этого мы не "лочим" данные, а явно указываем что мы собираемся с ними делать: читать, модифицировать, ждать или изменить и оповестить об этом.

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

                      Это скорее концептуальные преимущества, а не технические.

                      Но пользователь должен сам решать как он будет работать с данными в режиме read-calculate-modify, когда блоки кода разделены (со всеми вытекающими), либо все сделать в modify.

                      Не понятно. Вы что-то такое подразумеваете

                      Да, только внутри DataB нужно, наверное, поместить хотя бы еще один SharedState иначе непонятно зачем такой огород городить :)
                      Может вы объясните какой юзкейз мы пытаемся покрыть в данном случае? Тогда что-нибудь поизящнее попробуем выдумать :)


                      1. eao197
                        23.12.2021 14:29

                        Еще можно дожидаться определенного состояния данных и нотифицировать об этом.

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

                        Кстати говоря, ваш SharedState всегда сопровождается condition_variable (event)?

                        Помимо этого мы не "лочим" данные

                        Ну да, ну да. Это не лок. Выглядит как лок, работает как лок, но не лок.

                        Может вы объясните какой юзкейз мы пытаемся покрыть в данном случае?

                        Одной синхронной операцией модифицировать сразу несколько независимых друг от друга SharedState. Как раз то, для чего в C++ную stdlib добавили std::lock.


                      1. akornilov Автор
                        23.12.2021 14:47

                        Так что это не столько достоинство вашего подхода, сколько последствия его особенностей.

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

                        Вон в Java прямо в базовый Object добавили wait/notify/notifyAll :)

                        Кстати говоря, ваш SharedState всегда сопровождается condition_variable (event)?

                        Да.

                        Ну да, ну да. Это не лок. Выглядит как лок, работает как лок, но не лок.

                        Хорошо давайте посмотрим на это так - вы смотрите код и видите "залочить мютекс". Какие данные он защищает? Когда его можно разлочить?

                        Или вы видите в коде "читаю данные". И смотрите на небольшой блок кода в котором они читаются.

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

                        Одной синхронной операцией модифицировать сразу несколько независимых друг от друга SharedState.

                        Это я понял, мне непонятно зачем может понадобиться одновременно лочить независимые SharedState? Цель то какая? Выглядит как какой-то костыль при кривом дизайне системы.

                        Может нужен просто еще один SharedState в котором будут собираться данные из остальных?

                        Либо вариант, который вы уже предложили.

                        Либо внутри блока доступа к одному SharedState открыть остальные на чтение или запись.


                      1. eao197
                        23.12.2021 15:01
                        +1

                        В моем понимании это все-такие достоинство

                        Странно было бы, если бы вы думали иначе.

                        Или вы видите в коде "читаю данные". 

                        Я вижу "залочить данные для чтения ограничив длительность лока вот этим скоупом". Принципиально здесь "залочить". То, что данные затем читаются или модифицируются -- это уже второй вопрос.

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

                        мне непонятно зачем может понадобиться одновременно лочить независимые SharedState? 

                        Ну раз непонятно, значит ваш SharedState для таких сценариев не подойдет.

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

                        Если кэши защищаются обычными мутексами, то мы можем это сделать через std::lock.

                        Как это сделать через ваши SharedState -- непонятно.

                        Выглядит как какой-то костыль при кривом дизайне системы.

                        Если вы чего-то не понимаете или с чем-то не сталкивались, то это не значит, что дизайн кривой.

                        У меня есть устойчивое подозрение, что вы просто не осознаете, зачем нужен std::lock и почему он был добавлен.

                        Либо вариант, который вы уже предложили.

                        Я ничего не предлагал. Просто пытаюсь выяснить что следовало бы сделать в вашем подходе.


                      1. akornilov Автор
                        23.12.2021 15:25

                        Странно было бы, если бы вы думали иначе.

                        Я просто привел аргументы в пользу своей точки зрения.

                        Я вижу "залочить данные для чтения ограничив длительность лока вот этим скоупом". Принципиально здесь "залочить".

                        Вы видите потому что я вам сказал что они лочатся :)

                        Я то как раз предлагал перестать мыслить в рамках лочить-разлочить.

                        Но, конечно, ни в коем случае никому не навязываю свое видение :)

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

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

                        Для меня, как для стороннего наблюдателя, это не так важно. Весь ваш
                        фокус в том, что вы сами создаете lock_guard и удаляете его, заставляя
                        пользователя оформить работу с залоченными данными в лямбде. В этом
                        суть.

                        Вы правильно уловили суть реализации SharedState, но на мой взгляд сама идея важнее и совсем не принципиально что там за кулисами лочится или не лочится, главное что SharedState выполняет свой контракт публичного API.

                        А то, что вы на эту суть еще несколько уровней концептуальных смыслов пытаетесь навесить -- это ваши личные проблемы.

                        Ну не нужно горячиться :) Какие личные проблемы? Вы о чем вообще? Вы что психоаналитик? :)

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

                        Ну раз непонятно, значит ваш SharedState для таких сценариев не подойдет.

                        Зачем же так категорично? :) Может и подойдет.

                        Если вы чего-то не понимаете или с чем-то не сталкивались, то это не значит, что дизайн кривой.

                        Да вы успокойтесь, мы же не какой-то конкретный дизайн обсуждаем.

                        У меня есть устойчивое подозрение, что вы просто не осознаете, зачем нужен std::lock и почему он был добавлен.

                        Напрасно подозреваете, там все очевидно :)

                        Я ничего не предлагал. Просто пытаюсь выяснить что следовало бы сделать в вашем подходе.

                        Ну вы же привели кусок кода? Так вот вы все правильно поняли это и есть одно из решений.


                      1. eao197
                        23.12.2021 15:33
                        +1

                        Вы видите потому что я вам сказал что они лочатся

                        Вы это сказали еще в своей статье.

                        Вы правильно уловили суть реализации SharedState, но на мой взгляд сама идея важнее и совсем не принципиально что там за кулисами лочится или не лочится, главное что SharedState выполняет свой контракт публичного API.

                        Ахринеть, дайте два.

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

                        Именно это и должен понимать потенциальный пользователь выбирая подобный подход.

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

                        Да вы успокойтесь, мы же не какой-то конкретный дизайн обсуждаем.

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

                        Ну вы же привели кусок кода?

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

                        Так вот вы все правильно поняли это и есть одно из решений.

                        Это не решение. И мне кажется, что от слова совсем.


                      1. akornilov Автор
                        23.12.2021 18:11

                        Ахринеть, дайте два.

                        Увольте пожалуйста от такой "камасутры" :)

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

                        Именно это и должен понимать потенциальный пользователь выбирая подобный подход.

                        Совершенно верно.

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

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

                        Выбор за разработчиком.

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

                        Во-первых, нас никто не вынуждает.

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

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

                        Так не успел я :) только идею озвучил, а вы уже код сразу выкатили в следующем посте :)

                        Это не решение. И мне кажется, что от слова совсем.

                        А... ну раз кажется тогда, конечно :)


                      1. eao197
                        23.12.2021 15:04
                        +1

                        > Кстати говоря, ваш SharedState всегда сопровождается condition_variable (event)?

                        Да.

                        То, что у вас всегда внутри SharedState живет condition_variable, -- это еще один недостаток вашего подхода. Т.к. нарушается принцип не платить за то, что не используется.

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


                      1. akornilov Автор
                        23.12.2021 17:54
                        -1

                        То, что у вас всегда внутри SharedState живет condition_variable, -- это
                        еще один недостаток вашего подхода. Т.к. нарушается принцип не платить
                        за то, что не используется.

                        А почему вы заранее решили что это не будет использоваться? Да еще сразу же в недостатки записали :)

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

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


  1. K40
    23.12.2021 09:43

    Для синхронизации доступа к общим данным вместо мьютекса лучше использовать критическую секцию. Критическая секция - user space обьект, не требует переключения в контекст ядра, в отличии от мьютекса.


    1. akornilov Автор
      23.12.2021 09:50
      +1

      Термин "Критическая секция" это, если не ошибаюсь, изобретение Microsoft. По своей сути это тот же мьютекс. Что касается имплементации то название "мьютекс" (mutually exclusive access to shared data structures in a multi-threaded environment, одно из определений) совершенно не подразумевает вызовы ядра, его спокойно можно реализовать в user space.

      Например, в JVM и CLI используется еще один термин - Monitor, реализовываться может через тот же "thin lock".


      1. K36
        23.12.2021 13:17
        -1

        Под мьютексом обычно понимется обьект ядра MUTEX, он разделяем между процессами.

        Критическая секция это обьект пользовательского уровня и работает в одном процессе.


        1. akornilov Автор
          23.12.2021 14:00

          Я бы не стал утверждать что это "обычно", это разве что в Windows так :)


  1. XopHeT
    23.12.2021 14:01
    -2

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


    1. Devoter
      25.12.2021 04:14

      За то, что решения подаются, как серебрянная пуля без недостатков/ограничений области применимости, что, очевидно, не так. Да и сами по себе решения не являются изобретениям (подобное и ранее встречал, и реализовывал в том или ином виде - тоже).


      1. XopHeT
        25.12.2021 07:32

        Ну и что? Встречал, реализовывал но не описывал ведь?

        Человек описал, дал посмотреть на код. В чем проблема то?

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

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