将互斥锁绑定到对象

24

给定以下示例代码:

int var;
int mvar;
std::mutex mvar_mutex;

void f(){
    mvar_mutex.lock();
    mvar = var * var;
    mvar_mutex.unlock();
}

我想表达的是mvar_mutex绑定到变量mvar并且只保护该变量。 mvar_mutex不应该保护var,因为它没有被绑定到var。 因此编译器允许将上述代码转换为下面的代码:

int var;
int mvar;
std::mutex mvar_mutex;

void f(){
    int r = var * var; //possible data race created if binding is not known
    mvar_mutex.lock();
    mvar = r;
    mvar_mutex.unlock();
}

当持有锁时,减少工作量可能会降低锁的争用。

对于int类型,可以使用std::atomic<int> mvar;并删除mvar_mutex,但对于其他类型,例如std::vector<int>,这是不可能的。

我该如何表达互斥变量绑定,以便C++编译器理解它并进行优化?可以允许在任何未绑定到该互斥体的变量上对其进行重新排序。

由于代码是使用clang :: ASTConsumerclang :: RecursiveASTVisitor生成的,因此我愿意使用非标准扩展和AST操作,只要clang(理想情况下是clang 4.0)支持它们,并且生成的代码不需要优雅或可读。

编辑:由于这似乎引起了困惑:上述转换在C ++中是不合法的。互斥体与变量的描述绑定不存在。问题是如何实现这一点或达到相同的效果。


1
你确定编译器可以在锁之前移动乘法吗?它不应该这样做,因为乘法也受锁的保护。如果编译器允许这样移动,那么很多代码都会崩溃。 - taskinoor
1
@taskinoor 不可以这样做。但这正是 OP 想要实现的目标。编译器应该被告知只保护 mvar - Hatted Rooster
5
请使用lock_guard或unique_lock代替手动锁定和解锁锁。 - Paul Rooney
2
另一种表达方式是这样的:mutex.lock创建了一个屏障,使得代码可以向下移动,但不能向上移动。mutex.unlock创建了一个屏障,使得代码可以向上移动,但不能向下移动。(屏障不能穿过屏障)。我想修改它,使得这些屏障只适用于特定的对象,而其他代码可以自由地移动。例如,如果这些函数可以实现所需的重新排序行为,将mutex.lock替换为acquire_barrier<mvar>();,并将mutex.unlock替换为release_barrier<mvar>();将是一种可接受的解决方案。 - nwp
4
@nwp 请解释一下为什么需要这个。在实际场景中,std::atomic和无锁算法应该能够满足您的需求。 - Andrey Nasonov
显示剩余19条评论
6个回答

11

如果您希望实现 std::mutex 只持有被保护对象的操作期间,可以编写以下包装器class:

#include <cstdio>
#include <mutex>

template<typename T>
class LockAssignable {
public:
    LockAssignable& operator=(const T& t) {
        std::lock_guard<std::mutex> lk(m_mutex);
        m_protected = t;
        return *this;
    }
    operator T() const {
        std::lock_guard<std::mutex> lk(m_mutex);
        return m_protected;
    }    
    /* other stuff */
private:
    mutable std::mutex m_mutex;
    T m_protected {};
};

inline int factorial(int n) {
    return (n > 1 ? n * factorial(n - 1) : 1);
}

int main() {
    int var = 5;
    LockAssignable<int> mvar;

    mvar = factorial(var);
    printf("Result: %d\n", static_cast<int>(mvar));
    return 0;
}

在上面的示例中,factorial将被提前计算,并且只有在对mvar进行赋值或隐式转换运算符调用时才会获取m_mutex汇编输出

示例不满足要求的原因是printf及其操作被mvar_mutex保护。它应该只保护mvar。编译器应该允许将printf移动到lock块之外,但在此实现中却没有这样做。包装类也是如此。它使用的mutex不仅保护了m_protected,还同步了其周围的代码,这是不应该的。 - nwp
@nwp,我修改了我的第一个示例,因为printf仅用于反馈目的。int r = factorial(var)部分是我所指的,它在由std::mutex保护的关键部分之外。 - Akira
现在mvar存在未受保护的读取。您可以将其更改为printf("Result: %d\n", r);以解决此问题,但仍然存在一个问题,即printf应该被允许移动到lock块上面,但是mutex阻止了这一点,尽管printf中不再有mvar。此外,重点是不要手动执行编译器应该执行的所有重新排序。目标是允许编译器根据自己的意愿重新排列代码。 - nwp

8

对于原始数据类型,您可以使用std::atomicstd::memory_order_relaxed。文档说明如下:

没有对其他读取或写入施加同步或排序约束,仅保证此操作的原子性

在以下示例中,分配的原子性是得到保证的,但编译器应该能够移动操作。

std::atomic<int> z = {0};
int a = 3;
z.store(a*a, std::memory_order_relaxed);

对于对象,我想到了几个解决方案,但是:
  • 没有标准的方法可以从std::mutex中删除排序要求。
  • 不可能创建一个std::atomic<std::vector>
  • 不可能使用std::memory_order_relaxed创建自旋锁(请参见示例)。

我发现一些答案表明:

  • 如果函数在编译单元中不可见,则编译器会生成一个屏障,因为它不知道它使用哪些变量。
  • 如果函数可见并且有互斥锁,则编译器会生成一个屏障。例如,请参见这个这个

因此,为了表示mvar_mutex与变量绑定,您可以使用其他答案中所述的一些类,但我认为不可能完全允许代码重新排序。


