记一个App卡死问题的解决过程

问题背景:

先介绍下项目,我做的是一个摄像头的App,同时有多个线程在处理接收数据、解码、渲染等逻辑,而且调试过程中打了很多日志。 之前使用的是NSLogger来记录并传输log到mac 端展示,但后面发现这个框架有些问题,经常存在连不上的问题,而且网络故障时,日志全都丢失了。因此后面改成了用dup2重定向stderr和stdout到指定管道的方式把日志全部写到本地。这个方案一开始没有什么问题,但随着日志越来越多, 在播放过程中就经常会出现卡死的现象,点击屏幕任何位置都没有反应,卡死时CPU使用率为0。导出日志也发现不了什么问题。

解决过程:

首先,从CPU的使用率上来看,应该不是死循环或者是什么耗时任务导致的卡死,比较像是死锁导致的,那么如何排查死锁问题呢? 我在网上找到了一个文章https://juejin.cn/post/7037454684453339144

这里思路就是单独开辟一个线程,每隔3秒钟去dump所有的线程,并逐个检查其cpu使用率和运行状态,如果cpu使用率大于80%且为运行中就大概率是死循环了,如果是cpu使用率为0,并且栈顶指令是__psynch_mutexwait ,则是死锁。

通过这种方法证明确实是死锁了,调用栈如下:

20230830-174614.jpeg
20230830-174636.jpeg

从调用栈可以看出是printf和debugPrint导致的,这两个函数都是打印日志的,因此我断定是写日志引发的。但问题来了,为啥在控制台打印日志的时候没问题,写到本地却有问题呢?

对于这个问题,我没有找到明确的答案,但是大致可以推断是因为把日志写到本地文件时,系统为了避免多线程竞争导致脏数据而采用了加锁的方式,而打印到控制台则不存在这个问题。

而且像NSLogger也是采用dup2的方式去重定向日志,却没有发生卡死,问题是在哪呢?

问题出在哪里?

带着这个疑问,我试着注释掉部分代码来排查问题。最开始我是这样写的:

- (void)redirectNotificationHandle:(NSNotification *)nf{
    NSData *data = [[nf userInfo] objectForKey:NSFileHandleNotificationDataItem];
    NSString *output = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
    [self saveLogMessage:output];
    [[nf object] readInBackgroundAndNotify];
}
//这个方法当多个线程同时写日志时会造成死锁,不推荐使用
- (void)redirectSTD:(int )fd{
    NSPipe * pipe = [NSPipe pipe] ;
    NSFileHandle *pipeReadHandle = [pipe fileHandleForReading] ;
    dup2([[pipe fileHandleForWriting] fileDescriptor], fd) ;

    // 打开文件
    NSString *documentpath = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) lastObject];
    NSString *logDir = [documentpath stringByAppendingFormat:@"/logs"];

    NSString *fileName = [NSString stringWithFormat:@"printf_%@.log",[NSDate date]];
    NSString *logFilePath = [logDir stringByAppendingPathComponent:fileName];

    NSFileHandle *fileHandle = [NSFileHandle fileHandleForWritingAtPath:logFilePath];
    if (fileHandle == nil) {
        [[NSFileManager defaultManager] createFileAtPath:logFilePath contents:nil attributes:nil];
        fileHandle = [NSFileHandle fileHandleForWritingAtPath:logFilePath];
    }
    self.fileHandle = fileHandle;

    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(redirectNotificationHandle:) name:NSFileHandleReadCompletionNotification object:pipeReadHandle] ;
    [pipeReadHandle readInBackgroundAndNotify];
}

