首页 > 系统相关 >python 内存优化

python 内存优化

时间:2024-02-24 21:22:05浏览次数:21  
标签:空闲 字节 python 链表 Python 内存 优化 pool

Python 内存管理层次:

图片描述

众所周知,计算机硬件资源由操作系统负责管理,内存资源也不例外。应用程序通过 系统调用 向操作系统申请内存,而 C 库函数则进一步将系统调用封装成通用的 内存分配器 ,并提供了 malloc 系列函数。

C 库函数实现的通用目的内存管理器是一个重要的分水岭,即内存管理层次中的 第 0 层 。此层之上是应用程序自己的内存管理,此层之下则是隐藏在冰山下方的操作系统部分。

操作系统内部是一个基于页表的虚拟内存管理器 (第 - 1 层),以 页 ( page ) 为单位管理内存,CPU 内存管理单元 ( MMU ) 在这个过程中发挥重要作用。虚拟内存管理器下方则是底层存储设备 ( 第 - 2 层),直接管理物理内存以及磁盘等二级存储设备。

绿色部分则是 Python 自己的内存管理,分为 3 层:

  • 第 1 层,是一个内存分配器,接管一切内存分配,内部是本文的主角 —— 内存池 ;
  • 第 2 层,在第 1 层提供的统一 PyMem_XXXX 接口基础上,实现统一的对象内存分配 ( object.tp_alloc );
  • 第 3 层,为特定对象服务,例如前面章节介绍的 float 空闲对象缓存池;

那么,Python 为什么不直接使用 malloc 系列函数,而是自己折腾一遍呢?原因主要是以下几点:

  • 引入内存池,可化解对象频繁创建销毁带来的内存分配压力;
  • 最大程度避免内存碎片化,提升内存利用效率;
  • malloc 有很多实现版本,不同实现性能千差万别;

内存碎片的挑战

内存碎片化 是困扰经典内存分配器的一大难题,碎片化导致的结果也是惨重的。这是一个典型的内存碎片化例子:

图片描述

虽然还有 1900K 的空闲内存,但都分散在一系列不连续的碎片上,甚至无法成功分配出 1000K 。

那么,如何避免内存碎片化呢?想要解决问题,必先分析导致问题的根源。

我们知道,应用程序请求内存块尺寸是不确定的,有大有小;释放内存的时机也是不确定的,有先有后。经典内存分配器将不同尺寸内存块混合管理,按照先来后到的顺序分配:

图片描述

当大块内存回收后,可以被分为更小的块,然后分配出去:

图片描述

而先分配的内存块未必先释放,慢慢地空洞就出现了:

图片描述

随着时间的推移,碎片化会越来越严重,最终变得支离破碎:

图片描述

由此可见,将不同尺寸内存块混合管理,将大块内存切分后再次分配的做法是罪魁祸首。

按尺寸分类管理

揪出内存碎片根源后,解决方案也就浮出水面了 —— 根据内存块尺寸,将内存空间划分成不同区域,独立管理。举个最简单的例子:

图片描述

如图,内存被划分成小、中、大三个不同尺寸的区域,区域可由若干内存页组成,每个页都划分为统一规格的内存块。这样一来,小块内存的分配,不会影响大块内存区域,使其碎片化。

每个区域的碎片仍无法完全避免,但这些碎片都是可以被重新分配出去的,影响不大。此外,通过优化分配策略,碎片还可被进一步合并。以小块内存为例,新内存优先从内存页 1 分配,内存页 2 将慢慢变空,最终将被整体回收。

在 Python 虚拟机内部,时刻有对象创建、销毁,这引发频繁的内存申请、释放动作。这类内存尺寸一般不大,但分配、释放频率非常高,因此 Python 专门设计 内存池 对此进行优化。

那么,尺寸多大的内存才会动用内存池呢?Python 以 512 字节为限,小于 512 的内存分配才会被内存池接管:

  • 0 ,直接调用 malloc 函数;
  • 1 ~ 512 ,由专门的内存池负责分配,内存池以内存尺寸进行划分;
  • 512 以上,直接调动 malloc 函数;

那么,Python 是否为每个尺寸的内存都准备一个独立内存池呢?答案是否定的,原因有几个:

  • 内存规格有 512 种之多,如果内存池分也分 512 种,徒增复杂性;
  • 内存池种类越多,额外开销越大;
  • 如果某个尺寸内存只申请一次,将浪费内存页内其他空闲内存;

相反,Python 以 8 字节为梯度,将内存块分为:8 字节、16 字节、24 字节,以此类推。总共 64 种:

请求大小分配内存块大小类别编号
1 ~ 8 8 0
9 ~ 16 16 1
17 ~ 24 24 2
25 ~ 32 32 3
497 ~ 504 504 62
505 ~ 512 512 63

