2012年5月8日火曜日

iOSでSQLiteを使う2(FMDBのFMDatabaseQueueクラスを使ってみる)

FMDBにはスレッド間でSQLiteを使用する際のヘルパークラスが提供されています。そのクラスがFMDatabaseQueueです。 このクラスの用途と、どういったシーンで使用していけば良いのかを考えつつ、サンプルプログラムで試してみます。

因みに、FMDBの基本的な使い方に関しては前回書いた iOSでSQLiteを使う(FMDB) を参照。
マルチスレッドにおけるFMDatabaseインスタンスに関して

FMDBのREADMEにはFMDatabaseのインスタンスをシングルトンにして、マルチスレッド上から唯一のFMDatabaseインスタンスを 取得して使用することは推奨されておらず、一見うまく動いているようでも、いつかはクラッシュしたり、例外が 発生することになるだろうと書かれています。つまり、FMDatabaseクラスはスレッドセーフでは無いということになります。

ちなみに、シングルトンじゃなくてスレッド毎にFMDatabaseインスタンスを生成すれば良いんじゃ?という考え方もあるかもしれない。 確かにそのアプローチもアリかと思いますが、FMDBのトランザクションが読み取り・書き込み排他ロックをデフォルトに してるっぽいので、複数インスタンスを生成してもDBへのアクセスは結局排他されるのであまり意味が無く、余計にメモリ を使用してしまうだけだと思う。(beginTransactionでトランザクションを開始した場合)
beginDeferredTransactionにてトランザクションを開始した場合は、読み取りは共有ロックになるので、 更新中の読み取りが可能になりますが、そもそもSQLiteへのアクセスは自身のアプリケーションからのみであるため そうそう更新中の読み取りを実施したいという要望は無いはず。それより、SQLiteへのアクセスは直列化してアクセスさせ、 処理をシンプルにする方がメリットが大きいのではないでしょうか。

試しにマルチスレッド上で同一のFMDatabaseインスタンスを使ってみる

以下のコードは、DBを検索してその結果を文字列としてUITextView上に表示するという処理を 10個の新たなスレッド上で実行させています。 ちなみに、以下コードのDB検索部分を載せていませんが 前回の記事 と同じものです。

  1. //結果取得して画面描画を別スレッドで行う
  2. //スレッドはNSOperationQueueにて生成
  3. NSOperationQueue *opeQueue = [[NSOperationQueue alloc]init];
  4.  
  5. //同期用のオブジェクト(インデックス表示用)
  6. NSObject *sync = [[NSObject alloc]init];
  7. static int index = 0;
  8.  
  9. //スレッド実行部分のブロック処理
  10. void (^exec)(void) = ^(void) {
  11. int _index = 0;
  12. @synchronized(sync) {
  13. index++;
  14. NSLog(@"新しいスレッド。index : %d.", index);
  15. _index = index;
  16. }
  17. //DB検索を実行
  18. NSArray *result = [db executeQuery2];
  19. //検索結果の文字列を作成
  20. NSString *text = [NSString stringWithFormat:@"%d\n", _index];
  21. for (ExampleRecord *record in result) {
  22. text = [text stringByAppendingString:
  23. [NSString stringWithFormat:@"%d, %@, %@, %f\n",
  24. record.columnOfInteger,
  25. record.columnOfDate,
  26. record.columnOfText,
  27. record.columnOfReal]];
  28. }
  29. //特に意味ないけど、2秒スリープ
  30. [NSThread sleepForTimeInterval: 2.0];
  31. //画面に表示。メインスレッド以外からの更新なのでperformSelectorOnMainThreadを使用。
  32. if ([result count] != 0) {
  33. [self performSelectorOnMainThread:@selector(updateTextView:) withObject:text waitUntilDone:NO];
  34. }
  35.  
  36. };//end of the block.
  37. //新しいスレッドを生成し実行
  38. [opeQueue addOperationWithBlock: exec];
  39. [opeQueue addOperationWithBlock: exec];
  40. [opeQueue addOperationWithBlock: exec];
  41. [opeQueue addOperationWithBlock: exec];
  42. [opeQueue addOperationWithBlock: exec];
  43. [opeQueue addOperationWithBlock: exec];
  44. [opeQueue addOperationWithBlock: exec];
  45. [opeQueue addOperationWithBlock: exec];
  46. [opeQueue addOperationWithBlock: exec];
  47. [opeQueue addOperationWithBlock: exec];

