cover_image

PHP内核分析-FPM数据类型

杨通 贝壳产品技术
2018年06月09日 14:23
图片

点击上方蓝字关注


杨通,新房研发部研发工程师,负责新房Link研发工作。2015年加入链家网(现贝壳找房),曾在大数据、新房研发部等部门工作。


上一章PHP内核分析-FPM进程管理中我们分析了FPM做进程管理的细节,本章中我们将进入PHP内核部分,我们从最简单的变量存储开始。

图片

1.  变量
图片
变量结构
图片

PHP中变量可以分为三个部分:变量名、变量值和变量类型。稍微对PHP有些了解的人都知道,PHP变量存储的基本单元就是Zval,我们先来看下Zval的结构:(Zend/zend_types.h:179L)

 1struct _zval_struct {
2    zend_value        value;                //实际存储值的地方,8Byte
3    union {
4        //存储的一些辅助变量
5        //ZEND_ENDIAN_LOHI_4主要是为了兼容操作系统大小字节顺序,参考其定义
6        struct {
7            ZEND_ENDIAN_LOHI_4(
8                zend_uchar    type,         //变量类型
9                zend_uchar    type_flags,    //不同变量类型的依稀属性
10                zend_uchar    const_flags,
11                zend_uchar    reserved)     //预留字段
12        } v;
13        uint32_t type_info;
14    } u1;
15    union {
16        uint32_t     next;                 //解决Array的哈希冲突用的
17        uint32_t     cache_slot;           //运行时缓存使用
18        uint32_t     lineno;               //编译时用的
19        uint32_t     num_args;             //参数个数
20        uint32_t     fe_pos;               //foreach用的
21        uint32_t     fe_iter_idx;          //foreach用的
22        uint32_t     access_flags;         //class的访问属性
23        uint32_t     property_guard;      
24        uint32_t     extra;                
25    } u2;//u2里面主要是一些辅助变量,用union存储,在不同的场景下用不同的用处
26};

分析下来每个zval_struct结构占用的内存是16Byte。其实真正和zval相关的是zend_value和u1,这两个占用了12Byte(8Byte+4Byte)。但是考虑到内存对齐,zval_struct实际会占用16Byte,所以又定义了u2,充分利用内存空隙。了解完_zval,我们还需要分析下_zend_value结构,源码如下:

 1typedef union _zend_value {
2    zend_long         lval;             zend_long是int64_t类型,直接存储在_zend_value里面
3    double            dval;             double类型也是直接存储在_zend_value里面
4    zend_refcounted  *counted;            每个变量GC信息,这里存储的是GC信息的指针
5    zend_string      *str;                String类型的指针
6    zend_array       *arr;                Array类型的指针
7    zend_object      *obj;                Obj类型的指针
8    zend_resource    *res;              资源类型指针
9    zend_reference   *ref;              引用类型指针
10    zend_ast_ref     *ast;                编译时的抽象语法树节点指针,内核使用
11    zval             *zv;                指向下一个zval,内核使用
12    void             *ptr;                通用指针,内核使用
13    zend_class_entry *ce;                类指针,内核使用
14    zend_function    *func;                函数指针,内核使用
15    struct {
16        uint32_t w1;
17        uint32_t w2;
18    } ww;                                两个变量,也是通用类型
19    //还可以继续扩展,用来支持不同的变量类型
20} zend_value;

通过上面的分析我们看到zend_value是一个union类型,实际占用的大小是8Byte。我们还看到只有简单的数据类型才会直接存储在zend_value里面,其他的类型只是存储了一个指针。我们继续分析string、object和resource类型:

 1struct _zend_string {
2    zend_refcounted_h gc;
3    zend_ulong        h;                /* hash value */
4    size_t            len;
5    char              val[1];
6};
7struct _zend_object {
8    zend_refcounted_h gc;
9    uint32_t          handle; // TODO: may be removed ???
10    zend_class_entry *ce;
11    const zend_object_handlers *handlers;
12    HashTable        *properties;
13    zval              properties_table[1];
14};
15struct _zend_resource {
16    zend_refcounted_h gc;
17    int               handle; // TODO: may be removed ???
18    int               type;
19    void             *ptr;
20};
21_zend_array类型我们会在稍后详细分析

