硬件结构
缓存一致性
MESI 协议其实是 4 个状态单词的开头字母缩写,分别是:
Modified,已修改
Exclusive,独占
Shared,共享
Invalidated,已失效
CPU如何执行任务
因为多个线程同时读写同一个 Cache Line 的不同变量时,而导致 CPU Cache 失效的现象称为伪共享(False Sharing)
解决:
- 宏定义将b设置为对齐地址,使得a、b两个变量不在同一块
- Disruptor 中有一个 RingBuffer 类会经常被多个线程使用-前置填充和后置填充
在 Linux 里面,实现了一个基于 CFS 的调度算法,也就是完全公平调度(Completely Fair Scheduling)。这个算法的理念是想让分配给每个任务的 CPU 时间是一样,于是它为每个任务安排一个虚拟运行时间 vruntime,在 CFS 算法调度的时候,会优先选择 vruntime 少的任务。高权重任务的 vruntime 比低权重任务的 vruntime 少。
nice 调整的是普通任务的优先级,用命令行调整
软中断
Linux 系统为了解决中断处理程序执行过长和中断丢失的问题,将中断过程分成了两个阶段,分别是「上半部和下半部分」。
上半部用来快速处理中断,也就是硬中断,一般会暂时关闭中断请求,主要负责处理跟硬件紧密相关或者时间敏感的事情。耗时短,特点是快速执行。会打断 CPU 正在执行的任务
下半部用来延迟处理上半部未完成的工作,也就说软中断,一般以「内核线程」的方式运行。耗时比较长,特点是延迟执行。每一个 CPU 都对应一个软中断内核线程,名字通常为「ksoftirqd/CPU 编号」,比如 0 号 CPU 对应的软中断内核线程的名字是 ksoftirqd/0。
软中断不只是包括硬件设备中断处理程序的下半部,一些内核自定义事件也属于软中断,比如内核调度等、RCU 锁(内核里常用的一种锁)等。
cat /proc/softirqs
查软中断,cat /proc/interrupts
查硬中断。
系统的中断次数的变化速率才是我们要关注的,我们可以使用watch -d cat /proc/softirqs
命令查看中断次数的变化速率。
watch:定期执行命令并刷新屏幕显示结果。
-d:高亮显示输出中的变化部分。
cat /proc/softirqs:显示 /proc/softirqs 文件的内容,包含内核软中断的统计信息。
ps aux | grep softirq
软中断内核线程
ps aux:
ps:显示当前系统中正在运行的进程。
a:显示所有用户的进程,包括其他用户的进程。
u:以用户友好的格式显示进程信息,包括用户、CPU 使用率、内存使用率等。
x:显示没有控制终端的进程。
grep softirq:
grep:用于搜索文本,查找包含指定模式的行。
softirq:指定的搜索模式,这里指包含 softirq 的行。
可以发现,内核线程的名字外面都有有中括号,这说明 ps 无法获取它们的命令行参数,所以一般来说,名字在中括号里的都可以认为是内核线程。
定位软中断 CPU 使用率过高的问题
->如果在top
命令发现,CPU 在软中断上的使用率比较高,而且 CPU 使用率最高的进程也是软中断 ksoftirqd 的时候,这种一般可以认为系统的开销被软中断占据了
->watch -d cat /proc/softirqs
命令查看中断次数的变化速率
->如果发现 NET_RX 网络接收中断次数的变化速率过快,sar -n DEV
查看网卡的网络包接收速率情况,然后分析是哪个网卡有大量的网络包进来
->接着,在通过 tcpdump 抓包,分析这些包的来源,如果是非法的地址,可以考虑加防火墙,如果是正常流量,则要考虑硬件升级等。
操作系统结构
Linux 内核 vs Windows 内核
现代操作系统,内核一般会提供 4 个基本能力:
管理进程、线程,决定哪个进程、线程使用 CPU,也就是进程调度的能力;
管理内存,决定内存的分配和回收,也就是内存管理的能力;
管理硬件设备,为进程与硬件设备之间提供通信能力,也就是硬件通信能力;
提供系统调用,如果应用程序要运行更高权限运行的服务,那么就需要有系统调用,它是用户程序与操作系统之间的接口。
用户空间的代码只能访问一个局部的内存空间,而内核空间的代码可以访问所有内存空间。因此,当程序使用用户空间时,我们常说该程序在用户态执行,而当程序使内核空间时,程序则在内核态执行。
应用程序如果需要进入内核空间,就需要通过系统调用。
Linux 内核设计的理念主要有这几个点:
MultiTask,多任务
对于单核 CPU 时,可以让每个任务执行一小段时间,时间到就切换另外一个任务,从宏观角度看,一段时间内执行了多个任务,这被称为并发。
对于多核 CPU 时,多个任务可以同时被不同核心的 CPU 同时执行,这被称为并行。
SMP,对称多处理
SMP 的意思是对称多处理,代表着每个 CPU 的地位是相等的,对资源的使用权限也是相同的,多个 CPU 共享同一个内存,每个 CPU 都可以访问完整的内存和硬件资源。
这个特点决定了 Linux 操作系统不会有某个 CPU 单独服务应用程序或内核程序,而是每个程序都可以被分配到任意一个 CPU 上被执行。
ELF,可执行文件链接格式
ELF 文件有两种索引,Program header table 中记录了「运行时」所需的段,而 Section header table 记录了二进制文件中各个「段的首地址」
Monolithic Kernel,宏内核
Monolithic Kernel 的意思是宏内核,Linux 内核架构就是宏内核,意味着 Linux 的内核是一个完整的可执行程序,且拥有最高的权限。
宏内核的特征是系统内核的所有模块,比如进程调度、内存管理、文件系统、设备驱动等,都运行在内核态。
不过,Linux 也实现了动态加载内核模块的功能,例如大部分设备驱动是以可加载模块的形式存在的,与内核其他模块解藕,让驱动开发和驱动加载更为方便、灵活。
与宏内核相反的是微内核,微内核架构的内核只保留最基本的能力,比如进程调度、虚拟机内存、中断等,把一些应用放到了用户空间,比如驱动程序、文件系统等。这样服务与服务之间是隔离的,单个服务出现故障或者完全攻击,也不会导致整个操作系统挂掉,提高了操作系统的稳定性和可靠性。
微内核内核功能少,可移植性高,相比宏内核有一点不好的地方在于,由于驱动程序不在内核中,而且驱动程序一般会频繁调用底层能力的,于是驱动和硬件设备交互就需要频繁切换到内核态,这样会带来性能损耗。华为的鸿蒙操作系统的内核架构就是微内核。
还有一种内核叫混合类型内核,它的架构有点像微内核,内核里面会有一个最小版本的内核,然后其他模块会在这个基础上搭建,然后实现的时候会跟宏内核类似,也就是把整个内核做成一个完整的程序,大部分服务都在内核中,这就像是宏内核的方式包裹着一个微内核。
Windows 和 Linux 一样,同样支持 MultiTask 和 SMP,但不同的是,Window 的内核设计是混合型内核。
Windows 的可执行文件格式叫 PE,称为可移植执行文件,扩展名通常是.exe、.dll、.sys等。
内存管理
虚拟内存
分段:
这个内存交换空间,在 Linux 系统里,也就是我们常看到的 Swap 空间,这块空间是从硬盘划分出来的,用于内存与硬盘的空间交换。
对于多进程的系统来说,用分段的方式,外部内存碎片是很容易产生的,产生了外部内存碎片,那不得不重新 Swap 内存区域,这个过程会产生性能瓶颈。
内存管理单元 (MMU)就做将虚拟内存地址转换成物理地址的工作。
对于 64 位的系统,两级分页肯定不够了,就变成了四级目录,分别是:
全局页目录项 PGD(Page Global Directory);
上层页目录项 PUD(Page Upper Directory);
中间页目录项 PMD(Page Middle Directory);
页表项 PTE(Page Table Entry);
Intel CPU提供的地址转换方式:
Linux 系统中的每个段都是从 0 地址开始的整个 4GB 虚拟空间(32 位环境下),也就是所有的段的起始地址都是一样的。这意味着,Linux 系统中的代码,包括操作系统本身的代码和应用程序代码,所面对的地址空间都是线性地址空间(虚拟地址),这种做法相当于屏蔽了处理器中的逻辑地址概念,段只被用于访问控制和内存保护。
代码段,包括二进制可执行代码;
数据段,包括已初始化的静态常量和全局变量;
BSS 段,包括未初始化的静态变量和全局变量;
堆段,包括动态分配的内存,从低地址开始向上增长;
文件映射段,包括动态库、共享内存等,从低地址开始向上增长(跟硬件和内核版本有关 (opens new window));
栈段,包括局部变量和函数调用的上下文等。栈的大小是固定的,一般是 8 MB。当然系统也提供了参数,以便我们自定义大小;
代码段下面还有一段内存空间的(灰色部分),这一块区域是「保留区」,之所以要有保留区这是因为在大多数的系统里,我们认为比较小数值的地址不是一个合法地址,例如,我们通常在 C 的代码里会将无效的指针赋值为 NULL。
堆和文件映射段的内存是动态分配的。比如说,使用 C 标准库的 malloc() 或者 mmap() ,就可以分别在堆和文件映射段动态分配内存。
malloc 是如何分配内存的
malloc 申请内存的时候,会有两种方式向操作系统申请堆内存。
方式一:通过 brk() 系统调用从堆分配内存, brk() 函数将「堆顶」指针向高地址移动,获得新的内存空间。
方式二:通过 mmap() 系统调用在文件映射区域分配内存;通过 mmap() 系统调用中「私有匿名映射」的方式,在文件映射区分配一块内存,也就是从文件映射区“偷”了一块内存。
malloc() 源码里默认定义了一个阈值:
如果用户分配的内存小于 128 KB,则通过 brk() 申请内存;
如果用户分配的内存大于 128 KB,则通过 mmap() 申请内存;
注意,不同的 glibc 版本定义的阈值也是不同的。
malloc() 在分配内存的时候,并不是老老实实按用户预期申请的字节数来分配内存空间大小,而是会预分配更大的空间作为内存池。
#include <stdio.h>
#include <malloc.h>
int main() {
printf("使用cat /proc/%d/maps查看内存分配\n",getpid());
//申请1字节的内存
void *addr = malloc(1);
printf("此1字节的内存起始地址:%x\n", addr);
printf("使用cat /proc/%d/maps查看内存分配\n",getpid());
//将程序阻塞,当输入任意字符时才往下执行
getchar();
//释放内存
free(addr);
printf("释放了1字节的内存,但heap堆并不会释放\n");
getchar();
return 0;
}
free 内存后堆内存还存在,是针对 malloc 通过 brk() 方式申请的内存的情况。
#include <stdio.h>
#include <malloc.h>
int main() {
//申请1字节的内存
void *addr = malloc(128*1024);
printf("此128KB字节的内存起始地址:%x\n", addr);
printf("使用cat /proc/%d/maps查看内存分配\n",getpid());
//将程序阻塞,当输入任意字符时才往下执行
getchar();
//释放内存
free(addr);
printf("释放了128KB字节的内存,内存也归还给了操作系统\n");
getchar();
return 0;
}
如果 malloc 通过 mmap 方式申请的内存,free 释放内存后就会归归还给操作系统。
查看进程的内存的分布情况,可以发现最右边没有 [heap] 标志,说明是通过 mmap 以匿名映射的方式从文件映射区分配的匿名内存。
总结:
malloc 通过 brk() 方式申请的内存,free 释放内存的时候,并不会把内存归还给操作系统,而是缓存在 malloc 的内存池中,待下次使用;
malloc 通过 mmap() 方式申请的内存,free 释放内存的时候,会把内存归还给操作系统,内存得到真正的释放。
如果内存满了
后台内存回收(kswapd):在物理内存紧张的时候,会唤醒 kswapd 内核线程来回收内存,这个回收内存的过程异步的,不会阻塞进程的执行。
直接内存回收(direct reclaim):如果后台异步回收跟不上进程内存申请的速度,就会开始直接回收,这个回收内存的过程是同步的,会阻塞进程的执行。
如果直接内存回收后,空闲的物理内存仍然无法满足此次物理内存的申请,那么内核就会放最后的大招了 ——触发 OOM (Out of Memory)机制。
主要有两类内存可以被回收,而且它们的回收方式也不同。
文件页(File-backed Page):内核缓存的磁盘数据(Buffer)和内核缓存的文件数据(Cache)都叫作文件页。大部分文件页,都可以直接释放内存,以后有需要时,再从磁盘重新读取就可以了。而那些被应用程序修改过,并且暂时还没写入磁盘的数据(也就是脏页),就得先写入磁盘,然后才能进行内存释放。所以,回收干净页的方式是直接释放内存,回收脏页的方式是先写回磁盘后再释放内存。
匿名页(Anonymous Page):这部分内存没有实际载体,不像文件缓存有硬盘文件这样一个载体,比如堆、栈数据等。这部分内存很可能还要再次被访问,所以不能直接释放内存,它们回收的方式是通过 Linux 的 Swap 机制,Swap 会把不常访问的内存先写到磁盘中,然后释放这些内存,给其他更需要的进程使用。再次访问这些内存时,重新从磁盘读入内存就可以了。
文件页和匿名页的回收都是基于 LRU 算法,也就是优先回收不常访问的内存。LRU 回收算法,实际上维护着 active 和 inactive 两个双向链表,其中:
active_list 活跃内存页链表,这里存放的是最近被访问过(活跃)的内存页;
inactive_list 不活跃内存页链表,这里存放的是很少被访问(非活跃)的内存页;
cat /proc/meminfo | grep -i active | sort
查看活跃和非活跃的内存页
Linux 提供了一个 /proc/sys/vm/swappiness 选项,用来调整文件页和匿名页的回收倾向。
swappiness 的范围是 0-100,数值越大,越积极使用 Swap,也就是更倾向于回收匿名页;数值越小,越消极使用 Swap,也就是更倾向于回收文件页。
sar -B 1
查看系统的直接内存回收和后台内存回收的指标
pgscank/s : kswapd(后台回收线程) 每秒扫描的 page 个数。
pgscand/s: 应用程序在内存申请过程中每秒直接扫描的 page 个数。
pgsteal/s: 扫描的 page 中每秒被回收的个数(pgscank+pgscand)。
如果系统时不时发生抖动,并且在抖动的时间段里如果通过 sar -B 观察到 pgscand 数值很大,那大概率是因为「直接内存回收」导致的。通过尽早的触发「后台内存回收」来避免应用程序进行直接内存回收。
内核定义了三个内存阈值(watermark,也称为水位),用来衡量当前剩余内存(pages_free)是否充裕或者紧张,分别是:
页最小阈值(pages_min);
页低阈值(pages_low);
页高阈值(pages_high);
橙色部分 kswapd0 会执行内存回收,直到剩余内存大于高阈值(pages_high)为止。
红色部分触发直接内存回收。
可以通过内核选项 /proc/sys/vm/min_free_kbytes (该参数代表系统所保留空闲内存的最低限)设置。
抖动->增大 min_free_kbytes
pages_min = min_free_kbytes
pages_low = pages_min*5/4
pages_high = pages_min*3/2
SMP 指的是一种多个 CPU 处理器共享资源的电脑硬件架构,也就是说每个 CPU 地位平等,它们共享相同的物理资源,包括总线、内存、IO、操作系统等。每个 CPU 访问内存所用时间都是相同的,因此,这种系统也被称为一致存储访问结构(UMA,Uniform Memory Access)。
随着 CPU 处理器核数的增多,多个 CPU 都通过一个总线访问内存,这样总线的带宽压力会越来越大,同时每个 CPU 可用带宽会减少,这也就是 SMP 架构的问题。
为了解决 SMP 架构的问题,就研制出了 NUMA 结构,即非一致存储访问结构(Non-uniform memory access,NUMA)。
NUMA 架构将每个 CPU 进行了分组,每一组 CPU 用 Node 来表示,一个 Node 可能包含多个 CPU 。
每个 Node 有自己独立的资源,包括内存、IO 等,每个 Node 之间可以通过互联模块总线(QPI)进行通信,所以,也就意味着每个 Node 上的 CPU 都可以访问到整个系统中的所有内存。但是,访问远端 Node 的内存比访问本地内存要耗时很多。
在 NUMA 架构下,当某个 Node 内存不足时,系统可以从其他 Node 寻找空闲内存,也可以从本地内存中回收内存。
具体选哪种模式,可以通过 /proc/sys/vm/zone_reclaim_mode 来控制。它支持以下几个选项:
0 (默认值):在回收本地内存之前,在其他 Node 寻找空闲内存;
1:只回收本地内存;
2:只回收本地内存,在本地回收内存时,可以将文件页中的脏页写回硬盘,以回收内存。
4:只回收本地内存,在本地回收内存时,可以用 swap 方式回收内存。
在使用 NUMA 架构的服务器,如果系统出现还有一半内存的时候,却发现系统频繁触发「直接内存回收」,导致了影响了系统性能,那么大概率是因为 zone_reclaim_mode 没有设置为 0
在 Linux 内核里有一个 oom_badness() 函数,它会把系统中可以被杀掉的进程扫描一遍,并对每个进程打分,得分最高的进程就会被首先杀掉。
// points 代表打分的结果
// process_pages 代表进程已经使用的物理内存页面数
// oom_score_adj 代表 OOM 校准值,通过 /proc/[pid]/oom_score_adj 来配置, -1000 到 1000
// totalpages 代表系统总的可用页面数
points = process_pages + oom_score_adj*totalpages/1000
在 4GB 物理内存的机器上,申请 8G 内存会怎么样?
使用 cat /proc/sys/vm/overcommit_memory 来查看overcommit_memory参数,这个参数接受三个值:
如果值为 0(默认值),代表:Heuristic overcommit handling,它允许overcommit,但过于明目张胆的overcommit会被拒绝,比如malloc一次性申请的内存大小就超过了系统总内存。Heuristic的意思是“试探式的”,内核利用某种算法猜测你的内存申请是否合理,大概可以理解为单次申请不能超过free memory + free swap + pagecache的大小 + SLAB中可回收的部分 ,超过了就会拒绝overcommit。
如果值为 1,代表:Always overcommit. 允许overcommit,对内存申请来者不拒。
如果值为 2,代表:Don’t overcommit. 禁止overcommit。
当时那位读者的 overcommit_memory 参数是默认值 0 ,所以申请失败的原因可能是内核认为我们申请的内存太大了,它认为不合理,所以 malloc() 返回了 Cannot allocate memory 错误,这里申请 4GB 虚拟内存失败的同学可以将这个 overcommit_memory 设置为1,就可以 overcommit 了。
Linux 中的 Swap 机制会在内存不足和内存闲置的场景下触发:
内存不足:当系统需要的内存超过了可用的物理内存时,内核会将内存中不常使用的内存页交换到磁盘上为当前进程让出内存,保证正在执行的进程的可用性,这个内存回收的过程是强制的直接内存回收(Direct Page Reclaim)。直接内存回收是同步的过程,会阻塞当前申请内存的进程。
内存闲置:应用程序在启动阶段使用的大量内存在启动后往往都不会使用,通过后台运行的守护进程(kSwapd),我们可以将这部分只使用一次的内存交换到磁盘上为其他内存的申请预留空间。kSwapd 是 Linux 负责页面置换(Page replacement)的守护进程,它也是负责交换闲置内存的主要进程,它会在空闲内存低于一定水位 (opens new window)时,回收内存页中的空闲内存保证系统中的其他进程可以尽快获得申请的内存。kSwapd 是后台进程,所以回收内存的过程是异步的,不会阻塞当前申请内存的进程。
Linux 提供了两种不同的方法启用 Swap,分别是 Swap 分区(Swap Partition)和 Swap 文件(Swapfile),开启方法可以看这个资料https://support.huaweicloud.com/trouble-ecs/ecs_trouble_0322.html:
tail -n 2 /var/log/messages
tail 命令用于显示文件的最后几行。-n 2 参数指定要显示最后的两行。在 Linux 系统中,/var/log/messages 文件通常是一个系统日志文件,记录了系统启动过程、内核事件、服务和应用程序的信息。
如何避免预读失效和缓存污染的问题
传统的 LRU 算法预读失效和缓存污染的问题。
“预读失效”(Prefetching Failures)是指在预读机制中,系统预先加载到缓存中的数据并没有被实际使用,从而导致缓存空间被不需要的数据占用,进而影响缓存命中率。->Redis 的缓存淘汰算法则是通过实现 LFU 算法来避免「缓存污染」而导致缓存命中率下降的问题(Redis 没有预读机制)。
缓存污染:短期大量数据访问、访问模式变化等->MySQL 和 Linux 操作系统是通过改进 LRU 算法来避免「预读失效和缓存污染」而导致缓存命中率下降的问题。
Linux 操作系统实现两个了 LRU 链表:活跃 LRU 链表(active_list)和非活跃 LRU 链表(inactive_list);
MySQL 的 Innodb 存储引擎是在一个 LRU 链表上划分来 2 个区域:young 区域 和 old 区域。
但是如果还是使用「只要数据被访问一次,就将数据加入到活跃 LRU 链表头部(或者 young 区域)」这种方式的话,那么还存在缓存污染的问题。
为了避免「缓存污染」造成的影响,Linux 操作系统和 MySQL Innodb 存储引擎分别提高了升级为热点数据的门槛:
Linux 操作系统:在内存页被访问第二次的时候,才将页从 inactive list 升级到 active list 里。 MySQL Innodb:在内存页被访问第二次的时候,并不会马上将该页从 old 区域升级到 young 区域,因为还要进行停留在 old 区域的时间判断: 如果第二次的访问时间与第一次访问的时间在 1 秒内(默认值),那么该页就不会被从 old 区域升级到 young 区域; 如果第二次的访问时间与第一次访问的时间超过 1 秒,那么该页就会从 old 区域升级到 young 区域; 通过提高了进入 active list (或者 young 区域)的门槛后,就很好了避免缓存污染带来的影响。
深入理解 Linux 虚拟内存管理
用户态虚拟内存空间的布局
内核中使用 start_brk 标识堆的起始位置,brk 标识堆当前的结束位置。cat /proc/pid/maps
或pmap pid
查看某个进程的实际虚拟内存布局。
64位:在代码段跟数据段的中间还有一段不可以读写的保护段,它的作用是防止程序在读写数据段的时候越界访问到代码段,这个保护段可以让越界访问行为直接崩溃,防止它继续往下运行。
进程在内核中的描述符 task_struct 结构
struct task_struct {
// 进程id
pid_t pid;
// 用于标识线程所属的进程 pid
pid_t tgid;
// 进程打开的文件信息
struct files_struct *files;
// 内存描述符表示进程虚拟地址空间
struct mm_struct *mm; //重点,父进程与子进程的区别,进程与线程的区别,以及内核线程与用户态线程的区别其实都是围绕着这个 mm_struct 展开的。
.......... 省略 .......
}
通过 fork() 函数创建出的子进程,它的虚拟内存空间以及相关页表相当于父进程虚拟内存空间的一份拷贝,直接从父进程中拷贝到子进程中。
通过 vfork 或者 clone 系统调用创建出的子进程,首先会设置 CLONE_VM 标识,父子进程之间使用的虚拟内存空间是一样的,并不是一份拷贝。
子进程共享了父进程的虚拟内存空间,这样子进程就变成了我们熟悉的线程,是否共享地址空间几乎是进程和线程之间的本质区别。Linux 内核并不区别对待它们,线程对于内核来说仅仅是一个共享特定资源的进程而已。
64 位系统中的 TASK_SIZE 为 0x00007FFFFFFFF000,64 位虚拟内存空间的布局是和物理内存页 page 的大小有关的,物理内存页 page 默认大小 PAGE_SIZE 为 4K。
内核如何布局进程虚拟内存空间
struct mm_struct {
unsigned long task_size;
unsigned long start_code, end_code, start_data, end_data;
unsigned long start_brk, brk, start_stack; // brk堆的开始和结束
unsigned long arg_start, arg_end, env_start, env_end;//参数列表、环境变量
unsigned long mmap_base;//内存映射区的起始地址
unsigned long total_vm; /* Total pages mapped */
unsigned long locked_vm; /* Pages that have PG_mlocked set 不能被换出*/
unsigned long pinned_vm; /* Refcount permanently increased 既不能换出,也不能移动*/
unsigned long data_vm; /* VM_WRITE & ~VM_SHARED & ~VM_STACK 数据段中映射的内存页数目*/
unsigned long exec_vm; /* VM_EXEC & ~VM_WRITE & ~VM_STACK 存放可执行文件的内存页数目*/
unsigned long stack_vm; /* VM_STACK 栈中所映射的内存页数目*/
struct vm_area_struct *mmap;//VMAs的双向链表的头指针
struct rb_root mm_rb;//红黑树的根节点
...... 省略 ........
}
内核如何管理虚拟内存区域
vm_area_struc这个结构体描述了这些虚拟内存区域 VMA(virtual memory area)。
struct vm_area_struct {
struct vm_area_struct *vm_next, *vm_prev;//双向链表串联VMA
struct rb_node vm_rb;
struct list_head anon_vma_chain;
struct mm_struct *vm_mm; /* The address space we belong to. */
unsigned long vm_start; /* Our start address within vm_mm. */
unsigned long vm_end; /* The first byte after our end address
within vm_mm. 左闭右开[vm_start,vm_end) */
/*
* Access permissions of this VMA.
*/
//通过 vma->vm_page_prot = vm_get_page_prot(vma->vm_flags) 实现到具体页面访问权限 vm_page_prot 的转换
pgprot_t vm_page_prot;//页级别的访问权限和行为规范
unsigned long vm_flags; //整体的访问权限和行为规范
struct anon_vma *anon_vma; /* Serialized by page_table_lock */
//non_vma,vm_file,vm_pgoff 分别和虚拟内存映射相关,虚拟内存区域可以映射到物理内存上,也可以映射到文件中,映射到物理内存上我们称之为匿名映射,映射到文件中我们称之为文件映射。
struct file * vm_file; /* File we map to (can be NULL). */
unsigned long vm_pgoff; /* Offset (within vm_file) in PAGE_SIZE
units */
void * vm_private_data; /* was vm_pte (shared mem) */
//vm_private_data 则用于存储 VMA 中的私有数据。具体的存储内容和内存映射的类型有关。
/* Function pointers to deal with this struct. */
const struct vm_operations_struct *vm_ops;
}
针对虚拟内存区域的相关操作
struct vm_operations_struct {
void (*open)(struct vm_area_struct * area);
//当指定的虚拟内存区域被加入到进程虚拟内存空间中时,open 函数会被调用
void (*close)(struct vm_area_struct * area);
//当虚拟内存区域 VMA 从进程虚拟内存空间中被删除时,close 函数会被调用
vm_fault_t (*fault)(struct vm_fault *vmf);
//缺页异常,fault 函数就会被调用。
vm_fault_t (*page_mkwrite)(struct vm_fault *vmf);
//当一个只读的页面将要变为可写时,page_mkwrite 函数会被调用。
..... 省略 .......
}
cat /proc/pid/maps
或者 pmap pid
命令背后的实现原理就是通过遍历内核中的这个 vm_area_struct 双向链表获取的。
尤其在进程虚拟内存空间中包含的内存区域 VMA 比较多的情况下,使用红黑树查找特定虚拟内存区域的时间复杂度是 O( logN ) ,可以显著减少查找所需的时间。
程序编译后的二进制文件如何映射到虚拟内存空间中
磁盘文件中的段我们叫做 Section,内存中的段我们叫做 Segment,也就是内存区域。
多个section->一个segment,内核中完成这个映射过程的函数是load_elf_binary ,这个函数的作用很大,加载内核的是它,启动第一个用户态进程 init 的是它,fork 完了以后,调用 exec 运行一个二进制程序的也是它。当 exec 运行一个二进制程序的时候,除了解析 ELF 的格式之外,另外一个重要的事情就是建立上述提到的内存映射。
.text,.rodata -> CODE
.data,.bss -> BSS
static int load_elf_binary(struct linux_binprm *bprm)
{
...... 省略 ........
// 设置虚拟内存空间中的内存映射区域起始地址 mmap_base
setup_new_exec(bprm);
...... 省略 ........
// 创建并初始化栈对应的 vm_area_struct 结构。
// 设置 mm->start_stack 就是栈的起始地址也就是栈底,并将 mm->arg_start 是指向栈底的。
retval = setup_arg_pages(bprm, randomize_stack_top(STACK_TOP),
executable_stack);
...... 省略 ........
// 将二进制文件中的代码部分映射到虚拟内存空间中
error = elf_map(bprm->file, load_bias + vaddr, elf_ppnt,
elf_prot, elf_flags, total_size);
...... 省略 ........
// 创建并初始化堆对应的的 vm_area_struct 结构
// 设置 current->mm->start_brk = current->mm->brk,设置堆的起始地址 start_brk,结束地址 brk。 起初两者相等表示堆是空的
retval = set_brk(elf_bss, elf_brk, bss_prot);
...... 省略 ........
// 将进程依赖的动态链接库 .so 文件映射到虚拟内存空间中的内存映射区域
elf_entry = load_elf_interp(&loc->interp_elf_ex,
interpreter,
&interp_map_addr,
load_bias, interp_elf_phdata);
...... 省略 ........
// 初始化内存描述符 mm_struct
current->mm->end_code = end_code;
current->mm->start_code = start_code;
current->mm->start_data = start_data;
current->mm->end_data = end_data;
current->mm->start_stack = bprm->p;
...... 省略 ........
}
内核虚拟内存空间
在总共大小 1G 的内核虚拟内存空间中,位于最前边有一块 896M 大小的区域,我们称之为直接映射区或者线性映射区,地址范围为 3G – 3G + 896m 。这块连续的虚拟内存地址会映射到 0 - 896M 这块连续的物理内存上。这块区域中的虚拟内存地址直接减去 0xC000 0000 (3G) 就得到了物理内存地址。
在这段 896M 大小的物理内存中,前 1M 已经在系统启动的时候被系统占用,1M 之后的物理内存存放的是内核代码段,数据段,BSS 段(这些信息起初存放在 ELF格式的二进制文件中,在系统启动的时候被加载进内存)
我们可以通过cat /proc/iomem
命令查看具体物理内存布局情况。
当我们使用 fork 系统调用创建进程的时候,内核会创建一系列进程相关的描述符,比如之前提到的进程的核心数据结构 task_struct,进程的内存空间描述符 mm_struct,以及虚拟内存区域描述符 vm_area_struct 等。这些进程相关的数据结构也会存放在物理内存前 896M 的这段区域中,当然也会被直接映射至内核态虚拟内存空间中的 3G – 3G + 896m 这段直接映射区域中。
当进程被创建完毕之后,在内核运行的过程中,会涉及内核栈的分配,内核会为每个进程分配一个固定大小的内核栈(一般是两个页大小,依赖具体的体系结构),每个进程的整个调用链必须放在自己的内核栈中,内核栈也是分配在直接映射区。
在 X86 体系结构下,ISA 总线的 DMA (直接内存存取)控制器,只能对内存的前16M 进行寻址,这就导致了 ISA 设备不能在整个 32 位地址空间中执行 DMA,只能使用物理内存的前 16M 进行 DMA 操作。
因此直接映射区的前 16M 专门让内核用来为 DMA 分配内存,这块 16M 大小的内存区域我们称之为 ZONE_DMA。
用于 DMA 的内存必须从 ZONE_DMA 区域中分配。
高端内存区域为 4G - 896M = 3200M,内核剩余可用的虚拟内存空间1G - 896M = 128M
内核虚拟内存空间中的 3G + 896M 这块地址在内核中定义为 high_memory,high_memory 往上有一段 8M 大小的内存空洞。空洞范围为:high_memory 到 VMALLOC_START 。
VMALLOC_START 定义在内核源码 /arch/x86/include/asm/pgtable_32_areas.h 文件中:
接下来 VMALLOC_START 到 VMALLOC_END 之间的这块区域成为动态映射区。采用动态映射的方式映射物理内存中的高端内存。
和用户态进程使用 malloc 申请内存一样,在这块动态映射区内核是使用 vmalloc 进行内存分配。由于之前介绍的动态映射的原因,vmalloc 分配的内存在虚拟内存上是连续的,但是物理内存是不连续的。通过页表来建立物理内存与虚拟内存之间的映射关系,从而可以将不连续的物理内存映射到连续的虚拟内存上。
内核通过 alloc_pages() 函数在物理内存的高端内存中申请获取到的物理内存页,这些物理内存页可以通过调用 kmap 映射到永久映射区中。
LAST_PKMAP 表示永久映射区可以映射的页数限制。
FIXADDR_START 和 FIXADDR_TOP 定义在内核源码 /arch/x86/include/asm/fixmap.h 文件中
在固定映射区中虚拟地址是固定的,而被映射的物理地址是可以改变的
那为什么会有固定映射这个概念呢 ? 比如:在内核的启动过程中,有些模块需要使用虚拟内存并映射到指定的物理地址上,而且这些模块也没有办法等待完整的内存管理模块初始化之后再进行地址映射。因此,内核固定分配了一些虚拟地址,这些地址有固定的用途,使用该地址的模块在初始化的时候,将这些固定分配的虚拟地址映射到指定的物理地址上去。
总结:
总线上传输的地址均为物理内存地址。
深入理解 Linux 物理内存管理
为了快速索引到具体的物理内存页,内核为每个物理页 struct page 结构体定义了一个索引编号:PFN(Page Frame Number)。PFN 与 struct page 是一一对应的关系。
内核提供了两个宏来完成 PFN 与 物理页结构体 struct page 之间的相互转换。它们分别是 page_to_pfn 与 pfn_to_page。
FLATMEM 平坦内存模型
内核中的默认配置是使用 FLATMEM 平坦内存模型。
DISCONTIGMEM 非连续内存模型
SPARSEMEM 稀疏内存模型
SPARSEMEM 内存模型中的这些所有的 mem_section 会被存放在一个全局的数组中,并且每个 mem_section 都可以在系统运行时改变 offline / online (下线 / 上线)状态,以便支持内存的热插拔(hotplug)功能。
将内存按照物理页是否可迁移,划分为不可迁移页,可回收页,可迁移页。
可能会被拔出的内存中只分配那些可迁移的内存页,这些信息会在内存初始化的时候被设置。
一致性内存访问 UMA 架构
在 UMA 架构下所有 CPU 访问内存的速度都是一样的。这种访问模式称为 SMP(Symmetric multiprocessing),即对称多处理器。
这里的一致性是指同一个 CPU 对所有内存的访问的速度是一样的。即一致性内存访问 UMA(Uniform Memory Access)。
非一致性内存访问 NUMA 架构
在 NUMA (Non-uniform memory access)架构下,任意一个 CPU 都可以访问全部的内存节点,访问自己的本地内存节点是最快的,但访问其他内存节点就会慢很多,这就导致了 CPU 访问内存的速度不一致,所以叫做非一致性内存访问架构。
在 NUMA 架构下,只有 DISCONTIGMEM 非连续内存模型和 SPARSEMEM 稀疏内存模型是可用的。而 UMA 架构下,前面介绍的三种内存模型都可以配置使用。numactl -H
命令可以查看服务器的 NUMA 配置numactl -s
查看 NUMA 的内存分配策略设置numactl -H
命令的输出结果查看节点 id
numastat
还可以查看各个 NUMA 节点的内存访问命中率、各numa的物理内存区域的分配情况
numactl --membind=nodes --cpunodebind=nodes command
# --membind 可以指定我们的应用程序只能在哪些具体的 NUMA 节点上分配内存,如果这些节点内存不足,则分配失败。
# --cpunodebind 可以指定我们的应用程序只能运行在哪些 NUMA 节点上。
numactl --physcpubind=cpus command
# 另外我们还可以通过 --physcpubind 将我们的应用程序绑定到具体的物理 CPU 上。这个选项后边指定的参数我们可以通过 cat /proc/cpuinfo 输出信息中的 processor 这一栏查看。例如:通过 numactl --physcpubind= 0-15 ./numatest.out 命令将进程 numatest 绑定到 0~15 CPU 上执行。
numactl --membind=0 --cpunodebind=0 ./numatest.out
numactl --membind=0 --cpunodebind=1 ./numatest.out
# 我们可以通过 numactl 命令将 numatest 进程分别绑定在相同的 NUMA 节点上和不同的 NUMA 节点上,运行观察。
内核中使用了 struct pglist_data 这样的一个数据结构来描述 NUMA 节点,在内核 2.4 版本之前,内核是使用一个 pgdat_list 单链表将这些 NUMA 节点串联起来的,单链表定义在 /include/linux/mmzone.h 文件中,2.4之后是一个大小为 MAX_NUMNODES ,类型为 struct pglist_data 的全局数组 node_data[] 来管理所有的 NUMA 节点。全局数组 node_data[] 定义在文件 /arch/arm64/include/asm/mmzone.h
node_start_pfn 指向 NUMA 节点内第一个物理页的 PFN,系统中所有 NUMA 节点中的物理页都是依次编号的,每个物理页的 PFN 都是全局唯一的(不只是其所在 NUMA 节点内唯一)
所以内核会根据各个物理内存区域的功能不同,将 NUMA 节点内的物理内存主要划分为以下四个物理内存区域:
ZONE_DMA:用于那些无法对全部物理内存进行寻址的硬件设备,进行 DMA 时的内存分配。例如前边介绍的 ISA 设备只能对物理内存的前 16M 进行寻址。该区域的长度依赖于具体的处理器类型。
ZONE_DMA32:与 ZONE_DMA 区域类似,该区域内的物理页面可用于执行 DMA 操作,不同之处在于该区域是提供给 32 位设备(只能寻址 4G 物理内存)执行 DMA 操作时使用的。该区域只在 64 位系统中起作用,因为只有在 64 位系统中才会专门为 32 位设备提供专门的 DMA 区域。
ZONE_NORMAL:这个区域的物理页都可以直接映射到内核中的虚拟内存,由于是线性映射,内核可以直接进行访问。
ZONE_HIGHMEM:这个区域包含的物理页就是我们说的高端内存,内核不能直接访问这些物理页,这些物理页需要动态映射进内核虚拟内存空间中(非线性映射)。该区域只在 32 位系统中才会存在,因为 64 位系统中的内核虚拟内存空间太大了(128T),都可以进行直接映射。
以上这些物理内存区域的划分定义在 /include/linux/mmzone.h 文件中。
内核中定义的 zone_type 除了上边为大家介绍的四个物理内存区域,又多出了两个区域:ZONE_MOVABLE 和 ZONE_DEVICE。
ZONE_DEVICE 是为支持热插拔设备而分配的非易失性内存( Non Volatile Memory ),也可用于内核崩溃时保存相关的调试信息。
ZONE_MOVABLE 是内核定义的一个虚拟内存区域,该区域中的物理页可以来自于上边介绍的几种真实的物理区域。该区域中的页全部都是可以迁移的,主要是为了防止内存碎片和支持内存的热插拔。如果物理页处于 ZONE_MOVABLE 区域,它们就可以被迁移,内核可以通过迁移页面来避免内存碎片的问题。
内核中请求分配的物理页面数只能是 2 的次幂!!
内核会为每个 NUMA 节点分配一个 kswapd 进程用于回收不经常使用的页面,还会为每个 NUMA 节点分配一个 kcompactd 进程用于内存的规整避免内存碎片。
通过cat /proc/zoneinfo | grep Node
命令来查看 NUMA 节点中内存区域的分布情况。
通过cat /proc/zoneinfo
命令来查看系统中各个 NUMA 节点中的各个内存区域的内存使用情况。
内核对 struct zone 结构体的设计是相当考究的,将这些频繁访问的字段信息归类为 4 个部分,并通过 ZONE_PADDING 来分割。
在结构体的最后内核还是用了 ____cacheline_internodealigned_in_smp 编译器关键字来实现最优的高速缓存行对齐方式。
struct zone {
.............省略..............
ZONE_PADDING(_pad1_)
.............省略..............
ZONE_PADDING(_pad2_)
.............省略..............
ZONE_PADDING(_pad3_)
.............省略..............
} ____cacheline_internodealigned_in_smp;
struct zone {
// 防止并发访问该内存区域
spinlock_t lock;
// 内存区域名称:Normal ,DMA,HighMem
const char *name;
// 指向该内存区域所属的 NUMA 节点
struct pglist_data *zone_pgdat;
// 属于该内存区域中的第一个物理页 PFN
unsigned long zone_start_pfn;
// 该内存区域中所有的物理页个数(包含内存空洞)
unsigned long spanned_pages;
// 该内存区域所有可用的物理页个数(不包含内存空洞)
unsigned long present_pages;
// 被伙伴系统所管理的物理页数
atomic_long_t managed_pages;
// 伙伴系统的核心数据结构
struct free_area free_area[MAX_ORDER];
// 该内存区域内存使用的统计信息
atomic_long_t vm_stat[NR_VM_ZONE_STAT_ITEMS];
} ____cacheline_internodealigned_in_smp;
物理内存在内核中管理的层级关系为:None -> Zone -> page
nr_reserved_highatomic 表示的是该内存区域内预留内存的大小,范围为 128 到 65536 KB 之间。
lowmem_reserve 数组则是用于规定每个内存区域必须为自己保留的物理页数量,防止更高位的内存区域对自己的内存空间进行过多的侵占挤压。
每个内存区域是按照一定的比例来计算自己的预留内存的,这个比例我们可以通过cat /proc/sys/vm/lowmem_reserve_ratio
命令查看。
我们可以在进程中通过 fsync() 系统调用将指定文件的所有脏页同步回写到磁盘,同时内核也会根据一定的条件唤醒专门用于回写脏页的 pflush 内核线程。
cat /proc/sys/vm/swappiness
命令查看,wappiness 选项的取值范围为 0 到 100,默认为 60。swappiness 用于表示 Swap 机制的积极程度,数值越大,Swap 的积极程度越高。
物理内存区域中的水位线。内核会为每个 NUMA 节点中的每个物理内存区域定制三条用于指示内存容量的水位线,分别是:WMARK_MIN(页最小阈值), WMARK_LOW (页低阈值),WMARK_HIGH(页高阈值)。定义在 /include/linux/mmzone.h 文件中。通过内核参数 /proc/sys/vm/min_free_kbytes 为基准分别计算出来。用户也可以通过 sysctl 来动态设置这个内核参数。
通过cat /proc/zoneinfo
命令来查看不同 NUMA 节点中不同内存区域中的水位线。
事实上 WMARK_MIN,WMARK_LOW ,WMARK_HIGH 这三个水位线的数值是通过内核参数 /proc/sys/vm/min_free_kbytes 为基准分别计算出来的,用户也可以通过 sysctl 来动态设置这个内核参数。
内核参数 min_free_kbytes 的单位为 KB 。
min_free_kbytes 的计算逻辑(64位):min_free_kbytes 的计算逻辑定义在内核文件 /mm/page_alloc.c 的 init_per_zone_wmark_min 方法中,用于计算最小水位线 WMARK_MIN 的数值也就是这里的 min_free_kbytes (单位为 KB)。 水位线的单位是物理内存页的数量。
物理内存区域内的三条水位线:WMARK_MIN,WMARK_LOW,WMARK_HIGH 的最终计算逻辑是在 __setup_per_zone_wmarks 方法中完成的。
因此内核引入了 /proc/sys/vm/watermark_scale_factor 参数来调节水位线之间的间距。该内核参数默认值为 10,最大值为 3000。
水位线间距计算公式:(watermark_scale_factor / 10000) * managed_pages 。
在内核中水位线间距计算逻辑是:(WMARK_MIN / 4) 与 (zone_managed_pages * watermark_scale_factor / 10000) 之间较大的那个值。