Грокаем C++
8.37K subscribers
34 photos
1 video
3 files
458 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
Дедлокаем код
#новичкам

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

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

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

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

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

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

Самый простой пример, который иллюстирует эту ситуацию:

std::mutex m1;
std::mutex m2;

std::thread t1([&m1, &m2] {
std::cout << "Thread 1. Acquiring m1." << std::endl;
m1.lock();
std::this_thread::sleep_for(std::chrono::milliseconds(10));
std::cout << "Thread 1. Acquiring m2." << std::endl;
m2.lock();
std::cout << "Thread 1 perform some work" << std::endl;
});

std::thread t2([&m1, &m2] {
std::cout << "Thread 2. Acquiring m2." << std::endl;
m2.lock();
std::this_thread::sleep_for(std::chrono::milliseconds(10));
std::cout << "Thread 2. Acquiring m1." << std::endl;
m1.lock();
std::cout << "Thread 2 perform some work" << std::endl;
});

t1.join();
t2.join();


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

Thread 1. Acquiring m1.
Thread 2. Acquiring m2.
Thread 2. Acquiring m1.
Thread 1. Acquiring m2.


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

Do useful work. Stay cool.

#concurrency
​​Зачем локать мьютексы в разном порядке
#новичкам

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

Дело в том, что иногда это не совсем очевидно. Точнее почти всегда это не очевидно.

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

Разберем чуть более сложный пример:

struct SomeSharedResource {
void swap(SomeSharedResource& obj) {
{
std::lock_guard lg{mtx};
// just for results reproducing
std::this_thread::sleep_for(std::chrono::milliseconds(10));
std::lock_guard lg1{obj.mtx};
// handle swap
}
}
std::mutex mtx;
};

int main() {
SomeSharedResource resource1;
SomeSharedResource resource2;
std::mutex m2;
std::thread t1([&resource1, &resource2] {
resource1.swap(resource2);
std::cout << "1 Do some work" << std::endl;
});
std::thread t2([&resource1, &resource2] {
resource2.swap(resource1);
std::cout << "2 Do some work" << std::endl;
});

t1.join();
t2.join();
}


Все просто. У нас есть пара объектов, которые используются в качестве разделяемых ресурсов (их могут изменять более 1 потока). Объекты могут обмениваться между собой данными.

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

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

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

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

Don't be obvious. Stay cool.

#concurrency
​​Локаем много мьютексов
#опытным

Cтандартное решение этой проблемы дедлока из постов выше - лочить замки в одном и том же порядке во всех потоках. Но как это сделать? Они не же на физре, "по порядку рассчитайсьььь" не делали.

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

Так как проблема довольно стандартная, то и решение мы скорее всего найдем в том же стандарте.

std::scoped_lock был введен в С++17 и представляет собой RAII обертку над локом множества мьютексов. Можно сказать, что это std::lock_guard на максималках. То есть буквально, это обертка, которая лочит любое количество мьютексов от 0 до "сами проверьте верхнюю границу".

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

Пример из прошлого поста может выглядеть теперь вот так:

struct SomeSharedResource {
void swap(SomeSharedResource& obj) {
{
std::scoped_lock lg{mtx, obj.mtx};
// handle swap
}
}
std::mutex mtx;
};

int main() {
SomeSharedResource resource1;
SomeSharedResource resource2;
std::mutex m2;
std::thread t1([&resource1, &resource2] {
resource1.swap(resource2);
std::cout << "1 Do some work" << std::endl;
});
std::thread t2([&resource1, &resource2] {
resource2.swap(resource1);
std::cout << "2 Do some work" << std::endl;
});

t1.join();
t2.join();
}


И все. И никакого дедлока.

Однако немногое лишь знают, что std::scoped_lock - это не только RAII-обертка. Это еще и более удобная обертка над "старой" функцией из С++11 std::lock.

О ней мы поговорим в следующий раз. Ведь не всем доступны самые современные стандарты. Да и легаси код всегда есть.

Be comfortable to work with. Stay cool.

#cpp17 #cpp11 #concurrency
std::lock
#опытным

Сейчас уже более менее опытные разрабы знают про std::scoped_lock и как он решает проблему блокировки множества мьютексов. Однако и в более старом стандарте С++11 есть средство, позволяющее решать ту же самую проблему. Более того std::scoped_lock - это всего лишь более удобная обертка над этим средством. Итак, std::lock.

Эта функция блокирует 2 и больше объектов, чьи типы удовлетворяют требованию Locable. То есть для них определены методы lock(), try_lock() и unlock() с соответствующей семантикой.

Причем порядок, в котором блокируются объекты - не определен. В стандарте сказано, что объекты блокируются с помощью неопределенной серии вызовов методов lock(), try_lock и unlock(). Однако гарантируется, что эта серия вызовов не может привести к дедлоку. Собстна, для этого все и затевалось.

Штука эта полезная, но не очень удобная. Сами посудите. Эта функция просто блокирует объекты, но не отпускает их. И это в эпоху RAII. Ай-ай-ай.

Поэтому ее безопасное использование выглядит несколько вычурно:

struct SomeSharedResource {
void swap(SomeSharedResource& obj) {
{
// !!!
std::unique_lock<std::mutex> lk_c1(mtx, std::defer_lock);
std::unique_lock<std::mutex> lk_c2(obj.mtx, std::defer_lock);
std::lock(mtx, obj.mtx);
// handle swap
}
}
std::mutex mtx;
};

int main() {
SomeSharedResource resource1;
SomeSharedResource resource2;
std::mutex m2;
std::thread t1([&resource1, &resource2] {
resource1.swap(resource2);
std::cout << "1 Do some work" << std::endl;
});
std::thread t2([&resource1, &resource2] {
resource2.swap(resource1);
std::cout << "2 Do some work" << std::endl;
});

t1.join();
t2.join();
}


Раз мы все-таки за безопасность и полезные практики, то нам приходится использовать std::unique_lock'и на мьютексах. Только нужно передать туда параметр std::defer_lock, который говорит, что не нужно локать замки при создании unique_lock'а, его залочит кто-то другой. Тем самым мы убиваем 2-х зайцев: и RAII используем для автоматического освобождения мьютексов, и перекладываем ответственность за блокировку замков на std::lock.

Можно использовать и более простую обертку, типа std::lock_guard:
struct SomeSharedResource {
void swap(SomeSharedResource& obj) {
{
// !!!
std::lock(mtx, obj.mtx);
std::lock_guard<std::mutex> lk_c1(mtx, std::adopt_lock);
std::lock_guard<std::mutex> lk_c2(obj.mtx, std::adopt_lock);
// handle swap
}
}
std::mutex mtx;
};


Здесь мы тоже используем непопулярный конструктор std::lock_guard: передаем в него параметр std::adopt_lock, который говорит о том, что мьютекс уже захвачен и его не нужно локать в конструкторе lock_guard.

Можно и ручками вызвать .unlock() у каждого замка, но это не по-православному.

Использование unique_lock может быть оправдано соседством с условной переменной, но если вам доступен C++17, то естественно лучше использовать std::scoped_lock.

Use modern things. Stay cool.

#cpp11 #cpp17 #concurrency
Почему не используют стратегию блокировки по адресам?
#опытным

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

Начнем с того, что локать один мьютекс - это норма. Все так делают, никто от этого не помер.

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

А именно это и происходит при вызове метода lock(). Поток мытается захватить мьютекс и если не получается - блокируется до того момента, пока мьютекс не освободится.

Поэтому любая схема с последовательным вызовом методов lock() будет подвержена дедлокам.

А в схеме с упорядоченным по адресам блокировкой именно так и происходит.

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

А что если в одном потоке замки будут лочиться через scoped_lock по адресной схеме, а в другом потоке - одиночно?

            1 поток            |            2 поток              |
-------------------------------|---------------------------------|
lock(mutex2) // УСПЕШНО | |
| scoped_lock() |
| lock(mutex1) // УСПЕШНО |
| lock(mutex2) // ОЖИДАНИЕ ... |
lock(mutex1) // ОЖИДАНИЕ... | |


В этом случае настанет дедлок. Спасибо за пример Сергею Борисову.

Ну или другую ситуацию рассмотрим: есть 4 замка l1, l2, l3, l4. Поток захватил замок с самым большим адресом l4 и надолго(потенциально навсегда) заблокировался.
Но другие треды продолжают нормально работать. И они иногда захватывают пары мьютексов. Все продолжается нормально, пока один из потоков не пытается залочить l3 и l4. Из-за ордеринга захватится l3, а дальше поток будет ждать освобождения l4 aka заблокируется. Дальше другой поток будет пытаться захватить l2 и l3. Он захватит l2 и будет дожидаться l3.