以 8 字节内存块为例,内存池由多个 内存页 ( page ,一般是 4K ) 构成,每个内存页划分为若干 8 字节内存块:

图片描述

上图表示一个内存页,每个小格表示 1 字节,8 个字节组成一个块 ( block )。灰色表示空闲内存块,蓝色表示已分配内存块,深蓝色表示应用内存请求大小。

只要请求的内存大小不超过 8 字节,Python 都在这个内存池为其分配一块 8 字节内存,就算只申请 1 字节内存也是如此。

这种做法好处显而易见,前面提到的问题均得到解决,还带来另一个好处:内存起始地址均以计算机字为单位对齐。计算机以 字 ( word ) 为单位访问内存,因此内存以字对齐可提升内存读写速度。字大小从早期硬件的 2 字节、4 字节,慢慢发展到现在的 8 字节,甚至 16 字节。

当然了,有得必有失,内存利用率成了被牺牲的因素,平均利用率为 (1+8)/2/8*100% ,大约只有 56.25% 。

乍然一看,内存利用率有些惨不忍睹,但这只是 8 字节内存块的平均利用率。如果考虑所有内存块的平均利用率,其实数值并不低 —— 可以达到 98.65% 呢!计算方法如下:

# 请求内存总量
total_requested = 0
# 实际分配内存总量
total_allocated = 0

# 请求内存从1到512字节
for i in range(1, 513):
    total_requested += i
    # 实际分配内存为请求内存向上对齐为8的整数倍
    total_allocated += (i+7)//8*8

print('{:.2f}%'.format(total_requested/total_allocated*100))
# 98.65%

内存池实现

pool

铺垫了这么多,终于可以开始研究源码,窥探 Python 内存池实现的秘密了,源码位于 Objects/obmalloc.c 。在源码中,我们发现对于 64 位系统,Python 将内存块大小定义为 16 字节的整数倍,而不是上述的 8 字节:

#if SIZEOF_VOID_P > 4
#define ALIGNMENT              16               /* must be 2^N */
#define ALIGNMENT_SHIFT         4
#else
#define ALIGNMENT               8               /* must be 2^N */
#define ALIGNMENT_SHIFT         3
#endif

为画图方便,我们仍然假设内存块为 8 字节的整数倍,即 (实际上,这些宏定义也是可配置的):

#define ALIGNMENT               8
#define ALIGNMENT_SHIFT         3

下面这个宏将类别编号转化成块大小,例如将类别 1 转化为块大小 16 :

#define INDEX2SIZE(I) (((uint)(I) + 1) << ALIGNMENT_SHIFT)

Python 每次申请一个 内存页 ( page ),然后将其划分为统一尺寸的 内存块 ( block ),一个内存页大小是 4K :

#define SYSTEM_PAGE_SIZE        (4 * 1024)
#define SYSTEM_PAGE_SIZE_MASK   (SYSTEM_PAGE_SIZE - 1)

#define POOL_SIZE               SYSTEM_PAGE_SIZE
#define POOL_SIZE_MASK          SYSTEM_PAGE_SIZE_MASK

Python 将内存页看做是由一个个内存块组成的池子 ( pool ),内存页开头是一个 pool_header 结构,用于组织当前页,并记录页中的空闲内存块:

/* Pool for small blocks. */
struct pool_header {
    union { block *_padding;
            uint count; } ref;          /* number of allocated blocks    */
    block *freeblock;                   /* pool's free list head         */
    struct pool_header *nextpool;       /* next pool of this size class  */
    struct pool_header *prevpool;       /* previous pool       ""        */
    uint arenaindex;                    /* index into arenas of base adr */
    uint szidx;                         /* block size class index        */
    uint nextoffset;                    /* bytes to virgin block         */
    uint maxnextoffset;                 /* largest valid nextoffset      */
};
  • count ,已分配出去的内存块个数;
  • freeblock ,指向空闲块链表的第一块;
  • nextpool ,用于将 pool 组织成链表的指针,指向下一个 pool ;
  • prevpool ,用于将 pool 组织成链表的指针,指向上一个 pool ;
  • szidx ,尺寸类别编号;
  • nextoffset ,下一个未初始化内存块的偏移量;
  • maxnextoffset ,合法内存块最大偏移量;

当 Python 通过内存池申请内存时,如果没有可用 pool ,内存池将新申请一个 4K 页,并进行初始化。注意到,由于新内存页总是由内存请求触发,因此初始化时第一个内存块便已经被分配出去了:

图片描述

随着内存分配请求的发起,空闲块将被分配出去。Python 将从灰色区域取出下一个作为空闲块,直到灰色块用光:

图片描述

