【问题标题】:Need some feedback on how to make a class "thread-safe"需要一些关于如何使类“线程安全”的反馈
【发布时间】:2010-08-14 06:53:33
【问题描述】:

我目前正在学习如何在 C++ 中进行多线程处理。我的一个学习项目是俄罗斯方块游戏。在这个项目中,我有一个包含所有游戏状态数据的 Game 类。它有移动方块的方法和其他一些东西。用户将访问此对象(用户将使用箭头键从主线程移动块),同时线程计时器正在对活动块执行重力(定期降低它)。

起初我认为我可以通过添加互斥成员变量并将其锁定在每个方法调用中来使 Game 类线程安全。但问题在于它只保护单个方法调用,而不是涉及多个方法调用的更改。例如:

// 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 上的 some posts,似乎大多数人强烈反对使用递归互斥锁。

但是,如果递归互斥锁是一个不可选项,这是否意味着不可能创建一个线程安全的类(支持协调更改)?

唯一有效的解决方案似乎是永远不要在方法调用内部锁定互斥锁,而是始终依赖用户从外部进行锁定。

但是,如果是这种情况,那么让 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);
}

它有同样的缺点,它依赖于用户从外部锁定互斥锁。但除此之外,这对我来说似乎是一个可行的解决方案。

我做得对吗?

【问题讨论】:

  • 有趣的是,您刚刚发现为什么 STL 容器不是“线程安全的”,锁定单个操作几乎毫无意义:)
  • 您应该在while 内添加一个额外的块,其中包括除sleep 之外的所有代码。你真的不想在拿着锁的时候睡觉。
  • @Davind 绝对是个好点 :) 在我的真实代码中,我使用的是计时器,所以不需要睡觉。

标签: c++ multithreading design-patterns


【解决方案1】:

在您的情况下,您有一个需要同步的大型游戏。您已经注意到每个方法都同步但仍然无法安全执行操作的问题。

如果我们看一下 ThreadSafeGame 类,我认为它的接口可以改进,以便我们只有在同步模式下才能访问游戏状态。有几种方法可以做到这一点。一种方法是让 getGame 返回一个同时持有锁和实例的类。您在该类上定义 operator-> 以便它返回 Game*。当类被销毁时,锁被释放。

