Продолжается подписка на наши издания! Вы не забыли подписаться?

Многопоточность в C++0x

Автор: Энтони Вильямс
Источник: www.devx.com/SpecialReports/Article/38883
Опубликовано: 28.04.2009

Одной из основных новых возможностей стандарта C++0x является поддержка многопоточности. До C++0x любую поддержку многопоточности в вашем C++-компиляторе обеспечивали расширения стандарта C++, а это значило, что детали такой поддержки на разных платформах и компиляторах различаются. Однако с выходом нового стандарта все компиляторы должны будут соответствовать одной модели памяти и обеспечивать одинаковые возможности работы с потоками (хотя производители по-прежнему могут создавать дополнительные расширения). Что это значит для вас? Это значит, что вы сможете куда проще переносить многопоточный код между платформами и компиляторами. Это также уменьшит число разнообразных API и синтаксисов, которые приходится знать при работе на разных платформах.

Ядром новой библиотеки для работы с потоками является класс std::thread, управляющий потоком исполнения, так что с него и начнем.

Запуск потоков

Вы запускаете новый поток, создавая экземпляр std::thread и передавая ему функцию в качестве аргумента. Эта функция затем используется как точка входа в новый поток, а после выхода из функции поток завершается:

  void do_work();
  std::thread t(do_work);

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

  class do_work
  {
  public:
    void operator()();
  };

  do_work dw;
  std::thread t(dw);

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

  do_work dw;
  std::thread t(std::ref(dw));

Большинство API создания потоков позволяют передать один параметр создаваемому потоку, обычно long или void*. std::thread тоже позволяет передачу аргументов, причем любого их количества и (почти) любого типа. Да, совершенно верно – любого количества аргументов. Конструктор использует новую возможность C++0x, variadic template (http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2007/n2242.pdf), допускающую переменное число аргументов, как в старом синтаксисе ... varargs, но в типобезопасной манере.

Поточной функции можно передавать в качестве аргументов объекты любого типа, допускающего копирование:

  void do_more_work(int i, std::string s, std::vector<double> v);
  std::thread
    t(do_more_work, 42, "hello", std::vector<double>(23, 3.141));

Как и сам функциональный объект (function object), аргументы копируются в поток до вызова функции, так что если вы хотите передать ссылку, нужно обернуть аргумент в std::ref:

  void foo(std::string&);
  std::string s;
  std::thread t(foo, std::ref(s));

Что же, хватит о запуске потоков. Что насчет ожидания завершения потока? Стандарт C++ (вслед за принятой в POSIX терминологией) называет это «объединением» (joining) с потоком, которое выполняется с помощью функции-члена join().

  void do_work();
  std::thread t(do_work);
  t.join();

Если вы не планируете объединения с потоком, просто уничтожьте объект потока или вызовите detach():

  void do_work();
  std::thread t(do_work);
  t.detach();

Итак, с запуском потоков все в порядке, но если вы планируете совместное использование данных, их лучше защитить. Новая стандартная библиотека C++ предоставляет средства и для этого.

Защита данных

В библиотеке для работы с потоками в C++0x, как и в большинстве API для работы с потоками, основным средством защиты совместно используемых данных являются мьютексы. В C++0x имеются четыре варианта мьютексов:

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

Хотя у всех этих мьютексов есть функции-члены для блокировки и разблокировки, в большинстве сценариев лучше всего делать это с помощью шаблонов классов блокировки std::unique_lock<> и std::lock_guard<>. Эти классы блокируют мьютекс в конструкторе и освобождают его в деструкторе. Поэтому при использовании их как локальных переменных мьютекс автоматически разблокируется при выходе за пределы области видимости:

  std::mutex m;
  my_class data;

  void foo()
  {
    std::lock_guard<std::mutex> lk(m);
    process(data);
  }   // здесь мьютекс разблокируется

std::lock_guard является базовым типом, и может использоваться только так, как показано. С другой стороны, std::unique_lock допускает отсроченную блокировку, попытки блокировки, попытки блокировки с таймаутом и снятие блокировки перед уничтожением объекта. Если вы выбрали std::timed_mutex, поскольку вам нужна блокировка с таймаутом, вам, скорее всего, нужно использовать std::unique_lock:

  std::timed_mutex m;
  my_class data;

  void foo()
  {
    std::unique_lock<std::timed_mutex> 
      lk(m, std::chrono::milliseconds(3)); // ожидает до 3 мс
    if(lk) // если блокировка получена, обрабатываем данные
      process(data);
  }   // здесь мьютекс разблокируется

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

Защита от взаимоблокировок при блокировании нескольких мьютексов

Иногда требуется заблокировать несколько мьютексов. При неправильном исполнении это может стать источником взаимоблокировок. Два потока могут крест-накрест заблокировать одни и те же мьютексы, и каждая из сторон будет удерживать один мьютекс, ожидая, когда другая сторона освободит другой. Библиотека C++0x для работы с потоками смягчает эту проблему в тех случаях, когда нужно сразу получить несколько блокировок, предоставляя generic-функцию std::lock, которая может заблокировать сразу несколько мьютексов. Вместо вызовов функции-члена lock() для каждого мьютекса, можно передать их std::lock(), которая заблокирует их все без риска взаимоблокировки. Ей можно даже передать незаблокированные экземпляры std::unique_lock<>:

  struct X
  {
    std::mutex m;
    int a;
    std::string b;
  };

  void foo(X& a, X& b)
  {
    std::unique_lock<std::mutex> lock_a(a.m, std::defer_lock);
    std::unique_lock<std::mutex> lock_b(b.m, std::defer_lock);
    std::lock(lock_a, lock_b);

    // делаем что-то с внутренностями a и b
  }

