Нужны отзывы о том, как сделать класс «потоко-безопасным»

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

Сначала я подумал, что мог бы сделать поток класса Game безопасным, добавив переменную-член mutex и заблокировав ее внутри каждого вызова метода. Но проблема в том, что он защищает только отдельные вызовы методов, а не изменения, связанные с несколькими вызовами методов. Например:

// This is not thread-safe.
while (!game.isGameOver())
{
    game.dropCurrentBlock();
}

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

// Extra scope added to limit the lifetime of the scoped_lock.    
{
    // => deadlock, unless a recursive mutex is used
    boost::mutex::scoped_lock lock(game.getMutex());
    while (!game.isGameOver())
    {
        game.dropCurrentBlock();
    }
}

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

Но если рекурсивные мьютексы не являются опцией, не означает ли это, что становится невозможным создать потокобезопасный класс (который поддерживает скоординированные изменения)?

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

Однако, если это так, то не лучше ли просто оставить класс Game таким, какой он есть, и создать класс-оболочку, который связывает объект Game с мьютексом?

Update

Я попробовал идею обертки и создал класс с именем ThreadSafeGame ( cpp ), который выглядит вот так:

class ThreadSafeGame
{
public:
    ThreadSafeGame(std::auto_ptr inGame) : mGame(inGame.release) {}

    const Game * getGame() const
    { return mGame.get(); }

    Game * getGame()
    { return mGame.get(); }

    boost::mutex & getMutex() const
    { return mMutex; }

private:
    boost::scoped_ptr mGame;
    mutable boost::mutex mMutex;
};

// Usage example, assuming "threadSafeGame" is pointer to a ThreadSafeGame object.    
{
    // First lock the game object.
    boost::mutex::scoped_lock lock(threadSafeGame->getMutex());

    // Then access it.
    Game * game = threadSafeGame->getGame();
    game->move(Direction_Down);
}

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

Правильно ли я это делаю?

17
задан StackedCrooked 1 July 2017 в 02:41
поделиться

5 ответов

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

Если мы посмотрим на класс ThreadSafeGame, я думаю, что интерфейс для него можно улучшить, чтобы мы могли получить доступ к состоянию игры, только если мы находимся в синхронизированном режиме. Есть несколько способов сделать это. Один из способов - заставить getGame возвращать класс, который одновременно содержит и блокировку, и экземпляр. Вы определяете operator-> для этого класса, чтобы он возвращал Game *. Когда класс уничтожается, блокировка снимается.

В моих примерах используются некоторые функции C ++ 0x (лямбда-выражения, семантика перемещения, auto и decltype), но возможно сделать его совместимым с C ++ 98.

Я продемонстрирую другой способ сделать это, используя метод посещения:

template<typename TValue>
struct threadsafe_container : boost::noncopyable
{
   explicit threadsafe_container (TValue && value)
      :  m_value (std::move (value))
   {
   }

   // visit executes action when have the lock
   template<typename TAction>
   auto visit (TAction action) -> decltype (action (m_value))
   {
      boost::mutex::scope_lock lock (&m_mutex);

      TValue & value (m_value);

      return action (value);
   }

private:
   boost::mutex m_mutex;
   TValue m_value;
};

// Extra paranthesis necessary otherwise c++ interprets it as a function declaration
threadsafe_container<game> s_state ((ConstructAGameSomehow ())); 

void EndTheGame ()
{
   s_state.visit ([](game & state)
      {
         // In here we are synchronized
         while (!state.is_game_over ()) 
         { 
            state.drop_current_block (); 
         } 
      });
}

bool IsGameOver ()
{
   return s_state.visit ([](game & state) {return state.is_game_over ();});
}

И метод класса блокировки:

template<typename TValue>
struct threadsafe_container2 : boost::noncopyable
{
   struct lock : boost::noncopyable
   {
      lock (TValue * value, mutex * mtx)
         :  m_value  (value)
         ,  m_lock   (mtx)
      {
      }

      // Support move semantics
      lock (lock && l);

      TValue * get () const 
      {
         return m_value;
      }

      TValue * operator-> () const
      {
         return get ();
      }
   private:
      TValue *                   m_value;
      boost::mutex::scope_lock   m_lock;
   };

