当前位置:首页 > 实时新闻 > 正文

mac开发系列29-for in遍历NSMutableArray隐藏的crash

摘要: mac开发系列29:forin遍历NSMutableArray隐藏的crash最佳答案53678位专家为你答疑解惑mac开发系列...

mac开发系列29:for in遍历NSMutableArray隐藏的crash

最佳答案 53678位专家为你答疑解惑

mac开发系列29:for in遍历NSMutableArray隐藏的crash

今天遇到一枚crash,主要错误信息如下:

        reason: '*** Collection <__NSArrayM: xxxxxxxx> was mutated while being enumerated.'

大致意思是,集合在枚举时被修改了。于是谷歌一把,发现出错的代码大多形如:

      for(item in array) { //难道这里的array用的不是下面修改后的实时array?           if(condition) {               [array removeObject:xxx];         }      }

自己的代码确实有这样的实现,问题是为啥会crash呢?难道for in循环中用的不是实时修改后的array?不能死得不明不白呀!我们知道,oc的底层实现是c,所以看看for in在c层面究竟干了什么,编辑一个demo.m如下:

    #import <Foundation/Foundation.h>      int main() {       NSMutableArray *arr=@[@"aaa", @"bbb", @"ccc"];            for (NSString *item in arr) {               if([item isEqualToString:@"bbb"]) {                     [arr removeObject:item];             }          }           return 0;      }

接着利用如下命令行,重写demo.m成demo.cpp:

         clang -rewrite-objc demo.m // 把m文件重写成cpp文件

demo.cpp的main函数如下:

     int main() {           NSMutableArray *arr=((NSArray *(*)(Class, SEL, const ObjectType *, NSUInteger))(void *)objc_msgSend)(objc_getClass("NSArray"), sel_registerName("arrayWithObjects:count:"), (const id *)__NSContainer_literal(3U, (NSString *)&__NSConstantStringImpl__var_folders_75_sj4g_8kx5kd0d0wvzlhjrnyr0000gn_T_demo_ccc02e_mi_0, (NSString *)&__NSConstantStringImpl__var_folders_75_sj4g_8kx5kd0d0wvzlhjrnyr0000gn_T_demo_ccc02e_mi_1, (NSString *)&__NSConstantStringImpl__var_folders_75_sj4g_8kx5kd0d0wvzlhjrnyr0000gn_T_demo_ccc02e_mi_2).arr, 3U);             {                 NSString * item;                struct __objcFastEnumerationState enumState={ 0 };                id __rw_items[16];                id l_collection=(id) arr;                 _WIN_NSUInteger limit=                       ((_WIN_NSUInteger (*) (id, SEL, struct __objcFastEnumerationState *, id *, _WIN_NSUInteger))(void *)objc_msgSend)                    ((id)l_collection,                    sel_registerName("countByEnumeratingWithState:objects:count:"),                    &enumState, (id *)__rw_items, (_WIN_NSUInteger)16);             if (limit) {       // startMutations保存mutationsPtr的初始值             unsigned long startMutations=*enumState.mutationsPtr;             do {                       unsigned long counter=0;                   do {      // mutationsPtr有变化,而且没有handler,就crash!!!             if (startMutations !=*enumState.mutationsPtr)                objc_enumerationMutation(l_collection);             item=(NSString *)enumState.itemsPtr[counter++]; {        if(((BOOL (*)(id, SEL, NSString *))(void *)objc_msgSend)((id)item, sel_registerName("isEqualToString:"), (NSString *)&__NSConstantStringImpl__var_folders_75_sj4g_8kx5kd0d0wvzlhjrnyr0000gn_T_demo_ccc02e_mi_3)) {         ((void (*)(id, SEL, ObjectType))(void *)objc_msgSend)((id)arr, sel_registerName("removeObject:"), (id)item);     } };             __continue_label_1: ;                 } while (counter < limit);        } while ((limit=((_WIN_NSUInteger (*) (id, SEL, struct __objcFastEnumerationState *, id *, _WIN_NSUInteger))(void *)objc_msgSend)                 ((id)l_collection,          sel_registerName("countByEnumeratingWithState:objects:count:"),    &enumState, (id *)__rw_items, (_WIN_NSUInteger)16)));     item=((NSString *)0);     __break_label_1: ;      }     else         item=((NSString *)0);   }       return 0;}

可以看到,for in的底层实现是嵌套do while循环。注意到上面绿色标注的那行代码,objc_enumerationMutation和mutationsPtr的官方解释如下:

解决方法主要有两种:1、copy一个数组,遍历副本,原数组removeObject。2、利用enumerateObjectsUsingBlock block进行遍历,官方推荐,效率更高。参考链接:https://developer.apple.com/reference/foundation/nsfastenumerationstate/1408376-mutationsptr?language=objchttps://developer.apple.com/library/content/documentation/General/Conceptual/DevPedia-CocoaCore/Enumeration.htmlhttp://blog.leichunfeng.com/blog/2016/06/20/objective-c-fast-enumeration-implementation-principle/https://developer.apple.com/reference/objectivec/1418744-objc_enumerationmutation?language=objchttp://blog.csdn.net/piaodang1234/article/details/11902541

Swift编译器Crash—Segmentation fault解决方案

背景

抖音上线 Swift 后,编译时偶现Segmentation fault: 11和Illegal instruction: 4的错误,CI/CD 和本地均有出现,且重新编译后均可恢复正常。

由于属于编译器层抛出的 Crash,加之提示的错误代码不固定且非必现,一时较为棘手。网上类似错误较多,但Segmentation fault属于访问了错误内存的通用报错,参考意义较小。和公司内外的团队交流过,也有遇到类似错误,但原因各不相同,难以借鉴。

虽然 Swift 库二进制化后,相关代码不会参与编译,本地出现的概率大大减少,但在 CI/CD/仓库二进制化任务中依旧使用源码,出现问题需要手动重试,影响效率且繁琐,故深入编译器寻求解决方案。

Crash 堆栈

结论

简而言之,是 Swift 代码中将在 OC 中声明为类属性的NSDictionary变量,当成 Swift 的Dictionary使用。即一个 immutable 变量当作 mutable 变量使用了。编译器在校验SILInstruction时出错,主动调用abort()结束进程或出现EXC_BAD_ACCESS的 Crash。

准备工作

编译 Swift

由于本地重现过错误,故拉取和本地一致的 swift-5.3.2-RELEASE 版本,同时推荐使用 VSCode 进行调试,Ninja 进行构建。

Ninja 是专注于速度的小型构建系统。

注意事项

提前预留 50G 磁盘空间首次编译时长在一小时左右,CPU 基本打满

下载&编译源码

brew install cmake ninjamkdir swift-sourcecd swift-sourcegit clone git@github.com:apple/swift.gitcd swift/utils./update-checkout --tag swift-5.3.2-RELEASE --clone./build-script

主要目录

提取编译参数

笔者将相关代码抽离抖音工程, 本地复现编译报错问题后,从 Xcode 中提取编译参数:

VSCode 调试

选择合适的 LLDB 插件,以 CodeLLDB 为例配置如下的 launch.json。

其中args内容为获取前一步提取的编译参数,批量将其中每个参数用双引号包裹,再用逗号隔开所得。

{    "version": "0.2.0",    "configurations": [        {            "type":  "lldb",            "request": "launch",            "name": "Debug",            "program": "${workspaceFolder}/build/Ninja-DebugAssert/swift-macosx-x86_64/bin/swift",            "args": ["-frontend","-c","-primary-file"/*and other params*/],            "cwd": "${workspaceFolder}",        }    ]}

SIL

LLVM

在深入 SIL 之前,先简单介绍 LLVM,经典的 LLVM 三段式架构如下图所示,分为前端(Frontend),优化器(Optimizer)和后端(Backend)。当需要支持新语言时只需实现前端部分,需要支持新的架构只需实现后端部分,而前后端的连接枢纽就是 IR(Intermediate Representation),IR 独立于编程语言和机器架构,故 IR 阶段的优化可以做到抽象而通用。

Frontend

前端经过词法分析(Lexical Analysis),语法分析(Syntactic Analysis)生成 AST,语义分析(Semantic Analysis),中间代码生成(Intermediate Code Generation)等步骤,生成 IR。

IR

格式

IR 是 LLVM 前后端的桥接语言,其主要有三种格式:

可读的格式,以.ll 结尾Bitcode 格式,以.bc 结尾运行时在内存中的格式

这三种格式完全等价。

SSA

LLVM IR 和 SIL 都是 SSA(Static Single Assignment)形式,SSA 形式中的所有变量使用前必须声明且只能被赋值一次,如此实现的好处是能够进行更高效,更深入和更具定制化的优化。

如下图所示,代码改造为 SSA 形式后,变量只能被赋值一次,就能很容易判断出 y1=1 是可被优化移除的赋值语句。

结构

基础结构由 Module 组成,每个 Module 大概相当于一个源文件。Module 包含全局变量和 Function 等。Function 对应着函数,包括方法的声实现,参数和返回值等。Function 最重要的部分就是各类 Basic Block。

Basic Block(BB) 对应着函数的控制流图,是 Instruction 的集合,且一定以 Terminator Instructions 结尾,其代表着 Basic Block 执行结束,进行分支跳转或函数返回。

Instruction 对应着指令,是程序执行的基本单元。

Optimizer

IR 经过优化器进行优化,优化器会调用执行各类 Pass。所谓 Pass,就是遍历一遍 IR,在进行针对性的处理的代码。LLVM 内置了若干 Pass,开发者也可自定义 Pass 实现特定功能,比如插桩统计函数运行耗时等。

Xcode Optimization Level

在 Xcode - Build Setting - Apple Clang - Code Generation - Optimization Level 中,可以选定优化级别,-O0 表示无优化,即不调用任何优化 Pass。其他优化级别则调用执行对应的 Pass。

Backend

后端将 IR 转成生成相应 CPU 架构的机器码。

Swiftc

不同于 OC 使用 clang 作为编译器前端,Swift 自定义了编译器前端 swiftc,如下图所示。

这里就体现出来 LLVM 三段式的好处了,支持新语言只需实现编译器前端即可。

对比 clang,Swift 新增了对 SIL(Swift Intermediate Language)的处理过程。SIL 是 Swift 引入的新的高级中间语言,用以实现更高级别的优化。

Swift 编译流程

Swift 源码经过词法分析,语法分析和语义分析生成 AST。SILGen 获取 AST 后生成 SIL,此时的 SIL 称为 Raw SIL。在经过分析和优化,生成 Canonical SIL。最后,IRGen 再将 Canonical SIL 转化为 LLVM IR 交给优化器和后端处理。

SIL 指令

SIL 假设虚拟寄存器数量无上限,以%+数字命名,如%0,%1 等一直往上递增 以下介绍几个后续会用到的指令:

alloc_stack : 分配栈内存apply : 传参调用函数Load : 从内存中加载指定地址的值function_ref : 创建对 SIL 函数的引用

SIL 详细的指令解析可参考官方文档。

Identifier

LLVM IR 标识符有 2 种基本类型:

全局标识符:包含方法和全局变量等,以@开头局部标识符:包含寄存器名和类型等,以%开头,其中%+数字代表未命名变量变量

在 SIL 中,标识符以@开头

SIL function 名都以@+字母/数字命名,且通常都经过 mangleSIL value 同样以%+字母/数字命名,表示其引用着 instruction 或 Basic block 的参数@convention(swift)使用 Swift 函数的调用约定(Calling Convention),默认使用@convention(c)和@convention(objc_method)分别表示使用 C 和 OC 的调用约定@convention(method)表示 Swift 实例方法的实现@convention(witness_method)表示 Swift protocol 方法的实现

SIL 结构

SIL 实现了一整套和 IR 类似的结构,定制化实现了SILModule SILFunction SILBasicBlock SILInstruction。

调试过程

复现 Crash

根据前文的准备工作设置好编译参数后,启动编译,复现 Crash,两种 Crash 都有复现,场景如下图所示。abort()和EXC_BAD_ACCESS会导致上文出现的Illegal instruction: 4和Segmentation fault: 11错误。由于二者的上层堆栈一致,以下以前者为例进行分析。

堆栈分析

通过堆栈溯源可看出是在生成SILFunction后,执行postEmitFunction校验SILFunction的合法性时,使用SILVerifier层层遍历并校验 BasicBlock(visitSILBasicBlock)。对 BasicBlock 内部的SILInstruction进行遍历校验(visitSILInstruction)。

在获取SILInstruction的类型时调用getKind()返回异常,触发 Crash。

异常 SIL

由于此时SILInstruction异常,比较难定位是在校验哪段指令时异常,故在遍历SILInstruction时打印上一段指令的内容。swift 源代码根目录执行以下命令,增量编译
cd build/Ninja-DebugAssert/swift-macosx-x86_64ninja

复现后打印内容如下图所示:

调试小 tips:LLVM 中很多类都实现了 dump()函数用以打印内容,方便调试。

// function_ref Dictionary.subscript.setter%32=function_ref @$sSDyq_Sgxcis : $@convention(method) <τ_0_0, τ_0_1 where τ_0_0 : Hashable> (@in Optional<τ_0_1>, @in τ_0_0, @inout Dictionary<τ_0_0, τ_0_1>) -> () // user: %33%33=apply %32<AnyHashable, Any>(%13, %11, %24) : $@convention(method) <τ_0_0, τ_0_1 where τ_0_0 : Hashable> (@in Optional<τ_0_1>, @in τ_0_0, @inout Dictionary<τ_0_0, τ_0_1>) -> ()%34=load [take] %24 : $*Dictionary<AnyHashable, Any> // users: %43, %37

正常 SIL

命令行使用swiftc -emit-silgen能生成 Raw SIL,由于该类引用到了 OC 文件,故加上桥接文件的编译参数,完整命令如下:

swiftc -emit-silgen /Users/cs/code/ThirdParty/Swift_MVP/Swift_MVP/SwiftCrash.swift -o test.sil  -import-objc-header /Users/cs/code/ThirdParty/Swift_MVP/Swift_MVP/Swift_MVP-Bridging-Header.h

截取部分 SIL 如下

%24=alloc_stack $Dictionary<AnyHashable, Any> // users: %44, %34, %33, %31%25=metatype $@objc_metatype TestObject.Type  // users: %40, %39, %27, %26%34=load [take] %24 : $*Dictionary<AnyHashable, Any> // users: %42, %36%35=function_ref @$sSD10FoundationE19_bridgeToObjectiveCSo12NSDictionaryCyF : $@convention(method) <τ_0_0, τ_0_1 where τ_0_0 : Hashable> (@guaranteed Dictionary<τ_0_0, τ_0_1>) -> @owned NSDictionary // user: %37%36=begin_borrow %34 : $Dictionary<AnyHashable, Any> // users: %38, %37%37=apply %35<AnyHashable, Any>(%36) : $@convention(method) <τ_0_0, τ_0_1 where τ_0_0 : Hashable> (@guaranteed Dictionary<τ_0_0, τ_0_1>) -> @owned NSDictionary // users: %41, %40

SIL 分析

对正常 SIL 逐条指令分析

在栈中分配类型为Dictionary<AnyHashable, Any>的内存,将其地址存到寄存器%24,该寄存器的使用者是%44, %34, %33, %31%25 表示类型TestObject.Type,即TestObject的类型 metaType加载%24 寄存器的值到%34 中,同时销毁%24 的值创建对函数_bridgeToObjectiveC()-> NSDictionary的引用,存到%35 中由于函数名被 mangle,先将函数名 demangle,如下图所示,得到函数

@convention(method)表明是 Swift 实例方法,有 2 个泛型参数,其中第一个参数τ_0_0实现了 Hashable 协议生成一个和%34 相同类型的值,存入%36,%36 结束使用之前,%34 一直存在执行%35 中存储的函数,传入参数%36,返回NSDictionary类型,结果存在%37。其作用就是将Dictionary转成了NSDictionary

曙光初现

对比异常 SIL,可以看出是在执行桥接方法_bridgeToObjectiveC()时失败,遂查看源码,发现是一个 OC 的NSDictionary不可变类型桥接到 Swift 的Dictionary成为一个可变类型时,对其内容进行修改。虽然这种写法存在可能导致逻辑异常,但并不致编译器 Crash,属于编译器代码 bug。更有意思的是,只有在 OC 中将该属性声明为类属性(class)时,才会导致编译器 Crash。

class SwiftCrash: NSObject {  func execute() {    //compiler crash    TestObject.cachedData[""]=""  }}
@interface TestObject : NSObject@property (strong, nonatomic, class) NSDictionary *cachedData;@end

解决方案

源码修改

找到错误根源就好处理了,将问题代码中的 NSDictionary 改成 NSMutableDictionary 即可解决。

重新运行 Swift 编译器编译源码,无报错。

修改抖音源码后,也再没出现编译器 Crash 的问题,问题修复。

静态分析

潜在问题

虽然NSDictionary正常情况下可以桥接成 Swift 的Dictionary正常使用,但当在 Swift 中对 immutable 对象进行修改后,会重新生成新的对象,对原有对象无影响,测试代码和输出结果如下:

可以看出变量temp内容无变化,Swift 代码修改无效。

TestObject *t=[TestObject new];t.cachedData=[@{@"oc":@"oc"} mutableCopy];NSDictionary *temp=t.cachedData;NSLog(@"before execution : temp %p: %@",temp,temp);NSLog(@"before execution : cachedData %p: %@",t.cachedData,t.cachedData);[[[SwiftDataMgr alloc] init] executeWithT:t];NSLog(@"after execution : temp %p: %@",temp,temp);NSLog(@"after execution : cachedData %p: %@",t.cachedData,t.cachedData);
class SwiftDataMgr: NSObject {  @objc  func execute(t : TestObject) {    t.cachedData["swift"]="swift"  }}

新增规则

新增对抖音源码的静态检测规则,检测所有 OC immutable 类是否在 Swift 中被修改。防止编译器 crash 和导致潜在的逻辑错误。

所有需检测的类如下:

NSDictionary/NSSet/NSData/NSArray/NSString/NSOrderedSet/NSURLRequest/NSIndexSet/NSCharacterSet/NSParagraphStyle/NSAttributedString

后记

行文至此,该编译器 Crash 问题已经解决。同时近期在升级 Xcode 至 12.5 版本时又遇到另一种编译器 Crash 且未提示具体报错文件,笔者如法炮制找出错误后并修复。待深入分析生成SILInstruction异常的根本原因后,另起文章总结。

此外笔者为 Swift 编译器提交了 bug 报告并附上最小可复现 demo, 有需要的同学可以在此链接下载:

bugs.swift.org/browse/SR-1…

加入我们

我们是负责抖音客户端基础能力研发和新技术探索的团队。我们在工程/业务架构,研发工具,编译系统等方向深耕,支撑业务快速迭代的同时,保证超大规模团队的研发效能和工程质量。在性能/稳定性等方面不断探索,努力为全球数亿用户提供最极致的基础体验。

如果你对技术充满热情,欢迎加入抖音基础技术团队,让我们共建亿级全球化 App。目前我们在深圳、北京、上海和杭州均有招聘需求。

内推可以联系邮箱:chenshan.cc@bytedance.com,邮件标题:姓名-工作年限-抖音-基础技术-iOS/Android。

发表评论