Подтвердить что ты не робот

Нужна некоторая обратная связь о том, как сделать класс "потокобезопасным",

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

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

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

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

// 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<Game> 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<Game> 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);
}

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

Я делаю это правильно?

4b9b3361

Ответ 1

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

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

В моих примерах используются некоторые функции С++ 0x (lambdas, move semantics, auto и decltype), но это не невозможно сделать совместимым с С++ 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 ();
}

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

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

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

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

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

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

Это иногда называют "Активным шаблоном объекта".

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

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

Ответ 2

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

Что вам нужно, это примерно так:

__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, и он не совсем совершенен, так как у меня мало опыта работы на других платформах. Однако фундаментальная концепция одинаков - получить таймер, измерить время вашего основного цикла, переместить, если время было достаточно продолжительным. Там нет необходимости или пользы для введения резьбы здесь вообще. Темы должны быть зарезервированы, если вы действительно, ДЕЙСТВИТЕЛЬНО нужны большие вычислительные нагрузки, сделанные на других потоках, либо потому, что ваш текущий насыщен, либо потому, что вам нужно, чтобы он реагировал на пользователя. Использование их в качестве механизма синхронизации - это общий объем отходов.

Ответ 3

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

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

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

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

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

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

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

Ответ 4

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

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

   // implement dropCurrentBlock
}

Ответ 5

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

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