Предположим, вы не используете в этом примере std::lock. Это может привести к взаимоблокировке, если один и другой поток выполнят foo(x, y) для двух объектов Х – х и у. При использовании std::lock такой опасности нет.

Защита данных при инициализации

Если защита данных нужна только при инициализации, использование мьютексов не подходит, так как приведет только к излишней синхронизации после завершения инициализации. Стандарт C++0x предоставляет несколько способов справиться с этим.

Во-первых, предположим, что конструктор объявлен с использованием нового ключевого слова constexpr и удовлетворяет требованиям к инициализации констант (constant initialization). В этом случае объект, инициализируемый этим конструктором, гарантированно инициализируется до выполнения любого кода как часть фазы статической инициализации. Эта возможность подходит для std::mutex, поскольку устраняет возможность возникновения гонок при инициализации мьютексов в глобальной области видимости:

class my_class
{
  int i;

public:
  constexpr my_class() : i(0) { }

  my_class(int i_):i(i_) { }

  void do_stuff();
};

my_class x; // статическая инициализация с использован constexpr-конструктора

int foo();
my_class y(42 + foo()); // динамическая инициализация

void f()
{
  y.do_stuff(); // инициализирована ли y?
}

Вторая возможность – использовать статические переменные в пределах блока. В C++0x инициализация статических переменных, областью видимости которых является блок, выполняется при первом вызове функции. Если второй поток вызовет функцию до завершения инициализации, ему придется подождать:

void bar()
{
  static my_class z(42 + foo()); // инициализация потокобезопасна

  z.do_stuff();
}

Если ни одна из опций не подходит (например потому, что объект размещен динамически), лучше использовать std::call_once и std::once_flag. Когда std::call_once используется совместно с экземпляром std::once_flag, указанная функция вызывается только один раз:

my_class* p = 0;
std::once_flag p_flag;

void create_instance()
{
  p=new my_class(42 + foo());
}

void baz()
{
  std::call_once(p_flag, create_instance);
  p->do_stuff(); 
}

Как конструктор std::thread, std::call_once может принимать функциональные объекты вместо функций и передавать функциям аргументы. Еще раз повторю, по умолчанию выполняется копирование, и в случае ссылок нужно использовать std::ref.

Ожидание событий

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

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

  std::mutex m;
  bool data_ready;

  void process_data();

  void foo()
  {
    std::unique_lock<std::mutex> lk(m);
    while(!data_ready)
    {
      lk.unlock();
      std::this_thread::sleep_for(std::chrono::milliseconds(10));
      lk.lock();
    }
    process_data();
  }

Этот метод может быть простейшим, но он не вполне идеален по двум причинам. Во-первых, в среднем поток будет ждать 5 ms (половину от 10 ms) после готовности данных, прежде чем проснется для проверки. В некоторых случаях это может вызвать заметный лаг. Это можно улучшить, сократив время ожидания, но возникает вторая проблема: поток будет просыпаться, обращаться к мьютексу и проверять флаг каждые 10 ms – даже если ничего не происходит. На это тратится процессорное время, это усиливают конкуренцию за мьютекс и потенциально замедляет исполнение потока, выполняющего ту самую задачу, которую ждут!

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

  std::mutex m;
  std::condition_variable cond;

  bool data_ready;

  void process_data();

  void foo()
  {
    std::unique_lock<std::mutex> lk(m);
    while(!data_ready)
      cond.wait(lk);
    process_data();
  }

Заметьте, что этот код передает wait() объект блокировки lk как параметр. Затем реализация условной переменной разблокирует мьютекс при входе в wait() и снова блокирует его при выходе. Это позволяет другим потокам изменять защищенные данные во время ожидания. Код, устанавливающий флаг data_ready, выглядит так:

  void set_data_ready()
  {
    std::lock_guard<std::mutex> lk(m);
    data_ready=true;
    cond.notify_one();
  }

Готовность данных по-прежнему нужно проверять, поскольку условные переменные могут страдать от так называемых ложных пробуждений. Вызов wait() может вернуться и без оповещения от другого потока. Во избежание этого можно переложить ответственность опять же на стандартную библиотеку, объяснив ей, чего вы ждете, с использованием предиката. Благодаря лямбде, новой возможности C++0x, это совсем просто:

void foo()
{
  std::unique_lock<std::mutex> lk(m);
  cond.wait(lk, []{ return data_ready; });
  process_data();
}

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

Данные, локальные для потока (Thread Local Data)

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

std::string foo(std::string const& s2)
{
  thread_local std::string s = "hello";

  s+=s2;
  return s;
}

В этой функции копия s для каждого потока инициализируется значением "hello". При каждом вызове функции предоставляемая строка присоединяется к переменной s, принадлежащей текущему потоку. Как можно увидеть из этого примера, это работает даже с типами классов, имеющими конструкторы и деструкторы (например, std::string), что лучше, чем у существовавших до C++0x расширений компиляторов.

Это не единственное изменение в поддержке параллелизма в C++. Есть также совершенно новая модель памяти с поддержкой атомарных операций.

Новая модель памяти и атомарные операции

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

Если же вы работаете на действительно низком уровне и создаете высокопроизводительные библиотеки, важно знать детали – которые слишком сложны, чтобы вдаваться в них здесь. Пока что достаточно знать, что в C++0x есть набор атомарных типов, соответствующих встроенным целочисленным типам и void-указателям – а также шаблон std::atomic<> – которые можно использовать в атомарной версии простого пользовательского типа. Подробнее об этом см. http://www.open-std.org/JTC1/sc22/wg21/docs/papers/2007/n2427.html

Вот и все

Вот и закончился очень поверхностный тур по новым возможностям работы с потоками C++0x. В библиотеке есть куда больше, например, ID потоков или asynchronous future values.


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

Copyright © 1994-2016 ООО "К-Пресс"