掘金 后端 ( ) • 2024-04-16 11:33

书接上回, tracemalloc内存检查的整体机制见该文章的分析 https://juejin.cn/post/7354515533662847012

问题

今天在代码调试过程中, 发现了另一个core, 内存的字节都是0xdd image.png

内存置0xdd的原因

往回翻了一下代码, python在--with-pydebug启动了debug模式下编译的python, 在内存释放的时候, 不仅会检查填充的特殊字符来做踩内存检查, 也会把本次释放的内存全部置为 0xdd

#  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

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的情况

测试代码:

void *pOrigin = PyMem_RawMalloc(64);
memset(pOrigin, 0xab, 64);
void *pNew = PyMem_RawRealloc(pOrigin, 128);

image.png

原有数据不变,新数据置成了0xcd

2、原始内存大于128, 新申请的内存也大于128的情况

测试代码:

void *pOrigin = PyMem_RawMalloc(192);
memset(pOrigin, 0xab, 192);
void *pNew = PyMem_RawRealloc(pOrigin, 256);

没有debug到内存分配函数看,直接看最终的效果和上面一种情况一致

3、原始内存大于128, 新申请的内存小于128的情况

void *pOrigin = PyMem_RawMalloc(192);
memset(pOrigin, 0xab, 192);
void *pNew = PyMem_RawRealloc(pOrigin, 64);

image.png

全是旧数据,也和预期一致

总结

对于python调用c的异步接口场景,需要保证在c里访问python对象的时候,对象一定是不能被垃圾回收的,可以使用一个全局list或者dict变量存放该对象的引用,或者使用局部变量,等异步任务完成后,再返回python函数释放局部变量。