Грокаем C++
5.07K subscribers
5 photos
3 files
268 links
Два сеньора C++ - Владимир и Денис - отныне ваши гиды в этом дремучем мире плюсов.

По всем вопросам - @ninjatelegramm

Менеджер: @Spiral_Yuri
Реклама: https://telega.in/c/grokaemcpp
Мы на TGstat: https://tgstat.ru/channel/@grokaemcpp/stat
Download Telegram
Приветственный пост

Рады приветствовать всех на нашем канале!
Вы устали от скучного, монотонного, обезличенного контента по плюсам?

Тогда мы идем к вам!

Здесь не будет бесполезных 30 IQ постов, сгенеренных ChatGPT, накрученных подписчиков и активности.

Канал ведут два сеньора, Денис и Владимир, которые искренне хотят делится своими знаниями по С++ и создать самое уютное коммьюнити позитивных прогеров в телеге!
(ну вы поняли, да? с++, плюс плюс, плюс типа
позитивный?.. ай ладно)

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

ГАЙДЫ:

Мини-гайд по собеседования
Гайд по категория выражения и мув-семантике
Гайд по inline

Дальше пойдет список хэштегов, которыми вы можете пользоваться для более удобной навигации по каналу и для быстрого поиска группы постов по интересующей теме:
#algorithms
#datastructures
#cppcore
#stl
#goodoldc
#cpp11
#cpp14
#cpp17
#cpp20
#commercial
#net
#database
#hardcore
#memory
#goodpractice
#howitworks
#NONSTANDARD
#interview
#digest
#OS
#tools
#optimization
#performance
#fun
#compiler
#multitasking
#design
#exception
#guide
#задачки
#base
#quiz
#concurrency
Еще одна проблема при разрушении статиков
#опытным

Идею для поста подкинул Михаил в этом комменте

Суть в чем. Все глобальные переменные, не помеченные thread_local, создаются и уничтожаются в главном потоке, в котором выполняется main(). Но использовать мы их можем и в других потоках, адресное пространство-то одно. И вот здесь скрывается опасность: мы можем использовать в другом потоке глобальную переменную, которая уже была уничтожена!

Вы просите объяснений? Их есть у меня.

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

По пунктам

1️⃣ Статические переменные удаляются при вызове std::exit, что происходит после завершения main(). Значит, нам нужно выйти из main'а.

2️⃣ Получается, что второй поток должен продолжать выполняться даже после завершения main. Тут только один вариант: отделить тред от его объекта, чтобы его не нужно было джойнить. Делается это с помощью метода detach().

3️⃣ Использование переменной вторым потоком должно быть между разрушением глобальной переменной и завершением std::exit, потому что эта функция завершает процесс. И естественно, что после завершения процесса уже никакие потоки выполняться не могут.

Вот такие незамысловатые условия. Давайте посмотрим на примере.


struct A {
~A() {
std::this_thread::sleep_for(std::chrono::seconds(5));
}
};

struct B {
std::string str = "Use me";
~B() {
std::cout << "B dtor" << std::endl;;
}
};

A global_for_waiting_inside_globals_dectruction;
B violated_global;

void Func() {
for (int i = 0; i < 20; ++i) {
std::cout << violated_global.str << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(1));
}
}

int main() {
std::thread th{Func};
th.detach();
std::this_thread::sleep_for(std::chrono::seconds(3)); // aka some usefull work
}


Быстренькое пояснение. Создал 2 простеньких класса, которые позволят наглядно показать процесс удаления переменной и использования ее после удаления. Деструктор первого класса заставляет главный тред уснуть на 5 секунд, что помещает программу в опасное состояние как раз между ее завершением и разрушением статиков. Второй класс мы как раз и будем использовать для создания шаренного объекта, который использует второй тред. У него в деструкторе выводится сообщение-индикатор удаления. Давайте посмотрим на вывод:

Use me
Use me
Use me
B dtor
Use me
Use me
Use me
Use me
Use me


Поймана за хвост, паршивка! Мы используем поле удаленного объекта, что чистой воды UB!

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

Если я что-то упустил, то пусть Михаил меня поправит в комментах.

Avoid dangerous practices. Stay cool.

#cppcore #cpp11 #concurrency
Double-Checked Locking Pattern Classic
#опытным

