逐行分析Python内存管理机制

572 阅读10分钟
原文链接: zhuanlan.zhihu.com

写这个的也是面试官的一道题, 问 python 中内存是怎么分配的, 其实这个问题是一个很复杂的问题, 因为涉及到好多个层次, 分别包括:

  • Python 对象内部的内存管理
  • Python 虚拟机的内存管理 (memory allocator)
  • malloc 内存管理
  • linux 的内存管理

这次就从机制上, 用代码, 介绍一下这几层内存管理的大致实现.

阅读的过程中, 可以重点关注单独自己层要解决的和其它层不一样的问题和他与其他层的相似之处.

Python 内部对象的内存管理

这里能说的比较少, 每个对象的面临的场景不一样, 简单来说, 比如 Python 的 List, list 每次发现容量满了的时候, 都会预先申请一些空间, 我们在 Objects/listobject.c 中 的 repr 方法里查看一下 list 的状态

这样 每次打印的时候 就能看的 list 状态

试一下

可以看到 只插了一个元素 却申请了 4 个元素空间

在插了到了 5 个元素空间变成了 8

其实其他的对象也有类似的机制, 会提前申请大一些的内存, 用来插入. 这样的目的也是减少从下级的内存分配器申请内存

那我们申请内存的下一级在哪里呢?

我们看 listobject.c 的代码

我们看到

大致猜想一下, PyObject_GC_New 这个应该就是下一层的内存分配器的接口, _PyObject_GC_TRACK 这个应该是处理垃圾回收的 暂时不管

Python 虚拟机的内存管理 (memory allocator)

不断的紧跟函数调用关系 在Objects/obmalloc.c 中 我们发现了

这个, 这个应该是 python 垃圾分配器的接口, 看了一堆麻烦的调用关系以后,

我们 找到了这个接口的实现, 首先他也是分层的, 分为 raw 和 mem, obj 三层, 由于 mem,obj用的一个分配器, 我们把它当做一个来看, 去看一下 PYMALLOC_ALLOC的实现

可以看到

红线一组目测就是我们要的东西

点开发现 同样的思想又来了, 先看看能不能申请成功不能就去下一层, python 虚拟机里这个限制是 512Byte, 下一层就是我们的刚刚看到的 raw_alloc 跟一下看到了

熟悉的 malloc 这里就不管了, 我们把重点放在 pymalloc_alloc 这个上面.

简单看一下逻辑, 大约意思是, 我们先从一个已经用过的 usedpool 池子里找, 没有了就从 allocate_from_new_pool , 最后返回一个 blocks数组, 代表申请的空间.

好, 从这里起, 我们就看到了 python 内存分配的核心, pool 和 block, 我们抛弃掉流程上的东西, 从 block pool , uesedpool 是什么开始研究,

内存块的申请和释放

点开定义

首先 block 就很简单了 就是一个 unit_8 表示一个字节的控件, pool 一眼就看出来这是一个双向链表,pool_head是链表的头指针

再看一下 used_pool

这个定义不是人能看懂的, 大约意思就是有一堆大小不同的 pool 每个俩个存起来, 我们运行起来打个断点看看, 每次调用这个都会不断,遇到空的,都会调用allocate_from_new_pool的初始化, 结合一些资料, 我们大约知道这个是一堆大小不同的 pool 连起来, 每一个上的szidx 是提供的 block数组 的大小, 用一半是为了快速根据大小定位到下标(不太懂)

我们把多个 block 组合在一起叫做内存块, pool 分配按照大小不同的内存块来分配

初始化的过程 代码位于 github.com/python/cpyt… 前面的申请pool过程暂时不表,重点是后面的步骤,很明确感觉是一个头插法, 不停地插入到 usedpools 所对应大小的头部:

初始化了一些字段:

  • 首先用 index2size 算出下标对应的块大小
  • bp 应该是头部的大小 一个pool 4k 减去头部就是块的大小
  • nextoffset 是还没用过空间的 offset
  • maxnextooffset 应该是最大的空间
  • freeblock 就是指向首个空闲的, 然后这个空闲的内存块指向了 NULL, 这里应该是这样一个结构

熟悉 stl 的sgi_alloc的同学知道, 有一种优化内存分配器链表占用空间的手段的方法, 就是把块空的时候的值设置成下一个节点的地址, 这样既可以拉俩表, 有省去了一个指针的空间

初始化后的usedpool 大约如下图所示

可以参考这个图的结构:

好, 知道这些了我们看看怎么用的,

我们看到大约就是 增加一下使用的 内存块, 把内存块从俩表摘出来, 如果内存块是末节点, 就拓展, 如果不是末节点, 就说明这里正好是一个空闲的块, 那么直接把这个拿出来, 让 freeblock 指向他的下一个节点就好 (头部删除). 目测这里的拓展就是把尾巴的那一个大块切出来分成小块.

拓展的逻辑, 就是从尾巴拿空间, 扣除一个块

大约就是这么一个转换, 如果尾巴拿不出来了, 那说明这个 pool已经满了, 就把整个 pool 删除.

注解 这里我们就看懂了 从 pool 拿一个 block 的所有过程, 总体上来看 是一个对 以freeblock 为俩表头结点的 next 指针的一条链表,进行头部操作, 从头部删除结点并且把剩余的插入到头部, 以此来达到高效操作,

看完了这里, 我们基本搞懂了申请一个内存块的逻辑, 那么归还一个内存块的逻辑就是自然而然了

我们打开 pymalloc_alloc_free 函数 github.com/python/cpyt…

一看, 涉及到的逻辑简直不要太简单, 核心逻辑就是一个头插法, 先通过内存大小 + 偏移量, 算出pool_head 的位置(好骚啊), 然后进 pool 头插一梭子, 就完事了

