线上 Zombie 方案 - CF 对象监控

2,597 阅读8分钟

zombie 触发的崩溃在缺少数据竞争的堆栈时,定位起来相对比较棘手,尤其是 autorelease pool 触发的问题,或者是 pb 这种通用的 model 触发的问题,看不到任何定位问题的特征信息。

autorelease pool 的崩溃堆栈如下图所示:

0	libobjc.A.dylib	        _objc_release()
1	libobjc.A.dylib	        AutoreleasePoolPage::releaseUntil(objc_object**)()
2	libobjc.A.dylib	        _objc_autoreleasePoolPop()
3	libdispatch.dylib	__dispatch_last_resort_autorelease_pool_pop()
4	libdispatch.dylib	__dispatch_lane_invoke()
5	libdispatch.dylib	__dispatch_root_queue_drain_deferred_wlh()
6	libdispatch.dylib	__dispatch_workloop_worker_thread()
7	libsystem_pthread.dylib	__pthread_wqthread()

zombie 问题本质上是 double-free 或者 use-after-free 的内存问题,因此解决这个问题的关键是取到第一次 free 的堆栈,再结合崩溃的堆栈,保证这两个堆栈对对象的访问线程安全。对于 NSObject 对象,可以直接 hook dealloc 来记录 free 的堆栈,CF 类型的对象监控 free 相比 NSObject 会有一些难度,但是也必须包含在监控范围内,从处理线上 zombie 问题的经验来看,大多数难定位的 zombie 对象类型都是 __NSCFString。获取 CF 对象的 free 堆栈是本文探讨的重点,需要注意的是,本文中提到的方案尚未在线上进行验证,仅供讨论可行性,各位前辈如果能指点一二,将不胜感激。

CFRelease

CF 对象的释放通过 CFRelease 方法,CFRelease 最终会调用 CFAllocatorDeallocate 释放 CF 对象,CFRelease 这个方法不能被直接 hook 掉,而 deallocate 这个方法里面存在很多可以替换的钩子方法,比较容易找到记录 free 堆栈的方案。

CFAllocatorDeallocate 删除部分 debug 代码后的具体实现:

void CFAllocatorDeallocate(CFAllocatorRef allocator, void *ptr) {
    CFAllocatorDeallocateCallBack deallocateFunc;
    
    // allocator 如果为空,则赋值 __CFGetDefaultAllocator
    if (NULL == allocator) {
        allocator = __CFGetDefaultAllocator();
    }

    __CFGenericValidateType(allocator, __kCFAllocatorTypeID);

    // 校验 _cfisa,如果 allocator 不是通过 CFAllocatorCreate 创建的,而是一个自定义的
    // malloc_zone_t 则直接调用 zone 的 free 方法。
    if (allocator->_base._cfisa != __CFISAForTypeID(__kCFAllocatorTypeID)) {	
         return malloc_zone_free((malloc_zone_t *)allocator, ptr);
    }
    //  如果是 CFAllocator 则调用 context 的 deallocate 方法
    deallocateFunc = __CFAlloc    
atorGetDeallocateFunction(&allocator->_context);
    if (NULL != ptr && NULL != deallocateFunc) {
	INVOKE_CALLBACK2(deallocateFunc, ptr, allocator->_context.info);
    }
}

这个函数的语义: 如果 allocator 的类型是 malloc_zone_t,则直接调用 zone 的 free 方法,如果 allocator 的类型是 CFAllocator 则调用其持有的 _context 的 deallocate 方法。根据这个语义,我目前能想到的 hook 方案有如下 3 种。

方案 1: set malloc_zone_t

系统提供了 api 设置 default CFAllocator

void CFAllocatorSetDefault(CFAllocatorRef allocator);

但是在这个 api 里面把 malloc_zone_t 这个类型给禁用掉了,因此不能直接调用。

if (allocator && allocator->_base._cfisa != __CFISAForTypeID(__kCFAllocatorTypeID)) {	// malloc_zone_t *
        return; // require allocator to this function to be an allocator
}

CFAllocatorSetDefault 这个方法核心是将 allocator 放到 __CFTSDTable 容器里面,然后将 Table 存储到线程的局部变量。

CFRetain(allocator);
    _CFSetTSD(__CFTSDKeyAllocator, (void *)allocator, NULL);
}

存储的 key 值通过 pthread_key_init_np 初始化,相对于动态创建 key 值的 pthread_key_create 方法,init_np 可以指定 key 值,也就是说 Table 在 TSD 里面的 key 是个固定的数值,我们可以绕开 CFAllocatorSetDefault 直接将 malloc_zone_t 存储到 TSD 里面。考虑到时间成本,我们先 export _CFSetTSD 方法验证可行性,实现如下所示:

void    (*(origin_cf_zone_free))(struct _malloc_zone_t *zone, void *ptr);
void    (*(origin_cf_zone_free_definite_size))(struct _malloc_zone_t *zone, void *ptr, size_t size);
void    (*(origin_cf_zone_try_free_default))(struct _malloc_zone_t *zone, void *ptr);

void new_cf_zone_free(struct _malloc_zone_t *zone, void *ptr) {
    origin_cf_zone_free(zone, ptr); // <----- 在这个方法内记录对战
}

void new_cf_zone_free_definite_size(struct _malloc_zone_t *zone, void *ptr, size_t size) {
    origin_cf_zone_free_definite_size(zone, ptr, size);
}

void  new_cf_zone_try_free_default(struct _malloc_zone_t *zone, void *ptr) {
    origin_cf_zone_try_free_default(zone, ptr);
}

void swizzle_cf_deallocate() {
    malloc_zone_t *cf_zone = malloc_create_zone(0, 0);
    origin_cf_zone_free = cf_zone->free;
    origin_cf_zone_free_definite_size = cf_zone->free_definite_size;
    origin_cf_zone_try_free_default = cf_zone->try_free_default;
    mprotect(cf_zone, **sizeof**(malloc_zone_t), PROT_READ | PROT_WRITE);
    cf_zone->free = new_cf_zone_free;
    cf_zone->free_definite_size = new_cf_zone_free_definite_size;
    cf_zone->try_free_default = new_cf_zone_try_free_default;
    _CFSetTSD(1, cf_zone, nil);
}

断点 3 个 free 方法,CFRelease 在替换之后会执行到 origin_cf_zone_free 方法里面,可以在这个方法里面记录 CF 对象释放的堆栈。

这里存在一个问题,对于 default allocator 的替换是从 app 运行过程中进行的,在替换之前 allocate 的 CF 对象是否需要特殊处理?也就是在 zone 的 free 方法判断是否是替换之前的 CFAllocator allocate 的 CF 对象,如果是则调用 CFAllocator 的 deallocate 方法。从 debug 的现象来看是不需要的,替换之前的 allocate 在释放时会继续执行 CFAllocator 的 deallocate 方法,并没有走 zone 的 free 方法。因为懒,这里不做过多的源码分析。

// 替换之前创建 CF 对象
CFStringRef cf_str_1 = CFStringCreateWithCString(kCFAllocatorDefault, "CFString_kCFAllocatorDefault", kCFStringEncodingUTF8);

// CFAllocator 替换为 malloc_zone_t
_CFSetTSD(1, cf_zone, nil);   

// 替换之后创建 CF 对象
CFStringRef cf_str_2 = CFStringCreateWithCString(kCFAllocatorDefault, 
"CFString_kCFAllocatorDefault", kCFStringEncodingUTF8);

// 释放替换之前创建的 CF 对象,执行 CFAllocator deallocate
CFRelease(cf_str_1);    

// 释放替换之前创建的 CF 对象,执行 malloc_zone_t free
CFRelease(cf_str_2);   

方案2: 自定义 CFAllocator

和方案一相比,这里自定义的分配器类型是 CFAllocator,对应的 deallocate 方法在 CFAllocator 持有的 _context 结构体里面,_context 可以通过系统 api 获取。这种方案不改变分配器的类型,理论上对于 CF 对象的内存管理影响更小一些。

void        (*cf_origin_deallocate)(void *ptr, void *info);
void        cf_new_deallocate(void *ptr, void *info) {
    cf_origin_deallocate(ptr, info);
}

CFAllocatorContext context = { 0 };
// 获取默认的 CFAllocator 的 context
CFAllocatorGetContext(CFAllocatorGetDefault(), &context);
// 记录 context 原始的 deallocate 方法
cf_origin_deallocate = context.deallocate;
// 将 deallocate 方法替换为自定义方法
context.deallocate = cf_new_deallocate;
// 使用上述 conteext 新创建一个 allocator,并设置为 default CFAllocator
CFAllocatorSetDefault(CFAllocatorCreate(kCFAllocatorDefault, &context));

存在的问题: 在执行 UIGraphicsEndImageContext 时触发了一个崩溃,至今原因不明。

Example(5274,0x1e49d8800) malloc: Non-aligned pointer 0x281231c90 being freed (2)

方案3: 修改 default CFAllocator deallocate 方法

涉及到两个私有的结构体 __CFAllocator 和 CFRuntimeBase。对于 __CFAllocator 结构体,我们只需要关注其中的 _context。CFRuntimeBase 在映射结构体的过程中非必须,可以使用两个 void * 指针占位,但是后续 free 记录堆栈时判断 CF 类型会用到,这里也直接映射了一份。

struct __CFAllocator {
    CFRuntimeBase _base;
    // CFAllocator structure must match struct _malloc_zone_t!
    // The first two reserved fields in struct _malloc_zone_t are for us with CFRuntimeBase
    size_t 	(*size)(struct _malloc_zone_t *zone, const void *ptr); /* returns the size of a block or 0 if not in this zone; must be fast, especially for negative answers */
    void 	*(*malloc)(struct _malloc_zone_t *zone, size_t size);
    void 	*(*calloc)(struct _malloc_zone_t *zone, size_t num_items, size_t size); /* same as malloc, but block returned is set to zero */
    void 	*(*valloc)(struct _malloc_zone_t *zone, size_t size); /* same as malloc, but block returned is set to zero and is guaranteed to be page aligned */
    void 	(*free)(struct _malloc_zone_t *zone, void *ptr);
    void 	*(*realloc)(struct _malloc_zone_t *zone, void *ptr, size_t size);
    void 	(*destroy)(struct _malloc_zone_t *zone); /* zone is destroyed and all memory reclaimed */
    const char	*zone_name;

    /* Optional batch callbacks; these may be NULL */
    unsigned	(*batch_malloc)(struct _malloc_zone_t *zone, size_t size, void **results, unsigned num_requested); /* given a size, returns pointers capable of holding that size; returns the number of pointers allocated (maybe 0 or less than num_requested) */
    void	(*batch_free)(struct _malloc_zone_t *zone, void **to_be_freed, unsigned num_to_be_freed); /* frees all the pointers in to_be_freed; note that to_be_freed may be overwritten during the process */

    struct malloc_introspection_t	*introspect;
    unsigned	version;
    
    /* aligned memory allocation. The callback may be NULL. */
	void *(*memalign)(struct _malloc_zone_t *zone, size_t alignment, size_t size);
    
    /* free a pointer known to be in zone and known to have the given size. The callback may be NULL. */
    void (*free_definite_size)(struct _malloc_zone_t *zone, void *ptr, size_t size);
    CFAllocatorRef _allocator;
    CFAllocatorContext _context;    // <--- 映射 __CFAllocator 是为了获取 _context
};

typedef struct __CFRuntimeBase {
    uintptr_t _cfisa;
    uint8_t _cfinfo[4];
#if __LP64__
    uint32_t _rc;
#endif
} CFRuntimeBase;

映射 __CFAllocator 替换 _context 的 deallocate 方法

void        (*cf_origin_deallocate)(void *ptr, void *info);
void        cf_new_deallocate(void *ptr, void *info) {
    CFTypeID ID = __XXXCFGenericTypeID_inline(ptr);
    if (ID == CFStringGetTypeID()) {
        // 这里根据根据类型筛选记录堆栈
    }
    cf_origin_deallocate(ptr, info);

}

void swizzle_cf_deallocate() {
    struct __XXXCFAllocator *cf_zone = (struct __XXXCFAllocator *)CFAllocatorGetDefault();
    // 可能得需要提前调用 mprotect 方法保证指针可被修改
    cf_origin_deallocate = cf_zone->_context.deallocate;
    cf_zone->_context.deallocate = cf_new_deallocate;
}

映射系统的私有结构体通常是是一个危险的操作,如果系统更新了该结构体还是按照原始的结构映射修改,可能会把结构体的内存写坏。但是这里修改的 deallocate 方法本身是可以获取的,因此我们可以加一层校验来保证这里的映射是安全的。

CFAllocatorContext context = { 0 };
CFAllocatorGetContext(CFAllocatorGetDefault(), &context);
if (cf_zone->_context.deallocate != context.deallocate) {
    return;
}

结论

方案 1 将 CF 的分配器替换为 malloc_zone_t,方案 2 将 CF 的分配器替换为自定义的 CFAllocator,而方案 3 只修改了一个函数指针 deallocate,相对于前两者影响范围更小,在均能实现功能的基础之上,目前本人更倾向于方案 3。另外在某些场景下只有 deallocate 的堆栈并不能定位问题,同时需要采集 allocate 的堆栈,这对于方案 3 也非常容易扩展,只需要修改 _context 的 allocate 指针。本文只是方案的分析探索,尚未在线上验证效果,如果各位大佬有更好的方案,请不吝赐教,非常欢迎您的宝贵意见和建议。

后续一些探索的点会包含以下几个方向:

  1. zombie 检测的方案选型: 修改 isa vs 保存 ptr 和 bt 映射关系。
  2. 堆栈信息如何存储查询。
  3. 对象如何加权: autorelease 对象重点监控。
  4. 过滤: 线上方案最难的一个点,在线上做全量对象的监控存在很大的性能开销,过滤那些不可能产生 zombie 问题的对象,尽可能的保证在触发 zombie 崩溃时,问题对象的 free 堆栈已经成功记录。