今天我们来开linux的“任督二脉”第二脉——内存管理。
内存统计信息
执行free -h,结果如下图所示:
其中,free是空闲内存,available是free+buff/cache中可释放的内存,就是实际可用内存。当available耗尽后,就会出现OOM(Out Of memory)的情况,linux内核的内存管理系统会运行OOM Killer选择合适的进程进行kill。
简单内存分配及其问题
计算器启动后,CPU首先进入实模式,在此基础上可以进入保护模式(分段)。这两种模式下进行的内存分配是简单模式,即段+偏移的方式。
在内存简单分配模式下,会出现三种主要的问题:
- 内存碎片化
内存碎片化之后,可能会存在多个不连续的小块内存空间,这样的话不能利用一块大内存来完成任务。比如有多个不连续的10Byte的小空间,我想申请一个100Byte的数组没法做到。
- 可以访问其他进程的内存
存在数据被损毁或泄漏的风险。
- 难以执行多任务
需要小心翼翼地安排各个进程,给多任务带来很多困难。
虚拟内存
即分页模式。进程无法直接访问物理内存,只是使用虚拟内存,也叫线性地址空间。所有内存都以页为单位进行管理。操作系统使用保存在内核使用内存的页表来完成线性地址到物理地址的转换。
申请虚拟内存的例子:
mmap.go
package mainimport ("fmt""log""os""os/exec""golang.org/x/sys/unix")var ALLOC_SIZE = 100 * 1024 * 1024 // 100Mfunc main() {pid := os.Getpid()fmt.Println("*** memory map before memory allocation ***")out1, err := checkMaps(pid)if err != nil {log.Fatalf("check maps before mmap failed with %s\n", err)}fmt.Println(out1)memory, err := unix.Mmap(-1, 0, ALLOC_SIZE, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_PRIVATE|unix.MAP_ANON)if err != nil {log.Fatalf("mmap() failed with %s\n", err)}defer unix.Munmap(memory)fmt.Printf("*** succeed to allocate memory: address-%p, size-%d ***\n", memory, ALLOC_SIZE)fmt.Println("*** memory map after memory allocation ***")out2, err := checkMaps(pid)if err != nil {log.Fatalf("check maps after mmap failed with %s\n", err)}fmt.Println(out2)}func checkMaps(pid int) (string, error) {cmd := exec.Command("bash", "-c", fmt.Sprintf("cat /proc/%d/maps", pid))out, err := cmd.CombinedOutput()return string(out), err}
cat /proc/{pid}/maps可以查看进程的虚拟内存。
我用mmap系统调用申请100M的虚拟内存(其实用户空间malloc底层就是调用mmap来申请内存),然后在申请前后执行cat /proc/{pid}/maps来查看申请前后虚拟内存的变化。结果如下:
*** memory map before memory allocation ***00400000-0049e000 r-xp 00000000 08:10 1382385 /tmp/go-build3881664940/b001/exe/mmap0049e000-00541000 r--p 0009e000 08:10 1382385 /tmp/go-build3881664940/b001/exe/mmap00541000-0055c000 rw-p 00141000 08:10 1382385 /tmp/go-build3881664940/b001/exe/mmap0055c000-00590000 rw-p 00000000 00:00 0 c000000000-c000400000 rw-p 00000000 00:00 0 c000400000-c004000000 ---p 00000000 00:00 0 7f96fa7ec000-7f96fcb9d000 rw-p 00000000 00:00 0 7f96fcb9d000-7f970cd1d000 ---p 00000000 00:00 0 7f970cd1d000-7f970cd1e000 rw-p 00000000 00:00 0 7f970cd1e000-7f971ebcd000 ---p 00000000 00:00 0 7f971ebcd000-7f971ebce000 rw-p 00000000 00:00 0 7f971ebce000-7f9720fa3000 ---p 00000000 00:00 0 7f9720fa3000-7f9720fa4000 rw-p 00000000 00:00 0 7f9720fa4000-7f972141d000 ---p 00000000 00:00 0 7f972141d000-7f972141e000 rw-p 00000000 00:00 0 7f972141e000-7f972149d000 ---p 00000000 00:00 0 7f972149d000-7f97214fd000 rw-p 00000000 00:00 0 7ffe050f1000-7ffe05112000 rw-p 00000000 00:00 0 [stack]7ffe051ca000-7ffe051ce000 r--p 00000000 00:00 0 [vvar]7ffe051ce000-7ffe051cf000 r-xp 00000000 00:00 0 [vdso]*** succeed to allocate memory: address-0x7f96f43ec000, size-104857600 ****** memory map after memory allocation ***00400000-0049e000 r-xp 00000000 08:10 1382385 /tmp/go-build3881664940/b001/exe/mmap0049e000-00541000 r--p 0009e000 08:10 1382385 /tmp/go-build3881664940/b001/exe/mmap00541000-0055c000 rw-p 00141000 08:10 1382385 /tmp/go-build3881664940/b001/exe/mmap0055c000-00590000 rw-p 00000000 00:00 0 c000000000-c000400000 rw-p 00000000 00:00 0 c000400000-c004000000 ---p 00000000 00:00 0 7f96f43ec000-7f96fcb9d000 rw-p 00000000 00:00 0 7f96fcb9d000-7f970cd1d000 ---p 00000000 00:00 0 7f970cd1d000-7f970cd1e000 rw-p 00000000 00:00 0 7f970cd1e000-7f971ebcd000 ---p 00000000 00:00 0 7f971ebcd000-7f971ebce000 rw-p 00000000 00:00 0 7f971ebce000-7f9720fa3000 ---p 00000000 00:00 0 7f9720fa3000-7f9720fa4000 rw-p 00000000 00:00 0 7f9720fa4000-7f972141d000 ---p 00000000 00:00 0 7f972141d000-7f972141e000 rw-p 00000000 00:00 0 7f972141e000-7f972149d000 ---p 00000000 00:00 0 7f972149d000-7f97214fd000 rw-p 00000000 00:00 0 7ffe050f1000-7ffe05112000 rw-p 00000000 00:00 0 [stack]7ffe051ca000-7ffe051ce000 r--p 00000000 00:00 0 [vvar]7ffe051ce000-7ffe051cf000 r-xp 00000000 00:00 0 [vdso]
从中可见:
(略)
*** succeed to allocate memory: address-0x7f96f43ec000, size-104857600 ***
(略)
7f96f43ec000-7f96fcb9d000 rw-p 00000000 00:00 0
(略)
调用mmap返回的地址和cat /proc/{pid}/maps中显示的地址一样,说明成功申请到了内存。
虚拟内存解决了简单内存分配出现的3个问题:通过页表,将物理地址上的碎片整合成线性地址空间上的连续空间,解决了内存碎片化问题。每个进程都有各自的页表,这样就解决了可以访问其他进程的内存的问题。有了虚拟内存,我们不用关心自身在哪个物理内存上,所以可以很方便地执行多任务。
虚拟内存的应用
- 文件映射
进程在访问文件时,一般可以用read()、write()、lseek()等系统调用。但是这样会有很多内核缓冲区与进程缓冲区之间的复制行为发生,效率较低。我们可以使用mmap将文件映射到进程的虚拟内存,对虚拟内存的读写即对文件的读写。
filemap.go
package mainimport ("log""os""golang.org/x/sys/unix")var ALLOC_SIZE = 100 * 1024 * 1024 // 100Mfunc main() {memory, err := mmap("foo")if err != nil {log.Fatalf("mmap failed with %s\n", err)}defer unix.Munmap(memory)copy(memory, []byte("hello, linux"))unix.Msync(memory, unix.MS_ASYNC)}func mmap(name string) ([]byte, error) {file, err := os.OpenFile(name, os.O_CREATE|os.O_RDWR, 0644)if err != nil {return nil, err}file.Truncate(10)defer file.Close()return unix.Mmap(int(file.Fd()), 0, ALLOC_SIZE, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)}
运行后,文件foo的内容为"hello, lin",因为文件长度是10Byte,所以被截取了一部分。
etcd使用了mmap,所以提升了写文件的效率。同时,因为是堆外内存,所以不参与gc,也提升了效率。
- 请求分页(demand paging)
进程在申请完内存后,其实linux不会马上为其分配对应的物理内存,当实际使用虚拟内存后,引发缺页中断,进入内核态,内核才真正分配物理内存,这样不会造成物理内存浪费。
demandpaging.go
package mainimport ("fmt""log""os""os/exec""golang.org/x/sys/unix")var ALLOC_SIZE = 100 * 1024 * 1024 // 100Mfunc main() {pid := os.Getpid()fmt.Println("*** memory usage before memory allocation ***")out1, err := checkMemUsage(pid)if err != nil {log.Fatalf("checkMemUsage1 failed with %s\n", err)}fmt.Println(out1)memory, err := unix.Mmap(-1, 0, ALLOC_SIZE, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_PRIVATE|unix.MAP_ANON)if err != nil {log.Fatalf("mmap() failed with %s\n", err)}defer unix.Munmap(memory)fmt.Printf("*** succeed to allocate memory: address-%p, size-%d ***\n", memory, ALLOC_SIZE)fmt.Println("*** memory usage after memory allocation ***")out2, err := checkMemUsage(pid)if err != nil {log.Fatalf("checkMemUsage2 failed with %s\n", err)}fmt.Println(out2)memory[10*1024*1024] = 1fmt.Println("*** memory usage after memory touch ***")out3, err := checkMemUsage(pid)if err != nil {log.Fatalf("checkMemUsage3 failed with %s\n", err)}fmt.Println(out3)}func checkMemUsage(pid int) (string, error) {cmd := exec.Command("bash", "-c", fmt.Sprintf("ps aux | grep %d", pid))out, err := cmd.CombinedOutput()return string(out), err}
输出结果为:
*** memory usage before memory allocation ***hoo 26271 0.0 0.0 703264 3084 pts/1 Sl+ 23:51 0:00 /tmp/go-build265496847/b001/exe/demandpaginghoo 26276 0.0 0.0 8620 3052 pts/1 S+ 23:51 0:00 bash -c ps aux | grep 26271hoo 26278 0.0 0.0 8164 720 pts/1 S+ 23:51 0:00 grep 26271*** succeed to allocate memory: address-0x7faa0484b000, size-104857600 ****** memory usage after memory allocation ***hoo 26271 0.0 0.0 805664 3084 pts/1 Sl+ 23:51 0:00 /tmp/go-build265496847/b001/exe/demandpaginghoo 26279 0.0 0.0 8620 2996 pts/1 S+ 23:51 0:00 bash -c ps aux | grep 26271hoo 26281 0.0 0.0 8164 652 pts/1 S+ 23:51 0:00 grep 26271*** memory usage after memory touch ***hoo 26271 0.0 0.0 805664 5132 pts/1 Sl+ 23:51 0:00 /tmp/go-build265496847/b001/exe/demandpaginghoo 26282 0.0 0.0 8620 3080 pts/1 S+ 23:51 0:00 bash -c ps aux | grep 26271hoo 26284 0.0 0.0 8164 656 pts/1 S+ 23:51 0:00 grep 26271
可见,申请100M虚拟内存后,虚拟内存由703264K变为805664K,但是物理内存仍然是3084K,直到touch了一定量的虚拟内存后,物理内存才变化为5132K。
- 写时复制(copy on write)
fork系统调用实际上是为子进程复制了一份父进程相同的页表。
cow.go
package mainimport ("log""os""github.com/docker/docker/pkg/reexec")var i = 10func init() {log.Printf("init start, os.Args = %+v\n", os.Args)reexec.Register("childProcess", childProcess)if reexec.Init() {os.Exit(0)}}func childProcess() {i = 20log.Printf("2: %v", i)log.Println("childProcess")}func main() {log.Printf("main start, os.Args = %+v\n", os.Args)log.Printf("1: %v", i)cmd := reexec.Command("childProcess")cmd.Stdin = os.Stdincmd.Stdout = os.Stdoutcmd.Stderr = os.Stderrif err := cmd.Start(); err != nil {log.Panicf("failed to run command: %s", err)}if err := cmd.Wait(); err != nil {log.Panicf("failed to wait command: %s", err)}log.Printf("3: %v", i)log.Println("main exit")}
运行结果:10 20 10。
原因是:一开始变量i所在的数据段是可rw的,fork以后P1和P2数据段变成readonly,这时不管P1或P2谁去改变量i就会产生page fault缺页异常。这时就会copy变量i所在的page到新的物理地址,而P1和P2的虚拟地址保持不变。所以这个操作依赖有MMU内存管理单元的CPU。
- swap
swap算是linux对于OOM的一种补救。当物理内存不足时,内核会将正在使用的物理内存的一部分页面换出到swap空间。后续再使用时再换入内存。但是,如果系统长期处于内存不足状态时,会频繁地换出换入,造成系统抖动。
- 虚拟内存/物理内存不足
64bit的虚拟内存高达128T,所以虚拟内存不足非常罕见。物理内存不足比较常见。
- 标准大页
标准大页可以减少页表占用的空间,fork会复制页表,所以也会提升fork的效率。