上記処理を何度も実行すると以下のような例外が発生します。以下は例外時のイメージですが、 結構何度も実行させてやっと発生した感じです。通常は例外が発生しないでFMDBの各メソッドがエラー返却される パターンが殆どでした。まあ、エラーになることはこれで分かりました。


・デバッガ

・標準出力結果(backtrace)
FMDatabaseQueueを使用して実行してみる

マルチスレッド上からFMDatabaseQueueを使用してDB検索を実行してみます。

  1. //結果取得して画面描画を別スレッドで行う
  2. //スレッドはNSOperationQueueにて生成
  3. NSOperationQueue *opeQueue = [[NSOperationQueue alloc]init];
  4.  
  5. //同期用のオブジェクト(インデックス表示用)
  6. NSObject *sync = [[NSObject alloc]init];
  7. static int index = 0;
  8.  
  9. //スレッドで実行するブロック定義
  10. void (^exec)(void) = ^(void) {
  11. int _index=0;
  12. //各スレッドで表示するインデックスインクリメント
  13. //ここはログ表示用なのであまり気にしないで下さい。
  14. @synchronized(sync) {
  15. index++;
  16. _index = index;
  17. }
  18. NSLog(@"新しいスレッド。index : %d.", _index);
  19. //SQLを実行。以下のメソッド内でFMDatabaseQueueクラスを使用しています。
  20. [db executeQuery3:self index:_index];
  21. };
  22. //スレッド実行
  23. [opeQueue addOperationWithBlock:exec];
  24. [opeQueue addOperationWithBlock:exec];
  25. [opeQueue addOperationWithBlock:exec];
  26. [opeQueue addOperationWithBlock:exec];
  27. [opeQueue addOperationWithBlock:exec];
  28. [opeQueue addOperationWithBlock:exec];
  29. [opeQueue addOperationWithBlock:exec];
  30. [opeQueue addOperationWithBlock:exec];
  31. [opeQueue addOperationWithBlock:exec];
  32. [opeQueue addOperationWithBlock:exec];
  33.  

実際にDBアクセス部分(上記のexecuteQuery3というメソッド)のコードは以下になります。 以下コードで、_dbQueueという変数がFMDatabaseQueueのインスタンスです。インスタンス生成部分のコードは 別途示します。

  1. - (void)executeQuery3:(id)delegate index:(int)index {
  2. if (_dbQueue) {
  3.  
  4. //トランザクション無しのパターン
  5. //_dbQueue変数はFMDatabaseQueueのインスタンスです。生成は別途実施済みです。
  6. [_dbQueue inDatabase:^(FMDatabase *db) {
  7. FMResultSet* rs = [db executeQuery:@"select * from example where t like ?", @"%2."];
  8. if ([db hadError]) {
  9. NSLog(@"Err %d: %@",
  10. [db lastErrorCode], [db lastErrorMessage]);
  11. }
  12.  
  13. NSString *text = [NSString stringWithFormat:@"%d\n", index];
  14. while ([rs next]) {
  15. text = [text stringByAppendingString:
  16. [NSString stringWithFormat:@"%d, %@, %@, %f\n",
  17. [rs intForColumnIndex:0],
  18. [rs dateForColumnIndex:1],
  19. [rs stringForColumnIndex:2],
  20. [rs doubleForColumnIndex:3]]];
  21. }
  22.  
  23. //sleep
  24. [NSThread sleepForTimeInterval:2.0];
  25. //update UI.
  26. [delegate performSelectorOnMainThread:@selector(updateTextView:) withObject:text waitUntilDone:NO];
  27. }];
  28. NSLog(@"call finished. index : %d.", index);
  29. }
  30. }

このコードで何度か実行しましたが、例外やFMDBのクラスからエラーが発生することはありませんでした。 実行すると処理内で2秒スリープしているため2秒間隔で画面が更新されていきます(以下、赤字部分)。