我们看到基本上所有类型的结构中,第一个元素都是zend_refcounted_h类型的gc信息,为什么这么设置呢?我们看到zend_value结构只是一个8Byte的union结构体,而64位机器中指针的大小也是8Byte,所以zend_value是不可能存储两个指针的。正是因为这样,在每个具体的数据类型的结构中最开始的就是zend_refcounted_h,当一个指针指向zend_string类型时,该指针同时指向了zend_refcounted_h结构的起始位置,所以zend_value里面使用一个指针指向了两个位置,具体使用时可以转化为不同的指针类型。

我们可以再来看下PHP5中有关zval的定义:

 1typedef union _zvalue_value {
2    long lval;                  /* long value */
3    double dval;                /* double value */
4    struct {
5        char *val;
6        int len;
7    } str;
8    HashTable *ht;              /* hash table value */
9    zend_object_value obj;
10} zvalue_value;
11struct _zval_struct {
12    /* Variable information */
13    zvalue_value value;     /* value */
14    zend_uint refcount__gc;
15    zend_uchar type;    /* active type */
16    zend_uchar is_ref__gc;
17};

我们可以看到,PHP7和PHP5有三个重要的不同:

  • 内核使用的结构也都用zend_value管理起来;

  • gc信息从zval下沉到了zend_value,不需要gc的类型可以不设置gc;

  • zval结构增加了u1和u2两个字段,为扩展功能提供了不少便利


图片
变量类型
图片

析完了数据结果后,我们看下PHP定义了哪些类型,参考:Zend/zend_type.h:362L

 1/* regular data types */
2#define IS_UNDEF                    0
3#define IS_NULL                     1
4#define IS_FALSE                    2
5#define IS_TRUE                     3
6#define IS_LONG                     4
7#define IS_DOUBLE                   5
8#define IS_STRING                   6
9#define IS_ARRAY                    7
10#define IS_OBJECT                   8
11#define IS_RESOURCE                 9
12#define IS_REFERENCE                10
13/* constant expressions */
14#define IS_CONSTANT                 11
15#define IS_CONSTANT_AST             12
16/* fake types */
17#define _IS_BOOL                    13
18#define IS_CALLABLE                 14
19#define IS_ITERABLE                 19
20#define IS_VOID                     18
21/* internal types */
22#define IS_INDIRECT                 15
23#define IS_PTR                      17
24#define _IS_ERROR                   20

图片
数据结构
图片

PHP中最重要的数据结构莫过于数组,在PHP7中的一个关键点也是对数组的优化,我们先来看下数组的定义:Zend/zend_types.h:236LZend/zend_types.h:236L

 1typedef struct _zend_array HashTable;
2struct _zend_array {
3    zend_refcounted_h gc;      //8Byte的GC信息
4    union {                    //定义一些flag变量,在不同场景下使用
5        struct {
6            ZEND_ENDIAN_LOHI_4(
7                zend_uchar    flags,
8                zend_uchar    nApplyCount,
9                zend_uchar    nIteratorsCount,
10                zend_uchar    consistency)
11        } v;
12        uint32_t flags;
13    } u;
14    uint32_t          nTableMask;                //掩码,用于计算下标
15    Bucket           *arData;                    //Bucket数组,每个元素都存储在一个Bucket里面,HashTable里面只存储指针
16    uint32_t          nNumUsed;                  //已经使用的Bucket数量,元素删除后并不会释放Bucket,而是标记为IS_UNDEF
17    uint32_t          nNumOfElements;            //数组实际存储的有效元素数量
18    uint32_t          nTableSize;                //数组的总容量
19    uint32_t          nInternalPointer;            
20    zend_long         nNextFreeElement;            //下一个可用的数值索引
21    dtor_func_t       pDestructor;                //如果元素被删除时的调用回调方法
22};
23Bucke的结构:
24typedef struct _Bucket {
25    zval              val;
26    zend_ulong        h;                //hash值,数字下标的话就是数字值
27    zend_string      *key;              //hash之前的key,数字下标则为NULL
28} Bucket;

数组的初始化方法是在:_zend_hash_init方法中实现的,具体参考:

 1ZEND_API void ZEND_FASTCALL _zend_hash_init(HashTable *ht, uint32_t nSize, dtor_func_t pDestructor, zend_bool persistent ZEND_FILE_LINE_DC)
2{
3    GC_REFCOUNT(ht) = 1;
4    GC_TYPE_INFO(ht) = IS_ARRAY | (persistent ? 0 : (GC_COLLECTABLE << GC_FLAGS_SHIFT));
5    ht->u.flags = (persistent ? HASH_FLAG_PERSISTENT : 0) | HASH_FLAG_APPLY_PROTECTION | HASH_FLAG_STATIC_KEYS; //是否持久化和一些其他flag
6    ht->nTableMask = HT_MIN_MASK;
7    HT_SET_DATA_ADDR(ht, &uninitialized_bucket);//主要关注这个宏
8    ht->nNumUsed = 0;
9    ht->nNumOfElements = 0;
10    ht->nInternalPointer = HT_INVALID_IDX;
11    ht->nNextFreeElement = 0;
12    ht->pDestructor = pDestructor;
13    ht->nTableSize = zend_hash_check_size(nSize); 默认是8
14}

我们看到主要是初始化各个变量,其中初始大小是8。 这里我们需要重点关注下HT_SET_DATA_ADDR这个宏,看下定义:

1#define HT_SET_DATA_ADDR(ht, ptr) do { \
2        (ht)->arData = (Bucket*)(((char*)(ptr)) + HT_HASH_SIZE((ht)->nTableMask)); \
3    } while (0)
4

实际上是将arData的地址指向了传入的ptr地址加上HT_HASH_SIZE((ht)->nTableMask)的大小,通过查看HT_HASH_SIZE这个宏,我们可以发现这个宏计算的结果是(sizeof(uint32_t) * nTableSize),也就是说在arData之前还有一块内存是没有放在zend_array里,但又确实存在的。所以完整的数据结构可参考:


图片

这个地方虽然大部分文章里面提到,但很少说明白细节。这块内存非常重要,因为解决哈希冲突主要通过这块辅助内存来完成。

知道了数组的底层结构,我们来看下数据是怎么插入到数组里面的。当有数据插入时会顺序地插入到arData后面,然后是更新我们上面提到的中间表。如果发生哈希冲突,那么会将新插入的元素的next变量指向上一个碰撞元素在arData中的下标,同时更新辅助表的值为插入的元素在arData中的下标。

图片


发生冲突时:

图片

具体插入过程可参考_zend_hash_add_or_update_i方法,(Zend/zend_hash.c:541L)

如果大家了解PHP5中数组的实现,我们发现PHP7里面对数组结构做了些优化,其中主要是去掉了几个指针。我们来看下PHP5中数组的结构:

 1typedef struct _hashtable {
2    uint nTableSize;
3    uint nTableMask;
4    uint nNumOfElements;
5    ulong nNextFreeElement;
6    Bucket *pInternalPointer;   /* Used for element traversal */
7    Bucket *pListHead;
8    Bucket *pListTail;
9    Bucket **arBuckets;
10    dtor_func_t pDestructor;
11    zend_bool persistent;
12    unsigned char nApplyCount;
13    zend_bool bApplyProtection;
14#if ZEND_DEBUG
15    int inconsistent;
16#endif
17} HashTable;
18每个Bucket定义:
19typedef struct bucket {
20    ulong h;                        /* Used for numeric indexing */
21    uint nKeyLength;
22    void *pData;
23    void *pDataPtr;
24    //主要是去掉了这四个指针
25    struct bucket *pListNext;
26    struct bucket *pListLast;
27    struct bucket *pNext;
28    struct bucket *pLast;
29    const char *arKey;
30} Bucket;

PHP5在Bucket中设置这么多指针无非是想保证两点:

  • 发生哈希冲突时使用链表法解决冲突,通过pNext和pLast来保证

  • 数组是有序的,所以需要pListNext和pListLast来保证全局顺序

所以这导致PHP5里面里面的Bucket定义非常臃肿,直接导致PHP5的内存效率非常低。在PHP7里面,如果想顺序访问所有的元素时,只需要按照arData指针的逐次访问就可以。发生冲突时使用zval的扩展字段u2中的next字段来将冲突值串联成单向链表,实现得如此得优雅!!!

