书接上回, tracemalloc内存检查的整体机制见该文章的分析 juejin.cn/post/735451...
问题
今天在代码调试过程中, 发现了另一个core, 内存的字节都是0xdd
内存置0xdd的原因
往回翻了一下代码, python在--with-pydebug启动了debug模式下编译的python, 在内存释放的时候, 不仅会检查填充的特殊字符来做踩内存检查, 也会把本次释放的内存全部置为 0xdd
c
# define PYMEM_DEBUG_EXTRA_BYTES 3 * SST
static void
_PyMem_DebugRawFree(void *ctx, void *p)
{
/* PyMem_Free(NULL) has no effect */
if (p == NULL) {
return;
}
debug_alloc_api_t *api = (debug_alloc_api_t *)ctx;
uint8_t *q = (uint8_t *)p - 2*SST; /* address returned from malloc */
size_t nbytes;
// 检查内存是否被人修改过
_PyMem_DebugCheckAddress(api->api_id, p);
// 把内存和debug头尾全部置为0xdd
nbytes = read_size_t(q);
nbytes += PYMEM_DEBUG_EXTRA_BYTES;
memset(q, PYMEM_DEADBYTE, nbytes);
api->alloc.free(api->alloc.ctx, q);
}
因此可以猜测, 是我的代码访问了被gc垃圾回收掉的python对象内存, 导致访问异常. 走读了一遍代码, 发现在传入生成器时, 直接把生成器对象给传进了函数里, 由于该函数是异步函数, 投递完请求后, 改迭代器对象就没有地方引用它了, 因此导致异步任务在访问该对象内存进行迭代的时候, 由于该对象内存已经被回收, 访问异常core.
其他置为0xdd的情况
在调用realloc接口的时候,也会把之前的内存置为0xdd
scss
static void *
_PyMem_DebugRawRealloc(void *ctx, void *p, size_t nbytes)
{
......
size_t original_nbytes;
#define ERASED_SIZE 64
uint8_t save[2*ERASED_SIZE]; /* A copy of erased bytes. */
_PyMem_DebugCheckAddress(api->api_id, p);
data = (uint8_t *)p;
head = data - 2*SST;
original_nbytes = read_size_t(head);
total = nbytes + PYMEM_DEBUG_EXTRA_BYTES;
tail = data + original_nbytes;
#endif
if (original_nbytes <= sizeof(save)) {
// 对于原始内存大小128字节save缓冲区的情况, save数组可以保留完整的原内存副本, 原内存拷贝save数组
memcpy(save, data, original_nbytes);
// 把原内存及debug头尾全置为0xdd
memset(data - 2 * SST, PYMEM_DEADBYTE,
original_nbytes + PYMEM_DEBUG_EXTRA_BYTES);
}
else {
// 对于超过128字节的情况, save数组无法保留完整的原内存副本, 因此这个时候python做的处理是保留原内存的前64个字节和后64个字节,把前64个字节和后64个字节置为0xdd
// 先拷贝前64字节内存
memcpy(save, data, ERASED_SIZE);
// 把原内存前64个字节及debug头置为0xdd
memset(head, PYMEM_DEADBYTE, ERASED_SIZE + 2 * SST);
// 再拷贝后64个字节
memcpy(&save[ERASED_SIZE], tail - ERASED_SIZE, ERASED_SIZE);
// 把后64个字节和debug尾置为0xdd
memset(tail - ERASED_SIZE, PYMEM_DEADBYTE,
ERASED_SIZE + PYMEM_DEBUG_EXTRA_BYTES - 2 * SST);
}
......
r = (uint8_t *)api->alloc.realloc(api->alloc.ctx, head, total);
......
// 和原memalloc机制一样,填充字节
data = head + 2*SST;
write_size_t(head, nbytes);
head[SST] = (uint8_t)api->api_id;
memset(head + SST + 1, PYMEM_FORBIDDENBYTE, SST-1);
tail = data + nbytes;
memset(tail, PYMEM_FORBIDDENBYTE, SST);
/* Restore saved bytes. */
// 恢复save数组的内存内容
if (original_nbytes <= sizeof(save)) {
// 原内存大小等于小于128字节的情况, 按照上面的save数组保存的内容,恢复到新的内存中(取64和新分配内存大小的最小值)
memcpy(data, save, Py_MIN(nbytes, original_nbytes));
}
else {
// 原内存大小大于128字节的情况
size_t i = original_nbytes - ERASED_SIZE;
// 先拷贝前字节n个字节(取64和新分配内存大小的最小值)
memcpy(data, save, Py_MIN(nbytes, ERASED_SIZE));
// 如果新分配内存大小已经恢复完了, 那就不继续拷贝了, 否则再拷贝剩下的内存。(取64和剩余内存大小的最小值), 拷贝到了原内存的尾部
if (nbytes > i) {
memcpy(data + i, &save[ERASED_SIZE],
Py_MIN(nbytes - i, ERASED_SIZE));
}
}
// 如果新分配的内存大小大于原内存, 那么把多分配的这部分内存置为0xcd
if (nbytes > original_nbytes) {
/* growing: mark new extra memory clean */
memset(data + original_nbytes, PYMEM_CLEANBYTE,
nbytes - original_nbytes);
}
需要注意的是, malloc与realloc返回的地址有可能不是同一个!主要是看原内存的后面有没有足够大的空闲内存满足新分配的大小。
realloc的流程比较多, 实际验证一下是否和理解的预期一致
用下面三种情况看下内存情况
1、原始内存小于128的情况
测试代码:
ini
void *pOrigin = PyMem_RawMalloc(64);
memset(pOrigin, 0xab, 64);
void *pNew = PyMem_RawRealloc(pOrigin, 128);
原有数据不变,新数据置成了0xcd
2、原始内存大于128, 新申请的内存也大于128的情况
测试代码:
ini
void *pOrigin = PyMem_RawMalloc(192);
memset(pOrigin, 0xab, 192);
void *pNew = PyMem_RawRealloc(pOrigin, 256);
没有debug到内存分配函数看,直接看最终的效果和上面一种情况一致
3、原始内存大于128, 新申请的内存小于128的情况
ini
void *pOrigin = PyMem_RawMalloc(192);
memset(pOrigin, 0xab, 192);
void *pNew = PyMem_RawRealloc(pOrigin, 64);
全是旧数据,也和预期一致
总结
对于python调用c的异步接口场景,需要保证在c里访问python对象的时候,对象一定是不能被垃圾回收的,可以使用一个全局list或者dict变量存放该对象的引用,或者使用局部变量,等异步任务完成后,再返回python函数释放局部变量。