Логику можно продолжать и дальше. Таким образом из-за одного мьютекса и немного поломанного потока может остановиться вся программа.

std::mutex l1, l2, l3, l4;
// Пусть я как-то гарантирую, что они пронумерованы в порядке
возрастания адресов и std::scoped_lock(sc_lock для краткости)
работает с помощью сортировки по адресам

1 поток | 2 поток | 3 поток | 4 поток
-------------|----------------|----------------|----------------
l4.lock(); | | |
//blocks here| | |
|sc_lock(l3, l4);| |
| // lock l3 | |
| // blocks on l4| |
|sc_lock(l2, l3);|
| // lock l2 |
| // blocks on l3|
| | sc_lock(l1, l2);
| // lock l1
| // blocks on l2


Примеры немного преувеличены, но тем не менее они говорят о том, что схема с адресами не совсем безопасна.

Так может тогда вообще не будем блокироваться при уже захваченном мьютексе? Именно это и делают в реализации стандартной библиотеки. Первый захват мьютекса происходит через обычный lock(), а остальные мьютексы пытаются заблокировать через try_lock. Можно сказать, что это lock-free взятие замка. Если мьютекс можно захватить - захватываем, если нет, то не блокируемся и дальше продолжаем исполнение. Так вот в случае, если хотя бы один try_lock для оставшихся замков вернул false, то реализация освобождает все захваченные замки и начинает попытку снова.

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

ПРОДОЛЖЕНИЕ В КОММЕНТАРИЯХ

#concurrency
​​Еще один способ залочить много мьютексов

Последний пост из серии.

Вот у мьютекса есть метод lock, который его захватывает. А разработчики stdlib взяли и сделали функцию std::lock, которая лочит сразу несколько замков.

Также у мьютекса есть метод try_lock, который пытается в неблокирующем режиме его захватить. "Авось да получится". И видимо по аналогии с lock в стандартной библиотеке существует свободная функция std::try_lock, которая пытается захватить несколько мьютексов так же в неблокирующем режиме.

template< class Lockable1, class Lockable2, class... LockableN >  
int try_lock( Lockable1& lock1, Lockable2& lock2, LockableN&... lockn );


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

Чтобы с помощью std::try_lock наверняка захватить все замки, нужно крутиться в горячем цикле и постоянно вызывать std::try_lock, пока она не вернет -1.

Непонятно, зачем эта функция нужна в прикладном программировании при наличии std::scoped_lock и std::lock, которые еще и удобно оборачивают все эти циклы, скрывая все эти кишки в деталях реализации.

Единственное, что пришло в голову - реализация своего scoped_lock'а с блэкджеком и другим алгоритмом предотвращения дедлока. Этот алгоритм должен быть чем-то похож на поддавки, но как-то изменен. У кого есть кейсы применения - отпишитесь в комменты.

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

Stay useful. Stay cool.

#concurrency
​​std::jthread

С std::thread в С++ есть один интересный и возможно назойливый нюанс. Давайте посмотрим на код:

int main()
{
std::thread thr{[]{ std::cout << "Hello, World!" << std::endl;}};
}


Простой Хелло Ворлд в другом потоке. Но при запуске программы она тут же завершится примерно с таким сообщением: terminate called without an active exception.

Эм. "Я же просто хотел быть счастливым вывести в другом потоке сообщение. Неужели просто так не работает?"

В плюсах много чего просто так не работает)

А вот такая программа:

int main()
{
std::thread thr{[]{ std::cout << "Hello, World!" << std::endl;}};
std::this_thread::sleep_for(std::chrono::seconds(1));
}


Все-таки напечатает Hello, World!, но потом все равно завершится с std::terminate.

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

С помощью слипа мы немного затормозили main тред и сообщение появилось. То есть мы оттянули выход из main и завершение программы и дали возможность подольше поработать новосозданному потоку. А что происходит при выходе из скоупа функции? Вызов деструкторов локальных объектов.

Так вот в деструкторе единственного локального объекта и проблема. Согласно документации, для каждого потока мы обязательно должны выбрать одну из 2-х стратегий поведения: отсоединить его от родительского потока или дождаться его завершения. Делается это методами detach и join соответственно.