当有内存块被释放时,比如第一块,Python 将其链入空闲块链表头。请注意空闲块链表的组织方式 —— 每个块头部保存一个 next 指针,指向下一个空闲块:

图片描述

这样一来,一个 pool 在其生命周期内,可能处于以下 3 种状态 (空闲内存块链表结构被省略,请自行脑补):

图片描述

  • empty ,完全空闲 状态,内部所有内存块都是空闲的,没有任何块已被分配,因此 count 为 0 ;
  • used ,部分使用 状态,内部内存块部分已被分配,但还有另一部分是空闲的;
  • full ,完全用满 状态,内部所有内存块都已被分配,没有任何空闲块,因此 freeblock 为 NULL ;

为什么要讨论 pool 状态呢?—— 因为 pool 的状态决定 Python 对它的处理策略:

  • 如果 pool 完全空闲,Python 可以将它占用的内存页归还给操作系统,或者缓存起来,后续需要分配新 pool 时直接拿来用;
  • 如果 pool 完全用满,Python 就无须关注它了,将它丢到一边;
  • 如果 pool 只是部分使用,说明它还有内存块未分配,Python 则将它们以 双向循环链表 的形式组织起来;

可用 pool 链表

由于 used 状态的 pool 只是部分使用,内部还有内存块未分配,将它们组织起来可供后续分配。Python 通过 pool_header 结构体中的 nextpool 和 prevpool 指针,将他们连成一个双向循环链表:

图片描述

注意到,同个可用 pool 链表中的内存块大小规格都是一样的,上图以 16 字节类别为例。另外,为了简化链表处理逻辑,Python 引入了一个虚拟节点,这是一个常见的 C 语言链表实现技巧。一个空的 pool 链表是这样的,判断条件是 pool->nextpool == pool :

图片描述

虚拟节点只参与链表维护,并不实际管理内存块。因此,无须为虚拟节点分配一个完整的 4K 内存页,64 字节的 pool_header 结构体足矣。实际上,Python 作者们更抠,只分配刚好足够 nextpool 和 prevpool 指针用的内存,手法巧妙得令人瞠目结舌,我们稍后再表。

Python 优先从链表第一个 pool 分配内存块,如果 pool 用满则将其从链表中剔除:

图片描述

当一个内存块 ( block ) 被回收,Python 根据块地址计算得到 pool 地址。计算方法是大概是这样的:将 block 地址对齐为内存页 ( pool ) 尺寸的整数倍,便得到 pool 地址,具体请参看源码中的宏定义 POOL_ADDR 。

得到 pool 地址后,Python 将空闲内存块插到空闲内存块链表头部。如果 pool 状态是由 完全用满 ( full ) 变为 可用 ( used ),Python 还会将它插回可用 pool 链表头部:

图片描述

插到可用 pool 链表头部是为了保证比较满的 pool 在链表前面,以便优先使用。位于尾部的 pool 被使用的概率很低,随着时间的推移,更多的内存块被释放出来,慢慢变空。因此,pool 链表明显头重脚轻,靠前的 pool 比较满,而靠后的 pool 比较空,正如上图所示。

当一个 pool 所有内存块 ( block ) 都被释放,状态就变为 完全空闲 ( empty )。Python 会将它移出链表,内存页可能直接归还给操作系统,或者缓存起来以备后用:

图片描述

实际上,pool 链表任一节点均有机会完全空闲下来。这由概率决定,尾部节点概率最高,因此上图就这么画了。

pool 链表数组

Python 内存池管理内存块,按照尺寸分门别类进行。因此,每种规格都需要维护一个独立的可用 pool 链表。如果以 8 字节为梯度,内存块规格可分 64 种之多 (见上表)。

那么,如何组织这么多 pool 链表呢?最直接的方法是分配一个长度为 64 的虚拟节点数组:

图片描述

如果程序请求 5 字节,Python 将分配 8 字节内存块,通过数组第 0 个虚拟节点即可找到 8 字节 pool 链表;如果程序请求 56 字节,Python 将分配 64 字节内存块,则需要从数组第 7 个虚拟节点出发;其他以此类推。

那么,虚拟节点数组需要占用多少内存呢?这不难计算:48∗64=3072=3�48∗64=3072=3K

哟,看上去还不少!Python 作者们可没这么大方,他们还从中抠出三分之二,具体是如何做到的呢?

您可能已经注意到了,虚拟节点只参与维护链表结构,并不管理内存页。因此,虚拟节点其实只使用 pool_header 结构体中参与链表维护的 nextpool 和 prevpool 这两个指针字段:

图片描述

为避免浅蓝色部分内存浪费,Python 作者们将虚拟节点想象成一个个卡片,将深蓝色部分首尾相接,最终转换成一个纯指针数组。数组在 Objects/obmalloc.c 中定义,即 usedpools 。每个虚拟节点对应数组里面的两个指针:

