制作 | CSDN博客
最近很多读者评论说,博客里的代码越来越原始,但讨论的问题却越来越高级,从并发到乱序执行再到内存布局。
其实这不是发布,只是Rust的学习门槛对我来说太高了,学习过程中的挫折感也很强。写完之前的《Rust 中的胖指针在哪里》,作者曾经决定我要摆脱 Rust,但是截止到本周,这个目标还没有实现,因为我所在的 Rust 学习群有一个灵魂拷问, Rust 的技术本质是什么?这个问题我不回答好,我真的无法平静。
Rust 枚举的本质是什么?
1.枚举与通用变量定义对比:首先,Rust和C/C++在枚举的处理上更加一致。从汇编的角度来看,枚举与普通变量声明最大的区别在于,枚举多存储了一种描述符。我们先来看下面的代码:
#[derive(Debug)]
enum IpAddr {
V4(u8, u8, u8, u8),
V6(String),
}
fn main{
let a=127;
let b=0;
let c=0;
let d=1;
let home = IpAddr::V4(127, 0, 0, 1);
println!("{:#?}", home);
}
IP 地址是更适合枚举的场景。 IP地址分为两种细分类型:IPV6和IPV4。与一般结构不同的是,IPV6和IPV4这两种类型是相等的,相互独立,要么一种,要么另一种,不是IP类型下的两种元素,所以此时最好使用枚举类型IpAddr。抽象 IP 地址这个场景。
反汇编上面的代码可以看到,与普通的变量定义和声明相比,枚举对象的定义不仅存储了栈上对应的值,还存储了额外的枚举信息。请参阅下面的图标。红色注释:
2.枚举与结构的异同:我们还是以IP为例来说明,IP地址分为V4和V6两种,但是从IPV4的角度来看,比如IP地址: 127.0.0.1,其中每个网段都是IPV4地址的一部分,是普通的关系,比较适合用结构体来定义,如图在以下代码中:
#[derive(Debug)]
enum IpAddr {
V4(u8, u8, u8, u8),
V6(String),
}
#[derive(Debug)]
struct IPV4(u8, u8, u8,u8);
fn main{
let a=127;
let b=0;
let c=0;
let d=1;
let home = IpAddr::V4(127, 0, 0, 1);
let ipv4home= IPV4(a,b,c,d);
let remotehost = IpAddr::V4(119, 3, 187, 35);
let loopback = IpAddr::V6(String::from("::1"));
let loopStr=String::from("::1");
let remotehost1 = IpAddr::V6(String::from("1030::C9B4:FF12:48AA:1A2B"));
println!("{:#?}", home);
println!("{:#?}", loopback);
println!("{}",loopStr);
println!("{:?}", remotehost);
println!("{:?}", remotehost1);
println!("{:?}",ipv4home);
}
对上面的代码进行反汇编可以看出,与结构体相比,枚举只是增加了一个枚举类型的记录。
一行不相关的代码提高效率10%?
上面关于枚举的描述比较容易理解,但这不是今天的重点。最近我的 Rust 学习小组的很多同事都在做并发和内存布局方面的一些研究。
刚好把上面的代码放到了一个Rust并行原型程序中,却意外发现执行时间缩短了5%-10%。变量定义差别不大,所以代码简化如下:
use std::thread;
fn main {
let mut s = String::with_capacity(100000000);
let mut s1 = String::with_capacity(100000000);
let handle = thread::spawn(move || {
let mut i = 0;
while i < 10000000 {
s.push_str("hello");
i += 1;
}
});
let handle1 = thread::spawn(move || {
let mut i = 0;
while i < 10000000 {
s1.push_str("hello");
i += 1;
}
});
handle.join.unwrap;
handle1.join.unwrap;
}
以上代码的执行时间测试结果如下:
[root@ecs-a4d3 hello_world]# rustc hello7.rs
[root@ecs-a4d3 hello_world]# time ./hello7
real 0m0.999s
user 0m1.906s
sys 0m0.050s
[root@ecs-a4d3 hello_world]# time ./hello7
real 0m1.093s
user 0m2.005s
sys 0m0.060s
[root@ecs-a4d3 hello_world]# time ./hello7
real 0m1.079s
user 0m1.979s
sys 0m0.069s
[root@ecs-a4d3 hello_world]# time ./hello7
real 0m1.011s
user 0m1.902s
sys 0m0.066s
[root@ecs-a4d3 hello_world]# time ./hello7
real 0m1.031s
user 0m1.944s
sys 0m0.053s
但是在定义了一个不相关的变量并打印步骤后,代码如下:
use std::thread;
fn main {
let mut s = String::with_capacity(100000000);
let reverbit="abcdefghijk";
let mut s1 = String::with_capacity(100000000);
let handle = thread::spawn(move || {
let mut i = 0;
while i < 10000000 {
s.push_str("hello");
i += 1;
}
});
let handle1 = thread::spawn(move || {
let mut i = 0;
while i < 10000000 {
s1.push_str("hello");
i += 1;
}
});
handle.join.unwrap;
handle1.join.unwrap;
println!("{}",reverbit);
}
加上这个不相关的变量定义后,这段代码的执行时间比上一段缩短了至少5%。这个结果是在执行print more的IO操作的基础上实现的。
[root@ecs-a4d3 hello_world]# time ./hello7
abcdefghijk
real 0m0.963s
user 0m1.856s
sys 0m0.050s
[root@ecs-a4d3 hello_world]# time ./hello7
abcdefghijk
real 0m0.960s
user 0m1.844s
sys 0m0.055s
[root@ecs-a4d3 hello_world]# time ./hello7
abcdefghijk
real 0m0.964s
user 0m1.846s
sys 0m0.065s
[root@ecs-a4d3 hello_world]# time ./hello7
abcdefghijk
real 0m0.958s
user 0m1.858s
sys 0m0.045s
[root@ecs-a4d3 hello_world]# time ./hello7
abcdefghijk
real 0m0.963s
user 0m1.862s
sys 0m0.052s
[root@ecs-a4d3 hello_world]# time ./hello7
abcdefghijk
real 0m0.963s
user 0m1.853s
sys 0m0.047s
在确认编译方式没有问题后,我基本确认了这个性能提升不是一个可以忽略的偶然事件。
初步提示在初始化内存时,尽量指定合适的容量:这个Rust程序实际上分别通过两个线程handle和handle1来处理两个字符串s和s1。从程序本身来说,只有一个小窍门,就是说初始化字符串的方式是通过String::with_capacity方法。在这里,我们回顾一下上一篇博客中提到的String内存布局。
在上述内存状态下,执行了push_str(“!”)操作,字符串的容量没有溢出,堆内存空间不会被重新申请给系统,ptr指针也不会被改变了。 +1,并在 o! ,如下图:
也就是说,提前将容量设置为合适的大小,可以避免重复向系统申请动态堆内存,提高程序运行效率。
是什么让无关代码更有效率?
这里给出的结论是内存、缓存和CPU多核之间的另一个竞争和协同问题。在分析这个问题之前,我们还是要回到上一篇博文的内容。栈上String对象的三个成员ptr、capacity和len都是64位长,加起来就是192位或者24字节。详情见下文:
X86CPU的缓存容量是每行64bytes,也就是说按照我们原来的定义:
let mut s = String::with_capacity(100000000);
let mut s1 = String::with_capacity(100000000);
字符串 s 和 s1 占用连续的 48 字节堆栈空间。这种内存分配布局使得它们很可能位于同一个内存缓存行上,也就是说,当不同的CPU分别操作s和s1时,它们实际上操作的是同一个缓存行刷空间留言软件源码,那么这样的操作可能会相互影响,从而减少效率。
我们知道现代 CPU 配备了缓存。根据多核缓存同步的MESI协议,每个缓存行有四种状态,分别是E(独占)、M(修改)、S(共享)、I(无效)刷空间留言软件源码,其中:
四种状态的状态转移图如下:
我们上面也提到,当容量足够的时候,执行push_str操作不会导致程序再次malloc内存给系统,而是会让len的值发生变化,那么因为不同的CPU同时在处理当 s1 和 s 实际操作同一个 cache line 时,CPU0 很可能操作 s1 的 len 而 CPU0 操作 s 的 len。这种远程写操作使得cache line的状态始终在S和I之间,在它们之间进行状态转换,一旦状态变为I,就需要更多的时间来同步状态。
所以我们基本上可以得出结论 let reverbit=”abcdefghijk”;在这行不相关的代码之后,栈上的内存空间布局发生了变化,s1和s不经意间被分成了不同的缓存行,这也使得最终的执行效率得到了提升。当然,由于dump缓存的状态会极大地改变程序的行为,所以本文的验证过程没有前面那么严谨。如有错误或遗漏,请指正。
let reverbit=”abcdefghijk”这行,好像没什么用;代码最终提高了近10%的效率,这也让人不得不感叹编程到底是一门艺术。相反,它是最强大的。
马超,CSDN博客专家,阿里云MVP,华为云MVP,华为2020技术社区开发者之星。
暂无评论内容