Sucha's Blog ~ Welcome

20年11月9日 周一 16:02

JavaScript(1)

学习了一下 javascript,使用的是 JavaScript 教程,基础的 ES5,包含的内容可太丰富了。

跟其他语言的差别,主要在语法、面向对象,基础库和运行环境这几个部分。

语法

语法部分,'==' 可真够让人头疼的,为了比较会自动转换类型,所以教程建议直接使用 ‘===’ 进行严格比较。

相对高级的语言,都有 << 和 >>,甚至 >>> 操作符,可惜了,Lua 5.1 比如 LuaJIT 并没有这么好用的操作符,只能通过 bit 库来使用,或者塞给 C 代码处理吧。

JS 里面的 null 和 undefined 也得注意一下,它们几乎是一样的,除了 null 在 Number(null) 时为 0,而 Number(undefined) 为 NaN。

面向对象

这里一开始让我挺头疼的,比如原型对象及继承关系。

对比 C++/Java 的面向对象实现都隐藏在语言核心运行时里面,通过语法‘类’来实现继承,在类里面写构造函数,描述继承关系。且 C++ 因为是零成本抽象,极端强调运行时性能,继承、面向对象的逻辑在编译期几乎都解决了,只留下了动态派发给运行时处理。Java 有反射,任何时候都能拿到其继承关系,其所有的类都继承自 Object。两者作为静态语言,在运行时无法动态修改继承关系。

JS 虽然跟 Java 一样所有类都继承自 Object,但其继承的语言特性,是通过原型对象来实现的,运行时可以访问。原型对象就是类的 prototype 属性,构造函数则是 prototype.constructor,因为有 new 关键字的存在,定义一个新类,只需要定义构造函数就好了,比如

function Car(color) {
    this.color = color;
    console.log("color:" + color);
}

var car = new Car("white");

会输出 'color:white"。如果定义 Bus 继承 Car 是这么实现的:

function Bus(color) {
    Car.call(this, color);
}
Bus.prototype = Object.create(Car.prototype)
Bus.prototype.constructor = Bus

var bus = new Bus("red")

将输出 'color:red',如果需要在运行时修改类的继承关系,propotype 和 prototype.constructor 必须同时修改。

感觉原型对象这个词,在继承关系上,跟 Objective-C 的类对象有一点像,不过实际表现及使用,千差万别。

对比 Lua 实现面向对象,感觉还是有一点像的,不过 Lua 里面实现面向对象,继承关系,需要了解的可太多了,需要了解 Lua 的元表(metatable)才好。

基础库和运行环境

作为一个动态脚本语言,JS 的基础库和运行环境挺丰富的,作为 Web 端统治性的语言,运行环境包含对 HTML DOM 的操作,以及浏览器环境,这两个部分我还没怎么看呢。

作为后端语言,基于 Node.js 事件驱动、非阻塞式 I/O 模型,极大降低了后端开发的难度,不过 Node 下的运行环境,我还没怎么接触。

虽然 JS 是单线程的 VM,但是 HTML 5 及 Node 都实践了其多线程交互的方式,也有了不同领域下的解决方案,其成熟程度,丰富程度,对比 Lua 可要好太多了。

引擎部分,标准桌面、后端用的是 Chrome V8,嵌入式应该用的是其他方案。现在已经有了前后兼容的 ES5、ES6 标准,貌似 ES7 也快要出来了。

--

总的来说,JS 是一门比较完善,还在不断渐进式发展,且考虑兼容性的编程语言,慢慢的将变成桌面应用编程语言,部分后端编程语言,可不再是脚本语言了。

相比 Go,JS 是动态类型,也不是静态编译的;相比 Lua,语言成熟度,库的丰富程度,应用范围都好好很多,没有 Lua 目的是宿主语言的累赘,虽然 Lua 还有速度上的优势。

而且,学会 JS 的好处可是,前端通吃,后端独立。

20年11月5日 周四 09:49

go_bitcask

学习了 Go 后要使用,实践的第一个程序是 go_bitcask。其逻辑大部分源于 ffi_bitcask.lua

但在 Go 这个版本中,因为一直开关读写文件效率比较低,所以做了一层抽象优化,将文件的读写抽象到了 DataFile 里面,其中,读的部分使用了 mmap,写的时候只保留一个活跃文件,一直追加写,也兼顾当前活跃文件的读,待到关闭数据库的时候才做 Sync(),并关闭文件。

