Как стать автором
Обновить

Девиации и разветвление личности: как лечить?

Время на прочтение18 мин
Количество просмотров5.8K

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

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

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

Часть 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#:

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

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

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

Теги:
Хабы:
Всего голосов 18: ↑8 и ↓10+3
Комментарии60

Публикации

Истории

Работа

Программист C++
116 вакансий
QT разработчик
14 вакансий

Ближайшие события