虚拟机最多会page walk几次

(物理机虚拟机都是四级页表。)在没有虚拟化的普通Linux系统(使用x86-64的4级页表)中,将虚拟地址(VA)转换为物理地址(PA)的过程称为一次Page Walk

虚拟机(Guest)认为自己拥有整个物理机器。它的内核维护着自己的页表(Guest页表),负责将 Guest虚拟地址(GVA) 映射到 Guest物理地址(GPA)。虚拟机监控器(Hypervisor / VMM)管理着真实的物理硬件。它维护着另一套页表(Nested页表 / Extended页表,简称EPT),负责将 Guest物理地址(GPA) 映射到真正的 主机物理地址(HPA)

EPT Walk:

  1. 转换Guest的CR3 (GPA) -> HPA:需要1次完整的4级EPT Walk。(为了找到PML4E的HPA)
  2. 转换PML4E (GPA) -> HPA:需要1次完整的4级EPT Walk。
  3. 转换PDPTE (GPA) -> HPA:需要1次完整的4级EPT Walk。
  4. 转换PDE (GPA) -> HPA:需要1次完整的4级EPT Walk。
  5. 转换PTE (GPA) -> HPA:需要1次完整的4级EPT Walk。
  6. 转换最终目标数据 (GPA) -> HPA:需要1次完整的4级EPT Walk。

总内存访问次数 = 6次EPT Walk × 4次访问/每次Walk = 24次内存访问


Memory Zones(内存区域)有哪些什么作用

是Linux内核为了兼容不同硬件架构的限制和优化内存管理而设计的一种分层策略。

Zone 名称 主要作用 物理地址范围 (典型) 存在于
ZONE_DMA 为老式ISA设备提供DMA可用内存 0 ~ 16 MB 所有系统
ZONE_DMA32 为32位设备提供DMA可用内存 16 MB ~ 4 GB 64位系统
ZONE_NORMAL 内核直接映射区,主要工作区域 32位: 16MB ~ 896MB 64位: 几乎所有内存 所有系统
ZONE_HIGHMEM 管理32位系统无法直接映射的内存 > ~896 MB 仅32位系统
ZONE_MOVABLE 防止内存碎片,支持内存热插拔 物理上不连续,逻辑上的一个集合。页迁移机制内存碎片整理(Memory Compaction)算法使用这个区域。 所有系统
ZONE_DEVICE 是一种附着在NUMA节点上的特殊内存类型。主要是表示Intel的Optane DC Persistent Memory这类持久内存的。
NUMA Node 0
    ZONE_DMA
    ZONE_DMA32
    ZONE_NORMAL (主要的DRAM内存)
NUMA Node 1
    ZONE_NORMAL (主要的DRAM内存)
NUMA Node 2 (内核为持久内存创建的新节点)
    ZONE_DEVICE (几乎全部的容量都是这个区域)

从NUMA的角度看,访问这个 ZONE_DEVICE 内存就是访问NUMA Node 2。


内存屏障

背景:生产者 是内核态的 perf 事件采样机制(可能由 NMI、PMU 或其他硬件触发),它会写入 ring buffer。消费者 是用户态程序(比如 perf 工具或你自己的采样线程),它从 ring buffer 读出数据。这俩线程可以运行在不同的 CPU 上,因此会有跨核缓存一致性问题。

数据是否同步取决于 CPU 缓存机制,如果不通过内存屏障明确同步可能会出现:

  • 读取到过时的 data_head
  • 写入的 data_tail 没有及时刷新

为了让消费者看到生产者“已经完成”的更新,从而安全地消费数据:

  1. __sync_synchronize() 是 GCC 内建的全内存屏障:所有之前的读写操作在屏障前完成,之后的读写操作不会被重排到前面。
  2. 针对编译器优化层面,必须与 smp_rmb() / smp_mb() 配合使用,才能确保跨核同步语义。
  • **READ_ONCE()**:防止编译器优化(缓存寄存器副本、重排等),强制每次从内存读取。
  • **WRITE_ONCE()**:防止编译器合并写入或重排。
  1. 在 perf ring buffer 消费逻辑中,这个顺序正是官方推荐的:
    • READ_ONCE(data_head) —— 获取生产者写的头部位置。
    • smp_rmb() —— 确保读取数据前不会看到被重排的旧数据。
    • 从 buffer 中读取样本数据。
    • 处理完毕后:
      • smp_mb() —— 确保所有读取都完成。
      • WRITE_ONCE(data_tail, ...) —— 更新消费者位置。