在我的 16 年 MacBook Pro 版本 10.15.7 中,Go 1.13.4 读写文件极慢,LuaJIT FFI 版本用了不到 0.2 秒完成的测试,在 Go 这边需要 2 秒,太夸张了。

试了一下在云端 Linux 上面,效果好了很多,大概需要 0.2 秒,但 LuaJIT FFI 版本只需要 0.02 秒

LuaJIT FFI 版本的 bitcask

$ time luajit test/test_bitcask.lua
PASS Bucket
PASS Set/Get
PASS Delete
PASS GC

real    0m0.021s
user    0m0.011s
sys     0m0.008s

Go 版本我编译后才测试的

$ time ./test
using database: {Path:/tmp/go_bitcask BucketName:0 DataFileSize:512}
Pass Bucket
PASS Set/Get
PASS Delete
PASS GC

real    0m0.234s
user    0m0.004s
sys     0m0.030s

也许 Go 启动准备需要不少时间吧,或者是我实现的问题。

在 Go 版本中,我同样是使用了 struct map 到内存的方式来表示一条 record,然后一次性读取 key 及 value,再 slice 切割为 key 和 value。

映射的代码如下,感觉也没什么呀

// map recordinfo to bytes
func recordInfoToBytes(ri *recordInfo) []byte {
        var x reflect.SliceHeader
        x.Len = int(recordSize)
        x.Cap = int(recordSize)
        x.Data = uintptr(unsafe.Pointer(ri))
        retBytes := *(*[]byte)(unsafe.Pointer(&ri))
        return retBytes[:recordSize]
}

// map byte to recordinfo
func bytesToRecordInfo(b []byte) *recordInfo {
        return (*recordInfo)(unsafe.Pointer((*reflect.SliceHeader)(unsafe.Pointer(&b)).Data))
}

也许完全运行起来以后,Go 的速度才会又继续上去吧,我 pprof 的结果,是 system call 占用时间最多,其中 Set 是占用很多的,其次是 GC,但是 GC 不常用,这个倒没什么关系。

Set 占用多,感觉说不过去呀,因为就检测如果有重复 key,做了 remove,然后再 set 新的 key/value,两次 system call write 函数。

也许 Go 版本真的应该做完整一些,将写的操作单独一个 goroutine,并且做 LSM 操作,将读写都 encode/decode 起来,再做一层抽象才好吧,但是目前这个算是简单版本,不打算再做这一层了。

20年11月4日 周三 10:32

Go(1)

又开始学习 Go,之前其实断断续续看过一些,因为没有实践过,也陆续淡忘了不少,现在重新拾起,教程用的是 Go 语言官方教程中文版以及另外一本(彻底贯彻学完就扔的习惯现在找不到网址了),后面因为反复查询标准库,用了很多 Golang 标准库文档

Go 是一门命令式的语言,入门曲线比较低,比如非面向对象,简单的控制流、少了继承、枚举等等,变量声明也简单明了;但是要掌握我觉得不算简单,比如 goroutine 以及 CSP 数据交换的并发模型,slice、反射等,因此学习起来有一个曲线。

Go 有不少必知必会的语言规定,比如变量名首字母大写才会在包里面导出,变量需要驼峰命名,带间隔的下划线是不被推荐的(VSCode 里面的 golints 有对我多次提示啦)。相比 C/C++、Java、Lua 等,我觉得变量声明、defer、slice、接口、并发模型、包管理系统,是 Go 的特色,可以拣出来说一说。

变量声明

这个知识点很简单了,可以不用指明变量类型,让系统推断,比如

a := 10

或者指明类型

var a uint32 = 10

上面的两种是有区别的,第一种 Go 默认是 int 类型,下面指定了 uint32 类型,Go 是强类型语言,变量的后续使用必须对齐类型,否则编译报错。当然可以这样写 a := uint32(10),成功用第一种方式声明了 uint32 类型。类型转换还有下面这样的

var b interface{} = 10
var c interface{} = "hello"
var d string = c.(string)
fmt.Println(b, c, d)