Ядро идеи этого паттерна - тот факт, что решение из предыдущего поста неоптимально. Нам на самом деле нужно всего один раз взять замок для того, чтобы создать объект и потом не возвращаться к этом шагу. Если кто-то увидит, что наш указатель - ненулевой, то он даже не будет пытаться что-то делать и сразу вернется из функции.

Поэтому в паттерне блокировки с двойной проверкой, нулёвость указателя проверяется перед локом. Таким образом мы откидываем просадку производительности для подавляющего большинства вызова геттера синглтона. Однако у нас теперь остается узкое место - момент инициализации. И вот где появляется вторая проверка(всю обертку уже не буду писать для краткости).

static Singleton* Singleton::instance() {
if (inst_ptr == NULL) {
Lock lock;
if (inst_ptr == NULL) {
inst_ptr = new Singleton;
}
}
return inst_ptr;
}


Таким образом, даже если 2 потока войдут в первое условие и первый из них проинициализирует указатель, то второй поток будет вынужден проверить еще раз, можно ли ему создать объект. И грустный вернется из геттера, потому что ему нельзя.

Это классическая реализация, многие подписчики, думаю, видели ее. Однако от того, что она классическая, не следует, что она корректная.

Давайте посмотрим на вот эту строчку поближе:

inst_ptr = new Singleton;


Что здесь происходит? На самом деле происходят 3 шага:

1️⃣ Аллокация памяти под объект.

2️⃣ Вызов его конструктора на аллоцированной памяти.

3️⃣ Присваивание inst_ptr'у нового значения.

И вот мы, как наивные чукотские мальчики, думаем, что все эти 3 шага происходят в этом конкретном порядке. А вот фигушки! Компилятор, мать его ети. Иногда он может просто взять и переставить шаги 2 и 3 местами! И вот к чему это может привести.

Давайте посмотрим эквивалентный плюсовый код, когда компилятор переставил шаги:

static Singleton* Singleton::instance() {
if (inst_ptr == NULL) {
Lock lock;
if (inst_ptr == NULL) {
inst_ptr = // step 3
operator new(sizeof(Singleton)); // step 1
new(inst_ptr) Singleton; // step 2
}
}
return inst_ptr;
}


Че здесь происходит. Здесь просто явно показаны шаги. С помощью operator new мы выделяем память(1 шаг), дальше присваиваем указатель на эту память inst_ptr'у(шаг 3). И в конце конструируем объект. И напомню, это не программист так пишет. Это эквивалентный код тому, что может сгенерировать компилятор.

И этот код совсем не эквивалентен тому, что было изначально. Потому что конструктор Singleton может кинуть исключение и очень важно, чтобы есть он это сделает, то inst_ptr останется нетронутым. А он как бы изменяется. Поэтому, в большинстве случаев, компилятору нельзя генерировать такой код. Но при определенных условиях, он может это сделать. Например, если докажет сам себе, что конструктор не может кинуть исключение. И вот тогда происходит magic.

Тред №1 входит в первое условие, берет лок и выполняет шаги 1 и 3 и потом засыпает по воле планировщика. И мы имеем состояние, когда указатель проинициализирован, а объекта на этой памяти еще нет(шаг 2 не выполнен).

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

Что можно сделать? Вообще говоря, ничего. Если сам язык не подразумевает многопоточности, то компилятор даже не думает о таких штуках и с его точки зрения все валидно. Даже volatile предотвращает реордеринг инструкций в рамках только одного потока. Но мы же в многоядерной среде и там существуют совершенно другие эффекты, о которых "безпоточные" С и С++ в душе не знают. Напоминаю, что мы до сих пор в эре до С++11. Завтра чуть ближе посмотрим на конкретные проблемы, при которых мы сталкиваемся, находясь в многопоточном окружении.

Criticize your solutions. Stay cool.

#concurrency #cppcore #compiler #cpp11
Что опасного в многопоточке?
#новичкам

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

