将互斥锁与其数据相关联的正确方法是什么?

What's the proper way to associate a mutex with its data?

本文关键字:方法 是什么 关联 数据      更新时间:2023-10-16

在将资金从一个银行账户转移到另一个银行账户的经典问题中,公认的解决方案(我相信)是将一个互斥锁与每个银行账户关联,然后在从一个账户提取资金并将其存入另一个账户之前将其锁定。乍一看,我会这样做:

class Account {
public:
  void deposit(const Money& amount);
  void withdraw(const Money& amount);
  void lock() { m.lock(); }
  void unlock() { m.unlock(); }
private:
  std::mutex m;
};
void transfer(Account& src, Account& dest, const Money& amount)
{
  src.lock();
  dest.lock();
  src.withdraw(amount);
  dest.deposit(amount);
  dest.unlock();
  src.unlock();
}

但是手动解锁有气味。我可以将互斥锁设为公共,然后在transfer中使用std::lock_guard,但是公共数据成员也有问题。

std::lock_guard的要求是其类型满足BasicLockable要求,也就是说对lockunlock的调用是有效的。Account满足这个要求,所以我可以直接使用std::lock_guardAccount:

void transfer(Account& src, Account& dest, const Money& amount)
{
  std::lock_guard<Account> g1(src);
  std::lock_guard<Account> g2(dest);
  src.withdraw(amount);
  dest.deposit(amount);
}

这看起来不错,但是我以前从来没有见过这样的事情,而且在Account中复制互斥锁的锁定和解锁本身就有点臭。

在这种情况下,把互斥锁和它所保护的数据关联起来的最好方法是什么?

更新:在下面的评论中,我注意到std::lock可以用来避免死锁,但我忽略了std::lock依赖于try_lock功能的存在(除了lockunlock)。将try_lock添加到Account的界面似乎是一个相当粗糙的hack。这样看来,如果Account对象的互斥锁要留在Account中,它必须是公共的。有股臭味。

一些建议的解决方案让客户端使用包装类来静默地将互斥锁与Account对象关联起来,但是,正如我在评论中指出的那样,这似乎使代码的不同部分很容易在Account周围使用不同的包装对象,每个都创建自己的互斥锁,这意味着代码的不同部分可能会尝试使用不同的互斥锁来锁定Account。这是不好的。

其他建议的解决方案依赖于一次只锁定一个互斥锁。这样就不需要锁定多个互斥锁,但代价是某些线程可能会看到不一致的系统视图。实质上,这放弃了涉及多个对象的操作的事务语义。

在这一点上,公共互斥锁开始看起来像是可用选项中最不糟糕的,这是我真的不想得出的结论。真的没有更好的了吗?

查看Herb Sutter c++ and Beyond 2012: c++ Concurrency的演讲。他展示了在c++ 11中实现类似Monitor object的例子。

monitor<Account> m[2];
transaction([](Account &x,Account &y)
{
    // Both accounts are automaticaly locked at this place.
    // Do whatever operations you want to do on them.
    x.money-=100;
    y.money+=100;
},m[0],m[1]);
// transaction - is variadic function template, it may accept many accounts
实现:

现场演示

#include <iostream>
#include <utility>
#include <ostream>
#include <mutex>
using namespace std;
typedef int Money;
struct Account
{
    Money money = 1000;
    // ...
};
template<typename T>
T &lvalue(T &&t)
{
    return t;
}
template<typename T>
class monitor
{
    mutable mutex m;
    mutable T t;
public:
    template<typename F>
    auto operator()(F f) const -> decltype(f(t))
    {
        return lock_guard<mutex>(m),
               f(t);
    }
    template<typename F,typename ...Ts> friend
    auto transaction(F f,const monitor<Ts>& ...ms) ->
        decltype(f(ms.t ...))
    {
        return lock(lvalue(unique_lock<mutex>(ms.m,defer_lock))...),
        f(ms.t ...);
    }
};
int main()
{
    monitor<Account> m[2];
    transaction([](Account &x,Account &y)
    {
        x.money-=100;
        y.money+=100;
    },m[0],m[1]);
    for(auto &&t : m)
        cout << t([](Account &x){return x.money;}) << endl;
}

输出是:

900
1100

暂时把钱"运走"并没有什么错。像这样:

Account src, dst;
dst.deposit(src.withdraw(400));

现在让每个方法都是线程安全的,例如