我的示例使用了一些 C++0x 特性(lambda、移动语义、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++,所以我们总能找到打破规则的方法,但引用 Herb Sutter 的话:防止墨菲而不是马基雅维利,即。保护自己免受错误的伤害,而不是那些试图打破规则的程序员(他们总能找到办法做到这一点)

现在进入评论的第二部分:

粗粒度锁定与细粒度锁定? 粗粒度的实现相当容易,但存在性能问题,细粒度的锁定很难正确实现,但可能具有更好的性能。

我会说;尽量避免一起锁定。我不是这个意思;交叉我的拇指,希望我没有得到比赛条件。我的意思是构建你的程序,以便只有一个线程管理可变状态并隔离这个可变状态,这样它就不会被多个线程错误地改变。

在您的情况下,您有一个输入线程接受用户输入并更新状态。一个线程在计时器上更新游戏状态。

相反,接受用户状态的输入线程会向游戏状态管理器线程发送消息说 :“这就是用户所做的”。然后游戏状态线程消费消息并采取适当的行动。这样,游戏状态只能由该线程访问,不会发生竞争条件和死锁。

这有时被称为“活动对象模式”。

提醒读者说:但是,消息队列必须是线程安全的!确实如此,但消息队列对于线程安全来说相对来说是微不足道的。

IMO 这种模式是构建可维护并发项目最重要的模式之一。

【讨论】:

  • 关于并发和 C++,这是一个经典:drdobbs.com/cpp/184403766 Andrei 有时会遗漏一些细节,但他会跳出框框思考。
  • +1 表示事件队列的想法。生产者/消费者更容易正确。
【解决方案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,而且还不是很完美,因为我在其他平台上几乎没有经验。但是,基本概念是相同的 - 获取一个计时器,测量主循环的时间,如果时间足够长则移动。在这里引入线程根本没有必要或好处。线程应该保留用于当你真的,真的需要在其他线程上完成大量计算负载时——要么因为你当前的线程已经饱和,要么因为你需要它来响应用户。将它们用作计时机制完全是浪费。

【讨论】:

  • +1:我在回答中完全错过了这一点,尽管我实际上有过这方面的经验。我在大学开发了一款 Tron 游戏,里面有很多线程——每个“玩家”都有一个线程。这很复杂,并且需要大量的同步才能使每个玩家只能与其他玩家一样前进。几年前我重新审视了它,并完全使用了您在此处描述的方法 - 代码如此简单得多,而且更明显正确。
  • 我的目标是学习如何正确处理多线程。实际上,我还有一个基于 WinAPI 的 ::SetTimer 函数的非线程 Timer 类,它与您的代码基本相同。
  • @StackedCrooked:正确的多线程的第一部分是选择真正需要它的东西。
  • 好吧,计时器并不真的需要它,但它似乎是一个很好的例子来说明我的问题。我的主要目标是实现一个俄罗斯方块 AI,它通过同时处理多个分支来计算提前几个动作。
  • 以您的方式测量经过的时间是错误的。您应该只获得一次“滴答声”(在循环开始时),然后使用当前/之前的“滴答声”数量(即从前一帧)之间的差异来计算经过的时间。原因:渲染例程可能是异步的,something 很容易在你“结束”之后消耗额外的时间。但是这个时间不会被考虑在内。
【解决方案3】:

我个人只是从外面锁上。但那是基于我有限的经验 - 我并没有自称是线程专家,我会感谢比我更了解的人提供的任何 cmet。

我经常发现,在很多情况下,让一个类负责自己的线程安全几乎是不可能的。即使您的类看起来不能违反其不变量,您也会遇到想要执行操作组合的问题,正如您现在所发现的那样。

我发现,将线程安全的责任完全推到消费类身上会更容易理解代码,并且你的域类将更容易设计。

通过尝试使您的课程默认线程安全,您将推理在实践中甚至可能永远不会出现的情况(尽管这通常可以是一个很好的教育练习 - 我发现通过问自己两个问题在我短暂的职业生涯中,我已经改进了我的编码。一个是我将如何对此进行单元测试,另一个是如果多个线程掌握了这个会发生什么。

您最并发的操作似乎是移动块的用户代理和将块拖到地板上的计时器。对我来说,这听起来像是两个互斥锁。你的俄罗斯方块类目前是什么样的?听起来可能比这复杂得多。

为了尽可能做最简单的事情,我只是公开互斥锁并允许您的消费系统在认为必要时锁定。

(顺便说一句,.NET 开发人员(包括在 BCL 中)的默认 MO 是默认情况下使实例成员非线程安全,将责任推给消费类。

【讨论】:

    【解决方案4】:

    isGameOver检查移动到dropCurrentBlock方法有问题吗?

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

    【讨论】:

    • 您是在建议我应该坚持“锁定在方法内部”的策略吗?实际上,您说得对,修复这个特定示例很容易。然而,这并不是唯一需要从外部锁定的地方。例如,查看code.google.com/p/tetris-challenge/source/browse/trunk/… 中的“TetrisComponent::paint”方法实现(第 222-251 行)。似乎经常需要执行一组协调的更改(并且需要从外部锁定)。
    【解决方案5】:

    我会在这里避免使用多线程 - 它会显着增加代码的复杂性,使调试/测试变得更加困难,而且实际上是不必要的。

    继续让计时器定期触发,但不是直接降低块,而是向 UI 消息队列发布新的 LOWER_BLOCK 事件。然后,通过降低活动块来处理 UI 线程上的 LOWER_BLOCK。

    【讨论】:

      猜你喜欢
      • 2011-05-23
      • 2012-05-07
      • 1970-01-01
      • 2011-12-06
      • 1970-01-01
      • 2011-04-25
      • 2021-11-01
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多