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

问题背景:

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

解决过程:

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

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

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

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

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

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

问题出在哪里?

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

ini 复制代码
- (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里面我们可以看到他是如何处理日志重定向的,这里只摘出核心代码分析

scss 复制代码
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的元素赋值,但是却出现了卡死,卡死的调用栈跟前面一摸一样。代码如下:

ini 复制代码
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对象,导致他被释放了,从而引起管道出现错误。有兴趣研究的朋友可以一起探讨下。

相关推荐
sp_wxf6 分钟前
Stream流
linux·服务器·windows
LYK_HAHA24 分钟前
centos常用知识和命令
linux·运维·centos
可涵不会debug1 小时前
【Linux】信号知识三把斧——信号的产生、保存和处理
linux·运维·信号
jyan_敬言1 小时前
【Linux】Linux命令与操作详解(一)文件管理(文件命令)、用户与用户组管理(创建、删除用户/组)
linux·运维·服务器·c语言·开发语言·汇编·c++
unix2linux2 小时前
Parade Series - SHA256
linux·python·mysql·shell
学思之道2 小时前
给Linux操作系统命令取个别名
linux·运维·经验分享·学习方法
GOTXX3 小时前
应用层协议HTTP
linux·网络·网络协议·计算机网络·http·fiddler
dong_beijing3 小时前
GO语言工程构建示例-mac和linux适用
linux·运维·服务器
小小工匠3 小时前
系统安全 - Linux /Docker 安全模型及实践
linux·安全·系统安全
2201_761199044 小时前
nginx 负载均衡1
linux·运维·服务器·nginx·负载均衡