到此位置, 我们对于 pool 的 内存块的申请和释放已经了如指掌了, 不过, 还有几多乌云没有解开, 那就是 github.com/python/cpyt… 前面那坨逻辑 和 pymalloc_free 的 insert_too_freepool 的逻辑, 接下来就轮到 arena 出场了

pool的牧羊人 arena

首先我们看一下 arena 的定义

又是一个眼熟的双链表, 内存分配器真的很喜欢这个数据结构, 里面简单看了一下, 主要是管理 pool 的信息, 一个是 pool 的链表首节点, 一个是数量, 数量包括, 可以用的 (nfreepools) 和总共的(ntotalpool), 一减就是不可以用的了

我们再次返回 allocate_from_new_pool 中,

这个代码比较长, 大约可以分为 3 部分:

  1. 如果当前没有可用的 arenas 去申请一个, usable_arenas是一个全局变量
  2. 维护nfp2lasta 就是一个用来快速找到满足数量的 freepool 最少的 freepool 的表
  3. 拉出来 pool, 作为内存块

我们先看看一个 arena 怎么创建的吧, 就是new_arena, 去看看这个函数做了什么

这部分可以分成三个大块, 分别是 :

  1. 已经没有不可用 arena 时 申请新的(arena)
  2. 不可用的arena (ununsed_arena_object) 的第一个摘出来, 并且初始化, 值得一提的是, 他freeppools 初始化成了 NULL,pool_address是指向了arena 代表的头部

我们在看看 allocate_from_new_pool 是怎么使用 pool 的,

首先让 pool 等于 freepools, 然后做了个判断, 这里我们先不讨论 freepools 不为空的情况, 这个应该和释放的逻辑有关. 我们看为空的情况

简单的一看, 就是一个从头部截取一块内存(POOL_SIZE 4KB)当做链表的一项. 检查这个 arena 的剩余的 pool 得数量, 没了就从可用链表中删除.

到这里为止, 我们看懂了从一个初始化好的 arena 拿走一个 pool 的过程:
1. 新的 arena 申请好了会放在usable_arena 中

2. 从 usable_arena中的 pool_address 形成的链表中拿出来一个 pool, 拿完了发现没剩余的了就删除他

理解了拿去, 我们开始看 unused_arena_object 和 free_pool 是怎么在 pool 归还中发挥作用

接下来 我们看 pymalloc_free 的 insert_too_freepool ,

这个函数分成俩部分, 开头的逻辑是一个通用的归还. 下面的逻辑是根据不同的情况做不同的处理, 归还的逻辑比较简单, 先把链表放回去, 让 freepool 指向了归还的这个 pool, 这里没有做节约内存, 直接用的 pool 的指针, 然后找到这个链表的 arena 从一个 全局变量arenas 目测是所有的 arenas, 该增减增加. 问题是之后的分情况讨论

他这个注释写的也比较清楚, 我们就跟着注释看源码:

  • case1 :如果所有的pool归还了 就 free 掉:

总共三步, 1.usale 删除这个节点, 2. arena结构体放入unsed_arenas 里 3. 释放所有内存

  • case2: 只归还一个 pool

只归还一个 pool 就把它放入 usable_arenas 的头部, 底下处理一下查找表

  • case3: case3 是一个子步骤, 每次插入完了都检查一下顺序 让 空闲 pool 数量少的 arena 放在前面, 这里就不贴代码了可以看 github.com/python/cpyt… 这里

综上所述 , 我们心中可以有这么一个结构:

  • 每个 arena 放着一堆 pool,
  • 全局 arenas 放着所有 arena
  • usable_arenas_object 放着可以用的 arenas 指的一个分配好pool 的 arena, 按着空闲的 pool 的数量排序的一个单链表
  • unused_arena_object 放着 没有分配好 pool 的 arena, 一般是释放了所有使用过的 pool 得 arena, 会把结构放在这里
  • arena 的 pool_address 是一个未被使用过的链表的头, freepool 是归还回来的 pool 的链表

再看一下之前未解决的 allocate_from_new_pool 的 freepool 不为空的情况:

直观感受就是一个头部删除....没什么可说的了

总结

至此, 我们理解完 python 虚拟机的内存管理的流程了, 首先 利用这套机制的内存申请大小在 SMALL_REQUEST_THRESHOLD 限制 (这个是 512 byte), 大于这个会直接使用 malloc 申请.

然后我们申请的单位是内存块 由多个 block(1byte) 组成, 申请 block 向 pool 申请, 一个 pool 大小 为 POOL_SIZE , 默认 4kb 和系统页大小一致, pool 的内存就是内存块的内存, pool 里的内存块用一种压缩的链表的方式连接在一起.并且用 used_pool 作为一个缓存. 可以直接拿到 可以用的pool.

pool 不够了要向 arena 申请, arena 是一个管理 pool 的东西, 他一块里有未使用过的 pool和 已经归还的 pool 得信息, 优先会使用 usable_arena_object链表上的 arena 里拿 pool, 这个按照剩余可用的 pool 升序排列, 没有了会从 unusable_arena_object 拿 arena 分配内存构建 arena 放入 usable_arena

释放内存也是先释放 block, 用头插法插入 pool 中, 如果一个 pool 全部释放了, 就放回 arena里, 如果一个 arena 里所有的 pool 都释放了, 就放回 unused_pool_object, 否则根据剩余的pool 数量, 提升自己在 unabel_pool_object的位置.

最后我们可以用

来查看 python 虚拟机这层内存分配的一些状态信息

下一篇会分析 malloc 的实现, 马上就要看到一种浓浓的我好像在哪里见过的感觉了哈哈哈

参考资料

[1]. github.com/zpoint/CPyt…

[2]. github.com/python/cpyt…