Грокаем C++


Гео и язык канала: Россия, Русский
Категория: Технологии


Два сеньора C++ - Владимир и Денис - отныне ваши гиды в этом дремучем мире плюсов.
По всем вопросам (+ реклама) @ninjatelegramm
Менеджер: @Spiral_Yuri
Реклама: https://telega.in/c/grokaemcpp
Мы на TGstat: https://tgstat.ru/channel/@grokaemcpp/stat

Связанные каналы  |  Похожие каналы

Гео и язык канала
Россия, Русский
Категория
Технологии
Статистика
Фильтр публикаций


Критическая секция. Примитив синхронизации
#опытным

Стандартный ответ на обычный вопрос "а какие примитивы синхронизации вы знаете?" - мьютексы, атомарные переменные, условные переменные. Для них есть привычные инструменты в языке - std::mutex, std::atomic и std::condition_variable.

Иногда проскальзывают в ответах более опытных людей людей семафоры и барьеры - std::counting_semophore и std::barrier.

Но очень очень редко кто-то да упомянет критическую секцию.

Если вы пишите только на плюсах и кроссплатформенный код, то вряд ли вообще слышали про этот примитив. В стандартной библиотеке нет класса std::critical_section. Тем не менее такой примитив действительно есть, просто в чистом Windows API.

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

Сейчас мы отходим от языка и погружаемся в системное апи.

Оба эти примитива выполняют одинаковую функцию - обеспечивают взаимное исключение потоков. Разница в нюансах

Мьютекс Windows API может быть использован для синхронизации между несколькими процессами
(если он именованый). CRITICAL_SECTION помогает синхронизировать потоки только внутри одного процесса.

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

Вот и вся разница.

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

На уровне С++ мы имеем все тот же std::mutex, который скорее всего также использует эту оптимизацию, поэтому в языке про CRITICAL_SECTION ни сном, ни духом.

Think critical. Stay cool.

#concurrency


Приглашаем на бесплатный открытый вебинар курса «Программист С»: «С без компромиссов: обработка ошибок и полиморфизм»
 
Когда: 25 августа, 20:00 (мск)
Язык С даёт полный контроль над памятью и железом, но за эту мощь приходится платить — в нём нет встроенных исключений и классов. Однако, это не значит, что отказоустойчивость и гибкость недоступны. На вебинаре разберём техники, которые обычно остаются за кадром, и покажем, как писать на С надёжно, масштабируемо и без ущерба для производительности.

Что будет на вебинаре:
Рассмотрим setjmp/longjmp — аналог исключений, который позволяет «телепортироваться» сквозь стек вызовов;
Создадим виртуальные таблицы (vtable) — руками реализуем полиморфизм, как в C++;
Соединим обе техники в одном проекте – создадим систему логирования с централизованной обработкой ошибок.

Зарегистрироваться https://otus.pw/4vWb/

Бесплатное занятие приурочено к старту курса «Программист С», на котором вы освоите продвинутые приёмы работы с памятью, многопоточностью, сетевым взаимодействием и созданием высоконагруженных систем на чистом С.

Реклама. ООО «Отус онлайн-образование», ОГРН 1177746618576, www.otus.ru


​​Критическая секция. Блок кода
#новичкам

Термин "критическая секция" имеет 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

1.9k 0 17 19 34

​​Гибридные вектора
#опытным

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

2.1k 0 14 27 31

​​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 пложительные числа и возвести их в квадрат:

template
constexpr std::inplace_vector square_positive(const std::array& arr) {
std::inplace_vector result;
for (int x : arr) {
if (x > 0 && result.size() < result.capacity()) {
result.push_back(x * x);
}
}
return result;
}

