自定义并发队列实现读写锁的死锁问题记录

1,667 阅读4分钟

在进行iOS司机端崩溃治理时,发现司机端账号中心里面相关模型比如司机信息模型,里面的多个属性,比如phone_nocity_id等多个字段,都存在多线程安全问题,即在主线程更新值,在子线程持续访问的野指针崩溃问题。

因为涉及到的字段相对较多,当时为了保证后面其他的字段也能规避多线程安全问题,决定利用并发队列,来对模型里面的属性,实现读写锁,来保证线程安全。

就类似如下:

image.png

然后提测阶段,测试性能压测出现了卡死被watchdog强杀的崩溃。根据崩溃日志,查看是在司机信息的location_city_name.getter这里卡死导致的。

image.png

二. 原因排查

这里我一直无法理解,按道理并行队列,来实现读写锁,保证get方法在当前线程执行,而set方法,会等之前队列里面所有的get都完成之后阻塞执行完成set方法,再去执行其他操作。

从理论上来讲,这里并不存在死锁等待的可能性。

因此我写了一个demo测试:

Demo如下:

从输出日志可以看出:

这里只输出了print("--------------Thread VC: (Thread.current)")而没有输出后面的print("--------------------------------------"), 也就证明这里存在死锁,导致后面的任务不会执行。

这是因为都是放在子线程里面进行操作,所以主线程依然是可以执行,因此UI事件也是可以响应的,因此我们点击,调用点击事件:

 // MARK: - Actions
    @objc
    func buttonTapClicked(button: UIButton) {
        self.testModel.update(phoneNo: "100999988888")
    }

这是我们发现主线程也陷入了等待中,从而卡死。

从这个例子里面我们主要是多次异步访问了模型里面的phoneNo属性,而这个属性的使用并行队列的读写锁,来保证phoneNo访问的线程安全。

因此我们进一步简化这个例子:

从简化的例子,我们依然可以观察到死锁,因为任务并没有执行完毕。

我们选择了其中的32线程,查看线程调用的堆栈,我们看到了

2   libdispatch.dylib                   0x0000000104cae96c _dispatch_thread_event_wait_slow + 56,
3   libdispatch.dylib                   0x0000000104cbfbe8 __DISPATCH_WAIT_FOR_QUEUE__ + 384,
4   libdispatch.dylib                   0x0000000104cbf520 _dispatch_sync_f_slow + 184,

这里_dispatch_thread_event_wait_slow说明任务正在等待线程来执行,之所以陷入等待,是因为线程池里面的64条线程全部被占用了,没有多余的线程来执行任务,那为什么线程池里面的线程会被全部占用呢。

这里需要知道一点是,自定义并发队列和全局并发队列都依赖于GCD线程池的线程去执行任务,而且所有并发队列,最多只能创建64条线程,如果这64条线程,全部被占用,那么并发队列里面的任务,就需要等待线程,直到线程空闲。

具体详见: GCD的串行队列、并发队列、全局并发队列创建线程数

那为什么并发队列的 64 条都被占用了,而且没有释放呢?

首先我们看下循环之前的线程分布:

我们可以看到,在调用全局队列进行读写操作之前,GCD线程池里面已经存储了2、3、4、5、7、8、9这几条子线程。

然后我们看下打印日志的输出:

这里增加了current thread的日志打印。

  • 首先for循环执行1000DispatchQueue.global(qos: .default).async任务,这时候默认优先级全局队列会去GCD线程池获取线程来执行block里面的任务,由于GCD线程池,之前就存在几条线程,因此可以直接获取,去执行block里面的任务,这几条线程称为第一批。

  • 第一批线程执行完打印操作和driverInfoQueue.syncget操作后,执行到了driverInfoQueue.async(flags: .barrier)

  • 因为driverInfoQueue.async(flags: .barrier)会等到队列前面所有任务执行完毕之后,才能执行,而这时候for循环的DispatchQueue.global(qos: .default).async的其他线程也已经创建,并执行了打印操作,执行到了driverInfoQueue.sync这里。

  • 也就是说这时候driverInfoQueue.async(flags: .barrier)任务是先进队列的,而后面的driverInfoQueue.sync是后面进的队列,而这里的driverInfoQueue.sync任务又占满了线程池的其他线程,导致线程池64条线程全部占满。

  • 这时候driverInfoQueue.async(flags: .barrier)任务虽然在队列前面,但确一直没有线程来执行只能等待。而后面的driverInfoQueue.sync任务,由于barrier的阻塞作用,也一直处于等待中。

三. 解决方案

  • 改变加锁方案,针对DriverInfoDriverTokenInfoDriverPreferenceInfo里面的属性比如accessTokenphone_nolocation_city_id等多个字段,存在多线程安全问题的字段,使用各自独立的NSLock锁来解决。