开始学习 Rust,从 rustlings 入手,可以看中文版的 Rust 程序设计语言。看了两天书,边看边写习题,完全当成一门新语言来学,内容太厚重了,而且对于我自己已有的经验来说,跟我以往了解的语言,大不一样。
其实 Rust 无需垃圾回收的所有权(borrow)部分,为了防止多线程竞态条件的部分,有 C/C++/Java 多线程经验的话,是很好理解的。Rust 虽然也有指针,以我的理解,因为编译器可以确切知道具体引用情况,是不需要多写 *p 这样的了。
深入骨髓的不同,从编译器、语言设计角度来说,是模式匹配、None、trait、宏,字符串、迭代器。
模式匹配不仅仅只是 match 关键字,及其控制流,从 let 到 if let 都有模式匹配的影子,如下
let tup = (500, 6.4, 1);
let (x, y, z) = tup;
上面就是元组的模式匹配,如果数量或类型对应不上,编译期就抛错误。而 if let 是为了简写 match 出现的。因为 Rust 自己的错误处理机制,其实大量用了 if let 或者 match,所以模式匹配一定会遇到的。
Rust 这里还深入提到了一点,有些模式匹配必须成功,程序才能编译通过,比如上面的例子,下面 Some(T) 在其实也是模式匹配,是可以接受匹配失败的。
跟很多语言不同,Rust 的 None 不算是一个值,而是一种状态,是我的感觉。下面两个是语言固定了的,一定会遇到
enum Result<T, E> {
Ok(T),
Err(E),
}
pub enum Option<T> {
/// No value
None,
/// Some value `T`
Some(T),
}
Result 用于表明结果是否正确,正确的使用 Ok(T) 返回,错误的用 Err(E) 返回,具体的值需要解包才能使用。Option 表示要么有值,要么没有值,Option::None 没有别的作用,就只是表明没有值而已,如果是 Some(T) 返回的,也是需要解包才能用具体的值。
Lua 的 nil 是 false 含义,还能塞入 table 表示 array 终结,ObjC 的 nil 也是 false 的含义,还能初始化指针,C 里面的 NULL 同理,Java 里面的 null 也是经常用来比较的,还容易引发 NullPointerException,反正是一个大量使用的合理值,新世代的 C++ 我不了解了,但 Rust 不一样,None 就是没有值,是一个枚举定义,我们经常使用且想见到的是 Some(T) 包裹的值。
Rust 没有继承,跟 C++/Java 不同,其实鼓励使用组合来完成功能,认为两个不同实例拥有继承关系,因此隐含了大量重复的代码是危险的行为。trait 跟接口很像,但 Java 的 interface 还可以定义函数行为,Rust 这里就不可以了。而且 Rust 教程里面也不会使用 interface 这样已有的词汇来描述 trait,因为 Rust 还有非常严格的类型系统,如果需要做容器的话,会用到很多 trait 提供的能力,比如 Box dyn 之类的。
trait AppendBar {
fn append_bar(self) -> Self;
}
impl AppendBar for String {
//Add your code here
fn append_bar(mut self) -> Self {
self = self + &"Bar".to_string();
self
}
}
上面为 String 增加了 append_bar 的接口,添加固定的 "Bar" 后缀。
因为 Rust 的编译器需要在编译期知道所有变量的大小,对于容器来说,是通过 Box 包裹来描述。Rust 为了实现零成本的抽象,泛型的处理实际上很多时候是编译期展开的,只有一些特殊的情况才是运行期动态决定的,这里我了解不深,先略过。
Rust 的宏跟 C/C++ 的 define 完全不同,因为不懂 C++ 的 template,所以也回答不上是否拥有 template 这么强大的元编程能力。同样作为元编程,能力强是真的,这个部分我还看不大懂,大概了解到,Rust 宏在 AST 构建完成后才展开,能捕捉下面这些元素
item: an item, like a function, struct, module, etc.
block: a block (i.e. a block of statements and/or an expression, surrounded by braces)
stmt: a statement
pat: a pattern
expr: an expression
ty: a type
ident: an identifier
path: a path (e.g. foo, ::std::mem::replace, transmute::<_, int>, …)
meta: a meta item; the things that go inside #[...] and #![...] attributes
tt: a single token tree
举个例子,下面 vec! 宏捕捉了 expr,push 每个 x
let v: Vec<u32> = vec![1, 2, 3];
#[macro_export]
macro_rules! vec {
( $( $x:expr ),* ) => {
{
let mut temp_vec = Vec::new();
$(
temp_vec.push($x);
)*
temp_vec
}
};
}
例子都来自 The Little Book of Rust Macros
Rust 内建了 str 类型,表示字符串的纸面含义,是输入字符串原来的值,String 是一个 std 类,内部是 UTF-8 编码的字符串,char 在 Rust 实现是 4 个字节的 UNICODE。
做习题的时候,经常性的 String::From("abc") 或者 a.to_string(),习题里面的 char 倒是很少用。
感觉字符串在哪一种语言都不是一个简单的事情,现在都是全球化了,考虑字符串是一种输入,可能是任何一种语言,因此其长度、分割、排版显示都不是小事情。
Rust 中每种容器都有迭代器,即便是 (0..10) 表示 0 - 9,(0..=10) 表示 0 - 10,可以下面这样用
pub fn factorial(num: u64) -> u64 {
if num <= 0 {
0
} else {
(1..=num).fold(1, |acc, x| acc * x)
}
}
assert_eq!(24, factorial(4));
上面的斐波那契数列计算,没有用到多余的变量,没有用到 for、while、loop 和递归,只用了 Rust 里面的 fold 累加器,其他的还有 sum、filter、map 等,还有,上面忘了说的,expression 放到最后,就直接返回计算的值了。
--
举了好几个跟我之前了解的编程语言大不相同的例子,其实除此之外,还有不少,一些是我还不了解的,比如生命周期,比如 unsafe pointer,生命周期的描述是可以根据一些 trait 控制来改变的。Rust 性能优先,还考虑能高效调用 C 接口,unsafe pointer 是在 Rust 的借用模型、寿命周期外,自己控制程序的行为,毕竟需要调用 C 的接口等等。性能优先,因此零成本抽象、宏等等都是在这个基础上展开的,动态的部分不能说没有,只能说场景很少了。
这两天的心得,大概是这样吧。
写了一个 bitcask 模型的 key/value store ffi_bitcask.lua。利用了 LuaJIT cdata memory layout 来写每一个 record 前面 timestamp、crc、fid、ksize、vsize 部分,然后接着写入 key 及 value,读取的时候,先读取每个 record 的前面固定部分,得到 kszie 及 vsize 后,再读取 key 及 value。
ffi_bitcask 的模型,db 目录下有不同的 bucket 目录,bucket 里面可以有相同的 key,bucket 类似于 namespace。每一个 bucket 只有一个活跃文件,所有的 SET/GET/DELETE 操作都是记录,添加到活跃文件末尾。当活跃文件超出阈值后,重新开启一个新的活跃文件,旧的文件是只读的。
我偷了一个懒,所有的活跃文件写入操作,实际上都有重新打开并添加数据,没有像网上介绍的 bitcask 模型一样,一直保留一个打开的文件指针,也许待后面写入速度影响之后,再做这个修改吧,目前自己使用,吞吐量还没到这个份上。
由于删除操作也是记录,原有的记录空间重复占用,只有在下次整理文件时才能回收。我觉得这里自己的算法不好,总共读取了两遍。因为先要扫描出来,哪些是需要删除的条目,并按照文件 id 分类,之后再做一次读取,过滤掉删除的条目,将其他不受影响的条目拷贝到一个新的活跃文件中。
如果有不少删除操作,而许久没有做回收操作,文件目录占用空间比实际使用要多上许多。我自己还加上了将回收后的文件 id 重新利用的逻辑,文件 id 绝对值的增长不会因此影响。
LuaJIT 的 FFI 定义 cdata,内存 layout 跟 C 是一样的,缺点就是 uint64_t 跟 uint32_t 混用的话,是按照 uint64_t 对齐的,另外 Lua 没有 unsigned number 的说法,如果需要用到这个,需要用到 bit.tobit 函数来做比较了。
目前 ffi_bitcask 还缺少的是 hint 文件,现在还不影响,后面可以考虑加上去。还有就是回收操作,其实可以每次只扫描新产生的文件就可以了,因为删除操作只会在之前扫面过后,新生成的数据文件中产生,不过这里需要记录哪些是新生成的文件,如果文件 id 只朝一个方向增加的话是挺好办的,但像我这样重复利用已经回收后的文件 id 的话,就不好处理了。
后面再看看吧。
找了几个 Object Storage 的方案,理想中的可以是去中心化的方案,然而找不到,找到的要么是 IPFS,要么是链圈的,太高大上了。退而求其次,分布式的的方案,就很多了,各大云商家的对象存储,其实蛮便宜的,还有基于 minio 的,基于 Redis Master-Slave 的方案,及其变种的也算一类,还有豆瓣的 gobeandb,以及 bitraft 和 lf,一个去中心化的 Key/Value Store.
因为有云服务器了,单独又开对象存储不大值得,因为需要存的东西不多,只是希望至少是分布式的,多个保全而已;minio 的方案,在分布式使用到时候,需要占用单独的磁盘,这个只好放弃了;Redis 及其变种 SSDB 之类的,底层 levelDB,或者占用内存多的放弃了,因为读写的频率很低,而且需要跟其他程序一起占用小排量的云服务器;gobeandb 犹犹豫豫很想用,但需要单独使用一个 goproxy 来分流感觉不大值得,接口是 memcached 的,手头也没有立即能用的解析器;bitraft 试用了一下,感觉最为接近,接口是 Redis 的,但是,无法基于不同数据中心做同步,我的两台云服务器位于不同的服务商它居然无法连接,就不说他默认 value 只有 64k 了;lf 一样没有趁手的接口,而且感觉不好配置。
bitraft 是最接近能用的,而且本地验证同步是很不错的,可惜了。
这里衬托了这么久,只是为了说自己最后写了一个,底层存储基于 Tokyo Cabinet,有现成的 Lua 绑定,网络服务接口用的是 rpc_framework 搭建的,同样基于 Redis 协议 lua-resp,同步方案现在很挫的了,一个 master,slave 主动同步 master,启动的时候同步全部 key(也许后面可以优化),然后每隔 30 秒同步一次最新的 SET/DEL 操作;master 这边是每 15 秒区分一个操作 group,操作 group 里面记录所有的 SET/DEL 操作,有 slave 请求,就将 group 时间点及其之后的所有分组 group 操作同步过去;slave 每次都同步上次最新 group 时间点之后的操作数据。
如果 master 这边留了足够多时间跨度的操作 group,后续即便偶尔连不上也是可以保证接下来的数据的;不过目前确实没有考虑 slave 间隔读取不到 master 操作的问题。
为了安全,还加上了 AUTH 命令,简简单单,基本能用了,VALUE 的长度,跟 REDIS 一样是 512M,基本够用了。
Tokyo Cabinet 据说在 2kw 左右的数据量后,会读写缓慢,现在距离这个点还早得很,而且很可能都遇不上。更好的,当然是使用 bitcast 做底层存储,可惜目前也没有方案。
先这样吧,代码就不公开了,单机版在 rpc_framework apps 里做 demo 了。
--
方案找了好久,差不多一周,试用了 bitcast 半天,最后决定用自己的半成品,大概写了 2 天,郁闷的是后面半天时间,每次自己手动启动就正正常常的,但是使用服务推上去就跑不起来,slave 变成了 master。
后来发现,是接受推送,拉起服务的 service,没有使用到包含最新区分不同云服务商的 shell 变量,所以 fork 后拉起来的服务,自然就缺少这个 shell 变量了;手动启动正常,是因为 SSH 进去后每次都读取最新的 bash 配置。
囧啊。