И если мы не вызовем один из этих методов для объекта потока, то в своем деструкторе он вызовет std::terminate. То есть корректный код выглядит так:

int main()
{
std::thread thr{[]{ std::cout << "Hello, World!" << std::endl;}};
thr.join();
}


Мы дожидаемся конца исполнения потока и только после этого завершаем программу. Теперь никаких терминаторов.

Но зачем эти формальности? Вообще говоря, часто мы хотим присоединить поток почти сразу перед вызовом его деструктора. А вот отсоединяем поток мы почти сразу после создания объекта. Мы же заранее знаем, хотим ли мы отпустить поток в свободное плавание или нет. И, учитывая эти факты, было бы приятно иметь возможность не вызывать join самостоятельно, а чтобы он за нас вызывался в деструкторе.

И С++20 приходит здесь нам на помощь с помощью std::jthread. Он делает ровно это. Если его не освободили и не присоединили мануально, то он присоединяется в деструкторе.

Поэтому такой код сделает то, что мы ожидаем:

int main()
{
std::jthread thr{[]{ std::cout << "Hello, World!" << std::endl;}};
}


jthread не только этим хорош. Его исполнение можно еще отменять/приостанавливать. Но об этом уже в другой раз.

Кстати. Вопрос на засыпку. Слышал, что там какие-то сложности у кланга с jthread были. Сейчас все нормально работает?

Make life easier. Stay cool.

#cpp20 #concurrency
RAII обертки мьютекса
#новичкам

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

Для начала - для работы с мьютексами нужны обертки. Очень некрасиво в наше время использовать чистые вызовы lock и unlock. Это наплевательское отношение к современному и безопасному(посмеемся) С++.

Здесь прямая аналогия с выделением памяти. Для каждого new нужно вызывать соответствующий delete. Чтобы случайно не забыть этого сделать(а в большом объеме кода что-то забыть вообще не проблема), используют умные указатели. Это классы, используя концепцию Resource Acquisition Is Initialization, помогают нам автоматически освобождать ресурсы, когда они нам более не нужны.

Также для каждого lock нужно вызвать unlock. Так может по аналогии сделаем класс, который в конструкторе будет лочить мьютекс, а в деструкторе - разблокировать его?

Хорошая новость в том, что это уже сделали за нас. И таких классов даже несколько. Если не уходить в специфическую функциональность, то их всего 2. Это std::lock_guard и std::unique_lock.

lock_guard - базовая и самая простая обертка. В конструкторе лочим, в деструкторе разлачиваем. И все. Больше ничего мы с ним делать не можем. Мы можем только создать объект и удалить его. Даже получить доступ к самому мьютексу нельзя. Очень лаконичный и безопасный интерфейс. Но и юзкейсы его применения довольно ограничены. Нужна простая критическая секция. Обезопасил и идешь дальше.


bool MapInsertSafe(std::unordered_map<Key, Value>& map, const Key& key, Value value) {
std::lock_guard lck(mtx);
if (auto it = map.find(key))
return false;
else {
it->second = std::move(value);
return true;
}
}


unique_lock же больше походит на std::unique_ptr. Уже можно получить доступ в объекты, поменяться содержимым с другим объектом unique_lock, поменять нижележащий объект на другой. Ну и раз мы все равно даем возможность пользователю получить доступ к нижележащему объекту, то удобно вынести в публичный интерфейс unique_lock те же методы, которыми можно управлять самим мьютексом. То есть lock, unlock, try_lock и тд - это часть интерфейса std::unique_lock.

Уже понятно, что этот вид обертки нужно использовать в более сложных случаях, когда функциональности lock_guard не хватает.

А в основном не хватает возможности использовать методы lock и unlock. Например, при работе в кондварами std::unique_lock просто необходим. Читатель заходит в критическую секцию, локает замок и затем видит, что определенные условия еще не наступили. Допустим еще нет данных для чтения. В такой ситуации надо отпустить мьютекс и заснуть до лучших времен. А при пробуждении и наступлении подходящих условий, опять залочить замок и начать делать работу.

void worker_thread()
{
std::unique_lock lk(m);
cv.wait(lk, []{ return ready; });
// process data
lk.unlock();
}


