PHP7内核剖析 学习笔记 第四章 内存管理(1)

PHP中的变量不需要手动释放,内核实现了变量的内存管理,包括内存的分配与回收。

4.1 变量的自动GC机制

C/C++语言中,如果想在堆上分配变量,需要手动进行内存的分配与释放,变量的内存管理是一件非常烦琐的事情,稍有不慎就可能导致不可预知的错误。现代高级语言普遍提供了变量的自动GC机制,由语言自己进行管理,这使得开发者不需要再去关心变量的分配与释放,PHP也实现了这种机制,PHP中可直接通过$声明一个变量,使用完也不需要手动销毁,内核自己清楚什么时间该进行释放。

我们先自己思考下如何实现自动GC,最简单的实现方式:在函数中定义变量时分配一块内存,用于保存zval及对应的value结构,在函数返回时再将内存释放,如果在函数执行期间该变量作为参数调用了其他函数或赋值给了其他变量,则把变量复制一份,变量之间互相独立,不会出现冲突。

这种方式是可行的,而且内存管理也很简单,但深拷贝带来的一个无法接受的问题是效率,而且内存浪费严重,比如我们定义了一个变量然后赋值给另一个变量,可能后面都是只读操作,假如深拷贝的话就会有多余的一份数据。这个问题比较通用的解决方案是:引用计数+写时复制,PHP变量的内存管理正是基于这两点实现的。当变量赋值、传递时不是直接进行深拷贝,而是多个变量共用同一个value,引用计数用来记录value有多少个变量在使用;当某个变量的value发生改变时将无法继续与其他变量共用value,此时就需要进行深拷贝分离value,这就是写时复制。

4.1.1 引用计数

引用计数用来记录当前有多少zval指向同一个zend_value。当有新zval指向这个value时,计数器加1;当zval销毁时,计数器减1。当引用计数为0时,表示此value已经没有被任何变量指向,就可以对value进行释放了。

PHP7将变量的引用计数保存在了zend_value中,也就是不同类型的结构中(比如字符串,就存在zend_value的str字段指向的_zend_string结构的gc字段中),这一点与之前的版本不同,旧版本中引用计数保存在zval中。上一章介绍的各种数据类型的结构体中都有一个相同的成员gc,这个结构就是用来保存引用计数的,它的类型为zend_refcounted_h:

c 复制代码
typedef struct _zend_refcounted_h {
    // 引用计数
    uint32_t refcount;
    union {
        struct {
            ZEND_ENDIAN_LOHI_3(
                // 类型
                zend_uchar type,
                zend_uchar flags,
                // 垃圾回收时用到
                uint16_t gc_info
            )
        } v;
        uint32_t type_info;
    } u;
} zend_refcounted_h;

例如:

php 复制代码
$a = array(); // $a -> zend_array(refcount=1)
$b = $a;      // $a,$b -> zend_array(refcount=2)
$c = $b;      // $a,$b,$c -> zend_array(refcount=3)
unset($b);    // $a,$c -> zend_array(refcount=2)    $b = IS_UNDEF

并不是所有类型都会用到引用计数,没有具体value结构的类型是不会用到的,比如整型、浮点型、布尔型、NULL,它们的值直接通过zval保存,因此这些类型不会共用value,而是深拷贝,也就是上面介绍的那种最简单的内存模型。除了这些不会用到引用计数的类型,还有一些类型在特殊情况下也不会使用引用计数,例如:

php 复制代码
// refcount.php
$a = "hi";
$b = $a;

我们通过gdb看一下其实际的引用情况:

一直n,执行完第2条opcode后停下,也就是$b赋值完成后,此时打印value的refcount值:

这里提前介绍一下,PHP中局部变量的zval分配在zend_execute_data结构上,也就是我们调试的execute_ex()函数中的execute_data变量,它是运行期最重要、最关键的一个结构,用于局部变量的分配、保存执行位置、调用上下文切换等。局部变量分配在这个结构体的末尾,在执行前会根据局部变量的数量确定内存的大小,变量按照定义的先后顺序依次分配,96指的是内存的offset值,也就是局部变量区的起始位置,前面的内存被zend_execute_data结构体成员占用。当然96这个值并不是不变的,在不同机器上它的值可能不同,笔者使用的机器为x86_64 GNU/Linux。所以这里变量a就是(zval*)((char *)execute_data + 96),变量b为(zval*)((char *)execute_data + 112)

上图在gdb中打印的值就是a、b两个变量value的引用计数结构,可见其refcount=0,下面换一个例子:

php 复制代码
$a = "hi" . time();
$b = $a;

上例的refcount=2。在PHP中除了上面介绍的几种没有value结构的类型不会用到引用计数外,还有两种特殊情况不会用到:内部字符串(internal string)、不可变数组(immutable array),它们的类型分别是字符串、数组:

1.internal string:内部字符串,在PHP中写的函数名、类名、变量名、静态字符串都是这种类型,第一个例子中$a = "hi",后面的字符串是唯一不变的,这些字符串等同于C语言中定义在静态变量区的字符串char *a = "hi",这些字符串的生命周期为整个请求执行期间,request完成后会统一销毁释放,自然无需在运行期通过引用计数来管理内存。

2.immutable array:不可变数组,它是opcache优化出的一种类型,这里不做详细说明。

内部字符串和普通字符串的类型都是IS_STRING,它们不是通过type进行区分的,而是通过zend_refcounted_h.u.v.flags来区分,内部字符串这个值将包含IS_STR_INTERNED。除了内部字符串和普通字符串之分,flags还用来表示其他含义,比如持久化字符串。

上例说明无法通过value的类型来判断一个变量是否使用引用计数机制,需要使用zval.u1中的类型掩码type_flag来标识变量是否使用引用计数机制,它是一个bitmap,还有几个其他标识位用于其他含义。是否是支持引用计数的value类型需要用zval.u1.type_flag & IS_TYPE_REFCOUNTED来判断。

c 复制代码
#define IS_TYPE_REFCOUNTED (1<<2)

以下是使用引用计数机制的类型:

4.1.2 写时复制

写时复制只在必要的时候(即发生写的时候)才会进行深拷贝,可以很好地提升效率,例如Linux中fork子进程时不会立即复制父进程的地址空间,而是让父子进程共享同一地址空间,只有在需要写入时才会复制地址空间,从而使各个进程拥有各自的地址空间。也就是说,资源的复制是在需要写入的时候才会进行,在此之前,以只读方式共享。

变量使用了引用计数必然会出现其中一个变量修改value的情况,此时就需要对value进行分离,发生修改的变量会复制一份数据出来进行修改,同时断开原来value的指向,指向新的value。例如:

php 复制代码
$a = array(1, 2);
$b = &$a;
$c = $a;
// 发生分离
$c[] = 3;

其引用计数及分离情况如图4-1所示:

但并不是所有类型value都可以进行复制,比如对象、资源,也就无法进行分离,如果多个变量指向同一对象,当其中一个变量修改对象时,其修改将反映到所有变量上。事实上只有string、array两种类型支持value的分离,与引用计数相同,这个信息也是通过zval.u1.type_flag记录的。

c 复制代码
#define IS_TYPE_COPYABLE (1<<4)

支持复制的value类型:

除了变量的写时复制会用到copyable属性,变量从literals静态数据区复制到局部变量区时也会用到。比如$a = array(),赋值时发现array支持copyable,就会从literals中深拷贝一份数据进行赋值。再比如$a = "hi",而"hi"是内部字符串,不支持copyable,所以不会深拷贝,变量a直接指向literals中的value。literal静态数据区存储在代码中定义的字面量,类似C语言中的静态数据区,比如$a = "hi",编译时就会把字符串"hi"保存在literal中。

4.1.3 回收时机

在自动GC机制中,在zval断开value的指向时如果发现refcount=0则会直接释放value,这就是变量回收的时机,发生断开的两种常见情况为修改变量与函数返回时,修改变量时会断开原有value的指向,函数返回时会释放所有局部变量,也就是把所有局部变量的引用计数减1。

除了自动GC,PHP中也可通过unset()函数主动销毁一个变量。

4.2 垃圾回收

有一种情况是引用计数实现的GC机制无法解决的,即循环引用。简单来讲,循环引用就是变量的内部成员引用了变量自身,比如数组中的某个元素指向了数组,这样数组的引用计数中就有一个来自自身成员,当所有外部引用全部断开时,数组的refcount仍大于0而得不到释放,而实际上这种变量不可能再被使用了。例如:

php 复制代码
$a = array(1);
$a[] = &$a;
unset($a);

unset($a)前,变量a的类型为引用,该引用的refcount=2,一个来自$a,另一个来自$a[1],如图4-2所示:

unset($a)后,减少了一次该引用的refcount,此时已经没有任何外部引用了,但数组中仍有一个元素指向该引用,如图4-3所示:

这种因为循环引用而导致无法释放的变量称为垃圾,PHP引入了另一种机制来对这些垃圾进行回收,也就是垃圾回收器,先明确两个准则:

1.如果一个变量value的refcount减少到0,那么此value可以被释放掉,不属于垃圾。

2.如果一个变量value的refcount减少后大于0,那么此value还不能被释放,此value可能成为一个垃圾。

第一种情况垃圾回收器不会处理,只有第二种情况垃圾回收器才会将变量收集起来。在value的引用计数减少后如果仍大于0,那么垃圾回收器就会把可能成为垃圾的value收集起来,等达到一定数量后开始启动垃圾鉴定程序,把真正的垃圾释放掉。

目前垃圾只会出现在array、object两种类型中,数组的情况上面已经介绍过了,object的情况则是成员属性引用对象本身导致的(这句话不完全正确,有两个数组或对象互相引用也会导致出现垃圾),其他类型不会出现这种变量中的成员引用变量自身的情况,所以垃圾回收器只会处理这两种类型。垃圾回收器判断是否要收集疑似垃圾时,不是根据类型进行判断的,而是与是否用到引用计数一样,通过zval.u1.type_flag进行标识的,只有包含IS_TYPE_COLLECTABLE标识的变量类型才会被收集。

c 复制代码
#define IS_TYPE_COLLECTABLE (1<<3)

垃圾回收器把收集到的可能垃圾保存到一个buffer缓冲区中。收集的时机是refcount减少时,如下例会触发两次收集动作,第2次收集时发现已经收集过了就不再重复收集:

php 复制代码
$a = array();
$b = $a;
$c = $a;
unset($b);
unset($c);

4.2.1 回收算法

等到垃圾回收器收集的可能垃圾达到一定数量后,就会启动垃圾鉴定、回收程序。回收算法的原理:既然垃圾是由成员引用自身导致的,那么就对value中的所有成员减一遍引用计数,如果发现value本身refcount变为0,就表明其引用全部来自自身成员。具体的回收步骤:

1.遍历垃圾回收器的buffer缓冲区,把当前value标为灰色(zend_refcounted_h.gc_info置为GC_GREY),然后对当前value的成员进行深度优先遍历,把成员value的refcount减1,并且也标为灰色。

2.重复遍历buffer,检查当前value引用是否为0:为0则表示确实是垃圾,把它标为白色(GC_WHITE);不为0则排除了引用全部来自自身成员的可能,表示还有外部的引用,不是垃圾。由于步骤1对成员进行了refcount减1操作,需要再还原回去,对所有成员进行深度优先遍历,把成员refcount加1,同时标为黑色。

3.再次遍历buffer,把非GC_WHITE的节点从buffer中删除,最终buffer中全部是垃圾,把这些垃圾释放,回收完成。

4.2.2 具体实现

垃圾回收器主要通过zend_gc_globals结构对垃圾进行管理,收集到的可能成为垃圾的value就保存在这个结构的buf中,即垃圾缓冲区:

c 复制代码
typedef struct _zend_gc_globals {
    // 是否启用gc
    zend_bool gc_enabled;
    // 是否在垃圾检查过程中
    zend_bool gc_active;
    // 缓冲区是否已满
    zend_bool gc_full;
    // 启动时分配的用于保存可能垃圾的缓冲区
    gc_root_buffer *buf;
    // 指向buf中最新加入的一个可能垃圾,roots是一个链表的固定头部,它永远指向最新加入的可能垃圾
    gc_root_buffer roots;
    // 指向buf中没有使用的buffer
    gc_root_buffer *unused;
    // 指向buf中第一个没有使用的buffer
    gc_root_buffer *first_unused;
    // 指向buf尾部
    gc_root_buffer *last_unused;
    // 待释放的垃圾
    gc_root_buffer to_free;
    gc_root_buffer *next_to_free;
    // 统计gc运行次数
    uint32_t gc_runs;
    // 统计已回收的垃圾数
    uint32_t collected;
} zend_gc_globals;

buf用于保存收集到的value,它是一个数组,在垃圾回收器初始化时一次性分配了10001个gc_root_buffer,其中第一个buffer被保留,插入value时直接取出可用节点即可。roots指向buf中最新加入的一个节点,roots是一个双向链表的头部,之所以是一个双向链表,是因为buf数组中保存的只是有可能成为垃圾的value,其中有些value在加入后又被删除了(比如有的value在之后的操作中refcount变为0了,此时需要将其从buf中删除),这样buf数组中就会出现空隙。first_unused一开始指向buf的第一个位置,有元素插入roots时如果first_unused还没有到达buf尾部,则返回first_unused给最新的元素,然后执行first_unused++,直到last_unused。比如现在已经加入了2个gc,则对应结构如图4-4所示:

zend_gc_globals结构中还有一个unused成员,用来管理buf中加入后又删除的节点,这是一个单链表。即first_unused是一直往后偏移的,直到buf的结尾,buf中间由于value删除而重新空闲的节点则由unused串起来。下次有新的value插入roots时优先使用unused的这些节点,其次才是first_unused的节点。如果图4-4中,buf[1]后来发现不是一个垃圾被移除了,则unused将指向buf[1],如图4-5所示:

垃圾回收机制可通过php.ini中的zend.enable_gc设置开启,默认是开启的,尽管GC回收的过程会短暂中断正常逻辑的执行(即垃圾回收停顿),但关闭这个机制带来的风险更高,除非你有信心代码中不会出现循环引用的情况。如果开启,会在php.ini解析后调用gc_init()初始化垃圾回收器:分配buf数组内存、设置first_unused/unused指针等。

