Топ 20 ошибок при работе с многопоточностью на С++ и способы избежать их

Привет, Хабр! Предлагаю вашему вниманию перевод статьи «Top 20 C++ multithreading mistakes and how to avoid them» автора Deb Haldar.


Сцена из фильма «Петля времени» (2012)

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

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

Ошибка №1: Не использовать join() для ожидания фоновых потоков перед завершением приложения


Если вы забыли присоединить поток (join()) или открепить его (detach()) (сделать его не joinable) до завершения программы, это приведет к аварийному завершению. (В переводе будут встречаться слова присоединить в контексте join() и открепить в контексте detach(), хотя это не совсем корректно. Фактически join() это точка, в которой один поток выполнения дожидается завершения другого, и никакого присоединения или объединения потоков не происходит [прим. переводчика]).

В примере ниже, мы забыли выполнить join() потока t1 в основном потоке:

#include "stdafx.h"
#include <iostream>
#include <thread>
 
using namespace std;
 
void LaunchRocket()
{
   cout << "Launching Rocket" << endl;
}
int main()
{
   thread t1(LaunchRocket);
   //t1.join(); // как только мы забыли join- мы получаем аварийное завершение программы
   return 0;
}


Почему программа упала?! Потому что в конце функции main() переменная t1 вышла из области видимости и был вызван деструктор потока. В деструкторе происходит проверка является ли поток t1 joinable. Поток является joinable, если он не был откреплен. В этом случае в его деструкторе вызывается std::terminate. Вот что, например, делает компилятор MSVC++.

~thread() _NOEXCEPT
{  // clean up
    if (joinable())
        XSTD terminate();
}


Есть два способа исправления проблемы в зависимости от задачи:

1. Вызвать join() потока t1 в основном потоке:

int main()
{
  thread t1(LaunchRocket);
  t1.join(); // выполняем join потока t1, ожидаем завершение этого потока в основном потоке выполнения
    return 0;
}


2. Открепить поток t1 от основного потока, позволить ему продолжить работать как «демонизированный» поток:

int main()
{
    thread t1(LaunchRocket);
    t1.detach(); // открепление  t1 от основного потока
    return 0;
}


Ошибка №2: Пытаться присоединить поток, который ранее был откреплен


Если в какой-то точке работы программы у вас есть открепленный (detach) поток, вы не можете присоединить его обратно к основному потоку. Это очень очевидная ошибка. Проблема в том, что вы можете открепить поток, а потом написать несколько сотен строк кода и попробовать вновь присоединить его. В конце концов, кто помнит, что он писал 300 строк назад, верно?

Проблема в том, что это не вызовет ошибку компиляции, вместо этого программа аварийно завершится при запуске. Например:

#include "stdafx.h"
#include <iostream>
#include <thread>
 
using namespace std;
 
void LaunchRocket()
{
    cout << "Launching Rocket" << endl;
}
 
int main()
{
    thread t1(LaunchRocket);
    t1.detach();
    //..... 100 строк какого-то кода
    t1.join(); // CRASH !!!
    return 0;
}


Решение заключается в том, что необходимо всегда делать проверку потока на joinable() перед тем как пытаться его присоединить к вызывающему потоку.

int main()
{
  thread t1(LaunchRocket);
  t1.detach();
  //..... 100 строк какого-то кода
 
  if (t1.joinable())
  {
    t1.join(); 
  }
 
  return 0;
}


Ошибка №3: Непонимание того, что std::thread::join() блокирует вызывающий поток выполнения


В реальных приложениях вам часто может потребоваться выделить в отдельный поток «долгоиграющие» операции обработки сетевого ввода-вывода или ожидания нажатия пользователя на кнопку и т.п. Вызов join() для таких рабочих потоков (например поток отрисовки UI) может привести к зависанию пользовательского интерфейса. Существуют более подходящие способы реализации.

Например, в GUI приложениях рабочий поток при завершении может отправить сообщение UI потоку. UI поток имеет собственный цикл обработки событий таких как: перемещение мыши, нажатие на клавиши и т.д. Этот цикл также может принимать сообщения от рабочих потоков и реагировать на них без необходимости вызова блокирующего метода join().

По этой самой причине в платформе WinRT от Microsoft практически все взаимодействия с пользователем сделаны асинхронными, а синхронные альтернативы недоступны. Эти решения были приняты для гарантии того, что разработчики будут использовать API, которое предоставляет наилучший опыт использования для конечных пользователей. Можно обратиться к руководству «Modern C++ and Windows Store Apps» для получения более подробной информации по данной теме.

Ошибка №4: Считать, что аргументы функции потока по умолчанию передаются по ссылке


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

Под спойлером примеры из другой статьи C++11 Multithreading Tutorial via Q&A – Thread Management Basics (Deb Haldar), иллюстрирующие передачу параметров [прим. переводчика].

подробнее:
При выполнении кода:
#include "stdafx.h"
#include <string>
#include <thread>
#include <iostream>
#include <functional>
 
using namespace std;
 
void ChangeCurrentMissileTarget(string& targetCity)
{
  targetCity = "Metropolis";
  cout << " Changing The Target City To " << targetCity << endl;
}
 
int main()
{
  string targetCity = "Star City";
  thread t1(ChangeCurrentMissileTarget, targetCity);
  t1.join();
  cout << "Current Target City is " << targetCity << endl;
 
  return 0;
}
 


Будет выведено в терминал:
Changing The Target City To Metropolis
Current Target City is Star City


Как видите, значение переменной targetCity, получаемой функцией, вызываемой в потоке, по ссылке не изменилось.

Перепишем код с использованием std::ref() для передачи аргумента:

#include "stdafx.h"
#include <string>
#include <thread>
#include <iostream>
#include <functional>
 
using namespace std;
 
void ChangeCurrentMissileTarget(string& targetCity)
{
  targetCity = "Metropolis";
  cout << " Changing The Target City To " << targetCity << endl;
}
 
int main()
{
  string targetCity = "Star City";
  thread t1(ChangeCurrentMissileTarget, std::ref(targetCity));
  t1.join();
  cout << "Current Target City is " << targetCity << endl;
 
  return 0;
}


Будет выведено:
Changing The Target City To Metropolis
Current Target City is Metropolis


Изменения, сделанные в новом потоке, отразятся на значении переменной targetCity объявленной и инициализированной в функции main.

Ошибка №5: Не защищать разделяемые данные и ресурсы с помощью критической секции (например мьютексом)


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

В примере ниже std::cout является разделяемым ресурсом, с которым работают 6 потоков (t1-t5 + main).

#include "stdafx.h"
#include <iostream>
#include <string>
#include <thread>
#include <mutex>
 
using namespace std;
 
std::mutex mu;
 
void CallHome(string message)
{
  cout << "Thread " << this_thread::get_id() << " says " << message << endl;
}
 
int main()
{
  thread t1(CallHome, "Hello from Jupiter");
  thread t2(CallHome, "Hello from Pluto");
  thread t3(CallHome, "Hello from Moon");
 
  CallHome("Hello from Main/Earth");
 
  thread t4(CallHome, "Hello from Uranus");
  thread t5(CallHome, "Hello from Neptune");
 
  t1.join();
  t2.join();
  t3.join();
  t4.join();
  t5.join();
 
  return 0;
}
 


Если мы выполним эту программу, то получим вывод:

Thread 0x1000fb5c0 says Hello from Main/Earth
Thread Thread Thread 0x700005bd20000x700005b4f000 says says Thread Thread Hello from Pluto0x700005c55000Hello from Jupiter says 0x700005d5b000Hello from Moon
0x700005cd8000 says says Hello from Uranus

Hello from Neptune


Это происходит потому, что пять потоков одновременно обращаются к потоку вывода в произвольном порядке. Чтобы сделать вывод более определенным, необходимо защитить доступ к разделяемому ресурсу с помощью std::mutex. Просто изменим функцию CallHome() таким образом, чтобы она захватывала мьютекс перед использованием std::cout и освобождала его после.

void CallHome(string message)
{
  mu.lock();
  cout << "Thread " << this_thread::get_id() << " says " << message << endl;
  mu.unlock();
}


Ошибка №6: Забыть освободить блокировку после выхода из критической секции


В предыдущем пункте вы видели как защитить критическую секцию с помощью мьютекса. Однако, вызов методов lock() и unlock() непосредственно у мьютекса не является предпочтительным вариантом потому, что вы можете забыть отдать удерживаемую блокировку. Что произойдет дальше? Все остальные потоки, которые ожидают освобождения ресурса, будут бесконечно заблокированы и программа может зависнуть.

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

void CallHome(string message)
{
  mu.lock();
  cout << "Thread " << this_thread::get_id() << " says " << message << endl;
  //mu.unlock();  мы забыли освободить блокировку
}


Ниже приведен вывод данного кода– программа зависла, выведя единственное сообщение в терминал, и не завершается:

Thread 0x700005986000 says Hello from Pluto



Подобные ошибки часто случаются, именно поэтому нежелательно использовать методы lock()/unlock() напрямую из мьютекса. Вместо этого следует использовать шаблонный класс std::lock_guard, который использует идиому RAII для управления временем жизни блокировки. Когда объект lock_guard создаётся, он пытается завладеть мьютексом. Когда программа выходит из области видимости lock_guard объекта, вызывается деструктор, который освобождает мьютекс.

Перепишем функцию CallHome() с применением std::lock_guard объекта:

void CallHome(string message)
{
  std::lock_guard<std::mutex> lock(mu);  // пытаемся захватить блокировку
  cout << "Thread " << this_thread::get_id() << " says " << message << endl;
}// объект lock_guard уничтожится и освободит мьютекс


Ошибка №7: Делать размер критической секции больше, чем это необходимо


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

void CallHome(string message)
{
  std::lock_guard<std::mutex> lock(mu); // Начало критической секции, защищаем доступ к std::cout
 
  ReadFifyThousandRecords();
 
  cout << "Thread " << this_thread::get_id() << " says " << message << endl;
 
}// при уничтожении объекта lock_guard блокировка на мьютекс mu освобождается


Метод ReadFifyThousandRecords() не модифицирует данные. Нет никаких причин выполнять его под блокировкой. Если данный метод будет выполняться 10 секунд, считывая 50 тысяч строк из БД, все остальные потоки будут заблокированы на весь этот период без необходимости. Это может серьезно сказаться на производительности программы.

Правильным решением было бы держать в критической секции только работу с std::cout.

void CallHome(string message)
{
  ReadFifyThousandRecords(); // Нет необходимости держать данный метод в критической секции т.к. он не модифицирует данные
  std::lock_guard<std::mutex> lock(mu); // Начало критической секции, защищаем доступ к std::cout
  cout << "Thread " << this_thread::get_id() << " says " << message << endl;
 
}//  при уничтожении объекта lock_guard блокировка на мьютекс mu освобождается


Ошибка №8: Взятие нескольких блокировок в разном порядке



Это одна из наиболее распространенных причин взаимной блокировки (deadlock), ситуации, в которой потоки оказываются бесконечно заблокированы из-за ожидания получения доступа к ресурсам, заблокированным другими потоками. Рассмотрим пример:

поток 1 поток 2
lock A lock B
//… какие-то операции //… какие-то операции
lock B lock A
//… какие-то еще операции //… какие-то еще операции
unlock B unlock A
unlock A unlock B

Может возникнуть ситуация, в которой поток 1 попытается захватить блокировку B и окажется заблокированным, потому что поток 2 уже ее захватил. В тоже время, второй поток пытается захватить блокировку A, но не может этого сделать, потому что ее захватил первый поток. Поток 1 не может освободить блокировку A пока не захватит блокировку B и т.д. Другими словами, программа зависнет.

Данный пример кода поможет вам воспроизвести deadlock:

#include "stdafx.h"
#include <iostream>
#include <string>
#include <thread>
#include <mutex>
 
using namespace std;
 
std::mutex muA;
std::mutex muB;
 
void CallHome_Th1(string message)
{
  muA.lock();
  // выполнение каких-то операций
  std::this_thread::sleep_for(std::chrono::milliseconds(100));
  muB.lock();
 
  cout << "Thread " << this_thread::get_id() << " says " << message << endl;
 
  muB.unlock();
  muA.unlock();
}
 
void CallHome_Th2(string message)
{
  muB.lock();
  // какие-то дополнительные операции
  std::this_thread::sleep_for(std::chrono::milliseconds(100));
  muA.lock();
 
  cout << "Thread " << this_thread::get_id() << " says " << message << endl;
 
  muA.unlock();
  muB.unlock();
}
 
int main()
{
  thread t1(CallHome_Th1, "Hello from Jupiter");
  thread t2(CallHome_Th2, "Hello from Pluto");
 
  t1.join();
  t2.join();
 
  return 0;
}


Если вы запустите этот код, он зависнет. Если залезть глубже в отладчик в окно потоков, вы увидите, что первый поток (вызванный из функции CallHome_Th1()) пытается получить блокировку мьютекса B, в то время как поток 2 (вызванный из CallHome_Th2()) пытается заблокировать мьютекс A. Никто из потоков не может достичь успеха, что и приводит к взаимной блокировке!


(картинка кликабельна)

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

В зависимости от ситуации можно воспользоваться другими стратегиями:

1. Использовать класс-обертку std::scoped_lock для совместного захвата нескольких блокировок:

std::scoped_lock lock{muA, muB};

2. Воспользоваться классом std::timed_mutex, в котором можно указать таймаут, по истечении которого блокировка будет снята, если ресурс не стал доступен.

std::timed_mutex m;
 
void DoSome(){
    std::chrono::milliseconds timeout(100);
 
    while(true){
        if(m.try_lock_for(timeout)){
            std::cout << std::this_thread::get_id() << ": acquire mutex successfully" << std::endl;
            m.unlock();
        } else {
            std::cout << std::this_thread::get_id() << ": can’t  acquire mutex, do something else" << std::endl;
        }
    }
}


Ошибка №9: Пытаться дважды захватить блокировку std::mutex


Попытка дважды захватить блокировку приведет к неопределенному поведению. В большинстве отладочных реализаций это приведет к аварийному завершению. Например, в представленном ниже коде LaunchRocket() заблокирует мьютекс и после этого вызовет StartThruster(). Что любопытно, в приведенном коде вы не столкнетесь с этой проблемой при нормальной работе программы, проблема возникает только при выбросе исключения, который сопровождается неопределенным поведением или аварийным завершением программы.

#include "stdafx.h"
#include <iostream>
#include <thread>
#include <mutex>
 
std::mutex mu;
 
static int counter = 0;
 
void StartThruster()
{
  try
  {
    // какие-то операции
  }
  catch (...)
  {
    std::lock_guard<std::mutex> lock(mu);
    std::cout << "Launching rocket" << std::endl;
  }
}
 
void LaunchRocket()
{
  std::lock_guard<std::mutex> lock(mu);
  counter++;
  StartThruster();
}
 
int main()
{
  std::thread t1(LaunchRocket);
  t1.join();
  return 0;
}
 


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

Ошибка №10: Использовать мьютексы, когда достаточно std::atomic типов



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

Например, вместо того чтобы использовать следующую конструкцию:

int counter;
...
mu.lock();
counter++;
mu.unlock();


Лучше объявить переменную как std::atomic:

std::atomic<int> counter;
...
counter++;


Для получения подробного сравнения mutex и atomic обратитесь к статье «Comparison: Lockless programming with atomics in C++ 11 vs. mutex and RW-locks»

Ошибка №11: Создавать и разрушать большое количество потоков напрямую, вместо использования пула свободных потоков


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

Еще одно преимущество пула потоков, по сравнению с порождением и уничтожением потоков самостоятельно, заключается в том, что вам не нужно беспокоиться об thread oversubscription (ситуация, в которой количество потоков превышает количество доступных ядер и значительная часть процессорного времени тратится на переключение контекста [прим. переводчика]). Это может повлиять на производительность системы.

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

