20
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

macOS/iOSスレッドプログラミング(ThreadとRunLoop)

Last updated at Posted at 2020-06-07

シリーズ(予定)

突然ですがOSの進化とともに充実していった、今では使うことはないであろうAPIなどにも触れつつスレッドなどの概念についてまとめていきたいと思います。

  1. macOS/iOSスレッドプログラミング(ThreadとRunLoop) :point_left:
  2. macOS/iOSスレッドプログラミング(排他制御とキャンセル)
  3. macOS/iOSスレッドプログラミング(Grand Central Dispatch)
  4. macOS/iOSスレッドプログラミング(Swift + Dispatch / Combine)

注意点

  • macOSまたはiOSで簡単なアプリが開発できるレベルの知識が前提となります。ご注意ください
  • OS作れるレベルまで熟知しているわけではないため、間違い等あればお手数ですがご指摘いただけると嬉しいです

Mac OS X 10.4 Tiger / iPhone OS 3 までの非同期処理

懐かしいと思う方、 iPhone OS とはなんぞやと思われる方もいるかもしれません。当時のMac OS XのアプデはDVDで販売されていましたしMac自体もまだPowerPCでAPIもCocoaと Carbon の二本立てで JavaでCocoaアプリを作る仕組みすらあった という、、置いておいて本題に入りたいと思います。

当時のコード環境

  • Objective-C 1.0
    • @property がありません!自前でセッターゲッターを書いていました。「ストアド」なのか「コンピューテッド」のプロパティなのかに応じて書き分けます(Javaと同じですね)
    • ドットアクセスもありません。 obj.description は [obj description] のようにメッセージを送信する記法になります、、というよりかはObjective-Cのドットでアクセスする方法は単なる引数なしのメッセージ呼び出しのシンタックスシュガーにすぎないです。よって NSMutableString.new みたいなクラスメソッド含めありとあらゆるところで使えます(やりすぎると可読性が落ちます)
  • MRC (Manual Reference Counting) ... [[XXX alloc] init~] / [XXX new~] して所有権を持った場合、適切にreleaseを呼ぶかautoreleaseでNSAutoreleasePoolのスコープを抜けた際に開放するようにしないとリークしたり不正メモリアクセスでクラッシュします
  • ラムダ式(blocks)もありません、Delegate万歳、target-actionパラダイム天国です

試しているうちにいろいろ面倒になったので最新のObjective-Cで書きますw

スレッドがなぜ必要性なのか

こんなコードを書いてみればわかりますね

// ボタンがクリックされたらAPIを呼び出して結果を画面で表示
- (IBAction)showResult:(id)sender {
    NSData *data = [NSData dataWithURL:[NSURL URLWithString:@"https://..."]]; // ダメ
}

一見便利そうなメソッドですがメインスレッドをブロックしてダウンロードが終わるまでユーザーが操作ができない、20世紀の時計カーソルが出るようなレガシーアプリが誕生します。

Don't use this synchronous method to request network-based URLs. For network-based URLs, this method can block the current thread for tens of seconds on a slow network, resulting in a poor user experience, and in iOS, may cause your app to be terminated.

と公式にも書かれていますが「現在のスレッドをブロックする」と書いてあります。メインスレッドをブロックすることで操作がロックされてしまうわけですね。

スレッドとは

簡単に説明すると複数のコード(命令)を同時に(場合によっては擬似的に)走らせるためにさまざまなOSやプラットフォームで普及した技術です。その前にプロセスとマルチタスクについて軽く触れておくと、一般的に普及しているOSがアプリ=プロセスを複数同時起動して見えるのはフェイクで実際はCPUを占有できるのは一つのアプリだけです (プリエンプティブマルチタスク) 。OSが最適なプロセスに時間を割り当ててCPUを使用させて時間切れになると次のプロセスへ、、を人間が感知できないほど短い時間で行っています (タイムスライス) 。スレッドはプロセス内で使える小さなプロセスみたいなもので物理的な一つのCPU、一つのコアであってもこのタイムスライスによって擬似的な並行処理を使うことができます(スライシングのコンテキストスイッチによって効率は多少落ちます)。マルチコアだと分散ができるので効率が上がるわけですね。プロセスとスレッドの大きな違いはメモリ空間を共有するかどうかです。プロセス間は カーネル(CPU) によって メモリ保護 が行われるため直接アクセスできず、データをやり取りするにはプロセス間通信という方法を取る必要があるのに対して、スレッドであればヒープの変数を共有できます。