c 复制代码
ZEND_API void gc_init(void) {
    if (GC_G(buf) == NULL && GC_G(gc_enabled)) {
        // 分配buf缓存,大小为GC_ROOT_BUFFER_MAX_ENTRIES(10001),其中第1个保留不被使用
        GC_G(buf) = (gc_root_buffer *)malloc(sizeof(gc_root_buffer) * 
          GC_ROOT_BUFFER_MAX_ENTRIES);
        GC_G(last_unused) = &GC_G(buf)[GC_ROOT_BUFFER_MAX_ENTRIES];
        // 进行GC_G的初始化,其中:GC_G(first_unused) = GC_G(buf) + 1;从第2个开始,第1个保留
        gc_reset();
    }
}

初始化后就可以进行垃圾收集了,在Zend执行过程中如果销毁一个变量就会判断是否需要加入垃圾收集器。销毁一个zval会调用i_zval_ptr_dtor函数:

c 复制代码
// file: zend_variables.h
static zend_always_inline void i_zval_ptr_dtor(zval *zval_ptr ZEND_FILE_LINE_DC) {
    // 不使用引用计数的类型不需要进行回收
    if (Z_REFCOUNTED_P(zval_ptr)) {
        // refcount减1,如果变为0,则不是垃圾,正常回收
        if (!Z_DELREF_P(zval_ptr)) {
            _zval_dtor_func_for_ptr(Z_COUNTED_P(zval_ptr) ZEND_FILE_LINE_RELAY_CC);
        } else {
            // refcount减1后大于0
            GC_ZVAL_CHECK_POSSIBLE_ROOT(zval_ptr);
        }
    }
}

从上面的过程可以看到,在销毁一个zval时首先对refcount进行减一操作,如果refcount变为0则表示不是垃圾,正常释放即可,如果减掉后仍大于0则表示可能是一个垃圾,此时就会被垃圾收集器收集。

c 复制代码
// file: zend_gc.h
#define GC_ZVAL_CHECK_POSSIBLE_ROOT(z) \
    gc_check_possible_root((z))

static zend_always_inline void gc_check_possible_root(zval *z) {
    ZVAL_DEREF(z);
    // 判断是否是可收集类型,是否已收集过
    if (Z_COLLECTABLE_P(z) && UNEXPECTED(!Z_GC_INFO_P(z))) {
        gc_possible_root(Z_COUNTED_P(z));
    }
}

收集时检查变量类型掩码是否包含IS_TYPE_COLLECTABLE,即是否是数组、对象,如果是其他类型则不会出现循环引用导致的垃圾,也就没必要收集。除了检查是否可收集,还会检查该变量是否已经被收集过,这是通过zend_refcounted_h.v.u.gc_info来判断的,第一次收集后会把这个值设为GC_PURPLE。如果变量是可收集的类型且之前没被收集过则会触发收集操作。

收集时首先会从buf中选择一个空闲节点,然后将value的gc保存到这个节点中,如果没有空闲节点则表明回收器已满,此时就会触发垃圾鉴定、回收。

c 复制代码
ZEND_API void ZEND_FASTCALL gc_possible_root(zend_refcounted *ref) {
    gc_root_buffer *newRoot;
    // 插入的节点必须是GC_BLACK,防止重复插入
    ZEND_ASSERT(EXPECTED(GC_REF_GET_COLOR(ref) == GC_BLACK);
    
    newRoot = GC_G(unused); // 先看一下unused中有没有可用的
    if (newRoot) {
        // 如果有先用unused的,然后将GC_G(unused)指向单链表的下一个
        GC_G(unused) = newRoot->prev;
    } else if (GC_G(first_unused) != GC_G(last_unused)) {
        // unused没有可用的,且buf中有可用的
        newRoot = GC_G(first_unused);
        GC_G(first_unused)++;
    } else {
        // buf缓存期已满,此时需要启动垃圾鉴定、回收程序
        ...
    }
    // 将插入的ref标为紫色,防止重复插入
    GC_TRACE_SET_COLOR(ref, GC_PURPLE);
    // 设置ref的信息,包括其在buf数组中的位置以及颜色标记
    GC_INFO(ref) = (newRoot - GC_G(buf)) | GC_PURPLE;
    newRoot->ref = ref;
    // 插入到roots链表头部,roots是固定头部,实际是将newRoot插入到roots指向的下一位置
    newRoot->next = GC_G(roots).next;
    newRoot->prev = &GC_G(roots);
    GC_G(roots).next->prev = newRoot;
    GC_G(roots).next = newRoot;
}

收集后会设置value的zend_refcounted_h.v.u.gc_info信息,这里除了设置为GC_PURPLE避免后续重复插入,还会设置一个信息GC_INFO(ref) = (newRoot - GC_G(buf)) | GC_PURPLE,即把ref在buf数组中的位置保存到了zend_refcounted_h.u.v.gc_info中。这样做的目的是当后续value的refcount变为0时,需要将其从buf中删除,此时就可以知道该value保存在哪个gc_root_buffer中。删除操作通过GC_REMOVE_FROM_BUFFER()宏完成:

c 复制代码
#define GC_REMOVE_FROM_BUFFER(p) do { \
    zend_refcounted *_p = (zend_refcounted *)(p); \
    if (GC_ADDRESS(GC_INFO(_p))) { \
        gc_remove_from_buffer(_p); \
    } \
} while (0)

#define GC_ADDRESS(v) \
    ((v) & ~GC_COLOR)

删除时首先根据gc_info取到gc_root_buffer,然后再从buf中移除,删除后把空出来的gc_root_buffer插入unused单链表尾部。

c 复制代码
ZEND_API void ZEND_FASTCALL gc_remove_from_buffer(zend_refcounted *ref) {
    gc_root_buffer *root;
    /* GC_ADDRESS获取的就是节点在缓冲区中的位置 */
    root = GC_G(buf) + GC_ADDRESS(GC_INFO(ref));
    if (GC_REF_GET_COLOR(ref) != GC_BLACK) {
        GC_TRACE_SET_COLOR(ref, GC_PURPLE);
    }
    GC_INFO(ref) = 0;
    // 双向链表删除操作
    GC_REMOVE_FROM_ROOTS(root);
}

最后我们看一下buf缓存区满后,执行垃圾回收的过程,具体操作是在zend_gc_collect_cycles()中完成:

c 复制代码
ZEND_API int zend_gc_collect_cycles(void) {
    ...
    // (1)遍历roots链表,对当前节点value的所有成员(如数组元素、对象成员)进行深度优先遍历,
    //     把成员refcount减1
    gc_mark_roots();
    // (2)再次遍历roots链表,检查各节点当前refcount是否为0,如果是标为白色,表示是垃圾,
    //     不是的话需要还原(1),把refcount再加回去
    gc_scan_roots();
    // (3)将roots链表中的非白色节点删除,之后roots链表中全部是真正的垃圾,
    //     将垃圾链表转到to_free等待释放
    count = gc_collect_roots(&gc_flags, &additional_buffer);
    ...
    // (4)释放垃圾
    current = to_free.next;
    while (current != &to_free) {
        p = current->ref;
        GC_G(next_to_free) = current->next;
        if ((GC_TYPE(p) & GC_TYPE_MASK) == IS_OBJECT) {
            // 调用free_obj释放对象
            obj->handlers->free_obj(obj);
            ...
        } else if ((GC_TYPE(p) & GC_TYPE_MASK) == IS_ARRAY) {
            // 释放数组
            zend_array *arr = (zend_array *)p;
            
            GC_TYPE(arr) = IS_NULL;
            zend_hash_destroy(arr);
        }
        current = GC_G(next_to_free);
    }
    ...
}

