Что происходит при системном вызове?
#опытным
Существует строгая граница между приложениями, которые вы запускаете, и ядром компьютера (аппаратным обеспечением и ядром ОС).
Зачем она нужна? Пользователям нельзя доверять. Нужно оградить процессы, чтобы они не пострадали от действий другого процесса(написанного говнокодером или хакером).
Системный вызов является фундаментальным интерфейсом между приложением и ядром системы. Только через него пользователи могут запросить у системы выполнение какой-то задачи.
Для большинства сисколы - это просто хэндлеры, которые они дергают, чтобы получить нужный результат. Но там много интересностей происходит под капотом и именно об этом пойдет сегодня речь.
Будем все рассматривать на примере linux, как самой популярной серверной ОС.
0️⃣ В коде все начинается с какой-нибудь обертки для системного вызова
Оператор<< для плюсового объекта потока принимает высокоуровневые объекты и вызывает сискол write, чтобы записать данные в поток.
Но даже если вы напрямую из кода вызываете write, то вы вызываете обертку POSIX API, которая уже сама дергает одноименный системный вызов. Без оберток напрямую сисколл можно вызывать только ассемблерными вставками и прочей магией.
1️⃣ Подготовка данных в пользовательском пространстве
Функция обертка при необходимости делает некий препроцессинг аргументов(например форматирование в функции printf).
После копирует номер системного вызова и его готовые аргументы в определённые регистры процессора, где ядро ожидает их найти. В архитектуре x86-64 для Linux это делается так:
- В rax кладется номер системного вызова.
- В rdi, rsi, rdx, r10, r8, r9 кладутся аргументы (не больше шести).
Дальше обёртка выполняет специальную инструкцию, которая инициирует переход в ядро. Например,
2️⃣ Переключение в режим ядра
Как и инструкция call, syscall делает какую-то работу. Только в отличие от call, она минимальна:
👉🏿 сохраняет адрес возврата (следующую инструкцию) в регистр
👉🏿 сохраняет регистр флагов RFLAGS в %r11;
👉🏿 загружает новый указатель инструкций из MSR-регистра
После выполнения инструкции syscall ядро получает доступ к привилегированным ресурсам: управлению памятью, прерываниями, устройствами ввода-вывода.
3️⃣ Обработка в ядре
Управление передаётся функции
И в первую очередь происходит смена контекста - ядро сохраняет значения регистров текущего потока и заменяет пользовательский стек на стек ядра. Это нужно для лучшей защиты от злонамеренного изменения стека ядрённых вызовов.
После нужно найти конкретный обработчик. Ядро извлекает номер системного вызова из
Перед выполнением операции ядро проверяет, есть ли у текущего процесса достаточно прав (например, на запись в файл). И затем вызывается нужная функция, внутри которой и выполняется полезная логика сискола.
4️⃣ Возврат в пользовательский режим
После того как обработчик завершил работу происходит следующее:
👉🏿 Сохранение результата – возвращаемое значение обработчика помещается в регистр
👉🏿 Восстановление контекста – ядро восстанавливает сохранённые пользовательские регистры и стек.
👉🏿 Обратный переход – выполняется инструкция
И самый главный вопрос здесь: а какой оверхэд приходится на обеспечение системного вызова(не учитывая его логику)? В следующем посте обсудим это.
Be privileged. Stay cool.
#OS #howitworks
#опытным
Существует строгая граница между приложениями, которые вы запускаете, и ядром компьютера (аппаратным обеспечением и ядром ОС).
Зачем она нужна? Пользователям нельзя доверять. Нужно оградить процессы, чтобы они не пострадали от действий другого процесса(написанного говнокодером или хакером).
Системный вызов является фундаментальным интерфейсом между приложением и ядром системы. Только через него пользователи могут запросить у системы выполнение какой-то задачи.
Для большинства сисколы - это просто хэндлеры, которые они дергают, чтобы получить нужный результат. Но там много интересностей происходит под капотом и именно об этом пойдет сегодня речь.
Будем все рассматривать на примере linux, как самой популярной серверной ОС.
0️⃣ В коде все начинается с какой-нибудь обертки для системного вызова
Оператор<< для плюсового объекта потока принимает высокоуровневые объекты и вызывает сискол write, чтобы записать данные в поток.
Но даже если вы напрямую из кода вызываете write, то вы вызываете обертку POSIX API, которая уже сама дергает одноименный системный вызов. Без оберток напрямую сисколл можно вызывать только ассемблерными вставками и прочей магией.
int main() {
write(1, "Hello\n", 6);
return 0;
}1️⃣ Подготовка данных в пользовательском пространстве
Функция обертка при необходимости делает некий препроцессинг аргументов(например форматирование в функции printf).
После копирует номер системного вызова и его готовые аргументы в определённые регистры процессора, где ядро ожидает их найти. В архитектуре x86-64 для Linux это делается так:
- В rax кладется номер системного вызова.
- В rdi, rsi, rdx, r10, r8, r9 кладутся аргументы (не больше шести).
Дальше обёртка выполняет специальную инструкцию, которая инициирует переход в ядро. Например,
syscall (в 64-битном режиме x86-64).2️⃣ Переключение в режим ядра
Как и инструкция call, syscall делает какую-то работу. Только в отличие от call, она минимальна:
👉🏿 сохраняет адрес возврата (следующую инструкцию) в регистр
%rcx;👉🏿 сохраняет регистр флагов RFLAGS в %r11;
👉🏿 загружает новый указатель инструкций из MSR-регистра
IA32_LSTAR_MSR (там лежит адрес функции entry_SYSCALL_64 - входной точки для всех сисколов).После выполнения инструкции syscall ядро получает доступ к привилегированным ресурсам: управлению памятью, прерываниями, устройствами ввода-вывода.
3️⃣ Обработка в ядре
Управление передаётся функции
entry_SYSCALL_64 - единой точки входа в ядро. Здесь начинается основная работа. И в первую очередь происходит смена контекста - ядро сохраняет значения регистров текущего потока и заменяет пользовательский стек на стек ядра. Это нужно для лучшей защиты от злонамеренного изменения стека ядрённых вызовов.
После нужно найти конкретный обработчик. Ядро извлекает номер системного вызова из
%rax и проверяет, не выходит ли он за допустимые пределы. После номер вызова используется как индекс в глобальной таблице ядра – sys_call_table. Это массив указателей на функции-обработчики.Перед выполнением операции ядро проверяет, есть ли у текущего процесса достаточно прав (например, на запись в файл). И затем вызывается нужная функция, внутри которой и выполняется полезная логика сискола.
4️⃣ Возврат в пользовательский режим
После того как обработчик завершил работу происходит следующее:
👉🏿 Сохранение результата – возвращаемое значение обработчика помещается в регистр
%rax. Если произошла ошибка – возвращается отрицательное число (код ошибки).👉🏿 Восстановление контекста – ядро восстанавливает сохранённые пользовательские регистры и стек.
👉🏿 Обратный переход – выполняется инструкция
sysret. Она использует сохранённые в %rcx и %r11 значения, чтобы восстановить адрес возврата и флаги, переключает процессор обратно в пользовательский режим и передаёт управление коду, следующему за syscall.И самый главный вопрос здесь: а какой оверхэд приходится на обеспечение системного вызова(не учитывая его логику)? В следующем посте обсудим это.
Be privileged. Stay cool.
#OS #howitworks
🔥20❤15👍10❤🔥2
Оверхэд системных вызовов
#опытным
Как измерить оверхэд системного вызова?
Задача ведь не самая тривиальная: сискол делает разную полезную работу, которая потенциально занимает сильно больше времени, чем переход в режим ядра и обратно.
И вставить свой измеряющий код в место начала и конца логики сискола мы тоже не можем - это часть скрыта от нас и вообще написана на ассемблере.
Что делать?
Брать самый легковесный сискол. Который делает минимальную работу.
Список сисколов можно найти в man'е. Изучив их поближе, я понял, что один и самых дешевых системных вызовов это getpid.
Все, что он делает - возвращает ID процесса. По сути одно чтение из памяти.
Однако библиотечная функция getpid() не всегда делает системный вызов. Айди процесса не меняется и это отличный кандидат для кэширования результата. И по сути эта библиотечная обертка делает сискол только в первый свой вызов. Дальше результат кэшируется в обертке и выдается из кэша.
Как обойти эту неприятность? Было бы круто уметь напрямую вызывать сискол без этих оберток со своей логикой.
Как мы уже говорили, напрямую(без библиотечных оберток) вызывать сискол можно только с ассемблерными вставками. Однако есть функция syscall, которая принимает первым параметром номер системного вызова и далее его аргументы с помощью вариабельного параметра:
Таким образом мы избегаем кэширования и всегда делаем честный сискол. Который делает одно чтение из памяти и возвращает результат.
То есть фактически, измеряя затраты на вызов getpid, мы очень близко подбираемся к реальному оверхэду на переход в режим ядра и обратно.
Теперь обсудим несколько принципов, которые нужно учесть в измерениях, чтобы они были более точные:
✅ Большое количество прогонов - естественно. Флуктуации бывают везде, нужно их усреднять за счет большого количества экспериментов.
✅ Запрещаем компилятору оптимизировать результат. Компилятор хитрый: если он видит, что результат вызова не используется(а нафига нам нужны миллионы значений айди текущего процесса?) он пытается выкинуть весь вызов. Делаем ассемблерную вставку, запрещающую оптимизировать конкретно это место в коде.
✅ Прогрев. Первые вызовы всегда медленнее: кэши холодные, предсказатель ветвлений не обучен, процессор ещё не разогнался до турбо-частоты. Сделаем некоторое количество «холостых» вызовов до начала измерений, чтобы система вошла в стабильный «горячий» режим. Иначе первые тысячи медленных вызовов исказили бы результат.
✅ Вычесть стоимость самого измерения. Подсчет времени сам по себе требует времени на выполнение, поэтому это нужно учесть.
✅ Измерение с выборкой. Делаем 100 млн замеров для имитации бурной деятельности, но замеряем только небольшую часть, чтобы вывести подробную статистику с пенцентилями и меньше привносить эффекта наблюдателя в измерение.
Я навайбкодил программулину, которая учитывает эти штуки и делает измерение стоимости вызова чистого сискола getpid.
Результаты вышли вот такими:
Получилось, что медианное время - 84 нс. Что при частоте процессора около 4.5 Ггц дает около 380 клоков процессора.
Видел где-то в статье чувак тестил на интеле и у него вышло около 130 нс и примерно 350 циклов проца.
Если среди нас есть спецы по перф измерениям, подскажите, что можно было сделать, чтобы результаты были более приближены к реальности?
Measure your performance. Stay cool.
#performance #OS
#опытным
Как измерить оверхэд системного вызова?
Задача ведь не самая тривиальная: сискол делает разную полезную работу, которая потенциально занимает сильно больше времени, чем переход в режим ядра и обратно.
И вставить свой измеряющий код в место начала и конца логики сискола мы тоже не можем - это часть скрыта от нас и вообще написана на ассемблере.
Что делать?
Брать самый легковесный сискол. Который делает минимальную работу.
Список сисколов можно найти в man'е. Изучив их поближе, я понял, что один и самых дешевых системных вызовов это getpid.
Все, что он делает - возвращает ID процесса. По сути одно чтение из памяти.
Однако библиотечная функция getpid() не всегда делает системный вызов. Айди процесса не меняется и это отличный кандидат для кэширования результата. И по сути эта библиотечная обертка делает сискол только в первый свой вызов. Дальше результат кэшируется в обертке и выдается из кэша.
Как обойти эту неприятность? Было бы круто уметь напрямую вызывать сискол без этих оберток со своей логикой.
Как мы уже говорили, напрямую(без библиотечных оберток) вызывать сискол можно только с ассемблерными вставками. Однако есть функция syscall, которая принимает первым параметром номер системного вызова и далее его аргументы с помощью вариабельного параметра:
#include <unistd.h>
#include <sys/syscall.h>
#include <sys/types.h>
int main(int argc, char *argv[])
{
pid_t pid;
pid = syscall(SYS_getpid);
}
Таким образом мы избегаем кэширования и всегда делаем честный сискол. Который делает одно чтение из памяти и возвращает результат.
То есть фактически, измеряя затраты на вызов getpid, мы очень близко подбираемся к реальному оверхэду на переход в режим ядра и обратно.
Теперь обсудим несколько принципов, которые нужно учесть в измерениях, чтобы они были более точные:
✅ Большое количество прогонов - естественно. Флуктуации бывают везде, нужно их усреднять за счет большого количества экспериментов.
✅ Запрещаем компилятору оптимизировать результат. Компилятор хитрый: если он видит, что результат вызова не используется(а нафига нам нужны миллионы значений айди текущего процесса?) он пытается выкинуть весь вызов. Делаем ассемблерную вставку, запрещающую оптимизировать конкретно это место в коде.
✅ Прогрев. Первые вызовы всегда медленнее: кэши холодные, предсказатель ветвлений не обучен, процессор ещё не разогнался до турбо-частоты. Сделаем некоторое количество «холостых» вызовов до начала измерений, чтобы система вошла в стабильный «горячий» режим. Иначе первые тысячи медленных вызовов исказили бы результат.
✅ Вычесть стоимость самого измерения. Подсчет времени сам по себе требует времени на выполнение, поэтому это нужно учесть.
✅ Измерение с выборкой. Делаем 100 млн замеров для имитации бурной деятельности, но замеряем только небольшую часть, чтобы вывести подробную статистику с пенцентилями и меньше привносить эффекта наблюдателя в измерение.
Я навайбкодил программулину, которая учитывает эти штуки и делает измерение стоимости вызова чистого сискола getpid.
Результаты вышли вот такими:
getpid() syscall benchmark (macOS)
Method : syscall(SYS_getpid)
Iterations : 100,000,000
Total time : 9.536 s
Average time: 95.36 ns per call
Calls/sec : 10,486,821 calls/sec (approx)
Min : 18 ns
Median : 84 ns
P50 : 84 ns
P90 : 125 ns
P99 : 125 ns
Max : 28726 ns
CPU : Apple M4 Pro
Kernel : Darwin 24.6.0 (arm64)
Compiler : clang++ (Clang) 16.0.0
Flags : -O3 -Wall
Получилось, что медианное время - 84 нс. Что при частоте процессора около 4.5 Ггц дает около 380 клоков процессора.
Видел где-то в статье чувак тестил на интеле и у него вышло около 130 нс и примерно 350 циклов проца.
Если среди нас есть спецы по перф измерениям, подскажите, что можно было сделать, чтобы результаты были более приближены к реальности?
Measure your performance. Stay cool.
#performance #OS
👍20❤12❤🔥7👏3👎1🔥1
Квиз
Выберете правильные утверждения о вызовах в коде:
Выберете правильные утверждения о вызовах в коде:
struct A {
void func(const std::string&); // #1
};
struct B : A {
void func(float); // #2
};
struct C : B {
using A::func;
void func(int); // #3
};
C c;
B& b = c;👍9🔥5❤3❤🔥1💯1
Какие из утверждений ниже правдивы?
Anonymous Poll
28%
c.func(3.14f); не скомпилируется
77%
c.func(123); вызывает #3
72%
c.func("hello"); вызывает #1
41%
c.func(3.14f); вызывает #2
72%
b.func(3.14f); вызывает #2
❤🔥1👍1
Ответ
#опытным
2 вещи нужно знать(помимо всего остального С++😆), чтобы правильно ответить на квиз выше:
1️⃣ Сокрытие имен
Когда в классе объявляют метод с неким именем, все методы с этим именем из базовых классов становятся невидимыми (скрытыми), независимо от их параметров.
То есть
у объекта
Почему так?
Компилятор увидел вызов метода и должен выполнить overload resolution. Он видит, что у объекта
И вот здесь срабатывает ключевая особенность поиска. Компилятор ищет имя
В классе
Поэтому:
2️⃣ Вообще говоря, это сокрытие имен - это проблема с точки зрения привычного нам ООП.
"У меня есть базовый класс, от которого я наследую функциональность. Почему я не могу использовать метод базового класса?"
Сокрытие имен - процесс неявный. Но мы можем явно сказать, какой сокрытый метод мы как разработчики хотим видеть в наследнике.
С помощью директивы
Такой же прием кстати используется в реализации overloaded паттерна.
Когда мы это знаем, правильные ответы находятся на поверхности:
Don't let others shadow you. Stay cool
#cppcore
#опытным
2 вещи нужно знать(помимо всего остального С++😆), чтобы правильно ответить на квиз выше:
1️⃣ Сокрытие имен
Когда в классе объявляют метод с неким именем, все методы с этим именем из базовых классов становятся невидимыми (скрытыми), независимо от их параметров.
То есть
struct A {
void func(const std::string&);
};
struct B : A {
void func(float);
};
B b; у объекта
b можно вызвать только флотовый вариант func.Почему так?
Компилятор увидел вызов метода и должен выполнить overload resolution. Он видит, что у объекта
b статический тип B и идет смотреть, какие методы из этого класса подходят для вызова.И вот здесь срабатывает ключевая особенность поиска. Компилятор ищет имя
func по цепочке областей видимости — снизу вверх (от производного класса к базовому). Как только он находит хотя бы одно объявление с именем func, поиск по иерархии немедленно останавливается . Дальше вверх (в A) он уже не заглядывает. В классе
B есть func(float) — имя найдено. Поиск завершён. В набор кандидатов для overload resolution попадает только B::func(float). Метод A::func(const std::string&) в этот набор даже не рассматривается — он остался за границей поиска. Поэтому:
b.func(15.f); // OK: float
b.func("hello"); // Compile Error
2️⃣ Вообще говоря, это сокрытие имен - это проблема с точки зрения привычного нам ООП.
"У меня есть базовый класс, от которого я наследую функциональность. Почему я не могу использовать метод базового класса?"
Сокрытие имен - процесс неявный. Но мы можем явно сказать, какой сокрытый метод мы как разработчики хотим видеть в наследнике.
С помощью директивы
using можно вернуть видимость методу базового класса в наследник:struct A {
void func(const std::string&);
};
struct B : A {
using A::func;
void func(float);
};
B b;
b.func(15.f); // OK
b.func("hello"); // OKusing A::func; позволяет компилятору увидеть метод, принимающий строку, и успешно вызывать его при передаче строкового литерала.Такой же прием кстати используется в реализации overloaded паттерна.
Когда мы это знаем, правильные ответы находятся на поверхности:
struct A {
void func(const std::string&); // #1
};
struct B : A {
void func(float); // #2
};
struct C : B {
using A::func;
void func(int); // #3
};
C c;
B& b = c;
c.func(3.14f); // вызывает #3 за счет неявного приведения float к int
c.func(123); // вызывает #3
c.func("hello"); // вызывает #1 за счет using
b.func(3.14f); // вызывает #2Don't let others shadow you. Stay cool
#cppcore
❤24👍22🔥9❤🔥1👎1😱1
Мапим значения на типы. Ч1
#опытным
Иногда требуется на основе какого-то рантайм значения, например перечисления, получить какой-то соответствующий тип.
Например, у вас есть шаблонная функция и вы хотите вызвать правильную ее инстанциацию:
Как на основе DataType создать объект нужного типа?
Пойдем доисторическим способом. Там, где есть enum, всегда где-то в углу стоит застенчивый switch и хочет быть использован. Ну давайте попробуем:
И это вроде работает. Но не зря switch стоит в углу:
1️⃣ Он смешивает логику выбора, соответствия сущностей и обработки
2️⃣ Часто он приводит к длинным функциям, которые сложно поддерживать
3️⃣ switch может и быстрый, но засоряет клиентский код.
К тому же код кучу раз повторяется конкретно в этом кейсе.
Давайте пробовать решать.
Если наш enum плотный aka вы не присваивали никаким перечислителям числа, то можно примерно все красиво вынести в compile-time.
Начнем с того, что нужно создать compile-time маппинг между типами и перечислителями. Это поможет в коде отделить логику соответствия enum'а и типов:
Используем полную специализацию шаблонов и зависимые типы.
Дальше нужна логика обработки
Через раскрытие пака параметров делаем массив обработчиков для каждого элемента перечисления. Это гарантирует
Для этого и было условие плотности enum'а, чтобы make_index_sequence корректно передавал индексы.
Для каждого обработчика выбираем нужный тип через EnumMappedType.
Плюсик перед лямбдой кастит ее к указателю на функцию. Это чтобы не использовать опасный для перфа std::function.
Ну а логику выбора написать уже очень просто:
Итого, мы явно разделили 3 задачи: описание обработчиков, маппинг и сам выбор обработчика.
Да, код все еще повторяется при описании маппинга EnumMap. Однако этот код намного более атомарно фундаментальный чтоли. Намного менее вероятно, что он изменится, потому что в нем зашита только необходимый маппинг и больше ничего. Можно делать через туплы парных сущностей, но выглядело бы это сильно менее понятным.
Divide and conquer. Stay cool.
#опытным
Иногда требуется на основе какого-то рантайм значения, например перечисления, получить какой-то соответствующий тип.
Например, у вас есть шаблонная функция и вы хотите вызвать правильную ее инстанциацию:
enum class DataType {
kInt32,
kDouble,
kUint64
};
template<typename T>
T create_from_buffer(const void* buffer) {
static_assert(std::is_trivially_copyable_v<T>,
"Type T must be trivially copyable");
T obj;
std::memcpy(&obj, buffer, sizeof(T));
return obj;
}Как на основе DataType создать объект нужного типа?
Пойдем доисторическим способом. Там, где есть enum, всегда где-то в углу стоит застенчивый switch и хочет быть использован. Ну давайте попробуем:
using DataVariant = std::variant<int32_t, double, uint64_t>;
DataVariant foo(const void* buffer, DataType type) {
switch (type) {
case DataType::kInt32:
return create_from_buffer<int32_t>(buffer);
// ...
default:
throw std::runtime_error("Unknown type");
}
}
И это вроде работает. Но не зря switch стоит в углу:
1️⃣ Он смешивает логику выбора, соответствия сущностей и обработки
2️⃣ Часто он приводит к длинным функциям, которые сложно поддерживать
3️⃣ switch может и быстрый, но засоряет клиентский код.
К тому же код кучу раз повторяется конкретно в этом кейсе.
Давайте пробовать решать.
Если наш enum плотный aka вы не присваивали никаким перечислителям числа, то можно примерно все красиво вынести в compile-time.
Начнем с того, что нужно создать compile-time маппинг между типами и перечислителями. Это поможет в коде отделить логику соответствия enum'а и типов:
enum class DataType { kInt32, kDouble, kUint64, kCount };
template <DataType> struct EnumMap;
template <> struct EnumMap<DataType::kInt32> { using Type = int32_t; };
template <> struct EnumMap<DataType::kDouble> { using Type = double; };
template <> struct EnumMap<DataType::kUint64> { using Type = uint64_t; };
template <DataType D>
using EnumMappedType = typename EnumMap<D>::Type;Используем полную специализацию шаблонов и зависимые типы.
Дальше нужна логика обработки
using DataVariant = std::variant<int32_t, double, uint64_t>;
using MakerFn = DataVariant(*)(const void*);
template <std::size_t... Is>
constexpr auto make_table(std::index_sequence<Is...>) {
return std::array<MakerFn, sizeof...(Is)>{
+[](const void* buffer) -> DataVariant {
return create_from_buffer<EnumMappedType<static_cast<DataType>(Is)>>(buffer);
}...
};
}
static constexpr auto dispatcher =
make_table(std::make_index_sequence<static_cast<std::size_t>(DataType::kCount)>{});
Через раскрытие пака параметров делаем массив обработчиков для каждого элемента перечисления. Это гарантирует
std::make_index_sequence<static_cast<std::size_t>(DataType::kCount)>, который раскрывается в последовательность индексов перечислителей вплоть до последнего kCount.Для этого и было условие плотности enum'а, чтобы make_index_sequence корректно передавал индексы.
Для каждого обработчика выбираем нужный тип через EnumMappedType.
Плюсик перед лямбдой кастит ее к указателю на функцию. Это чтобы не использовать опасный для перфа std::function.
Ну а логику выбора написать уже очень просто:
DataVariant foo(const void* buffer, DataType type) {
auto idx = static_cast<std::size_t>(type);
if (idx >= dispatcher.size())
throw std::runtime_error("Unknown type");
return dispatcher[idx](buffer);
}Итого, мы явно разделили 3 задачи: описание обработчиков, маппинг и сам выбор обработчика.
Да, код все еще повторяется при описании маппинга EnumMap. Однако этот код намного более атомарно фундаментальный чтоли. Намного менее вероятно, что он изменится, потому что в нем зашита только необходимый маппинг и больше ничего. Можно делать через туплы парных сущностей, но выглядело бы это сильно менее понятным.
Divide and conquer. Stay cool.
👍16🔥14❤7❤🔥5
Мапим значения на типы. Ч2
#опытным
Напомню проблему:
Как на основе DataType создать объект соответствующего типа?
Compile-time мапа из массива, где по индексу подкапотного числа, стоящего за каждым перечислителем, позволяет получить соответствующий нужный тип и выбрать правильный обработчик. И все это за О(1) с тратами на сдвиг элемента в массиве
Если же перечислителям явно присвоены числа, то этот номер не прокатит и надо думать дальше.
Без шаблонной магии нам не обойтись, ведь мы хотим только добавлять маппинги, но не менять самого кода обработчиков и логику их выбора.
И давайте заодно попробуем уйти от полной специализации шаблонов, как было в предыдущей части.
Сделаем структуру аля ноду маппинга:
В структуре хранится конкретный перечислитель, получаемый из шаблонного параметра, и соответствующий ему тип.
И в качестве пака шаблонных параметров нашей шаблонной функции мы будем передавать список нод
В функции мы должны сделать примерно следующее. Перебираем рантайм значение enum'а на соответствие значению Key из нод пака параметров. Перебираем, пока не найдем нужный и после используем правильную ноду для получения замаппленого типа.
И вот этот перебор можно делать через fold expression и короткозамкнутый оператор
Также тут используется фишка оператора запятой, что результатом выражения является только самый крайний справа операнд.
Теперь вместо поиска элемента в массиве по индексу мы занимаемся линейным проходом выполнения логического ИЛИ для типов.
На первый взгляд это может быть сильно дольше, но компиляторы хорошо умеют оптимизировать fold expression, даже в худшем случае большой разницы не будет.
Код кстати по размеру уже не сильно больше изначального свитча.
Вот примерчик с рабочим кодом, можете поиграться.
Use tricks. Stay cool.
#template #cpp17
#опытным
Напомню проблему:
enum class DataType: uint8_t {
kInt32,
kDouble,
kUint64
};
template<typename T>
T create_from_buffer(const void* buffer) {
static_assert(std::is_trivially_copyable_v<T>,
"Type T must be trivially copyable");
T obj;
std::memcpy(&obj, buffer, sizeof(T));
return obj;
}Как на основе DataType создать объект соответствующего типа?
Compile-time мапа из массива, где по индексу подкапотного числа, стоящего за каждым перечислителем, позволяет получить соответствующий нужный тип и выбрать правильный обработчик. И все это за О(1) с тратами на сдвиг элемента в массиве
Если же перечислителям явно присвоены числа, то этот номер не прокатит и надо думать дальше.
Без шаблонной магии нам не обойтись, ведь мы хотим только добавлять маппинги, но не менять самого кода обработчиков и логику их выбора.
И давайте заодно попробуем уйти от полной специализации шаблонов, как было в предыдущей части.
Сделаем структуру аля ноду маппинга:
template <DataType D, typename T>
struct Node { static constexpr DataType key = D; using type = T; };
В структуре хранится конкретный перечислитель, получаемый из шаблонного параметра, и соответствующий ему тип.
И в качестве пака шаблонных параметров нашей шаблонной функции мы будем передавать список нод
Node<DataType::kInt32, int32_t>, Node<DataType::kDouble, double>, Node<DataType::kUint64, uint64_t>.В функции мы должны сделать примерно следующее. Перебираем рантайм значение enum'а на соответствие значению Key из нод пака параметров. Перебираем, пока не найдем нужный и после используем правильную ноду для получения замаппленого типа.
И вот этот перебор можно делать через fold expression и короткозамкнутый оператор
||. Как только условие истинно, вычисления прекращаются.template <typename... Es>
DataVariant ConvertImpl(const void* buffer, DataType type) {
DataVariant result;
bool found = ((Es::key == type
? (result = create_from_buffer<typename Es::type>(buffer), true)
: false) || ...);
if (!found) throw std::runtime_error("Unknown type");
return result;
}
DataVariant Convert(const void* buffer, DataType type) {
return ConvertImpl<
Node<DataType::kInt32, int32_t>,
Node<DataType::kDouble, double>,
Node<DataType::kUint64, uint64_t>
>(buffer, type);
}
Также тут используется фишка оператора запятой, что результатом выражения является только самый крайний справа операнд.
Теперь вместо поиска элемента в массиве по индексу мы занимаемся линейным проходом выполнения логического ИЛИ для типов.
На первый взгляд это может быть сильно дольше, но компиляторы хорошо умеют оптимизировать fold expression, даже в худшем случае большой разницы не будет.
Код кстати по размеру уже не сильно больше изначального свитча.
Вот примерчик с рабочим кодом, можете поиграться.
Use tricks. Stay cool.
#template #cpp17
🔥12❤7👍7⚡1
std::inplace_vector
#опытным
Стандартная библиотека традиционно запаздывает с внедрением полезного функционала.
Вот у нас есть std::vector. Прекрасный контейнер, расширяемый. более менее все им пользуются. Однако у него есть проблема - динамические аллокации. От них не уйти. А если вам они не нужны, то вы вынуждены использовать другие инструменты. Да и еще и ограниченное использование вектора в constexpr контексте.
Ну ладно. Есть std::array. Нет скрытых аллокаций, давно можно использовать в constexpr. Красота.
Но как бы не так: не расширяемый он. Еще и элементы должны быть созданы сразу все и должны соответствовать требованию DefaultConstructable.
Короче опять недостатки.
Но в С++26 появился контейнер, который объединяет преимущества std::vector и std::array. Называется он std::inplace_vector.
По сути это динамически расширяемый массив с фиксированной в compile-time емкостью:
1️⃣ Элементы массива хранятся прям внутри объекта, поэтому нет никаких дополнительных аллокаций.
2️⃣ Объект inplace_vector сразу при создании содержит буфер размера capacity.
3️⃣ Можно создать объект без элементов вообще и изменять их набор как угодно во время выполнения программы. Главное не превышать capacity.
4️⃣ Так как этот массив не предполагает дополнительных динамических аллокаций, то контейнер можно полноценно использовать в constexpr контексте.
Рассмотрим небольшой примерчик, где нам нужно отфильтровать из std::array пложительные числа и возвести их в квадрат:
Мы не можем заранее сказать, сколько элементов вернет функция square_positive. Но мы можем гарантировать, что их количество <= размеру входного массива.
Создается inplace_vector пустым и элементы накидываются в него по очереди.
Проверки static_assert гарантируют, что все вычисления происходят в compile-time.
Еще одна особенность - у inplace_vector меньше требований к типу своих элементов:
Так как std::array создает сразу все свои элементы, конструирование arr завершится ошибкой, потому что
Также std::inplace_vector - хороший пример того, что стандартная библиотека в новых стандартах старается поддерживать апи с использованием исключений и без них. Есть принципиально 2 разных подхода к добавлению элементов в этот массив:
👉🏿
👉🏿
Есть кстати еще метод
В общем, крутой новый контейнер, который явно найдет себе место в системах с ограничениями кучи или суперпроизводительном софте.
Combine advantages. Stay cool.
#cpp26 #STL
#опытным
Стандартная библиотека традиционно запаздывает с внедрением полезного функционала.
Вот у нас есть std::vector. Прекрасный контейнер, расширяемый. более менее все им пользуются. Однако у него есть проблема - динамические аллокации. От них не уйти. А если вам они не нужны, то вы вынуждены использовать другие инструменты. Да и еще и ограниченное использование вектора в constexpr контексте.
Ну ладно. Есть std::array. Нет скрытых аллокаций, давно можно использовать в constexpr. Красота.
Но как бы не так: не расширяемый он. Еще и элементы должны быть созданы сразу все и должны соответствовать требованию DefaultConstructable.
Короче опять недостатки.
Но в С++26 появился контейнер, который объединяет преимущества std::vector и std::array. Называется он std::inplace_vector.
По сути это динамически расширяемый массив с фиксированной в compile-time емкостью:
1️⃣ Элементы массива хранятся прям внутри объекта, поэтому нет никаких дополнительных аллокаций.
2️⃣ Объект inplace_vector сразу при создании содержит буфер размера capacity.
3️⃣ Можно создать объект без элементов вообще и изменять их набор как угодно во время выполнения программы. Главное не превышать capacity.
4️⃣ Так как этот массив не предполагает дополнительных динамических аллокаций, то контейнер можно полноценно использовать в constexpr контексте.
Рассмотрим небольшой примерчик, где нам нужно отфильтровать из std::array пложительные числа и возвести их в квадрат:
template<size_t N>
constexpr std::inplace_vector<int, N> square_positive(const std::array<int, N>& arr) {
std::inplace_vector<int, N> result;
for (int x : arr) {
if (x > 0 && result.size() < result.capacity()) {
result.push_back(x * x);
}
}
return result;
}
int main() {
constexpr std::array<int, 6> data = {-3, 5, -1, 7, 0, 4};
constexpr auto squares = square_positive<6, 6>(data);
static_assert(squares.size() == 3);
static_assert(squares.capacity() == 6);
static_assert(squares[0] == 25);
static_assert(squares[1] == 49);
static_assert(squares[2] == 16);
return 0;
}
Мы не можем заранее сказать, сколько элементов вернет функция square_positive. Но мы можем гарантировать, что их количество <= размеру входного массива.
Создается inplace_vector пустым и элементы накидываются в него по очереди.
Проверки static_assert гарантируют, что все вычисления происходят в compile-time.
Еще одна особенность - у inplace_vector меньше требований к типу своих элементов:
struct S {
S() = delete;
S(int) { }
};
std::array<S, 5> arr; // compile error: S does not have a default constructor
std::inplace_vector<S, 5> inplace_vec; // okТак как std::array создает сразу все свои элементы, конструирование arr завершится ошибкой, потому что
S не имеет конструктора по умолчанию. Но inplace_vec успешно создается, потому что не имеет этого требования.Также std::inplace_vector - хороший пример того, что стандартная библиотека в новых стандартах старается поддерживать апи с использованием исключений и без них. Есть принципиально 2 разных подхода к добавлению элементов в этот массив:
👉🏿
constexpr reference push_back( const T& value ) — классический вариант, который выбрасывает std::bad_alloc при попытке превысить capacity.👉🏿
constexpr std::optional<reference> try_push_back( const T& value ) - добавляет элемент и возвращает на него ссылку или не добавляет элемент и возвращает std::nullopt. То есть ошибка обрабатывается с помощью возвращаемого значенияЕсть кстати еще метод
constexpr reference unchecked_push_back( const T& value );, который забивает на все проверки и перекладывает эту ответственность на пользователя. В случае превышения емкости получаем UB.В общем, крутой новый контейнер, который явно найдет себе место в системах с ограничениями кучи или суперпроизводительном софте.
Combine advantages. Stay cool.
#cpp26 #STL
❤18👍11😁6🔥4❤🔥3
💻 АЦП в ESP32. Оцифровать сигнал, а не "погоду на Марсе". Нюансы, особенности, повышение точности
Приглашаем на открытый урок.
🗓 24 августа в 20:00 МСК
🆓 Бесплатно. Урок в рамках старта курса «Embedded-разработчик».
Мы рассмотрим весь пусть сигнала - от датчика до его цифровой формы в микроконтроллере ESP32. Вспомним основные параметры, погрешности АЦП, увидим их влияние "вживую", узнаем, почему иногда теоремы Котельникова недостаточно и что с этим делать? Поговорим о важности источника опорного напряжения, произведем расчет бюджета погрешностей тракта АЦП.
Применим калибровку и сравним результаты до\после на АЦП, после чего рассмотрим простые методы фильтрации исходно зашумленных сигналов.
Вебинар будет полезен: радиолюбителям, начинающим разработчикам электроники, инженерам-схемотехникам, разработчикам встраиваемого программного обеспечения
В результате вебинара слушатели узнают:
✔️ все основные параметры АЦП и смогут увидеть их влияние вживую на примере ESP32
✔️ особенности и нюансы схемотехники при проектировании тракта АЦП
✔️ что такое "алиасинг" и как его избежать
✔️ как провести обобщенный расчет погрешностей тракта АЦП
✔️ как откалибровать АЦП в ESP32
✔️ простые методы фильтрации зашумленных сигналов
🔗 Ссылка на регистрацию: https://otus.pw/X5Wt/
Реклама. ООО «Отус онлайн-образование», ОГРН 1177746618576
Приглашаем на открытый урок.
🗓 24 августа в 20:00 МСК
🆓 Бесплатно. Урок в рамках старта курса «Embedded-разработчик».
Мы рассмотрим весь пусть сигнала - от датчика до его цифровой формы в микроконтроллере ESP32. Вспомним основные параметры, погрешности АЦП, увидим их влияние "вживую", узнаем, почему иногда теоремы Котельникова недостаточно и что с этим делать? Поговорим о важности источника опорного напряжения, произведем расчет бюджета погрешностей тракта АЦП.
Применим калибровку и сравним результаты до\после на АЦП, после чего рассмотрим простые методы фильтрации исходно зашумленных сигналов.
Вебинар будет полезен: радиолюбителям, начинающим разработчикам электроники, инженерам-схемотехникам, разработчикам встраиваемого программного обеспечения
В результате вебинара слушатели узнают:
✔️ все основные параметры АЦП и смогут увидеть их влияние вживую на примере ESP32
✔️ особенности и нюансы схемотехники при проектировании тракта АЦП
✔️ что такое "алиасинг" и как его избежать
✔️ как провести обобщенный расчет погрешностей тракта АЦП
✔️ как откалибровать АЦП в ESP32
✔️ простые методы фильтрации зашумленных сигналов
🔗 Ссылка на регистрацию: https://otus.pw/X5Wt/
Реклама. ООО «Отус онлайн-образование», ОГРН 1177746618576
❤4👍4🔥3
Гибридные вектора
#опытным
std::inplace_vector воплощает в себя преимущества динамического расширения размеров и отсутствия аллокаций. Полезная штука, но и она ограничена. Literally: вы не можете добавить в массив элементов больше, чем capacity.
И это нормальные ограничения. Нельзя иметь неограничено расширяемый массив на стеке.
Но что если я знаю, что в 99% случаев мой массив будет содержать не больше N элементов. Но все же иногда нужно будет хранить потенциально намного больше элементов. Что делать?
Ну для начала есть small object optimization для стандартного std::vector. Вместо того, чтобы хранить элементы в куче, небольшое число маленьких по размеру элементов можно хранить на стеке. И при добавлении элементов больше порога уже использовать динамические аллокации. Как SSO в std::string.
Но это не гарантированная оптимизации и зависит от реализации стандартной библиотеки. Плюс невозможно управлять размером этого маленького буфера.
Но можно сделать и более управляемый вариант.
Пусть будет динамический контейнер, в котором мы гарантировано сможем расположить на стеке N элементов, а при превышении этого порога будет триггериться динамическое выделение памяти под буфер большего размера.
Такой комбинированный подход позволяет и рыбку съесть, и на правильный стул сесть. Аллокаций на минимуме, перф суперблейзинговый и привычный интерфейс.
Вот несколько представителей этого подхода:
- absl::InlinedVector.
- boost::container::small_vector.
- llvm::SmallVector.
- folly::small_vector.
Combine advantages. Stay cool.
#performance #memory #optimization
#опытным
std::inplace_vector воплощает в себя преимущества динамического расширения размеров и отсутствия аллокаций. Полезная штука, но и она ограничена. Literally: вы не можете добавить в массив элементов больше, чем capacity.
И это нормальные ограничения. Нельзя иметь неограничено расширяемый массив на стеке.
Но что если я знаю, что в 99% случаев мой массив будет содержать не больше N элементов. Но все же иногда нужно будет хранить потенциально намного больше элементов. Что делать?
Ну для начала есть small object optimization для стандартного std::vector. Вместо того, чтобы хранить элементы в куче, небольшое число маленьких по размеру элементов можно хранить на стеке. И при добавлении элементов больше порога уже использовать динамические аллокации. Как SSO в std::string.
Но это не гарантированная оптимизации и зависит от реализации стандартной библиотеки. Плюс невозможно управлять размером этого маленького буфера.
Но можно сделать и более управляемый вариант.
Пусть будет динамический контейнер, в котором мы гарантировано сможем расположить на стеке N элементов, а при превышении этого порога будет триггериться динамическое выделение памяти под буфер большего размера.
Такой комбинированный подход позволяет и рыбку съесть, и на правильный стул сесть. Аллокаций на минимуме, перф суперблейзинговый и привычный интерфейс.
Вот несколько представителей этого подхода:
- absl::InlinedVector.
- boost::container::small_vector.
- llvm::SmallVector.
- folly::small_vector.
Combine advantages. Stay cool.
#performance #memory #optimization
❤15👍10🔥5
Критическая секция. Блок кода
#новичкам
Термин "критическая секция" имеет 2 значения и это несколько конфузит как новичков, так и опытных разрабов, кто просто не встречался с двумя личинами термина. Сейчас и в следующем посте разберем оба значения.
Начнем с простого.
Одна из самых больших опасностей многопоточного кода - это гонка данных. Когда 2 потока пытаются читать и записывать в одну ячейку памяти без использования синхронизации.
Проблема гонки на самом деле в том, что потоки могут увидеть промежуточное состояние комплексной операции и вклиниться посередине. Даже банальный инкремент инта это 3 операции: чтение, модификация и запись.
Но если мы уже будем говорить про такие комплексные операции, как вставка элемента в контейнер, то проблема становится очень явной. Невозможно потокобезопасно, например, проверить превышение size на capacity в векторе, выделить новую память, перенести туда все элементы вектора и создать новый объект. И чтобы между этих операций в контейнер не вмешался другой поток.
Чуть более понятный пример:
Если 2 потока одновременно зайдут в эту функцию, то оба уменьшат баланс. Мало того, что с клиента снимут больше денег, так он еще и должным может остаться(баланс уйдет в минуса).
То есть существуют участки кода, внутри которых происходит доступ к разделяемому ресурсу и там каждый момент времени может исполняться максимум один поток. Такие участки кода называют критическими секциями.
Критические секции должны быть защищены примитивами синхронизации, обеспечивающими взаимное исключение потоков. Например, мьютекс:
Критической секцией также может быть только часть функции:
В этом случае очень удобно обозначать ее вложеным скоупом (фигурными скобками) для лучшего выделения на фоне остального кода. Также выход из блока кода триггерит разрушение его локальных объектов, поэтому вам в большинстве случаев не нужно явно вызывать mtx.unlock().
Критические секции стоит делать как можно меньшего размера, чтобы время блокировки потоков было минимально.
Спасибо @antom1n за идею для поста
Think critical. Stay cool.
#concurrency
#новичкам
Термин "критическая секция" имеет 2 значения и это несколько конфузит как новичков, так и опытных разрабов, кто просто не встречался с двумя личинами термина. Сейчас и в следующем посте разберем оба значения.
Начнем с простого.
Одна из самых больших опасностей многопоточного кода - это гонка данных. Когда 2 потока пытаются читать и записывать в одну ячейку памяти без использования синхронизации.
Проблема гонки на самом деле в том, что потоки могут увидеть промежуточное состояние комплексной операции и вклиниться посередине. Даже банальный инкремент инта это 3 операции: чтение, модификация и запись.
int counter = 0; // unprotected variable
void increment() {
for (int i = 0; i < 10000; ++i) {
++counter; // data race
}
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
// expected 20000, but there is data race so counter will never be eq to the number
std::cout << "Final counter value: " << counter << std::endl;
return 0;
}
Но если мы уже будем говорить про такие комплексные операции, как вставка элемента в контейнер, то проблема становится очень явной. Невозможно потокобезопасно, например, проверить превышение size на capacity в векторе, выделить новую память, перенести туда все элементы вектора и создать новый объект. И чтобы между этих операций в контейнер не вмешался другой поток.
Чуть более понятный пример:
struct Balance {
void withdraw_balance(int amount) {
if (balance_ >= amount {
balance_ -= amount;
}
}
private:
int balance_;
};Если 2 потока одновременно зайдут в эту функцию, то оба уменьшат баланс. Мало того, что с клиента снимут больше денег, так он еще и должным может остаться(баланс уйдет в минуса).
То есть существуют участки кода, внутри которых происходит доступ к разделяемому ресурсу и там каждый момент времени может исполняться максимум один поток. Такие участки кода называют критическими секциями.
Критические секции должны быть защищены примитивами синхронизации, обеспечивающими взаимное исключение потоков. Например, мьютекс:
struct Balance {
vvoid withdraw_balance(int amount) {
std::lock_guard lg{mtx_};
if (balance_ >= amount {
balance_ -= amount;
}
}
private:
int balance_;
std::mutex mtx_;
};
Критической секцией также может быть только часть функции:
template<typename T>
class ThreadSafeQueue {
std::queue<T> queue_;
std::mutex mtx_;
public:
std::queue<T> pop_all() {
std::queue<T> result;
{ // critical section begin
std::lock_guard<std::mutex> lock(mtx_);
std::swap(result, queue_);
} // critical section end
return result;
}
};
В этом случае очень удобно обозначать ее вложеным скоупом (фигурными скобками) для лучшего выделения на фоне остального кода. Также выход из блока кода триггерит разрушение его локальных объектов, поэтому вам в большинстве случаев не нужно явно вызывать mtx.unlock().
Критические секции стоит делать как можно меньшего размера, чтобы время блокировки потоков было минимально.
Спасибо @antom1n за идею для поста
Think critical. Stay cool.
#concurrency
❤22👍6🔥4