这样使用C++锁定容器是可接受的吗?

6
我需要在C++中实现一个线程安全的容器,这样只有一个线程能够添加或删除容器中的项。我之前通过在线程间共享互斥量来完成这样的操作。然而这样会在我的代码中留下很多互斥对象,使得代码变得混乱且难以维护。
我想知道是否有更简洁和面向对象的方法来实现这个目标。我考虑使用以下简单的类对容器进行包装(半伪代码):
 class LockedList {
    private:
        std::list<MyClass> m_List;

    public:
        MutexObject Mutex;
 };

为了实现锁定,可以按照以下方式进行操作。
 LockedList lockableList;     //create instance
 lockableList.Mutex.Lock();    // Lock object

 ... // search and add or remove items

 lockableList.Mutex.Unlock();   // Unlock object

所以我的问题是,从设计的角度来说,这样做是否是一个好方法?我知道允许公共访问成员在设计上是不受欢迎的,以上设计是否有严重的缺陷。如果有的话,是否有更好的方法来实现线程安全的容器对象?
我已经读了很多关于设计和C++的书籍,但在多线程编程和多线程软件设计方面确实缺乏文献。
如果以上方法解决问题并不理想,是否有人能够提出改进的方法,或者指向一些解释如何设计类以实现线程安全的信息?非常感谢。

17
谁关心“更面向对象”?这需要的是“更多RAII”。 - R. Martinho Fernandes
6
发布的代码不具备异常安全性。如果搜索、添加或删除操作抛出异常,互斥锁将不会被解锁。提供一个作用域锁定-解锁机制可以避免这种情况发生。 - hmjd
1
那你不应该问问你的老板这段代码是否可接受吗?看起来他的意见才是最重要的。 - jalf
1
为什么你的老板要规定你如何编写代码?难道他自己不会写吗? - user1203803
1
@mathematician1975 当然,这些是不错的目标。但它们并不一定意味着“更多面向对象”。面向对象编程只是实现这些目标的几种方式之一。但重要的是目标,而不是面向对象编程本身。如果另一种编程范型能够更好地实现这些目标,那么理智的做法就是说“放弃面向对象编程”。 - jalf
显示剩余7条评论
6个回答

7
我会这样做,通过使用RAII来使其更具异常安全性。
class LockedList {
private:
    std::list<MyClass> m_List;
    MutexObject Mutex;
    friend class LockableListLock;
};

class LockableListLock {
private:
    LockedList& list_;
public:
    LockableListLock(LockedList& list) : list_(list) { list.Mutex.Lock(); }
    ~LockableListLock(){ list.Mutex.Unlock(); }
}

您会像这样使用它。
LockableList list;
{
    LockableListLock lock(list); // The list is now locked.

    // do stuff to the list

} // The list is automatically unlocked when lock goes out of scope.

你还可以通过在LockableListLock接口周围添加包装器,强制使类在使用它之前锁定,以便在不通过LockedList类访问列表的情况下,通过LockableListLock类访问列表。例如,你需要在std::list::begin()周围创建这个包装器。

std::list::iterator LockableListLock::begin() {
    return list_.m_List.begin();
}

然后像这样使用它

LockableList list;
LockableListLock lock(list);
// list.begin();   //This is a compiler error so you can't 
                   //access the list without locking it
lock.begin(); // This gets you the beginning of the list

2
与其为要使用的每个方法制作一个包装器,不如通过LockableListLock中的operator->()公开列表。这允许用户调用其中的方法,但永远不会获得引用或指针以进行存储。std::list<MyClass>* LockableListLock::operator->() {return &list_.m_List;} - Martin York

7

我更愿意设计一个资源所有者,该所有者锁定互斥量并返回一个可以被线程使用的对象。一旦线程完成了对该对象的使用并停止使用它,资源就会自动返回到其所有者,并释放锁定。

template<typename Resource>
class ResourceOwner
{
      Lock         lock; 
      Resource     resource;

      public:
         ResourceHolder<Resource>  getExclusiveAccess()
         {
              // Let the ResourceHolder lock and unlock the lock
              // So while a thread holds a copy of this object only it
              // can access the resource. Once the thread releases all
              // copies then the lock is released allowing another
              // thread to call getExclusiveAccess().
              //
              // Make it behave like a form of smart pointer
              //    1) So you can pass it around.
              //    2) So all properties of the resource are provided via ->
              //    3) So the lock is automatically released when the thread
              //       releases the object.

              return ResourceHolder<Resource>(lock, resource);
         }
};

资源持有者(没有深入思考,因此可能需要改进)
template<typename Resource>
class ResourceHolder<
{
    // Use a shared_ptr to hold the scopped lock
    // When first created will lock the lock. When the shared_ptr
    // destroyes the scopped lock (after all copies are gone)
    // this will unlock the lock thus allowding other to use
    // getExclusiveAccess() on the owner
    std::shared_ptr<scopped_lock>    locker;
    Resource&                        resource;   // local reference on the resource.

    public:
        ResourceHolder(Lock& lock, Resource& r)
            : locker(new scopped_lock(lock))
            , resource(r)
        {}

        // Access to the resource via the -> operator
        // Thus allowing you to use all normal functionality of 
        // the resource.
        Resource* operator->() {return &resource;}
};

现在一个可锁定的列表是:
ResourceOwner<list<int>>  lockedList;

