iOS 写一个死锁检测

4,754 阅读6分钟

前言

最近看了字节的新文章 如何系统性治理 iOS 稳定性问题。里面提到了当检测到APP卡死时的归因问题,通过死锁检测,我们可以判断本次卡死是否是死锁引起。

原理看上去非常简单,就好像打开冰箱,把大象放进去一样。EE750ED1D46F419727CD8F3E423E532F.jpg

本着学习心态,去简单实现一下,同时记录一下本次遇到的问题。个人水平有限,代码仅供参考。

项目地址

卡顿检测

首先,我们回顾一下主流的卡顿检测方案。

  1. Ping主线程
    开一个子线程,定时把一个bool变量扔到主线程去设值,一旦检测上一次扔到主线程的变量没有恢复就可以判断为卡顿。实现可以参考KSCrash中的KSCrashMonitor_Deadlock.m

  2. 主线程Runloop检测
    实现案例有GCDFetchFeed中的SMLagMonitor.m。
    以及matrix
    等等

目标

假设我已经检测到了卡顿,现在我想检测一下,当前的卡顿是否是死锁引起的。

一步一步实现一下

一些底层线程库的api,平时用的比较少,所以参考了许多优秀源码的使用方式。这里会把所参考的文件也列出来,方便想了解的同学学习。

  1. 获取线程列表
    // 获取线程列表
    // 参考 KSMachineContext.c    ksmc_suspendEnvironment
    // 参考 SMCPUMonitor.m       updateCPU
    thread_act_array_t threads = NULL;
    mach_msg_type_number_t numThreads = 0;
    kern_return_t kr;
    const task_t thisTask = mach_task_self();
    if((kr = task_threads(thisTask, &threads, &numThreads)) != KERN_SUCCESS)
    {
        NSLog(@"task_threads: %s", mach_error_string(kr));
        return;
    }
  1. 获取线程信息
uintptr_t thread_self(void) {
    // 一个“反问”引发的内存反思:https://blog.csdn.net/killer1989/article/details/106674973
    thread_t thread_self = mach_thread_self();
    mach_port_deallocate(mach_task_self(), thread_self);
    return thread_self;
}
    // 获取线程信息
    // 参考 KSThread.c    ksthread_getQueueName
    // 参考 SMCallStack.m   smStackOfThread
    const thread_t thisThread = (thread_t)thread_self();
    for(mach_msg_type_number_t i = 0; i < numThreads; i++) {
        if (threads[i] == thisThread) {
            continue;
        }
        // 线程基本信息 名称 cpu占用等
        thread_extended_info_data_t threadInfoData;
        mach_msg_type_number_t threadInfoCount = THREAD_EXTENDED_INFO_COUNT;
    if (thread_info((thread_act_t)threads[i], THREAD_EXTENDED_INFO, (thread_info_t)&threadInfoData, &threadInfoCount) == KERN_SUCCESS){
        integer_t cpu_usage = threadInfoData.pth_cpu_usage;
        integer_t run_state = threadInfoData.pth_run_state;
        integer_t flags = threadInfoData.pth_flags;
        char *pth_name = threadInfoData.pth_name;
    }

到这里,我们就可以遍历到每个线程的cpu使用率,线程状态,标识,线程名称。这里注意thread_info函数还可以获取其他线程信息,需要传入对应THREAD_EXTENDED_INFO_COUNT和THREAD_EXTENDED_INFO和对应的线程信息结构体。后面获取线程id时就用到了。这里我选择使用thread_extended_info_data_t是因为这个结构体里有线程名称,不用再通过其他api再获取一次。

  1. 死循环判断

先来个简单的,根据字节文中的方案,判断死循环。

//另外一种,特征有所区别,主线程的 CPU 占用一直很高 ,处于运行的状态,那么就应该怀疑主线程是否存在一些死循环等 CPU 密集型的任务。
if ((run_state & TH_STATE_RUNNING) && cpu_usage > 800) {
    //怀疑死循环
    //参考 [SMCPUMonitor updateCPU]
    NSLog(@"怀疑死循环:%@",threadDesc);
}

4.怀疑死锁

//我们主要的分析思路有两种:
//第一种,如果看到主线程的 CPU 占用为 0,当前处于等待的状态,已经被换出,那我们就有理由怀疑当前这次卡死可能是因为死锁导致的
if ((run_state & TH_STATE_WAITING) && (flags & TH_FLAGS_SWAPPED) && cpu_usage == 0){
    //怀疑死锁
}

5.获取线程指令

//怀疑死锁
//我们可以在卡死时获取到所有线程的状态并且筛选出所有处于等待状态的线程,再获取每个线程当前的 PC 地址,也就是正在执行的方法,并通过符号化判断它是否是一个锁等待的方法。
// 参考 SMCallStack.m   smStackOfThread
// 参考 KSStackCursor
_STRUCT_MCONTEXT machineContext;
//通过 thread_get_state 获取完整的 machineContext 信息,包含 thread 状态信息
mach_msg_type_number_t state_count = smThreadStateCountByCPU();
kern_return_t kr = thread_get_state(threads[i], smThreadStateByCPU(), (thread_state_t)&machineContext.__ss, &state_count);
if (kr != KERN_SUCCESS) {
    NSLog(@"Fail get thread: %u", threads[i]);
    continue;
}
//通过指令指针来获取当前指令地址
const uintptr_t instructionAddress = smMachInstructionPointerByCPU(&machineContext);
Dl_info info;
dladdr((void *)instructionAddress, &info);
NSLog(@"指令是啥----------%s %s",info.dli_sname,info.dli_fname);

这部分代码可以参考KSCrash和SMCallStack中获取当前线程pc寄存器指令,回溯栈帧,并符号化指令地址的代码。两者实现方式是一样的,SMCallStack.m中代码注释更多,更易懂一些。KSCrash中则用了游标的方式一步一步回溯。
这里我有一个疑问,为什么两者都自己实现的符号化,而不用系统的dladdr?是因为APP审核问题吗?

  1. 判断指令
if (strcmp(info.dli_sname, "__psynch_mutexwait") == 0) {
//  __psynch_mutexwait /usr/lib/system/libsystem_kernel.dylib
    //这是一个正在等待锁的线程
}

每个锁等待的方法都会定义一个参数,传入当前锁等待的信息。

那我怎么知道__psynch_mutexwait方法的参数是什么,看上去这个函数是定义在libsystem_kernel.dylib中,这不像是一个开源的库。

我尝试在其他开源库中查找,确实有。 苹果开源库列表

// 参考  libpthread-454.80.2
//  extern uint32_t __psynch_mutexwait(pthread_mutex_t *mutex,  uint32_t mgen, uint32_t  ugen, uint64_t tid, uint32_t flags);

OK,是第一个参数。按照c语言函数调用约定,arm架构下,第一个参数放在x0寄存器。照着上面获取pc寄存器的方法,获取参数

uintptr_t firstParamRegister(mcontext_t const machineContext) {
#if defined(__arm64__)
    return machineContext->__ss.__x[0];
#elif defined(__arm__)
    return machineContext->__ss.__x[0];
#elif defined(__x86_64__)
    return machineContext->__ss.__rdi;
#endif
}

uintptr_t firstParam = firstParamRegister(&machineContext);

我们可以从寄存器中读取到这些锁等待信息,强转为对应的结构体

查看pthread_mutex_t的定义是这样的

#ifndef _PTHREAD_MUTEX_T
#define _PTHREAD_MUTEX_T
#include <sys/_pthread/_pthread_types.h> /* __darwin_pthread_mutex_t */
typedef __darwin_pthread_mutex_t pthread_mutex_t;
#endif /*_PTHREAD_MUTEX_T */

A6BE41503A921F548710D312BC4630A9.png

继续查阅源码发现types_internal.h中有定义pthread_mutex_t其实就是pthread_mutex_s。
OK,把pthread_mutex_s的定义拷贝过来,强转。

typedef os_unfair_lock _pthread_lock;

struct pthread_mutex_options_s {
    uint32_t
        protocol:2,
        type:2,
        pshared:2,
        policy:3,
        hold:2,
        misalign:1,
        notify:1,
        mutex:1,
        ulock:1,
        unused:1,
        lock_count:16;
};

typedef struct _pthread_mutex_ulock_s {
    uint32_t uval;
} *_pthread_mutex_ulock_t;

struct pthread_mutex_s {
    long sig;
    _pthread_lock lock;
    union {
        uint32_t value;
        struct pthread_mutex_options_s options;
    } mtxopts;
    int16_t prioceiling;
    int16_t priority;
#if defined(__LP64__)
    uint32_t _pad;
#endif
    union {
        struct {
            uint32_t m_tid[2]; // thread id of thread that has mutex locked
            uint32_t m_seq[2]; // mutex sequence id
            uint32_t m_mis[2]; // for misaligned locks m_tid/m_seq will span into here
        } psynch;
        struct _pthread_mutex_ulock_s ulock;
    };
#if defined(__LP64__)
    uint32_t _reserved[4];
#else
    uint32_t _reserved[1];
#endif
};

强转!
struct pthread_mutex_s *mutex = (struct pthread_mutex_s *)firstParam;
uint32_t *tid = mutex->psynch.m_tid;
uint64_t hold_lock_thread_id = *tid;
NSLog(@"谁持有了?------>%d", *tid);

其实从结构体的定义中,可以看到 联合体中就有我想要的东西了:谁在持有这个锁

uint32_t m_tid[2]; // thread id of thread that has mutex locked

那么,线程Id又是嘛呢

9D2E5102-1CA1-4B4C-A052-12416F4B42AA.png
我写了AB两个线程,分别持有AB锁,又同时请求对方的锁。线程id可以从图中看到。

那么如何获取线程id呢。这里可以参考上面thread_info函数的其他结构体。

struct thread_identifier_info {
	uint64_t        thread_id;      /* system-wide unique 64-bit thread id */
	uint64_t        thread_handle;  /* handle to be used by libproc */
	uint64_t        dispatch_qaddr; /* libdispatch queue address */
};

获取一下
thread_identifier_info_data_t threadIDData;
mach_msg_type_number_t threadIDDataCount = THREAD_IDENTIFIER_INFO_COUNT;
if(thread_info((thread_act_t)threads[i], THREAD_IDENTIFIER_INFO, (thread_info_t)&threadIDData, &threadIDDataCount) == KERN_SUCCESS){
    uint64_t thread_id = threadIDData.thread_id;
}

通过这个结构体,我可以获取到线程id以及队列地址。在上面获取线程信息时,很多线程的线程名称是空的,我甚至无法分辨哪个线程是主线程(有其他api可以判断),所以这里可以通过队列名称来提供辅助信息。

int queueNameLen = 128;
char queueName[queueNameLen];
bool getQueueNameSuccess = ksthread_getQueueName((thread_t)threads[i], queueName, queueNameLen);

这里获取线程的队列名称,我直接使用了KSCrash的ksthread_getQueueName方法,一个原因是我没法在arc环境下去强转队列结构体指针,另一个是因为优秀的源码中有很多地址安全性检查,所以直接用现成的,不单独再写了。感恩优秀的源码。

到这里为止,我已经知道了。

  • 谁在等锁
  • 等的锁归谁
  1. 判断是否形成死锁
// 保存线程描述信息
    NSMutableDictionary<NSNumber *,NSString *> *threadDescDic = [NSMutableDictionary dictionary];
    NSMutableDictionary<NSNumber *,NSMutableArray<NSNumber *> *> *threadWaitDic = [NSMutableDictionary dictionary];
    
    
    NSString *threadDesc = [NSString stringWithFormat:@"[%llu %s %s ] [run_state: %d] [flags : %d] [cpu_usage : %d]",thread_id,pth_name,getQueueNameSuccess ? queueName : "",run_state,flags,cpu_usage];
    threadDescDic[@(thread_id)] = threadDesc;
    
//当发现一个锁等待信息,保存
NSMutableArray *array = threadWaitDic[@(hold_lock_thread_id)];
if (!array) {
    array = [NSMutableArray array];
}
[array addObject:@(thread_id)];
threadWaitDic[@(hold_lock_thread_id)] = array;

这边我用两个字典去保存所有线程的信息,以及等待锁的线程id。

/// 判断是否死锁
/// @param threadDescDic 线程描述信息
/// @param threadWaitDic 线程等待信息
+ (void)checkIfIsCircleWithThreadDescDic:(NSMutableDictionary<NSNumber *,NSString *> *)threadDescDic threadWaitDic:(NSMutableDictionary<NSNumber *,NSMutableArray<NSNumber *> *> *)threadWaitDic {
    __block BOOL hasCircle = NO;
    NSMutableDictionary<NSNumber *,NSNumber *> *visited = [NSMutableDictionary dictionary];
    NSMutableArray *path = [NSMutableArray array];
    [threadWaitDic enumerateKeysAndObjectsUsingBlock:^(NSNumber * _Nonnull hold_lock_thread_id, NSMutableArray<NSNumber *> * _Nonnull waitArray, BOOL * _Nonnull stop) {
        [self checkThreadID:hold_lock_thread_id withThreadDescDic:threadDescDic threadWaitDic:threadWaitDic visited:visited path:path hasCircle:&hasCircle];
        if (hasCircle) {
            *stop = YES;
        }
    }];
    
    if (hasCircle) {
        NSLog(@"发现死锁如下:");
        for (NSNumber *threadID in path) {
            NSLog(@"%@",threadDescDic[threadID]);
        }
    }else {
        NSLog(@"未发现死锁");
    }
}

+ (void)checkThreadID:(NSNumber *)threadID withThreadDescDic:(NSMutableDictionary<NSNumber *,NSString *> *)threadDescDic threadWaitDic:(NSMutableDictionary<NSNumber *,NSMutableArray<NSNumber *> *> *)threadWaitDic visited:(NSMutableDictionary<NSNumber *,NSNumber *> *)visited path:(NSMutableArray *)path hasCircle:(BOOL *)hasCircle {
    if (visited[threadID]) {
        *hasCircle = YES;
        NSUInteger index = [path indexOfObject:threadID];
        path = [[path subarrayWithRange:NSMakeRange(index, path.count - index)] mutableCopy];
    }
    if (*hasCircle) {
        return;
    }
    
    visited[threadID] = @1;
    [path addObject:threadID];
    NSMutableArray *array = threadWaitDic[threadID];
    if (array.count) {
        for (NSNumber *next in array) {
            [self checkThreadID:next withThreadDescDic:threadDescDic threadWaitDic:threadWaitDic visited:visited path:path hasCircle:hasCircle];
        }
    }
    [visited removeObjectForKey:threadID];
}

用一个检查有向图是否有环的算法,检查是否形成死锁。

123.png


//其他锁情况 TODO
//__psynch_rw_rdlock   ReadWrite lock
//__psynch_rw_wrlock   ReadWrite lock
//__ulock_wait         UnfariLock lock
//_kevent_id           GCD lock

因为时间有限,其他锁的检测未实现。我感觉应该大概差不多吧😅,需要去翻一下libdispatch的源码等。

整个实现过程还是比较有趣的。

最后,感恩官方的和大佬们的开源代码。

2222.jpeg