Одна из многих проблем - когерентность кэша. У нас есть много вычислительных юнитов. У каждого из них есть свой кэш. И все они шарят общее адресное пространство процесса. Кэши напрямую не связаны с другими вычислительными юнитами, только со своими(это про кэши низких уровней). В такой архитектуре нужно четко определить механизм, по которому изменения одного кэша станут видны другому ядру. Такие механизмы есть. Например, упрощенный вариант того, что сейчас есть - модель MESI. Непростая штука и мы пока не будем разбираться в деталях. Важно вот что: на процесс, охватывающий промежуток от изменения одной кэш линии до того, как эти изменения станут доступны другому ядру, тратится время. И это не атомарная операция! То есть нет такого, что при каждом изменении кэш линии информация об этом инциденте моментально доходит до других юнитов и они тут же первым приоритетом подгружают новое значение. Это очень неэффективно. Поэтому может случиться такая ситуация, при которой переменная в одном кэше процессора уже изменилась, а в другом кэше еще осталась ее старая копия, которая используется другим процессором. Это и есть одна из граней проблемы когерентности кэша.

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

struct Class {
Class(int a, int b, int c) : x{a}, y{b}, z{c} {}
int x;
int y;
int z;
};

Class * shared;

void fun() {
shared = new Class{1, 2, 3};
}


Функция fun выполняется в каком-то потоке и и меняет значения переменной. Логично, что в начале выполняется создание объекта, а потом присвоение указателя. Но это актуально лишь для этого потока и так это видит соотвествующее ядро. Мы ведь в многопоточной среде, здесь убивают...
Может произойти так, что данные в другой процессор подтянутся в обратном порядке. То есть в начале появится инициализированный указатель, указывающий на какую-то память, а потом подтянется инфа об созданном на этой памяти объекте. Вот и получается, что этот другой поток может сделать проверку:

if (shared) 
// do smt with object

И код войдет в условие, потому что указатель ненулевой. Но память по этому указателю будет еще не инициализирована. А это, друзья, наше любимое UB.

И это в точности то, что может происходить с нашим беднягой синглтоном! Если вы думаете, что lock на мьютексе вас спасет, то нет, не спасет!

Да, лок подразумевает барьеры памяти и при unlock'e изменения флашатся. Но на незащищенном чтении-то они подтягиваются без барьеров! Это был небольшой спойлер для шарящих за барьеры. О них не сегодня.

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

Завтра как раз об этом и поговорим.

Be able to work in multitasking mode. Stay cool.

#concurrency #cppcore
Рабочий Double-Checked Locking Pattern
#опытным

Мы уже довольно много говорим о нем и его проблемах. Давайте же сегодня обсудим решение.

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

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

Singleton* Singleton::getInstance() {
Singleton* tmp = m_instance;
... // insert acquire memory barrier
if (tmp == NULL) {
Lock lock;
tmp = m_instance;
if (tmp == NULL) {
tmp = new Singleton;
... // insert release memory barrier
m_instance = tmp;
}
}
return tmp;
}


Вот как выглядела бы более менее работающая реализация паттерна блокировки с двойной проверкой до нашей эры(до С++11). Так как в то время в языке и стандартной библиотеке не было ничего, что связано с потоками, то для барьеров приходилось использовать platform-specific инструкции, часто с ассемблерными вставками.

Acquire барьер предотвращает переупорядочивание любого чтения, которое находится сверху от него, с любыми чтением/записью, которые следуют после барьера. Одна из проблем кода без барьеров: мы можем считать ненулевой указатель в tmp, но при этом результат операции инициализации объекта к нам еще не подтянется. Мы вернем из геттера неинициализированный указатель, что UB. Именно для предотвращения такого эффекта, в данном случае такой барьер нужен сверху для того, чтобы мы подтянули инициализированный объект из кэша другого ядра в случае, если мы все-таки считали ненулевой указатель.

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

Release барьер предотвращает переупорядочивание любого чтения/записи, которое находится сверху от него, с любой записью, которые следуют после барьера. Здесь также 2 составляющие. Первая: предотвращает переупорядочивание иницализации синглтона с присваиванием его указателя к m_instance. Это дает четкий порядок: в начале создаем объект, а потом m_instance указываем на него. Вторая гарантирует нам правильный порядок "отправки" изменений из текущего треда в точки назначения.

Объяснения не самые подробные и точные, но опять же, не было такой цели. Кто понимает - поймет, а кто не понимает - ждите статьи по модели памяти)