4.3 内存池

在C语言中,我们通常直接使用malloc进行内存的分配,而频繁地分配、释放内存会产生内存碎片,降低系统性能。在PHP中,变量的分配、释放非常频繁,如果所有变量都通过malloc的方式分配,将会造成严重的性能问题,作为语言级的应用,这种损耗是无法接受的。为此,PHP自己实现了一套内存池(ZendMM:Zend Memory Manager),用于替换glibc的malloc、free,以解决内存频繁分配、释放的问题。内存池技术主要有两方面的作用:减少内存分配和释放次数、有效控制内存碎片的产生。

PHP内存池的实现参考了tcmalloc的设计,tcmalloc是Google开源的一个非常优秀的内存分配器,尤其是在多线程应用中,它使用C++实现。除了tcmalloc,还有很多内存池的实现,如ptmalloc、jemalloc等。

内存池是PHP内核中最底层的内存操作,它是非常独立的一个模块,可以移植到其他C语言应用中去。内存池定义了三种粒度的内存块:chunk、page、slot,chunk的大小为2MB,page大小为4KB,一个chunk被切割为512个page,而一个或若干个page被切割为多个slot。申请内存时按照不同的申请大小决定具体的分配策略:

1.Huge(chunk):申请内存大于2MB,直接调用系统分配,分配若干个chunk。

2.Large(page):申请内存大于3092B(即page大小的3/4),小于2044KB(即511个page大小),分配若干个page。

3.Small(slot):申请内存小于等于3092B(即page大小的3/4),内存池提前定义好了30种大小的内存(8,16,24,32···3072),每种大小的内存有多个,它们分配在page上,申请内存时直接在对应page上查找可用的slot。

zend_mm_heap结构中存储了内存池的主要信息,比如大内存链表、chunk链表、slot内存链表等,这个结构通过全局变量alloc_globals保存,AG宏用于操作这个全局变量。

c 复制代码
// file: zend_alloc.c
#define AG(v) (alloc_globals.v)

static zend_alloc_globals alloc_globals;

// file: zend_alloc.h
typedef struct _zend_mm_heap zend_mm_heap;

struct _zend_mm_heap {
#if ZEND_MM_STAT
    size_t  size; // 当前已用内存数
    size_t  peak; // 内存单次申请的峰值
#endif
    /* 小内存分配的可用位置链表,ZEND_MM_BINS等于30,即此数组表示的是各种大小内存对应的链表头部 */
    zend_mm_free_slot *free_slot[ZEND_MM_BINS];
    ...
    // 大内存链表
    zend_mm_huge_list *huge_list;
    // 指向chunk链表头部
    zend_mm_chunk *main_chunk;
    // 缓存的chunk链表
    zend_mm_chunk *cached_chunks;
    // 已分配chunk数
    int chunks_count;
    // 当前request使用chunk峰值
    int peak_chunks_count;
    // 缓存的chunk数
    int cached_chunks_count;
    /* chunk使用均值,每次请求后会根据peak_chunks_count重新计算:
      (avg_chunks_count + peak_chunks_count) / 2.0 */
    double avg_chunks_count;
};

大内存分配的实际是若干个chunk,然后通过一个zend_mm_huge_list结构进行管理:

c 复制代码
typedef struct _zend_mm_huge_list zend_mm_huge_list;
struct _zend_mm_huge_list {
    void *ptr;
    size_t size;
    zend_mm_huge_list *next;
};

大内存之间构成一个单向链表,如图4-6所示:

chunk是内存池向系统申请、释放内存的最小粒度,chunk之间构成双向链表,第一个chunk的地址保存于zend_mm_heap->main_chunk。每个chunk的大小为2MB,它被切割为512个page,所以每个page的大小是4KB,其中第一个page的内存用于chunk自己的结构体成员,主要记录chunk的一些信息,比如前后chunk的指针、当前chunk上各个page的使用情况等:

c 复制代码
struct _zend_mm_chunk {
    zend_mm_heap *heap;
    // 指向下一个chunk
    zend_mm_chunk *next;
    // 指向上一个chunk
    zend_mm_chunk *prev;
    // 当前chunk的剩余可用page数
    int free_pages;
    int free_tail;
    int num;
    char reserve[64 - (sizeof(void *) * 3 + sizeof(int) * 3)];
    // heap结构,只有主chunk会用到
    zend_mm_heap heap_slot;
    // 标识各page是否已被使用的bitmap,总大小512bit,对应page总数,每个page占一个bit位
    zend_mm_page_map free_map;
    // 各page的信息:当前page使用类型(用于large分配还是small)、占用的page数等
    zend_mm_page_info map[ZEND_MM_PAGES];
};

slot内存是把若干个page按固定大小分割好的内存块,内存池定义了30种大小的slot内存:8、16、24、32、···、1792、2048、2560、3072。这些slot的大小是有规律的,最小的slot大小为8byte,前8个slot依次递增8byte,后面每隔4个递增值乘2,即0~7递增8byte、8~11递增16byte、12~15递增32byte、16~19递增64byte、20~23递增128byte、24~27递增256byte、28~29递增512byte。每种大小的slot占用的page数也不同:slot 0~15各占1个page,slot 16~29分别占5、3、1、1、5、3、2、2、5、3、7、4、5、3个page,分配各个规格的slot时会按照这个配置申请对应数量的page,然后进行分割组成链表。所有slot的大小、数量、占用的page数等信息由zend_alloc_sizes.h文件中的ZEND_MM_BINS_INFO定义。相同大小的slot之间构成单链表,具体结构如下:

c 复制代码
typedef struct _zend_mm_free_slot zend_mm_free_slot;
struct _zend_mm_free_slot {
    zend_mm_free_slot *next_free_slot;
};

heap、huge、chunk、page、slot之间的关系如图4-7所示:

4.3.1 内存池的初始化

内存池在php_module_startup()阶段初始化,初始化过程主要是分配heap结构,这个过程在start_memory_manager()中完成,如果是多线程环境,则会为每个线程分配一个内存池,线程之间互不影响。

c 复制代码
ZEND_API void start_memory_manager(void) {
#ifdef ZTS
    ...
#else
    // 非线程安全
    alloc_globals_ctor(&alloc_globals);
#endif
}

static void alloc_globals_ctor(zend_alloc_globals *alloc_globals) {
#ifdef MAP_HUGETLB
    // 根据环境变量设定是否开启大页
    tmp = getenv("USE_ZEND_ALLOC_HUGE_PAGES");
    if (tmp && zend_atoi(tmp, 0)) {
        zend_mm_use_huge_pages = 1;
    }
#endif
    ZEND_TSRMLS_CACHE_UPDATE();
    alloc_globals->mm_heap = zend_mm_init();
}

初始化时会根据环境变量USE_ZEND_ALLOC_HUGE_PAGES设定是否开启内存大页。非线程安全环境下,会将分配的heap结构保存到全局变量alloc_globals中,也就是AG()宏。需要注意的是,zend_mm_heap这个结构不是单独分配的,它嵌在chunk结构体中(即heap_slot成员)。也就是说,内存池初始化时分配了一个chunk结构,zend_mm_chunk->heap_slot作为内存池的heap结构,这个chunk也是第一个chunk,即main_chunk,如图4-8所示:

内存池的heap结构为什么要嵌在chunk中而不是单独分配呢?这是因为每个chunk的第一个page始终是给chunk结构体自己使用,剩下的511个page才用作内存分配,但chunk结构体并不需要一个page那么大的内存,即被chunk结构体占用的page中会有剩余空间,为了尽可能利用空间,就把heap结构嵌在了chunk中,这种做法与zval结构体中u2的起源很像。具体的分配过程在zend_mm_init()中实现:

c 复制代码
static zend_mm_heap *zend_mm_init(void) {
    // 向系统申请2MB大小的chunk
    zend_mm_chunk *chunk = (zend_mm_chunk *)zend_mm_chunk_alloc_int(ZEND_MM_CHUNK_SIZE, 
      ZEND_MM_CHUNK_SIZE);
    zend_mm_heap *heap;
    // heap结构实际是嵌入到主chunk的一个结构
    heap = &chunk->heap_slot;
    ...
    // 剩余可用page数
    chunk->free_pages = ZEND_MM_PAGES - ZEND_MM_FIRST_PAGE;
    chunk->free_tail = ZEND_MM_FIRST_PAGE;
    chunk->num = 0;
    // 将第一个page的bit分配标识位设为1,表示已被分配、占用
    chunk->free_map[0] = (Z_L(1) << ZEND_MM_FIRST_PAGE) - 1;
    // 第一个page的类型为ZEND_MM_IS_LRUN,即large内存
    chunk->map[0] = ZEND_MM_LRUN(ZEND_MM_FIRST_PAGE);
    heap->main_chunk = chunk; // 指向主chunk
    // 初始化剩下的成员
    ...
    heap->huge_list = NULL; // huge内存链表
    return heap;
}

4.3.2 内存分配

接下来分析三种粒度内存的分配过程,其中Huge大内存的分配过程比较简单,而Large与Small内存分配都涉及page的查找操作,过程稍显复杂。需要使用emalloc()、emalloc_large()、emalloc_huge()、ecalloc()、erealloc()等方法向内存池申请内存。以emalloc()为例,申请时,内存池会按照申请内存的大小自动选择哪种内存进行分配,如图4-9所示:

4.3.2.1 Huge分配

Huge分配是指超过2MB大小内存的分配,实际分配时将对齐到chunk的整数倍,分配完后还会分配一个zend_mm_huge_list结构,这个结构用于管理所有分配的Huge内存:

c 复制代码
static void *zend_mm_alloc_huge(zend_mm_heap *heap, size_t size ZEND_FILE_LINE_DC
  ZEND_FILE_LINE_ORIG_DC) {
    // 按页大小重置实际要分配的内存
    size_t new_size = ZEND_MM_ALIGNED_SIZE_EX(size, REAL_PAGE_SIZE);

#if ZEND_MM_LIMIT
    // 如果有内存使用限制,则check是否已达上限,达到的话进行zend_mm_gc清理后再检查,此过程不再展开分析
#endif
    // 分配chunk
    ptr = zend_mm_chunk_alloc(heap, new_size, ZEND_MM_CHUNK_SIZE);
    ...
    // 将申请的内存通过zend_mm_huge_list插入到链表中
    zend_mm_add_huge_block(heap, ptr, new_size, ...);
    ...
    return ptr;
}

除Huge分配外,分配chunk内存也是Large、Small内存分配的基础,它是ZendMM向系统申请内存的唯一粒度。在分配chunk时有一个关键操作,那就是将内存地址对齐到chunk的大小(2MB,即ZEND_MM_CHUNK_SIZE)。即分配的chunk地址都是ZEND_MM_CHUNK_SIZE的整数倍。需要注意的是,这个对齐不是简单地由系统完成的,而是需要内存池在申请内存后自己进行调整。系统返回的地址是随机的,并不一定是ZEND_MM_CHUNK_SIZE的倍数,内存池要自己移动到对齐的位置上,比如返回的地址ptr是2000,而最近的一个对齐地址是2048,那么内存池就会把ptr移到2048,从这个位置使用。

ZendMM处理对齐的方法是:先按实际要申请的内存大小申请一次,如果系统分配的地址恰好是ZEND_MM_CHUNK_SIZE的整数倍,那么就不需要调整了,直接返回使用;如果不是ZEND_MM_CHUNK_SIZE的整数倍,ZendMM会把这块内存释放掉,然后按照"实际要申请的内存大小+ZEND_MM_CHUNK_SIZE"的大小重新申请一块内存,多申请的ZEND_MM_CHUNK_SIZE大小的内存是用来调整的,ZendMM会从系统分配的地址向后偏移到最近一个ZEND_MM_CHUNK_SIZE的整数倍位置,调整完后再把多余的内存释放掉。

c 复制代码
static void *zend_mm_chunk_alloc_int(size_t size, size_t alignment) {
    // 向系统申请size大小的内存
    void *ptr = zend_mm_mmap(size);
    if (ptr == NULL) {
        return NULL;
    } else if (ZEND_MM_ALIGNED_OFFSET(ptr, alignment) == 0) {
        // 判断申请的内存是否为alignment的整数倍,是的话直接返回
        return ptr;
    } else {
        // 申请的内存不是按照alignment对齐的
        size_t offset;
        // 将申请的内存释放掉重新申请
        zend_mm_munmap(ptr, size);
        // 重新申请一块内存,这里会多申请一块内存,用于截取到alignment的整数倍,可以忽略REAL_PAGE_SIZE
        ptr = zend_mm_mmap(size + alignment - REAL_PAGE_SIZE);
        // offset为ptr距离上一个alignment对齐内存位置的大小,注意不能往前移,因为前面的内存都是分配了的
        offset = ZEND_MM_ALIGNED_OFFSET(ptr, alignment);
        if (offset != 0) {
            offset = alignment - offset;
            zend_mm_munmap(ptr, offset);
            // 偏移ptr,对齐到alignment
            ptr = (char *)ptr + offset;
            alignment -= offset;
        }
        if (alignment > REAL_PAGE_SIZE) {
            zend_mm_munmap((char *)ptr + size, alignment - REAL_PAGE_SIZE);
        }
        return ptr;
    }
}

以上过程用到了宏ZEND_MM_ALIGNED_OFFSET(),这个宏的作用是计算内存地址距离上一个alignment整数倍内存地址的距离,也就是offset偏移量。alignment必须为2的n次方,比如n * alignment大小的内存,ptr为其中一个位置,那么就可通过位运算得到ptr在所属alignment内存块中的offset,如图4-10所示:

ZEND_MM_ALIGNED_OFFSET宏的内容:

c 复制代码
#define ZEND_MM_ALIGNED_OFFSET(size, alignment) \
    (((size_t)(size)) & ((alignment) - 1))

以上位运算中,alignment为2 n ^n n,比如n为3,则alignment为8,用二进制表示为1000,那么alignment - 1的值的二进制表示为0111,与size做与运算,得到的就是alignment % size

4.3.2.2 Large分配

当申请的内存大于3072B、小于2044KB时,内存池会选择在chunk上查找对应数量的page返回。Large内存申请的粒度是page,也就是分配n页连续的page,所以Large分配的过程就是在chunk上查找n页连续可用的page的过程。

