Грокаем C++
رفتن به کانال در Telegram
Два сеньора C++ - Владимир и Денис - отныне ваши гиды в этом дремучем мире плюсов. По всем вопросам (+ реклама) @ninjatelegramm Менеджер: @Spiral_Yuri Реклама: https://telega.in/c/grokaemcpp Мы на TGstat: https://tgstat.ru/channel/@grokaemcpp/stat
نمایش بیشتر9 377
مشترکین
+324 ساعت
+117 روز
+1930 روز
آرشیو پست ها
9 380
Сколько раз можно разыменовать лямбду?
#опытным
Давайте для начала поговорим о том, сколько раз можно разыменовать указатель?
Зависит от того, какой указатель мы имеем ввиду.
Тип
int* можно разыменовать всего один раз. Получившийся объект после разыменования будет lvalue int, для которого отсутствует перегрузка operator*:
int i = 5;
int * p = &i;
std::cout << **p << std::endl; // error: indirection requires pointer operand ('int' invalid)
Но мы конечно же можем определить указатель на указатель и добиваться очень глубоких уровней индирекции. Однако мы все равно сможем разыменовать такой указатель ровно по количеству этих уровней, не больше:
int i = 5;
int * p = &i;
int ** p1 = &p;
std::cout << **p1 << std::endl; // OK
std::cout << **p1 << std::endl; // Error
Сколько же раз можно разыменовывать лямбду?
Так стоп. Как в лямбде можно применять оператор?
Лямбда без захвата неявно кастится к указателю на функцию. А указатель можно разыменовывать.
Так сколько?
Бесконечно
#include <iostream>
int main() {
auto ret = *****************************************[]{ return 23; };
std::cout << ret() << std::endl;
}
Чисто технически мы конечно ограничены количеством атомов во вселенной или, что более реально, возможностями компилятора обрабатывать длиннющие тексты программ. Но формальных ограничений нет.
#ЧЗХ?
Давайте подумаем, что происходит при разыменовании лямбды. Она приводится к указателю на функцию, оператор применяется и результатом мы получаем lvalue функции(саму функцию, а не указатель). Тип этого выражения – int().
А функции у нас что любят делать? Правильно, неявно преобразовываться к указателю на саму себя.
Поэтому можем применить оператор еще разик.
А потом еще и еще. И еще, и еще, и еще, и еще... Ну вы поняли.
В любом случае ret по итогам того же неявного преобразования будет иметь тип указателя на фукнцию:
static_assert(std::is_same_v<decltype(ret), int()()>);
По тем же рассуждениям, кстати, любой указатель на функцию тоже можно бесконечно разыменовать.
Спасибо, @Ivaneo, за любезно предоставленный примерчик.
Be limited only by your imagination. Stay cool.
#cppcore9 380
Loop unrolling. pragma unroll
#опытным
Компилятор конечно умный и сам умеет разворачивать циклы. Но делает он это не во всех случаях. Когда-то уровень оптимизации не позволяет(агрессивная развертка делается на O3) или количество итераций цикла неизвестно.
Если же вы хотите вручную контролировать развертку, но не писать код развертки руками - для вас есть один вариант. Это
#pragma unroll.
Правда, есть загвоздка. Стандартного синтаксиса этой директивы не существует, для каждого компилятора чуть свой синтаксис:
-👉🏿GCC и Clang: Используется #pragma GCC unroll Для Clang существует более детализированный синтаксис #pragma clang loop unroll.
👉🏿 MSVC (Microsoft Visual C++): здесь все, как не у всех: в msvc нет прямого аналога директивы #pragma unroll. Есть #pragma loop, но она контролирует скорее автовекторизацию, чем развертку.
👉🏿 Intel и NVIDIA компиляторы: Используют #pragma unroll.
👉🏿 ARM компилятор: Поддерживает #pragma unroll.
Раз у всех все свое, то в примерах будет использовать некую усредненную версию.
По сути есть 2 возможных варианта использования директивы:
🙈 Полная развертка
#pragma unroll
for (int i = 0; i < 12; ++i) {
p1[i] += p2[i] * 2;
}
В этом случае компилятор обязан знать число итераций цикла, чтобы полностью его развернуть.
Плюс чем больше итераций, тем сильнее распухает код бинаря.
Поэтому в данном виде директива встречается не так часто.
☃️ Частичная развертка
Это именно та история, которую мы обсуждаем всю серию статей. #pragma unroll N позволяет развернуть цикл так, чтобы за одну итерацию тело цикла выполнялось N раз.
#pragma GCC unroll 4
for (size_t i = 0; i < n; ++i) {
sum += arr[i];
}
Как прагма работает?
1️⃣ Препроцессор оставляет #pragma unroll после препроцессинга в единице трансляции для того, чтобы компилятор смог ее учесть.
2️⃣ На этапе семантического анализа компилятор встречает #pragma unroll и связывает его со следующим за ним циклом.
3️⃣ Специальная сущность в компиляторе, отвечающая за развертку циклов, слушает и повинуется прагме и делает столько операций в цикле, сколько сказал разработчик. Плюс добавляет обработку хвоста.
Здесь можно посмотреть примерчики с gcc-шной версией прагмы.
Напомню, что компилятор сам хорошо умеет оптимизировать циклы, особенно на O3. Используйте прагму только тогда, когда вы сами ручками проверили, что компилятор в вашем случае бессилен или делает что-то неправильно.
Плюс обязательно делайте перфоманс тестирование, чтобы выявить действительно лучший по производительности вариант.
Measure your performance. Stay cool.
#compiler #optimization #performance9 380
Loop unrolling. Duff's device
#опытным
Если у вас чуть подвытекали глаза, когда вы увидели в прошлом посте эту схему со свитчами и гоуту, то сегодня они вытекут окончательно.
Если приглядеться в механику этого кода
switch (r) {
case 3: goto rest3;
case 2: goto rest2;
case 1: goto rest1;
default: goto main_loop;
}
rest3:
output[i] = i * i; ++i;
rest2:
output[i] = i * i; ++i;
rest1:
output[i] = i * i; ++i;
main_loop:
for (; i < count; i += 4) {
output[i] = i * i;
output[i + 1] = (i + 1) * (i + 1);
output[i + 2] = (i + 2) * (i + 2);
output[i + 3] = (i + 3) * (i + 3);
}
То можно заметить одну вещь. Мы используем возможность беспретятственного прохождения исполнения сквозь метки при этом имеем возможность воткнуться в любой момент времени, перейдя по нужной метке.
Но эта механика же полностью закрывается функциональностью switch. Код может проходить от кейса к кейсу, пока не встретит break и изначально можно перейти в любой из кейсов.
Давайте сделаем трах-тебедох и представим код в другом виде:
if (count == 0) return;
size_t i = 0;
size_t n = (count + 3) / 4;
switch (count % 4) {
case 0:
do {
output[i] = i * i; i++;
case 3:
output[i] = i * i; i++;
case 2:
output[i] = i * i; i++;
case 1:
output[i] = i * i; i++;
} while (--n > 0);
}
Как это работает
👉🏿 n – число полных итераций развёрнутого цикла, необходимых для обработки всех элементов (с учётом возможного неполного первого прохода).
👉🏿 switch (count % 4) выбирает точку входа в тело цикла do-while в зависимости от остатка. При остатке 0 начинаем с начала (полная итерация), при остатке 3 – с третьей операции и т.д.
👉🏿 Тело цикла содержит четыре одинаковых операции (вычисление квадрата и сдвиг индекса), размеченных метками case. За счёт проваливания (fall-through) первая (неполная) итерация выполняет ровно столько операций, сколько нужно для обработки хвоста, а все последующие итерации – по четыре операции.
👉🏿 Цикл продолжается, пока --n > 0, что гарантирует обработку всех элементов.
Первым такую особенность заметил мисье Том Дафф. В тот момент он работал в Lucasfilm и пытался оптимизировать копирование данных в memory-mapped регистр для анимации реального времени. Ему нужно было развернуть цикл для скорости, но была проблема — количество итераций не всегда кратно размеру развёртки.
Он вдохновился приемами из ассемблера и написал на языке K&R С(одна из ранних версий С, до стандартизации) вот такую конструкцию:
send(to, from, count)
register short *to, *from;
register count;
{
// Begin of the function
register n = (count + 7) / 8;
switch (count % 8) {
case 0: do { *to = *from++;
case 7: *to = *from++;
case 6: *to = *from++;
case 5: *to = *from++;
case 4: *to = *from++;
case 3: *to = *from++;
case 2: *to = *from++;
case 1: *to = *from++;
} while (--n > 0);
}
}
Функция send - это такой аналог memcpy, до внедрения в стандарт этой функции. Идея такая же: прыгаем в середину свитча для обработки остатка, а дальше крутимся в развернутом while, пока не скопируем все элементы.
С тех пор код такого вида называется Duff's device или устройство Даффа.
В 80-е техника давала буст производительности хоть и вызывала вопросы к читаемости кода и в целом к средствам языка, которые предоставляют такие возможности.
Но в современном мире не стоит даже и пытаться нечто такое написать в проде.Компиляторы cами отлично разворачивают циклы, часто лучше, чем ручная оптимизация.
И вообще перед применением любой оптимизации кода следует выполнить его бенчмаркинг или изучить сгенерированный компилятором вывод, чтобы убедиться, что код работает ожидаемым образом на целевой архитектуре, уровне оптимизации и компиляторе.
Тем не менее Duff's device остался в истории как пример вырвиглазной эксплуатации тонкостей языка в гонке за производительностью.
Be relevant. Stay cool.
#compiler #optimization #performance9 380
Loop unrolling. Быстрая обработка хвостов
#новичкам
В прошлый раз мы пришли к важной мысли: нам нужно обрабатывать хвосты развернутых циклов и делать это быстро.
В С++ коде пока непонятно, как это сделать. Но в ассемблере есть довольно старая техника для этого.
Возьмем С++ код:
void output_squares(size_t count, size_t *output) {
for (size_t i = 0; i < count; ++i) {
*output++ = i * i;
}
}
В целом, мы хотим, чтобы хвосты обрабатывались примерно так:
#include <cstddef>
void output_squares(size_t count, size_t *output) {
size_t i = 0;
size_t remainder = count % 4;
// Эта часть назвается пролог
if (remainder == 3) {
output[2] = 2 * 2;
output[1] = 1 * 1;
output[0] = 0 * 0;
i = 3;
} else if (remainder == 2) {
output[1] = 1 * 1;
output[0] = 0 * 0;
i = 2;
} else if (remainder == 1) {
output[0] = 0 * 0;
i = 1;
}
for (; i < count; i += 4) {
output[i] = i * i;
output[i + 1] = (i + 1) * (i + 1);
output[i + 2] = (i + 2) * (i + 2);
output[i + 3] = (i + 3) * (i + 3);
}
}
Только без кучи повторяющегося кода, количество которого будет только увеличиваться с увеличением фактора разворачивания цикла.
Идея: на месте пролога напишем 4 инструкции присваивания, а между ними поставим метки. В начале мы узнаем остаток и прыгаем на нужную метку. После чего раз за разом проваливаемся в следующую метку и в итоге в развернутый цикл. То есть в начале обрабатываем хвост правильного размера с помощью меток, а потом уже попадаем в основной цикл. В переводе на С++ это выглядит так:
void output_squares(size_t count, size_t *output) {
size_t i = 0;
size_t r = count % 4;
switch (r) {
case 3: goto rest3;
case 2: goto rest2;
case 1: goto rest1;
default: goto main_loop;
}
rest3:
output[i] = i * i; ++i;
rest2:
output[i] = i * i; ++i;
rest1:
output[i] = i * i; ++i;
main_loop:
for (; i < count; i += 4) {
output[i] = i * i;
output[i + 1] = (i + 1) * (i + 1);
output[i + 2] = (i + 2) * (i + 2);
output[i + 3] = (i + 3) * (i + 3);
}
}
Выглядит адски: свитч, какие-то метки, на которых одинаковый код, goto. За такое сразу бы запретили человеку коммитить код, только документацию писать отныне и навсегда. Поэтому никто так не пишет.
Зато эту лапшу можно спрятать в скомпилированном ассемблере, который вряд ли кто-то будет читать. Компилятор превращает исходный С++ код из начала поста в ассемблер примерно такого же вида, как последний сниппет. Вот примерчик на годболте как это выглядит в оригинале.
Hide your impurities deep. Stay cool.
#performance #compiler9 380
Loop unrolling. Хвосты
#новичкам
Предыдущий пост тут.
Следующие пару постов могут быть немного скучными и замудренными, но темы по смыслу именно так бьются. Разговор о хвостах нужен, чтобы правильно понять одну технику(спойлер: Duff device)
Удобненько получилось, что в прошлом посте число итераций цикла делилось на 4. 1024 % 4 = 0.
int sum = 0;
for (int i = 0; i < 1024; i += 4) {
sum += arr[i];
sum += arr[i+1];
sum += arr[i+2];
sum += arr[i+3];
}
Но как разворачивать циклы, если число итераций не делится нацело на фактор развертывания(количество повторов операции в развертке)?
Да тем же циклом. Обрабатываем целую часть развернутым циклом и потом дорабатываем остаток:
int sum = 0;
int i = 0;
int unroll_factor = 4;
int main_limit = n - n % unroll_factor;
for (; i < main_limit; i += unroll_factor) {
sum += arr[i];
sum += arr[i + 1];
sum += arr[i + 2];
sum += arr[i + 3];
}
// HERE
for (; i < n; ++i) {
sum += arr[i];
}
Последний цикл - это и есть дообработка хвоста.
И обработка хвостов - это важная штука, которую нельзя забывать хоть при разворачивании цикла, хоть при векторизации вычислений, хоть при распараллеливании вычислений.
Чем вообще плох хвост? Мы заранее не знаем его размер и поэтому вынуждены использовать привычный неразвернутый цикл для его обработки. Это опять же приносит с собой недостатки обычных циклов.
Если бы мы только знали, сколько элементов в хвосте коллекции, то смогли бы и этот последний цикл развернуть просто в набор операций.
И есть несколько техник, которые позволяют сэмулировать нечто подобное. О них мы поговорим в следующие разы, они отдельных постов заслуживают.
See the job through. Stay cool.
#performance9 380
WAT
#опытным
Сколько будет х/х?
Как ни странно, на этот вопрос есть множество ответов.
Математический ответ: при x != 0 выражение вырождается в единицу. Если же x = 0, то значение выражения неопределено. x=0 приводит к неопределенности вида 0/0, у которого нет корректного значения.
Какой же ответ у С++?
#include <iostream>
int main() {
int x = 0;
std::cout << x/x << std::endl;
}
Ответ будет 1!
На те вот ссылочку на годболт, если не верите.
#ЧЗХ? Компьютер считать чтоли не умеет?
По ссылочке на самом деле только вывод gcc показан. И он действительно выдает 1.
Но на кланге при запуске программа получает сигнал SIGFPE, а на msvc выдается вот такая цифирь 3221225620.
Понятное дело, что деление на 0 - это UB, и это явно видно по результатам запуска на разных компиляторах.
Если вы думали, что проблема проявляется только оптимизациях - нет. Даже на -O0 все равно gcc единичку выдает.
Тогда как могла появиться единичка?
С какой-то стороны этот вопрос не имеет смысла, потому что при UB компилятор волен поступать, как ему вздумается. Но этот конкретный пример показывает одну из популярных моделей поведения компилятора при UB.
С точки зрения разработчика С++ UB - это скорее плохо. Некоторые из нас идут на риск, когда понимают реальный эффект на конкретном компиляторе и железе, но в большинстве кейсов мы стараемся избегать UB.
И компилятор это учитывает. Если UB - это плохо и программист старается его не допускать в своем коде, значит можно считать, что UB в коде не будет. И на основе этого знания компилировать программу.
UB в данном случае произойдет только при x=0. Тогда компилятор просто выкидывает этот вариант из анализа, притворяется, что такого не может быть. Тогда выражение x/x действительно становится равным единице.
Компилятор применил константную свёртку (constant folding) — одно из базовых преобразований, которое работает даже на нулевом уровне оптимизации. Он увидел выражение x / x, где x — целочисленная переменная, и, как мы все делали в школе, сократил числитель и знаменатель. В итоге на консоль вывелась единичка.
Чтобы компилятор поступал максимально честно и ничего не смог сделать с выражением, надо пометить переменную volatile.
volatile int x = 0;
std::cout << x/x << std::endl;
Это ключевое слово запрещает компилятору делать предположения о значении переменной и заставляет чего честно читать переменную из памяти.
Do not trust programmers. Stay cool.
#compiler9 380
C++ разработчики в 2ГИС
Сейчас открыто две вакансии в разные команды:
— Middle C++ Developer в команду Transport Core
Делаем транспортный движок 2ГИС: маршруты, графы, расчёты и highload-обработку данных.
— Team Lead C++ в команду 3D Карты
Ищем сильного C++ разработчика на роль играющего тренера: часть времени — разработка, остальное — управление небольшой командой, техрешения и развитие процессов.
Важно: опыт именно в графике не обязателен. Если ты сильный плюсовик и хочешь попробовать себя в 3D-направлении — откликайся!
Что общего:
— современный C++
— сложные инженерные задачи
— большие объёмы данных
— сильные команды без лишней бюрократии
Можно удалённо
Вакансии:
Middle C++ Developer — Transport Core
Team Lead C++ — 3D Карты
Другие инженерные инсайты от 2ГИС → в Telegram-канале RnD
9 380
std::terminate. Когда вызывается
#опытным
Давайте посмотрим на список ситуаций, при которых вызывается std::terminate. Некоторые из них вполне мемные и заслуживают отдельных взвизгов, что так вообще можно в языке. Будем рассматривать более-менее понятные примеры, без малоизвестных динозавров. Погнали:
🔞 Вы бросили исключение, но не перехватили его нигде по стеку вызовов:
int main() {
throw 42; // нет catch → terminate
}
🔞 Исключение покидает noexcept функцию:
void foo() noexcept {
throw 42;
}
Частый вопрос на собесах: что будет если в деструкторе кинуть исключение? Тут без всяких раскруток стеков будет сразу terminate, потому что все деструкторы по умолчанию noexcept.
🔞 Деструктор бросает исключение во время раскрутки стека. Если вы уже такие смелые, что отменили noexcept спецификацию для деструкторов, то будьте готовы к аварийному завершению приложения. Если деструктор такого объекта вызовется во время раскрутки стека, ждите гитлер капут:
struct A {
~A() noexcept(false) { throw 1; }
};
int main() {
try {
A a;
throw 2;
} catch(...) {}
}
🔞 Из конструктора статического объекта вылетает исключение. В языке нет средств отловить такие исключения, поэтому сразу ломаемся:
struct A {
A() { throw 1; }
};
static A a;
int main() {}
🔞 В ту же песню залетает исключение из деструктора статических объектов. При нормальном завершении программы вызываются деструкторы глобальных объектов и если оттуда вылетит исключение, его никто не сможет поймать:
struct A {
~A() noexcept(false) { throw 1; }
};
static A a;
int main() {}
🔞 Из функции, зарегистрированной через atexit, вылетает исключение. Если вы хотите, чтобы при штатном завершении программы выполнилась функция, бросающая исключение, то у вас специфические вкусы. Такое исключение невозможно перехватить, поэтому тут же упадем:
void boom() { throw 1; }
int main() {
std::atexit(boom); // при выходе из main вызовется boom → terminate
}
🔞 Пустой throw без активного на данный момент исключения. Что вы хотели этим сказать - непонятно, зачем разрешать такое - тоже.
int main() {
throw; // нет текущего исключения → terminate
}
🔞 Из копирующего конструктора исключения вылетает исключение. Что-то такое:
struct MyExcept {
MyExcept() = default;
MyExcept(const MyExcept&) { throw 1; }
};
int main() {
try {
MyExcept e;
throw e;
} catch (MyExcept) {
}
}
Тут объект исключения после создания копируется во внутренний объект исключения, который будет переноситься механизмом раскрутки стека. Еще до раскрутки стека и вызова обработчика. В этом случае как бы произошла ошибка при выбросе исключения и это вполне аварийная ситуация, заканчивающаяся terminate. Ситуация странная, последствия вполне понятны.
🔞 Потоковая функция завершается исключением. Если исключение вылетает из последнего фрейма стека любого потока, вызывается std::terminate.
void thread_func() { throw 42; }
int main() {
std::thread t(thread_func);
t.join();
}
🔞 Вызвался деструктор или приваивание перемещением у joinable потока. Его не присоединили и не отсоединили и хотят уничтожить. А он уничтожил их:
int main() {
std::thread t([]{});
// деструктор t вызывается без join/detach → terminate
}
🔞 Вы мануально вызвали std::terminate. Хотите явно аварийно грохнуть программу и не плясать с бубнами, вам это дозволено:
int main() {
std::terminate();
}
Есть еще пару кейсов с потоковыми токенами остановки и с новой библиотекой std::execution, но это экзотика.
Видно, что большинство кейсов завязано на исключениях и сводятся к тому, что вы хотите сообщить об ошибке не из того места. А ошибка при попытке сообщить об ошибке легально попадает в список аварийных ситуаций.
Terminate your enemies. Stay cool.9 380
std::terminate
#опытным
Эта функция даже звучит страшно. Как будто бы даже есть немного опасений, что придет Шварц и заберет мою одежду, если в программе она вызовется.
Большинство из вас скорее всего знает, что вызов std::terminate приводит к завершению программы из-за какой-то ошибки.
Но как именно приводит? И почему это очень плохо? - На эти вопросы постараемся сегодня ответить.
Термин "ошибка" - слишком неоднозначный. На самом деле есть как минимум 2 категории ошибок: от которых можно восстановиться и от которых нельзя.
К первой категории можно отнести ситуации, от которых можно восстановиться. Когда системные вызовы возвращают отрицательное значение. Обычно это значит, что что-то пошло не так. Но жизнь программы продолжается: мы можем сделать ретрай или вообще прекратить исполнение задачи.
Туда же можно отнести выброс исключения. Ну ничего страшного: поймаем исключение, раскрутим стек, разрушим локальные объекты и продолжим исполнение дальше.
Ситуации же из второй категории намного серьезней. Вы в принципе нарушаете правила исполнения программы. Выход за границы массива - сегфолт. Слишком глубокая рекурсия - переполнение стека.
Таких ситуаций можно придумать много, и в каком-то небольшом их подмножестве вызывается функция std::terminate.
[[noreturn]] void terminate() noexcept;
Эта функция, которая не может бросать исключений и не возвращает никакого значения. Непонятно, как можно обработать ошибку при аварийном завершении программы. И так, как программа завершается при вызове terminate, никакой другой код не должен продолжится после этого вызова.
Внутренний механизм вызова std::terminate() достаточно общий для такого рода функций. std::terminate() не выполняет всю работу сама. Вместо этого она вызывает обработчик события "уничтожения программы". Обработчик настраивается через функцию std::set_terminate и по умолчанию там стоит вызов std::abort. А это уже функция которая немедленно посылает сигнал SIGABRT текущему процессу. Стандартная реакция на этот сигнал — аварийное завершение программы.
В чем проблемы такого завершения программы? Ну помимо того, что мы уже довольно сильно накосячили, что вызвался std::terminate.
Не раскручивается стек и не вызываются деструкторы ни локальных, ни глобальных объектов. Не вызываются никакие cleanup операции. Так или иначе при завершении программы ОС все равно заберет себе все ресурсы, но никакие процессы, происходящие в программе, грамотно не завершаются. Не флашатся потоки ввода-вывода и буферы, не возвращается нормальный ответ на запрос, не дообрабатываются уже готовые к обработке события. Батя ушел за хлебом и не вернулся... Ни свидетелей, ни весточки.
Поэтому очевидно, что просто не надо допускать ситуаций, которые приводят к вызову std::teminate. Их конечно много, но они вполне четко определены и их довольно просто избегать. Об этих ситуациях мы и поговорим в следующем посте.
Recover from your mistakes. Stay cool.
#cppcore9 380
Одна из проблем решения задачек с литкода – отсутствие учителя
Либо ты гений, нарешавший кучу задач, и сам придумываешь решение, либо застреваешь часами в попытке выработать оптимальный алгоритм, либо просто смотришь готовое решение. Не самые завидные варианты.
Но не одним литкодом едины. В свое время Яндекс создал для своих разработчиков онлайн-тренажер, а потом открыл доступ для всех желающих попрактиковаться в решении задач. Недавно в CodeRun появилась новая фича в виде AI-помощника на базе SourceCraft – Кодерун AI.
Он не напишет код за вас, но проведёт от намека к инсайту, не лишая права на ошибку. Вместо готового решения он поможет разобраться с примерами из условия, даст прогрессивные подсказки и тесты для корнеркейсов. Работает почти на всех языках.
Чтобы попробовать, заходите в задачи на CodeRun и открывайте вкладку «Кодерун AI». Пока фича в бета-режиме, нужна авторизация, а лимит — 20 запросов в сутки.
9 380
joinable
#опытным
Не часто мы в коде сами создаем потоки. Но когда это происходит, то начинаются интересности буквально везде и повсюду. Опустим гонки данных и прочие проблемы и обсудим вот что.
В деструкторах классов, которые в качестве поля содержат поток, часто можно найти такую конструкцию:
if (worker_.joinable())
worker_.join();
Полноценно это может выглядеть так:
class BackgroundWorker {
std::atomic<bool> stop_{false};
std::thread worker_;
public:
BackgroundWorker() {
worker_ = std::thread([this] {
while (!stop_.load()) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
// do something important
}
});
}
~BackgroundWorker() {
stop_ = true;
if (worker_.joinable())
worker_.join();
}
BackgroundWorker(const BackgroundWorker&) = delete;
BackgroundWorker& operator=(const BackgroundWorker&) = delete;
};
Зачем нужен этот joinable? Как это поток может не присоединиться? К чему может привести присоединение не joinable потока? На все эти вопросы сейчас ответим.
Проверка joinable() перед вызовом join() в деструкторе класса - это защита от ошибок, связанных с некорректным состоянием потока. Если вызвать join() для не joinable потока, будет сгенерировано исключение std::system_error, чего нам явно не хочется получить в такой важный момент.
Какой поток нельзя присоединять?
1️⃣ Созданный дефолтным конструктором. std::thread thd; - объект потока конечно есть, но никакого реального потока запущено не было. Поток должен быть успешно запущен, чтобы быть в состоянии joinable.
2️⃣ Перемещенный объект. В ту же степь. Перемещенный объект std::thread не связан больше ни с каким потоком, поэтому и находится в неприсоединяемом состоянии.
3️⃣ Ранее присоединенный объект. Если тебя уже присоединили, то ты уже не можешь бы joinable.
4️⃣ Отсоединенный. Если у объекта вызвали метод detach() и отсоединили поток исполнения от этого объекта, то присоединить его совсем не получится.
По идее, все эти пункты мы контролируем в коде. Если мы явно вызываем конструктор потока с функцией, не отсоединяем поток, не присоединяем его по середине кода и не перемещаем, то и joinable казалось бы не нужен.
Но все-таки это идиоматическая конструкция, которую просто надо запомнить и использовать "на всякий случай".
В будущем вы скорее всего будете менять этот класс. И, например, добавите в апи метод Wait(), который явно дожидается завершения потока. Или вообще отсоедините поток.
Никто не застрахован от изменений в будущем, поэтому уже заранее нужно заложить безопасное разрушение объекта.
Ну или используйте C++20 std::jthread и забудьте про эту конструкцию)
Be safe. Stay cool.
#concurrency9 380
Особый день
Сегодня очень важный и теплый для нашей страны праздник - День Победы.
К нему можно по-разному относиться, непростая ситуация сейчас в мире. Но, на наш взгляд, это не имеет значения.
Значение имеет то, что конкретно наши с вами предки, конкретные люди сделали очень много для того, чтобы мы с вами просто жили.
Любой знаковый день - повод сделать что-то. Накидываем беспроигрышный вариант.
Давайте же просто сегодня вспомним своих бабушек и дедушек и искренне поблагодарим их за то, что мы живы. Они заслужили.
С праздником, дорогие подписчики! Благодарность свернет горы.
Tip your hat to your ancestors. Stay cool.
9 380
Ты такой стараешься, оптимизируешь код, а компилятор просто плюет на него и делает то, что ему вздумается..
Чертова железяка!
9 380
Loop unrolling. Мотивация.
#новичкам
Современные компиляторы - это чудо-расчудесное. В целом, это такая шайтан-машина, в которую запихивается С++ код, который зачастую пахнет как коровья лепеха, а на выходе получается пушка-бомба-ракета. Базово ракета будет лететь довольно быстро. Иногда она может взрываться по середине пути, но не суть.
Компилятор позволяет нам думать о логике, в то время как сам заботится о перформансе.
На каком-то уровне можно без серьезных последствий поддерживать в голове образ шайтан-машины. Но чтобы расти дальше и выше, нужно понимать по крайней мере некоторые механики его работы и оптимизации, которые он совершает с нашим кодом.
Сегодня и дальше мы поговорим про одну из таких оптимизаций - loop unrolling или развертывание цикла.
В чём проблема обычного цикла?
Возьмём простейшую задачу: просуммировать элементы массива.
int sum = 0;
for (int i = 0; i < 1024; ++i) {
sum += arr[i];
}
На каждый проход цикла процессор делает:
1️⃣ Загружает arr[i]
2️⃣ Прибавляет к sum
3️⃣ Увеличивает i
4️⃣ Сравнивает i с 1024
5️⃣ Если не конец — прыгает обратно
Можно было просуммировать элементы вот так:
int sum = 0;
sum += arr[0];
sum += arr[1];
sum += arr[2];
...
sum += arr[1023];
Но мы так не делаем, у нас есть цикл, чтобы писать короче. Получается, что цикл - это в некотором роде абстракция.
Так вот шаги 3–5 — это чистые накладные расходы (overhead) на использование этой абстракции. На 1024 итерациях мы теряем 1024 сравнения и 1024 условных перехода. Все это вносит свой, да мизерный, но вклад, в просадку перфа.
Было бы прикольно совместить два подхода, чтобы и абстракцию использовать и не терять тики процессора на ветвление.
Полностью получить все плюшки и убрать все минусюшки не получится. Но получится смешать два подхода. Будем делать цикл не по каждой итерации i, а через 4 числа:
int sum = 0;
for (int i = 0; i < 1024; i += 4) {
sum += arr[i];
sum += arr[i+1];
sum += arr[i+2];
sum += arr[i+3];
}
Теперь на 256 итерациях мы делаем те же 1024 сложения, но сравнений и переходов в 4 раза меньше.
Круто? Круто. Это и называется loop unrolling.
Плюсы:
- быстрее исполнение за счет уменьшения оверхэда
Минусы:
- Теряется красота и лаконичность кода
- Увеличивается размер бинарника за счет большего количества инструкций
Получается такой трейдоф: ускоряем код за счет красоты кода и увеличения бинаря. Классический баланс используемой памяти и скорости кода.
Хорошие новости в том, что скорее все такую оптимизацию может провернуть компилятор за вас без вашего участия. Но это уже детали, которые будем обсуждать в следующих постах серии.
Optimize your performance. Stay cool.
#compiler #performance9 380
WAT
#новичкам
Спасибо, ₿ Satoshic, за любезно предоставленный примерчик в рамках рубрики #ЧЗХ.
Посмотрите на этот код и скажите, что выведется на экран:
std::string_view crop_string_view(std::string_view str_view)
{
return std::string_view{str_view.begin() + 5};
}
int main()
{
const char* str = "some super mega long string";
std::string_view str_view = {str, 10};
std::cout << crop_string_view(str_view);
}
Складывается довольно уверенное ощущение, что мы берем первые 10 символов строки str и после этого отрезаем от этой подстроки первые 5 символов. И в итоге выведется "super".
Однако ваш компилятор думает иначе и выведется на самом деле вот что:
super mega long string
ЧЗХ? str_view же содержит обезанную строку! Откуда там изначальная последовательность символов?
Дело в том, что str_view конечно не содержит никакую строку. Этот объект грубо говоря лишь ссылается на оригинальную строку с ограничениями на длину, которую мы задали в конструкторе.
И конечно вполне естественно на первый взгляд подумать, что std::string_view{str_view.begin() + 5} здесь обрезается сама подстрока. Но это не так.
Конструктор string_view от одного аргумента формирует вьюху от переданного итератора на начало строки и идет дальше прям до символа конца строки. str_view.begin() и str.begin() ничем не отличаются, это фактически тот же самый указатель на начало супер длинной строки. Поэтому и остановится конструктор в конце этой строки и на консоль выведется "super mega long string".
Поэтому если вы создаете std::string_view не от строкового литерала, то указывайте в конструкторе либо длину, либо итератор на конец последовательности.
Specify your boundaries. Stay cool.
#cpp179 380
make_unique и приватный конструктор
#новичкам
Если вы хотите как-то по-особенному контролировать время жизни объекта, то с функциями std::make_unique и std::make_shared у вас могут быть проблемы.
struct Type {
static std::unique_ptr<Type> Create() {
return std::make_unique<Type>();
}
private:
Type() = default;
};
int main()
{
auto obj = Type::Create();
}
Этот код не скомпилируется. Все потому что для класса std::make_unique - это внешний код и ей нужен публичный конструктор для работы.
Это можно обойти, просто использовав явный вызов new:
struct Type {
static std::unique_ptr<Type> Create() {
return std::unique_ptr<Type>(new Type);
}
private:
Type() = default;
};
int main()
{
auto obj = Type::Create();
}
Но это же явный вызов new! Из всех утюгов твердят, что сырые указатели - наши враги и с ними надо вести ожесточенную войну!
Есть один вариант, как этого можно избежать(если сырые указатели вызывают у вас диарею). Давайте сделаем публичный конструктор, но сделаем один из его параметров приватным типом класса. Тогда создавать объект по-прежнему можно будет только с помощью фабричного статического метода, но разблокируется возможность использовать std::make_unique:
class Type {
struct PrivateKey { // private struct of Type
PrivateKey() = default;
};
public:
Type(PrivateKey) {}
static std::unique_ptr<Type> Create() {
return std::make_unique<Type>(PrivateKey{});
}
};
int main() {
auto obj = Type::Create(); // OK
auto obj2 = Type(PrivateKey{}); // ERROR: PrivateKey is private
}
По сути, это та же идиома passkey из этого поста. Только здесь она раскрывается с еще одной стороны.
Спасибо комментаторам из поста про запрет создания объектов на стеке за идею для публикации!
Know your enemies. Stay cool.
#cppcore #design #memory9 380
Каналы про IT делятся на 2 типа:
1. Выучи Python, JavaScript и C++ за 0,0001 секунды просто читая наш канал…
2. Хочешь читать переписки бывшей? Хакер из канала "Взлом Жопы" рассказывает как скачать Tor…
Но среди копипастных статей и мусора есть реально годный проект айтишника, работавшего 9 лет в ИБ — Пакет Безопасности.
Внутри узнаете когда наступит эра без паролей, почему и как изолируется рунет, как удалить упоминание о себе из интернетов и как не оказаться жертвой новой схемы интернет-скама.
Подпишитесь, злоумышленники не дремлют: @package_security
9 380
мьютекс vs семафор
#новичкам
Мьютексы довольно часто используют при разработке потокобезопасных модулей. Хоть их использование в высокопроизводительных приложениях нежелательно, они все равно являются базовым выбором для реализации наивной потокобезопасности. Более менее все про них знают.
А вот про семафоры - не все. В стандарт их добавили только в С++20 в виде std::counting_semaphore и std::binary_semaphore. Да и в принципе они не так часто используются.
Однако мьютексы и семафоры довольно похожи по внутренней реализации, хоть и довольно сильно отличаются по кейсам применения. Поэтому есть смысл их сравнить side-by-side, чтобы наглядно видеть все похожести и отличия.
Аналогия
Мьютекс - это дверь в очень маленькую туалетную комнату. Когда она свободна, любой может в нее войти. Любой, но только один. Как только кто-то вошел, все остальные начинают выстраиваться в очередь и ждать освобождения комнаты. А освободить комнату может только тот, кто в нее вошел.
Семафор - это турникет для автопарковки. У парковки есть определенное количество машин, которое на ней может разместиться. Больше не получится - места не будет, поэтому и турникет не пропустит. И машины опять выстраиваются в очередь. И только когда одна машина освободила место с парковки, одна новая может на нее заехать.
Для чего используется
Мьютекс - судя из названия mutual exclusion - взаимное исключение. Применяется, когда только один поток в один момент времени может получить доступ к разделяемому ресурсу.
Семафор же просто контролирует количество ресурсов и не дает уйти в минуса.
Низкоуровневое представление
Можно представить мьютекс как атомарный флаг, к которому прикручен механизм ожидания и очереди. Флаг можно выставить вверх и тогда попытка снова выставить его вверх карается блокировкой этого потока. Флаг можно опустить и тогда его может снова поднять любой желающий поток, либо тот, кто был первый в очереди на ожидание.
Семафор же - атомарный счетчик с таким же механизмом ожидания и очереди. Счетчик инициализируется каким-то числом и его можно инкрементировать и декрементировать. При попытке декремента нуля поток уходит в ожидание. Если какой-то другой поток снова накрутил единичку на семафоре - поток просыпается и делает таки свой декремент.
Владелец
У мьютекса есть владелец - тот, кто захватил замок, должен его отпустить. Если каким-то образом в коде это условие нарушается - сразу ub.
У семафора же нет никаких ограничений - любой поток может накручивать и скручивать счетчик.
Примеры
Мьютекс нужен для ограничения доступа потоков к критической секции. Например у вас есть какой-то кэш и вы хотите его потокобезопасно обновить:
std::mutex mtx;
std::map<std::string, int> cache;
void update_cache(const std::string& key, int value) {
std::lock_guard<std::mutex> lock(mtx);
cache[key] = value;
}
Это все конечно нужно обернуть в класс, но суть понятна и так.
Семафор же нужен для ограничения количество одновременно используемых ресурсов.
Например, ограниченная потокобезопасная очередь:
template<typename T, size_t N>
class BoundedQueue {
std::queue<T> queue_;
std::counting_semaphore<N> empty_slots_{N};
std::counting_semaphore<N> filled_slots_{0};
std::mutex mtx_;
public:
void push(T value) {
empty_slots_.acquire();
{
std::lock_guard<std::mutex> lock(mtx_);
queue_.push(std::move(value));
}
filled_slots_.release();
}
T pop() {
filled_slots_.acquire();
T value;
{
std::lock_guard<std::mutex> lock(mtx_);
value = std::move(queue_.front());
queue_.pop();
}
empty_slots_.release();
return value;
}
};
Здесь семафоры нужны для того, чтобы в очередь не положили больше элементов, чем нужно, и не забрали больше, чем в очереди может потенциально быть. Обратите внимание на порядок инкремента и декремента.
Лайк, если понравилось. Да и если не понравилось, тоже ставьте.
Compare things. Stay cool.
#concurrency #cpp20 #cpp119 380
Как запретить объекту создаваться на куче?
#новичкам
Про стек поговорили, не будем и кучу обижать.
Начать можно со знакомого подхода: приватный конструктор и фабрика, возвращающая объект по значению:
class OnlyStack {
OnlyStack() = default;
public:
static OnlyStack Create() { return {}; }
};
Если объект может быть создан некорректно, то вернуть можно std::optional, с помощью которого ошибку можно будет отловить:
class OnlyStack {
OnlyStack() {
if (rand() % 2) {
std::cout << "ERROR" << std::endl;
}
}
public:
static std::optional<OnlyStack> Create() { return {}; }
};
"На приватных конструкторах мы уже собаку съели. Даешь способ по-интереснее!"
Хорошо. Давайте будем радикальными. Просто удалим перегрузку оператора new для этого класса. Тогда вообще никто не будет в состоянии объект на куче создать:
class OnlyStack {
public:
OnlyStack() = default;
~OnlyStack() = default;
static void* operator new(std::size_t) = delete;
static void* operator new = delete;
};
OnlyStack obj; // OK
OnlyStack* p = new OnlyStack(); // ERROR
Вот так просто и без дополнительных приседаний.
Но можно играть не радикально, а хитро. Не удалим, а переопределим operator new так, чтобы он размещал объекты на готовом существующем статическом буфере. Реально рабочий примерчик довольно большой будет, плюс чтобы не углубляться в пучины кастомных аллокаторов, покажем только идею:
class PooledObject {
static char pool[1024];
static size_t offset;
public:
void* operator new(size_t s) {
if (offset + s > 1024) throw std::bad_alloc();
void* ptr = pool + offset;
offset += s;
return ptr;
}
void operator delete(void*) noexcept {
// Complicated logic
// or just ignore freeing memory
}
};
PooledObject* obj = new PooledObject();
Типа линейный аллокатор, при запросе нового объекта просто сдвигаем offset буфера.
Это конечно все интересно. Но вспомните пост, где мы разбирали вопрос "Где аллоцируются элементы std::array?" и задумайтесь. А что если целевой объект будет полем другого класса, который мы создаем на куче?
Тогда его расположение будет определяться тем, где находится объемлющий объект. То есть, если мы создаём объект Container на куче, то и все его поля, включая OnlyStack, окажутся на куче. Получается, что наш запрет на new OnlyStack не спасает от ситуации, когда OnlyStack становится членом другого класса, который кто-то создаёт через new.
class OnlyStack {
OnlyStack() = default;
public:
static OnlyStack Create() { return {}; }
};
struct Container {
Container() : obj{OnlyStack::Create()} {}
OnlyStack obj;
};
Container* p = new Container(); // OK
Пишите в комментах, если знаете, как обойти эту проблему)
Don't be so radical. Stay cool.
#cppcore #memory #design9 380
Как запретить объекту создаваться на стеке? Экзотика
#опытным
В этом посте будут не самые обычные способы запретов, которые вряд ли полезны на практике в таком виде, но полезны их отдельные элементы. Да и просто прикольные по своей идее.
Начнем с конца. Жизни объекта, конечно. Давайте сделаем приватным не конструктор, а деструктор. Тогда внешний код не сможет разместить такой объект на стеке, ведь он не сможет его удалить потом.
Однако удалять объект хочется. Для этого сделаем публичный статический метод destroy и фабричный метод, возвращающий умный указатель с кастомным делитером:
class MyClass {
public:
MyClass() { std::cout << "MyClass constructor\n"; }
private:
~MyClass() { std::cout << "MyClass destructor\n"; }
public:
static void destroy(MyClass* ptr) {
delete ptr;
}
static std::unique_ptr<MyClass, decltype(&destroy)> create() {
return std::unique_ptr<MyClass, decltype(&destroy)>(new MyClass(), destroy);
}
};
При выходе из скоупа юник разрушится и его деструктор дернет destroy, который сам дернет delete.
Публичный конструктор здесь не имеет большого смысла, но и так и так работает.
Но вообще говоря, зачем все эти запреты? Можно ли как-то добиться запрета при публичном конструкторе и деструкторе?
Можно(почти).
Давайте сделаем публичный деструктор и конструктор с параметром, который не может создать внешний код:
class Key {
private:
Key() = default;
friend class Factory;
};
class Object {
public:
explicit Object(const Key&) {} // требует ключ
};
class Factory {
public:
static std::unique_ptr<Object> create() {
return std::make_unique<Object>(Key());
}
};
Технически, все методы класса Object публичные. Но инстанс Key может создать только фабрика. Поэтому и создание инстанса Object возможно только через нее.
Есть даже идиома, называется passkey, которая приписывает примерно так и создавать объекты.
Класс ключа может быть определен и внутри фабрики, как приватный класс. Фабрики может и не быть в принципе, Key может находиться внутри Object. Но суть одна.
И напоследок совсем гадкий утенок. Делаем в заголовке forward declaration типа и объявляем фабричную функцию. В cpp определяем класс и функцию.
// object.hpp
class Object;
std::unique_ptr<Object> createObject();
// object.cpp
#include "object.h"
class Object {
public:
Object() = default;
~Object() = default;
};
std::unique_ptr<Object> createObject() {
return std::make_unique<Object>();
}
В итоге да, мы запретили объекту создаваться, где угодно, кроме как через createObject. Но при этом пользоваться объектом мы никак не сможем. Только создать и удалить.
Explore exotic things. Stay cool.
#design #cppcore
اکنون در دسترس! پژوهش تلگرام ۲۰۲۵ — مهمترین بینشهای سال 
