什么是内存管理
内存是数据存储的介质,其访问速度比硬盘快,但是容量相对更小,且断电后数据会丢失。由于内存的种种性质,其成为了计算机开机后访问数据的最佳位置,操作系统或者用户进程所访问的数据大都也都是在内存上。
内存管理实际上管理的是内存物理介质的状态。内存作为一种物理介质,每一个bit都是0/1两种状态,它并不清楚自身存储的数据含义,甚至也并不清楚自身是否被使用了。
通常来说,操作系统为我们管理了作为物理介质的内存,即使用虚拟内存和分页的方式,使得我们的用户进程能够访问连续的/不受物理内存大小限制的虚拟地址空间。其次,c运行库中的malloc
/calloc
函数在操作系统层面之上提供了堆空间内存分配的接口。而python在此基础之上,为所有PyObject
提供了统一的内存分配/管理接口。
为什么要进行内存管理
个人认为,主要原因是为了减少内存分配调用而产生的开销;此外,通用的内存管理也方便在此基础上进行垃圾回收等其他操作。当调用malloc
函数时,我们通常会陷入内核进行系统调用,甚至会产生缺页异常从而分配新的页并更新页表,这样的操作是相对费时的。python内存管理的核心目的就是减少小对象的内存分配次数,从而提高程序运行性能。
Block/Pool/Arena概述
python的内存管理中有三个重要概念,从小到大也就是block
/pool
/arena
block
对应着一个PyObject
所占据的内存空间,小PyObject
都会被分配在一个block中,可能会有一定的内部碎片,相当于字节对齐后的缝隙。block的大小从8-512字节不等。pool
相当于对操作系统页的抽象,也是python内存管理通过malloc
向操作系统申请内存的最小单位。每个pool
都是4096字节,也就是4KB,对应大多数操作系统一页的大小。每个pool中包含的block都是固定大小的。arena
主要用来管理pool
,每个arena
包含的内存空间大小固定为256KB,其中固定包含64个pool
,且其中包含的pool
并不一定都有相同的block_size
pool如何管理block
pool_header以及pool的初始化
在obmalloc.c
中的头部位置,python定义了block
的字节对齐ALIGNMENT
以及小对象的最大大小SMALL_REQUEST_THRESHOLD
,实际上这也就是一个block
的大小范围。同时,python也简单将POOL_SIZE
定义为了大多数操作系统一页的大小4KB。
1 | /* |
之后,python给出了pool_header
的结构体定义,很容易看出多个pool_header
都被串联在一个双向链表中。其中需要解释的字段也就是free_block
,nextoffset
和maxnextoffset
,我们可以通过init_pool
部分的相关代码来理解这几个变量。由于整个pool的内存分配是由arena_object
管理的,因此这里pool的初始化里看不到这一方面的相关代码,就像__init__
代码里是没有对象的内存分配代码一样。
这里我们会发现pool
首先会被加入到usedpools[size + size]
这个双向链表中,实际上usedpools
可以理解为所有pools
的一个切片,或者说一个view
。能够通过usedpools[size + size]
从中获取到所有sizeidx
相同的可用的pools
的双向链表。暂时可以不用管他。此外,中间有一段复用pool
的代码,也可以先略过。
1 | /* Pool for small blocks. */ |
下图展示了一块刚被初始化的pool
的状态,此时所有的block
都未被分配,freeblock
指向了第一块block
,nextoffset
指向了第二块,maxnextoffset
指向了最后一块。除此之外,freeblock
所指向的内存的第一个指针大小空间被置为了NULL
。
pool如何分配block
从如下代码分析,usedpools
中存放的都是还未放满的pool,因为必定有一个freeblock
。所以在这个分支下,分配的内存空间地址肯定就是pool->freeblock
了。但是取走当前freeblock
之后还需要找到下一个freeblock
,还记得之前初始化时,freeblock
位置处放置了一个NULL指针,这实际上时freeblock
链表结束的标志。在其他情况下,该位置处存放的指针也就是下一个freeblock
的地址。由此得出维护状态的两种情况:
存在下一个
freeblock
,将freeblock
赋值为下一个指针即可没有下一个
freeblock
,即freeblock
指向的地址处是NULL指针- 尝试将
nextoffset
处的地址作为freeblock
nextoffset
也走到头了,即nextoffset
走到了maxnextoffset
位置- 说明
pool
已满,将这个pool
从usedpool[size + size]
双向链表中移除
- 说明
- 尝试将
1 | /* code fragment for allocate a block in function pymalloc_alloc */ |
pool如何释放block
释放block和分配的过程非常相似,实际上也就是分配的逆操作。其大致流程如下:
- 如果当前
pool
的freeblock
不为NULL
,及说明当前pool
还有freeblock
- 将这个释放的
block
链接到freeblock
链表的头部
- 将这个释放的
- 如果当前pool的
freeblock
为NULL
,说明pool已满,也就不在usedpools
链表中- 这时需要将
pool
重新链接回usedpools
链表中
- 这时需要将
1 | /* code fragment for free a block in function pymalloc_free */ |
pool分配释放block图示
pool的一个中间状态可能如下图所示:
此时如果分配了一个block,则会变为如下图所示
此后如果又释放了一个block,则会变为如下图所示
arena_object如何管理pool
arena_object结构体
arena_object
结构体的定义如下,注意到和pool
不同的是,arena_object
持有的空间并不包括其自身,而是用address
指向的,此外,pool
所在的地址是经过字节对齐的,因而是不是从address
开始累加的。
此外,关于arena
还定义了一部分常量,例如
- 一个
arena
是256KB - 第一次分配的
arena
的数量为16 - 一个
arena
最多包含的pool
数量为64
需要注意的是,arena_object
只负责提供一个空的pool
,至于这一个pool
中的block
被使用了一个还是全部,arena_object
是不负责管理的。也就是说,只有pool
从空到非空状态,或者从非空到空状态,才会触发arena_object
的管理操作。
再次强调,arena_object
只负责提供一个空的pool
,记住这个中心原则对理解代码很有帮助。
1 | /* Record keeping for arenas. */ |
arena_object的初始化
arena_object的初始化也和pool_header初始化基本一致,先是分配了整个arena的空间在address,在计算字节对齐后将pool_address初始化。初始化后的arena_object如图所示:
1 | /* Take the next available arena object off the head of the list. */ |
arena如何分配pool
和pool
分配block
非常类似
arena_object
中的freepools
就相当于pool_header
中的freeblock
arena_object
中的pool_address
就相当于pool_header
中的nextoffset
arena_object
中没有maxnextoffset
,因为maxnextoffset
可以用address + ARENA_SIZE - POOL_SIZE
计算得到
由此我们很容易理解arena分配pool的流程
如果
freepools
中有可用的pool,则从freepools
中取出一个可用的pool,并更新freepools
链表如果
freepools
为空,则从pool_address
处取出一块POOL_SIZE
大小的空间作为pool
和pool
分配block
相比,还是有些许的不同,pool_header
中单用freeblock
指针即可判断pool中是否还有freeblock
,因为每次pool_header
是在分配后保证freeblock
指向一块空闲空间。而arena_object
则没有这样的处理,因此会有两次判断,也有一些重复代码(将arena
从usable_arenas
链表中删除的代码),因为最后一块空闲的pool
可能在address
处也可能在freepools
处。相比之下,pool_header
的方法虽然稍微多了一些理解成本,但是实现上更巧妙。
1 | /* Try to get a cached free pool. */ |
arena如何释放pool
当释放完一个block
,当前pool为空时,即pool->ref.count
为0,则会触发arena
回收pool
的操作,和pool
回收block
一样,只是简单地将其放回freepools
中即可。
1 | struct arena_object* ao; |
如何管理arena_object
arena_object所在的容器
arena_object
主要通过一个数组和两个链表进行管理:
arenas
中存放了所有arena的数组,类似动态数组,翻倍增长usable_arenas
是存放了所有使用了的,即nfreepools
大于0小于ntotalpools
的arena_object
的双向链表unused_arena_objects
存放所有未使用的,即nfreepools
为ntotalpools
的arena_object
的单向链表- 而所有
pool
都已经使用的,即nfreepools
为0的arena_object
,则只放在arenas
中,没有链表管理
arenas
数组中一种可能的状态如图所示:
1 | /* Array of objects used to track chunks of memory (arenas). */ |
arena_object的创建和分配
什么时候会创建一个新的arena_object
对象呢?试想一下,如果想要分配一定的内存空间,如果恰好有一个对应szidx
的未满的pool
,实际上也就足够了。如果没有这样的pool
,如果有arena_object
(即从useable_arenas
中获取一个)能够提供一个空的pool
改造一下,也能凑合用了。最坏情况是,所有对应szidx
的pool
都满了,而且也没有空的pool
了,这时候只能重新创建新的arena_object
了。相关代码如下:
1 | size = (uint)(nbytes - 1) >> ALIGNMENT_SHIFT; |
arena_object
的创建主要经历以下几个流程:
如果没有未使用的
arena_object
,即unused_arena_objects
为NULL
- 创建两倍原大小的
arena_object
的数组,第一次则创建16个。之后将后一半arena_object
连接到unused_arena_objects
链表中去
- 创建两倍原大小的
- 此时可以从
unused_arena_objects
中取出一个arena
进行初始化操作- 从
unused_arena_objects
中移除 - 分配内存空间
- 处理
pool
字节对齐,初始化pool
总数以及可用pool总数
- 从
对应于代码中的三个状态如下图所示:
1 | /* Allocate a new arena. If we run out of memory, return NULL. Else |
arena_object的释放
释放一个block
后,我们很可能会产生一连串的连锁反应,导致持有block
的pool
的状态变化。同样的,在释放一个pool
后,也会使得持有这个pool
的arena_object
的状态产生变化,会有一下两种情况:
- 释放的pool为
arena_object
中唯一使用的pool
,那么需要将这个arena_object
持有的内存释放,并将arena_object
从usable_arenas
链表中移除,并加入到unused_arena_objects
链表中 - 释放的
pool
前arena_object
是满的,那么需要将arena_object
重新放到usable_arenas
中 - 释放
pool
前后arena_object
都在usable_arenas
中,由于usable_arenas
链表中是按照nfreepools
从小到大排序的,因而需要维护一下顺序(此段代码未附)
1 | if (nf == ao->ntotalpools) { |
usedpools是什么
usedpools
实际上是用pool
的szidx
作为索引的双向链表。举例来说,usedpools[i + i]
中存放的也就是szidx=i
的不为空也不满的pools所在的双向链表。回顾一下前文,arena_object
中只使用freepools
管理了所有为空的pool
,放满的pool
以及部分使用的pool
是未管理的,到了这一章节我们知道了部分使用的pool
是通过usedpools
来管理的,这是什么原因导致的呢?因为szidx
相等的pool
是跨arena
连接的,因而无法在arena_object
内部管理。
1 |
|
usedpools的巧妙结构
通常来说,巧妙和理解成本在一定程度上也是成正比的。按照一般思路,如果想要用usedpools
中每一项都存放一个pool的双向链表的话,usedpools
每一项都应该是一个poolp
指针。如果要给每个双向链表一个虚拟的头结点,那就需要多分配整个pool_header的大小,但是实际我们只需要使用nextpool
和prevpool
这两个成员。
下图展示了usedpools
刚刚初始化完成时的状态,usedpool[i + i]
以及usedpools[i + i + 1]
中都存放着usedpools[i-1 + i-1]
的地址。但是这个地址被当作了pool_header,所以从这个地址作.nextpool
操作和.prevpool
操作都会取到自身,这也就是初始化的头结点的状态。
在添加pool_header
结构体到链表后,nextpool
和prevpool
就会连接到一个真正的pool_header
对象上了。