这确保生产者不会覆盖你尚未读取的数据。


讲讲透明大页

HugePage and THP in memory | Yi’s Blog

大页面对文件类型页的支持情况(匿名页是肯定支持的):
| 特性 | 支持情况 |
| ————— | ———————————- |
| tmpfs/shmem THP | ✅ 支持(可配置策略) |
| 普通文件映射 THP | ⚠️ Linux 5.4+ 支持,但有严格限制(只读、可执行、对齐) |
| 显式大页(hugetlbfs) | ✅ 完全支持,性能最可控 |


程序 dump 时查看出错位置

  • 崩溃后会生成转存储core 文件,gdb <可执行文件> core查看调用栈、崩溃函数及对应源文件、行号。
  • 看一些log
  • 重启,加上各种调试信息复现。

段错误有哪些情况

  • 访问非法地址
    • 访问未分配的堆/栈/全局/代码段区域
    • 空指针解引用(NULL
    • 野指针(指向已释放的内存)
  • 越界访问
    • 栈溢出(递归过深或大数组局部变量)
    • 越界访问数组、缓冲区
  • 权限错误
    • 写入只读段(如 .text 代码段)
    • 执行不可执行的内存页(如数据段执行)

僵尸进程

产生条件:
①子进程先于父进程结束,②父进程未正确处理子进程退出:

  • 当一个子进程完成执行时,操作系统会保留它的进程表条目。
  • 如果父进程没有正确处理子进程的退出信号(如 SIGCHLD),或者父进程在子进程退出时没有调用 wait()waitpid(),子进程就会变成僵尸进程。
  • 例如,父进程可能忽略了 SIGCHLD 信号,或者父进程在子进程退出时正在执行其他任务,未能及时处理子进程的退出。
    危险:
    僵尸进程仍然占用进程表中的一个条目,这可能导致进程表资源耗尽,比如pid无法再分配了从而系统崩溃。
    清理:
  • 父进程仍然在运行,可以通过调用 wait()waitpid() 来读取子进程的状态信息,从而释放子进程的进程表条目。
  • 僵尸进程不能被 kill 命令终止,因为它们已经“死亡”,只是状态信息尚未被父进程读取。但是可以kill父进程,父进程退出后,僵尸进程会被系统自动回收。(僵尸进程会被 init 进程(进程号为 1)接管然后退出。)

进程/线程/协程的区别

进程是操作系统资源分配的基本单位,线程是CPU调度的基本单位,协程是用户态的轻量级线程,由语言runtime控制调度。协程和线程区别是协程完全用户态,包括对象定义、创建、调度和上下文切换,其中上下文切换开销是线程的二十分之一

区分线程和进程主要看它有没有独立的地址空间!但是,两者的相同点大于不同点。都是task_struct结构来实现的,具体来说有以下几个部分:

  1. 进程、线程状态:就绪、执行、阻塞
  2. 进程pid和线程tgid,对于主线程而言pid==tgid
  3. 进程树,维护父子兄弟之间的关系
  4. 调度优先级的一系列参数:普通进程CFS,实时进程又不一样,
  5. 进程地址空间mm_struct,不过这个主要针对用户进程来讲。包含代码段、数据段、BSS段、堆、栈、共享库。内核线程在高地址空间中是直接映射的,mm_struct==null.
  6. 进程文件相关信息:当前目录、进程打开的文件
  7. 命名空间隔离内核资源

协程的产生主要是同步阻塞用线程做很浪费资源,想和epoll的多路复用技术用 1 个线程监听多个连接,谁有数据就处理谁,避免阻塞等待。)结合起来,于是很多语言就有了运行时协程。go语言运行时库会和C语言运行时库一样,实现自己的线程,不过线程和协程最大区别是协程完全用户态可以自己裁剪,而线程和C实现的差不多。