- (void)saveLogMessage:(NSString *)message {
    if (!self.dateFormatter) {
        self.dateFormatter = [[NSDateFormatter alloc] init];
        [self.dateFormatter setDateFormat:@"yyyy-MM-dd HH:mm:ss.SSS"];
    }

    NSFileHandle* fileHandler = self.fileHandler;    
    NSString *currentTime = [self.dateFormatter stringFromDate:[NSDate date]];
    if (message.length > 0) {
        NSArray *lines = [message componentsSeparatedByString:@"\n"];
        for (NSString *line in lines) {
            NSString *newLine = [NSString stringWithFormat:@"%@ %@", currentTime, line];
            [fileHandler writeData:[newLine dataUsingEncoding:NSUTF8StringEncoding]];
            [fileHandler writeData:[@"\n" dataUsingEncoding:NSUTF8StringEncoding]];
        }
    }
}

我尝试把saveLogMessage这个函数的内容给注释掉,发现还是会卡死,因此问题不在写文件这里。然后我再注释掉redirectNotificationHandle 这个函数里面的所有代码,出现了崩溃,原因是readInBackgroundAndNotify 这句必须要调用。加上这一句之后没有崩溃了,但是还是会卡死。 然后我再把NSFileHandleReadCompletionNotification 添加监听的代码也注释了,这才没有卡死。因此可以断定问题出在NSFileHandleReadCompletionNotification 这个通知的发送上。由于没有找到相关源码,我无法进一步的探究原因,但从表象来看,不管在哪个线程打日志,一定是在主线程接收这个通知,我推测应该是发送这个通知时内部做了加锁来控制日志的顺序。如果有熟悉这块的朋友,可以一起探讨下。

那如何解决这个问题呢?

1. 最简单的方式就是关闭写日志到本地,但这样在排查问题时极为不便,因此 pass了。

2. 减少日志,仅保留必要的日志,这个也无法从根本上解决卡死的问题,还是会有概率发生,因此也pass了。

3. 不采用dup2重定向标准输出流,而是自己定义一些宏,来替代系统的NSlog和printf之类的函数,这个方案可行,也能从根本上避免重定向导致的卡死,但工作量偏大,不是最优解。

4. 看看NSlogger、cocoalumberjack之类的第三方库是如何解决这个问题的,借鉴一下。

最终我是决定借鉴NSlogger, 因为这个库之前用了一段时间,没发现过卡死的现象,而cocoalumberjack无法捕获到printf的日志(网上说是可以,但我实践下来发现不行,可能是用法不对吧)。

在loggerClient.m里面我们可以看到他是如何处理日志重定向的,这里只摘出核心代码分析

static void LoggerStartConsoleRedirection()
{
    // protected by `consoleGrabbersMutex`

    // keep the original pipes so we can still forward everything
    // (i.e. to the running IDE that needs to display or interpret console messages)
    // and remember the SIGPIPE settings, as we are going to clear them to prevent
    // the app from exiting when we close the pipes
    if (sSTDOUT == -1)
    {
        sSTDOUThadSIGPIPE = fcntl(STDOUT_FILENO, F_GETNOSIGPIPE);
        sSTDOUT = dup(STDOUT_FILENO);
        sSTDERRhadSIGPIPE = fcntl(STDERR_FILENO, F_GETNOSIGPIPE);
        sSTDERR = dup(STDERR_FILENO);
    }

    // create the pipes
    if (sConsolePipes[0] == -1)
    {
        if (pipe(sConsolePipes) != -1)
        {
            fcntl(sConsolePipes[0], F_SETNOSIGPIPE, 1);
            fcntl(sConsolePipes[1], F_SETNOSIGPIPE, 1);
            dup2(sConsolePipes[1], STDOUT_FILENO);
        }
    }

    if (sConsolePipes[2] == -1)
    {
        if (pipe(&sConsolePipes[2]) != -1)
        {
            fcntl(sConsolePipes[0], F_SETNOSIGPIPE, 1);
            fcntl(sConsolePipes[1], F_SETNOSIGPIPE, 1);
            dup2(sConsolePipes[3], STDERR_FILENO);
        }
    }

    pthread_create(&consoleGrabThread, NULL, &LoggerConsoleGrabThread, NULL);
}