int main() {
constexpr std::array data = {-3, 5, -1, 7, 0, 4};
constexpr auto squares = square_positive(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. Но мы можем гарантировать, что их количество

2.4k 0 13 19 42

Мапим значения на типы. Ч2
#опытным

Напомню проблему:

enum class DataType: uint8_t {
kInt32,
kDouble,
kUint64
};

template
T create_from_buffer(const void* buffer) {
static_assert(std::is_trivially_copyable_v,
"Type T must be trivially copyable");

T obj;
std::memcpy(&obj, buffer, sizeof(T));
return obj;
}

Как на основе DataType создать объект соответствующего типа?

Compile-time мапа из массива, где по индексу подкапотного числа, стоящего за каждым перечислителем, позволяет получить соответствующий нужный тип и выбрать правильный обработчик. И все это за О(1) с тратами на сдвиг элемента в массиве

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

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

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

Сделаем структуру аля ноду маппинга:

template
struct Node { static constexpr DataType key = D; using type = T; };

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

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

В функции мы должны сделать примерно следующее. Перебираем рантайм значение enum'а на соответствие значению Key из нод пака параметров. Перебираем, пока не найдем нужный и после используем правильную ноду для получения замаппленого типа.

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

template
DataVariant ConvertImpl(const void* buffer, DataType type) {
DataVariant result;
bool found = ((Es::key == type
? (result = create_from_buffer(buffer), true)
: false) || ...);
if (!found) throw std::runtime_error("Unknown type");
return result;
}

DataVariant Convert(const void* buffer, DataType type) {
return ConvertImpl<
Node,
Node,
Node
>(buffer, type);
}

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

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

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

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

Вот примерчик с рабочим кодом, можете поиграться.

Use tricks. Stay cool.

#template #cpp17


Мапим значения на типы. Ч1
#опытным

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

Например, у вас есть шаблонная функция и вы хотите вызвать правильную ее инстанциацию:

enum class DataType {
kInt32,
kDouble,
kUint64
};

template
T create_from_buffer(const void* buffer) {
static_assert(std::is_trivially_copyable_v,
"Type T must be trivially copyable");

T obj;
std::memcpy(&obj, buffer, sizeof(T));
return obj;
}

Как на основе DataType создать объект нужного типа?

Пойдем доисторическим способом. Там, где есть enum, всегда где-то в углу стоит застенчивый switch и хочет быть использован. Ну давайте попробуем:

using DataVariant = std::variant;

DataVariant foo(const void* buffer, DataType type) {
switch (type) {
case DataType::kInt32:
return create_from_buffer(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 struct EnumMap;
template struct EnumMap { using Type = int32_t; };
template struct EnumMap { using Type = double; };
template struct EnumMap { using Type = uint64_t; };

template
using EnumMappedType = typename EnumMap::Type;

Используем полную специализацию шаблонов и зависимые типы.

Дальше нужна логика обработки

using DataVariant = std::variant;
using MakerFn = DataVariant()(const void);

template
constexpr auto make_table(std::index_sequence) {
return std::array{
+[](const void* buffer) -> DataVariant {
return create_from_buffer(buffer);
}...
};
}

static constexpr auto dispatcher =
make_table(std::make_index_sequence{});

Через fold expression делаем массив обработчиков для каждого элемента перечисления. Это гарантирует std::make_index_sequence, который раскрывается в последовательность индексов перечислителей вплоть до последнего kCount.

Для этого и было условие плотности enum'а, чтобы make_index_sequence корректно передавал индексы.

Для каждого обработчика выбираем нужный тип через EnumMappedType.

Плюсик перед лямбдой кастит ее к указателю на функцию. Это чтобы не использовать опасный для перфа std::function.

Ну а логику выбора написать уже очень просто:

DataVariant foo(const void* buffer, DataType type) {
auto idx = static_cast(type);
if (idx >= dispatcher.size())
throw std::runtime_error("Unknown type");
return dispatcher[idx](buffer);
}

Итого, мы явно разделили 3 задачи: описание обработчиков, маппинг и сам выбор обработчика.

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

Divide and conquer. Stay cool.


​​Ответ
#опытным

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"); // OK

using 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); // вызывает #2

Don't let others shadow you. Stay cool

#cppcore

3k 0 23 20 58

Какие из утверждений ниже правдивы?
Опрос
  •   c.func(3.14f); не скомпилируется
  •   c.func(123); вызывает #3
  •   c.func("hello"); вызывает #1
  •   c.func(3.14f); вызывает #2
  •   b.func(3.14f); вызывает #2
330 голосов


Квиз

Выберете правильные утверждения о вызовах в коде:

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;


​​Оверхэд системных вызовов
#опытным

Как измерить оверхэд системного вызова?

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

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

Что делать?

Брать самый легковесный сискол. Который делает минимальную работу.

Список сисколов можно найти в man'е. Изучив их поближе, я понял, что один и самых дешевых системных вызовов это getpid.

Все, что он делает - возвращает ID процесса. По сути одно чтение из памяти.

Однако библиотечная функция getpid() не всегда делает системный вызов. Айди процесса не меняется и это отличный кандидат для кэширования результата. И по сути эта библиотечная обертка делает сискол только в первый свой вызов. Дальше результат кэшируется в обертке и выдается из кэша.

Как обойти эту неприятность? Было бы круто уметь напрямую вызывать сискол без этих оберток со своей логикой.

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

#include
#include
#include
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

3k 0 21 13 44

​​Что происходит при системном вызове?
#опытным

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

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

Системный вызов является фундаментальным интерфейсом между приложением и ядром системы. Только через него пользователи могут запросить у системы выполнение какой-то задачи.

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

Будем все рассматривать на примере linux, как самой популярной серверной ОС.

0️⃣ В коде все начинается с какой-нибудь обертки для системного вызова

Оператор


​​Есть ли в С++ отрицательный ноль?
#опытным

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

Какие у нас вообще числа есть?

Знаковые и беззнаковые целые, а также вещественные. Начнем с простого.

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

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

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

Но в С++20 стандарте четко зафиксировали использование дополнительного кода, в котором только один ноль.

А что с вещественными числами?

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

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

value=(−1)^S × 2^E × (1+M)
Ноль задается нулевой мантиссой и экспонентой. Но у нас же есть и знаковый бит еще. Поэтому на большинстве платформах в С++ есть отрицательный вещественный ноль. Кстати наряду с положительными и отрицательными бесконечностями.

Be positive. Stay cool.

#compiler #base #cpp20


​​Ответ на квиз
#новичкам

Напомню код:

std::string makeText()
{
std::string s = "Hello World!";
return s;
}
 
int main()
{
std::string t = makeText();
std::cout

4k 0 14 16 41

Cколько динамических аллокаций будет в коде из поста выше?
Опрос
  •  
  •   1
  •   2
  •   3
  •   4
  •   5
784 голосов


Квиз
#новичкам

Очень простой #quiz для по С++, который открывает ящик пандоры и довольно сложные концепции в том, как все под капотом реализовано.

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

std::string makeText()
{
std::string s = "Hello World!";
return s;
}
 
int main()
{
std::string t = makeText();
std::cout

4k 0 12 13 19

​​Сколько процессов можно запустить на одной машине?
#опытным

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

В windows довольно простой ответ.

Жестких лимитов на количество процессов нет. Разве что PID - это 32-битное число, поэтому больше физически создать нельзя.

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

С линукс чуть интереснее. В его ядре вообще нет процессов per se. Есть задачи.

Ядро Linux имеет параметр kernel.pid_max, который определяет максимально возможный идентификатор задачи. Исторически значение по умолчанию было 65535, но в современных ядрах оно может быть значительно выше — до нескольких миллионов.

Плюс для каждого пользователя действует ограничение nproc(number of processes). Его можно посмотреть командой ulimit -u:

👉🏿 По умолчанию это значение часто составляет 1024

👉🏿 Ограничение бывает "мягким" (soft) и "жестким" (hard). Пользователь может увеличить мягкий лимит, но не выше жесткого.

👉🏿 Эти лимиты настраиваются в файлах /etc/security/limits.conf или /etc/security/limits.d/.

Допустим, у вас лимит в 100 задач. Вы можете запустить 50 процессов, в каждом из которых по 1 потоку. Или 1 процесс и 99 потоков внутри него. Главное, чтобы сумма не превысила 100.

Ну и рассуждения про ограничения ресурсов машины здесь тоже имеют место быть.

Scale up. Stay cool.

#concurrency #OS


​​Сколько потоков можно запустить на одной машине?
#опытным

Из всех утюгов говорят про асинхронность вычислений и экономию ресурсов ОС. Зачем это нужно? И почему я не могу на каждую задачу запускать отдельный поток? Есть ли вообще какой-нибудь предел по количеству потоков?

Давайте раззззбираться.

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

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

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

И ограничено оно количеством вычислительных юнитов процессора. Максимальное число реально параллельных потоков можно узнать с помощью std::thread::hardware_concurrency().

Есть кстати технология HyperThreading от Intel, которая позволяет на одном физическом ядре получить 2 логических, увеличивая таким образом число потенциально параллельных потоков в 2 раза. Ускорения в 2 раза конечно не будет, но тем не менее.

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

Поток - это ресурс, который занимает место и им нужно управлять. Как минимум под каждый поток выделяется свой стек. На десктопах это 1-8 МБ. Поделите количество оперативной памяти на размер стека и получите максимально возможное количество потоков, которое в принципе может поместиться в память. В реальности количество будет еще меньше.

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

Ну и в некоторых системах явно прописано ограничение на количество потоков. В линуксе, например, можно увидеть заветную числеку с помощью команды cat /proc/sys/kernel/threads-max.

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

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

Scale up. Stay cool.

#concurrency #OS


​​Зачем нужен деструктор?
#новичкам

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

Несколько раз слышал ответ: "освобождает память, занятую объектом".

Следом идет вопрос: - "То есть деструктор деаллоцирует память, на месте которой находится объект?". После положительного ответа я привожу пару примеров:

void foo() {
std::vector vec = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
std::cout ~ArrType();
}

Происходит ли деаллокация динамической памяти в этом случае? Что здесь делает деструктор?

На самом деле деструктор просто семантически разрушает объект. Собственно это и есть значение слова "destructor" - разрушитель.

На такие темы надо рассуждать с точки зрения лайфтайма объекта. Конструктор создает объект и начинает его время жизни(даже если он ничего не делает). Деструктор же разрушает объект и заканчивает его время жизни.

Заметьте, пока про ресурсы ни слова.

Будут ли хоть какие-то ресурсы у объекта такого класса?

struct A {
int i;
char c;
};

Нет. Хотя у него есть конструктор и деструктор. Да, тривиальные, но есть же.

Когда я делаю:

void foo() {
A obj;
}

foo();

Вызывается и конструктор, и деструктор(при выходе из скоупа функции). Аллокацию и деаллокацию памяти под obj выполняет сам код, обеспечивающий запуск функции.

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

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

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

Вернемся к примерам из начала поста.

void foo() {
using ArrType = std::array; // just for explicit destructor call
auto * ptr = new ArrType{0, 1, 2, 3, 4};
ptr->~ArrType();
}

std::array - это тонкая обертка над сишным массивом. Для его работы не нужны дополнительные ресурсы: его элементы располагаются ровно там, где аллоцировали память под сам объект. Поэтому и деструктор у него ничего не делает.

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

Теперь другой:

void foo() {
std::vector vec = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
std::cout


​​Tricky move
#новичкам

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

struct Example {
~Example() {}
};

Example a;
Example b;
a = std::move(b);

Вы не получите ошибку компиляции и все запустится без проблем.

Просто код будет работать не совсем так, как вы ожидаете.

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

Однако вызовется копирующее присваивание.

#ЧЗХ?

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

Но не мешает копирующим!


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

Результат вызова std::move - rvalue reference aka &&. А правые ссылки спокойно биндятся к const &. И именно такой тип параметра принимает копирующее присваивание.

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

Еще один прикол на стыке легаси С++ и его модерновой версией.

See through tricks. Stay cool.

#cpp11

Показано 20 последних публикаций.