可以看到,虽然 Go 没有 C/C++ 的 void*,但可以用万能的接口 interface{} 弥补,interface{} 还可以在运行期用来做类型推断,这里不细说了。

Go 里面方便的地方,if 控制块一开始就可以定义新的变量在控制块内使用,比如下面判断文件是否打开成功的例子:

if fp, err := os.Open("/path/to/file"); err != nil {
    defer fp.Close()
    // ...
} else {
    return err
}

上面的 fp、err 只能在 if 及 else 块内使用,出了 if、else 的控制范围,编译会报错。

以上,方便的变量定义,算是 go 的特色了,其方便程度,跟 Lua 有点像,但是 Lua 默认是全局可见的,Go 有控制块的限定,包内控制块之外必须首字母大写才能导出。对于大型系统,我觉得 Go 的定义更稳妥一些。

defer

上面的例子已经提前用了 defer,比如在文件打开成功后,defer 了一个关闭函数,当 if 语句所在函数返回后,defer 按照先进后出的原则退栈运行。

defer 运行的只能是函数,所以如果是一些变量需要离开函数后保存,可以下面这样

stepRecorder = "enter function"
defer func(){
    stepRecorder = "leave function"
}()
// ...

defer 写得太爽了之后,遇到了习惯的反面,比如在 for 循环里面打开了文件,希望下个循环关闭,就老想着能够有这样一个关键字,在当前循环结束前运行这个关键字后面带的函数,因为在复杂的 for 里面,有 return,还有 continue,还有正常的流程。

然而 Go 没有这样的关键字,所以很遗憾的老老实实按老路子写代码了。

slice

Go 里面 array 跟 slice 有紧密的联系,我总感觉 array 是 slice 的特例,是一个预定义的 slice,举个例子:

a := []int{1, 2, 3, 4}
b := a[:2]
fmt.Println(a, b)

打印出来的是下面这样的

[1 2 3 4] [1 2]

可以看到其实两者底层是一致的,只不过 array 固定了 slice 的 len 和 cap,slice 的切割是左闭右开的区间 [),类似 for 定义的 for i := 0; i < 5; i++ 的一个语言习惯吧。

slice 可以使用 append 扩容,比如

b = append(a, 5)

结果是输出

[1 2 3 4 5]

slice 使用 make([]int, 5) 等方式定义,虽然用着像数组,但实际上可变长度的设计跟 C++ 里面的 vector 有点像了,由于 Go 没有 C++ STL 的 vector,slice 就是这样大量使用了。

不过我总觉得,虽然底层的抽象是这样,但是用着确实不方便,使用者得很熟悉底层的抽象、分割才行。

接口

Go 没有继承,没有 Java 这样 Object 的基类,也就没有面向对象的基础。其语言设计者认为面向对象不是必要的,特别是深层级的继承,过于复杂了。其推荐通过接口抽象,所有实现了接口定义函数的 struct 类型,认为具有接口的功能,是接口抽象的代表。在实现上 struct 没有特别的关键字来声明实现了某些接口,编译期、运行时核心是知道的,提前匹配了。

比如我们定义一个接口,并写某个 struct 实现接口

type Dog interface {
    Name() string
}

type BullDog struct {}
func (dog *BullDog) Name() string {
    return "BullDog"
}

var dog Dog = nil
dog = &BullDog{}
fmt.Println("dog is", dog.Name())

以上,斗牛犬 BullDog 实现了 Dog 的 Name() 接口,没有声明实现的关键字,只需要实际实现了这个接口,就可以编译运行通过。

通过上面的定义,对比 Java 的接口、抽象基类定义,可以想像,由于 Go 没有继承,以及 interface 的默认实现或者说抽象基类的东西,也没有函数重载,如果在 Go 里面定义了复杂的接口,或者某些特性通过接口暴露但只有很少量的实现需要特别处理,都是一件头疼的事情吧。

不过相反的,在稍微大型一点的代码里,应该可以新定义调整一个接口来区分不同的类型实现吧。

并发模型

Go 的并发模型是 goroutine,定义了函数后,通过命令字 go 来驱动,其通讯模式,用的是 CSP,通过管道(channel)通讯,而不鼓励通过共享变量通讯。

