難纏的Crash問題
本篇部落格的起源是由于收集到線上使用者産生的一些難纏的Crash問題,通過堆棧資訊觀察,Crash的堆棧資訊主要有兩類:
一類如下:
1 MJExtensionDemo 0x000000010903a5e0 main + 0,
2 MJExtension 0x000000010923f00d +[NSObject(MJClass) mj_setupBlockReturnValue:key:] + 333,
3 MJExtension 0x000000010923ec86 +[NSObject(MJClass) mj_setupIgnoredPropertyNames:] + 70,
4 MJExtensionTests 0x00000001095ebe1b -[MJExtensionTests testNestedModelArray] + 1467,
5 CoreFoundation 0x00007fff204272fc __invoking___ + 140,
6 CoreFoundation 0x00007fff204247b6 -[NSInvocation invoke] + 303,
一類如下:
1 MJExtensionDemo 0x000000010729e5e0 main + 0,
2 MJExtension 0x00000001074a3255 +[NSObject(MJClass) mj_totalObjectsWithSelector:key:] + 453,
3 MJExtension 0x00000001074a2ccf +[NSObject(MJClass) mj_totalIgnoredPropertyNames] + 47,
4 MJExtension 0x00000001074a3dcb -[NSObject(MJKeyValue) mj_setKeyValues:context:] + 443,
5 MJExtension 0x00000001074a3bdf -[NSObject(MJKeyValue) mj_setKeyValues:] + 79,
6 MJExtension 0x00000001074a6536 +[NSObject(MJKeyValue) mj_objectWithKeyValues:context:] + 710,
7 MJExtension 0x00000001074a623f +[NSObject(MJKeyValue) mj_objectWithKeyValues:] + 79,
此時使用的MJExtension版本為3.2.4,雖然堆棧資訊比較清楚,然而其最後的調用都是在MJExtension内部,且發生此Crash的幾率非常小(約為萬分之幾),定位和解決此Crash并不容易。
通過分析,發現此Crash有如下特點:
調用棧中最終定位到的函數都在MJExtension進行JSON轉對象或模型setup配置時。
隻有在多線程使用MJExtension方法時會出現此Crash。
是App在某次版本更新後才開始出現此類Crash。
通過分析上面的特點,可以推理出:
問題一定出在mj_objectWithKeyValues方法或mj_setup相關方法中。
此問題一定是由于業務的某種使用方式或場景的改變觸發的。
一定和多線程相關,推測和鎖可能相關。
問題的定位與複現
對于iOS端開發,定位和解決Crash畢竟兩個流程,首先是根據線索來分析和定位問題,得到一個大概的猜想,之後按照自己的猜想去提供外部條件,來嘗試複現問題,如果問題能夠成功複現并複原與線程問題相似的堆棧現場,則基本完成了90%的工作,剩下的10%才是修複此問題。
首先,根據前面我們對問題的分析和推理,可以從mj_objectWithKeyValues和mj_setup方法進行切入,通過對MJExtension代碼的Review,可以發現這些方法中有一個宏使用的非常頻繁,後來也證明問題确實出在這個宏的定義上:
這幾個宏的定義如下:
#ifndef MJ_LOCK
#define MJ_LOCK(lock) dispatch_semaphore_wait(lock, DISPATCH_TIME_FOREVER);
#endif
#ifndef MJ_UNLOCK
#define MJ_UNLOCK(lock) dispatch_semaphore_signal(lock);
// 信号量
#define MJExtensionSemaphoreCreate \
static dispatch_semaphore_t signalSemaphore; \
static dispatch_once_t onceTokenSemaphore; \
dispatch_once(&onceTokenSemaphore, ^{ \
signalSemaphore = dispatch_semaphore_create(1); \
});
#define MJExtensionSemaphoreWait MJ_LOCK(signalSemaphore)
#define MJExtensionSemaphoreSignal MJ_UNLOCK(signalSemaphore)
可以看到,這個宏的最終使用方式是通過信号量來實作鎖邏輯。問題出在static和宏定義本身,宏定義是做簡單的替換,是以在實際使用時,dispatch_semaphore_t信号量變量被定義成了局部靜态變量,局部靜态 變量有一個特點:其被建立後會被放入全局資料區,但是其受函數作用域的控制,即建立後不會銷毀,函數内永遠可用,但是對函數外來說是隐藏的。如果在不同的函數中使用了相同名稱的靜态局部變量,真正放入全局資料區的實際上是多個不同的變量。
我們可以通過檢視C檔案編譯後的.o可執行檔案來驗證局部靜态變量的這一特點:
測試代碼如下:
#include <stdio.h>
int main(int argc, const char * argv[]) {
static char *string = "hello";
return 0;
}
void func1() {
static char *string = "world";
檢視.o檔案的布局資訊如下:
可以看到,實際存儲的靜态變量名都被加上了函數字首。
到此,我們基本将問題定位到了,當多線程對MJExtension中的多個不同的函數進行調用時,如果這些函數中都有此加鎖邏輯,實際上這個鎖邏輯并沒有生效,會産生多線程資料讀寫Crash。要複現這個場景就非常簡單了:
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
for (int i = 0; i < 1000; i++) {
MJStatusResult *result = [MJStatusResult mj_objectWithKeyValues:dict];
}
for (int i = 0; i < 1000; i++) {
[MJStatus mj_setupIgnoredPropertyNames:^NSArray *{
return @[@"name"];
}];
通過場景複現,基本可以定位此問題原因。
幾個疑問的解答
1. 産生此Crash的核心原理
多線程鎖失效導緻的多線程讀寫異常。
2.為何版本更新後會出現
需要從業務使用上來分析,之前的版本類似mj_setup相關方法的調用會放入類的+load方法中,這個在main函數調用之前,所有類的解析配置都已完成,基本不會出現多線程問題,新版本做了冷啟動的優化,将mj_setup相關方法放入了+(void)initialize方法中,使得多線程問題被觸發的機率大大增加了。
MJExtension後續版本
截止到本篇部落格編寫時間,MJExtension最新版本3.2.5已經處理了這個鎖問題的Bug,其修複方式是将static修改為了extern,使這個信号量變量被聲明為了一個全局變量,如下:
extern dispatch_semaphore_t mje_signalSemaphore; \
extern dispatch_once_t mje_onceTokenSemaphore; \
dispatch_once(&mje_onceTokenSemaphore, ^{ \
mje_signalSemaphore = dispatch_semaphore_create(1); \
// .m檔案中
dispatch_semaphore_t mje_signalSemaphore;
dispatch_once_t mje_onceTokenSemaphore;
修改後的代碼保證了鎖的唯一性。
建議
使用MJExtension庫時,如果需要進行解析配置,優先使用複寫相關配置+方法來實作,例如:
// 不建議的使用方式
+ (void)initialize {
[self mj_setupObjectClassInArray:^NSDictionary *{
return @{
@"nicknames" : MJStatus.class
};
// 建議的使用方式
+ (NSDictionary *)mj_objectClassInArray {
return @{
@"nicknames" : @"MJStatus"
};
并且,在配置類型時,盡量使用NSString而不要使用Class,避免類過早的被加載。