・標準出力の結果
2012-05-08 06:52:00.515 FMDBTest[15557:f803] ViewDidLoad was finished.
2012-05-08 06:52:00.569 FMDBTest[15557:12e03] 新しいスレッド。index : 1.
2012-05-08 06:52:00.570 FMDBTest[15557:12f03] 新しいスレッド。index : 2.
2012-05-08 06:52:00.570 FMDBTest[15557:11103] 新しいスレッド。index : 3.
2012-05-08 06:52:00.571 FMDBTest[15557:13003] 新しいスレッド。index : 4.
2012-05-08 06:52:00.576 FMDBTest[15557:13203] 新しいスレッド。index : 5.
2012-05-08 06:52:00.580 FMDBTest[15557:13c03] 新しいスレッド。index : 6.
2012-05-08 06:52:00.588 FMDBTest[15557:14403] 新しいスレッド。index : 8.
2012-05-08 06:52:00.596 FMDBTest[15557:1450b] 新しいスレッド。index : 10.
2012-05-08 06:52:00.587 FMDBTest[15557:14203] 新しいスレッド。index : 7.
2012-05-08 06:52:00.595 FMDBTest[15557:14303] 新しいスレッド。index : 9.
2012-05-08 06:52:02.585 FMDBTest[15557:12e03] call finished. index : 1.
2012-05-08 06:52:04.588 FMDBTest[15557:12f03] call finished. index : 2.
2012-05-08 06:52:06.590 FMDBTest[15557:11103] call finished. index : 3.
2012-05-08 06:52:08.591 FMDBTest[15557:13003] call finished. index : 4.
2012-05-08 06:52:10.593 FMDBTest[15557:13203] call finished. index : 5.
2012-05-08 06:52:12.594 FMDBTest[15557:13c03] call finished. index : 6.
2012-05-08 06:52:14.596 FMDBTest[15557:14403] call finished. index : 8.
2012-05-08 06:52:16.598 FMDBTest[15557:1450b] call finished. index : 10.
2012-05-08 06:52:18.600 FMDBTest[15557:14203] call finished. index : 7.
2012-05-08 06:52:20.602 FMDBTest[15557:14303] call finished. index : 9.


・シュミレーター画面

上のコードをちょっと解説します。

・FMDatabaseQueueの生成部分
上記のサンプルコードには表れていませんが、FMDatabaseQueueのインスタンス生成はFMDatabaseの生成時と同じでDBパスを引数に指定します。
  1. _dbQueue = [FMDatabaseQueue databaseQueueWithPath:dbPath];
・FMDatabaseQueueへの処理依頼
処理はブロックで記載する必要があります。ここでいきなりブロックとは?となるかもしれませんが、、 すみません、ここではブロックの解説はしません。 FMDatabaseQueueは主に以下の2つのメソッドを提供しています。
  • - (void)inDatabase:(void (^)(FMDatabase *db))block
  • - (void)inTransaction:(void (^)(FMDatabase *db, BOOL *rollback))block;
トランザクションを必要としない場合(inDatabase)とする場合(inTransaction)です。 トランザクションを必要とする場合はロールバック有無を表す引数が存在します。 ちなみにこのrollbackの変数はC言語のポインタ型なので、値を設定する際は実体に設定する必要がありますので、 注意して下さい。

以下にFMDBのREADMEに載っているサンプルコードを示します。実体に値を設定するには *(アスタリスク)が必要です。#C/C++をご存知のかたは特に注意する必要もないですが
  1. [queue inTransaction:^(FMDatabase *db, BOOL *rollback) {
  2. [db executeUpdate:@"INSERT INTO myTable VALUES (?)", [NSNumber numberWithInt:1]];
  3. [db executeUpdate:@"INSERT INTO myTable VALUES (?)", [NSNumber numberWithInt:2]];
  4. [db executeUpdate:@"INSERT INTO myTable VALUES (?)", [NSNumber numberWithInt:3]];
  5.  
  6. if (whoopsSomethingWrongHappened) {
  7. //
  8. //↓ここです。実体に設定するには*が必要です。
  9. *rollback = YES;
  10. return;
  11. }
  12. // etc…
  13. [db executeUpdate:@"INSERT INTO myTable VALUES (?)", [NSNumber numberWithInt:4]];
  14. }];