void threadedCode()
{
    ResourceHolder<list<int>>  list = lockedList.getExclusiveAccess();

    list->push_back(1);
}
// When list goes out of scope here. 
// It is destroyed and the the member locker will unlock `lock`
// in its destructor thus allowing the next thread to call getExclusiveAccess()

我认为我会将其重命名为类似于“GetExclusiveAccess”的名称。至少对我来说,“UnsharedAccess”似乎不太描述意图。设计很好。 - Jerry Coffin
@JerryCoffin:这样更有意义。完成了(虽然我把第一个g小写了)。 - Martin York
@LokiAstari 谢谢。这里的一些回答非常有帮助,但是我认为我会采用您的设计。 - mathematician1975
不要忽视@Dirk Holsopple的想法。根据我在评论中提出的微小修改,它变得和这个一样灵活。他的解决方案还有一个额外的优点,就是更高效(因为你不需要共享指针)。实际上,在重新阅读后,我更喜欢他的解决方案(如果你添加->并将其模板化,并使其不可复制(你不能赋值,所以为什么要让它可复制),然后进行清理)。你仍然可以通过引用传递它,但线程将不再能够意外地保持副本存活。 - Martin York

2

好的,我会更直接地陈述其他人已经暗示的内容:至少部分,甚至可能全部,这个设计可能不是你想要的。至少,你需要RAII风格的锁。

我还会将locked(或者你喜欢的任何名称)变成一个模板,这样你就可以将锁定与容器本身解耦。

// C++ like pesudo-code. Not intended to compile as-is.
struct mutex {
    void lock() { /* ... */ }
    void unlock() { /* ... */ }
};

struct lock {
    lock(mutex &m) { m.lock(); }
    ~lock(mutex &m) { m.unlock(); }
};

template <class container>
class locked {
    typedef container::value_type value_type;
    typedef container::reference_type reference_type;
    // ...

    container c;
    mutex m;
public:
    void push_back(reference_type const t) {
        lock l(m);
        c.push_back(t);
    }

    void push_front(reference_type const t) { 
        lock l(m);
        c.push_front(t);
    }

    // etc.
};

这使得编写代码变得相当容易,并且(至少在某些情况下)仍然可以获得正确的行为-例如,您的单线程代码可能如下所示:
std::vector<int> x;

x.push_back(y);

你的线程安全代码应该如下:

locked<std::vector<int> > x;

x.push_back(y);

假设你提供了通常的 begin()end()push_frontpush_back 等函数,你的 locked<container> 仍然可以像普通容器一样使用,因此它可以与标准算法、迭代器等配合使用。

1
这种方法的问题在于它使得LockedList无法复制。关于这个问题的详细信息,请查看这个问题:
设计一个线程安全可复制的类
多年来,我尝试了各种方法,最终发现在容器声明旁边声明一个互斥锁是最简单的方法(一旦在天真地实现其他方法后修复了所有错误)。
你不需要在代码中“散布”互斥锁。你只需要一个互斥锁,声明在它所保护的容器旁边即可。

0

我想出了这个(我相信它可以改进以接受超过两个参数):

template<class T1, class T2>
class combine : public T1, public T2
{
public:

    /// We always need a virtual destructor.
    virtual ~combine() { }
};

这使您能够做到:

    // Combine an std::mutex and std::map<std::string, std::string> into
    // a single instance.
    combine<std::mutex, std::map<std::string, std::string>> mapWithMutex;

    // Lock the map within scope to modify the map in a thread-safe way.
    {
        // Lock the map.
        std::lock_guard<std::mutex> locked(mapWithMutex);

        // Modify the map.
        mapWithMutex["Person 1"] = "Jack";
        mapWithMutex["Person 2"] = "Jill";
    }

如果您想使用std::recursive_mutex和std::set,那也可以。

0

很难说粗粒度锁定是一个不好的设计决策。我们需要了解代码所在的系统才能谈论它。但如果你不知道它是否会起作用,那么这是一个很好的起点。首先尝试最简单的方法。

您可以通过使其在未解锁时更少出错来改善该代码。

struct ScopedLocker {
  ScopedLocker(MutexObject &mo_) : mo(mo_) { mo.Lock(); }
  ~ScopedLocker() { mo.Unlock(); }

  MutexObject &mo;
};

你也可以将实现隐藏起来,不让用户看到。

class LockedList {
  private:
    std::list<MyClass> m_List;
    MutexObject Mutex;

  public:
    struct ScopedLocker {
       ScopedLocker(LockedList &ll);
       ~ScopedLocker();
    };
};

然后你只需将锁定的列表传递给它,而无需担心MutexObject的细节。

您还可以让列表在内部处理所有锁定,这在某些情况下是可以的。设计问题是迭代。如果列表在内部锁定,则像这样的操作比让列表的用户决定何时锁定要糟糕得多。

void foo(LockedList &list) {
  for (size_t i = 0; i < 100000000; i++) {
    list.push_back(i);
  }
}

一般来说,这是一个难以给出建议的话题,因为存在这样的问题。往往更多的是关于如何使用对象。当你尝试编写解决多处理器编程问题的代码时,会有很多泄漏的抽象概念。这就是为什么你会看到更多的工具包,让人们组合满足他们需求的解决方案。

虽然有一些讨论多处理器编程的书籍,但它们很少。随着所有新的C++11功能的推出,未来几年应该会有更多的文献问世。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接