协程调度差不多把内核调度都实现了一遍:

为每个CPU定义一个运行队列(P相当于linux中cpu核的虚拟化,每个核都有个运行队列)

每个运行队列中定义红黑树来组织待运行任务(go是多任务队列)

基于定时器的节拍调度判断是否需要切换下一个任务

考虑多个运行队列中的负载均衡问题

(main不是真正运行起来了,要处理关键的初始化GMP,让调度器开始执行)


fork

  • 在父进程中,fork() 返回子进程的进程标识符(PID)。这个 PID 是操作系统分配给子进程的唯一标识符,父进程可以用它来跟踪子进程的状态,例如通过 wait() 系统调用来等待子进程结束。
  • 在子进程中,fork() 返回 0。子进程可以通过这个返回值知道自己是子进程,并且可以根据需要执行不同的代码逻辑。

1. 地址共享

哪些地址空间会共享,哪些不会共享?包括signal这些。

堆、栈、brk、匿名 mmap 等→ 只读共享,子进程获得父进程的完整副本,COW 机制,fork() 后父子进程共享内存,直到一方尝试修改数据时才会真正复制(即物理页在第一次写之后就不再共享)。

mmap(..., MAP_SHARED, ...) 映射的共享文件页POSIX/SysV 共享内存、tmpfs 页(可以理解为大页文件页)→ 物理页完全共享,修改双方立即可见。(动态链接库,代码段 .text

mmap(..., MAP_SHARED | MAP_ANONYMOUS, ...)映射的共享匿名页。(父子之间通信用)

不共享:内核栈、thread_info、task_struct、PID、TID、寄存器现场、命名空间。(只有“明确标记为共享”或“只读”的页才真正共享)

线程创建和进程创建虽然在用户态调用的函数不一样,但是内核态最终都会使用kernel_clone函数创建,不过传入的flag不一样,(命名空间,内存地址空间、挂载点(当前目录文件信息)、打开的文件)这四部分都是共用的。

进程最常见的典型信号(Shell、C 开发天天遇到):

名称 默认动作 常见场景
SIGINT 2 终止 Ctrl-C
SIGQUIT 3 终止+core Ctrl-
SIGKILL 9 终止(不可捕/不可忽) kill -9
SIGSEGV 11 终止+core 段错误、空指针
SIGTERM 15 终止 kill 默认信号
SIGCHLD 17 忽略 子进程状态变化通知父进程
SIGSTOP 19 停止(不可捕) Ctrl-Z / kill -STOP
SIGCONT 18 继续 唤醒被 STOP 的进程
  1. 已经到达、但还没处理的挂起信号(pending signal) 不继承:
    这些位图存在父进程 PCB 里 → fork 时子进程得到一份干净的新 PCB,挂起信号被清零
  2. 信号屏蔽字(blocked mask):调用 sigprocmask() 设置的那些“暂时不递送”的信号集合 → 子进程继承父进程当前的 mask。
  3. sigaction()signal() 注册过的信号,全部继承。

2. fork缺页

fork场景下什么情况会导致缺页?

整个fork之后子进程访问为什么会导致缺页,已经fault in,对物理页write protect写保护会触发。这个不是major fault 是Minor page fault.

3. 文件句柄继承

当父进程调用 fork() 时,子进程会获得父进程文件描述符表的一个副本。这并不是简单的数字拷贝,而是子进程的文件描述符指向了和父进程相同的文件表项。所以,子进程可以通过这个文件描述符访问和父进程打开的同一个文件,而且它们看到的文件读写位置等状态信息是一致的。比如在一些服务器程序中,父进程可以打开一个日志文件,然后创建多个子进程来处理不同的请求,子进程都可以通过继承的文件描述符将日志信息写入同一个日志文件。


进程间通信

进程间通信的方式有哪几种,分别适用于那些场合?

场景 方式
不相关进程通信(本机) FIFO、UNIX Socket
父子进程简单通信 匿名管道
大量数据共享 共享内存 + 信号量
异步事件通知 信号
分布式服务 TCP Socket、gRPC
高速实时数据 mmap + 环形缓冲区

中断

简述处理器中断处理的过程

处理器在中断处理的过程中,一般分为以下几个步骤:中断请求 -> 中断响应 -> 保护现场 -> 中断服务 -> 恢复现场 -> 中断返回。

①当硬件设备需要CPU处理时(例如,网卡收到数据包),它会通过主板上的中断控制器(如8259A或现代的APIC)向CPU的一个特定引脚发送一个电信号。②CPU在执行完当前指令后,会立即检测到这个中断请求。它会保存当前工作的现场(比如当前程序的地址、寄存器状态等),以便后续能恢复回来继续工作。③CPU根据中断请求号,去一个叫做中断描述符表(IDT) 的地方查找对应的中断服务程序(ISR) 的地址。这个ISR是设备驱动程序预先准备好的一段处理特定中断的代码。④CPU开始执行这个中断服务程序。这个程序通常会进行一些快速、关键的操作,比如:- 从网卡的缓冲区读取数据。- 确认中断已被处理。- 将更耗时的数据处理任务推迟到后面(通常放入一个队列,触发软中断或由内核线程处理)。

什么是中断向量?什么是中断嵌套?

中断向量:中断服务子程序的入口地址。

中断嵌套:中断系统正在执行一个中断服务程序时,有另一个优先级更高的中断源提出请求,这时会暂停当前正在执行的级别较低的中断源的服务程序,处理级别更高的中断源。处理完毕后再返回到被中断了的中断服务程序。

中断的优缺点?

优点:实现CPU和I/O设备的并行,提高CPU的利用率和系统的性能。

缺点:中断处理过程需要保护现场、恢复现场(用户态切换到内核态),整个过程需要一定的时间和空间开销。如果中断的频率太高,会降低系统的性能。

上下文切换指的是内核(操作系统的核心)在CPU上对进程或者线程进行切换。 上下文切换过程中的信息被保存在进程控制块(PCB-Process Control Block)中。算力记账给中断时被暂停的线程,并使用其内核栈。)


