lock_guard 的有效放置 - 来自 Effective Modern C++ 的第 16 条
Effective placement of lock_guard - from item 16 from Effective Modern C++
第16项:"Make const member functions thread safe"中有一段代码如下:
class Widget {
public:
int magicValue() const
{
std::lock_guard<std::mutex> guard(m); // lock m
if (cacheValid) return cachedValue;
else {
auto val1 = expensiveComputation1();
auto val2 = expensiveComputation2();
cachedValue = val1 + val2;
cacheValid = true;
return cachedValue;
}
} // unlock m
private:
mutable std::mutex m;
mutable int cachedValue; // no longer atomic
mutable bool cacheValid{ false }; // no longer atomic
};
我想知道为什么 std::lock_guard 应该总是在每次 magicValue() 调用时执行,不会像预期的那样工作吗?:
class Widget {
public:
int magicValue() const
{
if (cacheValid) return cachedValue;
else {
std::lock_guard<std::mutex> guard(m); // lock m
if (cacheValid) return cachedValue;
auto val1 = expensiveComputation1();
auto val2 = expensiveComputation2();
cachedValue = val1 + val2;
cacheValid = true;
return cachedValue;
}
} // unlock m
private:
mutable std::atomic<bool> cacheValid{false};
mutable std::mutex m;
mutable int cachedValue; // no longer atomic
};
这样就需要更少的互斥锁,从而提高代码效率。我在这里假设 atomica 总是比互斥体快。
[编辑]
为了完整性,我测量了两个 aprache 的效率,第二个看起来只快了 6%。: http://coliru.stacked-crooked.com/a/e8ce9c3cfd3a4019
我实际上认为您的代码片段是正确的,但它依赖于一个在现实世界示例中通常不正确的假设:它假设 cacheValid
从假变为真,但可以永远不要逆向进行,那会变得无效。
在旧代码中,mutex
保护 所有 读取 和 写入 cachedValue
。在您的新代码中, cachedValue
实际上在互斥体之外有一个读访问权限。这意味着一个线程可能读取这个值,而另一个线程正在写入它。要注意的是,只有当 cacheValid
为真时,才会在互斥量之外进行读取。但是如果 cacheValid
为真,则不会发生写入; cacheValid
只有在全部写入完成后才能变为真(注意这是强制执行的,因为cacheValid
上的赋值运算符会使用最严格的内存顺序保证,所以它不能用块中较早的指令重新排序)。
但是假设编写了一些其他代码,可以使缓存无效:Widget::invalidateCache()
。这段代码只是再次将 cacheValid
设置为 false。在旧代码中,如果您从不同线程重复调用 invalidateCache
和 magicValue
,后一个函数可能会在任何给定点重新计算值或不重新计算值。但是,即使您的复杂计算在每次调用时都 returning 不同的值(例如,因为它们使用全局状态),您也将始终获得旧值或新值,而不会得到其他任何值。但现在考虑代码中的以下执行顺序:
- 线程 1 调用
magicValue
,并检查 cacheValid
的值。这是真的。它在继续之前被打断了。
- 线程 2 调用
invalidateCache
,然后立即调用 magicValue
。 magicValue
看到缓存无效,获取互斥量,开始计算,开始写入cacheValid
.
- 线程 1 中断,正在读取部分写入的
cacheValid
。
实际上我认为这个示例不适用于大多数现代计算机,因为 int
通常是 32 位,并且通常 32 位写入和读取是原子的。所以真的不可能穿插或"tear"cachedValue
的值。但是在不同的体系结构上,或者如果您使用整数以外的类型(例如超过 64 位的任何类型),则不能保证写入或读取是原子的。所以你可以得到,作为 magicValue
的 return,一些既不是旧值也不是新值的东西,而是一些甚至不是有效对象的奇怪的按位混合。
所以,很高兴你找到了这个。我猜想为了简单起见,作者在尝试简化示例时忘记了不再需要严格地将互斥体放在外面。
您的第二个代码片段显示了 双重检查锁定模式 (DCLP) 的完全有效实现,并且(可能)比 Meyers 的解决方案更有效,因为它避免锁定 mutex
设置 cachedValue
后不必要。
保证不会多次执行昂贵的计算。
此外,cacheValid
标志是 atomic
很重要,因为它在 writing-to 之间创建了 happens-before 关系和 reading-from cachedValue
。
换句话说,它将 cachedValue
(在 mutex
之外访问)与其他调用 magicValue()
的线程同步。
如果 cacheValid
是一个常规的 'bool',您将在 cacheValid
和 cachedValue
上发生数据竞争(根据 C++11 标准导致未定义的行为)。
在 cacheValid
内存操作上使用默认顺序一致的内存排序很好,因为它暗示了 acquire/release 语义。
理论上,您可以通过在 atomic
加载和存储上使用较弱的内存顺序来优化:
int Widget::magicValue() const
{
if (cacheValid.load(std::memory_order_acquire)) return cachedValue;
else {
std::lock_guard<std::mutex> guard(m); // lock m
if (cacheValid.load(std::memory_order_relaxed)) return cachedValue;
auto val1 = expensiveComputation1();
auto val2 = expensiveComputation2();
cachedValue = val1 + val2;
cacheValid.store(true, std::memory_order_release);
return cachedValue;
}
}
请注意,这只是一个小的优化,因为读取 atomic
是许多平台上的常规负载(使其与读取 non-atomic 一样高效)。
正如 Nir Friedman 所指出的,这只适用于一种方式;您不能使 cacheValid
无效并重新开始计算。但这不是迈耶斯示例的一部分。
第16项:"Make const member functions thread safe"中有一段代码如下:
class Widget {
public:
int magicValue() const
{
std::lock_guard<std::mutex> guard(m); // lock m
if (cacheValid) return cachedValue;
else {
auto val1 = expensiveComputation1();
auto val2 = expensiveComputation2();
cachedValue = val1 + val2;
cacheValid = true;
return cachedValue;
}
} // unlock m
private:
mutable std::mutex m;
mutable int cachedValue; // no longer atomic
mutable bool cacheValid{ false }; // no longer atomic
};
我想知道为什么 std::lock_guard 应该总是在每次 magicValue() 调用时执行,不会像预期的那样工作吗?:
class Widget {
public:
int magicValue() const
{
if (cacheValid) return cachedValue;
else {
std::lock_guard<std::mutex> guard(m); // lock m
if (cacheValid) return cachedValue;
auto val1 = expensiveComputation1();
auto val2 = expensiveComputation2();
cachedValue = val1 + val2;
cacheValid = true;
return cachedValue;
}
} // unlock m
private:
mutable std::atomic<bool> cacheValid{false};
mutable std::mutex m;
mutable int cachedValue; // no longer atomic
};
这样就需要更少的互斥锁,从而提高代码效率。我在这里假设 atomica 总是比互斥体快。
[编辑]
为了完整性,我测量了两个 aprache 的效率,第二个看起来只快了 6%。: http://coliru.stacked-crooked.com/a/e8ce9c3cfd3a4019
我实际上认为您的代码片段是正确的,但它依赖于一个在现实世界示例中通常不正确的假设:它假设 cacheValid
从假变为真,但可以永远不要逆向进行,那会变得无效。
在旧代码中,mutex
保护 所有 读取 和 写入 cachedValue
。在您的新代码中, cachedValue
实际上在互斥体之外有一个读访问权限。这意味着一个线程可能读取这个值,而另一个线程正在写入它。要注意的是,只有当 cacheValid
为真时,才会在互斥量之外进行读取。但是如果 cacheValid
为真,则不会发生写入; cacheValid
只有在全部写入完成后才能变为真(注意这是强制执行的,因为cacheValid
上的赋值运算符会使用最严格的内存顺序保证,所以它不能用块中较早的指令重新排序)。
但是假设编写了一些其他代码,可以使缓存无效:Widget::invalidateCache()
。这段代码只是再次将 cacheValid
设置为 false。在旧代码中,如果您从不同线程重复调用 invalidateCache
和 magicValue
,后一个函数可能会在任何给定点重新计算值或不重新计算值。但是,即使您的复杂计算在每次调用时都 returning 不同的值(例如,因为它们使用全局状态),您也将始终获得旧值或新值,而不会得到其他任何值。但现在考虑代码中的以下执行顺序:
- 线程 1 调用
magicValue
,并检查cacheValid
的值。这是真的。它在继续之前被打断了。 - 线程 2 调用
invalidateCache
,然后立即调用magicValue
。magicValue
看到缓存无效,获取互斥量,开始计算,开始写入cacheValid
. - 线程 1 中断,正在读取部分写入的
cacheValid
。
实际上我认为这个示例不适用于大多数现代计算机,因为 int
通常是 32 位,并且通常 32 位写入和读取是原子的。所以真的不可能穿插或"tear"cachedValue
的值。但是在不同的体系结构上,或者如果您使用整数以外的类型(例如超过 64 位的任何类型),则不能保证写入或读取是原子的。所以你可以得到,作为 magicValue
的 return,一些既不是旧值也不是新值的东西,而是一些甚至不是有效对象的奇怪的按位混合。
所以,很高兴你找到了这个。我猜想为了简单起见,作者在尝试简化示例时忘记了不再需要严格地将互斥体放在外面。
您的第二个代码片段显示了 双重检查锁定模式 (DCLP) 的完全有效实现,并且(可能)比 Meyers 的解决方案更有效,因为它避免锁定 mutex
设置 cachedValue
后不必要。
保证不会多次执行昂贵的计算。
此外,cacheValid
标志是 atomic
很重要,因为它在 writing-to 之间创建了 happens-before 关系和 reading-from cachedValue
。
换句话说,它将 cachedValue
(在 mutex
之外访问)与其他调用 magicValue()
的线程同步。
如果 cacheValid
是一个常规的 'bool',您将在 cacheValid
和 cachedValue
上发生数据竞争(根据 C++11 标准导致未定义的行为)。
在 cacheValid
内存操作上使用默认顺序一致的内存排序很好,因为它暗示了 acquire/release 语义。
理论上,您可以通过在 atomic
加载和存储上使用较弱的内存顺序来优化:
int Widget::magicValue() const
{
if (cacheValid.load(std::memory_order_acquire)) return cachedValue;
else {
std::lock_guard<std::mutex> guard(m); // lock m
if (cacheValid.load(std::memory_order_relaxed)) return cachedValue;
auto val1 = expensiveComputation1();
auto val2 = expensiveComputation2();
cachedValue = val1 + val2;
cacheValid.store(true, std::memory_order_release);
return cachedValue;
}
}
请注意,这只是一个小的优化,因为读取 atomic
是许多平台上的常规负载(使其与读取 non-atomic 一样高效)。
正如 Nir Friedman 所指出的,这只适用于一种方式;您不能使 cacheValid
无效并重新开始计算。但这不是迈耶斯示例的一部分。