原文: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
标记每个块的状态,包括 free
, head
(多个连续分配的块的开始(头)), 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的状态,包括
free
,head
(多个连续分配的块的开始(头)),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
,比如一个对象占用了从第50
到100
共50
个block
, 申请时会在ATB
的第50
个block
设置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.type
(type
是mp_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 objects
,gc
无法知道变量仍然有用),再使用时出错
使用gc
创建变量不能像平时使用malloc
函数一样使用, malloc
只要创建了不free
就会一直存在,但是gc
通过alloc
创建的变量如果没有加入到gc
遍历时能遍历到的变量中,gc
就会认为是垃圾,会回收,如果我们的c程序逻辑既没有把它加入到gc
,可以遍历到的变量中,还把它当成和普通一样的malloc
的变量使用,就会出现bug
因为 内存不够时会自动回收,那些没有被引用的资源自然就会被释放掉了,如果是修改 micropython的源码时需要注意,比如在c层面创建了一个str
(vstr_init
),然后添加字符(vstr_add_strn
),可能底层某个地方在使用这个变量,并且在某个时候会手动调用vstr_clear
函数进行清除,但是由于这个变量没有被python引用,在系统回收垃圾时有可能会将其回收掉,然后在后来的某个时间c在某个地方调用了vstr_clear
去手动删除字符串,这时会出错,因为已经被free
过了
解决方法就是将这个变量被系统的变量引用一下
(比如创建一个变量链接到global
变量中,或者当成python层面的某个返回值,让python层面有确定的使用范围(比如img = image.Image()
, 在这个方法中,c层面使用了gc
分配内存,python
层面img
变量引用了,只要img
变量有效它就不会被自动回收),
或者干脆不要使用gc来创建,可以使用静态数组或者其它不属于gc
管理的内存。
可以看MaixPy
的a21188b23fa1853b211c0ad65b2bfc4bef8343b2
(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 中的垃圾回收之标记清除