一、基础
1.1 进程、线程、协程区别
- 进程:进程是操作系统管理和调度的基本单位,拥有独立的内存空间和系统资源,通常适用于不同程序的并发执行。
- 线程:进程内的执行单位,共享相同的内存空间和资源,适用于一个程序内部的并发和多任务处理。
- 协程:编程语言实现的轻量级线程,在单个线程上实现多任务的协作式并发,适用于高并发场景和异步任务处理。
1.2 进程、线程上下文切换
进程上下文切换开销:
进程上下文切换涉及保存当前运行进程的CPU寄存器状态、程序计数器、堆栈指针、内存管理信息(如页表)等,并恢复新进程的上下文信息。这些操作会带来很大的开销,因为:
- 上下文切换需要
CPU
从用户态切换到内核态,导致额外的运行时间。 - 需要保存和恢复大量寄存器、内存管理信息等,占用内存和计算资源。
- 进程上下文切换引起
Cache
和TLB
的失效,导致额外的Cache Miss
和TLB Miss
。
因此,进程上下文切换的开销相对较大,影响系统性能。
线程上下文切换开销:
线程上下文切换主要涉及保存和恢复线程的CPU
寄存器状态、程序计数器、堆栈指针等。由于同一进程内的线程共享内存空间和内存管理信息(如页表),线程切换时不需要进行这部分信息的保存和恢复。因此,线程上下文切换相较于进程上下文切换的开销较小。然而,它仍然可能引起Cache
和TLB
的失效,导致额外的开销。
1.3 IPC 进程通信的方式
进程间通信(IPC,Inter-Process Communication
)是指多个进程如何共享数据、发送消息和协同工作的机制。以下是一些常用的IPC
方法:
-
管道(Pipe):管道是父子进程之间用于单向数据传输的
IPC
机制。数据从管道一端输入,在另一端输出,且数据传输为顺序、无缓冲的。管道主要用于父子进程之间。 -
有名管道(Named Pipe,FIFO):也称作
FIFO
(First In First Out
,先进先出),有名管道是在文件系统中创建的一个特殊文件,可在不相关的进程之间传输数据。它与普通管道类似,但可以实现更广泛的通信场景。 -
信号(Signal):信号是一种异步通知机制,允许一个进程中断或通知另一个进程。信号可以处理一些特定的情况,如停止进程、进程异常终止、子进程终止等,但信号实际上无法传递复杂数据。
-
消息队列(Message Queue):消息队列是一种实现进程间通信的数据结构。消息队列可以在多个进程之间传递结构化数据,并遵循先进先出(
FIFO
)的原则。消息队列既可以用于同一个系统的进程间通信,也可以用于不同机器之间的通信。 -
共享内存(Shared Memory):共享内存将一段内存空间映射到多个进程的地址空间中,这样多个进程可以直接访问同一段内存空间来交换数据。共享内存是一种非常高效的
IPC
方式,但同步和一致性问题需要通过其他手段解决(如信号量、互斥锁等)。 -
信号量(Semaphore):信号量是一个同步原语,用于实现进程间或同一进程不同线程之间的同步和互斥操作。信号量本身不能传递数据,但常与共享内存结合使用,解决共享资源的同步问题。
-
套接字(Socket):套接字是一种跨网络的进程间通信机制。套接字支持在不同主机间的进程进行通信,有多种类型(如
TCP
、UDP
和UNIX Domain Socket
)以满足不同的通信需求和性能目标。
1.4 copy on write(写时复制)
写时复制(Copy-On-Write
,简称 COW
)是一种计算机程序优化策略,主要用于减少数据结构复制和内存分配的开销。在采用该策略时,只有在需要修改数据时才会产生数据的实际副本。
写时复制的基本原理如下:当多个程序、进程或线程共享同一数据对象时,它们最初只保留该对象的只读引用,而非立即创建副本。只有当某个程序或线程试图修改共享数据时,才会创建一个实际副本。创建副本后,正在执行修改操作的程序或线程将操作副本,而其他程序或线程仍然保持指向原始数据。这样,只在实际需要时进行昂贵的数据复制操作,提高了程序性能。
1.5 Linux下线程栈大小是多少
默认是8M
,ulimit -s
可以查看
1.6 Linux内存分配算法buddy system、slab
Buddy System算法 和 Slab分配算法 是Linux
内核中用于内存分配的两种主要方法。这两种算法旨在优化内存分配,提高内存分配的效率及减少内存碎片。
Buddy System算法是一种二次幂的内存分配方法,目的在于使得高速内存的分配和释放变得相对简单且减少内存碎片。其主要机制如下:
- 把所有的空闲页框分组为
11
个块链表,每个块链表分别包含大小为1
,2
,4
,8
,16
,32
,64
,128
,256
,512
和1024
个连续的页框,对1024
个页框的最大请求对应着4MB
大小的连续RAM
块。 - 为了简化分配过程,分配器通过一个名为
自由列表数组
的数据结构来记录可用内存的块。 - 当一个内存请求来時,分配器根据需求分配最接近请求大小的整数次幂内存块。
- 当内存被释放时,分配器会判断是否有相邻闲置内存块,并与这些相邻内存块结合成更大的内存块。
Slab分配算法致力于提高小内存对象的分配和释放效率。通过将内存分割成相同大小的Slabs
,它们可以在相应的内存缓存中存储相同类型的对象。主要特点如下:
- 内核中相同类型的对象存储在同一缓存中,这些缓存称为
Slab缓存
。 - 每个
Slab缓存
包含一个或多个大小相等的内存块。 - 内存块被分成若干用于存储数据对象的空间,称为
Slab对象
。 - 操作系统同时维护空闲和已经用完的
Slab列表
。
Slab
分配算法通过回收已经释放的对象并存储在相应的Slab
缓存中,以便在后续需求时快速分配给新对象,从而降低分配的时间复杂度,提高内存的使用效率。
1.7 进程状态、僵尸进程和孤儿进程
R
运行状态(running
): 并不意味着进程一定在运行中,它表明进程要么是在运行中要么在运行队列 里。S
睡眠状态(sleeping
): 意味着进程在等待事件完成(这里的睡眠有时候也叫做可中断睡眠 (interruptible sleep
)。D
磁盘休眠状态(Disk sleep
)有时候也叫不可中断睡眠状态(uninterruptible sleep
),在这个状态的 进程通常会等待IO的结束。T
停止状态(stopped
): 可以通过发送SIGSTOP
信号给进程来停止(T
)进程。这个被暂停的进程可以通过发送 SIGCONT
信号让进程继续运行。X
死亡状态(dead
):这个状态只是一个返回状态,你不会在任务列表里看到这个状态。Z
僵死状态(zombie
)
僵尸进程:简单来说,当进程退出时但是父进程并没有调用wait
或waitpid
获取子进程的状态信息时就会产生僵尸进程
其实,僵尸进程是有危害的。进程的退出状态必须被维持下去,因为它要告诉关心它的进程(父进程),你交给我的任务,我办的怎么样了。可父进程如果一直不读取,那子进程就一直处于Z
状态。维护退出状态本身就是要用数据维护,也属于进程基本信息,所以保存在task_struct(PCB)
中,换句话说,当一个进程一直处于Z
状态,那么它的PCB
也就一直都要被维护。因为PCB
本身就是一个结构体会占用空间,僵尸进程也就会造成资源浪费,所以我们应该避免僵尸进程的产生。
孤儿进程:则是指当一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被init
进程(进程号为1
)所收养,并由init
进程对它们完成状态收集工作。
1.8 Memory Model
Memory Model
其实是一个概念,表示在多线程场景下,如何保证数据同步的正确性。 内存模型非常重要,因为它们决定程序如何访问和操作内存,以确保程序的一致性和可靠性。
Happens Before
是Memory Model
中一个通用的概念。主要是用来保证内存操作的可见性。如果要保证E1
的内存写操作能够被E2
读到,那么需要满足。
1.9 尾递归优化
尾递归优化是一种编译器优化技术,它可以优化递归函数的执行效率,节省栈空间的使用。
在递归函数中,每次递归调用都会将当前函数的状态信息存储在栈中,包括函数参数、局部变量、返回地址等信息。当递归调用次数较多时,栈会不断增长,导致栈溢出等问题。尾递归优化的目的就是减少栈的使用,避免栈溢出等问题。
具体地说,尾递归优化会将递归调用转化为一个循环,从而避免在每次递归调用时都要保存当前函数的状态信息。在转化后的代码中,递归函数的返回值会不断更新,直到最终结果被计算出来。
尾递归优化可以解决递归函数效率低、栈溢出等问题,提高程序的性能和健壮性。但需要注意的是,并不是所有的递归函数都可以进行尾递归优化,只有满足尾递归条件的函数才能进行此种优化。同时,不同的编程语言也对尾递归优化的支持程度不同,需要具体了解每种编程语言的情况。
1.10 用户态和内核态什么区别?用户态切换到内核态的几种方式
用户态是指应用程序运行时使用的资源,例如CPU
、内存、磁盘等,应用程序只能访问自己的内存空间,而不能直接访问操作系统的资源。在用户态下,应用程序不能直接执行内核代码,必须通过系统调用等特定机制向内核发出请求,由内核来执行相应的操作。
内核态是指操作系统运行时使用的资源,包括CPU
、内存等,操作系统可以直接访问硬件资源,执行各种内核代码,例如设备驱动程序、文件系统、网络协议栈等。在内核态下,操作系统可以访问所有资源,并直接执行内核代码,没有受到任何限制。
当应用程序需要执行某些特权操作时,必须切换到内核态。用户态切换到内核态的几种方式如下:
- 系统调用:使用软中断指令(
int 0x80
)向内核发出系统调用请求,并将参数传递给内核,内核根据请求处理并将结果返回给用户程序。 - 异常/中断处理:当硬件出现异常或中断时,处理器会自动切换到内核态执行相应的异常/中断处理程序。
- 信号处理:当应用程序收到信号时,处理器会切换到内核态执行信号处理程序。
- 调试处理:调试程序可以通过特定的机制要求处理器切换到内核态执行调试程序。
总之,用户态和内核态是操作系统中两个不同的执行级别,用户态比内核态更受限制,只能执行受限的代码,而内核态则更加特权和自由,可以执行各种操作系统代码。切换方式主要有系统调用、异常、中断处理、信号处理、调试处理等。
1.11 硬中断、软中断
硬中断是由计算机硬件发出的中断请求,通常是由外部设备(如键盘、鼠标、网卡等)向处理器发出的信号,告诉处理器有一个事件需要处理。硬中断是立即发生的,处理器必须立即响应中断请求,停止当前运行中的程序,进行中断服务程序的处理。
软中断是由操作系统内部的程序(如系统调用)发起的中断请求,它通常是通过特定的指令(如int 0x80
)或函数调用(如sysenter
)触发,向处理器发出中断请求。软中断是由程序主动发起的,处理器同样要停止当前运行中的程序,进行中断服务程序的处理。
硬中断和软中断的区别主要有以下几点:
- 发起方式不同:硬中断由外部设备发起,软中断由内部程序发起。
- 触发时机不同:硬中断是立即发生的,中断服务程序必须立即响应处理。软中断是由程序主动发起的,处理延迟相对较短。
- 处理方式不同:硬中断的处理程序通常由硬件设备提供,例如外部设备的驱动程序。软中断的处理程序则由操作系统提供,例如系统调用处理程序。
- 硬中断是可屏蔽的,软中断不可屏蔽。
1.12 CPU操作基本单位?内存操作基本单位?磁盘操作基本单位?
CPU
读取基本单位是Cacheline
,大小默认是64 Byte
,可以通过命令getconf LEVEL1_DCACHE_LINESIZE
查看。- 内存操作的基本单位是
Page
,默认4K
,也有大页4M
,可以通过命令getconf PAGE_SIZE
查看。 - 磁盘操作的基本单位是
磁盘块
,默认也是4K
,也有8K
,可以通过命令sudo blockdev --getbsz /dev/sda
二、内存基础
2.1 什么是缺页中断?
缺页中断(英语:Page fault
,又名硬错误、硬中断、分页错误、寻页缺失、缺页中断、页故障等)指的是当软件试图访问已映射在虚拟地址空间中,但是目前并未被加载在物理内存中的一个分页时,由中央处理器的内存管理单元所发出的中断。
通常情况下,用于处理此中断的程序是操作系统的一部分。如果操作系统判断此次访问是有效的,那么操作系统会尝试将相关的分页从硬盘上的虚拟内存文件中调入内存。而如果访问是不被允许的,那么操作系统通常会结束相关的进程。
缺页中断发生时的事件顺序如下:
- 硬件陷入内核,在内核堆栈中保存程序计数器。大多数机器将当前指令的各种状态信息保存在特殊的CPU寄存器中。
- 启动一个汇编代码例程保存通用寄存器和其他易失的信息,以免被操作系统破坏。这个例程将操作系统作为一个函数来调用。
- 当操作系统发现一个缺页中断时,尝试发现需要哪个虚拟页面。通常一个硬件寄存器包含了这一信息,如果没有的话,操作系统必须检索程序计数器,取出这条指令,用软件分析这条指令,看看它在缺页中断时正在做什么。
- 一旦知道了发生缺页中断的虚拟地址,操作系统检查这个地址是否有效,并检查存取与保护是否一致。如果不一致,向进程发出一个信号或杀掉该进程。如果地址有效且没有保护错误发生,系统则检查是否有空闲页框。如果没有空闲页框,执行页面置换算法寻找一个页面来淘汰。
- 如果选择的页框“脏”了,安排该页写回磁盘,并发生一次上下文切换,挂起产生缺页中断的进程,让其他进程运行直至磁盘传输结束。无论如何,该页框被标记为忙,以免因为其他原因而被其他进程占用。
- 一旦页框“干净”后(无论是立刻还是在写回磁盘后),操作系统查找所需页面在磁盘上的地址,通过磁盘操作将其装入。该页面被装入后,产生缺页中断的进程仍然被挂起,并且如果有其他可运行的用户进程,则选择另一个用户进程运行。
- 当磁盘中断发生时,表明该页已经被装入,页表已经更新可以反映它的位置,页框也被标记为正常状态。
- 恢复发生缺页中断指令以前的状态,程序计数器重新指向这条指令。
- 调度引发缺页中断的进程,操作系统返回调用它的汇编语言例程。
- 该例程恢复寄存器和其他状态信息。
2.2 页面置换算法
页面置换算法是操作系统在内存管理中使用的一种技术,用于将在内存中的页面进行淘汰和替换。通过向主存中加载新的页面,可以提供更高效和优化的内存使用方式。操作系统常用的页面置换算法包括:
-
最优页置换算法(
Optimal replacement algorithm
):最优页置换算法是一种理论上最佳的页面置换算法。它基于未来访问方式,选择将最长时间内不会被访问的页面从内存中淘汰。 -
先进先出置换算法(
FIFO replacement algorithm
):先进先出置换算法是一种简单的置换算法,它选择最先被加载到内存中的页面进行淘汰,即最老的页面被淘汰。 -
最近最久未使用置换算法(
Least Recently Used replacement algorithm,LRU
):LRU
置换算法选择最近一段时间内最长时间未被使用的页面进行淘汰。该算法需要维护页面的使用记录,因此需要更多的计算和存储资源。 -
时钟置换算法(
Clock replacement algorithm
):时钟置换算法类似于FIFO
算法,但使用了一个“时钟”指针来遍历内存中所有页面。当需要淘汰页面时,时钟指针找到最老的页面,然后将页面的访问位设置为0
,直到找到一个页面访问位为0
,表示该页面未被访问。然后选择该页面进行淘汰,同时更新页面的状态。
2.3 虚拟内存技术?Linux的进程虚拟地址空间?
很多时候我们使用点了开了很多占内存的软件,这些软件占用的内存可能已经远远超出了我们电脑本身具有的物理内存。为什么可以这样呢? 正是因为 虚拟内存 的存在,通过 虚拟内存 可以让程序可以拥有超过系统物理内存大小的可用内存空间。另外,虚拟内存为每个进程提供了一个一致的、私有的地址空间,它让每个进程产生了一种自己在独享主存的错觉(每个进程拥有一片连续完整的内存空间)。这样会更加有效地管理内存并减少出错。
虚拟内存的核心原理是:为每个程序设置一段”连续”的虚拟地址空间,把这个地址空间分割成多个具有连续地址范围的页 (page
),并把这些页和物理内存做映射,在程序运行期间动态映射到物理内存。当程序引用到一段在物理内存的地址空间时,由硬件立刻执行必要的映射;而当程序引用到一段不在物理内存中的地址空间时,由操作系统负责将缺失的部分装入物理内存并重新执行失败的指令。
Linux的进程虚拟地址空间:
- 内核空间,内核总是驻留在内存中,是操作系统的一部分。内核空间为内核保留,不允许应用程序读写该区域的内容或直接调用内核代码定义的函数。
- 栈(
stack
),栈又称堆栈,由编译器自动分配释放,行为类似数据结构中的栈(先进后出)。 - 内存映射段(
mmap
),内核将硬盘文件的内容直接映射到内存, 任何应用程序都可通过Linux
的mmap()
系统调用。内存映射是一种方便高效的文件I/O
方式, 因而被用于装载动态共享库。 - 堆(
heap
),堆用于存放进程运行时动态分配的内存段,可动态扩张或缩减。堆中内容是匿名的,不能按名字直接访问,只能通过指针间接访问。 BSS段
,BSS(Block Started by Symbol)
段中通常存放程序中以下符号:- 未初始化的全局变量和静态局部变量
- 初始值为
0
的全局变量和静态局部变量(依赖于编译器实现) - 未定义且初值不为
0
的符号(该初值即common block
的大小)
- 数据段(
Data
),数据段通常用于存放程序中已初始化且初值不为0
的全局变量和静态局部变量。数据段属于静态内存分配(静态存储区),可读可写。 - 代码段(
text
)代码段也称正文段或文本段,通常用于存放程序执行代码(即CPU
执行的机器指令)。一般C
语言执行语句都编译成机器代码保存在代码段。通常代码段是可共享的,因此频繁执行的程序只需要在内存中拥有一份拷贝即可。代码段通常属于只读,以防止其他程序意外地修改其指令(对该段的写操作将导致段错误)。某些架构也允许代码段为可写,即允许修改程序。代码段指令中包括操作码和操作对象(或对象地址引用)。若操作对象是立即数(具体数值),将直接包含在代码中;若是局部数据,将在栈区分配空间,然后引用该数据地址;若位于BSS
段和数据段,同样引用该数据地址。 - 保留区,位于虚拟地址空间的最低部分,未赋予物理地址。任何对它的引用都是非法的,用于捕捉使用空指针和小整型值指针引用内存的异常情况。
2.4 栈和堆的区别?
栈(stack
)和堆(heap
)是计算机内存中两种不同的数据结构,它们在程序运行时用于存储数据。它们之间的主要区别是数据的分配和释放方式、内存管理和访问速度等。
栈和堆的主要区别:
- 内存分配和释放:栈是一种自动管理的内存区域,存储局部变量和函数调用相关信息。当函数被调用时,其相关数据(如局部变量)会被自动压入栈中,函数返回时会自动弹出。而堆内存的分配和释放需要程序员手动进行,通过内存分配函数如
malloc
或new
来分配内存,通过free
或delete
来释放内存。 - 内存管理:栈内存受到操作系统严格管理,其大小是固定的,当栈空间不足时会出现栈溢出错误。堆内存的管理相对灵活,可以动态地分配空间,但也容易导致内存泄漏、碎片化等问题。
- 访问速度:栈内存的访问速度通常比堆内存更快,因为栈使用连续的内存地址和
LIFO
(后进先出)的数据结构,这使得CPU
缓存更容易预测和优化对栈内存的访问。另一方面,堆内存的地址空间可能是分散的,访问速度相对较慢。 - 生命周期:栈上的数据的生命周期与函数的调用周期相关,函数返回后,其栈帧上的数据会被销毁。而堆上的数据在手动释放之前一直存在,可以跨越函数调用的边界。
- 数据大小:栈上的数据大小受到限制,因为栈的大小是固定的。堆上的数据大小则相对灵活,可以动态分配大块内存空间。
2.5 内存分页机制?为什么要使用内存分页? 为什么要多级页表?
内存分页机制是一种内存管理方式,将物理内存划分成固定大小的页(Page
),将进程的虚拟内存划分成大小相等的页框(Page Frame
),一一对应关系。其中,虚拟内存是指每个进程所能访问的地址空间,包含了代码、数据、堆栈等。
使用内存分页的主要目的是实现虚拟内存,使进程能够访问比物理内存更大的地址空间,以及实现内存保护和共享,提高内存的使用效率和安全性。此外,内存分页还有助于处理非连续的内存分配申请,同时也方便了操作系统进行物理内存管理。
多级页表 :32
位下页表是10、10、12
三级, 64
位页表9、9、9、9、12
共48
位
在32
的系统中,系统分配给每个进程的虚拟地址为4G
,对于每个虚拟地址页建立一个记录,这样也需要4G/4k(page)
个,假设每条记录大小为4B
,这样对于每个进程需要4M
的页表,对于一个helloworld
程序而言,不足4K
的程序需要4M
的页表,未免有些浪费。
2.6 MMU如何把虚拟地址翻译成物理地址的?
先看TLB
能不能命中,不能命中去一级级查页表
2.7 Cacheline、False Sharding?
Cacheline
是Cache
和RAM
交换数据的最小单位,通常为 64Byte
。当CPU
把内存的数据载入Cache
时,会把临近的共64Byte
的数据一同放入同一个Cache line
,因为空间局部性:临近的数据在将来被访问的可能性大。
由于CPU Cache
缓存数据最小的单位是一个Cache Line
,如果两个Core
读取了同一个Cache Line
,并对Cacheline
中的数据频繁读写,就会有Flase Sharing
的问题。
2.8 虚拟地址多少位?物理地址多少位?
48位- 256TB 52位 4 PB
2.9 Windows下32位操作系统如何突破4G内存限制?
使用Physical Address Extension(PAE)
: PAE
(物理地址扩展)是一种支持超过4GB
物理内存的技术。启用PAE
,32
位操作系统就可以利用大于32
位的物理地址空间。但是,即使启用PAE
,单个进程仍然受限于4GB
虚拟内存空间。
2.10 mmap
mmap
(内存映射)是一种在Unix
和类Unix
系统(如Linux
)下将文件或其他内核对象映射到进程虚拟地址空间的技术。映射后,进程可以通过对应的内存地址直接访问文件或相关对象,而无需显式地进行文件读写操作。这种方法有诸多优势,如减少拷贝开销、提高数据访问速度、更好地支持文件共享等。
当使用mmap
函数创建内存映射时,实现以下操作:
- 将文件或其他对象映射到进程的虚拟地址空间。
- 指定映射区域的大小、访问权限(只读、读写等)以及映射的类型(私有映射、共享映射等)。
- 返回指向创建映射区域起始地址的指针。
一旦内存映射创建成功,进程可以直接通过虚拟地址访问映射文件。操作系统负责按需在内存与磁盘文件之间传输数据(即按需分页)。这允许进程使用其自然的内存访问机制(如指针和数组索引)在虚拟地址空间上操作文件数据。
与传统的文件读写方法相比,mmap
有一些优势:
- 性能提升:减少了数据在用户空间和内核空间之间的拷贝次数,因为内核可以直接映射磁盘文件页到用户进程的虚拟地址空间。
- 简化操作:实现对文件的读写操作可以像访问普通内存一样自然地进行,无需使用
read
和write
等文件操作函数。 - 内存共享:
mmap
允许多个进程同时映射同一个文件,进程间可以通过共享内存区域进行高效的数据交互。
mmap
的使用场景包括内存映射文件访问、共享内存的进程间通信(IPC
)和匿名内存映射等。需要注意的是,当使用mmap
时,要确保正确处理文件大小变化,以避免数据丢失。最后,完成操作后,务必使用munmap
函数撤销映射,释放内存资源。
2.11 Page Cache 是如何“诞生”的
Page Cache
的产生有两种不同的方式:
- Buffered I/O(标准 I/O);
- Memory-Mapped I/O(存储映射 I/O)。
2.12 为什么需要 PageCache
红色的地方就是Page Cache
,很明显,Page Cache
是内核管理的内存,也就是说,它属于内核不属于用户。
通过第一张图你其实已经可以直观地看到,标准I/O
和内存映射会先把数据写入到Page Cache
,这样做会通过减少I/O
次数来提升读写效率
Page Cache
存在的意义:减少I/O
,提升应用的I/O
速度。
2.13 KSwap 线程
rest_init
:0
号进程,唯一一个没有通过fork
或kernel_thread
产生的进程,是进程列表的第一个。kernel_init
:1
号进程是用户态祖先进程。KThreadAdd
:2
号进程是内核态所有线程运行的祖先。kswapd0
:父进程是2
号进程,专门的内核线程用来定期回收内存,也就是kswapd0
。为了衡量内存的使用情况,kswapd0
定义了三个内存阈值(watermark
,也称为水位),分别是
2.14 内存回收过程
应用在申请内存的时候,即使没有free
内存,只要还有足够可回收的Page Cache
,就可以通过回收Page Cache
的方式来申请到内存,回收的方式主要是两种:直接回收和后台回收。
2.15 Memory cgroup protection
Linux
内核实现了从系统层面调整来保护重要数据的机制,这个机制就是 memory cgroup protection
。
如果你想要保护你的 Page Cache 不被回收,你就可以考虑将你的业务进程放在一个 memory cgroup 中,然后设置 memory.{min,low} 来进行保护;与之相反,如果你想要尽快释放你的 Page Cache,那你可以考虑设置 memory.high 来及时的释放掉不活跃的 Page Cache。
2.16 Linux 是如何组织虚拟内存的
三、CPU Cache 扩展知识
3.1 CPU Cache 的产生背景
计算机中的所有运算操作都是由CPU
的寄存器来完成的,CPU
指令的执行过程需要涉及数据的读取和写入,这些数据只能来自于计算机主存(通常指RAM
)。
CPU
的处理速度和内存的访问速度差距巨大,直连内存的访问方式使得CPU
资源没有得到充分合理的利用,于是产生了在CPU
与主存之间增加高速缓存CPU Cache
的设计。
3.2 CPU Cache 模型
CPU Cache
模型,缓存分为三级L1/L2/L3
,由于指令和数据的行为和热点分布差异很大,因此将L1按照用途划分为L1i(instruction)
和L1d(data
).
在多核CPU
的结构中,L1
和L2
是CPU
私有的,L3
则是所有CPU
共享的。
3.3 什么是 Cache Line
Cache line
是 Cache
和 RAM
交换数据的最小单位,通常为 64 Byte
。当 CPU 把内存的数据载入 Cache
时,会把临近的共 64 Byte
的数据一同放入同一个Cache line
,因为空间局部性:临近的数据在将来被访问的可能性大。
由于CPU Cache
缓存数据最小的单位是一个Cache Line(64节)
,如果两个Core
读取了同一个Cache Line
,并对Cache Line
中的数据频繁读写,就会有Flase Sharing
的问题。
3.4 Flase Sharing 问题
上图中 thread1
位于 core1
,而 thread2
位于 core2
,二者均想更新彼此独立的两个变量,但是由于两个变量位于不同核心中的同一个 L1
缓存行中,此时可知的是两个缓存行的状态应该都是 Shared
,而对于同一个缓存行的操作,不同的 core
间必须通过发送 RFO
消息来争夺所有权 (ownership
) ,如果 core1
抢到了, thread1
因此去更新该缓存行,把状态变成 Modified
,那就会导致 core2
中对应的缓存行失效变成 Invalid
,当 thread2
取得所有权之后再去更新该缓存行时必须先让 core1
把对应的缓存行刷回 L3
缓存/主存,然后它再从 L3
缓存/主存中加载该缓存行进 L1
之后才能进行修改。然而,这个过程又会导致 core1
对应的缓存行失效变成 Invalid
,这个过程将会一直循环发生,从而导致 L1
高速缓存并未起到应有的作用,反而会降低性能;轮番夺取所有权不但带来大量的 RFO
消息,而且如果某个线程需要读此行数据时,L1
和 L2
缓存上都是失效数据,只有 L3
缓存上是同步好的数据,而从前面的内容可以知道,L3
的读取速度相比 L1/L2
要慢了数十倍,性能下降很大;更坏的情况是跨槽读取,L3
都不能命中,只能从主存上加载,那就更慢了。
CPU 缓存的最小的处理单位永远是缓存行 (Cache Line),所以当某个核心发送 RFO 消息请求把其他核心对应的缓存行设置成Invalid 从而使得 var1 缓存失效的同时,也会导致同在一个缓存行里的 var2 失效,反之亦然。
Cache Line
缓存测试
func main() {
arr := make([][]int, 64*1024)
for i := 0; i < len(arr); i++ {
arr[i] = make([]int, 1024)
}
now := time.Now()
for i := 0; i < len(arr); i++ {
for j := 0; j < 1024; j++ {
arr[i][j]++
}
}
timeSpan := time.Since(now).Microseconds()
fmt.Println("横向遍历耗时:", timeSpan)
now = time.Now()
for j := 0; j < 1024; j++ {
for i := 0; i < len(arr); i++ {
arr[i][j]++
}
}
timeSpan = time.Since(now).Microseconds()
fmt.Println("纵向遍历耗时:", timeSpan)
}
横向遍历耗时: 485995 //因为横向写数据的时候,会一直命中CPU缓存,所以比纵向更快一些
纵向遍历耗时: 1705150
3.5 如何解决False Sharding问题
对一些热点数据,如果想避免cache line
被其他Core
设置为失效,可以通过Pading的方式把每个项凑齐cache line
的长度,即可实现隔离,虽然这不可避免的会浪费一些内存。
我们可以看到golang
的源码里面 p struct
的也用了CacheLinePad
的方式来避免了False Sharding
的问题
type p struct {
上面省略
.....
runSafePointFn uint32 // if 1, run sched.safePointFn at next safe point
pad cpu.CacheLinePad
}
CacheLinePad
是cpu
包下面定义的一个64字节的数组
const CacheLinePadSize = 64
// CacheLinePad is used to pad structs to avoid false sharing.
type CacheLinePad struct{ _ [CacheLinePadSize]byte }
这样能保证p
的数据不管怎么拼接都不会跟其他数据在同一个cache line
中。
3.6 CPU Cache 是如何存放数据的
由上图可以知Cache
是由Set
组成,Set
由Cache Line
组成,Cache Line
由Valid Bit
(MESI协议中这个是2个字节),Tag
和Data
组成。其中Data
是真正要缓存的内存地址中的数据,而Tag
是用来搜索Cache Line
的标签。
假设L1 Cache
总大小为32KB
,8路组相连(每个Set有8个Cache Line
),每个Cache Line
的大小为64Byte
。
我们可以得到一个
Set大小 = 8 * Cache Line = 512Byte
Set个数 = 32*1024 /512 = 64
Cache Line Count = 32*1024 / 64 = 512个
3.7 CPU Cache 寻址过程
先看下内存地址表示的含义
内存被分成了TAG
、Set Index、Block Offset
三部分。
- 根据地址中的
Set Index
找到对应的缓存中对应的Set - 根据
Tag
与Set
中所有CacheLine
的Tag
一一对比,遇到相等的表示找到缓存。 - 查看
Cache Line
的Validate Bit
是不是有效的。有效的表示命中Cache
。 - 根据
Block Offset
读取Cache Line
中Block Data
对应的值。
3.8 CPU Cache 三种寻址方式
- 直接映射(
direct mapped cache
),相当于每个set只有1个cache line
(E=1)。那么相隔2^(s+b)个单元的2个内存单元,会被映射到同一个cache line
中。 - 组关联(
set associative cache
),多个set
,每个set
多个cache line
。一般每个set
有n
个cache line
,就说n-ways associative cache
。 - 全相联(
fully associative cache
),相当于只有1个set
,每个内存单元都能映射到任意的cache line
。带有这样cache
的处理器几乎没有。可以想见这种方式不适合大的缓存。想想看,如果4M 的大缓存linesize
为32Byte
,采用全相联的话,就意味着4 * 1024 * 1024/32 = 128K 个line
挨个比较,来确定是否命中,这是多要命的事情。
3.9 CPU Cache 的组织方式
VIVT(Virtual Index Virtual Tag)
使用虚拟地址做索引,虚拟地址做Tag
。早期的ARM
处理器一般采用这种方式,在查找cache line
过程中不借助物理地址,这种方式会导致cache
别名(cache alias
)问题。比如当两个虚拟地址对应相同物理地址,并且没有映射到同一cache
行,那么就会产生问题。另外,当发生进程切换时,由于页表可能发生变化,所以要对cache
进行invalidate
等操作,效率较低。
VIPT(Virtual Index Physical Tag)
使用虚拟地址做索引,物理地址做Tag
。在利用虚拟地址索引cache
同时,同时会利用TLB/MMU
将虚拟地址转换为物理地址。然后将转换后的物理地址,与虚拟地址索引到的cache line
中的Tag
作比较,如果匹配则命中。这种方式要比VIVT
实现复杂,当进程切换时,不在需要对cache
进行invalidate
等操作(因为匹配过程中需要借物理地址)。但是这种方法仍然存在cache
别名的问题(即两个不同的虚拟地址映射到同一物理地址,且位于不同的cache line
),但是可以通过一定手段解决。
PIPT(Physical Index Physical Tag)
使用物理地址做索引,物理地址做Tag
。现代的ARM Cortex-A
大多采用PIPT
方式,由于采用物理地址作为Index
和Tag
,所以不会产生cache alias
问题。不过PIPT
的方式在芯片的设计要比VIPT
复杂得多,而且需要等待TLB/MMU
将虚拟地址转换为物理地址后,才能进行cache line
寻找操作。
四、其他基础
4.1 Zero copy 有哪些实现
mmap()
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
sendfile()
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count)
sendfile() with DMA Scatter/Gather Copy
splice()
ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);
sendfile() + DMA Scatter/Gather
的零拷贝方案虽然高效,但是也有两个缺点:
- 这种方案需要引入新的硬件支持;
- 虽然
sendfile()
的输出文件描述符在Linux kernel 2.6.33
版本之后已经可以支持任意类型的文件描述符,但是输入文件描述符依然只能指向文件。
这两个缺点限制了sendfile() + DMA Scatter/Gather
方案的适用场景。为此,Linux
在2.6.17
版本引入了一个新的系统调用splice()
,它在功能上和sendfile()
非常相似,但是能够实现在任意类型的两个文件描述符时之间传输数据;而在底层实现上,splice()
又比sendfile()
少了一次CPU
拷贝,也就是等同于sendfile() + DMA Scatter/Gather
,完全去除了数据传输过程中的CPU
拷贝。
send() with MSG_ZEROCOPY
- 绕过内核的直接
I/O
- 用户直接访问硬件
- 内核控制访问硬件
- 内核缓冲区和用户缓冲区之间的传输优化
- 动态重映射与写时拷贝 (
Copy-on-Write
) - 缓冲区共享 (
Buffer Sharing
)
4.2 加密算法比较
Hash :MD5、SHA-1
对称算法:DES、3DES、AES
非对称算法 : RSA 、DSA
SHA-1
- 安全性高
- 速度慢
MD5
- 安全性低
- 速度快
DES
- 秘钥长度56位
- 速度中,消耗资源中
- 安全性低
3DES
- 秘钥长度112、168位
- 速度慢、消耗资源高
- 安全性中
AES
- 秘钥长度128、192、256位
- 速度快、消耗资源低
- 安全性高
RSA
- 安全性取决于密码长度,越长越安全
- 速度慢,消耗资源高
- 可以加密数据、数字签名
- RSA这种加密算法应用非常广泛,如SSH、HTTPS、TLS、电子证书、电子签名、电子身份证等
DSA
- 安全性取决于密码长度,越长越安全
- 运算快,消耗资源低
- 只能做数字签名
4.3 ELF
ELF
(可执行可链接格式,Executable and Linkable Format
)是一种用于编译过程中的二进制文件的通用格式。主要应用于操作系统如Linux
、Unix
、Solaris
和FreeBSD
等。它支持多种处理器体系结构,可用于存储程序可执行文件、可重定位目标文件和共享库。
这些都是ELF
文件中常见的节区,主要包含程序的各种数据,协助实现编译、链接和执行过程。以下是各节区的详细介绍:
-
.text:通常存储程序的可执行代码。它是只读的,因此连续的代码段可以合并以节省内存。在程序运行时,
.text
节区通常被加载到一块只读内存区域。 -
.rodata:存放只读数据,例如常量、字符串字面量等。这一节区也是只读的,可以与其他只读数据段合并。
-
.data:包含已初始化的全局变量和静态变量。这些变量在程序启动前就由操作系统赋予初始值。与
.text
和.rodata
节区不同,.data
节区允许读写访问。这些变量在程序执行过程中可读取或修改。 -
.bss:存放未初始化的全局变量和静态变量。由于所有变量在开始时都为
0
或空指针,因此无需在ELF
文件中为它们分配存储空间,只需记录其大小。当程序加载到内存后,这部分变量会被置零。与.data
节区类似,.bss
节区也允许读写访问。 -
.symtab:存放符号表,包含全局和局部符号的信息。符号表对链接过程至关重要,它可以定位变量和函数的地址。
-
.strtab:存放字符串表,其中包括符号名、节区名等字符串。这些字符串在链接和加载过程中用于解析符号引用和查找节区等操作。
-
.rel.text/.rela.text 和 .rel.data/.rela.data:这些节区包含重定位信息。它们帮助链接器进行符号和地址引用的修正。
.rel
表示使用相对重定位,.rela
表示使用绝对重定位。 -
.debug:含有调试信息,如变量名、行号等。该节区在正常执行过程中并非必须,主要用于调试和错误排查。
4.4 锁优化方向
- 优化锁的粒度
- 读写分离
- 减少锁持有时间。
- 使用
Atomic(CAS)
4.5 系统启动过程
PC
机通电-> 读取BIOS
固件 -> 根据读取启动设备(磁盘前512
字节)-> GRUB
引导 -> 加载磁盘系统iso
镜像 ->OS
4.6 常用的一些排查问题工具
从 vmstat 的输出可以得到上下文切换次数、中断次数、运行状态和不可中断状态的进程数。
从 pidstat 的输出可以得到进程的用户 CPU 使用率、系统 CPU 使用率、以及自愿上下文切换和非自愿上下文切换情况。
mpstat:使用mpstat -P ALL 1则可以查看每一秒的 CPU 每一核变化信息 - CPU密集/IO密集型查看
vmstat 1 -Sm 工具可以查看系统的内存、CPU 上下文切换以及中断次数
perf top -g -p <pid> 分析内部CPU情况 -g开启调用关系分析,-p指定进程号21515
free 查看内存信息
sar -n DEV 表示显示网络收发的报告,间隔1秒输出一组数据
tcpdump -i eth0 -n tcp port 80 抓tcp prot 80的包
使用blktrace跟踪磁盘I/O,注意指定应用程序正在操作的磁盘
dstat 10 1 CPU、磁盘 I/O
iostat -d -x -k 1 10 磁盘详细统计信息
ss -ltnp | head -n 3 查看队列,fd数量
ss -lnt 查看当前LISTEN数,
netstat -s 显示网络统计信息、协议栈统计信息
systemtap
memleak
ftrace
strace