Две наиболее популярные библиотеки, реализующие пул потоков: Intel Thread Building Blocks(TBB) и Microsoft Parallel Patterns Library(PPL).

Ошибка №12: Не обрабатывать исключения, возникающие в фоновых потоках


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

#include "stdafx.h"
#include<iostream>
#include<thread>
#include<exception>
#include<stdexcept>
 
static std::exception_ptr teptr = nullptr;
 
void LaunchRocket()
{
  throw std::runtime_error("Catch me in MAIN");
}
 
int main()
{
  try
  {
    std::thread t1(LaunchRocket);
    t1.join();
  }
  catch (const std::exception &ex)
  {
    std::cout << "Thread exited with exception: " << ex.what() << "\n";
  }
 
  return 0;
}
 


При выполнении этой программы произойдет аварийное завершение, однако, catch блок в функции main() не выполнится и не обработает исключение, выброшенное в потоке t1.

Решение данной проблемы заключается в использовании возможности из C++11: std::exception_ptr применяется для обработки исключения, выброшенного в фоновом потоке. Вот шаги, которые необходимо предпринять:

  • Создать глобальный экземпляр класса std::exception_ptr, инициализированный nullptr
  • Внутри функции, которая выполняется в отдельном потоке, обрабатывать все исключения и устанавливать значение std::current_exception() глобальной переменной std::exception_ptr, объявленой на предыдущем шаге
  • Внутри основного потока проверять значение глобальной переменной
  • Если значение установлено, использовать функцию std::rethrow_exception(exception_ptr p) для повторного вызова пойманного ранее исключения, передав его по ссылке как параметр

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

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

#include "stdafx.h"
#include<iostream>
#include<thread>
#include<exception>
#include<stdexcept>
 
static std::exception_ptr globalExceptionPtr = nullptr;
 
void LaunchRocket()
{
  try
  {
    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    throw std::runtime_error("Catch me in MAIN");
  }
  catch (...)
  {
    //При возникновении исключения присваиваем значение указателю
    globalExceptionPtr = std::current_exception();
  }
}
 
int main()
{
  std::thread t1(LaunchRocket);
  t1.join();
 
  if (globalExceptionPtr)
  {
    try
    {
      std::rethrow_exception(globalExceptionPtr);
    }
    catch (const std::exception &ex)
    {
      std::cout << "Thread exited with exception: " << ex.what() << "\n";
    }
  }
 
  return 0;
}
 


Ошибка №13: Использовать потоки для симуляции асинхронной работы, вместо применения std::async


Если вам нужно, чтобы код выполнился асинхронно, т.е. без блокировки основного потока выполнения, наилучшим выбором будет использование std::async(). Это равносильно созданию потока и передаче необходимого кода на выполнение в этот поток через указатель на функцию или параметр в виде лямбда функции. Однако, в последнем случае вам необходимо следить за созданием, присоединением/отсоединением этого потока, а также за обработкой всех исключений, которые могут возникнуть в этом потоке. Если вы используете std::async(), вы избавляете себя от этих проблем, а также резко снижаете свои шансы попасть в deadlock.

Другое значительное преимущество использования std::async заключается в возможности получить результат выполнения асинхронной операции обратно в вызывающий поток с помощью std::future объекта. Представим, что у нас есть функция ConjureMagic(), которая возвращает int. Мы можем запустить асинхронную операцию, которая установит значение в будущем в future объект, когда выполнение задачи завершится, и мы сможем извлечь результат выполнения из этого объекта в том потоке выполнения, из которого операция была вызвана.

// запуск асинхронной операции и получение обработчика для future 
std::future asyncResult2 = std::async(&ConjureMagic);
 
//... выполнение каких-то операций пока future не будет установлено
 
// получение результата выполнения из future 
 int v = asyncResult2.get();


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

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

Kurt Guntheroth обнаружил, что с точки зрения производительности, накладные расходы на создание потока в 14 раз больше, чем использование async.

Итог: используйте std::async() по умолчанию, пока вы не найдете весомые аргументы в пользу использования непосредственно std::thread.

Ошибка №14: Не использовать std::launch::async если требуется асинхронность


Функция std::async() носит не совсем корректное название, потому что по умолчанию может не выполняться асинхронно!

Есть две политики выполнения std::async:

  1. std::launch::async: переданная функция начинает выполняться незамедлительно в отдельном потоке
  2. std::launch::deferred: переданная функция не запускается сразу же, ее запуск откладывается до того как будут произведены вызовы get() или wait() над std::future объектом, который будет возвращен из вызова std::async. В месте вызова этих методов, функция будет выполняться синхронно.

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

  • невозможность предсказать правильность доступа к локальным переменным потока
  • асинхронная задача может и вовсе не запуститься из-за того, что вызовы методов get() и wait() могут не быть вызваны в течение выполнения программы
  • при использовании в циклах, в которых условие выхода ожидает готовности std::future объекта, эти циклы могут никогда не завершиться, потому что std::future, возвращаемое вызовом std::async, может начаться в отложенном состоянии.

Для избежания всех этих сложностей всегда вызывайте std::async с политикой запуска std::launch::async.

Не делайте так:

//выполнение функции myFunction используя std::async с политикой запуска по умолчанию
auto myFuture = std::async(myFunction);


Вместо этого делайте так:

//выполнение функции myFunction асинхронно
auto myFuture = std::async(std::launch::async, myFunction);


Более подробно этот момент рассмотрен в книге Скотта Мейерса «Эффективный и современный С++».

Ошибка №15: Вызывать метод get() у std::future объекта в блоке кода, время выполнение которого критично


Приведенный ниже код обрабатывает результат, полученный из std::future объекта асинхронной операции. Однако, цикл while будет заблокирован, пока асинхронная операция не выполнится (в данном случае на 10 секунд). Если вы хотите использовать данный цикл для вывода информации на экран, это может привести к неприятным задержкам отрисовки пользовательского интерфейса.

#include "stdafx.h"
#include <future>
#include <iostream>
 
int main()
{
  std::future<int> myFuture = std::async(std::launch::async[]()
  {
    std::this_thread::sleep_for(std::chrono::seconds(10));
    return 8;
  });
 
  // Цикл обновления для выводимых данных
  while (true)
  {
    // вывод некоторой информации в терминал          
    std::cout << "Rendering Data" << std::endl;
    int val = myFuture.get(); // вызов блокируется на 10 секунд
    // выполнение каких-то операций над Val
  }
 
  return 0;
}
 


Замечание: еще одна проблема приведенного выше кода в том, что он пытается обратиться к std::future объекту второй раз, хотя состояние std::future объекта было извлечено на первой итерации цикла и повторно не может быть получено.

Правильным решением было бы проверять валидность std::future объекта перед вызовом get() метода. Таким образом, мы не блокируем завершение асинхронного задания и не пытаемся повторно опросить уже извлеченный std::future объект.

Данный фрагмент кода позволяет достичь этого:

#include "stdafx.h"
#include <future>
#include <iostream>
 
int main()
{
  std::future<int> myFuture = std::async(std::launch::async[]()
  {
    std::this_thread::sleep_for(std::chrono::seconds(10));
    return 8;
  });
 
  // Цикл обновления для выводимых данных
  while (true)
  {
    // вывод некоторой информации в терминал           
    std::cout << "Rendering Data" << std::endl;
 
    if (myFuture.valid())
    {
      int val = myFuture.get(); // вызов блокируется на 10 секунд
 
      //  выполнение каких-то операций над Val
    }
  }
 
  return 0;
}
 


Ошибка №16: Непонимание того, что исключения, выброшенные внутри асинхронной операции, передадутся в вызывающий поток только при вызове std::future::get()


Представим что у нас есть следующий фрагмент кода, как вы думаете, каким будет результат вызова std::future::get()?

#include "stdafx.h"
#include <future>
#include <iostream>
 
