С++ - язык с ручным управлением ресурсами, в нем нужно разработчику управлять выделением и освобождение памяти
Поэтому появилась идиома RAII (Resource Acquisition is Initialization) - “Получение ресурса есть инициализация”
Для некоторых объектов (например, для памяти, файловых дескрипторов) важно гарантировать освобождение ресурса при выходе из области видимости. Идиома утверждает, что ресурсы должны быть выделены, получены при инициализации (в конструкторе) и высвобождены в деструкторе
Принципу RAII соответствуют умные указатели std::unique_ptr и мьютексы std::lock_guard. В случае мьютексов код, не следующии RAII, выглядит так:
{
mutex1.lock()
// критическая секция
// здесь может быть return или throw
mutex1.unlock()
}
Здесь нужно быть внимательным, чтобы mutex1.unlock() однозначно вызвался. std::lock_guard избавляет от этого:
{
std::lock_guard lck(mutex1);
// конструктор делает mutex1.lock()
// критическая секция
// деструктор делает mutex1.unlock()
}
Деструктор вызывается в любом случае при выходе из скоупа, поэтому мьютекс будет однозначно разблокирован
std::unique_ptrУмные указатели std::unique_ptr и пара std::shared_ptr-std::weak_ptr также следуют идиоме RAII и используются для управления памяти
Уникальный указатель std::unique_ptr владеет объектом единолично. Копирование такое указателя запрещено, но разрешено перемещение
std::unique_ptr создается с помощью std::make_unique, а не через конструктор. Рассмотрим такой пример:
Foo(new Bar(), std::unique_ptr(new Boo()));
Стандарт C++ не указывает, в каком порядке должны быть созданы объекты-аргументы, поэтому может случиться такая ситуация:
new Bar()new Boo(), выделив память, конструктор которого вызывает исключениеstd::unique_ptr(new Boo())Исключение перехватывается на уровне выше по стеку вызовов, но память, выделившаяся в конструкторе new Bar(), не освободиться, поэтому возникнет утечка памяти. Вместо этого лучше использовать std::make_unique:
Foo(std::make_unique<Bar>(), std::make_unique<Boo>());
std::shared_ptr и слабый указатель std::weak_ptrРазделяемый указатель std::shared_ptr используется, когда объект должен иметь несколько владельцев. Каждый разделяемый указатель увеличивает счетчик ссылок при создании и копировании, уменьшает при уничтожении, а когда счетчик становится равен нулю - объект уничтожается
Рекомендуется создавать std::shared_ptr с помощью std::make_shared по тем же причинам
Вместе с std::shared_ptr в комплекте идет тип слабого указателя. Слабый указатель std::weak_ptr создан для того, чтобы избавиться от кольцевых зависимостей
struct B;
struct A {
std::shared_ptr<B> b;
};
struct B {
std::shared_ptr<A> a;
};
Если A и B ссылаются друг на друга через std::shared_ptr, счетчик ссылок никогда не станет нулем, и возникнет утечка памяти
Слабый указатель weak_ptr используется вместе с shared_ptr, но сам по себе ничего не хранит. Вместо этого можно вызвать метод .lock(), который вернет shared_ptr, если объект существует внутри, или nullptr, если он был уничтожен (то есть больше нет живых std::shared_ptr):
std::shared_ptr<Foo> shptr = std::make_shared<Foo>();
// ...
std::weak_ptr<Foo> wptr = shptr;
if (auto sptr = wptr.lock()) {
// объект еще существует
} else {
// объект уже уничтожен
}
std::shared_ptr при создании через std::make_shared выделяет один непрерывный блок памяти c самим объектом, счетчиком разделяемых указателей и счетчиком слабых указателей. Это эффективнее по памяти и быстрее по времени
Если счетчик std::shared_ptr становится равен нулю, объект уничтожается, но управляющий блок (в котором хранятся счетчики) остается в памяти, пока существуют std::weak_ptr
Если подразумевается, что слабых указателей во время жизни программы будет много, то не рекомендуется использовать std::make_shared
Также копирование и уничтожение разделяемых указателей потокобезопасно, так как инкремент и декремент - атомарные операции
Также для создания программ на C++ полезны функторы
Функтор (functor) — это объект, который можно вызывать как функцию. Он реализует оператор operator()
Функторы реализуют паттерн «Команда» — объект, хранящий в себе функцию и ее состояние.
Пример:
struct Add {
int x;
Add(int x) : x(x) {}
int operator()(int y) const {
return x + y;
}
};
int main() {
Add number(10);
std::cout << number(5); // 15
}
Зачастую количество выполняемых поток превышает число ядр процессора, что позволяет обрабатывать больше вычислений. Это достигается с помощью:
Корутин
Корутина (или сопрограмма) - программный модуль, сделанный таким образом, чтобы взаимодействовать с другими по принципу кооперативной многозадачности
По сути корутины исполняются в пределах одного процессорного потока: корутина приостанавливает исполнения там, где она хочет, затем менеджер выбирает следующую корутину для исполнения
Корутины бывает со стеком (stackful), если у каждой из них свой программный стек, и бесстековые (stackless), если у них общий стек с функцией, вызывающей корутины
В стандарте C++20 корутины бесстековые, и тот вид, в которой они представлены, спорен, поэтому рекомендуется их использовать в связке с оборачивающей их библиотекой
Hyper-Threading/Simultaneous Multithreading
Hyper-Threading у процессоров Intel и Simultaneous Multithreading у процессоров AMD позволяет производить вычисления двух потоков на одном ядре
Для этого в одном физическом ядре:
С технологией Hyper-Threading есть два (или более) логических ядра, у которых один и тот же кеш и предиктор, но выполняются они разные вычисления (поэтому увеличивается число промахов в кеш)
Константа std::hardware_concurrency покажет, сколько существует логических ядер процессора
Потоки операционной системы
В C++ потоки представлены объектом std::thread, который принимает любой вызываемый объект вместе с аргументами. Возвращаемое значение при этом игнорируется.
std::thread t([]{
// код потока
});
Обычно этот функтор - это лямбда-функция, которая захватывает по ссылке флаг, означающий завершенность потока
std::atomic<bool> flag = true;
std::thread t([&]{
// работа потока
flag = false;
});
while (flag) {
// ожидание
}
t.join();
В основной функции происходит ожидание этого флага
В рамках процесса потоками, на уровне которых работает планировщик, память и другие ресурсы общие, поэтому можно производить действия над общими структурами
Для лучшего управления потоками есть объекты std::future и std::promise. Объект std::promise представляет обещание передать в него значение, а std::future представляет будущее, из которого значение будет получено
std::promise<int> p;
std::future<int> f = p.get_future();
std::thread t([&]{
p.set_value(42);
});
// делает какую-то параллельную работу
int result = f.get(); // ждет результат
t.join();

Объект std::async еще сильнее упрощает работу: он создает поток, запускает функцию и возвращает std::future с результатом:
auto f = std::async([]{
return 42;
});
int result = f.get();
Для std::async есть политики запуска:
// обязательно создастся новый поток
std::async(std::launch::async, func);
// ленивый запуск, задача не начинает
// выполнение в момент вызова, а в момент получения результата
// и в этом же потоке
std::async(std::launch::deferred, func);