読者です 読者をやめる 読者になる 読者になる

なるようになるかも

力は多くの場合、その人の思いを超えない。

実行ループとdispatch_sourceの話。

いまやiOSのネットワーク処理といえばAFNetworkingな感じです。

しかし古いストリーミングAPIを使った経験があれば、ブロックベースのAPIでどうやってネットワーク通信を行っているのか疑問に思うはずです。なぜかというと、 GCDは実行ループを持てない からです。

そもそも実行ループとは何なのでしょう。

実行ループとは

NSRunLoop Class Referenceより

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.

実行ループはタッチイベントやNSPortNSConnectionといった入力イベントを処理するための、プログラムのためのインターフェースであり、同時に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

余談ですけど、「NSOperationQueueGCDで実装されている」という説明を良く見るのですけども、実質的には正しいのですけども、間違ってもいます。

なぜならNSOperationQueueGCDが登場したiOS4より前から存在するからです。

つまりもとはNSThreadで実装されていた非同期キューが、後にGCDによる実装に置き換わったのです。

なので、NSOperationQueuemainメソッドは、iOS3以前では@autoreleaseを書かないとメモリリークしますが、iOS4以降をターゲットとする場合はGCDがメモリを回収するために書く必要がありません。

ネットワークプログラミングと実行ループ

iOS移動体通信端末なわりに、本格的なネットワークプログラミングの本を見かけないのが不思議です。公式ドキュメントが充実しているから…というのは、ネットワーキングプログラミングトピックスがものすごい中途半端なところで説明を投げ捨てている辺り、可能性としてはないと思います。

ようやく本題に戻ってきたのですが、ネットワークプログラミングとは結局のところ、通信のためにソケットとポートをbindし、その入出力を処理する何らかのイベントループに登録する必要があるわけです。

CocoaのネットワークAPIを利用するには、イベントループとして基本的に実行ループを利用します。先に述べたように、メインスレッドの実行ループは貴重なリソースなので、バックグラウンドで通信を行うには、NSThreadを生成して、その実行ループを利用して通信するしかないのです。

GCDベースのネットワークライブラリがこの問題をどう解決しているのか、ソースを追うと面白いです。例えばCocoaAsyncSocketGCDAsyncSocketでは、

// 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に置き換えるのが必ずしも正しいとは言えないと思うのですけれども。