1

前几天去东莞的路上看电子书《深入理解Linux内核》,里面讲到Linux虚拟内存动态分配技术,联想到学习工作终涉及到的一些经历,特写此文。(因我水平有限,难免有错误之处,欢迎大佬斧正~)

1.惰性思想

我想大家在学习工作中,一定或多或少有Deadline的经历吧,假期作业留到最后快开学了才写,衣服拖到最后没衣服换了才洗,这样做好不好呢?其实还行,一般Deadline期间写作业洗衣服效率挺高的,而换一个思路来说,在写作业洗衣服之前,你都是空闲的,可以尽情做别的事情的,程序设计里面,我理解的惰性思想其实跟Deadline很像,资源分配推迟到不能再推迟为止

2. Linux内存动态分配里的惰性思想

2.1 请求调页

术语"请求调页"指的是把页框(虚拟内存调度单位)的分配推迟到不能再推迟为止,也就是说,一直推迟到进程要访问的页不在RAM中时为止,因此引起一个缺页异常。

请求调页技术背后的动机是:进程开始运行时并不访问其地址空间中的全部地址;事实上,有一部分地址也许永远不会被进程使用。此外,程序的局部性原理保证了在程序执行的每个阶段,真正引用的进程页只有一小部分,因此临时用不着的页所在的页框可以由其他进程来使用。这样增加了系统中的空闲页框的平均数,从而更好的利用空闲内存。从另一个观点来看,在RAM总数保持不变的情况下,请求调页从总体上使系统有更大的吞吐量。

2.2 写时复制(也就是大名鼎鼎的Copy On Write)

第一代Unix系统实现了一种傻瓜式的进程创建:当fork()子进程时候,内核原样复制父进程的整个地址空间并把复制的那一份分配给子进程。而子进程有很多东西和父进程一样且不发生修改的,没必要完全复制,这种做法既浪费很多内存空间,又消耗许多CPU周期。

现在的Unix内核(包括Linux)采用一种更为有效的方法,称为写时复制,也就是大名鼎鼎的Copy On Write。这种思想相当简单:父进程和子进程共享页框,只是不能修改,当修改的时候,就产生异常,这时内核再把这个页复制到一个新的页框中并标记为可写,原来的页框仍然是写保护的。当其他进程试图写入时,内核检查写进程是否是这个页框的唯一属主,如果是,就把这个页框标记为对这个进程可写。

其中,页描述符的_count字段用于跟踪共享当前页框的进程数目,为1时说明跟其他1个进程共享,为0时说明只有一个进程可写,为-1时说明需要被释放。

3. 举几门语言的例子

3.1 JS语言反面教材

掘金上的前端开发同学比较多,必然很了解JS的深浅拷贝问题,问烂了的面试题了

let a = {a:1};
let b = a;
let c = Object.assign({}, a)
console.log(a,b,c,a===b,a===c);
//{a:1} {a:1} {a:1} true false
a.a = 2;
console.log(a,b,c,a===b,a===c);
//{a:2} {a:2} {a:1} true false

上面的代码很好理解,b是浅拷贝,故而值跟着变了,c是深拷贝,故而值没变
而对应的变量内存地址的话,因为JS并不能像其他语言一样,显式的查看变量内存地址,但可以借助ChromeMemory工具快照,来查看其相应的V8虚拟地址。

明显的,ab有着相同的内存地址,而c因为是深拷贝,拥有新的内存地址;当我们给a.a = 2重新写入后,观察Snapshot 12,ab的内存地址依然相同,这是因为JS这门语言并没有实现写时复制。

3.2 Python写时复制

讲完JS反面教材,我们再来观察一个实现了写时复制的语言。

terence@k8s-master:/mydata$ python3
Python 3.6.9 (default, Oct  8 2020, 12:12:24) 
[GCC 8.4.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> a = "qwer"
>>> print(a)
qwer
>>> id(a)
140662197387536
>>> b = a;
>>> print(b)
qwer
>>> id(b)
140662197387536//跟a的内存地址一致
>>> b = "qwert"
>>> id(b)
140662197387592//写时复制,内存地址发生了变化
>>> 

3.3 PHP写时复制

PHP对数组实现了写时复制,普通变量是没有写时复制的,通过下面的例子能够清楚的看到内存占用量的变化,只是修改了$b[0],然后整个都被复制了。(当然也可以安装xdebug拓展,进一步可以观察到数组refcount引用计数的变化)

$a = [];
for ($i=0; $i < 10000; $i++) { 
    $a[] = rand(0,10000);
}
var_dump(memory_get_usage());//9438664
$b = $a;
var_dump(memory_get_usage());//9438664
$b[0] = 99999;
var_dump(memory_get_usage());//9967104

3.4 Rust写时复制

fn main() {

    let s1 = String::from("hello");

    println!("s1 heap address: {:p}", s1.as_ptr());//0x5645e759d9e0
    println!("s1 stack address: {:p}", &s1);//0x7fff6a964bb8

    let mut s2 = s1;

    println!("s2 heap address: {:p}", s2.as_ptr());//0x5645e759d9e0
    println!("s2 stack address: {:p}", &s2);//0x7fff6a964c70

    b = String::from("world");

    println!("s2 heap address: {:p}", s2.as_ptr());//0x5645e759da70
    println!("s2 stack address: {:p}", &s2);//0x7fff6a964c70
}

对于Rust这种系统级语言,我们能更直观的观察到进程栈中的局部变量是如何跟堆中的实际内存地址关联起来的。

以了解 String 的底层会发生什么。String 由三部分组成,如下图左侧所示:一个指向存放字符串内容内存的指针,一个长度,和一个容量。这一组数据存储在栈上。右侧则是堆上存放内容的内存部分。

长度表示 String 的内容当前使用了多少字节的内存。容量是 String 从操作系统总共获取了多少字节的内存。长度与容量的区别是很重要的,不过在当前上下文中并不重要,所以现在可以忽略容量。

当我们将 s1 赋值给 s2String 的数据被复制了,这意味着我们从栈上拷贝了它的指针、长度和容量。我们并没有复制指针指向的堆上数据。换句话说,内存中数据的表现如下图

当我们修改s2时,写时复制,变成了如下所示

4 其他例子

前端开发场景里有懒加载,节流防抖,其实都是惰性思想的体现,不到万不得已,不去分配资源。


tfzh
231 声望17 粉丝

code what u love & love what u code