线上 Zombie 计划 – CF 目标监控

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 仓库是本文讨论的要点。需求留意的是,本文中说到的计划没有在线上进行验证,仅供讨论可行性,各位前辈假如能点拨一二,将不胜感激。

后续一些探究的点会包含:

  1. zombie 检测的计划选型: 修正 isa vs 保存 ptr 和 bt 映射联系。
  2. 仓库信息怎么存储查询。
  3. 目标怎么加权: autorelease 目标要点监控。
  4. 过滤: 线上计划最难的一个点,在线上做全量目标的监控存在很大的功能开销,过滤那些不可能产生 zombie 问题的目标,尽可能的确保在触发 zombie 溃散时,问题目标的 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);
    }
    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

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 的 deallocate 办法。从 debug 的现象来看是不需求的,替换之前的 allocate 在开释时会继续履行 CFAllocator 的 deallocate 办法。因为懒,这儿不做过多的源码剖析。

// 替换之前创立 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。

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 办法

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。本文只是计划的剖析探究,没有在线上验证作用,假如各位大佬有更好的计划,请不吝赐教,非常欢迎您的宝贵意见和建议。