初窥Rust

在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之后我喜欢的第三门语言。



发表评论