C 抽象了体系的基本寄存器,C++ 是个牛刀,多模式的语言,但在早期的语言规范里面,没有多线程的定义,我查了一下,直到 C++11 才有语言提供的多线程,Java 的多线程语言内置支持,但是 C/C++/Java 的多线程实现,可以看成都是 pthread 的封装,一个语言线程,对应操作系统的一个线程。

但在 Go 里面不是这样的,Go 的线程独立于操作系统的线程,是协程,但调度是 Go 核心在用户空间实现的,虽然用起来跟线程类似。goroutine 的通讯、锁,官方的例子都是建议通过 channel 传递数据以及控制,但实际上不是什么都适合在 channel 中传递,有时候传递行为的控制反而是复杂的,所以也是有锁的,得合理区分场景使用,且这个时候的锁也是得考虑死锁的。

由于没有很深入的使用,这里就不展开了(没实料了)。

对比一下 Lua 的协程,Lua 是单线程模型,因为 Lua 语言本身的设计就是一个宿主语言,最开始的设计是靠着 C,而 C 的语言核心没有定义线程的,这也导致了 Lua 协程的设计没有考虑多线程。网上 Lua 多线程的模式,其实就是 C 多线程的模式,一个 C pthread 线程,带一个 Lua VM,通过 CSP 或者共享内存通讯。

而 Go 直接抽象了协程、CSP 这套协程、多线程通讯机制。

包管理系统

Go 的包管理系统我有看过浅显的资料,区分不同发展阶段,有使用 vendor 目录带支持包,或者新版本 go mod 的阶段共享支持包。

Go 使用关键字 package 定义包,包里面首字母大写导出函数或者变量,这是语言的强制约定,减少了关键字,另外 main 函数是打包编译为二进制后的运行入口。

C/C++ 是没有明确包管理的,只有系统层级的二进制库,以及编译、链接顺序。理好 include,然后依赖不同的 build tool 来构建,比如大名鼎鼎的 make,或者 Nijia,一般不会生成静态链接的二进制,当然也可以生成,只是真的很少见,由于可以控制编译期的任何一个阶段输出,在嵌入式应用可好了,输出动态链接,还可以各种裁剪,但相反就是要求高了不少,得自己学会搞定编译运行期的所有问题。

Java 语言的定义也是不带包管理工具的,但工程实践是有 mavel 或者 gradle,我不熟悉,就不展开了。

Lua 也没有包管理,不过默认的操作系统文件夹,都可以是一个包层级,然后命令行可以指定在哪里寻找包,以及动态库。

话说 Rust 也有包管理,但我没有深入具体跟仓库是怎么结合的。

我只用过 go mod 的包管理,go mod 命令行初始化后,感觉后面就很简单了。再说 IDE 也是强,VSCode 配置好后,任何不需要的包,都无法导入,而实际用了标准库的包,IDE 都自动帮忙导入到 import 里面。

感觉强在 github 等依赖外部仓库的包,go mod 看到有用到,运行 tidy 命令后,可以帮忙直接下载,这里并不依赖中央仓库的,算是一个特色,毕竟类似 CocoaPod 也是有中央仓库的,或者起码得指定私有仓库的地址才行。

编译成静态链接的的好处,是可以无视系统版本了,对于大型的程序,增大的这点空间不算什么,毕竟对比大量异构部署来说,极大减轻了部署的困难,俗称统一交付。估计谷歌内部对于 C/C++ 可以输出静态链接的版本,早就这么做了,估计不这么做的话,那么多个二进制输出版本,debug 也有困难吧。

从包管理,可以看到交付的逻辑,当然也注定了 Go 不会应用到嵌入式里面。除非能出一个非静态链接的版本,但是 Go 的核心运行时,能够单独出供动态链接的二进制版本吗,感觉往这个方向的估计都没考虑了。

有脚手架的语言,Rust、Go 是很方便的,有统一的脚手架,也规范了交付的正统逻辑。

--

用 Go 写了一点小东西,所以有了上面的一些思考,可惜是还没有用上 goroutine,那就先输出这么多吧。

最后说一下内存占用,C/C++ <- Lua <- Go <- Java,Lua 不是正统的非宿主语言,这里有加入 VM 对比的考虑,一般的评测,都是认为 Go 是要比 Java 快的,再说 JVM 基本占用内存就很大了,如果不是大型程序,用 JVM 的成本会比较高。

