1. 前言
递归是指一种通过重复将问题分解为同类的子问题而解决问题的方法。在程序中,通过函数直接或间接的调用自身来进行递归。[1]
我们在设计递归程序时需要一个或多个边界条件,用于退出递归。当未满足边界条件时调用函数自身,达到边界条件时退出递归。
2. 问题
在日常开发过程中,我们往往会使用递归处理一些逻辑。例如:在神策分析 iOS SDK 中,会使用递归逻辑进行埋点数据的上传。
简化流程如图 2-1 所示:
图 2-1 埋点数据上传的流程图
在程序中,如果函数无限调用自身,最终会导致栈溢出崩溃。通过流程图可以看出,退出递归的边界条件是所有数据上传成功或者某次数据上传失败,因此不会造成无限递归。
但是,仍有客户反馈此处递归发生了崩溃。
3. 排查经过
3.1. 推测原因
客户提供的部分堆栈信息如下:
...
35 SensorsAnalyticsSDK 0x0000000105d8e584 __39-[SAEventTracker flushRecordsWithSize:]_block_invoke_2 + 189828 (SAEventTracker.m:158)
36 SensorsAnalyticsSDK 0x0000000105d8e424 __39-[SAEventTracker flushRecordsWithSize:]_block_invoke + 189476 (SAEventTracker.m:161)
37 SensorsAnalyticsSDK 0x0000000105d8a4a8 -[SAEventFlush flushEventRecords:completion:] + 173224 (SAEventFlush.m:187)
38 SensorsAnalyticsSDK 0x0000000105d8e228 -[SAEventTracker flushRecordsWithSize:] + 188968 (SAEventTracker.m:147)
39 SensorsAnalyticsSDK 0x0000000105d8e584 __39-[SAEventTracker flushRecordsWithSize:]_block_invoke_2 + 189828 (SAEventTracker.m:158)
40 SensorsAnalyticsSDK 0x0000000105d8e424 __39-[SAEventTracker flushRecordsWithSize:]_block_invoke + 189476 (SAEventTracker.m:161)
41 SensorsAnalyticsSDK 0x0000000105d8a4a8 -[SAEventFlush flushEventRecords:completion:] + 173224 (SAEventFlush.m:187)
42 SensorsAnalyticsSDK 0x0000000105d8e228 -[SAEventTracker flushRecordsWithSize:] + 188968 (SAEventTracker.m:147)
43 SensorsAnalyticsSDK 0x0000000105d8e584 __39-[SAEventTracker flushRecordsWithSize:]_block_invoke_2 + 189828 (SAEventTracker.m:158)
44 SensorsAnalyticsSDK 0x0000000105d8e424 __39-[SAEventTracker flushRecordsWithSize:]_block_invoke + 189476 (SAEventTracker.m:161)
45 SensorsAnalyticsSDK 0x0000000105d8a4a8 -[SAEventFlush flushEventRecords:completion:] + 173224 (SAEventFlush.m:187)
46 SensorsAnalyticsSDK 0x0000000105d8e228 -[SAEventTracker flushRecordsWithSize:] + 188968 (SAEventTracker.m:147)
47 SensorsAnalyticsSDK 0x0000000105d8e584 __39-[SAEventTracker flushRecordsWithSize:]_block_invoke_2 + 189828 (SAEventTracker.m:158)
48 SensorsAnalyticsSDK 0x0000000105d8e424 __39-[SAEventTracker flushRecordsWithSize:]_block_invoke + 189476 (SAEventTracker.m:161)
49 SensorsAnalyticsSDK 0x0000000105d8a4a8 -[SAEventFlush flushEventRecords:completion:] + 173224 (SAEventFlush.m:187)
50 SensorsAnalyticsSDK 0x0000000105d8e228 -[SAEventTracker flushRecordsWithSize:] + 188968 (SAEventTracker.m:147)
51 SensorsAnalyticsSDK 0x0000000105d8e584 __39-[SAEventTracker flushRecordsWithSize:]_block_invoke_2 + 189828 (SAEventTracker.m:158)
52 SensorsAnalyticsSDK 0x0000000105d8e424 __39-[SAEventTracker flushRecordsWithSize:]_block_invoke + 189476 (SAEventTracker.m:161)
53 SensorsAnalyticsSDK 0x0000000105d8a4a8 -[SAEventFlush flushEventRecords:completion:] + 173224 (SAEventFlush.m:187)
54 SensorsAnalyticsSDK 0x0000000105d8e228 -[SAEventTracker flushRecordsWithSize:] + 188968 (SAEventTracker.m:147)
55 SensorsAnalyticsSDK 0x0000000105d8e584 __39-[SAEventTracker flushRecordsWithSize:]_block_invoke_2 + 189828 (SAEventTracker.m:158)
...
复制代码
通过堆栈信息初步推测是递归调用没有达到边界条件,导致无法退出递归 ,触发栈溢出崩溃。
接下来,梳理下 SDK 的上传逻辑:
在当次的数据上传失败时,会退出整个上传流程;
在当次的数据上传成功时,会先删除数据库中对应的数据,然后进行下一次的上传操作,直到数据库中无数据。
因此数据上传流程不可能无限递归下去,一定会在达到边界条件时退出递归。
确保 SDK 能够正常退出递归后,是否有可能在达到边界条件之前就发生了栈溢出崩溃呢?
为了验证这一猜想,对如下递归函数进行测试:
- (void)testRecursive:(NSInteger)count {
if (count > 0) {
[self testRecursive:count - 1];
}
}
复制代码
通过对该函数进行递归次数测试,发现递归调用在 12000 次时发生崩溃。
测试结果如表 3-1 所示:
表 3-1 递归函数的测试结果
因此,在达到边界条件之前,已经出现栈溢出了。
3.2. 栈溢出
什么是栈溢出?为什么会发生栈溢出呢?
下面我们进行详细说明。
3.2.1. 线程
iOS 中的每个 App 由一个或多个线程组成,每个线程表示通过应用程序代码执行的单个路径[2]。
当在应用程序创建一个新的线程时,该线程会成为应用程序进程空间内的一个独立实体,每个线程都有自己执行的栈空间。
3.2.2. 栈空间
通过 Apple 的官方文档可知[2],线程栈空间大小限制如表 3-2 所示:
表 3-2 栈空间大小
子线程的栈空间大小默认为 512 KB,该值是可配置的,但有如下要求:
必须是 4 KB 的倍数;
最小配置为 16 KB。
3.2.3. 内存布局
Objective-C 是基于 C 语言的,C 程序的内存布局[3] 如图 3-1 所示:
图 3-1 C 程序的内存布局
从低地址到高地址的布局依次为:
文本段:目标文件或内存中程序的一部分,包含可执行指令;
初始化数据段:是程序虚拟地址空间的一部分,包含全局变量和静态变量;
未初始化数据段:该段中的数据在程序开始执行之前由内核初始化为 0;
堆:动态分配和释放的内存空间,使用 malloc、realloc、free 管理;
栈:由编译器自动分配,包含局部变量、函数参数等。
3.2.4. 小结
通过对线程、栈空间及内存布局的了解,我们就比较清楚地认识了栈溢出的概念:系统为每个线程都分配了栈空间,用于存储局部变量、函数参数等内容,当函数执行过程中,栈上存储的内容超过了系统分配的栈空间,那么就会发生栈溢出。
在 iOS 中,NSThread 对象可通过 stackSize 属性配置栈空间大小,但必须在调用 start 方法前设置才有效。
示例如下:
NSThread *thread = [[NSThread alloc] initWithBlock:^{
[self testRecursive:12000];
}];
thread.stackSize = 1024 * 1024;
[thread start];
复制代码
注意:stackSize 的单位是字节(Byte)。
3.3. 确定问题
如何验证在崩溃时消耗的栈空间已经超过系统分配的限额了呢?
通过图 3-1 我们可以知道栈是从高地址向低地址增长的,且栈中存储了函数参数。因此,我们将第一次调用函数时的参数地址和最后一次调用函数时的参数地址进行相减,便能得到该递归函数运行时所消耗的栈空间。
调整递归函数,打印参数地址:
- (void)testRecursive:(NSInteger)count {
NSLog(@"count address = %p", &count);
if (count > 0) {
[self testRecursive:count - 1];
}
}
复制代码
测试结果如表 3-3 所示:
表 3-3 测试结果
因为新建的子线程只有递归函数在使用,所以在超过 512 KB 的系统限制后发生崩溃。
通过模拟大量递归调用数据上传函数的场景,确实会出现崩溃,此时栈空间大小超出了系统的限制。同时,调用堆栈和崩溃信号与客户反馈的一致,因此可以确认客户的崩溃是栈溢出导致的。
4. 解决方案
我们通常可以使用下面几种方式避免栈溢出崩溃:
for 循环代替递归;
将内存消耗较大的内容分配到堆上;
限制递归深度。
神策分析 iOS SDK 选择了第三种方式:限制递归深度。主要是基于下面两点原因:
对于 for 循环代替递归,需要事先知道数据库中的事件条数,而 iOS SDK 允许一边上传数据,一边采集事件入库,因此 for 循环代替递归的方案不适用该场景;
如果将内存消耗较大的内容分配到堆上,需要手动管理内存,改动较大且容易出错。
通常情况下,我们限制的递归深度能够保证上传所有埋点数据,同时确保不会发生栈溢出的崩溃。
极限情况下,如果当次未上传所有数据,则会在一定的时间间隔后继续上传(默认两次上传数据的时间间隔为 15s)。
修改后的埋点数据上传流程如图 4-1 所示:
图 4-1 修改后的埋点数据上传流程图
5. 总结
本文通过一个崩溃的案例引出了对于递归调用中栈溢出的讨论,对于递归的使用我们应当谨慎,不但需要考虑递归结束的边界条件,而且需要考虑在极限场景下是否会发生栈溢出。
鉴于作者水平有限,如果文中有表述不当的地方,欢迎批评指正。同时,也欢迎大家加入开源社区一起讨论。
6. 参考文献
[1]https://zh.wikipedia.org/wiki/%E9%80%92%E5%BD%92_(%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%A7%91%E5%AD%A6)
[2]https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/Multithreading/CreatingThreads/CreatingThreads.html
[3]https://en.wikipedia.org/wiki/Data_segment
文章来自公众号——神策技术社区
评论