Swift 中的多线程

/ Mac / 没有评论 / 1322浏览

Swift 中的多线程

提到多线程,无非就是关注二点,一是线程安全问题,二是在合适的地方合适的使用多线程(这个就有点广泛了,但是很重要不能为了去使用而使用)。

先看下OC中定义属性的关键字atomic/nonatomic,原子属性和非原子属性(此处先不谈内存相关的知识),有啥区别呢?property申明的属性会默认实现setter和get方法,而atomic默认会给setter方法加锁。也就是atomic线程安全,防止数据在被不同线程读写时发生了错误,设置属性时默认就是nonatomic。万能的苹果开发大神为啥不都设置为线程安全呢,主要是因为atomic很耗内存资源,相比这点安全远不能弥补在内存资源和运行速度上的缺陷,所以需要我们在开发时,适当的时候加锁。

说到加锁(我自己了解的也不是很多),只能说说最基本的。先看我们在OC中创建单利时,用到的关键字synchronized('加锁对象'){'加锁代码'},互斥锁,简单粗暴,不需要释放啥的,在不考虑效率下还是很方便(创建个单利能耗啥效率了,如果在swift中使用那么就是objc_sync_enter('加锁对象')objc_sync_exit('加锁对象')。还有就是NSLock,这个或许偶尔会用到,对象锁,lock和unlock需要成对出现,不能频繁调用lock不然可能会出现线程锁死

let lock = NSLock
lock.lock()
defer {
     lock.unlock()
 }

只是简单说下锁(至于NSRecursiveLock,NSConditionLock等各种锁的我也不是很熟,应该说是没有用过),如果多线程出现问(愿你写的多线程永远不出现线程安全问题,但是需要谨记一句:线程复杂必死),至少知道有解决的方法,在使用多线程稍微注意下。关于线程锁和线程发生锁死,我也只是略懂,以后再总结。除了加锁还可以用其他方法避免线程问题,比如:串行队列(FMDB线程安全就是这种方式),合适的使用信号量DispatchSemaphore。

我自己也是在开发中遇到二次线程安全问题,一次在使用CoreData,还有一次就是在多线程上传图片的时候,都是泪。CoreData,最后我是使用了串行队列解决线程安全问题,CoreData是有安全队列方法的,但是呢那个是接手的项目,别人写好了,我就在原来的基础上修改的。多线程上传图片出现问题,完全是自己不够细心导致的,过了好久才意识到的问题,先说下,OC中的系统对象基本都是线程不安全的,都是操作指针,swift中的系统对象基本都是线程安全的,都是直接读取内存。即使对象是线程安全的,但是也不能在不同的线程操作同一个对象,应该先copy在再操作,就是直接用=(OC是不行的)。

说了一大堆线程相关的废话,那再看看怎样在Swift 中使用多线程,其实最常用的也就是GCD和Operation,如果说对线程安全,GCD,Operation,队列和线程关系不了解,那就去网上找资料补一补。

最常用的API,主线程延迟执行:

DispatchQueue.main.asyncAfter(deadline: DispatchTime) {}

获取主线程(一个应用就一个主程):

DispatchQueue.main.async {}

再就是GCD创建队列了,分为串型队列和并发队列(串型队列只会开一个子线程,并发队列会创建多个子线程)

///   并发队列
let queue = DispatchQueue.global(qos: DispatchQoS.QoSClass.background)
///  串行队列
let queue = DispatchQueue(label: "test.queue")

接着就是异步/同步(是否阻塞当前线程的区别,注意下线程相互锁死问题)执行任务的API了,任务放到队列里面去,由队列决定是否开辟新的线程执行任务,强调一点,任何UI相关的任务不能放到子线程中执行,子线程是不能刷新UI的,如果你看到你的子线程刷新了UI那是你的眼晴欺骗了你,原因是UIKit框架不是线程安全,可能会出现资源争夺,所以只能在主线程中绘制。只是简单的介绍下比较常用的API,相对于OC的写法已经简单很多了。具体的区别和参数可以参考官方的文档,貌似还很复杂。

接下来就可以愉快的异步线程执行耗时任务了,执行完再回到主线程中,该干嘛就干嘛了,简单粗暴。但是还是有问题需要我们去注意:

很明显,有些问题GCD可能不太容易解决。

Operation 和OperationQueue,基于GCD封装的对象,支持KVO,处理多线程的API更方便,如果你看过其他第三方框架,可以经常看到这些对象,GCD反而用的比较少。最开始出来找工作时,其实我很不喜欢别人在没有给应用场景时问我什么时候使用GCD,什么时候使用OperationQueue有种被套路的感觉(在合适的地方合理的去使用合理的多线程),就现在而言,如果简单不复杂首选GCD,当然想用OperationQueue去封装,也完全赞同,主要是看业务需求,而不是看技术需求。关于GCD和OperationQueue的区别以及性能,具体还是推荐看网上的博客,最好是先了解里面处理多线程的API,知道各自的优缺点,再去看会有不一样的收获。Operation是一个抽象类不能直接使用,可以自定义其子类,或者直接使用系统提供的子类BlockOperation(swift中系统提供的子类貌似就剩这么一个了)。Operation直接使用是没有开辟新的线程的,只有放到OperationQueue 中才是多线程(OperationQueue可以直接使用,复杂场景时会使用到Operation的子类)。OperationQueue使用的优点会在下面解决问题中提到部分。

再看GCD中的队列组DispatchGroup,也可以对队列做简单的管理。主要将某一个任务入组和出组,实现入组和出组主要用到二种方式,那么就可以监听入组的任务是否全部完成。

let groupQueue = DispatchGroup()
let queue = DispatchQueue.global(qos: DispatchQoS.QoSClass.background)
defer {
  groupQueue.notify(queue: DispatchQueue.main) {
    debugPrint("执行完成\(Thread.current)")
  }
}
/// 第一种直接创建的时候就加到组中,执行完成自动出组
queue.async(group: groupQueue) {
  for i in 0 ... 100 {
    debugPrint(Thread.current , i , "------1")
  }
}
/// 第二种自己执行前加入,完成后手动出组
groupQueue.enter()
queue.async() {
  for i in 0 ... 100 {
    debugPrint(Thread.current , i ,  "------2")
  }
  groupQueue.leave()
}

二种方式只有选择的差距,第一种比较简单,但是适用的场景就比较简单,但是缺点也很明显,当执行的任务也存在异步,那么就不能判断是否真的完成任务了,很简单的例子,同时发送5个请求,要数据全部返回才能执行回调,那么很明显就只能使用第二种方式了,只有数据真正的返回了才出组。


先看第一个问题,比较简单,怎样知道队列中的任务全部执行完成? 使用GCD就很简单了,将任务放到队列组中去执行,完成后自动回调,当然也可以使用OperationQueue,那就是要使用KVO了监听OperationQueue 的maxConcurrentOperationCount属性,当为0的时候任务全部执行完成。

第二个问题,怎样控制执行的先后顺序?如果使用OperationQueue比较容易,方法比较多,举二个例子。看参数就知道,直到ops中的任务执行完成再接着执行其他的任务,如果每个任务都有严格的顺序,那么直接设置OperationQueue的maxConcurrentOperationCount为1就行了,为1时候其实也就是串行队列了。

func addOperations(_ ops: [Operation], waitUntilFinished wait: Bool)

第二种方式就是添加依耐关系

let operation1 = BlockOperation {
    debugPrint("开始1", Thread.current)
    sleep(2)
    debugPrint("完成 ")
}
let operation2 = BlockOperation {
    debugPrint("开始2")
    sleep(2)
    debugPrint("完成")
}
operation2.addDependency(operation1)
operationQueue.addOperation(operation1)
operationQueue.addOperation(operation2)

如果使用GCD的话,那么就需要使用栅栏函数了,这个可以具体看看官方的文档或者网上优秀博客,这就举一个简单的列子

/// 栅栏函数要求队列参数为. concurrent 
let queue = DispatchQueue(label: "text.queue", attributes:    .concurrent)
queue.async {
    for i in 0 ... 10 {
        debugPrint(i , Thread.current , "------1")
    }
}
queue.async {
    for i in 0 ... 10 {
        debugPrint(i , Thread.current , "-----2")
    }
}
/// 栅栏函数,前面的执行完,再执行栅栏函数里面的代码,执行完,再执行后面的任务
queue.async(flags: .barrier) {
    for i in 0 ... 10 {
        debugPrint(i , Thread.current , "------3")
    }
}
queue.async {
    for i in 0 ... 10 {
        debugPrint(i , Thread.current , "------4")
    }
}
queue.async {
    for i in 0 ... 10 {
        debugPrint(i , Thread.current , "------5")
    }
}

举的例子比较简单,遇到实际问题其实是很复杂的(强烈建议多看第三方框架,看看别人怎么封装的,多学习),考虑的问题比较多。

第三个问题,怎样控制并发数量?问题二中已经回答了设置OperationQueue的maxConcurrentOperationCount就可以控制并发量了。GCD中的话就可以使用信号量(DispatchSemaphore)去控制并发量了,用法也很简单。信号量还有很多其他的用法,比如将异步变成同步(这种骚操作不建议去使用信号量),资源的保护等等

let queue = DispatchQueue.global(qos:DispatchQoS.QoSClass.background)
/// 初始化信号量为2,最大并发为2,为0时会等待
let semap = DispatchSemaphore.init(value: 2)
semap.wait() // 信号量减1
queue.async {
    debugPrint("开始执行", Thread.current)
    sleep(2)
    debugPrint("执行完成" , Thread.current)
    semap.signal() // 信号量加1
}
semap.wait()
queue.async {
    debugPrint("开始执行", Thread.current)
    sleep(2)
    debugPrint("执行完成" , Thread.current)
    semap.signal()
}
semap.wait()
queue.async {
    debugPrint("开始执行", Thread.current)
    sleep(2)
    debugPrint("执行完成" , Thread.current)
    semap.signal()
}

第四个问题,怎样暂停/挂起任务?GCD可以但是相当麻烦,OperationQueue就很简单了,可以挂起,暂停所有的任务,也可以取消所有任务,在控制任务执行上,还是很方便的。这个可以参考下Alamofire中的设计,可以避免双重回调嵌套,在此不仔细详解,大概提下Alamofire设计的思路,有个方法是json序列化的,在我们最后获取数据的回调block中,但是,调用这个方法时,是将任务放到了OperationQueue中并且设置了maxConcurrentOperationCount为1,并且设置isSuspended为true,什么时候执行呢,当后台数据返回成功时isSuspended置为false,开始执行序列化的代码,执行完后我们在回调中获取后台返回的数据,没有嵌套block。

整个总结比较简单,只是大概说了下可能会遇到的问题,提供下简单的解决思路,解决复杂的问题,还需要再去多尝试,多去了解相关的文档,了解每一个API甚至参数使用的场景,或许用不到,但是万一在解决bug时不小心找到灵感了呢,书到用时方恨少。但是还是想强调下:线程复杂必死。