关于数组的其他操作,比如说查找、扩容等逻辑比较简单,所以我们就不再这里展开了

2. 垃圾回收

PHP作为高级语言,在内核中实现了GC机制。我们可以设想下如何管理内存,最简单来讲就是每次发生赋值操作时都把变量实际的内存复制一份,当方法执行结束后释放这块内存。这种实现是不存在垃圾的,但却是内存浪费太严重。比如一个变量$a是一个长度为10M的字符串,$b=$a命令时进行深拷贝,也就是说完全复制一份$a的内容到$b。然而程序对$b的使用仅限于只读,所以对$a的深拷贝是非常浪费的。

所以无论操作系统也好,高级语言如Python、PHP也罢,都不会直接拷贝内存,而是对$a变量的引用计数进行+1操作,当对$b进行写操作时才进行深拷贝。这就是我们常用的引用计数和写时复制。

图片
引用计数
图片

关于PHP引用计数的数据结构,我们可以参考:

 1typedef struct _zend_refcounted_h {
2    uint32_t         refcount;          //引用次数
3    union {
4        struct {
5            ZEND_ENDIAN_LOHI_3(
6                zend_uchar    type,
7                zend_uchar    flags,    //一些Flag,不同的类型有不同的设置
8                uint16_t      gc_info,  //垃圾回收用的
9        } v;
10        uint32_t type_info;
11    } u;
12} zend_refcounted_h;

PHP内部并不是所有的变量都是可以进行引用计数的,比如说整形、浮点型、布尔型、NULL等简单变量不需要进行引用计数。对于复杂的数据结构,也并不是所有场合都是需要引用计数的,比如说内置的String类型和不可变数组类型。具体查看类型定义:

1//String的类型flag定义
2#define IS_STR_PERSISTENT           (1<<0) /* allocated using malloc   */
3#define IS_STR_INTERNED             (1<<1) /* interned string          */
4#define IS_STR_PERMANENT            (1<<2) /* relives request boundary */
5#define IS_STR_CONSTANT             (1<<3) /* constant index */
6#define IS_STR_CONSTANT_UNQUALIFIED (1<<4) /* the same as IS_CONSTANT_UNQUALIFIED */
7//Array的类型flag定义
8#define IS_ARRAY_IMMUTABLE          (1<<1) 不可变数组

图片
写时复制
图片

写时复制在计算机领域有着很广泛的使用,最常见的就是Linux系统的进程创建。当父进程执行fork()方法时并不会完整得复制自己的内存给子进程,而是父子进程共享内存空间。当进程对内存进行修改时才会发生复制,从而使各个进程拥有自己独立的进程空间。

由此可见,复制发生的引用的一方要修改变量时,参考如下:

1$a = array(1,2,3);
2$b = &$a;
3$c = $b;
4//发生复制
5$c[] = 12;

实际上在PHP7里面只有Array和String两种类型是支持写实复制的,其他类型都不支持。

图片
垃圾回收
图片

PHP自身是有垃圾回收机制的,如果refcount=0时,那么给内存会被自动回收。但是有些场景是解决不了的,比如:Array的一个元素是指向Array自身的引用,或者是对象的一个属性是指向该对象的引用。

1$a = array(1);
2这样就形成了一个循环引用
3$a[] = &$a;
4//产生了垃圾
5unset($a);

事实上也只有Array和Object这两种类型才支持回收,因为其他类型不存在自身引用的场景。

PHP垃圾回收的原理大概如下:因为垃圾的产生是由于自身的元素引用自身导致的,所以就对所有的元素的zend_value进行减一操作,操作完成之后如果发现垃圾自身的引用次数变为0,说明引用来源于自身的元素,可以被认定是垃圾。

GC里面是使用颜色标记方法来进行处理的,zend_gc.h中定义了四种颜色:

1#define GC_COLOR  0xc000
2#define GC_BLACK  0x0000    //非垃圾标记为黑色
3#define GC_WHITE  0x8000    //判定为垃圾后标记为白色
4#define GC_GREY   0x4000    //减一操作后标记为灰色
5#define GC_PURPLE 0xc000    //收集之后会标记为紫色,避免重复收集

保存垃圾的结构如下:

 1typedef struct _zend_gc_globals {
2    zend_bool         gc_enabled;     //是否启用gc
3    zend_bool         gc_active;      //是否在垃圾回收过程中
4    zend_bool         gc_full;        //缓存区是否已满
5    gc_root_buffer   *buf;              //启动时分配的用于保存垃圾的缓存区
6    gc_root_buffer    roots;            //指向缓存中最新加入的一个可能垃圾
7    gc_root_buffer   *unused;           //指向未用的缓存列表,单向链表,主要是垃圾加入后可能又删除,
8    gc_root_buffer   *first_unused;     //连续未用的缓存列表头节点,一直是执行++操作
9    gc_root_buffer   *last_unused;      //连续未用的缓存列表
10    gc_root_buffer    to_free;          //待释放的节点列表
11    gc_root_buffer   *next_to_free;
12    uint32_t gc_runs;                    //GC运行次数
13    uint32_t collected;                  //实际回收的垃圾数量
14    gc_additional_buffer *additional_buffer;
15} zend_gc_globals;
16//gc_root_buffer信息
17typedef struct _gc_root_buffer {
18    //指向zend_refcounted结构,其实也是zend_value结构,这个非常重要
19    zend_refcounted          *ref;        
20    struct _gc_root_buffer   *next;     /* double-linked list               */
21    struct _gc_root_buffer   *prev;
22    uint32_t                 refcount;
23} gc_root_buffer;

具体垃圾回收过程可以总结为如下三步:

1. 遍历上述结构的所有buffer,将当前value标记为灰色(zend_refcounted_h.gc_info),然后对当前元素的成员进行深度遍历,对每个成员的value进行refcount进行减1操作;
2. 重复遍历所有的buffer,如果发现当前value的refcount已经是0了,说明是垃圾,直接加入到待回收列表。如果不是0,那么并不是垃圾,所以需要深度遍历对所有的value进行refcount加1的操作,并将当前的根节点标记为黑色;
3. 再次便利所有buffer,将非白色节点从buffer中删除,最终buffer存储的全都是垃圾,全部释放即可。

从上面的过程我们可以知道PHP的垃圾回收机制更像是个玩具。与Java相比,PHP没有考虑到STW(Stop The World)问题。这可能是因为PHP主要的使用场景是Web请求这种短时间场景,其次一个请求的内存是可控的,所以绝大部分场景是用不到垃圾回收的。

3. 内存管理

图片
结构
图片

PHP内置了内存管理机制,这对于提升PHP的执行效率是非常重要的。在C语言中,内存的分配大都是通过malloc来实现的,但是对于内存分配这么高频的操作,频繁调用malloc对性能的伤害是不可接受的。PHP中借鉴了tcmalloc机制,实现了简单的内存池。首先我们来看保存内存池的结构是什么样的:

 1struct _zend_mm_heap {
2#if ZEND_MM_CUSTOM
3    int                use_custom_heap;
4#endif
5#if ZEND_MM_STORAGE
6    zend_mm_storage   *storage;
7#endif
8#if ZEND_MM_STAT
9    size_t             size;                    //当前内存使用量
10    size_t             peak;                    //内存峰值使用量
11#endif
12    zend_mm_free_slot *free_slot[ZEND_MM_BINS]; //小内存列表
13#if ZEND_MM_STAT || ZEND_MM_LIMIT
14    size_t             real_size;               //当前分配的页数量
15#endif
16#if ZEND_MM_STAT
17    size_t             real_peak;               //峰值分配的页数量
18#endif
19#if ZEND_MM_LIMIT
20    size_t             limit;                   //内存上限
21    int                overflow;                //内存溢出标签
22#endif
23    zend_mm_huge_list *huge_list;               //大块内存链表
24    zend_mm_chunk     *main_chunk;              //zend_mm_heap所在的chunk地址
25    zend_mm_chunk     *cached_chunks;           //缓存的chunk列表
26    int                chunks_count;            //已分配的chunk列表
27    int                peak_chunks_count;       //当前请求分配的chunk数量峰值
28    int                cached_chunks_count;     //缓存的chunk的数量
29    double             avg_chunks_count;        //多个request申请的chunk数量的均值
30    int                last_chunks_delete_boundary;
31    int                last_chunks_delete_count;    
32#if ZEND_MM_CUSTOM
33    union {
34        struct {
35            void      *(*_malloc)(size_t);
36            void       (*_free)(void*);
37            void      *(*_realloc)(void*, size_t);
38        } std;
39        struct {
40            void      *(*_malloc)(size_t ZEND_FILE_LINE_DC ZEND_FILE_LINE_ORIG_DC);
41            void       (*_free)(void*  ZEND_FILE_LINE_DC ZEND_FILE_LINE_ORIG_DC);
42            void      *(*_realloc)(void*, size_t  ZEND_FImLE_LINE_DC ZEND_FILE_LINE_ORIG_DC);
43        } debug;
44    } custom_heap;
45#endif
46};

图片
分配
图片

底层实现
PHP是基于C语言实现的,C语言里面处理内存分配最主要的方法就是malloc。我们需要注意的是malloc并不是系统调用,而是glibc提供的方法。其实现细节我们可以参考malloc。简单来讲,当分配小块的内存时,malloc方法调用系统调用sbrk(参考sbrk)来调整进程堆的边界。当申请的内存大于一定大小(默认128K)时,malloc调用mmap来向操作系统申请一块连续的内存。我们知道,进程运行期间频繁的使用系统调用去申请内存会有很大的性能损失,而且频繁申请小内存会造成操作系统的内存空隙。所以在主流的实现里面都会先申请大块内存,进程将申请到的内存维护在内存池(类似上面提到的结构)中,当需要一块内存时,直接从已经申请的内存里面申请即可。进程退出时再释放所有的内存,并销毁内存池。

落地到具体实现,PHP参考了tcmalloc的实现方式自己实现了简单的内存池。其他开源实现类似于ptmalloc和jemalloc大家感兴趣可以自行翻阅,这里暂不展开。

布局

一节中我们展示了zend_mm_heap这个结构,这个结构是个全局变量,创建时机是在module_startup阶段,这一节里我们会继续基于这个结构来描述PHP的内存池的布局。

在开始分析布局之前,我们先看下PHP中内存的不同粒度,具体来讲可以分为三种:

  • HUGE,也就是chunk:当申请内存大于2MB,直接调用mmap系统调用,分配若干个chunk;

  • Large,也就是page:当申请的内存大于3092B,小于2044KB时,分配若干个page;

  • Small,也就是slot:当申请的内存小于3092B时,分配若干个slot。

具体参考:

1#define ZEND_MM_CHUNK_SIZE (2 * 1024 * 1024)               /* 2 MB  */
2#define ZEND_MM_PAGE_SIZE  (4 * 1024)                      /* 4 KB  */
3#define ZEND_MM_PAGES      (ZEND_MM_CHUNK_SIZE / ZEND_MM_PAGE_SIZE)  /* 512 */
4#define ZEND_MM_FIRST_PAGE (1)
5#define ZEND_MM_MIN_SMALL_SIZE      8
6#define ZEND_MM_MAX_SMALL_SIZE      3072
7//最多511个page,因为一个chunk最多能承载511页
8#define ZEND_MM_MAX_LARGE_SIZE      (ZEND_MM_CHUNK_SIZE - (ZEND_MM_PAGE_SIZE * ZEND_MM_FIRST_PAGE))
9//系统预定义的slot大小参考Zend/zend_alloc_sizes.h文件

分配逻辑参考:

 1static zend_always_inline void *zend_mm_alloc_heap(zend_mm_heap *heap, size_t size ZEND_FILE_LINE_DC ZEND_FILE_LINE_ORIG_DC)
2
{
3    void *ptr;
4    if (size <= ZEND_MM_MAX_SMALL_SIZE) {
5        ptr = zend_mm_alloc_small(heap, size, ZEND_MM_SMALL_SIZE_TO_BIN(size) ZEND_FILE_LINE_RELAY_CC ZEND_FILE_LINE_ORIG_RELAY_CC);
6        return ptr;
7    } else if (size <= ZEND_MM_MAX_LARGE_SIZE) {
8        ptr = zend_mm_alloc_large(heap, size ZEND_FILE_LINE_RELAY_CC ZEND_FILE_LINE_ORIG_RELAY_CC);
9        return ptr;
10    } else {
11        return zend_mm_alloc_huge(heap, size ZEND_FILE_LINE_RELAY_CC ZEND_FILE_LINE_ORIG_RELAY_CC);
12    }
13}

回过来看zend_mm_heap主要存储了几个结构:huge_list、main_chunk、cached_chunks和free_slot。

huge_list比较简单,是个zend_mm_huge_list类型的指针,zend_mm_huge_list类型结构如下,实际就是指向一个开始地址和大小和下元素的指针,PHP中申请的Huge内存都挂在了这个单向链表里面。

1struct _zend_mm_huge_list {
2    void              *ptr;
3    size_t             size;
4    zend_mm_huge_list *next;
5};

main_chunk指向的是整个自维护内存空间的第一个chunk,zend_mm_chunk结构如下:

 1struct _zend_mm_chunk {
2    zend_mm_heap      *heap;       //指向zend_mm_heap结构
3    zend_mm_chunk     *next;
4    zend_mm_chunk     *prev;
5    uint32_t           free_pages; //当前chunk的空闲页数
6    uint32_t           free_tail;  //chunk尾部连续空闲页的数量,应该是跨chunk分配页时使用
7    uint32_t           num;
8    char               reserve[64 - (sizeof(void*) * 3 + sizeof(uint32_t) * 3)];
9    zend_mm_heap       heap_slot;               只会在main_chunk里面用到
10    zend_mm_page_map   free_map;                标记当前chunk每个page是否好使用字段,512bits
11    zend_mm_page_info  map[ZEND_MM_PAGES];      标记当前chunk每个page的使用情况,512*4 bits
12};

我们可以认为实际上每个chunk分为了512个page,但是仅有511的page用来对外使用。第一个chunk用来保存chunk自身信息。其实zend_mm_heap也是存放在了第一个chunk的第一个page上面的(page大小是4K,显然够存放chunk+heap的大小)。因此第一个chunk也被称作是main chunk。

free_slot是个zend_mm_free_slot类型的指针数组,数组的大小是30。也就是说PHP定义了30中不同规格的小内存,具体的定义参考zend_alloc_sizes.h,我们来解读下每列的含义:

 1/* num, size, count, pages */
2#define ZEND_MM_BINS_INFO(_, x, y) \
3    _( 0,    8,  512, 1, x, y) \
4    _( 1,   16,  256, 1, x, y) \
5    _( 2,   24,  170, 1, x, y) \
6    ....

7    _(26, 1792,   16, 7, x, y) \
8    _(27, 2048,    8, 4, x, y) \
9    _(28, 2560,    8, 5, x, y) \
10    _(29, 3072,    4, 3, x, y)
11第一列是下标,第二列是内存多少字节,第三列是每次分配该类型的内存时分配多少个,第四个是一次分配要占用几个页。大家可以简单做下乘法运算验证下。

同一规格的slot保存在统一链表下面,链表的结构如下。我们看到这仅仅就是一个指针,没有任何数据元素的。

1struct _zend_mm_free_slot {
2    zend_mm_free_slot *next_free_slot;
3};

那么我们就有疑问了,没有元素怎么保存每个slot的起始地址呢?原来zend_mm_free_slot类型的指针指向的就是每个slot的开始地址,然后next_free_slot这个指针保存在当前slot开始的8个Byte里面。这也就是为什么slot的大小最小是8Byte,因为是至少能保存一个指针的,当然也可能是以为zend_value结构的大小是8Byte。第二个疑问是,slot里面保存了指针,那么怎么保存数据呢?答案就是指针和数据不是同时存在的,分配之前保存指针,分配后保存数据,二者互不冲突。使用时只要做下转化就好了。

所以分析完之前的几个数据结构,PHP内存池的结构我们大致也就画出来了,如下图:

图片

分配逻辑

实通过上面两部分我们对PHP内存分配的数据结构已经很清楚了,在这一节里面我们分析下具体的分配方法。

首先是mm_heap结构的初始化时机,在module_startup阶段会调用start_memory_manager方法,这个阶段主要是生成一个空的mm_heap结构,具体代码参考:Zend/zend_alloc.c 2633L。这里我们需要注意的是PHP7里面引入了大页模式(参考(HUGEPAGE)),具体来讲就是可以将Linux操作系统默认的页大小由4KB改为2MB,这么做的主要目的是减少页表的数量,提高CPU页表缓存的命中率,从而提高性能。目前一些主流的应用都支持HUGEPAGE模式,比如Hadoop生态。

通过查看代码我们知道PHP的内存管理模块提供的申请内存的方法为emalloc,这个方法具体实现的逻辑就是按照不同的尺寸调用不用的方法来申请内存,然后返回申请内存的地址。我们来看三种情况:

  • HUGE内存申请,调用zend_mm_alloc_huge方法,首先确定需要多少个chunk,然后申请内存,最后将内存加到huge_list列表的尾部。

  • Large内存申请,调用zend_mm_alloc_large方法,同样先确定实际需要的page数量,然后调用zend_mm_alloc_pages方法申请内存。这个方法具体的逻辑比较长,简单来讲就是顺序遍历所有的chunk,需要最合适的连续的N个page,如果找不到,则再创建一个chunk来满足分配需求。这是不是让我们想起了操作系统课程?无论谁来实现内存分配方法,基本上都会遵循寻找最合适大小,尽量减少内存碎片这个原则来。至于遍历过程,主要是使用了chunk的map和free_map两个字段,因为都是位操作,所以也比较高效。

  • Small内存申请,调用zend_mm_alloc_small方法,具体的逻辑就是从free_slot里面取出当前规格slot列表的第一个slot,并将该地址强行转化为void*类型返回。如果当前规格的slot为空,那么申请对应页数的page,然后将申请到的页按照规格大小切分,保存在free_slot对应的数组里面。

有一点我们需要注意的是每次请求结束时会调用zend_mm_shutdown方法,这个方法并不是真正要销毁mm_heap结构,而是将当前请求周期内申请的所有slot、page、chunk释放,并重新初始化mm_heap结构。所以请求期间通过emalloc申请的内存在请求结束后会全部销毁。

那么这样就会有一个问题,比如说我们需要有些变量是需要跨请求的(比如说持久类型的数组),这样的变量怎么存放呢?PHP中还提供了一个方法,pemalloc,具体的逻辑我们看下:

1#define pemalloc(size, persistent) ((persistent)?__zend_malloc(size):emalloc(size))
2ZEND_API void * __zend_malloc(size_t len)
3{
4    void *tmp = malloc(len);
5    if (EXPECTED(tmp || !len)) {
6        return tmp;
7    }    
8    zend_out_of_memory();
9}

我们看到如果设置了persistent为true,那么会调用malloc方法来申请内存,这样这块内存就是整个进程期间有效的。

回收逻辑

分析完了分配逻辑,回收逻辑就比较简单了,具体来讲也是分为三种场景:

  • HUGE内存释放:调用zend_mm_free_huge方法,直接释放内存;

  • Large内存释放:调用zend_mm_free_large方法,将所有要释放的page标记为未用,如果当前chunk所有的page都被标记为未用,那么将该chunk加入到cached_chunks里面。下次再申请page时,如果需要新申请chunk,那么系统会优先考虑cached_chunk,如果没有的话再去申请新的chunk;

  • Small内存释放:调用zend_mm_free_small方法,将新释放的内存加到对应规格的slot数组的头部,并将free_slot数组中对应的规格指向为刚释放的地址。

4. 总结

这一章中我们主要覆盖了三个内容:变量存储、垃圾回收和内存管理。从我工作经验来讲变量存储和内存管理是比较重要的。至于垃圾回收,设计的既不优雅,实际工作中也价值不大,所以可以作为了解。PHP的内存模块还有个内容是线程安全,这个模块在实际工作中也不怎么用,并且实现比较简单,所以大家有兴趣可以自行了解。下一章我们将分析PHP代码的执行流程。

作者:杨   通


监审:程天亮

编辑:钟   艳

网址:tech.lianjia.com


请猛戳右边二维码

关注我们的公众号





产品技术先行


图片
继续滑动看下一个
贝壳产品技术
向上滑动看下一个