   explicit threadsafe_container2 (TValue && value)
      :  m_value (std::move (value))
   {
   }

   lock get ()
   {
      return lock (&m_value, &m_mutex);
   }

private:
   boost::mutex   m_mutex;
   TValue         m_value;
};

// Extra paranthesis necessary otherwise c++ interprets it as a function declaration
threadsafe_container2<game> s_state ((ConstructAGameSomehow ())); 

void EndTheGame ()
{
   auto lock = s_state2.get ();
   // In here we are synchronized
   while (!lock->is_game_over ()) 
   { 
      lock->drop_current_block ();   
   } 
}

bool IsGameOver ()
{
   auto lock = s_state2.get ();
   // In here we are synchronized
   reutrn lock->is_game_over ();
}

Но основная идея та же. Убедитесь, что мы можем получить доступ к состоянию игры только тогда, когда у нас есть блокировка. Конечно, это C ++, поэтому мы всегда можем найти способы нарушить правила, но процитируем Херба Саттера: Защитите от Мерфи, а не от Макиавелли, т.е. защитите себя от ошибок, а не от программистов, которые намереваются нарушить правила (они всегда найдут способ сделать это)

Теперь перейдем ко второй части комментария:

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

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

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

А что насчет входящего потока, принимающего состояние пользователя, он отправляет сообщение в поток диспетчера состояний игры, в котором говорится: : «Это то, что сделал пользователь». Затем поток состояния игры потребляет сообщения и действует соответствующим образом. Таким образом, состояние игры доступно только этому потоку, и не может возникнуть никаких условий гонки и тупиковых блокировок.

Иногда это называют «шаблоном активного объекта».

Читатели оповещения говорят: Но ведь очередь сообщений должна быть потокобезопасной! Это правда, но очередь сообщений сравнительно тривиальна, чтобы сделать потокобезопасным.

ИМО, этот шаблон является одним из наиболее важных для создания поддерживаемых параллельных проектов.

9
ответ дан 30 November 2019 в 13:59
поделиться

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

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

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

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

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

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

(Между прочим, MO по умолчанию для разработчиков .NET (в том числе в BCL) состоит в том, чтобы по умолчанию члены экземпляра не были потокобезопасными, возлагая ответственность на потребляющие классы).

3
ответ дан 30 November 2019 в 13:59
поделиться

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

У вас должно получиться что-то вроде этого:

__int64 begin, end, frequency;
double elapsedtime = 0;
QueryPerformanceFrequency((LARGE_INTEGER*)&frequency);
while(true) {
    QueryPerformanceCounter((LARGE_INTEGER*)&begin);
    DoMessageLoop(); // grabs user input and moves the block, etc.
    QueryPerformanceCounter((LARGE_INTEGER*)&end);
    elapsedtime += (((double)end - (double)begin)/frequency) * 1000);
    if (elapsedtime > gravitytimeinMS) {
        MoveBlockDown();
        elapsedtime -= gravitytimeinMS;
    }
}

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

Этот код довольно специфичен для Windows и не совсем идеален, так как у меня мало опыта работы с другими платформами. Однако основная концепция та же - возьмите таймер, измерьте время вашего основного цикла, двигайтесь, если время было достаточно большим. Нет необходимости или пользы во внедрении потоковой передачи здесь вообще. Потоки должны быть зарезервированы на тот случай, когда вам действительно, ДЕЙСТВИТЕЛЬНО нужны большие вычислительные нагрузки, выполняемые на других потоках - либо потому, что ваш текущий поток насыщен, либо потому, что вам нужно, чтобы он реагировал на пользователя. Использование их в качестве механизма хронометража - пустая трата времени.

4
ответ дан 30 November 2019 в 13:59
поделиться

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

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

1
ответ дан 30 November 2019 в 13:59
поделиться

Есть ли проблема с перемещением проверки isGameOver в метод dropCurrentBlock?

void Game::dropCurrentBlock()
{
   boost::mutex::scoped_lock lock( getMutex() );
   if ( isGameOver() ) return; // game over

   // implement dropCurrentBlock
}
1
ответ дан 30 November 2019 в 13:59
поделиться
Другие вопросы по тегам:

Похожие вопросы: