Нужны отзывы о том, как сделать класс "потокобезопасным" - PullRequest
16 голосов
/ 14 августа 2010

В настоящее время я учусь делать многопоточность в C ++.Один из моих учебных проектов - игра тетрис.В этом проекте у меня есть класс 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();
    }
}

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

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

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

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

Обновление

Я попробовал идею обёртки и создал класс 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);
}

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

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

Ответы [ 5 ]

9 голосов
/ 14 августа 2010

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

Если мы посмотрим на класс 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 ++, поэтому мы всегда можем найти способы нарушить правила, но процитируем Херба Саттера: Защити от Мерфи, а не от Макиавелли, т.е.Защитите себя от ошибок, а не от программистов, которые намереваются нарушать правила (они всегда найдут способ сделать это)

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

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

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

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

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

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

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

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

4 голосов
/ 14 августа 2010

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

То, что вы должны иметь, выглядит примерно так:

__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 голосов
/ 14 августа 2010

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

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

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

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

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

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

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

1 голос
/ 16 августа 2010

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

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

1 голос
/ 14 августа 2010

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

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

   // implement dropCurrentBlock
}
Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...