遇到一个问题想请教一下大家。我想改进一个项目中的缓存读取性能,将原来的Mutex
替换为RwLock
,结果发现即便无写入,只有并发读,性能也没有什么改观。于是作了下面这个测试,结果令我很惊讶:
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::RwLock;
use std::thread;
use std::time;
fn main() {
for i in 1..=8 {
workload(i);
}
}
fn workload(concurrency: usize) {
let total = 1000 * 1000;
let mut m = HashMap::new();
for i in 0..total {
m.insert(i, i);
}
let m = Arc::new(RwLock::new(m));
let now = time::Instant::now();
let threads: Vec<_> = (0..concurrency)
.map(|_| {
let m = m.clone();
thread::spawn(move || {
for i in 0..total {
let _x = m.read().unwrap().get(&i);
}
})
})
.collect();
for t in threads {
t.join().unwrap();
}
let t = now.elapsed();
println!(
"threads: {}; time used: {:?}; ips: {}",
concurrency,
t,
(total * concurrency) as f64 / t.as_secs_f64()
);
}
cargo run --release
输出如下:
threads: 1; time used: 77.838377ms; ips: 12847133.23352053
threads: 2; time used: 205.569367ms; ips: 9729076.025223155
threads: 3; time used: 328.003797ms; ips: 9146235.584583797
threads: 4; time used: 415.737089ms; ips: 9621465.358362578
threads: 5; time used: 508.222261ms; ips: 9838215.252834035
threads: 6; time used: 586.550472ms; ips: 10229298.732880399
threads: 7; time used: 720.991697ms; ips: 9708849.67070571
threads: 8; time used: 856.792181ms; ips: 9337153.369750464
每个线程的负载是相同的,看起来增加线程明显只起到了负面作用。
我把代码翻译成Go,发现情况要好很多:
threads: 1; time used: 156.012685ms; ips: 6409735.208390
threads: 2; time used: 163.830266ms; ips: 12207756.532606
threads: 3; time used: 189.644867ms; ips: 15819041.387500
threads: 4; time used: 209.123695ms; ips: 19127435.559132
threads: 5; time used: 225.407194ms; ips: 22182078.181586
threads: 6; time used: 261.852325ms; ips: 22913678.539994
threads: 7; time used: 296.061541ms; ips: 23643732.908895
threads: 8; time used: 322.794129ms; ips: 24783598.217178
虽然性能不是线性提升的,但相比我写的rust版本要符合预期。
我觉得很可能是我写的rust版本有问题,想请教一下大家问题出在哪里,或者提高Hashmap的并发读性能的正确姿势是什么呢?
EDIT 1
其实我觉得重点并不是RwLock的加锁和解锁耗时长短,而是为什么线程数增加性能却不会增加甚至下降?线程内部只读不写的情况下,假设1个线程耗时100ms,如果读是并发的,核心数足够,理想情况下N个线程耗时应该还是100ms,总吞吐量是单线程的N倍。当然,实际情况效率不可能和线程数成正比的,线程切换和状态同步开销不可避免,但我觉得也不应该是单线程性能最高吧,毕竟RwLock存在的最大意义就是支持并发读。总感觉还是哪里没对
EDIT 2
@hr567 提供提了一个优化思路,将锁放到循环外面,性能确实会好很多,结果也比较符合预期。不过针对缓存场景,锁放到循环内部模拟可能更准确一些(循环模拟的是持续的请求,每次请求都需要访问一次缓存,但不能一直握着读锁不放,因为外部有时还需要写锁来更新缓存)。所以是不是说Rust中,对于读多写少的缓存来说,还是应该选择用(之前测试参数写错了,实际除了单线程RwLock的表现还是好与Mutex的)Mutex
更好呢?
下面是同一台机器上Mutex和RwLock的测试结果,除了锁做了替换,其他代码完全一样:
Mutex:
threads: 1; time used: 78.146596ms; ips: 12796462.689174587
threads: 2; time used: 554.152693ms; ips: 3609113.562496032
threads: 3; time used: 417.027343ms; ips: 7193772.90327939
threads: 4; time used: 717.132682ms; ips: 5577768.38289459
threads: 5; time used: 1.701271272s; ips: 2938978.6815844146
threads: 6; time used: 1.817029184s; ips: 3302093.3581218696
threads: 7; time used: 2.372727488s; ips: 2950191.30321636
threads: 8; time used: 2.505103477s; ips: 3193480.857557406
RwLock:
threads: 1; time used: 107.624433ms; ips: 9291570.437355986
threads: 2; time used: 278.304096ms; ips: 7186383.631234806
threads: 3; time used: 406.974556ms; ips: 7371468.205496365
threads: 4; time used: 527.331438ms; ips: 7585362.282155459
threads: 5; time used: 618.426131ms; ips: 8085039.990006503
threads: 6; time used: 767.771963ms; ips: 7814820.401301891
threads: 7; time used: 830.143264ms; ips: 8432279.46736673
threads: 8; time used: 908.431399ms; ips: 8806388.692427836
评论区
写评论挖个坟
其实最主要的问题出在RwLock上面
只要把RwLock换成ArcSwap,就可以让ips随线程数目近似线性增长
完整代码呢?
--
👇
星夜的蓝天: 经过测试,将标准库中hashtable的hasher替换为ahash,然后用arcswap包裹后,性能应该可以满足要求,在此附上测试结果:
核心代码
--
👇
Blues-star: 之前用tokio的闭包写错了,导致task没有执行,得到的之前用tokio的闭包写错了,导致task没有执行,得到的结果是错误的,用tokio并发+hashbrown+ArcSwap改写后的测试结果如下
经过测试,将标准库中hashtable的hasher替换为ahash,然后用arcswap包裹后,性能应该可以满足要求,在此附上测试结果:
核心代码
--
👇
Blues-star: 之前用tokio的闭包写错了,导致task没有执行,得到的之前用tokio的闭包写错了,导致task没有执行,得到的结果是错误的,用tokio并发+hashbrown+ArcSwap改写后的测试结果如下
之前用tokio的闭包写错了,导致task没有执行,得到的之前用tokio的闭包写错了,导致task没有执行,得到的结果是错误的,用tokio并发+hashbrown+ArcSwap改写后的测试结果如下
go 版的加锁解锁操作放到循环外还是比 rust 版快一倍
--
👇
liyiheng: 在我机器上,用 tokio::sync::RwLock 结果:
Go 结果:
在我机器上,用 tokio::sync::RwLock 结果:
Go 结果:
ArcSwap 换成 tokio::sync::RwLock 呢?
推荐使用
dashmap
嗯,性能随线程数的提升幅度是更高了,我以为@Blues-star的意思是从RwLock换成ArcSwap后的绝对性能的提升。之前我在循环内部少调用了一个方法,导致ArcSwap的测试数据整体看起来快了好几倍,改了之后测试数据没有那么夸张。
--
👇
eweca: 讲道理你改完之后提升更高了,你算算看,原来8线程是4倍多效率,现在有5倍多效率了。
讲道理你改完之后提升更高了,你算算看,原来8线程是4倍多效率,现在有5倍多效率了。
抱歉,确实没有那么大的提升,我仔细看了代码,发现之前改写成
ArcSwap
的时候,循环内部只load
了map本身,没有调用map的方法,代码如下:改正后的输出结果:
--
👇
Blues-star: 可否贴一下用arc_swap改进后的代码,我用arc_swap改写后有所提升,但time used的提升没有那么极致。
...
可否贴一下用arc_swap改进后的代码,我用arc_swap改写后有所提升,但time used的提升没有那么极致。
--
👇
wfxr: 谢谢建议,用arc-swap改写之后性能果然巨幅提升,而且确实随着线程增加性能在明显提升:
但是如何更新ArcSwap包裹的值还没搞懂,我先研究一下
--
👇
shaitao: 就是因为有这个问题有人专门写了个包叫arc-swap, 你可以试试
谢谢建议,用arc-swap改写之后性能果然巨幅提升,而且确实随着线程增加性能在明显提升:
但是如何更新ArcSwap包裹的值还没搞懂,我先研究一下
--
👇
shaitao: 就是因为有这个问题有人专门写了个包叫arc-swap, 你可以试试
其实是一样的,我开始是这么测试的,但结论还是无论多少个线程性能都比单线程差。你可能是在
debug
下测试的?--
👇
ezlearning: 总工作量不变的情况,线程增加,耗时会减少:
就是因为有这个问题有人专门写了个包叫arc-swap, 你可以试试
是在
debug
下测试的吗?debug
下看确实线程增加的时候性能有提升,但release
模型我不管怎么测试,单线程性能都是最高的,线程数大于1的时候会有一个条变,性能大幅下降,线程数2到8的时候性能是差不多的,下面是我另一台机器测试结果:Go的测试结果单线程性能远低于rust,但线程数大于1的时候都比rust好很多:
(原帖是在家里测试的,CPU是i9 9900K,这次是单位测的,CPU是i7 7700HQ,OS都是Arch 内核版本是5.10.9-arch1-1)
--
👇
eweca: 我试了下你的代码,发现实际上是可以增加效率啊。但是吃到3个线程以上时,几乎没有什么提升了。
@xin-water,
谢谢建议,分段锁肯定是有用的。只是不明白并为什么将独占锁替换成读写锁之后性能完全没有提升呢?
--
👇
xin-water: 不知道这种提升性能方法对你有没有用,某些情况可以用分段锁,比如这样:Arc::new(vec![rwlock::new(hashmap::new());1024])。
@hr567,
谢谢你的建议,将锁放到循环外部确实可以提升性能,只是项目的应用场景是缓存,单次请求只会访问一次,所以无法锁定一次持续访问。Go语言的版本我也是在循环内部加锁和解锁,代码是这样的:
go语言的版本虽然线程数比较多的时候性能提升幅度会逐渐减少,但总是可以找到一个合理的线程数获得比单线程高得多的性能,但是我测试的rust版本无论线程多少都不然单线程,所以我有点纳闷
--
👇
hr567: 虽然在看到你写的go语言的程序前无法做精确的判断,不过在rust(或者其他任何语言)中反复获取/释放锁都是一件非常耗时的工作。
在该程序创建的每个新线程中,为了读取HashMap中的值,每次循环都进行了一次获取/释放锁的过程,随着线程的增加这一过程产生的负担会越来越大。将新线程闭包中的内容简单地改成如下形式将使性能得到巨大改善。
...
不知道这种提升性能方法对你有没有用,某些情况可以用分段锁,比如这样:Arc::new(vec![rwlock::new(hashmap::new());1024])。
虽然在看到你写的go语言的程序前无法做精确的判断,不过在rust(或者其他任何语言)中反复获取/释放锁都是一件非常耗时的工作。
在该程序创建的每个新线程中,为了读取HashMap中的值,每次循环都进行了一次获取/释放锁的过程,随着线程的增加这一过程产生的负担会越来越大。将新线程闭包中的内容简单地改成如下形式将使性能得到巨大改善。
附上修改前后在我的笔记本电脑(i7-8750H 6C12T)上运行该程序得到的结果:
修改前
修改后
修改后的程序性能大致符合性能预期。
由此可见每个线程的负载受RwLock自身的读写次数影响较大。 go语言由于自身gc的影响工作方式想必与修改后的代码相近,因此性能也更加符合预期。