那个占着茅坑的线程
让我们来看看下面这个简单的代码:
class Foo {
std::mutex m_mutex;
std::unordered_map<std::string, int> m_map;
public:
void pass_by_reference(const std::string &key, int value) {
std::lock_guard<std::mutex> lock(m_mutex);
m_map.emplace(key, value);
}
void pass_by_value(const std::string key, int value) {
std::lock_guard<std::mutex> lock(m_mutex);
m_map.emplace(key, value);
}
void useless_copy(const std::string &key, int value) {
std::string copy(key);
std::lock_guard<std::mutex> lock(m_mutex);
m_map.emplace(copy, value);
}
void useless_hash(const std::string &key, int value) {
std::hash<std::string> hash_fn;
(void) hash_fn(key);
std::lock_guard<std::mutex> lock(m_mutex);
m_map.emplace(key, value);
}
};
前面两个函数唯一的区别就是传引用和传值的区别。我们都知道,传引用对于大的对象来说应该更快,因为这样可以防止无用的拷贝。 Effective C++ 的条款20就是:宁以 pass-by-reference-to-const 替换 pass-by-value。这是一个非常普遍使用的最佳实践,所以说每当你开始写一个函数的时候你都会不由自主地传引用。
useless_copy
传引用但是创建了个在栈上的副本。useless_hash
计算了这个字符串的 Hash 但是也没有使用它。我的直觉告诉我这四个函数应该有几乎相同的性能,pass_by_reference
甚至应该略快一点。
Benchmark
我写了个 Benchmark 程序,八个线程同时更新这个对象,每个线程操作1000000次,一共有1000000个长度为32的字符串。你可以在这里获取这个程序:https://gist.github.com/abcdabcd987/f0d99b2f21567654276ca14d7d1338db。 结果如下:
pass by reference: 7.360 ± 0.077 seconds
pass by value : 5.601 ± 0.040 seconds
useless copy : 5.657 ± 0.051 seconds
useless hash : 5.704 ± 0.065 seconds
结果显示,后面的三个函数比 pass_by_reference
快了25%,非常反直觉。
Cache
每当奇怪的事情发生的时候,就该开始怀疑是不是处理器 Cache 的问题了。
在 C++ 里面,引用就如同一个指针。pass_by_reference
实际上是传递了一个指向字符串的指针。这个字符串的内容在 std::unordered_map::emplace
之前从来没有被访问过。当字符串的内容第一次被访问的时候,一定会产生一次 Cache Miss,因为我们的程序里面有太多的字符串了。所以说,处理器就需要停下来去内存中读取数据。相比于 Cache,从内存读数据还是一个很慢的过程。
更糟糕的是,这个线程停在了 Critical Section 里面。其他所有的线程都等在锁上面,而这个拿了锁的线程却在发呆,等待数据从内存传过来。这个线程真的是占着茅坑不拉屎!
pass_by_value
和 useless_copy
没有这个问题,因为在创建栈上的副本的时候处理器已经把字符串抓到了 Cache 里面了,而这又发生在 Critical Section 之前,所以说其他的线程就不会被卡住。
useless_hash
也没有这个问题,因为计算字符串的 Hash 也需要读取一遍字符串的内容,所以说在进入 Critical Section 之前这个字符串也已经在 Cache 里面了。
__builtin_prefetch
骆铮同学在我发表了这篇文章之后提到了 __builtin_prefetch
,于是我又跑了一次 Benchmark(上面的 gist 已经更新):
builtin prefetch : 5.853 ± 0.081 seconds
这一招看来也很好用。不过有个让我很困惑的事情:__builtin_prefetch
会把多少数据抓到 Cache 里面?GCC 文档没有说清楚。我发现 __builtin_prefetch
被翻译成了 prefetcht0
指令。
42:cache-prefetching.cc **** std::lock_guard<std::mutex> lock(m_mutex);
5900 .loc 17 42 0
5901 0016 488B06 movq (%rsi), %rax
41:cache-prefetching.cc **** __builtin_prefetch(key.data());
5902 .loc 17 41 0
5903 0019 8955EC movl %edx, -20(%rbp)
42:cache-prefetching.cc **** std::lock_guard<std::mutex> lock(m_mutex);
5904 .loc 17 42 0
5905 001c 0F1808 prefetcht0 (%rax)
于是我去查了查 Intel® 64 and IA-32 Architectures Software Developer Manuals:
The implementation of prefetch locality hints is implementation-dependent, and can be overloaded or ignored by a processor implementation. The amount of data prefetched is also processor implementation-dependent. It will, however, be a minimum of 32 bytes.
Section 4.3 Instructions (M-U),
PREFETCHh
.
也就是说,每次抓多少是有具体的CPU实现决定的,但是至少会抓32字节。在我的 Benchmark 程序里面每个字符串长度正好是32字节,那如果字符串更长一点呢?
从图中可以看出,随着字符串长度变长,__builtin_prefetch
的效果会下降,最终就没有效果了。从4KB开始性能显著下降,这是因为我的处理器每个超线程有32KB、8路组关联L1 Cache,每一路正好有32/8=4KB。__builtin_prefetch
有个好处是,在数据量小的时候它很有用,当数据量大的时候它虽然不起作用但也不会有负面影响。useless_copy
和 useless_hash
可能造成了更多的 Cache Miss,所以在数据量大的时候跑得更慢。
总结
- “过早的优化是万恶之源。”不要就因为看了这篇文章所以就去优化你程序的Cache控制。大多数情况下你可以相信硬件本身的优化。
- 如果你真的想要优化 Cache
- 检查在进入 Critical Section 之前,数据是不是已经被抓到 Cache 里面了
- 也可以试试看
__builtin_prefetch
后记
或许你一读完上面的代码就已经想到了原因,但是当我在写上一篇文章分片:简单粗暴细化锁的粒度的通用方法的时候,我觉得并不是那么显然。当我在写那篇文章的时候,我遇到了一个非常怪异的现象:只有一个分片的 KVSharded
竟然跑得比 KVBigLock
快。我拉来了刘家昌同志帮我想想这是为什么。我们尝试了若干种办法,但没有一个起了作用:
- 用模板来代替虚函数
- 换一个操作系统、换一台机器
echo 3 > /proc/sys/vm/drop_caches
- 设置线程的 Affinity
- 跑各种各样的 Profilers
- 用不同的指令集编译
昌喵肝到了深夜最终给出了这个非常合理的解释。在此特别感谢昌喵!