int Account::withdraw(int n)
{
    std::lock_guard<std::mutex> _(m_);
    balance -= n;
    return n;
}

我更喜欢使用非侵入式包装器类,而不是用互斥锁污染原始对象,并在每次方法调用时将其锁定。这个包装器类(我将其命名为Protected<T>)包含作为私有变量的用户对象。Protected<T>授予另一个类Locker<T>友谊。锁将包装器作为其构造函数参数,并为用户对象提供公共访问器方法。该锁还使包装器的互斥锁在其生命周期内保持锁定状态。因此,锁存器的生命周期定义了一个作用域,在这个作用域中,可以以安全的方式访问原始对象。

Protected<T>可以实现operator->,实现快速调用单个方法。

工作的例子:

#include <iostream>
#include <mutex>

template<typename>
struct Locker;

template<typename T>
struct Protected
{
    template<typename ...Args>
    Protected(Args && ...args) :
        obj_(std::forward<Args>(args)...)
    {        
    }
    Locker<const T> operator->() const;
    Locker<T> operator->();
private:    
    friend class Locker<T>;
    friend class Locker<const T>;
    mutable std::mutex mtx_;
    T obj_;
};

template<typename T>
struct Locker
{
    Locker(Protected<T> & p) :
        lock_(p.mtx_),
        obj_(p.obj_)
    {
        std::cout << "LOCK" << std::endl;
    }
    Locker(Locker<T> && rhs) = default;
    ~Locker()
    {
        std::cout << "UNLOCKn" << std::endl;
    }
    const T& get() const { return obj_; }
    T& get() { return obj_; }
    const T* operator->() const { return &get(); }
    T* operator->() { return &get(); }
private:    
    std::unique_lock<std::mutex> lock_;
    T & obj_;    
};

template<typename T>
struct Locker<const T>
{
    Locker(const Protected<T> & p) :
        lock_(p.mtx_),
        obj_(p.obj_)
    {
        std::cout << "LOCK (const)" << std::endl;
    }
    Locker(Locker<const T> && rhs) = default;
    ~Locker()
    {
        std::cout << "UNLOCK (const)n" << std::endl;
    }
    const T& get() const { return obj_; }    
    const T* operator->() const { return &get(); }
private:    
    std::unique_lock<std::mutex> lock_;
    const T & obj_;
};

template<typename T>
Locker<T> Protected<T>::operator->()
{
    return Locker<T>(const_cast<Protected<T>&>(*this));
}

template<typename T>
Locker<const T> Protected<T>::operator->() const
{
    return Locker<T>(const_cast<Protected<T>&>(*this));
}
struct Foo
{
    void bar() { std::cout << "Foo::bar()" << std::endl; }
    void car() const { std::cout << "Foo::car() const" << std::endl; }
};
int main()
{
    Protected<Foo> foo;
    // Using Locker<T> for rw access
    {
        Locker<Foo> locker(foo);
        Foo & foo = locker.get();
        foo.bar();
        foo.car();
    }
    // Using Locker<const T> for const access
    {
        Locker<const Foo> locker(foo);
        const Foo & foo = locker.get();
        foo.car();
    }

    // Single actions can be performed quickly with operator-> 
    foo->bar();
    foo->car();
}

生成以下输出:

LOCK
Foo::bar()
Foo::car() const
UNLOCK
LOCK (const)
Foo::car() const
UNLOCK (const)
LOCK
Foo::bar()
UNLOCK
LOCK
Foo::car() const
UNLOCK

使用在线编译器测试。

更新:修复const正确性。

PS:还有一个异步的变体

我个人是LockingPtr范例的粉丝(这篇文章相当过时,我个人不会遵循它的所有建议):

struct thread_safe_account_pointer {
     thread_safe_account_pointer( std::mutex & m,Account * acc) : _acc(acc),_lock(m) {}
     Account * operator->() const {return _acc;}
     Account& operator*() const {return *_acc;}
private:
     Account * _acc;
     std::lock_guard<std::mutex> _lock;
};

并实现包含Account对象的类,像这样:

class SomeTypeWhichOwnsAnAccount {
public:
     thread_safe_account_pointer get_and_lock_account() const {return thread_safe_account_pointer(mutex,&_impl);}
      //Optional non thread-safe
      Account* get_account() const {return &_impl;}
      //Other stuff..
private:
     Account _impl;
     std::mutex mutex;
};
如果合适的话,可以用智能指针替换