c 复制代码
// 宏ZEND_FILE_LINE_DC和ZEND_FILE_LINE_ORIG_DC用于调试
// 它们可能展开为额外的参数,用来记录行数等信息用于debug,宏的DC后缀的含义为Debug Context
static zend_always_inline void *zend_mm_alloc_large(zend_mm_heap *heap, 
  size_t size ZEND_FILE_LINE_DC ZEND_FILE_LINE_ORIG_DC) {
    // 根据size大小计算需要分配多少个page
    int pages_count = (int)ZEND_MM_SIZE_TO_NUM(size, ZEND_MM_PAGE_SIZE);
    // 分配pages_count个page
    void *ptr = zend_mm_alloc_pages(heap, pages_count, ...);
    ...
    return ptr;
}

chunk结构中有两个成员用于记录page的分配信息:

1.free_map:类型为zend_mm_page_map,实际上就是zend_ulong free_map[16/8](此处的16/8的含义为,在64位系统上,数组大小为8,在32位系统上,数组大小为16,这样可以确保不论是在32位还是64位平台上,free_map数组大小都是64byte,因为64位系统上zend_ulong的长度为8byte,数组长度为8,因此总长64byte,而在32位系统上zend_ulong的长度为4byte,数组长度为16,总长度还是64byte),这是一个bitmap,总大小为64byte,即512bit,它的作用是记录当前chunk上512个page是否已分配,512个page对应512bit,每个page各占一个bit位,0表示未分配,1表示已分配。以64位系统为例,free_map为8个长整型的数组,比如当前chunk的page0、page1已经分配,则free_map[0] = 3,即00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000011,如图4-11所示:

2.map:这是一个可容纳512个uint32_t元素的数组,该数组用于记录各page的分配类型及分配的page页数,每个page对应一个数组成员。Large内存、Small内存都会占用page,正是通过这个数组标识该page属于Large还是Small内存的。该数组除了用来标识page的分配类型,还用来记录分配的page页数,比如申请分配了3个page,那么就会把3记录在起始page对应的map数组元素中。一个map数组元素中存储的分配类型和page页数通过bit位区分开,最高的两位用于标识page的分配类型:01为Large(即0x4000 0000)、10为Small(即0x8000 0000),剩余位用于page数。比如申请12KB的内存(即3个page),内存池分配了page 1、2、3,则map[1] = 0x40000000 | 3,如图4-12所示:

page分配时从第一个chunk开始遍历,依次查找各chunk是否有满足要求的page,如果当前chunk没有合适的则进入下一chunk,如果直到最后都没有找到,则新分配一个chunk。需要注意的是,查找过程不仅仅是page页数够了即可,这里有一个准则:"申请的page页数要尽可能填满chunk的空隙",也就是说要分配的page要尽可能与已分配的page连在一起,避免中间出现page空隙。这样做的目的是减少后续分配时的查找次数,提高内存利用率。page排列越紧凑,查找过程的效率就越高,如果未分配的page凌乱地分布在chunk上,那么查找时就需要花费更多时间检查这些page是否够用。比如1个未分配page与1个已分配page相间分布,如果想分配2页page,就会导致查找了整个chunk都没有合适的,因为没有2个连续未分配page,尽管chunk上有大量的空闲page,而如果这些已分配的page全部挨在一起,则只需要查找一次,在提高查找效率的同时也充分利用了内存空间。这种做法实际是避免在chunk上产生内存碎片。

最优page的检索过程:

1.首先从第一个page分组(page 0~63)开始检查,如果当前分组无空闲page(即free_map[x] = -1)则进入下一分组,直到当前分组有空闲page,然后进入第2步。

c 复制代码
// zend_mm_alloc_pages:
/* Best-Fit Search */
int best = -1;
int best_len = ZEND_MM_PAGES;
int free_tail = chunk->free_tail;
zend_mm_bitset *bitset = chunk->free_map;
// 将free_map复制一份进行操作
zend_mm_bitset tmp = *(bitset++); // zend_mm_bitset tmp = *bitset; bitset++;
int i = 0;

while (1) {
	// -1表示当前chunk所有page都已分配,在32位系统上,-1为:11111111 11111111 11111111 11111111
	while (tmp == (zend_mm_bitset)-1) {
	    i += ZEND_MM_BITSET_LEN;
	    if (i == ZEND_MM_PAGES) { // 最后一页
	        ...
	    }
	    // 继续检查下一个page分组
	    tmp = *(bitset)++;
	}

2.当前分组有可用page,首先检查当前page分组的bit位,找到第一个空闲page的位置,记作page_num,接着继续向下查找空闲page,直到遇到第一个已经分配的page为止,记录下一个空闲page位置为end_page_num。注意:查找end_page_num时并不局限在当前page分组内,会一直向下查找,直到最后一页。查找过程主要依据free_map,但这个过程没有遍历各个bit位,而是用到了很多位运算:

c 复制代码
// 接以上zend_mm_alloc_pages代码:
	// tmp为当前page分组的bit位,i为当前分组第1个page的页码
	// 找到tmp中第一个空闲page
	page_num = i + zend_mm_bitset_nts(tmp);
	// 此处将tmp中最右边的0,及其右边所有的位清0
	// 此处tmp不可能是全1,因为上面代码已经处理了全1的情况
	tmp &= tmp + 1;
	// 当计算后的tmp为0,即计算前的tmp最左边全为0,即二进制格式为000...01xxxx时
	// 即当前page中的第一个空闲page,及其之后全是空闲page时
	// 从这里可以看出,tmp是用小端序存的,因为低地址在低位,即越往左的位,表示的page的索引越大
	// 快速跳过剩余page中全部可用的分组
	while (tmp == 0) {
	    i += ZEND_MM_BIT_LEN;
	    
	    if (i >= free_tail || i == ZEND_MM_PAGES) {
	        ...
	    }
	    // 当前分组剩下的page都是可用的,直接跳到下一分组
	    tmp = *(bitset++);
	}
	// 找到第一个已分配page
	len = i + zend_mm_bitset_ntz(tmp) - page_num;
	// len为找到的可用page页数
	if (len >= pages_count) {
	    if (len == pages_count) {
	        goto found;
	    } else if (len < best_len) {
	        best_len = len;
	        best = page_num;
	    }
	}
	// 此处将tmp中最右边1的右边所有位变为1,即下次循环时,就会从把当前空闲page后面开始查找空闲page
	// 运行到此处说明找到的连续空闲page不够,或够了但可能不是最优的
	tmp |= tmp - 1;
}

page_num至end_page_num为找到的可用page,接着判断找到的page页数是否够用,如果不够则把page_num至end_page_num这些page的bit位标为1(从以上代码看来,tmp只会把end_page_num所在的unsigned long里,小于end_page_num的页对应的位标为1,这样下次循环就会从end_page_num后面开始找空闲page),也就是已分配,然后回到第1步继续检索其他page分组;如果page页数恰好是要申请的页数,那么直接使用,中断检索;如果找到的page页数比申请的页数大则表示可用,但不是最优的,此时会把page_num暂存起来,接着回到第1步继续向后找别的空闲page,最后比较选择best_len最小的,即能够最大程度填满page间隔的。

例如,当前某个chunk的page分配情况如图4-13中的A所示:

上图中A的page分配情况:page 0、1、2、6、9、10已经分配占用。如果要申请2页page,首先会找到可用的page 3、4、5标为已分配(这里更改的是复制出来的free_map,不会影响实际的值),如上图中的B。接着向下查找,找到可用page 7、8,其空间正好等于申请的页数,比page 3开始的连续page合适,因此最后返回page 7的地址,并更新page 7、8的实际free_map。

上面查找end_page_num的代码中有一步特殊操作:tmp &= tmp + 1,如果计算后tmp为0,表示该page分组剩余的page全是空闲的,此时会直接跳到下一个page分组。比如计算前tmp为3,表示前两个page已分配,二进制表示为:0000 ··· 0000 0011,tmp + 1 = 4,二进制表示为:0000 ···· 0000 0100,两个值相与的结果是0。

3.最后,找到合适的page页后设置对应page的分配信息,即free_map、map,然后返回找到第一页page的地址:

c 复制代码
// zend_mm_alloc_pages:
found:
    chunk->free_pages -= pages_count;
    zend_mm_bitset_range(chunk->free_map, page_num, pages_count);
    chunk->map[page_num] = ZEND_MM_LRUN(pages_count);
    if (page_num == chunk->free_tail) {
        chunk->free_tail = page_num + pages_count;
    }
    return ZEND_MM_PAGE_ADDR(chunk, page_num);

4.3.2.3 Small分配

Small内存在分配时,首先检查要申请的规格的内存是否已经分配,如果没有分配或分配的已用完,则申请相应页数的page,page的分配过程与Large分配完全一致,申请到page后按固定大小将page切割为slot,slot之间构成单链表,链表头部保存到AG(mm_heap)->free_slot;如果对应的slot已经分配,则直接返回AG(mm_heap)->free_slot。比如16 byte、3072 byte大小的slot,将分别申请1个、3个page,然后分别切割为256个16 byte的slot和4个3072 byte的slot,如图4-14所示:

Small内存的具体分配过程:

c 复制代码
static zend_always_inline void *zend_mm_alloc_small(zend_mm_heap *heap, size_t size,
  int bin_num ZEND_FIL E_LINE_DC ZEND_FILE_LINE_ORIG_DC) {
    ...
    // EXPECTED使用CPU的分支预测优化条件表达式的执行路径
    // 在这里,EXPECTED表示此条件在大多数情况下都为真,从而编译器能优化代码,加快条件为真时的执行效率
    if (EXPECTED(heap->free_slot[bin_num] != NULL)) {
        // 有可用slot
        zend_mm_free_slot *p = heap->free_slot[bin_num];
        heap->free_slot[bin_num] = p->next_free_slot;
        return (void *)p;
    } else {
        // 新分配slot
        return zend_mm_alloc_small_slow(heap, bin_num ZEND_FILE_LINE_RELAY_CC
          ZEND_FILE_LINE_ORIG_RELAY_CC);
    }
}

zend_mm_alloc_small_slow()是分配slot的操作:

c 复制代码
// bin_num为free_slot数组下标
static zend_never_inline void *zend_mm_alloc_small_slow(zend_mm_heap *heap, int bin_num
  ZEND_FILE_LINE_CC ZEND_FILE_LINE_ORIG_DC)
{
    zend_mm_chunk *chunk;
    int page_num;
    zend_mm_bin *bin;
    zend_mm_free_slot *p, *end;
    // 分配固定数量的page,bin_pages[bin_num]是bin_num对应大小的内存需要的page数
    bin = (zend_mm_bin *)zend_mm_alloc_pages(heap, bin_pages[bin_num] 
      ZEND_FILE_LINE_RELAY_CC ZEND_FILE_LI NE_ORIG_RELAY_CC);
    // 根据分配的page地址,计算出page所在chunk的基地址
    chunk = (zend_mm_chunk *)ZEND_MM_ALIGNED_BASE(bin, ZEND_MM_CHUNK_SIZE);
    // 计算bin在chunk中的相对位移量,然后转换为页面号
    page_num = ZEND_MM_ALIGNED_OFFSET(bin, ZEND_MM_CHUNK_SIZE) / ZEND_MM_PAGE_SIZE;
    // 设置第一个page的分配类型
    chunk->map[page_num] = ZEND_MM_SRUN(bin_num);
    // 如果分配的page数量大于1
    if (bin_pages[bin_num] > 1) {
        int i = 1;
        // 循环设置所有分配的page的分配类型
        do {
            chunk->map[page_num + i] = ZEND_MM_NRUN(bin_num, i);
            i++;
        } while (i < bin_pages[bin_num]);
    }
    // 创建slot链表
    // 计算bin中最后一个空闲slot的地址
    end = (zend_mm_free_slot *)((char *)bin + 
      (bin_data_size[bin_num] * (bin_elements[bin_num] - 1)));
    // 空闲slot,即heap->free_slot[bin_num]指向第2个slot,第1个slot给当前的申请者
    heap->free_slot[bin_num] = p = (zend_mm_free_slot *)((char *)bin +
      bin_data_size[bin_num]);
    // 将申请到的空闲slot用链表连起来
    do {
        p->next_free_slot = (zend_mm_free_slot *)((char *)p + bin_data_size[bin_num]);
        p = (zend_mm_free_slot *)((char *)p + bin_data_size[bin_num]);
    } while (p != end);
    p->next_free_slot = NULL;
    // 返回申请到的page中的第一个slot
    return (char *)bin;
}

4.3.3 系统内存分配

前面介绍了三种内存分配过程,实际上内存池只是在系统内存上做了一些额外工作,从而减少系统内存的分配、释放次数。内存池向系统申请内存的最小粒度是chunk,通过mmap()来申请,开启HugePage支持项也是这里用到的。

c 复制代码
static void *zend_mm_mmap(size_t size) {
    ...
// hugepage支持
#ifdef MAP_HUGETLB
    if (zend_mm_use_huge_pages && size == ZEND_MM_CHUNK_SIZE) {
        ptr = mmap(NULL, size, PROT_READ | PROT_WRITE, 
          MAP_PRIVATE | MAP_ANON | MAP_HUGETLB, -1, 0);
        if (ptr != MAP_FAILED) {
            return ptr;
        }
    }
#endif

    // MAP_ANON表示映射的内存不对应任何文件,而是系统内存,相当于获取一块内存区域
    ptr = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANON, -1, 0);
    if (ptr == MAP_FAILED) {
        return NULL;
    }
    return ptr;
}

关于Hugepage:大多数操作系统的内存是以4KB分页的,而虚拟地址和内存地址需要转换,转换时需要查表,CPU为了加速这个查表过程都会内建TLB(Translation Lookaside Buffer)。如果虚拟页越小,表里的条目就越多,而TLB大小是有限的,条目越多,TLB的Cache Miss就会越高,所以启动大内存页就能间接降低TLB Cache Miss。

4.3.4 内存释放

内存释放主要通过efree()来完成,内存池会根据释放的内存地址自动判断属于哪种粒度的内存,从而执行不同的释放逻辑。另外也可以直接使用对应类型的内存释放函数:

c 复制代码
#define efree(ptr) _efree((ptr) ZEND_FILE_LINE_CC ZEND_FILE_LINE_EMPTY_CC)
#define efree_large(ptr) _efree_large((ptr) ZEND_FILE_LINE_CC ZEND_FILE_LINE_EMPTY_CC)
#define efree_huge(ptr) _efree_huge((ptr) ZEND_FILE_LINE_CC ZEND_FILE_LINE_EMPTY_CC)