图片描述

接下来的一切交给想象力 —— 将两个指针前后的内存空间想象成自己的,这样就得到一个虚无缥缈的却非常完整的 pool_header 结构体(如下图左边虚线部分),我们甚至可以使用这个 pool_header 结构体的地址!由于我们不会访问除了 nextpool 和 prevpool 指针以外的字段,因此虽有内存越界,却也无伤大雅。

下图以一个代表空链表的虚拟节点为例,nextpool 和 prevpool 指针均指向 pool_header 自己。虽然实际上 nextpool 和 prevpool 都指向了数组中的其他虚拟节点,但逻辑上可以想象成指向当前的 pool_header 结构体:

图片描述

经过这般优化,数组只需 16*64 = 1024 字节的内存空间即可,折合 1K ,节省了三分之二。

标签:空闲,字节,python,链表,Python,内存,优化,pool
From: https://www.cnblogs.com/muzinan110/p/18031603

相关文章

  • python 对象
    面向对象理论中“ 类 ”和“ 对象 ”这两个重要概念,在 Python 内部均以对象的形式存在。“类”是一种对象,称为 类型对象 ;“类”实例化生成的“对象”也是对象,称为 实例对象 。根据对象不同特点还可进一步分类:类别特点可变对象对象创建后可以修改不可......
  • Python数据结构与算法05——归并排序
    归并排序:defmerge_sort(aimlist):#归并排序拆分-排序-合并也就是merge_返回的是是一个已经排好序的列表n=len(aimlist)ifn<=1:returnaimlistmid=n//2aimlist_left=merge_sort(aimlist[:mid])aimlist_right=merge_sort(aimlist[mid:......
  • python list 动态数组
    list 对象是一种 容量自适应 的 线性容器 ,底层由 动态数组 实现。动态数组结构决定了 list 对象具有优秀的尾部操作性能,但头部操作性能却很差劲。容量调整当我们调用 append 、pop 、insert 等方法时,列表长度随之发生变化。当列表长度超过底层数组容量时,便需要......
  • 用python脚本跑套娃题
    importosimportzipfiledir="C://Users//32454//Desktop//4032"defjieya():foriinrange(4032,0,-1):#从4032开始,递减到1print(i)file_path=os.path.join(dir,str(i)+".zip")ifos.path.exists(file_path):#检查文件是否存在try:zpf=zip......
  • go堆内存分配
    mutator申请内存是以应用视角来看问题,我需要的是某一个struct,某一个slice对应的内存,这与从操作系统中获取内存的接口(比如mmap)之间还有一个鸿沟。需要由allocator进行映射与转换,将以“块”来看待的内存与以“对象”来看待的内存进行映射。在现代CPU上,我们还要考虑内存分配......
  • golang io优化
    如果想兼顾开发效率,又能保证高并发,协程就是最好的选择。它可以在保持异步化运行机制的同时,用同步方式写代码(goroutine-per-connection),这在实现高并发的同时,缩短了开发周期,是高性能服务未来的发展方向。CPU和IO设备是不同的设备,能并行运行。合理调度程序,充分利用硬件,就能跑出......
  • golang性能优化
    性能优化流程理清待优化代码的常用逻辑与场景根据实际场景编写压测用例使用pprof或者火焰图等工具取得数据找到热点代码重点优化Profilingpprof是用于可视化和分析性能分析数据的工具。为什么pprof可以帮助我们分析Go程序性能呢?因为它可以采集程序运行时数据:比如说协程栈,这样服......
  • Python 潮流周刊第 39 期(摘要)
    本周刊由Python猫出品,精心筛选国内外的250+信息源,为你挑选最值得分享的文章、教程、开源项目、软件工具、播客和视频、热门话题等内容。愿景:帮助所有读者精进Python技术,并增长职业和副业的收入。周刊全文:https://pythoncat.top/posts/2024-02-24-weekly以下是本期摘要:......
  • 「笔记」Python 1
    感觉自己学的Python基本是翻译C++,开坑虽然标题带个1,但是不一定会有下集的样子(Python基础字符串Python里可以用+来拼接字符串,像这个样子:s1='me's2='w's3=s1+'o'+s2print(s3)#output:#meowPython里字符串可以单引号还可以双引号,用单引号的话打双......
  • volatile及内存屏障理解总结
    volatile关键字是一种类型修饰符,用它声明的类型变量表示可以被某些未知的因素更改。volatile提醒编译器它后面所定义的变量随时都有可能改变,因此编译后的程序每次需要存储或读取这个变量的时候,都会直接从变量地址中读取数据。如果没有volatile关键字,则编译器可能优化读取和存......