FMDatabaseQueueクラスの機能

FMDatabaseQueue.mのソースを見れば分かりますが、このクラスは次の機能を提供します。

・直列ディスパッチキューを生成
Grand Central Dispatch(GCD)の直列ディスパッチキューを生成します。この直列キューはタスクを同時には 1つしか実行しないので、キューに登録した処理は必ず同期処理されます。つまり、FMDatabaseQueueは全てのSQLite へのアクセスを完全同期していることになります。

・同期型で実行
直列ディスパッチキューからの実行は同期型(dispatch_sync)で実行しているので、呼び出し元スレッドは ブロックされるので注意する必要があります。私はこの部分を勘違いしており、ブロックで指定したタスクは FMDBのQueue側で非同期に実行してくれるものだと勘違いしていました。つまり、重そうなSQLをFMDatabaseQueue にまかせれば後は勝手に非同期でやってくれる訳では無いということです。スレッドは自分で作成する必要があります。


5 件のコメント :

  1. こんにちは。
    以前別のFMDBの記事でお世話になったものです。その節はありがとうございました。

    恐れ入れますが、こちらの記事でも質問があります。

    この記事のようにマルチスレッドでデータベースの処理をするとき、非同期で行いたいと考えています。

    >つまり、重そうなSQLをFMDatabaseQueue にまかせれば後は勝手に非同期でやってくれる訳では無いということです。スレッドは自分で作成する必要があります。

    一番の質問は、●この「スレッドは自分で作成する必要がある」、というのは、具体的にはどういった仕組みを作ればよいのか教えていただけますでしょうか。●

    それに付随する質問です。
    ここでいう非同期とは、問い合わせ結果が返ってくる間、ほかの処理を行うことができる、という意味でよろしいでしょうか。

    イメージとしてはデリゲート処理のようなもので実行が完了したら、それを受け取るような仕組みがあれば素晴らしいと思うのですが(NSURLSessionのような)、そういった処理を自分で作ることができるのでしょうか。

    お時間のある時に回答いただけますと嬉しいです。よろしくお願いします。

    返信削除
    返信
    1. こんばんは。お久しぶりですね。

      スレッドはNSThreadや本記事のようにNSOperationQueueを使ったりと色々な方法で生成出来ますが、最近はブロックやGCD(Grand Central Dispatch)を使うのがより良いと考えられているようです。
      GCDを使ったスレッドはdispatch_asyncという関数で生成出来ます。詳細はGCDやdispatch_asyncをキーワードに検索していただければ、詳しく説明頂いているWEBサイトがあるでしょう。

      「非同期」は要求に対した結果をメソッドの実行完了を待たずに、応答が返却されるイメージで、ご認識の通り結果を待たずに制御が戻ってくるので、他の処理を実行することが可能です。

      「非同期処理」の通知をデリゲートやブロックで設定しておいて、コールバックすることは可能で、もちろん自分で実装することが可能です。

      ちょっと今時間がないので、サンプルを示せませんが、自分の勉強のためにも作ってみたいと思っています。

      以下、私からのコメントになりますが、
      そもそもどうして「非同期」で実行しようと思ったのでしょうか?重いSQLをメインスレッドで実行して画面が固まってしまう等の事象があるのでしょうか?
      というのも、GCDテクノロジがあるとはいえ、スレッドは管理が難しくバグを生む原因になりがちですので、不用意には作成するべきでは無いと思っています。
      また、よほどのデータ量での検索や、大量更新のSQLで無い限りそんなに処理は重くならないかなとも考えていたので、何か実証したデータがあるなら興味があったので、質問させていただきました。

      削除
    2. おはようございます。お忙しい中ご回答いただきありがとうございます。

      >そもそもどうして「非同期」で実行しようと思ったのでしょうか?重いSQLをメインスレッドで実行して画面が固まってしまう等の事象があるのでしょうか?

      最初にいただいたコメントに答えさせていただきます。
      まず、ある画面で発行するSQLの量が大量(何万SQL?、単体のSQL自体は簡単なもの)で、画面の表示待ちが発生してしまうためです。

      さらに一番の理由は、既にこのプログラムはiOS以外のアプリで存在していて、操作感等この既存アプリに合わせる必要があり、非同期を使うことになりました。(しかし、使わない方がよいという大きな説得材料があれば使わなくてもよくなりそうなのですが。)

      ここからは再度質問させてください。

      マルチスレッドによる非同期の仕組みは理解したのですが、下記について調査してもわからないことがあります。

      >「非同期処理」の通知をデリゲートやブロックで設定しておいて、コールバックすることは可能で、もちろん自分で実装することが可能です。

      ・発行したSQLでデータベースの更新が完了したときに、それを通知するような仕組みはできるのかどうか

      もし、上記が可能でしたら、これについてキーワード等いただけますと幸いです。

      データベースの非同期自体はアドバイスいただいたようにGDCで実装と今のところ考えております。(通知、KVOより扱いやすそうな感じがしたため。)

      削除
    3. こんばんは。

      なるほど、SQLの大量発行により、メインスレッドがブロックされ画面が固まっているのですね。
      モバイルアプリで数万SQLの発行は驚きですが、「非同期」で実行したい理由は理解出来ました。

      発行したSQLの完了通知ですが、以下の実装でサンプルを作成してみました。まず「通知」といっても、実装方法は多々ありますが、今回はブロックによるコールバックという方式で実装してます。簡単な処理フローは以下のようになります。

      1)画面に何かしらの操作
      2)SQL発行メソッドの呼び出し
      3)呼び出されたメソッドにてスレッド生成(dispatch_async)
       この際に、引数でコールバック用のブロックを指定
      4)生成されたスレッド内で、SQL発行処理、SQL発行はFMDatabaseQueueを利用
      5)SQL発行処理が終わったら、コールバック用のブロックを呼び出し
      6)コールバック用ブロック内の処理が実行される

      という流れです。

      3)から6)までが、一つのメソッドになっていて、このメソッドの中でスレッドを生成し、SQL実行、コールバックブロックの呼び出しを行いますが、メソッドはスレッド生成後直ちに制御を戻します。したがって、呼び出し画面側はSQL発行の完了を待たずに制御が戻るので画面が固まることありません。

      ソースはGitHub上にアップしました。
      FMDBExample
      スレッドを生成してSQLを発行し、完了通知としてのブロックを呼び出すクラスは以下の
      https://github.com/takuran/FMDBExample/blob/master/FMDBExample/DataManager.m
      となり、このクラスの以下メソッドが該当のメソッドをイメージして作成してみました。
      - (void)createNewContent:(NSString*)content completeHandler:(void (^)(BOOL result))handler;

      このメソッドの場合、完了通知用のハンドラは第2引数のcompleteHandlerとして定義してあり、
      SQL実行結果を伝えるBOOL型の引数を定義しています。
      6)のコールバックはこのハンドラが呼び出されるイメージになりますので、完了したら実行したい処理をブロックに記載すれば良いです。これをベースに要件に合うようにな定義に変更してもらえればと思います。
      また、ソースを見るとわかりますが、コールバック用のハンドラ呼び出しはメインスレッドで実行するようにしていますので、このブロック内でUIの更新処理を呼び出しても問題ありません。コールバックで画面を更新する必要がなければ、dispatch_syncでメインスレッドで実行するようにしてある処理は不要かもしれません。

      まあ、先ずは色々試してみて、要件に合うような実装を見つけてみてください。
      それでは。がんばってくださいね。

      削除
    4. ご回答いただきありがとうございます。
      お忙しいところ、サンプルまで作っていただき本当にありがとうございます。さっそくダウンロードさせていただきました。
      まだ知識が乏しいため、いただいたプログラムを理解してから設計に入ろうと思います。
      この度は親切にご回答いただき本当にありがとうございました。
      これからも参考にさせていただきます。

      削除