线上 Zombie 方案 - CF 对象监控

线上 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 堆栈是本文探讨的重点。需要注意的是,本文中提到的方案尚未在线上进行验证,仅供讨论可行性,各位前辈如果能指点一二,将不胜感激。

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

  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 代码后的具体实现:

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。本文只是方案的分析探索,尚未在线上验证效果,如果各位大佬有更好的方案,请不吝赐教,非常欢迎您的宝贵意见和建议。

相关推荐
用户3157476081351 小时前
成为程序员的必经之路” Git “,你学会了吗?
面试·github·全栈
布川ku子2 小时前
[2024最新] java八股文实用版(附带原理)---Mysql篇
java·mysql·面试
iFlyCai2 小时前
Xcode 16 pod init失败的解决方案
ios·xcode·swift
有趣的杰克8 小时前
移动端【01】面试系统的MVVM重构实践
面试·职场和发展·重构
郝晨妤11 小时前
HarmonyOS和OpenHarmony区别是什么?鸿蒙和安卓IOS的区别是什么?
android·ios·harmonyos·鸿蒙
Hgc5588866611 小时前
iOS 18.1,未公开的新功能
ios
CocoaKier13 小时前
苹果商店下载链接如何获取
ios·apple
zhlx283515 小时前
【免越狱】iOS砸壳 可下载AppStore任意版本 旧版本IPA下载
macos·ios·cocoa
saturday-yh16 小时前
性能优化、安全
前端·面试·性能优化
前进别停留1 天前
206面试题(71~80)
面试