但是 Java 多年来的基础建设都很好,不管是 JVM 还是 IDE,我用过最好的 IDE 就是 Java 的,太省心了。但是另一方面,Java 的交付、部署应该是没有 Go 的简单,至少也是有 JVM 版本的,而 Go 真的就是统一交付了。

Go 席卷了互联网,Docker 及云的基础建设就是明证,Java 瓜分了剩下的很大一块,接着是 Python 的简易运维,C++ 是核心中的核心,不晓得这个分布是不是当前占有率的反映。

20年10月26日 周一 22:48

《在故宫寻找苏东坡》

一直很喜欢苏东坡的词,不记得在哪里看到了推荐,就买了这本书。

这本书随着苏东坡的一生展开,考取功名、入仕、被贬,然后其诗词的变化,心境的变化,所写词在当时心境的解读等等。

考取功名、入仕在前面几章,不表。

被贬是因为文字狱,经历了生死,但苏东坡并未因此颓废不堪,像陶渊明一样淡泊修仙,远离尘世,相反他一直在入世,只是不再朝中爭功名了(精神小伙爭儒家正统)。

被贬到黄州后,俸禄不够养活全家,不得不把自己当成农民,在县城东边买了一块地自给自足,并自称东坡居士。种植、收成、酿酒,看到黄州人民不爱吃猪肉,猪肉很便宜,又自创了东坡肉。这一切充满了泥土间的汗水、快乐,满是人世的酸甜,虽然消瘦了许多,但却是脚踏实地的。

随着王安石新政停摆(老皇帝驾崩),新皇帝又将苏东坡等老臣请上了朝,但是快乐很短暂,很快又因为写诗词的影响被贬。

即便如此,短暂的就职,也给杭州留下了疏通后的西湖,淤泥堆成了白堤。然后是一路向南的贬职,最远到了海南岛。当时海南岛都还是原住民,以当时的条件,过了海就当成是半个死囚了,当时那边连书都没有,这是他最痛苦的地方。

书籍里面对于苏东坡诗词画的介绍,结合当时环境、心境的分析太细腻了,我表达不出来,对比在其时代之前相同的主题,认为其自创了一派,在表达,艺术以及美的结合上,大大拓展了延伸的范围。

最悲哀的是,作者认为诗、词的顶峰在千年以前,自从苏东坡之后,就没有诗、词的达人了,至少没有那种高度的。

20年10月21日 周三 20:51

ffi_gccload 在 Lua 中动态加载 C Source

不晓得从哪里搜到了这个库 lua-tcc,可以在 Lua 运行期间,通过 one pass 的 TCC 动态加载 C 代码,让 Lua 的控制可以更深入底层,甚至可以在 Lua 中进行 C 的编程。

可惜这个项目距离久远,TCC 在 MacOS 下面编译也成问题,意味着通用性欠佳,挺可惜的。不过有了这个想法后,想着不通过 TCC,用 GCC、Clang 其实也可以的嘛,于是就有了 ffi_gccload

流程变成了这样,将 C Source String 导入到一个源文件里面,通过 GCC 编译得到动态库,使用 ffi.load() 加载进来,并通过 ffi.cdef 声明接口,就可以使用了。

其实就是将 C 的编译、链接过程给固定了就好,如下的代码:

local config = require("ffi_gccload").new()

-- like ffi.cdef
config:addSourceDef([[
    int print_name(void);
    int add_num(int a, int b); 
]])
local p = config:loadSourceString([[<a id="include"></a>
 <stdio.h>
int print_name(void) {
    printf("Hello, world\n");
    return 0;
}
int add_num(int a, int b) {
    return a * 2 + b;
}
]])
if p then
    p.print_name()
    local a, b = ...
    a, b = a and tonumber(a) or 0, b and tonumber(b) or 0
    print("result", p.add_num(a, b))
else
    print("failed to load")
end

生成一个动态编译配置,会输出

$ lua test.lua 2 3
Hello, world
result  7

其实是将编译产生的中间 .so 放在临时目录,加载后其实就可以删除掉这个 so 了,这在 MacOS 以及 Linux 应该都是可以的。

上面生成的动态编译配置,其实可以加入 include、libary 的 path 以及 lib name,已经越来越像一个 build 系统了,其实就是类似的。