И вот как выглядела бы реализация этого паттерна на современном С++, если бы статические локальные переменные не гарантировали бы потокобезопасной инициализации:

std::atomic<Singleton*> Singleton::m_instance;
std::mutex Singleton::m_mutex;

Singleton* Singleton::getInstance() {
Singleton* tmp = m_instance.load(std::memory_order_relaxed);
std::atomic_thread_fence(std::memory_order_acquire);
if (tmp == nullptr) {
std::lock_guard lock(m_mutex);
tmp = m_instance.load(std::memory_order_relaxed);
if (tmp == nullptr) {
tmp = new Singleton;
std::atomic_thread_fence(std::memory_order_release);
m_instance.store(tmp, std::memory_order_relaxed);
}
}
return tmp;
}


Здесь мы только на всякий случай обернули указатель синглтона в атомик указатель, чтобы полностью быть так сказать в lock-free контексте. Барьеры на своих местах, а для залочивания мьютекса используем стандартный std::lock_guard с CTAD из 17-х плюсов.

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

Establish your barriers. Stay cool.

#concurrency #cpp11 #cpp17
Ассемблер инициализации статических локальных переменных
#опытным

Пример из предыдущего поста - рабочая версия паттерна. Однако, нам, вообще говоря, можно всего этого не писать. Ведь начиная с С++11 нам гарантируют тред-сэйф инициализацию статических локальных переменных и можно просто писать:

Singleton& Singleton::getInstance() {
static Singleton instance;
return instance;
}


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

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

Singleton::getInstance():
1 movzbl guard variable for Singleton::getInstance()::instance(%rip), %eax
2 testb %al, %al
3 je .L19
4 movl $Singleton::getInstance()::instance, %eax
5 ret
.L19:
...
6 call __cxa_guard_acquire
7 testl %eax, %eax
8 jne .L20
.L9:
9 movl $Singleton::getInstance()::instance, %eax
10 popq %rbx
11 ret
.L20:
12 movl $Singleton::getInstance()::instance, %esi
{Constructor}
13 movl $guard variable for Singleton::getInstance()::instance, %edi
14 call __cxa_guard_release
{safe instance and return}



Так как код оперирует объектом, а не указателем, то и в ассемблере это отражено. Но да не особо это важно. Сейчас все поймете. Для удобства обращения к коду, пометил строчки номерами.

Итак, мы входим в функцию. И тут же на первой строчке у нас появляется строжевая гвардия для переменной instance. Гвардия защищена барьером памяти и она показывает, инициализирована уже instance или нет. Так как мы без указателей, то вместо загрузки указателя и установки барьера памяти тут просто происходит загрузка гард-переменной для instance в регистр eax. Дальше на второй строчке мы проверяем, была ли инициализирована instance. al - это младший байт регистра eax. Соотвественно, если al - ноль, то инструкция testb выставляет zero-flag и в условном прыжке на 3-ей строчке мы прыгаем по метке. Если al - не ноль, то наш синглтон уже инициализирован и мы можем вернуть его из функции. Получается, что это наша первая проверка на ноль.

На метке .L19 мы берем лок с помощью вызова __cxa_guard_acquire, которая используется для залочивания мьютексов. И снова проверяем переменную-гард на пустоту(напоминаем себе, что она в eax загружена), если до сих пор она нулевая, то прыгаем в .L20. Если уже не ноль, то есть переменная инициализирована, то проваливаемся в .L9, где кладем созданную переменную в регистр возврата значения на 9-й строчке и выходим из функции(10 и 11). Это была вторая проверка

На метке .L20 мы на 12-й строчке кладем наш неинициализированный синглтон в регистр для последующей обработки, а именно для конструирования объекта. На 13-й строчке кладем адрес гарда в регистр, чтобы чуть позже записать туда ненулевое значение aka синглтон инициализирован. Далее мы отпускаем лок с помощью __cxa_guard_release, делаем все необходимые завершающие действия и выходим из функции.

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

Стоит еще раз обратить внимание на то, что __cxa_guard_acquire и __cxa_guard_release - это не барьеры памяти! Это захват мьютекса. Барьеры памяти напрямую здесь не нужны. Нам важно только защитить гард-переменную для синглтона, потому что проверяется только она.

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

Dig deeper. Stay cool.

#concurrency #cppcore