在 C++ 中使用非虚拟公共接口和作用域锁避免死锁

Posted

技术标签:

【中文标题】在 C++ 中使用非虚拟公共接口和作用域锁避免死锁【英文标题】:Avoid Deadlock using Non-virtual Public Interface and Scoped Locks in C++ 【发布时间】:2009-05-07 14:10:35 【问题描述】:

我遇到了一个困扰我的问题。似乎我发现了一种很容易解决的情况,但是如果 a) 我在编程时注意力不集中,或者 b) 其他人开始实现我的接口并且不知道如何处理,这可能会导致问题这种情况。

这是我的基本设置:

我有一个抽象类,用作多种数据类型的通用接口。我采用了非虚拟公共接口范例(Sutter,2001)以及范围锁定来提供一些线程安全。一个示例接口类看起来像这样(我省略了有关范围锁定和互斥锁实现的细节,因为我认为它们不相关):

class Foo

public:
    A( )
    
        ScopedLock lock( mutex );
        aImp( );
    
    B( )
    
        ScopedLock lock( mutex );
        bImp( );
    
protected:
    aImp( ) = 0;
    bImp( ) = 0;

然后由用户来实现 aImp 和 bImp,这就是问题所在。如果 aImp 执行一些使用 bImp 的操作,那么执行此操作非常容易(并且在某种意义上几乎是合乎逻辑的):

class Bar

protected:
    aImp( )
    
        ...
        B( );
        ...
    
    bImp( )
    
        ...
    

死锁。当然,解决这个问题的简单方法是始终调用受保护的虚函数而不是它们的公共变体(在上面的 sn-p 中将 B( ) 替换为 bImp( ))。但是,如果我犯了错误,或者更糟的是让别人上吊自己,似乎仍然很容易上吊。

是否有人试图阻止抽象类的实现者在编译时调用这些公共函数,或者帮助避免死锁解决方案?

只是为了好玩,一些互斥锁允许操作以避免死锁问题。例如,如果我使用 Windows 函数 EnterCriticalSection 和 LeaveCriticalSection 来实现这一点,则没有问题。但我宁愿避免使用特定于平台的功能。我目前在我的作用域锁实现中使用 boost::mutex 和 boost::shared_mutex,据我所知,它并没有试图避免死锁(我认为我几乎更喜欢)。

【问题讨论】:

另一个 Sutter 建议:“避免在关键部分内调用未知代码”中的“避免调用虚拟方法”(ddj.com/architect/202802983)。 感谢您的链接。我现在就读一读。 【参考方案1】:

使用私有继承可能会解决您的问题:

class Foo

public:
  void A( )
    
      ScopedLock lock( mutex );
      aImp( );
    
  void B( )
    
      ScopedLock lock( mutex );
      bImp( );
    

protected:
  virtual void aImp( ) = 0;
  virtual void bImp( ) = 0;
;

class FooMiddle : private Foo

public:
  using Foo::aImp;
  using Foo::bImp;
;

class Bar : public FooMiddle

  virtual void aImpl ()
  
    bImp ();
    B ();                   // Compile error - B is private
  
;

从Foo私下派生,然后使用FooMiddle确保Bar无法访问A或B。但是,bar仍然可以覆盖aImp和bImp,并且FooMiddle中的using声明意味着仍然可以调用它们来自酒吧。

另外,一个帮助但不能解决问题的选项是使用 Pimpl 模式。你最终会得到如下结果:

class FooImpl

public:
  virtual void aImp( ) = 0;
  virtual void bImp( ) = 0;
;

class Foo

public:
  void A( )
    
      ScopedLock lock( mutex );
      m_impl->aImp( );
    
  void B( )
    
      ScopedLock lock( mutex );
      m_impl->bImp( );
    

private:
  FooImpl * m_impl;

好处是,在派生自 FooImpl 的类中,它们不再有“Foo”对象,因此不能轻易调用“A”或“B”。

【讨论】:

我认为使用 pimpl 模式的版本更清晰。有一个很好的关注点分离:类 Foo 负责客户端接口和锁定,而 FooImpl 和派生类处理 A() 和 B() 的算法。关键是将 Foo 和 FooImpl 的声明保存在单独的头文件中(Foo 类的头文件只需要 FooImpl 类的前向声明)。使用 Foo 的客户端代码不知道 FooImpl,实现从 FooImpl 派生的类的代码知道需要知道类 Foo 甚至存在。 同意,我认为 pimpl 看起来不错。尽管这两个建议都以吸引我的方式解决了问题。帮助我免于一遍又一遍地射击自己的脚。【参考方案2】:

您的互斥锁不能是递归互斥锁。如果它不是递归互斥锁,则第二次尝试将互斥锁锁定在同一线程中将导致该线程阻塞。由于该线程锁定了互斥锁,但在该互斥锁上被阻塞,因此您遇到了死锁。

你可能想看看:

boost::recursive_mutex

http://www.boost.org/doc/libs/1_32_0/doc/html/recursive_mutex.html

它应该实现跨平台的递归互斥行为。注意 Win32 CRITICAL_SECTION(通过 Enter/LeaveCriticalSection 使用)是递归的,这将创建您描述的行为。

【讨论】:

我不知道那种锁的术语,所以你的回答很有帮助。正如下面有人提到的,使用递归锁似乎是一种简单的方法,所以我可能不会使用它。但这是一个有用的答案。【参考方案3】:

虽然递归锁可以解决您的问题,但我一直认为,虽然有时是必要的,但在许多情况下,递归锁被用作一种简单的出路,锁定方式太多了。

您发布的代码显然是出于演示目的而简化的,所以我不确定它是否适用。

例如,假设使用资源 X 不是线程安全的。你有类似的东西。

A() 
   ScopedLock
   use(x)
   aImp()
   use(x)


aImp() 
   ScopedLock
   use(x)

显然,这会导致死锁。

但是,使用更窄的锁可以消除问题。在尽可能小的范围内使用锁始终是一个好主意,这既是出于性能原因,也是为了避免死锁。

A() 
   
      ScopedLock
      use(x)
   
   aImp()
   
      ScopedLock
      use(x)
   

你明白了。

我知道这并不总是可能的(或者会导致非常低效的代码),在不知道更多细节的情况下我不知道它是否适用于您的问题。不过觉得还是值得发帖的。

【讨论】:

以上是关于在 C++ 中使用非虚拟公共接口和作用域锁避免死锁的主要内容,如果未能解决你的问题,请参考以下文章

C ++如何避免从接口覆盖虚拟方法

通过模板的 C++ 混合:为啥这不起作用?

死锁的产生及避免

操作系统-死锁死锁发生的条件是什么?死锁的避免和预防方法

如何避免死锁?

为啥 Emit 中具有显式重载的接口实现对于公共和非公共的表现不同?