Кондвар в методе wait при ложном условии вызывает unlock у unique_lock. При пробуждении и правдивом условии вызывает lock и ждет освобождения мьютекса.

Юзкейсы можно обсуждать очень долго, но вывод один: всегда используйте обертки над мьютексами! Возможно, они спасут вашу жизнь.

А уж какую обертку использовать подскажет сама задача.

Stay safe. Stay cool.

#concurrency
Еще один плюс RAII
#опытным

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

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

Это такие противные малявки, которые прерывают нормальное выполнение программы в исключительных ситуациях. Так вот вы рассчитываете на "нормальное выполнение программы" и, исходя из этого, расставляете освобождения. А тут бац! И программа просто не доходит до нужной строчки.

std::shared_ptr<Value> DB::SelectWithCache(const Key& key) {
mtx_.lock();
if (auto it = cache.find(key); it != cache_.end()) {
mtx_.unlock();
return it->second;
} else {
std::shared_ptr<Value> result = Select(key);
cache.insert({key, result});
mtx_.unlock();
return result;
}
}


Простой код запроса к базе с кэшом. Что будет в том случае, если метод Select бросит исключение? unlock никогда не вызовется и мьютекс коннектора к базе будет навсегда залочен! Это очень печально, потому что ни один поток больше не сможет получить доступ к критической секции. Даже может произойти deadlock текущего потока, если он еще раз попытается захватить этот мьютекс. А это очень вероятно, потому что на запросы к базе скорее всего есть ретраи.

Мы могли бы сделать обработку исключений и руками разлочить замок:

std::shared_ptr<Value> DB::SelectWithCache(const Key& key) {
try {
mtx_.lock();
if (auto it = cache.find(key); it != cache_.end()) {
mtx_.unlock();
return it->second;
} else {
std::shared_ptr<Value> result = Select(key);
cache.insert({key, result});
mtx_.unlock();
return result;
}
}
catch (...) {
Log("Caught an exception");
mtx_.unlock();
}
}


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

А снаружи метода объекта мы уже не сможем разблокировать мьютекс при обработке исключения, потому что это приватное поле.

Выход один - использовать RAII.

std::shared_ptr<Value> DB::SelectWithCache(const Key& key) {
std::lock_guard lg{mtx_};
if (auto it = cache.find(key); it != cache_.end()) {
return it->second;
} else {
std::shared_ptr<Value> result = Select(key);
cache.insert({key, result});
return result;
}
}


При захвате исключения происходит раскрутка стека и вызов деструкторов локальных объектов. Это значит, что в любом случае вызовется деструктор lg и мьютекс освободится.

Спасибо Михаилу за идею)

Stay safe. Stay cool.

#cpp11 #concurrency #cppcore #goodpractice
​​Потокобезопасный интерфейс
#новичкам

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

Возьмем максимально простую реализацию самой простой очереди:

struct Queue {
void push(int value) {
storage.push_back(value);
}
void pop() {
storage.pop_front();
}
bool empty() {
return storage.empty();
}
int& front() {
return storage.front();
}
private:
std::deque<int> storage;
};


Она конечно потокоНЕбезопасная. То есть ей можно адекватно пользоваться только в рамках одного потока.

Как может выглядеть код простого консьюмера этой очереди?

while(condition)
if (!queue.empty()) {
auto & elem = queue.front();
process_elem(elem);
queue.pop();
}


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

Бабахаем везде лок гард на один мьютекс и дело в шляпе!

struct Queue {
void push(int value) {
std::lock_guard lg{m};
storage.push_back(value);
}
void pop() {
std::lock_guard lg{m};
storage.pop_front();
}
bool empty() {
std::lock_guard lg{m};
return storage.empty();
}
int& front() {
std::lock_guard lg{m};
return storage.front();
}
private:
std::deque<int> storage;
std::mutex m;
};


Все доступы к очереди защищены. Но спасло ли реально это нас?

Вернемся к коду консюмера:

while(true)
if (!queue.empty()) {
auto & elem = queue.front();
process_elem(elem);
queue.pop();
}



А вдруг у нас появится еще один консюмер? Тогда в первом из них мы можем войти условие, а в это время второй достанет последний элемент. Получается, что мы получим доступ к неинициализированной памяти в методе front.

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