释放内存时只根据一个内存地址来完成,即efree(ptr),那么内存池是如何只根据一个地址就判断出该地址属于那种内存类型的呢?这是因为chunk分配时是按ZEND_MM_CHUNK_SIZE(即2MB)对齐的,即chunk的起始内存地址一定是ZEND_MM_CHUNK_SIZE的整数倍,所以可以根据chunk上的任意地址知道chunk的起始位置即该地址所在page。

1.Huge内存的释放

首先,根据释放地址ptr计算该地址相对chunk起始位置的内存偏移量,这个值通过宏ZEND_MM_ALIGNED_OFFSET()得到,该宏通过位运算得到结果,比如ptr = 0x7fff7c01000,计算得到offset为4096,表示该内存地址距离chunk起始位置的距离为4096字节。

前面已经介绍过,只有Huge内存能完全使用chunk,即Huge内存地址相对chunk的offset一定等于0,而Large、Small内存因为chunk的第1个page被占用了(用于保存chunk自己的结构体_zend_mm_chunk),所以这两种内存的offset不可能为0。内存池可根据offset值判断出释放的内存是否是Huge类型,如果是则将占用的chunk释放,同时从AG(mm_heap)->huge_list链表中删除。

c 复制代码
if (UNEXPECTED(page_offset == 0)) {
    if (ptr != NULL) {
        // 释放huge内存,从huge_list中删除
        zend_mm_free_huge(heap, ptr ZEND_FILE_LINE_RELAY_CC ZEND_FILE_LINE_ORIG_RELAY_CC);
    }
}

Huge的具体删除过程:

c 复制代码
static void zend_mm_free_huge(zend_mm_heap *heap, void *ptr ZEND_FILE_LINE_DC
  ZEND_FILE_LINE_ORIG_DC) {
    size_t size;
    
    ZEND_MM_CHECK(ZEND_MM_ALIGNED_OFFSET(ptr, ZEND_MM_CHUNK_SIZE) == 0,
      "zend_mm_heap corrupted");
    // 将释放的huge内存从链表中删除
    size = zend_mm_del_huge_block(heap, ptr ZEND_FILE_LINE_RELAY_CC
      ZEND_FILE_LINE_ORIG_RELAY_CC);
    // 释放chunk
    zend_mm_chunk_free(heap, ptr, size);
    ...
}

2.Large内存的释放

如果计算得到的offset不为0,则表示该地址是Large内存或Small内存,然后根据offset进一步计算出该地址属于第几个page,这个计算比较简单,直接根据offset除page大小取整即可,得到page页码后就可以从chunk->map中获取该page的分配类型,此时就可以确定要释放的地址属于哪种粒度的内存了。

c 复制代码
// zend_mm_free_heap:
zend_mm_chunk *chunk = (zend_mm_chunk *)ZEND_MM_ALIGNED_BASE(ptr, ZEND_MM_CHUNK_SIZE);
// 计算所属page
int page_num = (int)(page_offset / ZEND_MM_PAGE_SIZE);
// 获取page的分配类型及页数
zend_mm_page_info info = chunk->map[page_num];
if (EXPECTED(info & ZEND_MM_IS_SRUN)) {
    // Small内存
} else {
    // Large内存
    int pages_count = ZEND_MM_LRUN_PAGES(info);
    
    ZEND_MM_CHECK(ZEND_MM_ALIGNED_OFFSET(page_offset, ZEND_MM_PAGE_SIZE) == 0,
      "zend_mm_heap corrupted");
    zend_mm_free_large(heap, chunk, page_num, pages_count);
}

如果是Large内存,并不会直接释放物理内存,只是将对应的分配信息重新设置为未分配。释放page后,如果发现当前chunk下所有page都是未分配的,则会释放chunk,释放时优先选择把chunk移到AG(mm_heap)->cached_chunks缓存队列中,缓存数达到一定值后就不再继续缓存新加入的chunk,而是将内存归还系统,避免占用过多内存资源。在分配chunk时,如果发现cached_chunks中有缓存的chunk,则直接取出使用,不再向系统申请。

c 复制代码
// page的释放
static zend_always_inline void zend_mm_free_pages_ex(zend_mm_heap *heap,
  zend_mm_chunk *chunk, int page_num, int pages_count, int free_chunk) {
    // 增加当前chunk的可用page数
    chunk->free_pages += pages_count;
    // 将对应page的分配bit位设为0,表示未分配
    zend_mm_bitset_reset_range(chunk->free_map, page_num, page_count);
    // 将要释放的内存块的信息(页数、分配类型Small or Large)重置
    chunk->map[page_num] = 0;
    // free_tail是第一个空闲page,如果释放的内存块在第一个空闲位置之前
    if (chunk->free_tail == page_num + pages_count) {
        /* this setting may be not accurate */
        // 将第一个空闲page前移
        chunk->free_tail = page_num;
    }
    // 如果当前chunk的所有page都是未分配的,则释放chunk
    if (free_chunk && chunk->free_pages == ZEND_MM_PAGES - ZEND_MM_FIRST_PAGE) {
        zend_mm_delete_chunk(heap, chunk);
    }
}

zend_mm_delete_chunk()完成缓存或释放chunk的操作,cached_chunks会根据每次request请求计算的chunk使用均值以保证其维持在一定范围内,具体过程不再展开。

3.Small内存的释放

如果根据获取的page分配类型发现释放的地址为Small内存,则会将释放的slot插入到该规格slot可用链表的头部,如图4-15所示:

c 复制代码
static zend_always_inline void zend_mm_free_small(zend_mm_heap *heap, void *ptr,
  int bin_num) {
    zend_mm_free_slot *p;
    ...
    // 将释放的slot插入free_slot头部
    p = (zend_mm_free_slot *)ptr;
    p->next_free_slot = heap->free_slot[bin_num];
    heap->free_slot[bin_num] = p;
}
相关推荐
LK_0743 分钟前
【Open3D】Ch.3:顶点法向量估计 | Python
开发语言·笔记·python
饮浊酒1 小时前
Python学习-----小游戏之人生重开模拟器(普通版)
python·学习·游戏程序
li星野1 小时前
打工人日报#20251011
笔记·程序人生·fpga开发·学习方法
摇滚侠1 小时前
Spring Boot 3零基础教程,yml配置文件,笔记13
spring boot·redis·笔记
QT 小鲜肉1 小时前
【个人成长笔记】在Ubuntu中的Linux系统安装 anaconda 及其相关终端命令行
linux·笔记·深度学习·学习·ubuntu·学习方法
QT 小鲜肉1 小时前
【个人成长笔记】在Ubuntu中的Linux系统安装实验室WIFI驱动安装(Driver for Linux RTL8188GU)
linux·笔记·学习·ubuntu·学习方法
急急黄豆1 小时前
MADDPG学习笔记
笔记·学习
BullSmall1 小时前
《道德经》第十七章
学习
Chloeis Syntax2 小时前
栈和队列笔记2025-10-12
java·数据结构·笔记·
知识分享小能手2 小时前
微信小程序入门学习教程,从入门到精通,项目实战:美妆商城小程序 —— 知识点详解与案例代码 (18)
前端·学习·react.js·微信小程序·小程序·vue·前端技术