DPDK原理和性能优化

在传统Linux网络栈中,数据包接收路径是这样的:

  1. 网卡(NIC)收到一个数据包后,会触发硬件中断(Interrupt)
  2. CPU 响应中断,进入内核态,调用中断处理函数;
  3. 数据包从网卡DMA缓冲区拷贝到内核缓冲区,硬中断通知CPU,处理完后软中断ksoftirqd内核线程将从ringbuffer摘下数据包;(涉及内核协议栈)
  4. 最终再通过socket接口从内核拷贝到用户态应用,并唤醒等待队列上的进程。。
    整个过程涉及:
  • 用户态 ↔ 内核态切换;
  • 多次内存拷贝;
  • 中断触发和上下文切换的开销。
    DPDK优化思路:
  • DPDK提供了基于UIO(Userspace I/O) 或更现代的VFIO(Virtual Function I/O) 的用户空间驱动。DPDK通过VFIO/UIO驱动,将这片物理内存(也就是DMA将要和正在使用的区域)映射到用户空间的虚拟地址。这使得应用程序可以完全在用户空间直接与网卡硬件交互,无需陷入内核。DPDK应用程序程序通过内存映射(Memory Map)的方式直接从网卡接收队列(RX Ring)中获取数据包描述符(Descriptor),该描述符指向已映射到用户空间的、由大页内存组成的内存池(Mempool) 中的数据包。处理完成后,只需修改描述符,即可将数据包放入发送队列(TX Ring)。从而完全避免了系统调用和上下文切换,整个过程中,数据包本身始终存放在用户空间的同一块内存中,没有任何拷贝操作。
  • DPDK轮询时CPU核心持续、主动地检查网卡接收队列是否有新数据包到达。没有包时就空转。这虽然看起来浪费CPU(空转),但在高负载流量下,效率远高于处理海量中断。DPDK应用程序通常会绑定专用的CPU核心来执行轮询任务。(传统方法包速率高时 CPU 时间几乎都浪费在中断调度上了)

文章作者: 易百分
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 易百分 !
  目录