Что делать? Не только сами методы класса должны быть потокобезопасными. Но еще и комбинации использования этих методов тоже должны обладать таким свойством. И с данным интерфейсом это сделать просто невозможно.

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

struct ThreadSafeQueue {
void push(int value) {
std::lock_guard lg{m};
storage.push_back(value);
}
std::optional<int> pop() {
std::lock_guard lg{m};
if (!storage.empty()) {
int elem = storage.front();
storage.pop_front();
return elem;
}
return nullopt;
}
private:
std::deque<int> storage;
std::mutex m;
};


Внутри метода pop мы можем использовать проверять и получать стейт очереди, так как мы оградились локом. Возвращаем из него std::optional, который будет хранить фронтальный элемент, если очередь была непуста. В обратном случае он будет пуст.

Теперь консюмер выглядит так:

while(true) {
auto elem = queue.pop();
if (elem)
process_elem(elem.value());
}


Можно конечно было использовать кондвары и прочее. Но я хотел сфокусироваться именно на интерфейсе. Теперь реализация просто не позволяет получать пользователю потенциально неактульные данные.

Stay safe. Stay cool.

#concurrency #design #goodpractice
Ответ
#опытным

Правильный ответ на квиз из предыдущего поста- 0. Вообще ни одного мьютекса не нужно.
Много можно вариантов придумать. И даже в комментах написали несколько рабочих способов.

Вообще говоря, в определении дедлока не звучит слово "мьютекс". Потоки должны ждать освобождения ресурсов. А этими ресурсами может быть что угодно.
Для организации дедлока достаточно просто, чтобы 2 потока запустились в попытке присоединить друг друга. Естественно, что они будут бесконечно ждать окончания работы своего визави.

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

Заметьте, что мы пытаемся сделать грязь. Так давайте же применим самые опасные вещи из плюсов и у нас все получится! Надо лишь добавить 50 грамм указателей и чайную ложку глобальных переменных. Получается вот такая каша:

std::thread * t_ptr = nullptr;

void func1() {
std::this_thread::sleep_for(std::chrono::seconds(1));
t_ptr->join();
std::cout << "Never reached this point1" << std::endl;
}

void func2(std::thread& t) {
t.join();
std::cout << "Never reached this point2" << std::endl;
}

int main() {
std::thread t1{func1};
t_ptr = new std::thread(func2, std::ref(t1));
while(true) {
std::this_thread::sleep_for(std::chrono::seconds(1));
}
}


Все просто. Вводим глобальный указатель на поток. В функции первого потока мы даем время инициализировать указатель и присоединяем поток по указателю. А тем временем в main создаем динамический объект потока и записываем его по указателю t_ptr. Таким образом первый поток получает доступ ко второму. В функцию второго потока передаем объект первого потока по ссылке и присоединяем его. Обе функции после инструкции join выводят на консоль запись.

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

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

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

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

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

Stay surprised. Stay cool.

#concurrency #cppcore
​​Дедлокаем один поток
#опытным

Мы привыкли, что для дедлоков нужно несколько потоков. Не удивительно. Давайте прочитаем определение дедлока по Коффману. Там речь про процессы, но если поменять слово "процесс" на "поток" ничего не изменится. Ну и перевод будет вольный.

Дедлок - это ситуация в коде, когда одновременно выполняются все следующие условия:

А ну, мальчики, играем поочереди. Только один поток может получить доступ к ресурсу в один момент времени.

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

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

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

Судя по этому определению, минимальное количество потоков, чтобы накодить дедлок - 2.

Но это такая общая теория работы с многозадачностью в программах.

Определение оперирует общим термином ресурс. И не учитывает поведение конкретного ресурса и деталей его реализации. А они важны!

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

std::mutex mtx;
mtx.lock();
mtx.lock();


Стандарт говорит, что будет UB. То есть поведение программы неопределено, возможно она заставит Ким Чен Ира спеть гангам стайл.

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

1️⃣ Компилятор имплементировал умный мьютекс, который может задетектить double lock и, например, кинуть в этом случае исключение.

2️⃣ Мьютекс у нас обычный, подтуповатый и он делает ровно то, что ему говорят. А именно пытается залочить мьютекс. Конечно у него ничего не получится и он вечно будет ждать его освобождения. Результат такого сценария - дедлок одного потока одним мьютексом!

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

Be self-sufficient. Stay cool.

#concurrency #cppcore #compiler