在最近的一个客户项目上,为了做性能优化,我们花了大量的时间,然而最终结果还是不够理想。我们的场景是实现特征处理过程和机器学习模型线上推理服务。由于用户量巨大,我们需要做到2万的TPS,每个请求需要在30ms内返回,且每个请求中包括对1000个项目的处理过程。
我们所使用的技术栈是spring
和grpc
。在经过极致的代码优化及内存调优之后,运行在一台32GB
内存64核
CPU的服务器上,我们发现90%
的请求可以在25ms
完成。但是如果观察99%
的分位线时,响应时间就下降到了70ms
,有时候还可能超过100ms
。
为什么会出现上面这么明显的波动呢?问题出在java
的gc
上。其实对于gc
,我们已经非常仔细的做过调优了,整个过程没有full gc
的发生。然而,在持续的压力测试下,java
的young gc
却在频繁的工作。由于处理的数据量过大,新生代的gc
几乎每秒都会触发一次,每次释放5GB
内存,耗时30ms
左后。
由于要服务于线上上亿的用户群,这样的性能还是不够理想,难以直接交付给客户使用。
java
语言发展了这么长时间,其性能还是能为人们所认可的。在一些性能测试上面,java
几乎可以媲美c++
的计算性能,比如这里就有一个这样的测试。然而内存管理却一直是java
语言一个挑战,即便java
已经有相对非常成熟的gc
算法了。在编写极致性能需求的服务端程序时,java
由于其本身的性能波动,似乎还是难以胜任。
究竟要如何做高性能的内存管理呢?难道非得像c/c++
一样的手动去管理内存吗?
当前流行内存管理机制
我们先看看当前流行的编程语言所采用的内存管理机制。以我所接触过的,使用相对广泛的编程语言为例,可以整理如下:
c
: 手动进行内存的申请和释放c++
: 通过delete
指针,在析构函数中手动释放内存java
及各种基于java
的语言: 适时分代gc
,多种垃圾回收算法python
: 引用计数,自动触发适时分代回收obj c / swift
: 引用计数或自动引用计数回收,编译期插入引用计数代码go
: 适时分代gc,并行标记清除模式javascript(v8)
: 适时分代gc,并行标记清除模式php
: 以引用计数为基础,适时触发gc
lua
: 标记清除模式
可以看到,当前的自动内存管理机制以 引用计数 和 分层并行标记清除 为主。
如果内存释放及时,引用计数机制对于程序运行时性能影响会比较小。但每一个对象都需要分配额外的内存去跟踪引用数量,这带来了额外的内存占用。如果没有自动引用计数的机制,在编写代码时,手动管理计数会带来不小的额外负担,内存的及时释放取决于引用计数代码的正确性。
而对于标记清除式的内存管理,由于其不可避免的会带来程序暂停,且并行标记还需要占用cpu时间,会对程序性能产生较大影响。
我们的眼光投向了rust
。rust
是一门没有gc
也不使用引用计数(不以此为主)进行内存管理的语言。那它究竟是怎样管理内存呢?它还能让我们像写java
代码一样流畅吗?
内存管理的新思路
回顾一下日常编写代码的过程,在实现某一个函数时,有这样几个要素:入参 函数体 返回值。比如,假设我们有下面这个计算字符串长度的函数:
1 | fn func() { |
考虑内存释放这件事,我们会希望:
- 对于函数
func
中的变量s1
,由于我们将其作为参数转交给了函数calculate_length
,在后续的执行过程,我们就不再希望关心它对应的字符串内存了。 - 对于
calculate_length
函数中的入参s
,由于其作为函数值返回,我们不希望在离开函数时释放其对应的内存。 - 对于
calculate_length
函数计算得到的length
变量,我们同样不希望在离开函数时释放内存,因为它也是一个返回值。 - 对于
func
从calculate_length
函数得到的变量s2
和len
,我们希望在func
函数离开的时候立即释放。
上述虽然只是一些很简单的想法,但这里面似乎隐藏着一些规律,我们能不能更进一步呢?甚至,我们是不是可以以此规律去设计一种新的内存管理方式呢?事实上,稍加抽象可以得到:
- 变量可以属于某个函数,即其所有权为某个函数
- 在函数结束的时候,回收函数所持有的所有变量的内存
- 在发生子函数调用时,如果我们传入某一变量,该变量的所有权将移交到子函数中去
- 在子函数调用返回时,如果有返回一些变量,则这些变量的所有权将回交给当前函数
这就是rust
的内存管理基础。初次接触这种全新的内存管理方式时,不禁让人觉得眼前一亮。这种方式看上去自然而高效,且根本无需独立的垃圾回收器。
在rust
中,我们实际上讨论的是更细致的值(内存)的所有权问题,但基本的观点与上述几点相似。更为严格的,rust
中定义了如下几条关于值(内存)的所有权的规则:
- 每一个值均存在一个对应的变量,该变量是这个值的所有者
- 同一时间每个值只能有一个所有者变量存在
- 当所有者变量离开当前可访问的代码范围(可以是一个函数,或一个由括号
{}
定义的一个范围等)时,该值对应的内存将会被释放 - 变量作为函数参数值传递时,值的所有权将移交到函数参数对应的变量中
- 函数返回一个变量时,该变量对应的值的所有权将回到上层函数对应的变量中
延伸
实际上,我们在编写代码时,会碰到比上述场景复杂得多的场景。那么这几条原则是否还奏效呢?
其中一个我们很快会碰到的问题就是,由于存在所有权移交,我们可能需要每次函数调用都返回一些额外的值。如果每个函数都这么写,那可能是一场灾难。因为这将带来不够清晰的函数定义,而这种不清晰将侵入到整个代码库里面去。
rust
是如何解决这个问题的呢?这就是rust
中的引用和借用的机制。比如我们可以编写如下的代码:
1 | fn func() { |
通过在变量前面增加一个&
符号,我们得到了一个称为 变量引用 的东西。对于变量引用而言,其对应的值的所有权并不会并发生移交。在函数调用时,我们可以仅仅将某一值的引用传递过去,这个时候发生的事情,称为借用。这里可以类比我们日常生活中借用别人的东西的场景,借用完之后,再归还给他。
另一个不难想到的问题是:如何去按照面向对象的方式组织数据?事实上,关于值的所有权定义也可以用来解决这个问题。在前面的讨论中,我们以函数作用范围举例,但这里的范围其实可以不只是函数,也可以是某个对象(或结构体,rust
中成为struct
)。也就是说,我们可以将某一个值作为另一个对象的一部分绑定到该对象上去,这样该值的所有者就变成了这个对象。这个对象本身可以作为一个值绑定到某一个变量中去,从而构成了一个完整的闭环。
还有一个问题,可能会成为rust
的难题,即需要共享某一个值的场景。这时,我们需要让一个值同时属于多个所有者。这跟我们前面提到的rust
的单一所有者原则相悖。在这样的场景下,rust
定义了一个称为Rc
的结构体,它存储的是这个对象的引用及一个引用计数。Rc
实际上就是reference count
的缩写。这里的内存管理其实就退化为引用计数式的内存管理。
我们还可以提出更多的问题,比如:多线程的情况下,值(内存)的所有权要怎么变化?如何实现线程同步的锁机制?如何处理循环引用问题?事实上,诸如此类的问题已经被rust
的社区及其编写者们思考并实践了多年。rust
所特有的所有权特性为解决这些问题提供了全新的思路,大部分问题也都被优雅的解决了。
总结
诞生于Mozilla
社区的rust
程序设计语言为我们带来了全新的内存管理思路。近几年来,rust
语言发展迅速,由于其在性能和稳定性可以超越c/c++
,在易用性上不输java
等高级语言,在高性能服务器端开发领域rust
已开始崭露头角。
rust
是否可以解决我们的线上特征处理和模型推理的极致性能需求呢?我们正在尝试过程中,同时满怀期待。
参考: