线上 Zombie 方案 - CF 对象监控
zombie 触发的崩溃在缺少数据竞争的堆栈时,定位起来相对比较棘手,尤其是 autorelease pool 触发的问题,或者是 pb 这种通用的 model 触发的问题,看不到任何定位问题的特征信息。
autorelease pool 的崩溃堆栈如下图所示:
scss
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 堆栈是本文探讨的重点。需要注意的是,本文中提到的方案尚未在线上进行验证,仅供讨论可行性,各位前辈如果能指点一二,将不胜感激。
后续一些探索的点会包含:
- zombie 检测的方案选型: 修改 isa vs 保存 ptr 和 bt 映射关系。
- 堆栈信息如何存储查询。
- 对象如何加权: autorelease 对象重点监控。
- 过滤: 线上方案最难的一个点,在线上做全量对象的监控存在很大的性能开销,过滤那些不可能产生 zombie 问题的对象,尽可能的保证在触发 zombie 崩溃时,问题对象的 free 堆栈已经成功记录。
CFRelease
CF 对象的释放通过 CFRelease 方法,CFRelease 最终会调用 CFAllocatorDeallocate 释放 CF 对象,CFRelease 这个方法不能被直接 hook 掉,而 deallocate 这个方法里面存在很多可以替换的钩子方法,比较容易找到记录 free 堆栈的方案。
CFAllocatorDeallocate 删除部分 debug 代码后的具体实现:
scss
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);
}
deallocateFunc = __CFAlloc // 如果是 CFAllocator 则调用 context 的 deallocate 方法
atorGetDeallocateFunction(&allocator->_context);
if (NULL != ptr && NULL != deallocateFunc) {
INVOKE_CALLBACK2(deallocateFunc, ptr, allocator->_context.info);
}
}
根据这个函数的语义,如果 allocator 的类型是 malloc_zone_t,则直接调用 zone 的 free 方法,如果 allocator 的类型是 CFAllocatorRef 则调用 _context 的 deallocate 方法。根据这个语义,我目前能想到的 hook 方案有如下 3 种。
方案 1: set malloc_zone_t
系统提供了 api 设置 default CFAllocator
objectivec
void CFAllocatorSetDefault(CFAllocatorRef allocator);
但是在这个 api 里面把 malloc_zone_t 这个类型给禁用掉了,因此不能直接调用。
kotlin
if (allocator && allocator->_base._cfisa != __CFISAForTypeID(__kCFAllocatorTypeID)) { // malloc_zone_t *
return; // require allocator to this function to be an allocator
}
CFAllocatorSetDefault 这个方法核心是将 allocator 放到 __CFTSDTable 容器里面,然后将 Table 存储到线程的局部变量。
scss
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 方法验证可行性,实现如下所示:
arduino
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 的 deallocate 方法。从 debug 的现象来看是不需要的,替换之前的 allocate 在释放时会继续执行 CFAllocator 的 deallocate 方法。因为懒,这里不做过多的源码分析。
scss
// 替换之前创建 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 对象的内存管理影响更小一些。
objectivec
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。
arduino
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;
};
typedef struct __CFRuntimeBase {
uintptr_t _cfisa;
uint8_t _cfinfo[4];
#if __LP64__
uint32_t _rc;
#endif
} CFRuntimeBase;
映射 __CFAllocator 替换 _context 的 deallocate 方法
ini
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 方法本身是可以获取的,因此我们可以加一层校验来保证这里的映射是安全的。
ini
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。本文只是方案的分析探索,尚未在线上验证效果,如果各位大佬有更好的方案,请不吝赐教,非常欢迎您的宝贵意见和建议。