はじめに
最近、Foundation.Operation(NSOperation)クラスを使うことがあって動作を調べたのでPlaygroundの実行結果を添えておく。
基本的にNSOperationはdispatchQueueのようにタスクを先入れ先出し(first-in, first-out)にするが、タスクの実行順序を他の要因によって決められるという特徴があるのと、機能自体にキャンセルすることを前提に作られている。また、並列オペレーションはタスクの中で非同期実行を行ってその実行をもってタスクの終了とすることができる。自分は非並列オペレーションしか知らなかったのでそのために最小限の実装と動作を調べることになった。
先に結論
- NSOperationでタスクの実行順序を気にしつつ非同期実行が必要なら並列オペレーションを使う
- KVOを使うといっても自分で監視するのではなく変更を伝えることが必要
- NSOperationを使わなくてもいい場合のほうが多いので使わないにこしたことはない
NSOperationでタスクの実行順序を気にしつつ非同期実行が必要なら並列オペレーションを使う
具体例でNSOperationのタスク実行順序について説明する
まず最初に、自分が一般的だと思っていたNSOperationの使い方を説明すると、NSOperationを使う時はFoundation.Operationを継承したクラスでmainメソッドを実装し、そのインスタンスをキューとして、Foundation.OperationQueueクラスのインスタンスに対してキューを追加していく。その時、実行順序を変更するために、あるキューAに対して別のキューBがaddDepencencyメソッドを呼び出して登録すると、キューBの実行は元のキューAより後になる。
let myOperationA = MyOperationA()
let myOperationB = MyOperationB()
myOperationB.addDependency(myOperationA) // BはAに依存してる
let queue = OperationQueue()
queue.maxConcurrentOperationCount = 1
queue.addOperation(myOperationB) // Bを先に登録だー!!
queue.addOperation(myOperationA)
addOperationでBを先に登録しているのにもかかわらず、addDependencyメソッドによってAのmainのタスクより先にBのmainのタスクが実行されることになる。これが基本形であって問題ないパターン。
言い換えると、addDependencyメソッドを使わなければAのmainのタスクが実行される。
しかし、これはmainメソッドでやりたいタスクが同期的に実行されてmainメソッドの終わりイコールAでやりたかった処理の場合に限る。
下記に非同期処理だった場合の例を示す。
import Foundation
class MyOperationA: Operation {
override func main() {
print("A_0")
DispatchQueue.main.async {
// 本当に待ちたかったタスクが非同期処理しないといけない
print("A_1")
}
}
}
class MyOperationB: Operation {
override func main() {
// Aのあとにやりたいタスク
print("B_0")
}
}
printで出力される順序は "A_0", "B_0", "A_1"になる(はず)。
なぜかというと、BはAのタスクに依存しているので待つが、Aのmainメソッドを抜けたことでBのタスクを実行する。
そもそも 「DispatchQueue.main.async
なんて使ってるからじゃない...?」と思うでしょうが、これは例えなのでそうしていて、実際は AVAssetExportSession.exportAsynchronously
だったり非同期でないと実行完了が分からないメソッドがあったりする。
ようやく本題に入ると、このような「Queueで非同期メソッド実行したら本来やりたかったタスクが完了する前に次のキューが実行されてしまう問題」を解決する手段がNSOperationの並列オペレーションということ。雑に言うと並列オペレーションではタスクの終了を明示することが出来るため、非同期処理後に自前でタスク終了を呼び出せる。
NSOperationの並列オペレーション
並列オペレーションにするための最低限の方法はFoundation.Operationを継承するクラスで次の事をやる
- プロパティのオーバーライドで動作を変える
- var isConcurrent: Bool でtrueを返す
- var isExecuting: Bool で実行してるときにtrueを返す
- var isFinished: Bool で実行終了したらtrueを返す
- startメソッドをoverrideする
- super.startなどは呼び出さない
- 非同期処理後に自前でタスク終了をする場合に特定のメソッドを呼び出す
- この特定のメソッドによってOperationQueueにKVO通知する
- KVO通知するが自分で値を監視するわけではない
具体例
//: Playground - noun: a place where people can play
import UIKit
import Foundation
import PlaygroundSupport
PlaygroundPage.current.needsIndefiniteExecution = true
class ConcurrentOperation: Foundation.Operation {
// KVO通知時に現在の状態をoverrideしたisFinishedメソッドで返すためのenum
// そもそもisExecutingがtrueのときisFinishedもtrueはありえないので
enum Status {
case ready
case execute
case finished
}
// 終了をKVO通知するため willChangeValue と didChangeValue を呼び出す
var status: Status = .ready {
willSet {
willChangeValue(forKey: "isExecuting")
willChangeValue(forKey: "isFinished")
}
didSet {
didChangeValue(forKey: "isExecuting")
didChangeValue(forKey: "isFinished")
}
}
// MARK: - NSOperationを並列オペレーションで動作させる
// プロパティのオーバーライドで動作を変える
override var isConcurrent: Bool {
return true
}
// KVO通知することで動作し、実際に終了していることが判断される
override var isExecuting: Bool {
return status == .execute ? true : false
}
// KVO通知することで動作し、実際に終了していることが判断される
override var isFinished: Bool {
return status == .finished ? true : false
}
override func start() {
print(type(of: self), name!, #function)
status = .execute // 状態を実行にしてKVO通知を行う
DispatchQueue.main.async {
print(type(of: self), self.name!, #function, "fnished")
self.status = .finished // 状態を終了にしてKVO通知を行う
}
}
}
class NotConcurrentOperation: Operation {
override func main() {
print(type(of: self), self.name!, #function, "最後に呼ばれること")
}
}
let concurrentOperation1 = ConcurrentOperation()
concurrentOperation1.name = "1"
let concurrentOperation2 = ConcurrentOperation()
concurrentOperation2.name = "2"
let endOperation = NotConcurrentOperation()
endOperation.name = "end"
endOperation.addDependency(concurrentOperation1)
endOperation.addDependency(concurrentOperation2)
let queue = OperationQueue()
queue.maxConcurrentOperationCount = 1
queue.addOperation(endOperation) // あえて先にOperationQueueに追加
queue.addOperation(concurrentOperation1)
queue.addOperation(concurrentOperation2)
上記の例ではendOperationをあえて最初にOperationQueueに追加したが、コンソールに下記のように出力され、最後に実行されていることが分かる。
ConcurrentOperation 1 start()
ConcurrentOperation 1 start() fnished
ConcurrentOperation 2 start()
ConcurrentOperation 2 start() fnished
NotConcurrentOperation end main() 最後に呼ばれること
addDependencyを行わなかった場合
ここで、「addDependencyを行わなかった場合」の出力は次のようにOperationQueueに追加した順序でタスクが呼び出される。
NotConcurrentOperation end main() 最後に呼ばれること
ConcurrentOperation 1 start()
ConcurrentOperation 1 start() fnished
ConcurrentOperation 2 start()
ConcurrentOperation 2 start() fnished
queue.maxConcurrentOperationCountを増やした場合
ちなみに、addDependencyをもとに戻し、「queue.maxConcurrentOperationCount = 3」にした場合の出力は次のようになる。
ConcurrentOperation 1 start()
ConcurrentOperation 2 start()
ConcurrentOperation 1 start() fnished
ConcurrentOperation 2 start() fnished
NotConcurrentOperation end main() 最後に呼ばれること
queue.maxConcurrentOperationCountを複数にすると、ConcurrentOperation 1の実行終了をConcurrentOperation 2が待っていないのが分かるし、queue.maxConcurrentOperationCountが3であろうがNotConcurrentOperationは1と2の終了を待って実行される。
ここまでのまとめ
NSOperationsを使った例を見ていると、なんでDispatch.queueを使わないんだという気になってくると思う。理由は2つあって、一つはキャンセルできること
completionBlockを使う場合
Foundation.OperationクラスにはcompletionBlockというクロージャを渡すとタスクの終了時に実行されるプロパティもある。
let concurrentOperation1 = ConcurrentOperation()
concurrentOperation1.name = "1"
concurrentOperation1.completionBlock = {
print(type(of: concurrentOperation1), concurrentOperation1.name!, "complete")
}
let concurrentOperation2 = ConcurrentOperation()
concurrentOperation2.name = "2"
concurrentOperation2.completionBlock = {
print(type(of: concurrentOperation2), concurrentOperation2.name!, "complete")
}
let endOperation = NotConcurrentOperation()
endOperation.name = "end"
endOperation.addDependency(concurrentOperation1)
endOperation.addDependency(concurrentOperation2)
let queue = OperationQueue()
queue.maxConcurrentOperationCount = 1
queue.addOperation(endOperation)
queue.addOperation(concurrentOperation1)
queue.addOperation(concurrentOperation2)
出力は次のようになる。想像通りだと思う
ConcurrentOperation 1 start()
ConcurrentOperation 1 start() fnished
ConcurrentOperation 1 complete
ConcurrentOperation 2 start()
ConcurrentOperation 2 start() fnished
ConcurrentOperation 2 complete
NotConcurrentOperation end main() 最後に呼ばれること
おまけ
- DispatchGroup
- 非同期処理の終了を検知できる
- DispatchWorkItem
- キャンセルできる
DispatchGroupを使って非同期のタスク実行順序と終了タスクを実行する
dispatchGroup.enter()でカウントを上げてdispatchGroup.leave()でカウントが下がって0になると終了になるため、非同期処理の中で非同期処理を行うのをグループ化するとやりたいことはできるが、キャンセル処理をするという概念があるわけではないのでキャンセルをするためには次のキューをDispatchしないことになってくるはず。
DispatchWorkItem
Swift3のDispatchWorkItemはキャンセルできるがdispatchGroup.enter()、dispatchGroup.leave()のような仕組みがない(ただまあそれでも充分便利なものだとは思う)。
おわりに
NSOperationを使わないのであればそれが一番良い気がする。Dispatchやライブラリを使いましょうという気になるが、そもそもなぜNSOperationの使い方を確認したかというと、ProcedureKitというNSOperationを拡張するというライブラリがこの記事を書いた当時は、Swift3.0.1対応してなかったから、どうせならということで代替方法を探さずそのままNSOperationを使うことにした。
追記:
ProcedureKitの対応バージョンが supports Swift 3+
とのことです。
参考URL
並列プログラミングガイド - Apple Developer
https://developer.apple.com/jp/documentation/ConcurrencyProgrammingGuide.pdf