在2021年4月14号LKML 邮件组在讨论是不是要接纳Rust语言进行开发,而Linus本人似乎对Rust也没有那么反感。种种迹象表明Rust是一门值得一学的语言。但是拖延症让我一直拖到2周以前才开始学习Rust.
现代编程语言一般都围绕三个方面进行设计:范式
,内存
,并发
(这是我自己的理解,也许并不正确,毕竟我没有设计过编程语言:D)。
就“范式”而言,Rust是一门多范式编程语言,而编程范式这几十年来没有什么太大变化,Rust同样在这方面也没有太大的创新。因此这一块没什么好说的。
刚接触Rust我就被它的“内存”管理震惊了,它号称在没有GC机制的情况下,可以做到内存安全。
我深知其中的艰难。
大约在5年前,我就尝试过通过编译器推导,来自动调用内存释放函数。
比如下面这段代码,在编译时可以推导出buf指针最长的生命周期在bar
函数内,所以在bar函数的结束处可以自动生成free(buf)
代码。
void foo(void *buf) {
//do_somthing of buf
}
void bar() {
char *buf = malloc(64);
foo(buf);
buf[64] = 0;
}
再复杂一点,我们依然可以推导出,这个指针该在bar函数的结束处释放。
char *foo(const char *s) {
return strdup(s);
}
void bar() {
char *s = foo("hello");
s[0] = 'x';
}
但是,对于一些更为复杂情况(结构体中包含指针、运行时执行路径的多变,比如下面代码、等),靠编译器是无法正确推导的。这个想法也就以失败而告终。
void bar(int a) {
char *s;
if (a == 1) {
s = foo("hello");
} else {
s = "world";
}
printf("%s", s);
}
也因此,Rust的内存管理方式对我格外有吸引力。
Rust首先提出了“所有权”的概念,某个变量拥有一个值
的所有权,在离开作用域时,它就有责任清理这个值
。“所有权”可以转移,不可以共享、复制。
在上面三段代码中不难看出,要推导一个函数内的所有值
的生命周期并不困难。困难的是当一个值
贯穿多于一个函数之后,生命周期就变得非常复杂。
Rust基于“所有权”,在函数原型上约束了参数
的生命周期。函数原型会指明,每一个参数是“借用(没有清理责任)”还是“转移(连清理责任一起传递过来了)”。这样编译器就可以检查调用期间,"所有权"是否正确转移。
可以说,这是一种极为睿智的取舍。只添加了少许限制
,就可以完成所有值
的生命周期的推导。简直是发明了,除引用计数
和标记清除
之外的第三种内存管理方式。
这种限制
似乎在实现复杂数据结构上颇为掣肘。
于是,又不得不在标准库中引入智能指针(引用计数)
来辅助实现一些复杂的数据结构,这着实让人觉得有点美中不足。
不过,Rust的智能指针并不像OC等语言一样,在语言层面实现,而是以标准库的形式提供。总算是能弥补一点遗憾。
Rust下的并发同样值得一提,在“所有权”的内存管理机制下,编译器可以提前避免各种竞争问题。
在大家都吹爆GO语言的goroutine时, 我也跟风学习了一下。
然而学完之后,我对GO语言一直热情不太高。
其根本原因就是,他们吹爆的goroutine,根本没有解决并发问题。goroutine解决的只是线程切换成本过高的问题。
我不清楚是不是吹爆GO的都是做Web的选手。因为Web具有天然的并行性,他们最终的逻辑都只在数据库交织。而数据库已经为他们实现了各种各样的锁。
考虑下面的go代码,大概率在你的计算机上最终a的值是小于1000的。
package main
import (
"fmt"
"time"
)
func main() {
var a = 0
for i := 0; i < 1000; i++ {
go func(idx int) {
a += 1
}(i)
}
time.Sleep(time.Second)
fmt.Println(a)
}
在C语言时代,这是一个常见的并发问题:没有加锁。
那为什么在GO语言上也会出现这种现象呢,因为goroutine是跑在线程池上的。
也许你会说:“加个锁不就好了么?”,“GO推荐使用channel进行通信,你用了不就解决问题了”。
在C++领域,我们造不出锁么,我们造不出channel么,为什么后来单线程大行其道。
其根本原因是,加锁这种行为,是极易犯错的。就算你使用了channel等同步机制,语言本身还是允许你自由的访问共享内存,不经意间就会产生竞争问题。
而Rust在这方面就做的非常好,他的“所有权”机制。可以在编译时就能提醒你潜在的并发问题。
如果你要在线程中访问一个变量,这个线程就必须拥有这个变量所代表值的“所有权”。如果别的线程访问同一个变量就会产生编译错误。这就从编译时解决了并发问题。
同样, Rust的多线程也允许两种同步方式:加锁和channel。
使用channel进行同步时,多线程不可以同时访问同一个变量,因为在发送某一个值时,连它的“所有权”也一起发送出去了。
在使用锁进行同步时,Rust的“所有权”机制同样会保证,你不获取锁就不能访问某个变量。
我认为只有在这样安全的环境下, 才可以真正编写并发程序。
ps. 我想Rust是继C,Lua之后我喜欢的第三门语言。