int main()
{
  std::future<int> myFuture = std::async(std::launch::async[]()
  {
    throw std::runtime_error("Catch me in MAIN");
    return 8;
  });
 
  if (myFuture.valid())
  {
    int result = myFuture.get();
  }
 
  return 0;
}
 


Если вы предположили что программа упадет– вы совершенно правы!

Исключение, выброшенное в асинхронной операции прокидывается только когда происходит вызов метода get() у std::future объекта. И если метод get() вызван не будет, то исключение будет проигнорировано и отброшено, когда std::future объект выйдет из области видимости.

Если ваша асинхронная операция может выбросить исключение, то необходимо всегда оборачивать вызов std::future::get() в try/catch блок. Пример как это может выглядеть:

#include "stdafx.h"
#include <future>
#include <iostream>
 
int main()
{
  std::future<int> myFuture = std::async(std::launch::async[]() 
  {
    throw std::runtime_error("Catch me in MAIN");
    return 8;
  });
 
  if (myFuture.valid())
  {
    try
    {
      int result = myFuture.get();
    }
    catch (const std::runtime_error& e)
    {
       std::cout << "Async task threw exception: " << e.what() << std::endl;
    }
  }
  return 0;
}
 


Ошибка №17: Использование std::async, когда требуется чёткий контроль над исполнением потока


Хотя std::async() достаточно в большинстве случаев, бывают ситуации, в которых вам может потребоваться тщательный контроль над выполнением вашего кода в потоке. Например, если вы хотите привязать определенный поток к конкретному ядру процессора в многопроцессорной системе (например Xbox).

Приведенный фрагмент кода устанавливает привязку потока к 5-му процессорному ядру в системе.

#include "stdafx.h"
#include <windows.h>
#include <iostream>
#include <thread>
 
using namespace std;
 
void LaunchRocket()
{
  cout << "Launching Rocket" << endl;
}
 
int main()
{
  thread t1(LaunchRocket);
 
  DWORD result = ::SetThreadIdealProcessor(t1.native_handle()5);
 
  t1.join();
 
  return 0;
}
 


Это возможно благодаря методу native_handle() объекта std::thread, и передаче его в потоковую функцию Win32 API. Существует множество других возможностей, предоставляемых через потоковое Win32 API, которые не доступны в std::thread или std::async(). При работе через std::async() эти базовые функции платформы недоступны, что и делает этот способ непригодным для более сложных задач.

Альтернативный вариант— создать std::packaged_task и переместить его в нужный поток выполнения после установки свойств потока.

Ошибка №18: Создавать намного больше «выполняющихся» потоков, чем доступно ядер


С точки зрения архитектуры потоки можно классифицировать на две группы: «выполняющиеся» и «ожидающие».

Выполняющиеся потоки утилизируют 100% процессорного времени ядра на котором работают. Когда более одного выполняющегося потока выделено на одно ядро, эффективность утилизации процессорного времени падает. Мы не получаем выигрыша в производительности, если выполняем более одного выполняющегося потока на одном процессорном ядре– в действительности производительность падает из-за дополнительных переключений контекста.

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

Итак, как понять какое количество выполняющихся потоков поддерживает система? Используйте метод std::thread::hardware_concurrency(). Эта функция обычно возвращает количество ядер процессора, но при этом учитывает ядра, которые ведут себя как два или более логических ядер из-за гипертрединга.

Необходимо использовать полученное значение целевой платформы для планирования максимального количества одновременно выполняющихся потоков вашей программы. Вы также можете назначить одно ядро для всех ожидающих потоков, и использовать оставшееся количество ядер для выполняющихся потоков. Например, в четырехъядерной системе используйте одно ядро для ВСЕХ ожидающих потоков, а для остальных трех ядер — три выполняющихся потока. В зависимости от эффективности вашего планировщика потоков, некоторые из ваших исполняемых потоков могут переключать контекст (из-за сбоев доступа к страницам и т.д.), оставляя ядро бездействующим в течение некоторого времени. Если вы наблюдаете эту ситуацию во время профилирования, вам следует создать чуть большее количество выполняемых потоков, чем количество ядер, и настроить эту величину для своей системы.

Ошибка №19: Использование ключевого слова volatile для синхронизации


Ключевое слово volatile перед указанием типа переменной не делает операции с этой переменной атомарными или потокобезопасными. То, что вы, вероятно, хотите, это std::atomic.

Посмотрите обсуждение на stackoverflow для получения подробностей.

Ошибка №20: Использование Lock Free архитектуры, кроме случаев когда это совершенно необходимо


В сложности есть что-то, что нравится каждому инженеру. Создание программ, работающих без блокировок (lock free), звучит очень соблазнительно по сравнению с обычными механизмами синхронизации, такими как мьютекс, условные переменные, асинхронность и т. д. Однако, каждый опытный разработчик C ++, с которым я говорил, придерживался мнения, что применение программирования без блокировок в качестве исходного варианта является видом преждевременной оптимизации, которая может выйти боком в самый неподходящий момент (подумайте о сбое в эксплуатируемой системе, когда у вас нет полного дампа кучи!).

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

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

  • Пробовали ли вы спроектировать архитектуру вашей системы таким образом, чтобы она не нуждалась в механизме синхронизации? Как правило, лучшая синхронизация– отсутствие синхронизации.
  • Если вам нужна синхронизация, профилировали ли вы свой код для понимания характеристик производительности? Если да, пытались ли вы оптимизировать узкие места?
  • Можете ли вы горизонтально масштабироваться вместо того чтобы масштабироваться вертикально?

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

[От. переводчика: огромное спасибо пользователю vovo4K за помощь в подготовке данной статьи.]
Поделиться публикацией

