Micropython GC(垃圾回收器 内存分配)分析

原文:https://neucrack.com/p/46

GC: (Garbage Collector, 垃圾回收器)

在 CPython 中

垃圾回收采用了引用计数+ 标记-清除 + 分代回收 的组合回收方法。
即给每个对象设置引用计数,当引用计数为0就立即回收内存;
但是由于对象之间存在互相引用的问题,单单使用引用计数是不够的,于是引入了标记-清除算法来回收来遍历引用到了的对象,将没有引用到的对象销毁
标记-清除方法需要消耗很多时间,为了提升效率,又引入了分代回收,将多次检测都有效的对象升级为下一代,代数越高则检查的次数就越少(共3代,每代实际上就是一个链表),以此来增加效率

Micropython GC

官方 wiki 介绍

与 CPython不同,Micropython 只使用了 标记-清除算法,可以说是教科书般的标记-清除算法实现了(官方的说法2333)

这里 GC 不单单包括了垃圾回收,实际上内存分配方法也包含在了里面,大致上包含了以下几个点:

  • 系统预分配一块内存给GC使用,之后所有的操作都在这块内存上进行
  • 然后分配和释放时根据分配算法进行分配和释放(后面再详细说明)
  • 在以下几个情况执行回收(使用标记-清除算法(mark-sweep):
    • 无法分配内存
    • 设置了回收阈值(threshold),当距离上几次回收后分配的空间(块)数量打到了设置的阈值,则自动进行回收。当然为了减少回收次数(某种意义上的)可以禁用这个功能
    • 手动调用函数进行回收

Micropython 中的 GC 详细分析

数据储存结构

为了简化程序,uPy将初始化时获得的一段连续内存区域分成3部分:

  • 储存数据块分配信息,标记内存分配情况,称为 ATB(alloc table)
  • 储存销毁对象时是否需要调用析构函数(finaliser)(__del__),称为FTB(finaliser table)
  • 实际储存数据的区域,称为内存池(pool),由多个块(block)组成

uPy 分配内存的最小单位是块(block)(比如设置为16或者32字节),所以ATB FTB中记录也是以块为单位记录的。

其中 ATB 标记每个块的状态,包括 freehead(多个连续分配的块的开始(头)), tail(多个连续分配的块的除了头部块以外的块), mark(标记了的头部,在回收时(collect)中才会标记) 几种状态,每两位表示一个block状态,即每个字节可以表示4个block的状态

FTB 则标记是否使用 finaliser,每位表示一个block是否使用finaliser,即每个字节表示8个block是否使用finaliser

名词

  • block: 块,分配的最小单位,比如设置为16或者32字节。
  • finaliser:这里可理解成析构,调用 类的__del__方法,(mpy目前(2019.5)还没有实现调用用户类的__del__方法)
  • ATB: alloc table,标记每个block的状态,包括 freehead(多个连续分配的块的开始(头)), tail(多个连续分配的块的除了头部块以外的块), mark(标记了的头部) 几种状态,每两位表示一个block状态,即每个字节可以表示4个block的状态
  • FTB: finaliser table标记是否使用 finaliser,每位表示一个block是否使用finaliser,即每个字节表示8个block是否使用finaliser
  • pool: 实际分配给用户使用的空间(alloc 返回的地址),也就是数据实际储存的位置,最小单位是块
  • (block) chain: (块)链,分配内存的最小单位是块,比如一个块32字节,如果用户程序需要分配36个字节,则直接分配2个块即64字节,这两个块称为一个块链(chain),我们标记第一个块为头,后面的所有块为尾(注意不是最后一个,是除了头的所有块)
  • reachable object: 可达的对象,即可以通过变量引用引用得到的对象
  • root object: 根节点对象,主要是指 uPy的一些特殊变量,比如局部字典、全局字典等,Micropython 中代码如下:
    ////////////////////////////////////////////////////////////
    // START ROOT POINTER SECTION
    // Everything that needs GC scanning must start here, and
    // is followed by state in the mp_state_vm_t structure.
    //

    mp_obj_dict_t *dict_locals;
    mp_obj_dict_t *dict_globals;

    nlr_buf_t *nlr_top;
    . 
    .
    .
    //
    // END ROOT POINTER SECTION
    ////////////////////////////////////////////////////////////

初始化

指定一块内存区域用来内存分配使用:

void gc_init(void *start, void *end)
#define BYTES_PER_WORD (sizeof(mp_uint_t))
#define MICROPY_BYTES_PER_GC_BLOCK (4 * BYTES_PER_WORD)
#define BYTES_PER_BLOCK (MICROPY_BYTES_PER_GC_BLOCK)
end = (void*)((uintptr_t)end & (~(BYTES_PER_BLOCK - 1)));  // 内存对齐

GC分配空间

uPy分配内存时以一个块为最小单位进行分配,并且对这些块进行了标记,初始化时所有块的标记都是free

每次分配,都至少分配1个块,称这次分配的空间为一个链(chain,即分配的连续内存大于一个块的大小时,比如一个块32字节,需要分配124字节实际分配了128字节即128/32=4块,称这4块为一个链(chain)),开头第一个块标记为head,后面的块都标记为tail。 在代码中,使用m_new(type, size)函数即可分配

另外,Micropython 有一个 finaliser的功能,可以简单地理解为,如果在释放对象时会尝试去调用它的__del__方法(如果存在的话),在创建对象的时候会在对应的 FTB 中设置标记,表示这个block在释放时需要调用__del__方法,只需要设置这一个block,比如一个对象占用了从第5010050block, 申请时会在ATB的第50block设置head标记,剩下的全部设置tail,会在FTB的第50个位设置标记(置1),其它位置不置1。

注意,使用finaliser的时候不再使用m_new,而是使用m_new_obj_with_finaliser()来进行分配,这个函数里面会自动标记。 另外如果使用这个函数创建的对象必须是一个有效的Micropython对象定义,比如

typedef struct{
    int a;
    int b;
} data_t;

data_t* p = m_new(data_t, 1);

这只是一个普通的结构体;
在每个定义的第一个成员必须是mp_obj_base_t base;,在实例化对象的时候必须指定base.typetypemp_obj_type_t类型), 比如

typedef struct{
    mp_obj_base_t base;
    int a;
    int b;
}
data_t* p = m_new(data_t, 1);
p->base.type = NULL;

如果使用了m_new_obj_with_finaliser来创建对象,但是结构体里面第一个成员又不是base,则最后在调用finaliser时就会出现致命的问题,可能会导致程序崩溃

比如 I2C 模块中:

typedef struct _machine_hard_i2c_obj_t {
    mp_obj_base_t         base;
    i2c_device_number_t   i2c;
    machine_i2c_mode_t    mode;
    uint32_t              freq;         // Hz
    uint32_t              timeout;      // reserved
    uint16_t              addr;
    uint8_t               addr_size;
    mp_obj_t              on_receive;
    mp_obj_t              on_transmit;
    mp_obj_t              on_event;
    int                   pin_scl;
    int                   pin_sda; 
} machine_hard_i2c_obj_t;

STATIC const mp_rom_map_elem_t machine_i2c_locals_dict_table[] = {
    { MP_ROM_QSTR(MP_QSTR_init), MP_ROM_PTR(&machine_i2c_init_obj) },
    { MP_ROM_QSTR(MP_QSTR___del__), MP_ROM_PTR(&machine_i2c_deinit_obj) },
    。
    。
    。    
};

MP_DEFINE_CONST_DICT(mp_machine_soft_i2c_locals_dict, machine_i2c_locals_dict_table);

const mp_obj_type_t machine_hard_i2c_type = {
    { &mp_type_type },
    .name = MP_QSTR_I2C,
    .print = machine_hard_i2c_print,
    .make_new = machine_hard_i2c_make_new,
    .protocol = &machine_hard_i2c_p,
    .locals_dict = (mp_obj_dict_t*)&mp_machine_soft_i2c_locals_dict,
};

machine_hard_i2c_make_new函数中,即用户通过i2c = machine.I2C()来创建对象的时候调用的函数中,会创建一个machine_hard_i2c_obj_t的对象,如果不使用finaliser

machine_hard_i2c_obj_t *self = m_new_obj(machine_hard_i2c_obj_t);
self->base.type = &machine_hard_i2c_type;
.
.
.
return MP_OBJ_FROM_PTR(self);

这样在对象销毁时不会调用__del__方法,如果需要自动调用:

machine_hard_i2c_obj_t *self = m_new_obj_with_finaliser(machine_hard_i2c_obj_t);
self->base.type = &machine_hard_i2c_type;

内存分配策略

一块连续内存,多个块,从左到右依次查找,直到找到符合大小的一块连续区域。
会有一个指针指向最左边的空闲的块,每次从这个指针查找的地方往右开始找,一旦最左边的空闲块位置发生了变动(更左边的已经分配的块得到了释放或者最左边的空闲块被分配),这个指针的值也要及时更新。

当然,这种分配算法简单有效,但是也没法避免内存碎片的问题

内存释放

调用释放函数时直接设置对应块的标识为free即可,
记得更新指向最左边空闲块的指针

回收过程(标记-清除(mark-sweep)算法)

执行回收的时机在文章开头已经介绍,这里阐述回收过程

uPy中实现自动垃圾回收使用了 mark-sweep 算法, 在gc_collect()函数中实现:

  • 遍历root objects找到所有能够找到的有效块(通过查找mp_state_ctx变量中保存的一系列变量,比如局部字典、全局字典、)

  • 对这些找到的块进行标记,所有标记为 head标识的标记为mark, 那些已经无法找到的块则仍然为head标识

  • 遍历所有块,对所有的标记为mark的块重新标记为head,对标记为head的块则进行销毁(标记为free),这些标记为head的块是在上一步没有找到的,也就是说内存已经是垃圾内存了,所以直接销毁

  • 销毁对象时检查是否需要调用finaliser(检查FTB标记),即尝试调用对象的__del__方法(如果存在的话),并且清除FTB标记

注意到这里使用了遍历这个词,虽然实现起来简单有效,但是注定效率不算高,所以写对时间要求高的micropython程序需要注意回收的时机,能手动定期回收也不错。(实际测试在 MaixPy 运行在400MHz的k210上collect()一次需要花费600us+

使用 GC 注意的地方

C层面使用 GC 注意

底层使用的变量被自动回收了(变量没有链接到root objectsgc无法知道变量仍然有用),再使用时出错

使用gc创建变量不能像平时使用malloc函数一样使用, malloc只要创建了不free就会一直存在,但是gc通过alloc创建的变量如果没有加入到gc遍历时能遍历到的变量中,gc就会认为是垃圾,会回收,如果我们的c程序逻辑既没有把它加入到gc,可以遍历到的变量中,还把它当成和普通一样的malloc的变量使用,就会出现bug

因为 内存不够时会自动回收,那些没有被引用的资源自然就会被释放掉了,如果是修改 micropython的源码时需要注意,比如在c层面创建了一个strvstr_init),然后添加字符(vstr_add_strn),可能底层某个地方在使用这个变量,并且在某个时候会手动调用vstr_clear函数进行清除,但是由于这个变量没有被python引用,在系统回收垃圾时有可能会将其回收掉,然后在后来的某个时间c在某个地方调用了vstr_clear去手动删除字符串,这时会出错,因为已经被free过了

解决方法就是将这个变量被系统的变量引用一下
(比如创建一个变量链接到global变量中,或者当成python层面的某个返回值,让python层面有确定的使用范围(比如img = image.Image(), 在这个方法中,c层面使用了gc分配内存,python层面img变量引用了,只要img变量有效它就不会被自动回收),
或者干脆不要使用gc来创建,可以使用静态数组或者其它不属于gc管理的内存。

可以看MaixPya21188b23fa1853b211c0ad65b2bfc4bef8343b2(fix str bug(for ide)) 提交

finaliser 使用时需要注意结构体是否是一个合法的对象形式

创建对象如果需要使用finaliser(使用m_new_obj_with_finaliser函数创建对象),结构体最开始一定要有mp_obj_base_t定义的变量(base),base变量下的type来指定变量的类型,否则会出现严重问题!!!

注意触发回收的时机

前面说了三种时机会触发垃圾回收(collect),所以写python程序时需要注意自动回收会不会影响到程序的性能和稳定性,以及需不需要自己手动调用collect来主动执行回收程序

参考文章

  • Micropython官方 wiki 介绍
  • Python垃圾回收机制详解
  • python 中的垃圾回收之标记清除

你可能感兴趣的:(Micropython GC(垃圾回收器 内存分配)分析)