Агенты добрались до продакшена
Про AI-агентов сейчас не говорит только ленивый. Обычно это выглядит так: «мы прикрутили LLM, дали ей пару тулов, теперь она сама все делает».
А потом это пытаются выкатить в прод, и внезапно оказывается, что агент — это не чатик с промптом, а распределенная система с дорогущими GPU, долгими запросами, динамическим графом исполнения, ретраями, которые нельзя просто так делать, и метриками качества, которые надо в принципе уметь измерять и делать это каждый день.
На недавнем deep tech night прошел показательный доклад «От поиска к агентам: путь Алисы», который я глянул.
Круто, когда говорят не про то, как ллм и агенты делают космолеты, а какие вызовы несет в себе их использование и как сделать работу с языковыми моделями надежной, отслеживаемой и универсальной в рамках большой компании.
Изыскания ребят привели их к AGTS - агентской транспортной системе.
Если коротко, это сервис-медиатор между агентами, тулами и моделями. Все общаются не напрямую друг с другом, а через AGTS.
И вот тут начинается нормальный backend:
🔸 агенты и тулы могут быть написаны на разных языках — Python, C++, Java/Kotlin, Go;
🔸 можно переиспользовать одну инфраструктуру для прода и приемки качества;
🔸 есть «железные пользователи» — агенты, которые имитируют пользовательское поведение;
🔸 LLM-as-a-judge - модели оценки качества "услуг агентов" - тоже живут как агенты на той же платформе;
🔸 AGTS кэширует запросы и ответы к моделям и тулам, чтобы при сбое не перезапускать всю дорогую LLM-мясорубку с нуля.
Последний пункт особенно важен.
Агент упал не в самом начале, а где-нибудь на пятой минуте размышлений? Не надо снова жечь GPU и молиться. Система делает fast-forward по закэшированным данным до точки падения и продолжает оттуда.
Пожалуй один из главных поинтов доклада: harness > model.
То есть качество можно серьезно растить не только заменой модели на новую большую и дорогую, а обвязкой вокруг нее. И эти обвязки централизовано добавляются и управляются в AGTS.
Вот некоторые достижения харнесса:
🔥 +9 п.п. на BrowseComp за счет фильтрации поисковой выдачи перед подачей в модель;
🔥 +8,5 п.п. на GAIA после добавления работы с файлами, мультимодальности и OCR;
🔥 стоимость запроса снизили в 6,6 раза;
🔥 latency p90 ужали с 30 минут до 2,5 минут;
🔥 GPU на проде стало нужно в 5 раз меньше.
В общем, очень ценный доклад именно с точки зрения раскрытия внутрянки такой сложной системы. Без AI мы уже никуда, но включение человека всё еще необходимо, чтобы заставить его работать с профитом и предсказуемостью на больших масштабах.
Про AI-агентов сейчас не говорит только ленивый. Обычно это выглядит так: «мы прикрутили LLM, дали ей пару тулов, теперь она сама все делает».
А потом это пытаются выкатить в прод, и внезапно оказывается, что агент — это не чатик с промптом, а распределенная система с дорогущими GPU, долгими запросами, динамическим графом исполнения, ретраями, которые нельзя просто так делать, и метриками качества, которые надо в принципе уметь измерять и делать это каждый день.
На недавнем deep tech night прошел показательный доклад «От поиска к агентам: путь Алисы», который я глянул.
Круто, когда говорят не про то, как ллм и агенты делают космолеты, а какие вызовы несет в себе их использование и как сделать работу с языковыми моделями надежной, отслеживаемой и универсальной в рамках большой компании.
Изыскания ребят привели их к AGTS - агентской транспортной системе.
Если коротко, это сервис-медиатор между агентами, тулами и моделями. Все общаются не напрямую друг с другом, а через AGTS.
И вот тут начинается нормальный backend:
🔸 агенты и тулы могут быть написаны на разных языках — Python, C++, Java/Kotlin, Go;
🔸 можно переиспользовать одну инфраструктуру для прода и приемки качества;
🔸 есть «железные пользователи» — агенты, которые имитируют пользовательское поведение;
🔸 LLM-as-a-judge - модели оценки качества "услуг агентов" - тоже живут как агенты на той же платформе;
🔸 AGTS кэширует запросы и ответы к моделям и тулам, чтобы при сбое не перезапускать всю дорогую LLM-мясорубку с нуля.
Последний пункт особенно важен.
Агент упал не в самом начале, а где-нибудь на пятой минуте размышлений? Не надо снова жечь GPU и молиться. Система делает fast-forward по закэшированным данным до точки падения и продолжает оттуда.
Пожалуй один из главных поинтов доклада: harness > model.
То есть качество можно серьезно растить не только заменой модели на новую большую и дорогую, а обвязкой вокруг нее. И эти обвязки централизовано добавляются и управляются в AGTS.
Вот некоторые достижения харнесса:
🔥 +9 п.п. на BrowseComp за счет фильтрации поисковой выдачи перед подачей в модель;
🔥 +8,5 п.п. на GAIA после добавления работы с файлами, мультимодальности и OCR;
🔥 стоимость запроса снизили в 6,6 раза;
🔥 latency p90 ужали с 30 минут до 2,5 минут;
🔥 GPU на проде стало нужно в 5 раз меньше.
В общем, очень ценный доклад именно с точки зрения раскрытия внутрянки такой сложной системы. Без AI мы уже никуда, но включение человека всё еще необходимо, чтобы заставить его работать с профитом и предсказуемостью на больших масштабах.
🔥8❤5👍3
Identity объекта
#опытным
На пространстве интернетов мне попался шортс, где довольно опытный в С++ чувак рассказывает про некий "трюк" в контексте такой задачи:
трюк в том, что если вам нужен идентификатор объекта, то вы можете использовать его уникальный адрес. Приводился такой пример:
Выглядит с первого взгляда круто. Действительно, у каждого объекта свой уникальный адрес. Два объекта в программе не могут занимать одну ячейку памяти. Получается, что адрес можно использовать как id и это "просто работает".
Только вот сходу можно назвать кучу проблем, к которому ведет этот подход:
🔞 id объекта меняется, если он перемещается
🔞 id объекта меняется, если он копируется(должны ли такие объекты копироваться и перемещать вообще - это вопрос, но проблемы остаются)
🔞 такой айди тесно связан с временем жизни объекта и не существует о отрыве объекта в программе
🔞 id переиспользуется другими объектами и на стеке, и на куче. Как только разрушился объект, новый объект может получить тот же самый id. И как различить объекты в таком случае непонятно.
🔞 id нельзя безопасно использовать как внешний идентификатор, аля передавать в другие сервисы или сохранять в хранилища.
Я понимаю, что скорее всего на интервью, где-то в отдельных модулях приложения или в каких-то игрушечных проектах такой подход может сработать. Но в каких-то больших распределенных системах вряд ли. Хотя мир большой и вы в комментариях расскажете, где такой подход применяется на практике.
Если хочется что-то простое и на коленке сделанное, то можно использовать приватный статический(потенциально атомарный) счетчик внутри класса и при создании объекта инкрементировать его:
При копировании, мувах Id не меняется(но это можно исправить, если надо), а чтобы новый объект заимел тот же айди нужно 100500 лет.
Однако при рестарте приложения, счетчик все еще обнуляется и будут дубли. Если вам критично, чтобы не существовало двух объектов с одинаковым id, можно использовать UUIDv7. Там 2¹²² вариантов айдишников, вероятностью совпадения при использовании uuid обычно пренебрегают.
Have your own identity. Stay cool.
#design
#опытным
На пространстве интернетов мне попался шортс, где довольно опытный в С++ чувак рассказывает про некий "трюк" в контексте такой задачи:
По сути, нужно построить центральный сервис, который отправляет обновления цен на станции, но новые желаемые цены могут поступать, пока старые обновления ещё в пути. Вас волнует только то, чтобы каждая станция в конечном итоге получила самую последнюю желаемую цену, при этом подтверждения (acknowledgements) должны приходить по порядку, а лишние обновления следует избегать. Таким образом, обновления, которые вы отправляете, могут прибывать на станцию не по порядку, но подтверждения, которые вы получаете обратно, всегда должны быть правильно упорядочены в том порядке, в котором станция применила обновление цены. И, по сути, спроектировать класс так, чтобы минимизировать время и тд.
трюк в том, что если вам нужен идентификатор объекта, то вы можете использовать его уникальный адрес. Приводился такой пример:
struct StationClient {
[[nodiscard]] uintptr_t ClientId() const {
return reinterpret_cast<uintptr_t>(this);
}
};Выглядит с первого взгляда круто. Действительно, у каждого объекта свой уникальный адрес. Два объекта в программе не могут занимать одну ячейку памяти. Получается, что адрес можно использовать как id и это "просто работает".
Только вот сходу можно назвать кучу проблем, к которому ведет этот подход:
🔞 id объекта меняется, если он перемещается
🔞 id объекта меняется, если он копируется(должны ли такие объекты копироваться и перемещать вообще - это вопрос, но проблемы остаются)
🔞 такой айди тесно связан с временем жизни объекта и не существует о отрыве объекта в программе
🔞 id переиспользуется другими объектами и на стеке, и на куче. Как только разрушился объект, новый объект может получить тот же самый id. И как различить объекты в таком случае непонятно.
🔞 id нельзя безопасно использовать как внешний идентификатор, аля передавать в другие сервисы или сохранять в хранилища.
Я понимаю, что скорее всего на интервью, где-то в отдельных модулях приложения или в каких-то игрушечных проектах такой подход может сработать. Но в каких-то больших распределенных системах вряд ли. Хотя мир большой и вы в комментариях расскажете, где такой подход применяется на практике.
Если хочется что-то простое и на коленке сделанное, то можно использовать приватный статический(потенциально атомарный) счетчик внутри класса и при создании объекта инкрементировать его:
struct StationClient {
StationClient() {
id = Id.fetch_add(1);
}
[[nodiscard]] std::size_t ClientId() const {
return id;
}
private:
static inline std::atomic<std::size_t> Id = 0;
int id;
};При копировании, мувах Id не меняется(но это можно исправить, если надо), а чтобы новый объект заимел тот же айди нужно 100500 лет.
Однако при рестарте приложения, счетчик все еще обнуляется и будут дубли. Если вам критично, чтобы не существовало двух объектов с одинаковым id, можно использовать UUIDv7. Там 2¹²² вариантов айдишников, вероятностью совпадения при использовании uuid обычно пренебрегают.
Have your own identity. Stay cool.
#design
❤15👍9❤🔥3🔥2
std::spanstream
#опытным
Радостная весть для всех, кто пользуется iostreams! В C++23 добавлены новые классы, позволяющие работать с массивами символов как c потоками, без лишних копирований и динамических выделений памяти.
Пусть у нас есть какой-то сырой буфер данных и мы хотим безопасно считать его с удобным и привычным iostream'овским апи, при этом не делая никаких копий. Для этого ввели std::ispanstream. Он принимает С++20 std::span в качестве буфера и сам не производит никаких аллокаций и копирований:
Для чтения данных фиксированного размера std::ispanstream фактически заменяет std::istringstream, потому что гарантирует отсутствие аллокаций.
В общем, если вы как-то получили доступ к непрерывному сырому участку памяти и вам нужен удобный доступ к парсингу -
Можно даже парсить данные из файла, если использовать mmap и замаппить данные файла в виртуальное пространство процесса. Тогда можно получить std::span на данные этого файла и создать std::ispanstream, парсящий их.
Правда, если вы уже используете mmap, то скорее всего хотите какое-то производительное решение. А парсер стандартных потоков ввода-вывода мягко говоря не самый быстрый. Тот же std::from_chars будет быстрее и не менее стандартным.
Don't allocate. Stay cool.
#cpp23
#опытным
Радостная весть для всех, кто пользуется iostreams! В C++23 добавлены новые классы, позволяющие работать с массивами символов как c потоками, без лишних копирований и динамических выделений памяти.
Пусть у нас есть какой-то сырой буфер данных и мы хотим безопасно считать его с удобным и привычным iostream'овским апи, при этом не делая никаких копий. Для этого ввели std::ispanstream. Он принимает С++20 std::span в качестве буфера и сам не производит никаких аллокаций и копирований:
char input[] = "10 20 30";
std::ispanstream is{span<char>{input}};
int i;
is >> i;
ASSERT_EQUAL(10,i);
is >> i;
ASSERT_EQUAL(20,i);
is >> i;
ASSERT_EQUAL(30,i);
is >> i;
std::ispanstream может работать с любыми символами, расположенными последовательно в памяти (другими словами, со всем, от чего можно сконструировать std::span).Для чтения данных фиксированного размера std::ispanstream фактически заменяет std::istringstream, потому что гарантирует отсутствие аллокаций.
В общем, если вы как-то получили доступ к непрерывному сырому участку памяти и вам нужен удобный доступ к парсингу -
std::ispanstream вам подойдет.Можно даже парсить данные из файла, если использовать mmap и замаппить данные файла в виртуальное пространство процесса. Тогда можно получить std::span на данные этого файла и создать std::ispanstream, парсящий их.
Правда, если вы уже используете mmap, то скорее всего хотите какое-то производительное решение. А парсер стандартных потоков ввода-вывода мягко говоря не самый быстрый. Тот же std::from_chars будет быстрее и не менее стандартным.
Don't allocate. Stay cool.
#cpp23
❤16👍6🔥4🤣2
Stacktrace
#опытным
Одна из проблема исключений - непонятно, откуда оно прилетело. Ну да, по сообщению об ошибке и типу исключения обычно можно отследить нужное место в программе. Но как исполнение дошло до этой точки? А это ведь самое важное - понимание, при каком сценарии наступила исключительная ситуация.
Когда мы дебажим программу, мы видим, что привело к точке останова. В любом дебаггере есть команда аля backtrace, которая выводит визуализированный стек вызовов. Например так:
В данном случае мы видим, что ошибка произошла в функции apply_discount, которую вызвал калькулятор цены при обработке запроса платежа. То есть в данном случае мы видим четкий сценарий возникновения ошибки
И вот было бы круто видеть такую картину каждый раз, когда в catch залетает исключение. Да и в принципе в любой ситуации, когда нам интересна информация из трейса.
С++23 предоставил нам возможность конструировать кастомные исключения с описанием стека вызова внутри. Делается это через класс std::stacktrace. Работает он очень просто:
С помощью статического метода std::stacktrace::current() можно получить стек вызовов в моменте использования метода. Для типа std::stacktrace даже перегружен оператор вывода в поток, поэтому вы спокойно можете вывести сырой трейс на консоль. Вывод может быть примерно таким:
Вот примерчик, чтобы поиграться.
Теперь легко сделать свое исключение со стектрейсом и прочими прелестями.
Трейс захватываем в конструкторе, собственно ровно в том месте, откуда и летит исключение. И скипаем один фрейм сверху, чтобы не учитывать конструктор, с помощью параметра метода std::stacktrace::current. В целом, использование очень похоже на std::source_location.
Know your origin. Stay cool.
#cpp23
#опытным
Одна из проблема исключений - непонятно, откуда оно прилетело. Ну да, по сообщению об ошибке и типу исключения обычно можно отследить нужное место в программе. Но как исполнение дошло до этой точки? А это ведь самое важное - понимание, при каком сценарии наступила исключительная ситуация.
Когда мы дебажим программу, мы видим, что привело к точке останова. В любом дебаггере есть команда аля backtrace, которая выводит визуализированный стек вызовов. Например так:
(gdb) bt
#0 0x0000555555556a2a in apply_discount (order_id=1001, total=250.5) at discount.c:45
#1 0x0000555555556b10 in calculate_final_price (order=0x7fffffffde40) at order_engine.c:89
#2 0x0000555555556c45 in process_payment (order=0x7fffffffde40, method=0x555555758020 "credit") at payment.c:120
#3 0x0000555555556d8a in handle_order_request (request=0x7fffffffdf00) at api_handler.c:56
#4 0x0000555555556e20 in main () at server.c:32
В данном случае мы видим, что ошибка произошла в функции apply_discount, которую вызвал калькулятор цены при обработке запроса платежа. То есть в данном случае мы видим четкий сценарий возникновения ошибки
И вот было бы круто видеть такую картину каждый раз, когда в catch залетает исключение. Да и в принципе в любой ситуации, когда нам интересна информация из трейса.
С++23 предоставил нам возможность конструировать кастомные исключения с описанием стека вызова внутри. Делается это через класс std::stacktrace. Работает он очень просто:
#include <iostream>
#include <stacktrace>
std::stacktrace bar() {
auto trace = std::stacktrace::current();
return trace;
}
std::stacktrace foo() {
return bar();
}
int main() {
auto trace = foo();
std::cout << trace << '\n';
}
С помощью статического метода std::stacktrace::current() можно получить стек вызовов в моменте использования метода. Для типа std::stacktrace даже перегружен оператор вывода в поток, поэтому вы спокойно можете вывести сырой трейс на консоль. Вывод может быть примерно таким:
0# bar() at /app/example.cpp:5 [0x4033ee]
1# foo() at /app/example.cpp:10 [0x40340d]
2# main at /app/example.cpp:14 [0x403428]
3# <unknown> [0x74eed1a2a1c9]
4# libc_start_main [0x74eed1a2a28a]
5# start [0x403304]
Вот примерчик, чтобы поиграться.
Теперь легко сделать свое исключение со стектрейсом и прочими прелестями.
class TracedException : public std::exception {
std::string message;
std::stacktrace trace;
mutable std::string full_what; // cached formatted output
public:
explicit TracedException(std::string msg)
: message_(std::move(msg))
// Capture the stack RIGHT when the exception is created
// skip = 1 means skip this constructor from the trace
, trace_(std::stacktrace::current(/skip=/1))
{}
const std::stacktrace& trace() const noexcept {
return trace_;
}
const char* what() const noexcept override {
if (full_what_.empty()) {
full_what_ = message_ + "\n\n--- Stack Trace ---\n"
+ std::to_string(trace_);
}
return full_what_.c_str();
}
};Трейс захватываем в конструкторе, собственно ровно в том месте, откуда и летит исключение. И скипаем один фрейм сверху, чтобы не учитывать конструктор, с помощью параметра метода std::stacktrace::current. В целом, использование очень похоже на std::source_location.
Know your origin. Stay cool.
#cpp23
❤13👍6🔥6😁2
Stacktrace. Tips
#опытным
Чтобы полноценно работать со стандартными трейсами, нужно знать несколько вещей:
✅ Чтобы фича заработала с новыми версиями gcc и clang, вам нужно залинковать либу
✅ Собирайте код с дебаг символами. Если хотите видеть трейсы и получать из них полезную информацию об именах функций и файлов, вместо прочтения чистых адресов, то надо собирать в дебаге. Итого полная команда сборки:
✅ Да, вы можете включать оптимизации компилятора и добавить в бинарник дебаг символы одновременно. Оптимизации конечно могу сильно преобразовывать потенциальные трейсы за счет инлайнинга, но без оптимизаций мы не можем.
✅ Дебаг символы обычно занимают довольно внушительный объем и не всегда хочется их тянуть везде вместе с бинарем. В таких случаях можно использовать утилиту
Ну или можно собирать код с флагом -g1, который включает только самую необходимую отладочную информацию
✅ Мы рассматривали стектрейсы в разрезе исключений, но их конечно же можно использовать и отдельно от них.
Однако стоит помнить, что получение трейса - не бесплатная штука. Логи-то не бесплатный, а проход по стеку вызовов и сбор информации из дебаг символов - очень затратная штука. Не стоит использовать трейсы в нагруженных частях программы. Только в ошибочных путях программы в некритичном коде.
✅ Сам класс стектрейсов предоставляет много гибкости. Можно отдельно проходиться по каждому фрейму и в принципе контролировать количество захватываемых фреймов:
Трейсы скорее всего рано или поздно заедут в каком-то виде в стандарт, чтобы их можно было использовать со стандартными исключениями и конктейнерами. Поэтому полезно же сейчас понимать, как это работает под капотом и как этим правильно пользоваться.
Know your origin. Stay cool.
#cpp23
#опытным
Чтобы полноценно работать со стандартными трейсами, нужно знать несколько вещей:
✅ Чтобы фича заработала с новыми версиями gcc и clang, вам нужно залинковать либу
-lstdc++exp. В туториалах обычно используется линковка с -lstdc++_libbacktrace, которая не работает на свежих версиях компиляторов✅ Собирайте код с дебаг символами. Если хотите видеть трейсы и получать из них полезную информацию об именах функций и файлов, вместо прочтения чистых адресов, то надо собирать в дебаге. Итого полная команда сборки:
g++ -std=c++23 -g -lstdc++exp -O2 main.cpp -o app
✅ Да, вы можете включать оптимизации компилятора и добавить в бинарник дебаг символы одновременно. Оптимизации конечно могу сильно преобразовывать потенциальные трейсы за счет инлайнинга, но без оптимизаций мы не можем.
✅ Дебаг символы обычно занимают довольно внушительный объем и не всегда хочется их тянуть везде вместе с бинарем. В таких случаях можно использовать утилиту
strip и вырезать из бинаря отдельный файл с дебаг символами. При обнаружении проблемы с программой можно в дебаггере подгрузить файл с символами и наслаждаться прелестями человекочитаемой отладки.Ну или можно собирать код с флагом -g1, который включает только самую необходимую отладочную информацию
✅ Мы рассматривали стектрейсы в разрезе исключений, но их конечно же можно использовать и отдельно от них.
int foo(int a, int b) {
if (b == 0) {
LOG_ERROR << "Second operand is zero. Trace:\n" << std::stacktrace::current();
}
return a / b;
}Однако стоит помнить, что получение трейса - не бесплатная штука. Логи-то не бесплатный, а проход по стеку вызовов и сбор информации из дебаг символов - очень затратная штука. Не стоит использовать трейсы в нагруженных частях программы. Только в ошибочных путях программы в некритичном коде.
✅ Сам класс стектрейсов предоставляет много гибкости. Можно отдельно проходиться по каждому фрейму и в принципе контролировать количество захватываемых фреймов:
void inspectTrace(const std::stacktrace& trace) {
for (const auto& frame : trace) {
std::cout << "Function : " << frame.description()
<< '\n'
<< "Source file : " << frame.source_file()
<< '\n'
<< "Line : " << frame.source_line()
<< '\n'
<< "---\n";
}
}
// Capture at most 10 frames except of the last one.
auto trace = std::stacktrace::current(/skip=/1, /max_depth=/10);Трейсы скорее всего рано или поздно заедут в каком-то виде в стандарт, чтобы их можно было использовать со стандартными исключениями и конктейнерами. Поэтому полезно же сейчас понимать, как это работает под капотом и как этим правильно пользоваться.
Know your origin. Stay cool.
#cpp23
🔥15❤8👍4
Откуда spurious wakeup на кондваре?
#опытным
У кондваров есть метод std::condition_variable::wait, который по документации блокирует исполнение потока до тех пор, пока не придет уведомление или не произойдет тн spurious wakeup(ложное пробуждение). Поэтому нам говорят, что нужно ставить wait в цикл while:
или использовать перегрузку, передавая в кондвар предикат:
Но что это за такие ложные пробуждения, из-за которых на нужно крутиться в цикле?
По сути, ложными пробуждениями можно назвать любую ситуацию, когда поток просыпается и условие предиката неудовлетворяется. Со стороны кажется, что поток просто в рандомный момент сам проснулся, когда условия еще не создались для него. Но это не совсем так. Давайте покопаемся в причинах.
👉🏿 Поток может проснуться без уведомления. Звучит как баг или признаки рождения скайнета, но все прозаичнее. На реальных системах есть свои реальные причины такого поведения.
В линуксе раньше для управления потоками(запуск, остановка и тд) система использовала сигналы. В такой модели условная переменная физически могла быть разбужена сигналом, предназначенным для совершенно другой цели — например, менеджер потоков решил передать потоку команду, а сигнал попал в тот же обработчик, что и пробуждение от
POSIX стандарт должен был учитывать эту особенность, поэтому прям в доке прописал возможность пробуждения без сигнала.
Свежих версиях линукса такой ситуации уже не может быть и из pthread'ной функции ожидания cv управление не вернется в юзерспейс при отправке любых сигналов.
Но наследие осталось.
👉🏿 А вот тут уже реальная проблема с race condition. Поток реально получил сигнал на пробуждение, проснулся, начал выполняться и увидел, что условие ложно. Потому что по сути какой-то другой поток уже успел обработать появившееся событие. Это может произойти по многим причинам, но может быть вот такая ситуация:
1. Поток A спит на
2. Поток B меняет условие и вызывает
3. Поток A просыпается, но ещё не запланирован шедулером.
4. Поток C успевает изменить условие обратно (забрал задачу из очереди, сбросил флаг).
5. Поток A наконец получает процессор, проверяет условие — оно
или такая
1. Поток A спит на
2. Поток B меняет условие и вызывает
3. Поток A просыпается, но ещё не запланирован шедулером.
4. Поток B успевает изменить условие еще раз(или несколько).
5. Поток A наконец получает процессор, проверяет условие — оно
Да, в вашем конкретном случае такого может и не произойти просто по логике программы. А можно не думать и просто использовать цикл с кондваром, хуже точно не будет.
Спасибо, @mike_hd, за вопрос в чате и идею для поста. И всем комментаторам, вовлекшимся в обсуждение, за предоставление фактуры по вопросу.
Don't get stolen. Stay cool.
#concurrency
#опытным
У кондваров есть метод std::condition_variable::wait, который по документации блокирует исполнение потока до тех пор, пока не придет уведомление или не произойдет тн spurious wakeup(ложное пробуждение). Поэтому нам говорят, что нужно ставить wait в цикл while:
while (!pred())
cv.wait(lock);
или использовать перегрузку, передавая в кондвар предикат:
cv.wait(lock, pred);
Но что это за такие ложные пробуждения, из-за которых на нужно крутиться в цикле?
По сути, ложными пробуждениями можно назвать любую ситуацию, когда поток просыпается и условие предиката неудовлетворяется. Со стороны кажется, что поток просто в рандомный момент сам проснулся, когда условия еще не создались для него. Но это не совсем так. Давайте покопаемся в причинах.
👉🏿 Поток может проснуться без уведомления. Звучит как баг или признаки рождения скайнета, но все прозаичнее. На реальных системах есть свои реальные причины такого поведения.
В линуксе раньше для управления потоками(запуск, остановка и тд) система использовала сигналы. В такой модели условная переменная физически могла быть разбужена сигналом, предназначенным для совершенно другой цели — например, менеджер потоков решил передать потоку команду, а сигнал попал в тот же обработчик, что и пробуждение от
cond_signal. POSIX стандарт должен был учитывать эту особенность, поэтому прям в доке прописал возможность пробуждения без сигнала.
Свежих версиях линукса такой ситуации уже не может быть и из pthread'ной функции ожидания cv управление не вернется в юзерспейс при отправке любых сигналов.
Но наследие осталось.
👉🏿 А вот тут уже реальная проблема с race condition. Поток реально получил сигнал на пробуждение, проснулся, начал выполняться и увидел, что условие ложно. Потому что по сути какой-то другой поток уже успел обработать появившееся событие. Это может произойти по многим причинам, но может быть вот такая ситуация:
1. Поток A спит на
cond_wait.2. Поток B меняет условие и вызывает
notify_one.3. Поток A просыпается, но ещё не запланирован шедулером.
4. Поток C успевает изменить условие обратно (забрал задачу из очереди, сбросил флаг).
5. Поток A наконец получает процессор, проверяет условие — оно
false. Это называется stolen wakeupили такая
1. Поток A спит на
cond_wait.2. Поток B меняет условие и вызывает
notify_one.3. Поток A просыпается, но ещё не запланирован шедулером.
4. Поток B успевает изменить условие еще раз(или несколько).
5. Поток A наконец получает процессор, проверяет условие — оно
false. То есть событие было фактически потеряно.Да, в вашем конкретном случае такого может и не произойти просто по логике программы. А можно не думать и просто использовать цикл с кондваром, хуже точно не будет.
Спасибо, @mike_hd, за вопрос в чате и идею для поста. И всем комментаторам, вовлекшимся в обсуждение, за предоставление фактуры по вопросу.
Don't get stolen. Stay cool.
#concurrency
👍14❤8🔥3🤣3
Где-то баг...
#опытным
Вот вам код:
Все ли в нем хорошо? Вы уверены? В чем здесь могут быть проблемы и какие у них проявления?
Напишите в комментах.
Prevent bugs. Stay cool.
#опытным
Вот вам код:
struct Order {
int price;
int id;
};
int main() {
std::vector<Order> book;
for (int i = 0; i < 40; ++i) {
book.push_back({100, i});
}
std::sort(book.begin(), book.end(),
[](const Order& a, const Order& b) {
return a.price <= b.price;
});
std::cout << "book.size() = " << book.size() << '\n';
return 0;
}Все ли в нем хорошо? Вы уверены? В чем здесь могут быть проблемы и какие у них проявления?
Напишите в комментах.
Prevent bugs. Stay cool.
❤10🔥6👍5
Strict weak ordering
#опытным
На первый взгляд, всё выглядит рабочим: мы создаём 40 заказов с одинаковой ценой 100 и уникальными
Но почему так? Мы же вообще ничего криминального не делали, а получили UB.
Баг на самом деле в том, что компаратор не удовлетворяет требованию strict weak ordering. Конкретно проблема в иррефлексивности компаратора. Для любых двух элементов с одинаковой ценой (в нашем случае все цены равны 100) компаратор возвращает
-
-
Это означает, что
Ну окей. "А почему эффекты проявляются только после определенного размера последовательности?"
Потому что реализация
1️⃣ Для малых подмассивов (обычно < 16 элементов) используется сортировка вставками (insertion sort). На каждом шаге работы эта сортировка проходится по всем предыдущим элементам и меняет текущий, если компаратор возвращает true. Есть четкая граница остановки - первый элемент. Поэтому даже если компаратор будет возвращать true для одинаковых элементов, то это приведет лишь к лишним обменам элементов.
2️⃣ Для больших диапазонов introsort переключается на быструю сортировку (quicksort) с разбиением (partitioning) вокруг опорного элемента (pivot).
Именно в фазе разбиения и прячется баг. Алгоритм разбиения тут примерно следующий. Обычно выбирается опорный элемент
Если
-
-
Таким образом, левый указатель будет двигаться вправо, пока не выйдет за границы, а правый — влево, также за пределы. Это приводит к чтению/записи за пределами массива и, в конечном счёте, к крашу.
Решение тут простое и даже есть соответствующая мемная паста на кодфосес. "Компаратор в С++ должен возвращать false, если аргументы равны". Или в нашем примере:
Reflect on your life. Stay cool.
#STL
#опытным
На первый взгляд, всё выглядит рабочим: мы создаём 40 заказов с одинаковой ценой 100 и уникальными
id, затем сортируем по возрастанию цены, используя <=. Программа компилируется и даже выполняется без ошибок… пока не достигнет определённого размера. Тогда она падает с segmentation fault.struct Order {
int price;
int id;
};
int main() {
std::vector<Order> book;
for (int i = 0; i < 40; ++i) {
book.push_back({100, i});
}
std::sort(book.begin(), book.end(),
[](const Order& a, const Order& b) {
return a.price <= b.price;
});
std::cout << "book.size() = " << book.size() << '\n';
return 0;
}Но почему так? Мы же вообще ничего криминального не делали, а получили UB.
Баг на самом деле в том, что компаратор не удовлетворяет требованию strict weak ordering. Конкретно проблема в иррефлексивности компаратора. Для любых двух элементов с одинаковой ценой (в нашем случае все цены равны 100) компаратор возвращает
true в обе стороны:-
comp(a, b) = 100 <= 100 → true-
comp(b, a) = 100 <= 100 → trueЭто означает, что
comp(a,a) тоже true (так как 100 <= 100). Согласно стандарту, компаратор должен возвращать false для сравнения объекта с самим собой. Это и есть требование иррефлексивности. Нарушение этого правила приводит к тому, что алгоритм сортировки некорректно сравнивает одинаковые элементы и по итогу входит за границы последовательностиНу окей. "А почему эффекты проявляются только после определенного размера последовательности?"
Потому что реализация
std::sort в стандартной библиотеке обычно использует интроспективную сортировку (introsort), которая выбирает стратегию в зависимости от размера диапазона:1️⃣ Для малых подмассивов (обычно < 16 элементов) используется сортировка вставками (insertion sort). На каждом шаге работы эта сортировка проходится по всем предыдущим элементам и меняет текущий, если компаратор возвращает true. Есть четкая граница остановки - первый элемент. Поэтому даже если компаратор будет возвращать true для одинаковых элементов, то это приведет лишь к лишним обменам элементов.
2️⃣ Для больших диапазонов introsort переключается на быструю сортировку (quicksort) с разбиением (partitioning) вокруг опорного элемента (pivot).
Именно в фазе разбиения и прячется баг. Алгоритм разбиения тут примерно следующий. Обычно выбирается опорный элемент
pivot. Затем два указателя двигаются навстречу друг другу, пока не найдут элементы, которые должны быть поменяны местами:while (comp(*left, pivot)) ++left; // пока левый < pivot
while (comp(pivot, *right)) --right; // пока pivot < правый
Если
comp использует <=, то для равных элементов оба условия вернут true:-
comp(*left, pivot) вернёт true (если *left == pivot, то pivot <= pivot → true)-
comp(pivot, *right) тоже true (если *right == pivot).Таким образом, левый указатель будет двигаться вправо, пока не выйдет за границы, а правый — влево, также за пределы. Это приводит к чтению/записи за пределами массива и, в конечном счёте, к крашу.
Решение тут простое и даже есть соответствующая мемная паста на кодфосес. "Компаратор в С++ должен возвращать false, если аргументы равны". Или в нашем примере:
std::sort(book.begin(), book.end(),
[](const Order& a, const Order& b) {
return a.price < b.price;
});
Reflect on your life. Stay cool.
#STL
🔥27👍10❤5
WAT
#новичкам
Как вы думаете, результат выражения:
будет правдой или ложью?
Нормальный человек думает, что математика работает.
Но С++ выкидывает ее в мусор и по итогу math == false.
#ЧЗХ ?
Вместо того, чтобы приводить беззнаковое число к знаковому, С++ делает наоборот. В итоге -1 должен конвертироваться в неотрицательное число. В дополненном коде -1 это 0xFFFFFFFF ака все единички, поэтому при касте к беззнаковому числу оно просто становится UINT32_MAX. И оно конечно больше 1.
Выход тут один. Не сравнивать знаковые и беззнаковые числа в одном выражении. Включайте -Wall -Werror и получите ошибку компиляции. После исправления арифметика будет работать как ожидается.
Mix things consciously. Stay cool
#cppcore
#новичкам
Как вы думаете, результат выражения:
bool math = -1 < 1U;
будет правдой или ложью?
Нормальный человек думает, что математика работает.
Но С++ выкидывает ее в мусор и по итогу math == false.
#ЧЗХ ?
Вместо того, чтобы приводить беззнаковое число к знаковому, С++ делает наоборот. В итоге -1 должен конвертироваться в неотрицательное число. В дополненном коде -1 это 0xFFFFFFFF ака все единички, поэтому при касте к беззнаковому числу оно просто становится UINT32_MAX. И оно конечно больше 1.
Выход тут один. Не сравнивать знаковые и беззнаковые числа в одном выражении. Включайте -Wall -Werror и получите ошибку компиляции. После исправления арифметика будет работать как ожидается.
Mix things consciously. Stay cool
#cppcore
❤20👍9🔥4😭3
Что внутри std::condition_variable?
#опытным
Более менее все знакомы с примитивом синхронизации "условная переменная". При наличии мьютекса она может погрузить поток в сон до тех пор, пока не поступит уведомлении и условие пробуждение не будет истинным. Сильно упрощая, в консюмере ждем события на wait, а в продюсере уведомляем через notify о возникновении события.
Но как это работает под катопом?
Если задуматься, то кондвар очень похож на семафор и мьютекс. По сути оба отправляют поток спать, когда счетчик пытаются декрементить ниже 0. И они оба уведомляют одного из ожидающих, когда счетчик становится равным 1 или больше. Да, семантика разная, но факт на лицо: есть и ожидание и уведомление.
Так может у кондваров и мьютексов/семафоров есть общие инструменты реализации?
Бинго!
На линуксе есть такая штука - futex. Fast Userspace muTEX. Это низкоуровневый примитив синхронизации в ядре. Главная идея: быстрый путь (захват/освобождение без конкуренции) делается в userspace обычными атомарными операциями. Ядро вызывается только когда нужно усыпить или разбудить поток.
Если надо усыпить поток - он помещается в очередь ожидания. Если надо разбудить поток - он достается из головы очереди и продолжает исполнение.
Так вот кондвары и реализованы на основе такой очереди.
Конечно, нужно довольно много всего построить вокруг помещения потока в ожидание, но принцип такой.
При вызове wait поток освобождает переданный мьютекс и засыпает(вызывается условный futex_wait), помещаясь в очередь ожидания. При уведомлении (вызов futex_wake) о наступлении условия поток достается из очереди, будится и конкурирует за захват мьютекса, чтобы честно проверить верность условия(может быть stolen wakeup и поток обратно уйдет спать).
Прямым аналогом futex в Windows является семейство функций
Don't busy wait. Stay cool.
#concurrency #OS
#опытным
Более менее все знакомы с примитивом синхронизации "условная переменная". При наличии мьютекса она может погрузить поток в сон до тех пор, пока не поступит уведомлении и условие пробуждение не будет истинным. Сильно упрощая, в консюмере ждем события на wait, а в продюсере уведомляем через notify о возникновении события.
std::mutex mtx;
std::condition_variable cv;
bool ready = false;
void consumer() {
std::unique_lock lock(mtx);
cv.wait(lock, []{ return ready; }); // Wait until 'ready' is true
// Proceed with work
}
void producer() {
{
std::lock_guard<std::mutex> lock(mtx);
ready = true;
}
cv.notify_one(); // Wake up one waiting thread
}
Но как это работает под катопом?
Если задуматься, то кондвар очень похож на семафор и мьютекс. По сути оба отправляют поток спать, когда счетчик пытаются декрементить ниже 0. И они оба уведомляют одного из ожидающих, когда счетчик становится равным 1 или больше. Да, семантика разная, но факт на лицо: есть и ожидание и уведомление.
Так может у кондваров и мьютексов/семафоров есть общие инструменты реализации?
Бинго!
На линуксе есть такая штука - futex. Fast Userspace muTEX. Это низкоуровневый примитив синхронизации в ядре. Главная идея: быстрый путь (захват/освобождение без конкуренции) делается в userspace обычными атомарными операциями. Ядро вызывается только когда нужно усыпить или разбудить поток.
Если надо усыпить поток - он помещается в очередь ожидания. Если надо разбудить поток - он достается из головы очереди и продолжает исполнение.
Так вот кондвары и реализованы на основе такой очереди.
Конечно, нужно довольно много всего построить вокруг помещения потока в ожидание, но принцип такой.
При вызове wait поток освобождает переданный мьютекс и засыпает(вызывается условный futex_wait), помещаясь в очередь ожидания. При уведомлении (вызов futex_wake) о наступлении условия поток достается из очереди, будится и конкурирует за захват мьютекса, чтобы честно проверить верность условия(может быть stolen wakeup и поток обратно уйдет спать).
Прямым аналогом futex в Windows является семейство функций
WaitOnAddress и WakeByAddressSingle/WakeByAddressAll.Don't busy wait. Stay cool.
#concurrency #OS
❤12🔥8👍6
Оптимистичные и пессимистичные блокировки
#опытным
Концепция блокировок уходит далеко за пределы языковых примитивов синхронизации. Блокировки актуальны в принципе для любых систем, с которыми можно работать конкурентно. И идейно для таких систем существует 2 типа локов: оптимистичные и пессимистичные. Они имеют принципиальные отличия и это важно понимать для того, чтобы корректно оперировать данными и их обновлениями.
Разница между ними в том, как предотвращается конфликт изменения данных из-за конкурентного доступа и что происходит с потоком/транзакцией до этого момента.
Пусть для примера у нас будет какая-то ассоциативная структура с кучей записей. И приходит очередной запрос на изменение одной конкретной записи.
Начнем с простого. Пессимистичная блокировка. Ее используют, когда есть риск конкурентного доступа/изменения данных и мы не хотим принимать этот риск. Например, в системах, где очень важна консистентность данных - банковский сектор.
При пессимистичной блокировке мы запрашиваем эксклюзивный доступ к конкретной записи и при получении доступа больше никто не может изменять ее одновременно с нами.
Реализацией пессимистичной блокировки в С++ является всем известный мьютекс. Он обеспечивает эксклюзивный доступ к данным:
Поток, который хочет изменить запись, сначала захватывает мьютекс. Если мьютекс уже кем-то занят, поток встаёт в очередь ожидания и спит, пока владелец не освободит его. В этот момент никто другой не может изменить
Плюсами являются предсказуемость, отсутствие откатов, гарантированная консистентность данных. Но за это приходится платить: блокировка и ожидание других запросов, возможность дедлоков, никакого параллелизма и оверхэд на сам факт блокировки записи(например системные вызовы и тд).
Теперь представим, что конфликты редки. Например, у нас миллиарды записей, и запросы на изменение приходят к разным записям. Блокировать каждую запись мьютексом — значит платить за блокировку там, где она почти никогда не нужна. Именно для таких случаев придумали оптимистичную блокировку.
Идея проста: мы не блокируем запись. Мы читаем её, запоминаем версию (или timestamp), работаем, а перед записью проверяем — не изменил ли кто-то запись, пока мы думали. Если не изменил - записываем. Если изменил - откатываемся и повторяем.
Для С++ нет отдельного примитива под это дело, потому что оптимистическая блокировка - это скорее паттерн, который надо отдельно внедрять в логику работы с данными. Но его легко собрать из
Здесь
Плюсами здесь будет: отсутствие блокировок и накладных расходов на них, нет ожиданий, высокая параллельность при редких конфликтах.
Минусы - при частых конфликтах много откатов и повторной работы, нагрузка на CPU. Ну и для некоторых доменов просто неприемлемо будет ретраить запросы и/или повторно пересчитывать актуальное значение.
Выбор между двумя типами блокировок - это выбор между гарантией и параллелизмом. И хороший инженер должен уметь считать, что более важно в его конкретной задаче.
Give guarantees. Stay cool.
#concurrency
#опытным
Концепция блокировок уходит далеко за пределы языковых примитивов синхронизации. Блокировки актуальны в принципе для любых систем, с которыми можно работать конкурентно. И идейно для таких систем существует 2 типа локов: оптимистичные и пессимистичные. Они имеют принципиальные отличия и это важно понимать для того, чтобы корректно оперировать данными и их обновлениями.
Разница между ними в том, как предотвращается конфликт изменения данных из-за конкурентного доступа и что происходит с потоком/транзакцией до этого момента.
Пусть для примера у нас будет какая-то ассоциативная структура с кучей записей. И приходит очередной запрос на изменение одной конкретной записи.
Начнем с простого. Пессимистичная блокировка. Ее используют, когда есть риск конкурентного доступа/изменения данных и мы не хотим принимать этот риск. Например, в системах, где очень важна консистентность данных - банковский сектор.
При пессимистичной блокировке мы запрашиваем эксклюзивный доступ к конкретной записи и при получении доступа больше никто не может изменять ее одновременно с нами.
Реализацией пессимистичной блокировки в С++ является всем известный мьютекс. Он обеспечивает эксклюзивный доступ к данным:
struct Record {
int value = 0;
std::mutex mtx;
};
void update_record(Record& rec, int new_value) {
std::lock_guard<std::mutex> lock(rec.mtx);
rec.value = new_value;
}Поток, который хочет изменить запись, сначала захватывает мьютекс. Если мьютекс уже кем-то занят, поток встаёт в очередь ожидания и спит, пока владелец не освободит его. В этот момент никто другой не может изменить
rec.value — мы работаем в условиях полной изоляции.Плюсами являются предсказуемость, отсутствие откатов, гарантированная консистентность данных. Но за это приходится платить: блокировка и ожидание других запросов, возможность дедлоков, никакого параллелизма и оверхэд на сам факт блокировки записи(например системные вызовы и тд).
Теперь представим, что конфликты редки. Например, у нас миллиарды записей, и запросы на изменение приходят к разным записям. Блокировать каждую запись мьютексом — значит платить за блокировку там, где она почти никогда не нужна. Именно для таких случаев придумали оптимистичную блокировку.
Идея проста: мы не блокируем запись. Мы читаем её, запоминаем версию (или timestamp), работаем, а перед записью проверяем — не изменил ли кто-то запись, пока мы думали. Если не изменил - записываем. Если изменил - откатываемся и повторяем.
Для С++ нет отдельного примитива под это дело, потому что оптимистическая блокировка - это скорее паттерн, который надо отдельно внедрять в логику работы с данными. Но его легко собрать из
std::atomic и CAS (compare-exchange):bool try_update(Record& rec, uint64_t& old, int new_value) {
uint32_t version = uint32_t(old >> 32);
uint64_t desired = (uint64_t(version + 1) << 32)
| uint32_t(new_value);
return rec.state.compare_exchange_weak(
old, desired,
std::memory_order_relaxed, std::memory_order_relaxed);
}
void update_with_retry(Record& rec) {
uint64_t old = rec.state.load(std::memory_order_relaxed); // читаем
while (!try_update(rec, old, uint32_t(old) + 1)) { // при неудаче old уже свежий
}
}Здесь
state - это счётчик версии, который увеличивается при каждом успешном обновлении, + само значение value. Если между нашим чтением и попыткой записи кто-то другой уже обновил запись, первая часть state изменится, и compare_exchange_strong вернёт false. Тогда просто повторяем попытку.Плюсами здесь будет: отсутствие блокировок и накладных расходов на них, нет ожиданий, высокая параллельность при редких конфликтах.
Минусы - при частых конфликтах много откатов и повторной работы, нагрузка на CPU. Ну и для некоторых доменов просто неприемлемо будет ретраить запросы и/или повторно пересчитывать актуальное значение.
Выбор между двумя типами блокировок - это выбор между гарантией и параллелизмом. И хороший инженер должен уметь считать, что более важно в его конкретной задаче.
Give guarantees. Stay cool.
#concurrency
👍15❤6🔥5