Комментарии 94

    +4
    Очень годная статья, спасибо вам за труд!
      0
      Спасибо за прочтение и обратную связь!
      +4
      Совет про проверку joinable странный: ошибка-то заключается не в вызове join, а в вызове detach. В итоге программа остаётся неправильной, но уже никому об этом не рассказывает.
        0
        К «Ошибка №8: Взятие нескольких блокировок в разном порядке» ещё бы добавил использовать std::lock() на проектах до С++17
          +1

          Советы годные есть, но по большей части — руководство для тех, кто хочет заюзать C++ потоки впервые и никогда их даже не трогал. Первый пример вообще падает, что явно повлечёт паломничество к гуглу. Ещё некоторые — элементарное непонимание/нечтение документации. На том же https://cppreference.com читать про библиотеку потоков — одно удовольствие.


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

            +1
            В статье действительно приведены простейшие случаи, которые будут особенно полезны людям которые мало работали с многопоточностью или не имеют опыта работы с ней на С++. Опытные инженеры все эти проблемы знают на зубок, тем не менее бывает удобно иметь ссылку, которой можно было бы поделиться с менее опытным коллегой. Документация на cppreference.com действительно хороша, и всем советую ее читать. Целью статьи (я так думаю, это всего лишь перевод) был не пересказ документации, а обратить внимание на типовые ошибки и предложить их решения.
            Первый пример вообще падает, что явно повлечёт паломничество к гуглу

            Вот тут не понял, первый пример и должен падать. Там нет вызова join() как и написано в тексте. Он не должен работать. Допускаю что где-то при публикации могли проскочить опечатки, но примеры я проверял, они компилируются и работают (по крайней мере на моей платформе и версии компилятора).
              0
              Вот тут не понял, первый пример и должен падать.

              Я о том и говорю. Написал новичок пример (hello world для потоков по-сути), запустил — ошибка. Полез сразу в гугл, что не так. Хуже, когда работает, но не так, как ожидаешь.


              А про volatile всё равно интересно.

                0

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

                  +1
                  volatile в MSVC имеет семантику атомарной операции,


                  А как он это делает для сложных объектов?
                    0
                    volatile
                    If a struct member is marked as volatile, then volatile is propagated to the whole structure. If a structure does not have a length that can be copied on the current architecture by using one instruction, volatile may be completely lost on that structure.
                    +1
                    Барьера памяти, не атомарной операции
                    docs.microsoft.com/en-us/cpp/cpp/volatile-cpp?view=vs-2017#microsoft-specific
                      0

                      Формально, верно.
                      Фактически, поскольку не-arm для ms, это почти всегда x86, Itanium похоронили, а на x86 инструкции чтения/записи по выровненному адресу атомарны, можно говорить, что в этих условиях volatile реализует семантику атомарной acq/rel операции, что ms косвенно подтверждает небесспорным утверждением: "This allows volatile objects to be used for memory locks and releases in multithreaded applications."
                      Но такие оговорки должны только утверждать во мнении, что оно не для этого.

                0
                А вот volatile — откуда вообще пошли про него слухи как про волшебную таблетку для многопоточности?


                Из микроконтроллеров, наверное. Там оно нужно для доступа из всяких там прерываний. Но в многопоточности при использовании объектов синхронизации оно не нужно, так как эти объекты и так являются барьерами и памяти и компилятора.
                  0
                  Не, из Java Memory Model — там volatile гарантирует публикацию изменений.
                    +2

                    Java, как бы, помоложе истории volatile в C/C++. И чёткое описание поведения как раз говорит за то, что проблему уже осознавали.

                      +1
                      Вообще из статьи Александреску. Он потом сделал оговорку, что это работает только в некоторых случаях, но миф уже разлетелся.
                        0

                        От 2001 года? Но люди до сих пор помнят?)

                          +1

                          Ещё вспомните:


                          “But throwing an exception from a constructor invoked by new causes a memory leak!” Nonsense! That’s an old-wives’ tale caused by a bug in one compiler – and that bug was immediately fixed over a decade ago.

                          https://isocpp.org/wiki/faq/exceptions#why-exceptions
                          Ведь помнят люди!

                    0

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

                      +2
                      Квалификатор volaitile совершенно необходим в случае, если чтение или запись имеют побочный эффект, т.е. при использовании MMIO. Ваш Кэп)))
                        0
                        запись int по выровненному адресу атомарна независимо от volatile.
                        и чтение атомарно.
                          +3

                          Что тут подразумевается под атомарностью?
                          Стандарт не требует от int быть не больше регистра процессора. А если он вдруг больше?

                            +1
                            Например, на той же Ардуине int — 2 байта, а регистры по байту.
                              0
                              подразумевается, что чтение и запись в регистр атомарны, если адрес выровнен в памяти.
                              и volaitile тут ни при чем.
                              +1
                              немного не так. в пределах одного треда/без прерываний — да, в отсутствие оптимизатора — тоже да

                              но оптимизатор может в другом треде ее заранее закешировать, например вынести из цикла =)

                              volatile запрещает именно такое поведение. Тем не менее стандарт явно прописывает, что не годится для использования для многопоточного обращения
                                0
                                Вы не понимаете что такое атомарность.
                                В данном случае речь идет не о чтение-сравнении-записи, а просто о чтении или записи. Теоритически, если переменная не выровнена в памяти и не влезает в регистр (не может быть считана в регистр за раз), то возможна ситуация когда будет считана только часть переменной, а позже будет дочитан уже кем то измененный остаток.
                                Только это все не про volatile.
                                0
                                По выровненному адресу и чтение и запись и еще некоторые операции для небольших переменных атомарны, но только с volatile. Без него компилятор может оптимизировать так, что упразднит вообще все записи в переменную если она далее по его мнению не используется, а в местах чтения переменной — оптимизировать до понимания переменной как константы или сохранить себе спокойно копию в регистр и не напрягаться по поводу реального содержимого самой переменной в памяти. В пределах даже многоядерной однопроцессорной системы все-таки атомарность есть так как кеш — общий. А вот как дела в многопроцессорных системах — теряюсь в догадках. Кто бы просвятил?
                                  0
                                  Даже небольшие операции не атомарны с volatile, что с оптимизациями, что без них
                                    –2
                                    Да, согласен, что операции типа прочитать-изменить-записать в одной команде никак не атомарны, но: (из stackoverflow):
                                    As pointed out by Peter Cordes in the comments, the LOCK prefix is not required for loads and stores, as those are always atomic on x86-64. However the Intel SDM (Volume 3, System Programming Guide) only guarantees that the following loads/stores are atomic:

                                    Instructions that read or write a single byte.
                                    Instructions that read or write a word (2 bytes) whose address is aligned on a 2 byte boundary.
                                    Instructions that read or write a doubleword (4 bytes) whose address is aligned on a 4 byte boundary.
                                    Instructions that read or write a quadword (8 bytes) whose address is aligned on an 8 byte boundary.
                                    In particular, atomicity of loads/stores from/to the larger XMM and YMM vector registers is not guaranteed.

                                    Это без lock и с volatile чтоб не нарваться на оптимизацию операций именно с конкретной переменной. На этом, конечно, далеко уехать сложно, да и процессор ничего не узнает о нашем желании не менять порядок выполнения команд, но для древнего подхода — установить флажок в одном потоке для другого потока — хватит.
                                    Но правда ли это для многопроцессорных систем и для архитектур не х86/64?
                                      +1
                                      На этом, конечно, далеко уехать сложно, да и процессор ничего не узнает о нашем желании не менять порядок выполнения команд, но для древнего подхода — установить флажок в одном потоке для другого потока — хватит.

                                      И получите UB.


                                      Древний подход с volatile — он не про установку флажка в разных потоках, он про установку флажка в ISR(Interrupt Service Routine), что не имеет к тредам никакого отношения.


                                      Не учите людей плохому.

                                        +1
                                        Переносимость даёт только std::atomic. volatile даёт только ошибки.

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

                                        Например, как volatile можно объявить большую структуру — и доступ к её элементам не будет кэшироваться, но атомарность от этого не появится. Но: это некэширование для одного процессора и скомпилированного кода в нём. Без явного барьера нет гарантии, что процессор выставит эту операцию на шину вовремя — и поэтому volatile для IO адресов явно надо поддерживать машинно-зависимыми методами. Фактически, без них, без атомиков и без средств межнитевой синхронизации, у volatile только один смысл — убирание оптимизаций процессора для тестирования производительности :)

                                        С другой стороны, в принципе можно кэшировать чтение и запись атомарных переменных. Например, две записи или два чтения одной и той же переменной с memory_order_seq_cst могут быть (в абстрактном языке) сведены в одну операцию, если между ними нет никаких других действий. Но, насколько помню, для C++ следует рассчитывать на то, что это не делается — даже если чтение/запись задаётся с memory_order_relaxed; а для прочих режимов тем более надо рассчитывать согласно их свойствам.
                                          0
                                          Фактически, без них, без атомиков и без средств межнитевой синхронизации, у volatile только один смысл — убирание оптимизаций процессора для тестирования производительности :)

                                          Мне это кажется преувеличением — volatile в C и C++ как раз и позволяет полагаться на то, что железо сможет правильно отработать ввод-вывод. Но «в сферическом смысле» конечно да)))
                                            0
                                            > Мне это кажется преувеличением — volatile в C и C++ как раз и позволяет полагаться на то, что железо сможет правильно отработать ввод-вывод.

                                            Вместе с прочими средствами гарантии — да. Без них — нет.

                                            Вот пример. Сейчас почти все ethernet адаптеры управляются так, что пакеты для отправки пишутся в память, а затем устройству задаётся «тебе есть работа, начинай отправлять». Пусть это «начинай отправлять» будет, упрощаем, записью адреса структуры отправки в некий регистр. Пусть регистр объявлен как

                                            volatile void **ether_send;
                                            


                                            и мы делаем отправку кодом вида

                                            send_header *h = malloc(sizeof(send_header));
                                            h->data = data;
                                            h->length = length;
                                            list_add_tail(&send_list, h);
                                            // 1
                                            *ether_send = h;
                                            // 2
                                            


                                            Что будет? У нас нет гарантии, что до записи в *ether_send всё остальное записано в память: компилятор может перенести эту запись. Надо обеспечить эту гарантию. Для этого в позицию `// 1` надо вставить нечто, что будет форсировать эту запись. Варианты:

                                            1. Для x86 достаточно такого (GCC/Clang):

                                            asm("" : : : "memory");
                                            


                                            это уже компилятору указание «ой, здесь выполняется что-то с сайд-эффектом, я должен «сбросить» всё, что накопил».

                                            Дальше сработает гарантия x86, что все записи «экспортируются» согласно потоку команд, и запись в регистр отправки будет позже всех нужных записей в память. (Уточнение: у x86 в это не входят «строковые» команды типа STORS, MOVS… если нужно их учитывать, лучше явно вызвать SFENCE.)

                                            2. Для всех прочих, считаем, архитектур нужен явный write memory barrier — предположим, функция называется wmb(). Функция может быть inline из одной ассемблерной команды, но она точно так же обязана иметь пометку memory как side effect.

                                            А вот теперь вопрос — а нужен ли нам вообще был volatile? Оказывается, нет, потому что можно было точно так же в позицию `// 2` вставить wmb().

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

                                            volatile void **ether_recv;
                                            ...
                                            // 3
                                            struct net_recv *received = (struct net_recv *) (*ether_recv);
                                            // 4
                                            тут разбираем что получили -
                                              received->flags, received->data, и т.п.
                                            


                                            в позицию 4 придётся вставить rmb() по любому, причём даже на x86, потому что для чтений, в отличие от записи, нет гарантии упорядочения согласно потоку команд. Роль этого rmb() выполняет LFENCE, поэтому может быть описано как

                                            asm("lfence" : : : "memory");
                                            


                                            И точно так же, *ether_recv может быть объявлено как volatile, но можно и не объявлять, если в позицию `// 3` тоже вставить rmb().

                                            Заметим, что тут происходит управление кодогенерацией в компиляторе. Поэтому я не уточнял, в какой памяти пакеты. Это может быть память собственно сетевой карты, тогда всё равно должны быть команды барьера для компилятора, чтобы компилятор не унёс запись в память позже «толчка» отправки. Может быть память при процессоре (обычный RAM), тогда дело или для процессора память помечается как некэшируемая, или сетевуха должна уметь читать из процессорных кэшей, или нужен явный сброс конкретных страниц (x86 умеет такое). Это уже зависит от местных реалий.

                                            По сумме сказанного как раз и получается, что смысл volatile для I/O очень сильно сократился.
                                              0
                                              Хм, так низко вне реального режима x86 и ARM не падал, но там это вполне прозрачно для программиста. А что сейчас в драйверах это должен программист делать или компилятор?
                                                0
                                                Программист, конечно. Компилятор… уже есть языки, которые умеют отслеживать такие ситуации и сами вставлять необходимые команды, но C этого, наверно, никогда не будет уметь. Зато писатели драйверов (в массе) обучены этому. Тут не так много тонкостей, проходится за полдня с перекурами. И часто во всяких вспомогательных bus_write_int() необходимые барьеры уже присутствуют, и дополнительно заботиться не нужно.
                                                +1
                                                А вот теперь вопрос — а нужен ли нам вообще был volatile? Оказывается, нет, потому что можно было точно так же в позицию // 2 вставить wmb().

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

                                                  0
                                                  > Вообще-то нужен, в противном случае компилятор может увидеть что по адресу ether_send ничего никогда не читается, и удалить присваивание.

                                                  Он не может такого увидеть, потому что ему явно запретили делать выводы о том, что происходит, когда вызывается wmb(). За ним стоит функция или про которую он ничего не знает, кроме названия, или которая пусть даже inline, но с явной пометкой «неизвестный доступ к памяти».

                                                  Простой пример.

                                                  int *b;
                                                  void f1(int a) {
                                                    *b = a;
                                                    *b = a;
                                                  }
                                                  void f2(int a) {
                                                    *b = a;
                                                    asm volatile ("" ::: "memory");
                                                    *b = a;
                                                  }
                                                  


                                                  Ассемблер полученного:

                                                  f1:
                                                          movq    b(%rip), %rax
                                                          movl    %edi, (%rax)
                                                          ret
                                                  f2:
                                                          movq    b(%rip), %rax
                                                          movl    %edi, (%rax)
                                                          movq    b(%rip), %rax
                                                          movl    %edi, (%rax)
                                                          ret
                                                  


                                                  Нельзя полагать, что после asm() то же состояние в *b, что было до него => обязан записать ещё раз.
                                                    0
                                                    А с чего это простой барьер будет неизвестными модификациями памяти?
                                                      0
                                                      > А с чего это простой барьер будет неизвестными модификациями памяти?

                                                      Я не знаю, какой барьер для вас «простой». Но я не вижу смысла в барьере без пометки «side effect: memory»: он просто не будет выполнять предписанную ему задачу. Поэтому я предполагаю, что любой барьер имеет такую пометку.

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

                                                      в случае если


                                                      volatile int *  b;
                                                      void f1(int a) {
                                                        *b = a;
                                                        *b = a;
                                                      }

                                                              movq    b(%rip), %rax
                                                              movl    %edi, (%rax)
                                                              movl    %edi, (%rax)
                                                              ret```
                                                        0
                                                        Это понятно, но заметно непрактично.
                                                        Если вы *ether_send пометите как volatile, то компилятор может переставить запись остального после его записи — потому что не видит никакой связи между сторонними эффектами и тем, что не имеет таких эффектов. Значит, надо помечать как volatile формирование дескриптора пакета, а это уже убивает все возможные оптимизации в этом формировании. Остаётся только порядок операций из-за зависимостей данных.
                                                        А явный барьер решает эту проблему дёшево и эффективно.
                                            0
                                            > запись int по выровненному адресу атомарна независимо от volatile.

                                            Только на x86. И при этом делает не то что вы ожидаете:
                                            * результат этой записи другой поток может увидеть очень не скоро (секунды??)
                                            * оптимизатор может выкинуть такую запись, и ваша программа сломается

                                            Другими словами — знание внутреннего устройства x86 вам не помогут, компилятор сделает всё исходя из предположения что разработчик для многопоточного кода не использует volatile.

                                            Хотите жутких проблем для себя и компании — используйте volatile.
                                              0
                                              я написал только то что написал.
                                              атомарность и volatile разные вещи, а запись/чтение регистра в память атомарны почти везде.
                                              т.е. переменную не искорежит при этом.
                                              колхозить на этом механизм синхронизации я не предлагал.
                                          +1
                                          ---вроде переменной изменяемой в прерывании для ардуино.

                                          В компютерах где устройства замаплены на память — типа DEC

                                          Читаете бы дважды с клавиатуры символ — а компилятор думает что это дурость и оптимизирует до одного чтения.
                                            +1
                                            Не обязательно к экзотике прибегать. В драйверах такая штука используется повсеместно на всех популярных платформах.
                                              0
                                              > В компютерах где устройства замаплены на память — типа DEC

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

                                              У этого подхода появилось особое преимущество с распространением виртуализации: достаточно распределить адреса так, чтобы какие-то страницы памяти достались только этому устройству, чтобы можно было его напрямую выдать в виртуального гостя.
                                              0
                                              А вот volatile — откуда вообще пошли про него слухи как про волшебную таблетку для многопоточности?
                                              Компилятор не имеет право оптимизировать чтение переменных, помеченных volatile. На это опирался примитивный способ синхронизации потоков с помощью флагов (работаем в цикле пока флаг все еще true) во времена, когда честных атомарных переменных еще не было в языке.
                                                0
                                                А еще ядро было одно и кэша как правило не было.
                                                  0
                                                  Оно и сейчас будет работать.
                                                  Речь о том, что с volatile компилятор вставит реальное чтение/запись с памятью и процессор тоже со временем все сделает так как было задуманно.
                                                  Если не особо критично в какой конкретно момент времени это должно произойти, то все сработает как задумывалось.
                                                  Другое дело, что лучше использовать нормальные средства для синхронизации, а не колхозить.
                                                    0
                                                    Если не ошибаюсь, с volatile ещё гарантируется порядок операций.
                                                    Например,
                                                    int a=10;
                                                    int b=5;
                                                    Не гарантируется инициализация b после a.

                                                    Но
                                                    volatile int a=10;
                                                    volatile int b=5;

                                                    гарантируется.

                                                    Это я где-то когда-то читал. Если ошибаюсь, поправьте. :)
                                                      0
                                                      Не, не гарантируется.
                                                      Даже если сами на ассемблере напишите, порядок все равно не гарантируется.
                                                      процессор волен исполнять инструкции так как ему удобнее.
                                                      Более того, у х86 ассемблер это всего лишь внешний программный интерфейс, внутри процессора инструкции декодируются в микрооперации и исполняются в произвольном порядке.
                                                      Даже когда вы думаете что считываете переменную в регистр, на самом деле в процессоре там целый пул этих регистров и он может параллельно выполнять инструкции завязанные на один и тот же программный регистр.
                                                      Все что может компилятор — это сгенерировать машинный код.
                                                      На уровне машинного кода, да — volatile обяжет компилятор добавить операции чтения/записи с памятью.
                                                      В вашем первом примере компилятор вообще может выкинуть инициализацию переменных.
                                                        0
                                                        Не, не гарантируется.


                                                        Точно ли? В статье была речь вот о чём:
                                                        2. приведение к volatile указателю там, где нужно

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

                                                        /*
                                                        * Пишем в физическую память для активации устройства
                                                        */
                                                        *((volatile int*)base_addr + 0xff) = 0;
                                                        *((volatile int*)base_addr + 0xff) = 0;
                                                        *((volatile int*)base_addr + 0xff) = 0;

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

                                                        we.easyelectronics.ru/blog/Soft/2593.html

                                                        Раз это работает для указателей на память, то так понимаю, для обычных volatile переменных тоже порядок определён.

                                                        И тут тоже пишут: Стандарт говорит, что последовательность чтения-записи должна сохраняться только для данных с квалификатором volatile.
                                                          0
                                                          Там автор не до конца понимает о чем пишет.
                                                          Он даже синтаксис языка не до конца понимает (вот отличный перл: «const int const * const p = &i»).
                                                          Но в целом он тоже упомянул, что volatile не гарантирует порядка исполнения/записи в память и необходимость использования дополнительных механизмов вроде memory barrier.
                                                          Т.е. в приведенном примере если записывать не нули, а например 1, 2, 3, то нет гарантии того, что они будут записаны в таком порядке, а не вперемешку.
                                                            0
                                                            Т.е. в приведенном примере если записывать не нули, а например 1, 2, 3, то нет гарантии того, что они будут записаны в таком порядке, а не вперемешку.


                                                            Судя по второй ссылке, гарантия всё же есть. Там же сказано «Стандарт говорит, что последовательность чтения-записи должна сохраняться только для данных с квалификатором volatile.» То есть, в моём примере с переменными порядок должен быть вроде как сохранён.
                                                              +1
                                                              Во первых это ссылка не на стандарт, а на чей то пересказ.
                                                              Во вторых в статье как раз опровергают ваш вывод — у вас данные не volatile. Там у них тоже кастуется к volatile * и через него перезаписывается и они пишут, что в этом случае стандарт не гарантирует.
                                                              В третьих — всё что стандарт может гарантировать, это что компилятор сгенерирует машинный код где нужные инструкции записи в память будут присутсвовать в нужном порядке. Стандарт не может гарантировать, что процессор исполнит их именно в таком порядке. Это уже личное дело процессора.
                                                              Посмотрите en.wikipedia.org/wiki/Memory_barrier
                                                                0
                                                                Во первых это ссылка не на стандарт, а на чей то пересказ.


                                                                Ну, значит, определённости в этом нет. Осталось найти того, кто чётко и точно всё распишет и расскажет, что же гарантируется сейчас, а что не гарантируется.
                                                                Но в комментариях вроде как никто не оспорил, что для volatile переменных порядок выполнения гарантируется. Спор был про указатель. Хотя и там нашли, что вроде как и в этом случае порядок сохраняется, раз уж приводят к volatile В этом случае работает вообще всё, как изначально написано.

                                                                Во вторых в статье как раз опровергают ваш вывод — у вас данные не volatile.


                                                                В исходном примере как раз данные и были volatile:
                                                                volatile int a=10;
                                                                volatile int b=5;

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


                                                                Да, в этом случае не гарантирует. Но компиляторы всё равно это делают по историческим причинам. Было бы интересно, если б кто рассказал, как запись в память по указателю однозначно гарантировать. asm volatile ("" ::: «memory»), наверное, надо использовать в этом случае (мне это нужно для работы с платами на шинах).

                                                                Стандарт не может гарантировать, что процессор исполнит их именно в таком порядке. Это уже личное дело процессора.


                                                                А вот чтобы процессор всё исполнял в нужном порядке, для того компилятор нужные барьеры памяти вводит. Поэтому раз стандарт гарантирует последовательность работы с переменными volatile, то забота компилятора это обеспечить не только барьером компилятора, но и памяти.
                                                                  0
                                                                  У меня не получилось играя с volatile вынудить компилятор сделать что то особенное. Компилятор просто ограничивался вполне очевидными инструкциями записи в память в правильном порядке и всё. Но я от него другого и не ожидал, потому что это уже должна быть забота программиста.
                                                                  Если надо работать с железом и важен порядок записи в память, то лучше сразу копать в сторону ядра и драйверов устройств.
                                                                  www.kernel.org/doc/Documentation/memory-barriers.txt
                                                                  Проще всего конечно будет найти хороший пример для подражания и следовать ему.
                                                        0
                                                        Согласно C99, пункт 5.1.2.3(2):

                                                        > Accessing a volatile object, modifying an object, modifying a file, or calling a function that does any of those operations are all side effects which are changes in the state of the execution environment. Evaluation of an expression may produce side effects. At certain specified points in the execution sequence called sequence points, all side effects of previous evaluations shall be complete and no side effects of subsequent evaluations
                                                        shall have taken place. (A summary of the sequence points is given in annex C.)

                                                        Тут на самом деле надо добавить в список любые действия, эффект которых компилятору неизвестен (вызов функции, которую он не знает, и т.д.) Но суть описана — да, в вашем примере запись в a и запись в b переупорядочены не будут: это действия с сайд-эффектом.
                                                        А вот действия без сайд-эффекта «обтекают» такие операции, как вода камни, и могут быть выполнены и раньше, и позже — это полностью право компилятора.

                                                        А вот дальше начинается вопрос, что будет на уровне исполняющей машины. Даже формально сделанную запись в память можно не показывать другим процессорам/ядрам или внешним устройствам, пока не давно явной команды на то. И тут много вариантов, например:
                                                        — x86, обычная память: гарантируется, что другие процессоры/ядра/гипертреды увидят; а вот для устройств — нет, кэш может задержать это надолго, если вообще не задержать до перекрытия новым значением; гарантируется показ «коллегам» до другой операции записи этим же исполнителем;
                                                        — x86, память с явной пометкой write-through или uncached (сюда включается и доступ к I/O через память): будет отправлено сразу, гарантируется порядок записи согласно потоку команд;
                                                        — ARM, MIPS и много прочих: требуется явный вызов команды барьера.

                                                        Повторюсь, без машинно-зависимых методов (типа выключения кэширования через MTRR для I/O area) смысл сейчас в volatile только один — убирание оптимизаций процессора для тестирования производительности или логики компиляции :) а с ними может оказаться, и оказывается чаще всего, что и volatile не нужен.
                                                +2

                                                П. 12 не понравился. Неужели нет более элегантного решения?
                                                А еще автору стоило бы упомянуть про замечательный инструмент thread sanitizer, который есть в gcc и clang. Позволяет обнаружить множество проблем многопоточности. Я специально себе делаю билд под линукс и периодически проверяю проект thread sanitizer'ом и другими sanitizer'ами. Жаль в MSVC нет подобных штук.

                                                  0

                                                  Выглядит немного громоздко, да, но в целом IMHO ок. Что предложил бы только — передать аргумент-переменную по референсу, а не загромождать глобальное пространство.


                                                  П.С.: спасибо за указание на thread sanitizer, выглядит хорошей тулзой.

                                                    0
                                                    Есть замечательный std::packaged_task, который делает примерно то же самое

                                                    И правильный код будет выглядеть как:
                                                    Код
                                                    #include <iostream>
                                                    #include <thread>
                                                    #include <exception>
                                                    #include <stdexcept>
                                                    #include <future>
                                                     
                                                    void LaunchRocket()
                                                    {
                                                      std::this_thread::sleep_for(std::chrono::milliseconds(100));
                                                      throw std::runtime_error("Catch me in MAIN");
                                                    }
                                                    
                                                    int main()
                                                    {
                                                      std::packaged_task<void()> task(LaunchRocket);
                                                      
                                                      std::future<void> result = task.get_future();
                                                      
                                                      std::thread t1(std::move(task));
                                                      t1.join();
                                                     
                                                      try
                                                      {
                                                        result.get();
                                                      }
                                                      catch (const std::exception &ex)
                                                      {
                                                        std::cout << "Thread exited with exception: " << ex.what() << "\n";
                                                      }
                                                     
                                                      return 0;
                                                    } 

                                                      0
                                                      Под Linux еще Valgrind есть.
                                                      +1

                                                      Строго говоря стандарт C++ не регламентирует поведение volatile объектов в runtime. Например, MVC++ добавляет release/acquire и барьеры памяти при доступе к таким объектам (https://docs.microsoft.com/en/cpp/cpp/volatile-cpp?view=vs-2017#microsoft-specific)

                                                        0
                                                        volatile куда корректнее трактовать как отмену оптимизации, но точно не атомарность. Ну и грубый пример: если для volatile int i сложение ( i+=321; ) ещё может как-то где-то дать (но не гарантировать) атомарность, то умножение ( i *= 321;) уж точно не будет атомарным.
                                                        Что касается отмены оптимизации, то очень даже работает в случае, когда компилятор выполняет вычисления в коде, которые без volatile смело отбрасывает.
                                                          0
                                                          Я бы добавил ещё, что инициализация std::thread в списке инициализации конструктора — это почти всегда плохая идея.
                                                          class Some {
                                                           public:
                                                            Some() : thread_(std::thread([this](){ run(); })) {}
                                                            void run();
                                                           private:
                                                            std::thread thread_;
                                                            .............
                                                          };
                                                          
                                                            0
                                                            Ошибка №18: Создавать намного больше «выполняющихся» потоков, чем доступно ядер

                                                            Поясните, пожалуйста, «намного больше» — это насколько?
                                                            На компьютере запущено множество процессов (около сотни, допустим) и все они выполняются разными ядрами CPU (в зависимости от планировщика). Получается, что они работают неэффективно? Или я где-то не прав?
                                                              0
                                                              Поток может или проводить активные вычисления, или чего-то ждать.

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

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

                                                                  В реальности все эти специальные костыли позволяют пользователю хоть как-то работать при полной загрузке процессора, но лаги UI всё равно заметны.
                                                                    0
                                                                    это прекрасно работает еще со времен одноядерных процессоров.
                                                                    можно запустить «сложные вычисления длительностью больше кванта времени» в низкоприоритетном потоке и никаких тормозов заметно не будет.
                                                                      0
                                                                      Если запустить их в низкоприоритетном потоке — то они вообще не будут выполнены в условиях, когда потоки с нормальным приоритетом грузят процессор на 100%.
                                                                        +1
                                                                        Зависит от дисциплины планирования и используемой операционной системы. Та же винда использует алгоритм «справедливого» планирования, одним из правил которой является повышение приоритета потока до максимального в пользовательском пространстве, если в течение 4 секунд поток ни разу не получил управление, то ли на один, то ли на 2 кванта времени, с последующим опусканием до исходного.
                                                                          0
                                                                          Вот задержка на 4 секунды как раз и даст пользователю ощущение того, что его компьютер лагает и тормозит.
                                                                            0
                                                                            А речь не о UI-потоках идёт, а о низкоприоритетных фоновых.
                                                                              0
                                                                              Если пользователь сделал некоторое действие и ожидает результата — он заметит лаг независимо от того, в каком потоке его действие обрабатывалось.
                                                                          0
                                                                          ну тогда у меня для вас плохие новости.
                                                                +2
                                                                Более правильный и быстрый способ получить корректный вывод — без всяких std::mutex использовать std::osyncstream из C++20. Такой способ позволит полностью убрать большую критическую секцию в которой происходит медленный ввод/вывод. Потоки не будут в принципе блокироваться:
                                                                void CallHome(string message)
                                                                {
                                                                  std::osyncstream{cout} << "Thread " << this_thread::get_id() << " says " << message << endl;
                                                                }
                                                                
                                                                  +1
                                                                  А как оно работает внутри? Данные буферируются и записываются целиком? Или просто внутренняя блокировка работает?
                                                                  Потоки ввода-вывода гарантируют атомарность вывода при выполнении операции << (то есть один вывод не может прервать другой).
                                                                    0
                                                                    Да, данные накапливаются в буффер, но в сам поток не пишутся пока не будет вызван деструктор osyncstream. В деструкторе данные выводятся через однократный вызов <<
                                                                      0
                                                                      И все зависает до момента, пока не выведется все задуманное? Я в смысле, что много так не выведешь, а если понемногу, но «почасту», то сколь велика будет разница?
                                                                        0
                                                                        Всё — это что? Поток блокируется только на время вывода уже подготовленного полного буфера, и всего один раз.
                                                                          +1
                                                                          ОС удобнее выводить большими кусками, а не маленькими. Так что понемного и часто — медленнее.
                                                                            –2
                                                                            И Вы всерьез называете то, что в Вашем примере «большим куском»? И говорите о переходе от синхронного посимвольного вывода к выводу целой строки, как о революции? Хотя да, наконец-то добавили, 40 лет не прошло…
                                                                              +1
                                                                              Почитайте исходники iostream. Вы говорите что-то странное и видите революции там, где их нет.
                                                                                0
                                                                                И тогда чем же отличается osyncstream от обычного буферированного std::out? Только тем, что осуществляет фактический вывод в деструкторе? Чем же это лучше? Мне кажется, что если деструктор делает что-то кроме освобождения ресурсов, то это не запрещенное, но сомнительное решение.
                                                                          0
                                                                          То есть отсутствие блокировок разменяли на аллокацию.
                                                                          Как всегда, каждое решение имеет цену.
                                                                            0
                                                                            Не обязательно. Есть куча способов избежать динамической аллокации: от страшненького thread_local буффера, до статического буффера в osyncstream или какого-нибудь lock free обмена буферами с нижележащим потоком.
                                                                              0
                                                                              Но все они будут работать лишь до определённого размера, после которого всё равно придётся перейти на динамическую аллокацию. А статический буфер — это вообще жуть с точки зрения reenterability.
                                                                                +1
                                                                                С чего бы? Буфер у каждого вызова свой — там нет реентерабельности.

                                                                                Надо конечно посмотреть кот, но вряд ли это убирает лок. Скорее всего просто каждый поток выплевывает пачку вызовов cout << x << y<< z <<… << endl единой пачкой через этот буфер, но также по очереди между потоками.
                                                                                ЗЫ (а вы видели, какой это ад в ассемблере ?)
                                                                                  0
                                                                                  Давайте договоримся о терминах. Что Вы вкладываете в слова «статический буфер»?
                                                                                  Я — то, что подразумевается в POSIX-функциях, которые возвращают указатель на внутренние строки, например strtok.
                                                                                    0
                                                                                    про статический это не ко мне
                                                                      +2

                                                                      Статья полезная, я бы добавил еще грех №21 "использование мьютексов вместо событий".
                                                                      Главное, что если убрать из заголовка "С++", то смысл не изменится: все эти проблемы (ну, разве что кроме GUI) известны системным программистам с 1960-х годов.

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

                                                                      Самое читаемое