指针,并且您可能需要一个const_thread_safe_account_pointer(或者更好的是一个通用模板thread_safe_pointer类)

为什么这比显示器(IMO)好?

    你可以设计Account类而不用考虑线程安全;线程安全是使用类的对象的属性,而不是类本身的属性。
  1. 在类中嵌套调用成员函数时不需要递归互斥体。
  2. 您可以在代码中清楚地记录您是否锁定互斥锁(并且您可以通过不实现get_account来防止完全使用无锁定)。同时拥有get_and_lock()get()函数迫使考虑线程安全性。
  3. 当定义函数(全局或成员)时,你有一个清晰的语义来指定一个函数是需要对象的互斥锁(只需传递一个thread_safe_pointer)还是线程安全不可知(使用Account&)。
  4. 最后但并非最不重要的是,thread_safe_pointer具有与监视器完全不同的语义:

考虑一个MyVector类,它通过监视器实现线程安全,以及以下代码:

MyVector foo;
// Stuff.. , other threads are using foo now, pushing and popping elements
int size = foo.size();
for (int i=0;i < size;++i)
   do_something(foo[i]);
在我看来,像这样的代码真的很糟糕,因为它让你觉得safe认为监视器会为你照顾线程安全,而这里我们有一个难以发现的竞争条件。

您的问题是将锁定与数据关联起来。在我看来,将mutex填充到对象中是好的。您还可以更进一步,将对象本质上变成监视器:进入函数成员时锁定,离开时解锁。

我认为为每个帐户提供自己的锁是好的。它向代码的任何读者提供了一个明确的信号,即访问Account是一个临界区。

任何涉及每个帐户一个锁的解决方案的缺点是,当您编写同时操作多个帐户的代码时,您必须注意死锁。但是,避免这个问题的直接方法是一次限制与一个帐户的交互。这不仅避免了潜在的死锁问题,还增加了并发性,因为当当前线程忙于其他事情时,您没有阻止其他线程访问其他帐户。

您对一致性视图的关注是有效的,但是可以通过记录当前事务中发生的操作来实现。例如,你可以用一个事务日志装饰你的deposit()withdraw()操作。

class Account {
  void deposit(const Money &amount);
  void withdraw(const Money &amount);
public:
  void deposit(const Money &amount, Transaction& t) {
    std::lock_guard<std::mutex> _(m_);
    deposit(amount);
    t.log_deposit(*this, amount);
  }
  void withdraw(const Money &amount, Transaction& t) {
    std::lock_guard<std::mutex> _(m_);
    withdraw(amount);
    t.log_withdraw(*this, amount);
  }
private:
  std::mutex m_;
};

那么,transfer是一个记录的取款和存款。

void transfer (Account &src, Account &dest, const Money &amount,
               Transaction &t) {
  t.log_transfer(src, dest, amount);
  try {
    src.withdraw(amount, t);
    dest.deposit(amount, t);
    t.log_transfer_complete(src, dest, amount);
  } catch (...) {
    t.log_transfer_fail(src, dest, amount);
    //...
  }
}

注意,事务日志的概念与您选择如何部署锁是正交的。

我认为您的答案是按照您的建议使用std::lock(),但将其放入好友函数中。这样就不需要将帐户互斥锁设为公共。新的好友函数不使用deposit()和withdraw()函数,需要分别锁定和解锁互斥锁。请记住,友元函数不是成员函数,但可以访问私有成员。

typedef int Money;
class Account {
public:
  Account(Money amount) : balance(amount)
  {
  }
  void deposit(const Money& amount);
  bool withdraw(const Money& amount);
  friend bool transfer(Account& src, Account& dest, const Money& amount)
  {
     std::unique_lock<std::mutex> src_lock(src.m, std::defer_lock);
     std::unique_lock<std::mutex> dest_lock(dest.m, std::defer_lock);
     std::lock(src_lock, dest_lock);
     if(src.balance >= amount)
     {
        src.balance -= amount;
        dest.balance += amount;
        return true;
     }
     return false;
  }
private:
  std::mutex m;
  Money balance;
};

大多数解决方案都存在一个问题,即数据是公开的,因此可以在不锁定锁的情况下访问它。

有一种方法可以解决这个问题,但是你不能使用模板,因此必须求助于宏。在c++ 11中实现它要好得多,而不是在这里重复整个讨论,我链接到我的实现:https://github.com/sveljko/lockstrap