美文网首页
Linux内存管理机制

Linux内存管理机制

作者: cj3479 | 来源:发表于2020-02-08 11:09 被阅读0次

    Linux内存管理涉及的面比较广泛而且比较复杂,这里只抽取部分知识来讲解

    一 早期的内存分配机制

    在早期的计算机中,要运行一个程序,需要把程序全部加载到物理内存(可以理解为内存条上的内存, 所有的程序运行都是在内存中运行,cpu运行程序时,如果要访问外部存储如磁盘,那么必须先把磁盘内存拷贝到内存中cpu才能操作,内存是cpu和外部存储的桥梁),如果,我们的一个计算机只运行一个程序,那么只有这个程序所需要的内存空间不超过物理内存空间的大小,就不会有问题。但是,我们正在希望的是在某个时候同时运行多个程序。那么这个时候,就会有个一个问题,计算机如何把有限的物理内存分配给多个程序使用呢?
    某台计算机总的物理内存大小是128M,现在同时运行两个程序A和B,A需占用内存10M,B需占用内存110。计算机在给程序分配内存时会采取这样的方法:先将内存中的前10M分配给程序A,接着再从内存中剩余的118M中划分出110M分配给程序B。这种分配方法可以保证程序A和程序B都能运行,但是这种简单的内存分配策略问题很多。


    早期的内存分配方法

    问题1:进程的地址空间隔离,因为他们直接访问的物理地址,所以恶意程序可以随意修改别的进程的内存数据,以达到破坏的目的。有些非恶意的,但是有 bug 的程序也可能不小心修改了其它程序的内存数据,就会导致其它程序的运行出现异常。这种情况对用户来说是无法容忍的,因为用户希望使用计算机的时候,其中一个任务失败了,至少不能影响其它的任务。

    问题2:内存使用效率低,接着上面的例子来说,计算机总的内存大小是128M,A占用10M,B占用110M,如果此时要运行一个占用内存20M的C程序,那么怎么办呢?
    做法是把当前没有运行的B程序内存全部拷贝到磁盘空间,释放内存,然后再将C程序加载到内存,待再次运行B时,再将其他没有运行的程序拷贝到磁盘,腾出内存给B使用,就这样拆了东墙补西墙,可以看到这里内存拷贝都是整个程序内存开拷贝,非常低效,注意这里是程序的整块内存与磁盘拷入烤出,存在大量的数据装入装出,导致效率十分低下。

    问题3:程序运行的地址不确定。当内存中的剩余空间可以满足程序 C 的要求后,操作系统会在剩余空间中随机分配一段连续的 20M 大小的空间给程序 C 使用,因为是随机分配的,所以程序运行的地址是不确定的。

    二 分段

    前面描述了早期的内存分配机制和存在的问题,那么我们这部分就来解决这些问题,
    为了解决这些问题,人们提出了以一种方案,增加中间层,利用一种间接的地址访问方法访问物理内存。按照这种方法,程序中访问的内存地址不在是真实的物理地址,而是一个虚拟地址,然后操作系统将这个虚拟地址映射待到适当的物理地址。这样只要操作系统处理好虚拟地址到物理内存地址的映 射,就可以保证不同的程序最终访问的内存地址位于不同的区域,彼此没有重叠,就可以达到内存地址空间隔离的效果。
    当创建一个进程时,操作系统会为该进程分配一个 4GB 大小的虚拟进程地址空间。之所以是 4GB ,是因为在 32 位的操作系统中,一个指针长度是 4 字节,而 4 字节指针的寻址能力是从 0x00000000~0xFFFFFFFF ,最大值 0xFFFFFFFF 表示的即为 4GB 大小的容量。与虚拟地址空间相对的,还有一个物理地址空间,这个地址空间对应的是真实的物理内存。如果你的计算机上安装了 512M 大小的内存,那么这个物理地址空间表示的范围是 0x00000000~0x1FFFFFFF 。当操作系统做虚拟地址到物理地址映射时,只能映射到这一范围,操作系统也只会映射到这一范围。当进程创建时,每个进程都会有一个自己的 4GB 虚拟地址空间。要注意的是这个 4GB 的地址空间是“虚拟”的,并不是真实存在的,而且每个进程只能访问自己虚拟地址空间中的数据,无法访问别的进程中的数据,通过这种方法实现了进程间的地址隔离。那是不是这 4GB 的虚拟地址空间应用程序可以随意使用呢?很遗憾,在 Windows 系统下,这个虚拟地址空间被分成了 4 部分: NULL 指针区、用户区、 64KB 禁入区、内核区。应用程序能使用的只是用户区而已,大约 2GB 左右 ( 最大可以调整到 3GB) 。内核区为 2GB ,内核区保存的是系统线程调度、内存管理、设备驱动等数据,这部分数据供所有的进程共享,但应用程序是不能直接访问的。如下图


    “549A3ABC-8DC7-489F-AD07-6FEA29AB0B76”的副本.jpg

    人们之所以要创建一个虚拟地址空间,目的是为了解决进程地址空间隔离的问题。但程序要想执行,必须运行在真实的内存上,所以,必须在虚拟地址与物理地址间建立一种映射关系。这样,通过映射机制,当程序访问虚拟地址空间上的某个地址值时,就相当于访问了物理地址空间中的另一个值。人们想到了一种分段 (Sagmentation) 的方法,它的思想是在虚拟地址空间和物理地址空间之间做一一映射。比如说虚拟地址空间中某个10M 大小的空间映射到物理地址空间中某个10M 大小的空间,可以多对一,就是说可虚拟地址的多个10M大小的空间可以映射到相同的物理地址空间的某个10M。这种思想理解起来并不难,操作系统保证不同进程的地址空间被映射到物理地址空间中不同的区域上,这样每个进程最终访问到的

    物理地址空间都是彼此分开的。通过这种方式,就实现了进程间的地址隔离。还是以实例说明,假设有两个进程 A 和 B ,进程 A 所需内存大小为 10M ,其虚拟地址空间分布在 0x00000000 到 0x00A00000 ,进程 B 所需内存为 100M ,其虚拟地址空间分布为 0x00000000 到 0x06400000 。那么按照分段的映射方法,进程 A 在物理内存上映射区域为 0x00100000 到 0x00B00000 ,,进程 B 在物理内存上映射区域为 0x00C00000 到 0x07000000 。于是进程 A 和进程 B 分别被映射到了不同的内存区间,彼此互不重叠,实现了地址隔离。从应用程序的角度看来,进程 A 的地址空间就是分布在 0x00000000 到 0x00A00000 ,在做开发时,开发人员只需访问这段区间上的地址即可。应用程序并不关心进程 A 究竟被映射到物理内存的那块区域上了,所以程序的运行地址也就是相当于说是确定的了


    分段方式的内存映射方法

    这 种分段的映射方法虽然解决了上述中的问题一和问题三,但并没能解决问题二,即内存的使用效率问题。在分段的映射方法中,每次换入换出内存的都是整个程序,这样会造成大量的磁盘访问操作,导致效率低下。所以这种映射方法还是稍显粗糙,粒度比较大。实际上,程序的运行有局部性特点,在某个时间段内,程序只是访 问程序的一小部分数据,也就是说,程序的大部分数据在一个时间段内都不会被用到。基于这种情况,人们想到了粒度更小的内存分割和映射方法,这种方法就是分页(Paging)

    二 分页

    分页就是解决分段中整个程序的所有内存与磁盘换入换出的问题,就是把内存分为更小的粒度便于按需与磁盘置换空间,没必要置换整块内存
    分页的基本方法是,将地址空间分成许多的页。每页的大小由 CPU 决定,然后由操作系统选择页的大小。目前 Inter 系列的 CPU 支持 4KB 或 4MB 的页大小,而 PC 上目前都选择使用 4KB 。按这种选择, 4GB 虚拟地址空间共可以分成 1048576 个页, 512M 的物理内存可以分为 131072 个页。显然虚拟空间的页数要比物理空间的页数多得多。

    在分段的方法中,每次程序运行时总是把程序全部装入内存,而分页的方法则有所不同。分页的思想是程序运行时用到哪页就为哪页分配内存,没用到的页暂时保留在硬盘上。当用到这些页时再在物理地址空间中为这些页分配内存,然后建立虚拟地址空间中的页和刚分配的物理内存页间的映射。

    下面通过介绍一个可执行文件的装载过程来说明分页机制的实现方法。一个可执行文件 (PE 文件 ) 其实就是一些编译链接好的数据和指令的集合,它也会被分成很多页,在 PE 文件执行的过程中,它往内存中装载的单位就是页。当一个 PE 文件被执行时,操作系统会先为该程序创建一个 4GB 的进程虚拟地址空间。前面介绍过,虚拟地址空间只是一个中间层而已,它的功能是利用一种映射机制将虚拟地址空间映射到物理地址空间,所以,创建 4GB 虚拟地址空间其实并不是要真的创建空间,只是要创建那种映射机制所需要的数据结构而已,这种数据结构就是页目和页表。

    当创建完虚拟地址空间所需要的数据结构后,进程开始读取 PE 文件的第一页。在 PE 文件的第一页包含了 PE 文件头和段表等信息,进程根据文件头和段表等信息,将 PE 文件中所有的段一一映射到虚拟地址空间中相应的页 (PE 文件中的段的长度都是页长的整数倍 ) 。这时 PE 文件的真正指令和数据还没有被装入内存中,操作系统只是根据 PE 文件的头部等信息建立了 PE 文件和进程虚拟地址空间中页的映射关系而已。当 CPU 要访问程序中用到的某个虚拟地址时,当 CPU 发现该地址并没有相相关联的物理地址时, CPU 认为该虚拟地址所在的页面是个空页面, CPU 会认为这是个页错误 (Page Fault) , CPU 也就知道了操作系统还未给该 PE 页面分配内存, CPU 会将控制权交还给操作系统。操作系统于是为该 PE 页面在物理空间中分配一个页面,然后再将这个物理页面与虚拟空间中的虚拟页面映射起来,然后将控制权再还给进程,进程从刚才发生页错误的位置重新开始执行。由于此时已为 PE 文件的那个页面分配了内存,所以就不会发生页错误了。随着程序的执行,页错误会不断地产生,操作系统也会为进程分配相应的物理页面来满足进程执行的需求。

    分页方法的核心思想就是当可执行文件执行到第 x 页时,就为第 x 页分配一个内存页 y ,然后再将这个内存页添加到进程虚拟地址空间的映射表中 , 这个映射表就相当于一个 y=f(x) 函数。应用程序通过这个映射表就可以访问到 x 页关联的 y 页了。
    其实这个页错误更专业的说法是缺页异常,此时会产生页中断就是把进程需要的数据从磁盘上拷贝到物理内存中,如果内存已经满了,操作系统可以暂时将不用页退避到磁盘, 调入马上要使用的页,另一种说法是找一个页覆盖,当然如果被覆盖的页曾经被修改过,需要将此页写回磁盘。

    三 地址空间
    • 虚拟地址的由来,其实这个在分段的知识点里面已经说了,进程隔离,物理内存进行映射,是一个大内存的程序可以在较小的物理内存上运行
      我们平时操作的内存其实都是通过操作虚拟地址的内存单元。通过MMU(内存管理单元)的映射来间接的操作我们的物理地址


      15774762-b0ec5de9cf127f27.png

    对虚拟内存的理解

    第一层理解

    1.每个进程都有自己独立的4G内存空间,各个进程的内存空间具有类似的结构。

    2.一个新进程建立的时候,将会建立起自己的内存空间,此进程的数据,代码等从磁盘拷贝到自己的进程空间,哪些数据在哪里,都由进程控制表中的task_struct记录,task_struct中记录中一条链表,记录中内存空间的分配情况,哪些地址有数据,哪些地址无数据,哪些可读,哪些可写,都可以通过这个链表记录。

    3.每个进程已经分配的内存空间,都与对应的磁盘空间映射。

    问题:计算机明明没有那么多内存(n个进程的话就需要n*4G)内存建立一个进程,就要把磁盘上的程序文件拷贝到进程对应的内存中去,对于一个程序对应的多个进程这种情况,浪费内存!

    第二层理解

    1.每个进程的4G内存空间只是虚拟内存空间,每次访问内存空间的某个地址,都需要把地址翻译为实际物理内存地址。

    2.所有进程共享同一物理内存,每个进程只把自己目前需要的虚拟内存空间映射并存储到物理内存上。

    3.进程要知道哪些内存地址上的数据在物理内存上,哪些不在,还有在物理内存上的哪里,需要用页表来记录。

    4.页表的每一个表项分两部分,第一部分记录此页是否在物理内存上,第二部分记录物理内存页的地址(如果在的话)。

    5.当进程访问某个虚拟地址,去看页表,如果发现对应的数据不在物理内存中,则缺页异常。

    6.缺页异常的处理过程,就是把进程需要的数据从磁盘上拷贝到物理内存中,如果内存已经满了,没有空地方了,那就找一个页覆盖,当然如果被覆盖的页曾经被修改过,需要将此页写回磁盘。

    总结:

    优点:

    1.既然每个进程的内存空间都是一致而且固定的,所以链接器在链接可执行文件时,可以设定内存地址,而不用去管这些数据最终实际的内存地址,这是有独立内存空间的好处。

    2.当不同的进程使用同样的代码时,比如库文件中的代码,物理内存中可以只存储一份这样的代码,不同的进程只需要把自己的虚拟内存映射过去就可以了,节省内存。

    3.在程序需要分配连续的内存空间的时候,只需要在虚拟内存空间分配连续空间,而不需要实际物理内存的连续空间,可以利用碎片。

    另外,事实上,在每个进程创建加载时,内核只是为进程“创建”了虚拟内存的布局,具体就是初始化进程控制表中内存相关的链表,实际上并不立即就把虚拟内存对应位置的程序数据和代码(比如.text .data段)拷贝到物理内存中,只是建立好虚拟内存和磁盘文件之间的映射就好(叫做存储器映射),等到运行到对应的程序时,才会通过缺页异常,来拷贝数据。还有进程运行过程中,要动态分配内存,比如malloc时,也只是分配了虚拟内存,即为这块虚拟内存对应的页表项做相应设置,当进程真正访问到此数据时,才引发缺页异常。

    对虚拟地址空间对应实际物理地址的理解
    虚拟地址和物理地址

    四 共享内存

    共享内存是进程间通信中最简单的方式之一。共享内存允许两个或更多进程访问同一块内存,就如同 malloc() 函数向不同进程返回了指向同一个物理内存区域的指针。当一个进程改变了这块地址中的内容的时候,其它进程都会察觉到这个更改。
    采用共享内存通信的一个显而易见的好处是效率高,因为进程可以直接读写内存,而不需要任何数据的拷贝。对于像管道和消息队列等通信方式,则需要在内核和用户空间进行四次的数据拷贝,而共享内存则只拷贝两次数据:一次从输入文件到共享内存区,另一次从共享内存区到输出文件。实际上,进程之间在共享内存时,并不总是读写少量数据后就解除映射,有新的通信时,再重新建立共享内存区域。而是保持共享区域,直到通信完毕为止,这样,数据内容一直保存在共享内存中,并没有写回文件。共享内存中的内容往往是在解除映射时才写回文件的。因此,采用共享内存的通信方式效率是非常高的。

    • linux的数据拷贝次数
      关于共享内存的拷贝次数有说法是2次,有的说法是0次,为什么会出现这两种说呢?为了解释这个说下,我们先来了解下以下几个知识
      拷贝次数:统计cpu拷贝数据从一个存储区到一个存储区的次数.

      传统 IO 数据拷贝原理
      在正式分析零拷贝机制原理之前,我们先来看下传统 IO 在数据拷贝的基本原理,从数据拷贝 (I/O 拷贝) 的次数以及上下文切换的次数进行对比分析。

      传统 IO:
      从上图可以看出
      1.用户空间进程内发起 read() 系统调用到内核空间(第一次上下文切换,从用户空间到内核空间)
      2.通过 DMA 引擎建数据从磁盘拷贝到内核态空间的缓冲区中(第一次拷贝
      3.将内核态空间缓冲区的数据原封不动的拷贝到用户态空间的缓存区中(第二次拷贝),同时内核态空间切换到用户态空间(第二次上下文切换),read() 系统调用结束
      4.用户空间发起write系统调用
      5.操作系统由用户空间切换到内核空间(第三次上下文切换),将用户态空间的缓存区数据原封不动的拷贝到内核态空间输出的缓存区中(第三次拷贝
      6.write系统调用返回,操作系统由内核态空间切换到用户态空间(第四次上下文切换),通过 DMA 引擎将数据从内核态空间的 缓存区数据拷贝到协议引擎中(第四次拷贝),注意write的系统调用不一定会马上触发缓冲区的数据到磁盘中,内核会把要写的数据暂时存在缓冲区中,积累到一定数量后再一 次写入磁盘,这也是减少磁盘的写操作

    从上面的分析来看,传统 IO 方式,一共在用户态空间与内核态空间之间发生了 4 次上下文的切换,4 次数据的拷贝过程,其中包括 2 次 DMA 拷贝和 2 次 I/O 拷贝(内核态与用户应用程序之间发生的拷贝),这里从系统的角度来看,是拷贝了4次,从cpu的角度来看,是拷贝了两次

    内核空间缓冲区一大用处是为了减少磁盘I/O操作,因为它会从磁盘中预读更多的数据到缓冲区中。这样当用户空间从内核空间读取数据时,会查看内核缓冲区是否有数据,有数据的话,直接从内核缓冲区读取,没有的话,内核才会从磁盘拷贝数据,而且一般都会预读取更多的数据到缓存,
    用户空间缓存区的用处是减少 用户空间到内核空间的上下文切换时间,也就是系统调用时间。用户空间从内核空间一次复制较大的(不能太大)地数据到用户空间,当用户空间程序每次需要数据时,直接从用户空间的缓冲中获取,避免频繁read系统调用,切换到内核空间。下面这篇文章对缓冲区有很好的解释
    文件 I/O 的内核缓冲

    什么是DMA
    DMA(Direct Memory Access)—直接内存访问 :DMA是允许外设组件将 I/O 数据直接传送到主存储器中并且传输不需要 CPU 的参与,以此将 CPU 解放出来去完成其他的事情。

    零拷贝:
    维基上是这么描述零拷贝的:零拷贝描述的是CPU不执行拷贝数据从一个存储区域到另一个存储区域的任务,这通常用于通过网络传输一个文件时以减少CPU周期和内存带宽,这里零拷贝强调的是cpu不执行拷贝数据从一个存储区域到另一个存储区域任务,也就是说这里的拷贝次数是统计cpu拷贝数据从一个存储区到一个存储区的次数.
    从描述中已经了解到零拷贝技术给我们带来的好处:
    1、节省了 CPU 周期,空出的 CPU 可以完成更多其他的任务
    2、减少了内存区域之间数据拷贝,节省内存带宽
    3、减少用户态和内核态之间数据拷贝,提升数据传输效率
    4、应用零拷贝技术,减少用户态和内核态之间的上下文切换

    很显然内存共享按照零拷贝的概念来说,它就是零拷贝,下面展示下用mmap来实现内存共享的过程
    mmap():内存映射,简而言之就是将用户空间的一段内存区域映射到内核空间,映射成功后,用户对这段内存区域的修改可以直接反映到内核空间,同样,内核空间对这段区域的修改也直接反映用户空间。那么对于内核空间<---->用户空间两者之间需要大量数据传输等操作的话效率是非常高的
    mmap()系统调用使得进程之间通过映射同一个普通文件实现共享内存。普通文件被映射到进程地址空间后,进程可以像访问普通内存一样对文件进行访问,不必再调用read(),write()等操作。

    注:实际上,mmap()系统调用并不是完全为了用于共享内存而设计的。它本身提供了不同于一般对普通文件的访问方式,进程可以像读写内存一样对普通文件的操作。而Posix或System V的共享内存IPC则纯粹用于共享目的,当然mmap()实现共享内存也是其主要应用之一


    mmap (1).png

    从上图可以看出用户空间的读写没有调用read和write,是直接通过映射的内核空间来读写,这样与内核空间没有拷贝数据,但是内核空间与在读写过程中发生两次拷贝,但是不属于CPU拷贝,是DMA拷贝,
    所以共享内存是发生两次拷贝的说法是从整个系统来看的,它确实发生了两次DMA拷贝,0次拷贝的说法是从cpu角度来看的,cpu没有发生内核空间和用户空间的拷贝。两种说法都没问题

    针对mmap的用法直接给出两个例子吧
    一个进程写共享内存

    #include "TestMmapWrite.h"
    #include <sys/mman.h>
    #include <fcntl.h>
    #include <sys/mman.h>
    #include <sys/types.h>
    #include <unistd.h>
    #include <stdio.h>
    #include <stdlib.h>
    #include <errno.h>
    #include <string.h>
    
    typedef struct
    {
        char name[32];
        int age;
    } people;
    
    void testMMapWrtire(){
        people* p_map;
        char temp = 'o';
    
        int fd = open("../../2.txt", O_CREAT|O_RDWR|O_TRUNC, 00777);
        if (-1 == fd)
        {
            printf("open file error = %s\n", strerror(errno));
            return ;
        }
      // 调整fd所指的文件的大小到length
        ftruncate(fd, sizeof(people)*10);
        printf("initialize mmap start\n");
        p_map = (people*)mmap(NULL, sizeof(people)*10, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
        if (MAP_FAILED == p_map)
        {
            printf("mmap file error = %s\n", strerror(errno));
            return;
        }
        printf("initialize start\n");
        for(int i = 0; i < 10; i++)
        {
            memcpy( ( *(p_map+i) ).name, &temp, 1);
            printf("copy name[0]=%c,name=%s\n", (*(p_map+i) ).name[0],(*(p_map+i) ).name);
            ( *(p_map+i) ).name[1] = 0;
            ( *(p_map+i) ).age = 20+i;
            temp += 1;
            printf("copy name=%s,temp=%c\n", (*(p_map+i) ).name,temp);
        }
        printf("initialize over\n");
    
        close(fd);
        //该调用在进程地址空间中解除一个映射关系
        munmap(p_map, sizeof(people)*10);
          //一般说来,进程在映射空间的对共享内容的改变并不直接写回到磁盘文件中,往//往在调用munmap()后才执行该操作。可以通过调用msync()实现磁盘上文件内容与共享内存区的内容一致。
    //    msync(p_map, sizeof(people)*10, MS_SYNC );
        printf("umap ok \n");
    }
    

    一个进程读共享内存

    #include <iostream>
    #include <fcntl.h>
    #include <sys/mman.h>
    #include <sys/types.h>
    #include <unistd.h>
    #include <stdio.h>
    #include <stdlib.h>
    #include <errno.h>
    #include <string.h>
    #include <sys/stat.h>
    
    using namespace std;
    typedef struct
    {
        char name[32];
        int age;
    } people;
    
    int main(int argc, char** argv)
    {
        people* p_map;
        struct stat filestat;
    
        int fd = open("../../2.txt", O_CREAT|O_RDWR, 00777);
        if (-1 == fd)
        {
            printf("open file error = %s\n", strerror(errno));
            return -1;
        }
      // 获取fd所指的文件的详细信息
        fstat(fd, &filestat);
        printf("open file length = %d\n", filestat.st_size);
       p_map = (people*)mmap(NULL, filestat.st_size, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
        //p_map = (people*)mmap(NULL, sizeof(people)*10, PROT_READ|PROT_WRITE, MAP_PRIVATE, fd, 0);
        if (MAP_FAILED == p_map)
        {
            printf("mmap file error = %s\n", strerror(errno));
            return -1;
        }
        printf("umap p_map=%p name=%s\n",p_map->name);
    //    for(int i = 0; i < 10; i++)
    //    {
    //        printf("name = %s, age = %d\n",(*(p_map+i)).name, (*(p_map+i)).age);
    //    }
    //    sleep(10);
        for(int i = 0; i < 10; i++)
        {
            printf("after name = %s, age = %d\n",(*(p_map+i)).name, (*(p_map+i)).age);
        }
        printf("umap p_map=%p name=%s\n",p_map->name);
        close(fd);
        munmap(p_map, sizeof(people)*10);
        printf("umap ok \n");
        return 0;
    }
    

    参考链接:
    对虚拟地址空间对应实际物理地址的理解
    虚拟地址和物理地址
    文件 I/O 的内核缓冲
    Linux 内核详解以及内核缓冲区技术
    linux零拷贝共享内存
    linux内存映射mmap原理分析和共享内存的两篇转载文章

    相关文章

      网友评论

          本文标题:Linux内存管理机制

          本文链接:https://www.haomeiwen.com/subject/xpduxhtx.html