スレッドのスケジューラなど詳しい情報は こちら にあります。

いい資料が見つかったのでリンク貼ります( こちら で作成された資料のようです):
https://www.eidos.ic.i.u-tokyo.ac.jp/~tau/lecture/operating_systems/gen/slides/1-thread-process.pdf
↑joinでスレッドを待たずに済むのもNSRunLoopによるイベントハンドラのおかげですね

スレッドを検討すべきコード

- (IBAction)buttonClick:(id)sender {
    [massiveData writeToFile:@"path/to/file" atomically:NO]; // 大容量の時間かかる処理
}

これだとちょっとイメージが沸きづらいのでNSStreamを使ってみます。CFWriteStreamならMac OS X 10.1以降で使えます(どうでもいい)


@interface ViewController () <NSStreamDelegate>

@property (nonatomic) NSOutputStream *stream;
@property (nonatomic) NSInteger count;

@end


@implementation ViewController

// 適当にボタンを置くなりして呼ばれるようにしてください
- (IBAction)buttonClick:(id)sender {
    NSString *path = [@"~/Documents/output.txt" stringByExpandingTildeInPath]; // macOSも権限が厳しくなったのでサンドボックス内に保存
    NSLog(@"path : %@", path);
    self.stream = [NSOutputStream outputStreamToFileAtPath:path append:NO];
    [self.stream setDelegate:self];
    [self.stream open];
    [self.stream scheduleInRunLoop:NSRunLoop.currentRunLoop forMode:NSDefaultRunLoopMode];
}

- (void)stream:(NSStream *)aStream handleEvent:(NSStreamEvent)eventCode {
    NSString *str = @"Objective-C is easy to learn because of primitive!\n";
    NSData *strData = [str dataUsingEncoding:NSUTF8StringEncoding];
    switch (eventCode) {
        case NSStreamEventHasSpaceAvailable:
            [self.stream write:strData.bytes maxLength:strData.length]; // 同期処理かつ低速なのが予想される
            if (++self.count >= 4) { // とりあえず4回書き込んだら閉じる
                [aStream close];
                self.stream = nil;
                self.count = 0;
                NSLog(@"DONE!");
            }
            break;
        case NSStreamEventErrorOccurred:
            NSLog(@"Error occurred!: %@", aStream.streamError);
            [aStream close];
            break;
        default:
            break;
    }
}

@end

このコードは製品にするには少々心許ないですがmacOSでもiOSでも動きます。NSStreamはRAMに収まらないほど巨大なデータを入出力するのに便利なわけですが、データが大きい場合いかにもスレッドを止めそうな見た目をしているのがわかります。

NSRunLoop (RunLoop in Swift)

さて別スレッド化する前に scheduleInRunLoop というメソッドで出てきた NSRunLoop についてもご紹介しましょう。一般的にGUIのアプリは イベントドリブン で動いており今回の buttonClick もユーザーがボタンをクリックしたイベントをデバイスドライバ、OS、AppKit/UIKitがハンドリングしてアプリまで到達しているわけです(Xcodeでブレークポイントを設定してみた画像はこちら)

スクリーンショット 2020-06-07 14.26.16.png

Hello Worldのコード

突然ですがとても初歩的なプログラムをここであえて紹介します

int main() {
    print("Hello, World!");
    return 0;
}

C言語やJava、Swiftなどどの言語でも入門するとまず紹介されるわけですが、このプログラムは実行してすぐプロセスが終了しますね。ここにwhileと例えばC++の cin などを加えることですぐに終了しない対話的なプログラムはできるわけですね。

NSApplicationMain / UIApplicationMain

さて Swift でプロジェクトを作成するとエントリーポイントが出てこないわけですが、Objective-Cでプロジェクトを作成するとmain.mというファイルが必ず作成されます:

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        // Setup code that might create autoreleased objects goes here.
    }
    return NSApplicationMain(argc, argv);
}
int main(int argc, char * argv[]) {
    NSString * appDelegateClassName;
    @autoreleasepool {
        // Setup code that might create autoreleased objects goes here.
        appDelegateClassName = NSStringFromClass([AppDelegate class]);
    }
    return UIApplicationMain(argc, argv, nil, appDelegateClassName);
}

この XXApplicationMain 関数がアプリが正常に動いている間、main関数に戻り値を返さないおかげでアプリが動き続けているわけです。

main関数でループを回してはいけない

main関数を実行するスレッドだからなのか一番最初に作成されるスレッドだから「メインスレッド」と呼ぶのかは調べていないのですが、main関数で自前で安易にイベントハンドリングをしようとするとCPU使用率が100%になるし操作を受け付けないアプリが出来上がります。簡易的なゲームエンジンではこのようなコードを書くことがありますが、macOS/iOSネイティブで作る場合は CADisplayLink などを使って無駄なフレームを描画しないように制御するのが正しいアプローチでしょう。そしてそもそもCocoaの仕組み上このようなマニュアルイベントハンドリングを実現するAPIは存在しません。

int main() {
    while (true) {
      switch (getEvent()) { // OSからイベントを受け取るイメージ
          case BUTTON_CLICK:
              ...;
              break;
          default:
              sleep(1); // CPUを休ませることで負荷をさげられるけど、間隔を開けすぎると反応が鈍いアプリになる
              break;
      }
    }
    return 0;
}

そこでこの辺りをいい感じにしてくれるのが (NS)RunLoop というわけです。さて RunLoop は自前でインスタンスを作ることがありません。なぜかというと NSThread が既に用意してくれているからです(そういう事情なんです)。

Your application neither creates or explicitly manages NSRunLoop objects. Each NSThread object—including the application’s main thread—has an NSRunLoop object automatically created for it as needed.

ラン「ループ」は回す必要があります。メインスレッドではこのランループが先ほどのXXApplicationMainのおかげで回り続けているのでアプリが終了せず、かつイベントを受け取ったりタイマーを設置できたりするわけです。そして回してしまうと明示的に止めるまではずっと回り続けることになります。

このNSRunLoopをベースとしたFoundationでは素のJavaのような同期コードは実は昔からあまり書かなくてすみます(OSが内部でスレッド処理をしてくれてコールバックをRunLoop経由で通知してくれるからです)。重い計算・データ処理やディスクアクセスがない限り明示的にスレッドを使わずに済んでいました。個人的には NSURLConnection であればデータを蓄積していく処理やJSONをパースする処理は別スレッドで処理し最終結果をメインスレッドで渡す設計が好みでした(当時のiPhoneは非力でした)。

  • NSURLConnection (URLSessionができるまではこれがよく使われたはず、CFNetworkというのもある)
  • NSStream
  • NSTimer

NSTimerとNSRunLoop

ランループを回す際、あるいはタイマーを設置する際 「モード」 を指定することができるかと思いますが、このモードを NSDefaultRunLoopMode にするか NSRunLoopCommonModes にするかで挙動が変わります。

  • NSDefaultRunLoopMode ... メニューバーの項目を選んだり、ボタンを押下中は発火しない
  • NSRunLoopCommonModes ... メニューバーの項目を選んだり、ボタンを押下中も発火する

NSRunLoopCommonModesNSDefaultRunLoopMode に加え下記も含まれていると考えられそうです

Thread を使った非常に簡単な非同期処理

せっかくなので自分でスレッドを作って引数を渡して結果を得るパターンを試してみましょう。スレッドを作るという行為は今後紹介するであろう Grand Central Dispatch によって基本的には不要になりました。スレッドの作成はシステムコールによるコストペナルティがあるので基本的には作らないに越したことはないでしょう。


- (IBAction)buttonClick:(id)sender {
    // スレッドを作ってメッセージをそこで実行する
    [NSThread detachNewThreadSelector:@selector(workerThread:)
                             toTarget:self
                           withObject:@10]; // スレッドにオブジェクトを渡せる
}

// 別スレッドで実行されるメソッド
- (void)workerThread:(id)param {
    NSLog(@"%s, isMainThread: %@", __func__, NSThread.isMainThread ? @"Y" : @"N");
    NSInteger num = ((NSNumber *) param).integerValue;
    NSMutableString *str = NSMutableString.string;
    for (NSInteger i = 0; i < num; ++i) {
        [str appendString:@"fugahoge\n"];
    }
    // メインスレッドに結果を渡す
    [self performSelectorOnMainThread:@selector(backToMainThread:)
                           withObject:str.copy
                        waitUntilDone:NO];
}

// workerThread: から呼ばれるメソッド
- (void)backToMainThread:(id)param {
    NSLog(@"%s, isMainThread: %@, result: %@", __func__, NSThread.isMainThread ? @"Y" : @"N", param);
}

これで簡単な非同期処理を実現できますがメソッドを分けると途端に保守しづらく感じますね。この一連の処理のためにクラスを作って凌ぎたくなります(それでもdelegateパターンが限界)。ログはこんな感じ:

Thread[15970:540268] -[ViewController workerThread:], isMainThread: N
Thread[15970:539803] -[ViewController backToMainThread:], isMainThread: Y, result: fugahoge
fugahoge
fugahoge
fugahoge
fugahoge
fugahoge
fugahoge
fugahoge
fugahoge
fugahoge

ちゃんとスレッドに引数を渡して結果をメインスレッドに戻せていますが、blocksのキャプチャがいかに便利か思い知らされます。ちなみに detachNewThreadSelector にブレークポイントを仕掛けてStep Inを押し続けるとこんなスタックトレースが表示されました:

スクリーンショット 2020-06-07 15.37.47.png

pthread を使っているのがわかります。あとCFですけどRunLoopが作られているのがわかります(NSRunLoopとCFRunLoopはキャストが可能で実行コストが0であることから toll-free bridgeとよばれています)

今回メソッドを経由して引数を渡したのには理由があって、ViewController のプロパティもスレッドなら当然アクセス可能なのですが非同期処理ならではの難しいとされる部分については次回説明したいと思います。

RunLoopをスレッドでも使う

長くなりましたが最後に先ほど書いた NSStream のコードもマルチスレッド化してしまいたいと思います


- (IBAction)buttonClick:(id)sender {
    // 保存先パスを渡す
    [NSThread detachNewThreadSelector:@selector(workerThread:)
                             toTarget:self
                           withObject:@"~/Documents/test.txt".stringByExpandingTildeInPath];
}

- (void)workerThread:(id)path {
    NSLog(@"%s, isMainThread: %@", __func__, NSThread.isMainThread ? @"Y" : @"N");
    NSLog(@"path : %@", path);
    self.stream = [NSOutputStream outputStreamToFileAtPath:path append:NO];
    [self.stream setDelegate:self];
    [self.stream open];
    [self.stream scheduleInRunLoop:NSRunLoop.currentRunLoop forMode:NSDefaultRunLoopMode];
    // 作ったNSThread内でランループが必要な場合は自分で回す(そして明示的に終了しない限り永遠に終わらなくなる)
    [NSRunLoop.currentRunLoop run];
}

- (void)backToMainThread:(id)error {
    NSLog(@"%s, isMainThread: %@, error: %@", __func__, NSThread.isMainThread ? @"Y" : @"N", error);
}

- (void)stream:(NSStream *)aStream handleEvent:(NSStreamEvent)eventCode {
    NSString *str = @"Objective-C is easy to learn because of primitive!\n";
    NSData *strData = [str dataUsingEncoding:NSUTF8StringEncoding];
    switch (eventCode) {
        case NSStreamEventHasSpaceAvailable:
            [self.stream write:strData.bytes maxLength:strData.length];
            if (++self.count >= 4) { // 4回書き込んだら閉じる
                [aStream close];
                [self performSelectorOnMainThread:@selector(backToMainThread:)
                                       withObject:nil
                                    waitUntilDone:NO];
                [NSThread exit]; // ランループで終わらないので明示的にスレッドを終了させる
            }
            break;
        case NSStreamEventErrorOccurred:
            [aStream close];
            [self performSelectorOnMainThread:@selector(backToMainThread:)
                                   withObject:aStream.streamError
                                waitUntilDone:NO];
            [NSThread exit]; // ランループで終わらないので明示的にスレッドを終了させる
            break;
        default:
            break;
    }
}

こんな感じになります。NSThread上ではRunLoopは作成されるもののデフォルトでは回っておらず(前の例のような使い方のため)、明示的に回す必要があるのと [NSThread exit] でスレッドを落とす必要があります。

次回

いつ書くかわかりませんが Mac OS X 10.5 Leopard / iOS 4 でパワーアップしたAPIを使いつつ「排他制御」「キャンセル」などもそちらで触れたいと思います。最後までご覧いただきありがとうございました。

20
12
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
20
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?