3
我想表达的是,mvar_mutex绑定到变量mvar并仅保护该变量。但实际上,互斥锁实际上保护获取和释放之间的机器指令的关键区域。只有通过约定,才与特定的共享数据实例相关联。
为了避免在关键区域内执行不必要的步骤,请尽可能简化关键区域。在关键区域中,只能使用编译器可以“看到”的本地变量,这些变量明显不与其他线程共享,并且属于该互斥锁的一组共享数据。请尽量避免在可能被怀疑共享的关键区域中访问其他数据。
如果您可以拥有所提议的语言功能,那么它只会向程序引入错误的可能性。它只是将现在正确的代码变成一些不正确的代码(换取某些速度的承诺:一些代码保持正确并且更快,因为多余的计算被移出了关键区域)。
这就像采用已经具有良好求值顺序的语言,在其中a [i] = i ++是定义良好的,并通过未指定的求值顺序使其混乱。

显然,削弱所有C++程序的保证通常会破坏C++程序。我不想为所有C++程序这样做,只想为我的一些程序这样做。我知道互斥栅栏往往是无法表达某些指令可以通过栅栏而其他指令不能通过栅栏的硬件指令。然而,在优化阶段期间,编译器不受指令集的限制,因此应该可以告诉编译器如何处理。我确实喜欢这个答案,因为它不仅仅是“在某个类中包装互斥锁”,而是试图回答问题。 - nwp
如果你拥有这个,你会有时候破坏自己的C++程序,认为自己是免疫的,因为你发明了它。 :) :) - Kaz

3
如何使用锁定的变量模板?
template<typename Type, typename Mutex = std::mutex>
class Lockable
{
public:
   Lockable(_Type t) : var_(std::move(t));
   Lockable(_Type&&) = default;
   // ...  could need a bit more

   T operator = (const T& x) 
   { 
      std::lock_guard<Lockable> lock(*this);
      var_ = x;
      return x;
   }

   T operator *() const
   { 
      std::lock_guard<Lockable> lock(*this);
      return var_;
   }

   void lock() const   { const_cast<Lockable*>(this)->mutex_.lock(); }
   void unlock() const { const_cast<Lockable*>(this)->mutex_.unlock().; }
private:
  Mutex mutex_;
  Type var_;
};

被赋值运算符锁定

Lockable<int>var;
var = mylongComputation();

与lock_guard搭配使用效果很好

Lockable<int>var;
std::lock_guard<Lockable<int>> lock(var);
var = 3;

容器实践

Lockable<std::vector<int>> vec;

etc...


它相当优雅,但无法解决问题,因为编译器不允许将计算移出由lock_guard保护的区域。 - nwp
4
编译器选项无法避免代码中的错误,最好使用atomic<>。 - Michaël Roy
8
为什么要使用const_castmutable _Mutex不是更好的解决方案吗? - bartop
const_cast 的作用是为了让你可以锁定并读取一个 const _Type。 - Michaël Roy
在我的例子中,一个人可以选择他所需要的互斥类型。'template<typename Type, typename Mutex = std::mutex>',Mutex 可以是任何具有 'lock()unlock()` 成员函数的类。 - Michaël Roy
1
noexcept 需要有条件。通过值返回字符串肯定会抛出异常。 - Mooing Duck

2
您可以使用 folly::Synchronized 来确保变量仅在锁定下访问:
int var;
folly::Synchronized<int> vmar;

void f() {
  *mvar.wlock() = var * var;
}

1
浏览代码(https://github.com/facebook/folly/blob/master/folly/Synchronized.h#L874),我相当确定 folly::Synchronized 所做的就是自动锁定和解锁 std::mutex。因此,它只是 Akira 回答中 LockAssignable 的不同实现,并且由于 std::mutex 的屏障,不允许代码重新排序。 - nwp
folly::Synchronized 不仅仅是 LockAssignable,但赋值运算符确实是与您的示例最相关的部分。 - vitaut
1
@nwp,你可以使用某种自旋锁来避免使用std::mutex,就像Boost.Lockfree库一样,但是它具有非常快的操作速度和特定类型。我不会在可能进行后台重新分配的std::vector::push_back上使用自旋锁。 - Akira

1
我想表达的是mvar_mutex绑定到变量mvar,并且只保护该变量。
这不是mutex的工作原理。它不会"绑定"任何东西来保护它。你仍然可以直接访问这个对象,完全不考虑任何线程安全问题。
你应该隐藏"受保护的变量",使其完全无法直接访问,并编写一个通过mutex操作它的接口。这样可以确保对底层数据的访问受到该mutex的保护。它可以是单个对象,也可以是功能组对象,还可以是许多对象、mutex和atomic的集合,旨在最小化阻塞。

我遇到的问题不是保护变量。std::mutexstd::unique_lock可以很好地解决这个问题。问题在于,无论是否隐藏在某个类后面,std::mutex都会对与std::mutex无关的变量施加限制。 - nwp
不一定 - 只有在必要时才通过互斥体构建访问接口。也许你应该详细说明你的“限制”假设。 - dtech
一个例子是 foo++; std::unique_lock<std::mutex>(mvar_mutex); foo--;。我期望编译器能够优化掉 foo++;foo--;,但是它无法做到,因为互斥锁的语义不允许这样做。我想找到一种方法来实现这个目标。 - nwp
是啊…要不试试一些真正实用的东西吧 :) 比如在OP中,mvar = var * var;-你可以这样做,但是mvar将是一个包装对象,它实现了=运算符,所以CPU首先计算乘法,只在赋值期间锁定底层数据。藏匿互斥量的想法是不要手动锁定和解锁它,而是让包装器对象为您执行操作,仅在必要时才执行操作。 - dtech

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