有了上面这套,虽然相比 TCC 速度慢一点(毕竟 TCC 是 one pass 就出成果的),但是胜在兼容性号,修改一下后,估计 Windows 下面也是能跑的。

往后可以在服务端拿到 C 代码,捣鼓以下就能用到客户端上面了,或者一些少量使用 C 操作更方面的逻辑,结合放在 Lua 中作为源代码发布出去,使用的时候,先编译加载动态库,也是一个方案。

20年10月11日 周二 02:12

DNS service 重构

将 rpc_framework 中 service_dns 网络层的控制从 C 层移到了 Lua 层,C 层只负责 UDP 包的数据拼接、解包,将代码统一到了 m_dnsutils 里。

在减少了大量 C 跟 Lua 的交互后,两者算是各自做了自己擅长的方面,自测后的效果也不错。

20年9月29日 周一 18:25

Rust(1)

开始学习 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) 在其实也是模式匹配,是可以接受匹配失败的。

None

跟很多语言不同,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) 包裹的值。

trait

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];
<a id="[macro_export]"></a>

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 的接口等等。性能优先,因此零成本抽象、宏等等都是在这个基础上展开的,动态的部分不能说没有,只能说场景很少了。

这两天的心得,大概是这样吧。

20年9月26日 周六 23:07

ffi_bitcask.lua

写了一个 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 的话,就不好处理了。

后面再看看吧。

20年9月19日 周六 23:57

Object Storage 和 Shell 环境变量

找了几个 Object Storage 的方案,理想中的可以是去中心化的方案,然而找不到,找到的要么是 IPFS,要么是链圈的,太高大上了。退而求其次,分布式的的方案,就很多了,各大云商家的对象存储,其实蛮便宜的,还有基于 minio 的,基于 Redis Master-Slave 的方案,及其变种的也算一类,还有豆瓣的 gobeandb,以及 bitraftlf,一个去中心化的 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 配置。

囧啊。

20年8月17日 周一 23:32

LuaJIT 下的双链表及 AVL 树

Lua 描述结构数据方面,只有一种内建结构,就是 table,table 既是 array,又是 hashmap,算是简洁高效的实现,但我们需要一种动态表示元素先后、大小关系的数据结构时,内建的 table 就不合适了,比如常见的 fifobinaryheap

先说优点,上面两个都是使用 table 来描述先后顺序、大小关系的,缺点是,当有插入、删除发生时,时间效率太慢了。其中 fifo 是使用 table 的 array 实现的,需要在删除元素后,对其他的元素做拷贝迁移,避免空位;而 bianryheap,也是使用使用 table 的 array,当元素位置变动时,采用了冒泡算法,对元素重新排位。

空间效率是保住了,但就时间效率来说,太不专业了,O(N^2) 的实现,数据量稍微多一点点,时间不能看。

其实 fifo 算是一种双链表,只是插入是在头尾,算是双链表的特例。双链表下,非头尾的删除发生,时间效率是 O(1) 级别的。其实可以用 table 来描述链表关系,缺点是空间消耗大,为了描述两个相邻元素间的先后顺序,就得用一个 table 实例来做。

我用 Lua 内建的 table 实做了一把,时间效率是很高效的,空间的话,插入 number,10,000,000 级别的插入,内存大概 6G。

-- performance
push 1000,000, cost 0.393088
pop  1000,000, cost 0.092984

在 C 的双链表实现中,需要将链表节点跟数据结构绑定起来,要么是以链表节点为主,链表节点指向数据结构,或者像 Linux Kernel 里面规范化的双链表实现,链表节点实例,放在数据结构中,通过偏移定位数据起点。

但是在 Lua 里面做不到这样,不得不使用 value(值)到 node(节点),以及 node 到 value 的两个 table 来做关系映射,再加上相邻两个 value 的顺序关系使用一个 node(table)来实现,也是空间消耗大的原因。

LuaJIT 下 cdata 的妙用

LuaJIT 因为有 FFI,可以创建结构化的 cdata 来描述相邻 node 之间的关系,而不需要使用内建的 table,使得可以花费更少的空间代价,比如下面这样来描述双链表的节点关系:

ffi.cdef [[
struct _cnode {
    struct _cnode *prev;
    struct _cnode *next;
    float key;
};
struct _chead {
    struct _cnode *head;
    struct _cnode *tail;
};
void* calloc(size_t count, size_t size);
void free(void *ptr);
]]

特别注明 cdata 是 calloc 出来的,不在 gc 管理范围,因为 C 下面的指针,需要保证指向固定的内存地址。其他跟纯 table 实现的双链表一样,需要两个 table 来维护 value 跟 node之间的映射关系,如上面 struct _cnode 下的 key,其实是用于 node 到 value 的 table 的映射 key,原因下面再说。

同样的 AVL 树也可以使用 cdata 来维护节点间的大小关系:

ffi.cdef [[
struct _avl_node {
    struct _avl_node *left;
    struct _avl_node *right;
    struct _avl_node *parent;
    int height;
    float key;
};
struct _avl_root {
    struct _avl_node *node;
};
void* calloc(size_t count, size_t size);
void free(void *ptr);
]]

以上使用 cdata 描述元素关系时间效率跟纯 table 时间效率一样,空间效率的话,第一个例子下,内存占用不到 4G,地址 ffi_list.luaffi_avl.lua

一些问题

LuaJIT 下使用 cdata 描述节点关系,遇到了一些问题:

上面的问题,估计还是要设计一些测试用例来定位一下才好,现在先这样吧。

后记

19 号解决了上面遇到的 2 个问题,原因在于 ffi.cdef 使用的 key 宽度是 float,但是 LuaJIT number 的宽度是 double 导致的,我修改 ffi.cdef node 里面的 key 为 double 类型,问题不再出现。

不过我也有疑问,为何 AVL 这边同样的实现,没有暴露出来问题呢。

反正现在修改为多轮循环 test 验证了,验证了几千遍,每次是 1000x1000 次的 push、pop,都是正常的了。

20年8月02日 周日 20:50

做了一个 web 框架:cincau

离职后在家呆了一个月,前面两周在休息,后面两周开始忙起来,想着将手头的数据搭一个可视化的网站出来,可以学习一下 web 前端的库之类的,但是呢,想找一个能 sqlite 和 nginx 的 web framework,找到一个感觉还可以的 Lor,但是呢,不支持 sqlite,感觉不算是一个 minimalist 的 web framework,就想着不如自己搭建一个吧。

可是如果搭建,顺便带上自己的 mnet 是肯定的了,当然 nginx 也是要支持的。于是参考了 lor 的不少东西,但更多的其实是自己摸索的,因为时间也比较充足,是先考虑了架构,才开始动手的。

没有浪费之前买的 goodnote 5,用来构思框架了,画了大概 5 个页面,大的模块上,跟 lor 一样,分为基础库,以及具体的项目代码。基础库在 /usr/local/cincau,用来生成基本的 demo 项目,本身包含了所有 proj 用到的的基础库。对上面的简单业务逻辑,封装 engine 层,不区分为 mnet 或者是 nginx,但实际上这两者一定是要区分开的,除非是静态的文件。

基础库里面,细分为 engine 层、router、MVC 逻辑、database、POST 的解析、HTTP Request 这几个部分,其中 MVC 中 view 的 rendering 用了 leafo 的 etlua,这其实是基于 openresty 的 lapis 的一个模版库。database 不出意外,我只考虑了 sqlite3,就是 minimalist 的 web framework,等有需要,在考虑 mysql、postgresql 之类的,毕竟这两者,没有基于 mnet 的接口,只有基于 nginx 的。

在 goodnotes 5 上面花了 3、4 天这样,其实上面这么多细节,都是在完成整个项目后,回头才细化出来的。在草图出来以后,框架搭建花了大概一周,mnet 是基于 ffi_mnet.lua 以及 hyperparser 的回调,拿到 http 数据结构层。而 nginx 是 content_by_lua_file 来驱动的。

然后还继续花了 3、4 天来完善框架细节,比如 POST 以及 HTTP request 的那一套,以及基于这个框架,写了一个 demo project,支持 ment、nginx,包含静态页面、mock 的 model,以及 post x-www-urlencoded 和 multipart/form-data 这几个部分。

该放地址了:cincau,话说之前也断断续续做过半个 web framework,现在终于点连成了线,水到渠成了。

goodnote 5 画出来的框架图: