物理内存与虚拟内存
物理内存:操作系统底层使用 Zone 概念划分不同类型的内存,然后针对每块 Zone 使用 64Byte 的 page struct 代表一个 4KB 的实际物理页并使用伙伴系统(分为4KB, 8KB 到 4MB 共 11 个 slot 大小的 Page 链表)进行统一的管理,对于 4KB 之下的小内存使用 SLAB 机制(对象池)进行优化。
虚拟内存:操作系统通过mmap 和 brk申请虚拟内存块,其底层则是通过 VMA并通过链表➕红黑树进行统一的管理,mmap 和 brk 系统调用都只是创建 vm_area_struct 或者调整 vm_area_struct 的 start 和 end。
物理内存和虚拟内存关联:最后通过 PageFault 机制调用伙伴系统的 alloc_pages 以及页表修改的方式进行物理内存分配并建立虚拟内存和物理内存之间的关联,从而将两者管理起来。
用户态内存管理:更上层的 glibc 中的 malloc 则是通过批量 mmap 和 brk 的方式预先申请一块虚拟内存空间并通过 arena 块的进行管理,进而避免频繁的系统调用以及碎片问题
在 Linux 中申请内存并不会分配具体的物理内存,所以可以调用 mmap 申请一块超过物理内存的虚拟内存空间,然后在实际 pagefault 的时候,再发现内存不够然后 OOM。真正分配物理内存是在 pagefault 中调用伙伴系统的 alloc_pages 然后建立页表映射实现的
mmap
mmap 本质上是创建 vma_struct 数据结构,从而建立某块磁盘文件页或者匿名内存页,与某块虚拟内存地址空间之间的关系(也就是 vm_area_struct),具体的实际映射建立(填充页表)与文件内容的拷贝(如果是匿名内存页则无需拷贝)是在缺页异常中执行的。
磁盘文件页一般只拷贝一次,因为它会实际缓存在 PageCache 中,在缺页异常处理的时候,会先去 PageCache 找,找不到的情况下才会分配物理内存页并拷贝。磁盘文件页更多用来加速文件的读写(零拷贝的基础)。
匿名内存页则对应于 tmpfs,其更多用来做大内存分配和父子进程间共享内存。
具体的内存映射主要分为共享/私有和文件/匿名两种,两个选项各自可以两两结合从而构成了其中常用选项:
- 共享 + 文件:这种情况下会将对应的文件页读取到 PageCache 并设置为可读可写,再将对应的 PageCache 对应的物理内存地址映射到进程的虚拟内存地址,多个进程可以共享对应的文件页并进行读写,写入了以后的脏页会由内核 flush 线程定时落盘。通过这种机制主要可以减少一次 CPU IO 拷贝开销(在堆对应的匿名内存和 PageCache 对应的拷贝,也就是把数据拷贝进用户定义的 buffer,mmap 可以直接访问内存,不再需要创建 buffer 并拷贝了),也就达到了所谓的零拷贝优化。另外也可以达到多进程共享内存的目的(大家映射同一个文件和同一个 offset 就行了)
- 私有 + 文件:这种情况下会将对应的文件页读取到 PageCache 并设置为只读,并在存在写时进行写时拷贝。需要注意,通过这种机制无法实现内存共享,因为会各自拷贝一份出来,同时需要自己手动将修改内容同步回磁盘,因为修改的并不是 PageCache ,而是拷贝出来的新的文件内存页。该机制主要用来快速读取大文件,如果只读的话,可以减少一次内存拷贝与系统调用(read),直接读取 PageCache 就行了
- 共享 + 匿名:这种情况下会在缺页异常时分配一块匿名内存,并设置为可读可写,再将对应的对应的物理内存地址映射到进程的虚拟内存地址,由于是匿名内存,因此在不同进程之间无法共享对应的内存空间(找不到对应的相同的文件),而只能在父子进程之间共享对应的内存空间(因为它们共享同一个匿名文件句柄)。该机制主要用于父子进程间共享内存通信
- 私有 + 匿名:这种情况下会在缺页异常时分配一块匿名内存,并设置为只读,再将对应的对应的物理内存地址映射到进程的虚拟内存地址,在修改内存时产生写时拷贝。该机制无法在父子进程共享内存,它主要用于降低分配大块内存的开销,在 Malloc 中,超过 1M 就默认使用该方式进行内存分配
一般提到 mmap 都会讨论到零拷贝,但需要注意的是,在 mmap 应用到零拷贝的场景中,它只是减少了文件读写时的一次拷贝开销,当需要把对应内容通过 Socket 进行网络传输时,依然需要一次拷贝,将数据从 Page Cache 拷贝到 Socket Buffer
另外需要注意物理内存只分为文件内存和匿名内存两种,所谓的用户空间内存和内核空间内存都是针对虚拟内存而言的,虚拟内存和物理内存之间通过缺页异常来构建页表从而建立关系和配置读写权限
可以看到除非是父子进程,A 进程很难直接共享 B 进程的某块内存,除非知道对应的内存的磁盘文件以及 offset 是什么,在这种情况下才可以通过 mmap 结合 PageCache 实现多个进程之间的共享内存(动态库底层的 .text 节的共享就是是用这种方式实现的)
除了 mmap 外,linux 另外有一个目录叫做 /dev/shm,它挂载了内存文件系统 tmpfs 因此进程间通信可以简单的通过在该目录下创建同名文件并读写文件来实现,由于对应的操作都是在内存中,因此也是一种内存共享方式
可以通过 unix domain socket 的 send_msg 传输 control 信息(共享内存文件申请的 fd),然后通过 mmap 实现跨进程内存共享和零拷贝
内存申请
malloc 申请内存的时候,会有两种方式向操作系统申请堆内存:
方式一:通过 brk() 系统调用从堆分配内存
方式二:通过 mmap() 系统调用在文件映射区域分配内存;
一般如果用户分配的内存小于 128 KB,则通过 brk() 申请内存。而brk()的实现的方式很简单,就是通过 brk() 函数将堆顶指针向高地址移动,获得新的内存空间。malloc 通过 brk() 方式申请的内存,free 释放内存的时候,并不会把内存归还给操作系统,而是缓存在 malloc 的内存池中,待下次使用,这样就可以重复使用。
父子进程的内存管理
父进程通过 fork 系统调用创建子进程之后,父进程只会创建并拷贝生成一个新的一级页表并赋予给子进程,其他二级,三级,四级页表都直接引用原来的父进程的多级页表(因为页表内部记录的物理地址一样),此时父子进程的虚拟内存空间完全是一模一样的,包括父子进程的页表内容都是一样的,唯一不同的是 CR3 指向的一级页表的物理地址。除此之外,内核会将父子进程四级页表中的 PTE 均改为只读的,并将父子进程共同映射的这个物理页面引用计数 + 1,同时每个多级页表的引用计数也会 + 1,当父进程或者子进程决定写某个物理页面时会触发 CoW,它首先根据物理页面的引用计数决定是否要拷贝物理页面(如果引用计数大于 1,则需要拷贝物理页面,反之则简单的设置为可写就行),如果需要拷贝物理页面,则代表页表中的页表项需要被改写,此时如果页表本身的引用计数大于 1 的话(该状态在上级页表的页表项中会记录),则会进一步创建和拷贝新的页表,对应的修改会进一步蔓延到上级页表并递归到一级页表,从而完成新的页表结构的创建
另外由于轻量级进程间是共享虚拟内存空间的,但是各个线程需要各自的栈,因此线程栈是由 glibc 单独申请并且不可扩容的,然而进程栈则是由操作系统管理并且可以扩容的
零拷贝
在消息传输过程中,除了可以通过 mmap 减少一次拷贝和系统调用之外:
Linux 还提供了 sendfile,虽然和 mmap 一样仍然需要一次内存拷贝,但是它减少了一次系统调用,从原来的 mmap + write 改成了 send_file 一次调用
在 send_file 的基础上,我们可以进一步通过 sendfile+DMA Scatter/Gather 技术,进一步减少内存拷贝,其实现核心在于从原来的将所有数据发送到 socket buffer 再 DMA 转换成了,仅仅发送一个 fd 和获取范围给 socket,再有 DMA 进行收集与发送,从而释放了 CPU: