実行ループとdispatch_sourceの話。
いまやiOSのネットワーク処理といえばAFNetworking
な感じです。
しかし古いストリーミングAPIを使った経験があれば、ブロックベースのAPIでどうやってネットワーク通信を行っているのか疑問に思うはずです。なぜかというと、 GCDは実行ループを持てない からです。
そもそも実行ループとは何なのでしょう。
実行ループとは
The NSRunLoop class declares the programmatic interface to objects that manage input sources. An NSRunLoop object processes input for sources such as mouse and keyboard events from the window system, NSPort objects, and NSConnection objects. An NSRunLoop object also processes NSTimer events.
実行ループはタッチイベントやNSPort
やNSConnection
といった入力イベントを処理するための、プログラムのためのインターフェースであり、同時にNSTimer
のイベントを処理していると説明されています。
while(true){
Event event = [_eventQueue pop];
if(event) {
[event do];
}
}
擬似コードとしてはこういう感じです。実際のソースはCFRunLoop.c
として公開されています。
イベントをキューやスタックで管理して、もし存在すればそれを順番に処理するようなループ処理を延々と続けているのです。
GCDのdispatch_main_queue()
が指しているものとは、「メインスレッド上の実行ループ」であり、「メインスレッドでの処理」とはその「実行ループのイベント」を指します。メインスレッドで重い処理を実行してしまうと、次のイベントが処理できなくなるので、描画が止まり画面が固まったり、操作を受け付けなくなったりするのです。
このサイトの解説がとても素晴らしいので、おすすめです。
さて、いくつか疑問が生まれると思います。
実行ループは誰が止めてるの?
while(true)
なんて処理を実際に書いたら、それは無限ループになってしまいます。
しかし入力イベントの監視を行うためには、入力ソースから新たなイベントが来ないと分かるまで待機し続ける必要があります。
無限の未来までイベントがないことを証明する計算は不可能ですので、実行ループは現在キューにあるイベントと、NSTimer
でスケジュールされた処理を完了した時点で停止する作りになっています。
唯一、アプリケーションのメインスレッドの実行ループのみ、アプリケーションが終了されるまで動作し続けます。必ず存在することが保証されている実行ループですので、貴重なリソースなのです。
じゃあ実行ループは誰が作っているの?
これについてはClass Referenceを読み進めると分かります。
Your application cannot either create or explicitly manage NSRunLoop objects. Each NSThread object, including the application’s main thread, has an NSRunLoop object automatically created for it as needed.
実行ループの生成、および明示的な管理はCocoaのシステム上で行われるため、開発者側では行えません。メインスレッドを含む、NSThread
が必要に応じて自動的に生成するのです。
この「NSThread
」というところがポイントです。
iOSにはスレッドプログラミングを実現するためのAPIとしてNSThread
だけではなく、POSIXスレッドやGCD
などいくつかの選択肢が用意されていますが、このうち実行ループを持てるのはNSThread
だけなのです。
スレッドとは
そもそもスレッドとは何なのでしょう。
スレッドを技術的に見れば、カーネルレベルのデータ構造体とアプリケーションレベルのデータ構造体を組み合わせて、コードの実行を管理できるようにしたものです。カーネルレベルの構造体では、スレッドに対するイベントのディスパッチと、使用可能なコアの1つで実行されるスレッドのプリエンプティブスケジューリングを連携させます。アプリケーションレベルの構造体には、関数呼び出しを格納するためのコールスタックと、スレッドの属性および状態を管理および操作するためにアプリケーションで必要とする構造体が含まれています。
…なんか分かったような分からないような感じの説明です。
つまるところ実行コンテキストを生成するわけですが、Cocoa上ではそれをオブジェクト(=構造体のポインタ)で扱っているわけです。
明示的にNSThread
やそのサブクラスを作る必要性はほとんどなくなりましたが、performSelectorInBackground:withObject:
は、必要があれば自動的にNSThread
を生成して、そのスレッド上で処理を行います。
アプリケーションのエントリポイントであるmain
関数が@autorelease
プールで囲われているように、新規にNSThread
を生成した場合、そのスレッドのスタックが確保したメモリの解放は明示的に行わなければならない、という良く分からないルールがあります。
先のperformSelectorInBackground:withObject
を使うと、ARC環境であっても簡単にメモリリークします。
追記: OSのバージョンによって挙動が違うらしいです。
iOS4.3以降だとperformSelectorInBackground:withObject
等が生成したスレッドのメモリはちゃんと回収されるらしいです。
NSThreadは処理が終了した後どうなるの?
NSThread
は自身のコールスタックの処理を完了したら、自動的に消滅します。
このため、スレッド内の処理が完了した後に何らかのイベントを渡しても、既に実行ループも解放されているので何もできません。このような時のために実行ループを待機させるメソッドrunUntilDate:
があります。
もっともClass Referenceには、
Warning: The NSRunLoop class is generally not considered to be thread-safe and its methods should only be called within the context of the current thread. You should never try to call the methods of an NSRunLoop object running in a different thread, as doing so might cause unexpected results.
実行ループは通常スレッドセーフではないと考慮し、現在のスレッドからのみメソッドを呼び出すべきであり、異なるスレッドからイベントを渡すような使い方は想定しない結果をもたらすと警告しています。
これを守る限りでは、上のような問題は起きないはずです。
NSOperationQueue
余談ですけど、「NSOperationQueue
はGCD
で実装されている」という説明を良く見るのですけども、実質的には正しいのですけども、間違ってもいます。
なぜならNSOperationQueue
はGCD
が登場したiOS4より前から存在するからです。
つまりもとはNSThread
で実装されていた非同期キューが、後にGCD
による実装に置き換わったのです。
なので、NSOperationQueue
のmain
メソッドは、iOS3以前では@autorelease
を書かないとメモリリークしますが、iOS4以降をターゲットとする場合はGCD
がメモリを回収するために書く必要がありません。
ネットワークプログラミングと実行ループ
iOSは移動体通信端末なわりに、本格的なネットワークプログラミングの本を見かけないのが不思議です。公式ドキュメントが充実しているから…というのは、ネットワーキングプログラミングトピックスがものすごい中途半端なところで説明を投げ捨てている辺り、可能性としてはないと思います。
ようやく本題に戻ってきたのですが、ネットワークプログラミングとは結局のところ、通信のためにソケットとポートをbindし、その入出力を処理する何らかのイベントループに登録する必要があるわけです。
CocoaのネットワークAPIを利用するには、イベントループとして基本的に実行ループを利用します。先に述べたように、メインスレッドの実行ループは貴重なリソースなので、バックグラウンドで通信を行うには、NSThread
を生成して、その実行ループを利用して通信するしかないのです。
GCDベースのネットワークライブラリがこの問題をどう解決しているのか、ソースを追うと面白いです。例えばCocoaAsyncSocket
のGCDAsyncSocket
では、
// We can't run the run loop unless it has an associated input source or a timer.
// So we'll just create a timer that will never fire - unless the server runs for decades.
[NSTimer scheduledTimerWithTimeInterval:[[NSDate distantFuture] timeIntervalSinceNow]
target:self
selector:@selector(ignore:)
userInfo:nil
repeats:YES];
[[NSRunLoop currentRunLoop] run];
という処理を書いています。
[NSDate distantFuture]
は2000年くらい後を返すらしいので、NSTimer
でそれまで実行ループを待機させれば、アプリケーション内に「もう一つの実行ループ」を生成できるわけです。
本来、このような記述は、ネットワーク通信の必要がなくなってもスレッドごとリークし続けるため忌避されるのですが、ライブラリが行う全ての通信で一つの実行ループを共有する作りになっているので、許容されるわけです。
AFNetworking
も概ね似たような感じです。
裏技的な方法で実行ループを管理することによって、便利に記述できるのですが、その反面としてCocoaのネットワークAPI制約のために万能足りえません。
実行ループが密接に絡むような処理、例えばサーバーサイドになって通信を待ち受けたり、ストリーミングを行うようなメソッドがないのはそのためです。
dispatch_source
「GCD
の脇役」みたいな扱いをされるdispatch_source
ですが、GCD
が実行ループを持たないただのFIFOキューであるならば、本来dispatch_after
のような遅延実行すら不可能なはずです。それを実現するのがdispatch_source
なのです。
ディスパッチソースは、システム関係のイベント処理によく使われる、非同期コールバック関数の代替となるものです。ディスパッチソースを生成した後、監視対象のイベント、ディスパッチキュー、イベントを処理するコードを設定します。このコードは、ブロックオブジェクトまたは関数の形で定義します。該当するイベントが届くと、ディスパッチソースはこのブロックまたは関数を所定のディスパッチキューに登録し、実行を委ねます。
キューに直接登録するタスクと違い、いったん設定してしまえば、随時発生する継続的なイベントソースとして振る舞います。明示的に解除しない限り、ディスパッチソースは、あるディスパッチキューに結びつけられた状態になっています。その間は、該当するイベントが発生する都度、対応するタスクコードをディスパッチキューに登録します。タイマーのように一定期間ごとに発生するイベントもありますが、多くは、ある条件が満たされたとき、突発的に発生します。そのため、ディスパッチソースは、自分自身に結びつけられたディスパッチキューを保持して、未処理のイベントが残っているかも知れない間は、誤って解放されてしまわないようにしています。
Cocoaではシステム関係のイベント処理を実行ループによって行っていました。その代替となるものであるという説明がここでなされています。タイマーのように一定間隔で発火させられる、タスクが紐付けられている限り解放されないなど、似た性質を持つことが分かります。
両者の違いは効率性です。カーネルレベルで書かれたGCDは実行ループより極めて効率的です。
dispatch_sourceで実行ループを代替する
Cocoa APIではCFSocket
のコールバックは基本的には実行ループ上で書くことになりますが、CFSocket
そのものはdispach_source
を利用して実装されていますし、実行ループの代わりにdispatch_source
を利用することも可能です。
これについては、GCD
ベースのWebサーバーであるオープンソースライブラリGCDWebServer
が、実際に実行ループからdispatch_source
に置き換えたコミットを読むのが参考になります。
CFNetServiceScheduleWithRunLoop()
の呼び出し自体は必要なのが解せないですが、公式ドキュメントではこの辺の説明を端折ってることもあって、非常に参考になります。(ただ、可読性を恐ろしく犠牲にしている気もするんですけど)
しかしdispach_source
は 実行ループより効率的という点が実はネック という気がするのですけれども、実際のところどうなんでしょうね。効率と精度はトレードオフなので、全てをGCD
に置き換えるのが必ずしも正しいとは言えないと思うのですけれども。