static void *LoggerConsoleGrabThread(void *context)
{
#pragma unused (context)
    pthread_mutex_lock(&consoleGrabbersMutex);

    int fdout = sConsolePipes[0];
    fcntl(fdout, F_SETFL, fcntl(fdout, F_GETFL, 0) | O_NONBLOCK);

    int fderr = sConsolePipes[2];
    fcntl(fderr, F_SETFL, fcntl(fderr, F_GETFL, 0) | O_NONBLOCK);

    CFMutableDataRef stdoutData = CFDataCreateMutable(NULL, 0);
    CFMutableDataRef stderrData = CFDataCreateMutable(NULL, 0);

    unsigned activeGrabbers = numActiveConsoleGrabbers;

    pthread_mutex_unlock(&consoleGrabbersMutex);

    while (activeGrabbers != 0)
    {
        fd_set set;
        FD_ZERO(&set);
        FD_SET(fdout, &set);
        FD_SET(fderr, &set);

        int ret = select(fderr + 1, &set, NULL, NULL, NULL);

        if (ret <= 0)
        {
            // ==0: time expired without activity
            // < 0: error occurred
            break;
        }

        pthread_mutex_lock(&consoleGrabbersMutex);

        activeGrabbers = numActiveConsoleGrabbers;
        if (activeGrabbers != 0)
        {
            if (FD_ISSET(fdout, &set))
                LoggerLogFromConsole(CFSTR("stdout"), fdout, sSTDOUT, stdoutData);
            if (FD_ISSET(fderr, &set ))
                LoggerLogFromConsole(CFSTR("stderr"), fderr, sSTDERR, stderrData);
        }

        pthread_mutex_unlock(&consoleGrabbersMutex);
    }

    CFRelease(stdoutData);
    CFRelease(stderrData);
    return NULL;
}

从NSLogger的源码可以看出,他也是使用了dup2进行重定向,只是前面加了fcntl 来避免捕获sigpipe的错误信号,另外就是开辟了一个单独的线程,在这个线程里面使用while循环+select语句来定时轮询stderr和stdout文件描述符,如果有变化就从前面前面建立的管道里读出日志来。

仿照他的写法,我也实现了一个类似的方法,发现还确实不会卡死,而且读到的日志也是完整的。顺便提一嘴,他这里为了避免重定向影响到原有的控制台输出,还专门保留了之前的文件描述符,在后面处理日志的时候再次调用输出到控制台,具体代码就不贴了,感兴趣的可以去看下LoggerLogFromConsole这个函数的实现。

到这里,问题就基本解决了,但是为了避免后来人踩坑,我还是提一下。在实现LoggerStartGrabbingConsole这个方法的时候,我尝试不使用他提供的Logger结构体来给consoleGrabbersList的元素赋值,但是却出现了卡死,卡死的调用栈跟前面一摸一样。代码如下:

void MGLoggerStartGrabbingConsole(Logger *logger)
//void MGLoggerStartGrabbingConsole()
{
    pthread_mutex_lock(&consoleGrabbersMutex);

    Boolean added = FALSE;
    for (unsigned i = 0; i < numActiveConsoleGrabbers; i++)
    {
        if (consoleGrabbersList[i] == NULL)
        {
            consoleGrabbersList[i] = logger;
            numActiveConsoleGrabbers++;
            added = TRUE;
            break;
        }
    }
    if (!added)
    {        //下面这两行不能去掉
        consoleGrabbersList = realloc(consoleGrabbersList, ++consoleGrabbersListLength * sizeof(Logger *));
        consoleGrabbersList[numActiveConsoleGrabbers++] = logger;
    }

    MGLoggerStartConsoleRedirection(); // Start redirection if necessary

    pthread_mutex_unlock(&consoleGrabbersMutex);
}

这个问题我还没有查到原因,初步猜测应该是没有对象持有Logger对象,导致他被释放了,从而引起管道出现错误。有兴趣研究的朋友可以一起探讨下。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,591评论 6 501
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,448评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,823评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,204评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,228评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,190评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,078评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,923评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,334评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,550评论 2 333
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,727评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,428评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,022评论 3 326
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,672评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,826评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,734评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,619评论 2 354

推荐阅读更多精彩内容