起因
春节期间,翻了翻《垃圾回收的算法和实现》,真是一本好书。时间比较短还没有完全看完。但是让我吃惊的时候,这是一本将垃圾回收的书,但是在它的实现篇里居然对python的内存管理有比较深入的讲解。今天有空,又细细结合python 3.6的代码看了一遍。在这里写下我的这个读书笔记。
intro
上面是我结合书中的讲解,把它提到的一些概念用这个图都表达了出来。当然也可以说,我把书中的几张图合成了一张。
其实如果你去看python的源代码,在Objects/obmalloc.c这个文件中,对python是怎么维护内存的有详细的讲解。
概念
arena
这个区域是从堆内存里面直接malloc出来的,每个是256KB。
pool
针对malloc出来的arena,我们会对它进行分割,pool的大小是4K字节。至于为什么是4K字节,书中说这个是因为现在的操作系统大都是以4KB为大小做了内存页的管理单位,把我们的pool的大小也就设为了4KB。
#define SMALL_REQUEST_THRESHOLD 512
static void *
_PyObject_Alloc(int use_calloc, void *ctx, size_t nelem, size_t elsize)
{
...
if ((nbytes - 1) < SMALL_REQUEST_THRESHOLD) {
// do all the small objects allocation just as the book describes
//...
}
redirect:
/* Redirect the original request to the underlying (libc) allocator.
* We jump here on bigger requests, on error in the code above (as a
* last chance to serve the request) or when the max memory limit
* has been reached.
*/
{
void *result;
if (use_calloc)
result = PyMem_RawCalloc(nelem, elsize);
else
result = PyMem_RawMalloc(nbytes);
if (!result)
_Py_AllocatedBlocks--;
return result;
}
}
从上面的代码可以看到,只有对象大小在512字节以内的才会走到我们现在要讨论的这个分配系统,而大于512字节的,则直接走malloc了。
这里的设置跟书中提到的256字节的限制是不同的,可能在不同版本中,这个值得到了修正。
对于usedpool,它里面所维护的都是正在使用的pool,用代码中的话来说
- 在它里面的pool至少有一个block是被分配的
- 在它里面的pool至少有一个block是没有被分配的
那么那些被分配满block的pool去哪里了呢?它没有在这个usedpool中,它们在堆中处于游离状态。
而那些空的pool,则将它们返回给arena的freepools,由arena继续管理。
当arena里面全都是空pool的时候,这个arena就可以被释放掉了,arena的释放是通过free
来完成的。
需要特殊说明的是,在上图中,pool左边我特意画了一块空白的空间出来,这个在书中是没有的,因为根据我的理解和读代码所得,每个pool都带有它自己的管理结构pool_header,也就是这个pool的metadata,它的定义我也在图中写出来了。它里面维护着这个pool中的一些信息,以及一些变量用来在这个pool中进行block分配。
block
有了pool之后,我们就可以在pool中划分出我们的block来了,但是我们在使用python过程中,对象的大小千奇百怪,为了内能适应不同的对象大小要求,python内部,采用了类似于malloc管理内存的方法,针对于每一个大小的对象,我们都会有一个pool与它对应。这要就有了上图中右边的这个结构。
这个结构是为了在分配过程中快速找到相对应的pool的一个结构,它可以在O(1)的时间分配所需要的内存。
usedpools
注意右边这个usedpool的全局变量,从概念上来看,它应该保存的是pool_header的前后关系,但是在在代码的实现中,里面实际上只是保存了pool_header里面的nextpool
和prevpool
的信息。为什么要这么做,在源代码里作者也给出了答案,这个结构需要经常的变动,如果把pool_header的信息全都放进去的话,会有一些空间上的浪费,使得cpu不能一次把整个结构load到cpu cache中去,或者说是cache line中去。为了防止这种情况的发生,加快对这个结构的访问,才做了这个优化。
pool中的freeblock
下面说说在看代码的过程中,我所遇到的一个问题,通过这个问题更深的了解了在pool中,block的分配是如何进行的。
typedef uint8_t block;
if ((pool->freeblock = *(block **)bp) != NULL) {
...
}
在看到代码的时候,经常看到*(block **)bp
,从代码的上下文来看,这个就是讲pool->freeblock
指向下一个freeblock,但是简单的这个指针操作真的就能完成了嘛?
bp本来就是一个block指针,现在把它强转成block指针的指针,也就是说强转成一个指向block指针数组的的指针(简单这么理解),然后再解引用,相当于取这个数组的第一个元素(一个block的指针),这个就是我们下一个freeblock了?注意这里的block实际上是一个8位整型的别名。这里很是疑惑,于是转过头去看pool初始化的地方的代码。
init_pool:
/* Frontlink to used pools. */
next = usedpools[size + size]; /* == prev */
pool->nextpool = next;
pool->prevpool = next;
next->nextpool = pool;
next->prevpool = pool;
pool->ref.count = 1;
if (pool->szidx == size) {
/* Luckily, this pool last contained blocks
* of the same size class, so its header
* and free list are already initialized.
*/
bp = pool->freeblock;
assert(bp != NULL);
pool->freeblock = *(block **)bp;
UNLOCK();
if (use_calloc)
memset(bp, 0, nbytes);
return (void *)bp;
}
/*
* Initialize the pool header, set up the free list to
* contain just the second block, and return the first
* block.
*/
pool->szidx = size;
size = INDEX2SIZE(size);
bp = (block *)pool + POOL_OVERHEAD;
pool->nextoffset = POOL_OVERHEAD + (size << 1);
pool->maxnextoffset = POOL_SIZE - size;
pool->freeblock = bp + size;
*(block **)(pool->freeblock) = NULL; //----#1
UNLOCK();
if (use_calloc)
memset(bp, 0, nbytes);
return (void *)bp;
在此看到代码注释1的时候,就明白了,初始化的时候,就使用了freeblock的第一个block指针,也就是说每个block的第一个指针大小(在32位机上就是第一个32位,在64位机上就是第一个64位)是用来存放下一个block的内存地址的。注意这里最开始并没有把整个pool都分割完,只是用了最前面两个block,然后freeblock以NULL结尾。那么当我们将要分配第三块block时,会发生什么呢?
if (pool->nextoffset <= pool->maxnextoffset) {
/* There is room for another block. */
pool->freeblock = (block*)pool +
pool->nextoffset;
pool->nextoffset += INDEX2SIZE(size);
*(block **)(pool->freeblock) = NULL;
UNLOCK();
if (use_calloc)
memset(bp, 0, nbytes);
return (void *)bp;
}
这里的pool实际上就上面我说的pool_header,通过它里面nextoffset的协助,我们继续划出了一块block,同时将freeblock的next置为NULL。
这里就得到证实,在分配block的时候,的确是使用了block的第一个32/64位做了连接这个链表的线索。当然如果想进一步证实,可以自己编译一个python的debug版本,调试一下看看,这里我就先不做了。(哎,又偷懒了)
剩下的问题
- 上面只是介绍了分配和释放对象大小小于512字节的情况,但是大于512字节的要怎么维护的呢?
- 比如一个list对象,刚开始的时候是比较小的,但是随着计算的增加,它是有可能超过512字节的,那么大小超过后会怎么处理呢?
更新
今天看到这篇文章,里面说到二维数组跟二级指针的问题,跟这里的二级指针的使用可以对照一下。
为什么不能将二维数组名传递给二级指针?
假如我们将a赋值给p,p = (int**)a; 既然p是二级指针,那么当然可以这么用:**p; 这样会出什么问题呢?
1)首先看一下p的值,p指向a[0][0],即p的值为a[0][0]的地址;
2)再看一下p的值,p所指向的类型是int,占4字节,根据前面所讲的解引用操作符的过程:从p指向的地址开始,取连续4个字节的内容。得到的正式a[0][0]的值,即0。
3)再看一下**p的值,诶,报错了?当然报错了,因为你访问了地址为0的空间,而这个空间你是没有权限访问的。
备注
如果图片看不清楚的话,我放到了网盘里面,可以去那里看。