Ещё один способ отстрелить себе ногу, используя std::thread

habr.png

Стандарт C++11 принёс в язык стандартный механизм поддержки тредов (их часто называют потоками, но это создаёт путаницу с термином streams, так что я буду использовать оригинальный англоязычный термин в русской транскрипции). Однако, как и любой механизм в C++, этот несёт в себе ряд хитростей, тонкостей и совершенно новых способов отстрелить себе ногу. Недавно на Хабре появлялся перевод статьи про 20 таких способов, но список этот исчёрпывающим не является. Я хочу рассказать ещё об одном таком способе, связанном с инициализацией экземпляров std::thread в конструкторах классов.

Вот простой пример использования std::thread:

class Usage {
 public:
  Usage() : th_([this](){ run(); }) {}
  void run() {
    // Run in thread
  }
 private:
  std::thread th_;
};

В этом простейшем примере код выглядит корректным, но есть одно любопытное НО: в момент вызова конструктора std::thread экземпляр класса Usage ещё не сконструирован полностью. Таким образом, Usage::run() может быть вызван для экземпляра, часть полей которого (объявленных после поля std::thread) ещё не инициализированы, что, в свою очередь, может привести к UB. Это может быть достаточно очевидно на небольшом примере, где код класса умещается в экран, но в реальных проектах этот капкан может быть припрятан за развесистой структурой наследования. Немного усложним пример для демонстрации:

class Usage {
 public:
  Usage() : th_([this](){ run(); }) {}
  virtual ~Usage() noexcept {}
  virtual void run() {}
 private:
  std::thread th_;
};

class BadUsage : public Usage {
 public:
  BadUsage() : ptr_(new char[100]) {}
  ~BadUsage() { delete[] ptr_; }
  void run() {
    std::memcpy(ptr_, "Hello");
  }
 private:
  char* ptr_;
};

На первый взгляд, код тоже выглядит вполне нормально, более того, он и работать почти всегда будет как ожидается… до тех пор, пока звёзды не сложатся так, что BadUsage::run() вызовется раньше, чем инициализируется ptr_. Чтобы это продемонстрировать, добавим крошечную задержку перед инициализацией:

class BadUsage : public Usage {
 public:
  BadUsage() : ptr_((std::this_thread::sleep_for(std::chrono::milliseconds(1)), new char[100])) {}
  ~BadUsage() { delete[] ptr_; }
  void run() {
    std::memcpy(ptr_, "Hello", 6);
  }
 private:
  char* ptr_;
};

В этом случае вызов BadUsage::run() приводит к Segmentation fault, а valgrind жалуется на обращение к неинициализированной памяти.

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

class TwoPhaseUsage {
 public:
  TwoPhaseUsage() = default;
  ~TwoPhaseUsage() noexcept {}
  void start() { th_.reset(new std::thread([this](){ run(); })); }
  virtual void run() {}
  void join() {
    if (th_ && th_->joinable()) {
      th_->join();
    }
  }
 private:
  std::unique_ptr th_;
};

class GoodUsage : public TwoPhaseUsage {
 public:
  GoodUsage() : ptr_((std::this_thread::sleep_for(std::chrono::milliseconds(1)), new char[100])) {}
  ~GoodUsage() noexcept { delete[] ptr_; }
  void run() {
    std::memcpy(ptr_, "Hello", sizeof("Hello"));
  }
 private:
  char* ptr_;
};

// ...
GoodUsage gu;
gu.start();
std::this_thread::sleep_for(std::chrono::milliseconds(100));
gu.join();
// ...

© Habrahabr.ru