Android アプリ開発における SQLite のロックとマルチスレッドの話
Android アプリ開発で SQLite を使っていると、しばしば次のような例外が投げられることがあります。*1
android.database.sqlite.SQLiteDatabaseLockedException: database is locked (code 5)
軽く調べてみたところ
このエラーをぐぐってみると、「複数スレッドから SQLite を使う場合に、それぞれのスレッドで異なる SQLiteDatabase
オブジェクトを使っているとこのエラーが出ることがある」 とか書かれていて、回避策として 「複数スレッドで SQLite を使う場合は 1 つの SQLiteDatabase
オブジェクトを複数スレッドで使いまわすこと」 というような情報が得られました。 あとは当たり前ですが 「トランザクションがちゃんと閉じられているか確認すること」 とか。
いくつかの質問やブログ記事を見てみましたが、いずこも上のようなことが書かれていて、具体的にこの例外が発生する条件などがよくわかりませんでした。
- 今度からSQLiteOpenHelperのDatabaseHelperのインスタンス取得はSingletonパターンにするよ・・・ - Androidはワンツーパンチ 三歩進んで二歩下がる
- Androidのデータベース周りでよくわからないエラーが出る | 技術者のたまごブログ
- Android Database Locked - Stack Overflow
あるスレッドでトランザクションを張っているときに別のスレッドで挿入などを行おうとすると 『SQLiteDatabaseLockedException
が投げられます』、ということが書かれていたブログ記事も見つけたのですが、手元で確認したところ、トランザクションを張ってる時に別スレッドで insertOrThrow
メソッドを呼び出しても必ずしもエラーになるわけじゃなくて、やっぱりよくわかんないなぁという感じでした。
そんなわけなのでちょっと詳細を調べてみることにしました。
調査
下記のようなテストコードを書いて動作を調べました。
次のような環境で実行しました。
API level によって待てる時間が違ってたりするっぽい (ちゃんと調べてない) ので、環境によっては一部のテストに失敗するかもしれません。
結果として
結論としては、次のような知見が得られました。
- “database is locked” というのは Android SDK レベルではなく SQLite レベルで発生している
- ロック取得待ち開始から一定時間が経過すると発生する
- 「一定時間」 は
sqlite3_busy_timeout
関数 で指定される - Android SDK のソースコード的には android_database_SQLiteConnection.cpp の
BUSY_TIMEOUT_MS = 2500;
のあたり を見ればよさそう (API level 18 の Android SDK では 2500 ms で設定されてるっぽい)
- そんなわけなので、とあるスレッドでトランザクションが張られていて、別のスレッドで別の
SQLiteDatabase
オブジェクトを使ってinsertOrThrow
メソッドによる行の挿入などを行おうとしたとき、ロック取得を待って一定時間が経過すると “database is locked” というような例外が発生する - 複数スレッドで同じ
SQLiteDatabase
オブジェクトを使っていれば、“database is locked” というような例外が発生することはない (SQLite レベルでロック取得を待っているのではなく、Android SDK レベルでロック取得を待つため) - 複数スレッドで同じ
SQLiteDatabase
オブジェクトを使う場合と、異なるSQLiteDatabase
オブジェクトを使う場合の (使用者側から見たときの) 違いは次のようなことしか無さそう- ロック取得を待って一定時間が経過したときに例外が送出されるかどうか
query
メソッドと、その返り値のCursor
オブジェクトに対する操作で行取得を行う場合の挙動:- 同じ
SQLiteDatabase
オブジェクトを使っている場合はquery
メソッドで他スレッドのトランザクション終了を待つ (他スレッドでのトランザクションが immediate モード (non exclusive) でも exclusive モードでも。) - 別の
SQLiteDatabase
オブジェクトを使っている場合は、他スレッドのトランザクションが exclusive モードならCursor
オブジェクトへの操作時点で他スレッドのトランザクション終了を待つし、他スレッドのトランザクションが immediate モード (non exclusive) ならトランザクション終了を待たずに結果を返す
- 同じ
ベストプラクティス?
ベストプラクティス的なものはまだはっきりとは見えてないのですが、ぐぐって出てきた情報や今回調べたことなどを元に今のところわかっていることを書いておくと次のようになります。
- 複数スレッドで SQLite とやり取りする場合、1 つの
SQLiteDatabase
オブジェクトを複数スレッドで使うようにしても、(おそらくだが) 問題はない- あるスレッドで
beginTransaction
したら、別のスレッドではトランザクションの終了を待つようになっている - ただし、immediate モードのトランザクションはさすがに Java のライブラリのレベルではサポートできていない
- あるスレッドで
- Exclusive モードのトランザクションを張っている場合は、他スレッドでの行の挿入や削除はもちろん、行の取得も待たせてしまうので、秒単位の時間がかかるトランザクション処理は書かないようにすべき
- 万一、秒単位のトランザクションが発生しても “database is locked” の例外が発生しないように、アプリケーション全体で 1 つの
SQLiteDatabase
オブジェクトを使いまわすというのは有効- だが、その場合は immediate モードのトランザクションを張っても、他のスレッドでは行の取得もトランザクションが終わるのを待ってしまう
- ので、immediate モードのトランザクションを使って、データ書き込み中でも別スレッドで読み取りができるようにするには、読み取り用の
SQLiteDatabase
オブジェクトと書き込み用のSQLiteDatabase
オブジェクトの少なくとも 2 つは使い分ける必要がある
- 当たり前だけど
SQLiteDatabase#beginTransaction
して、SQLiteDatabase#endTransaction
しなかったら、後で DB への行挿入などしようとしたときに “database is locked” って言われるので、トランザクション中に例外が発生してもSQLiteDatabase#endTransaction
が呼ばれるようなコードにしておくこと - 今回の話とは関係ないけど、複数回の行挿入をする場合など、必要がなくてもトランザクションを張った方が処理速度が速くなるらしい
- 接続を新たに張るのにかかるコストとか、必要ないときまで接続しっぱなしにしておくのにかかるコストとか、そこら辺は全然考えてないので、必要な時に接続を張って使い終わったら閉じるのがいいのか、それともずっと接続を開きっぱなしにしておくのがいいのかは不明
詳細
- あるスレッドにおいて
SQLiteDatabase#beginTransaction
やSQLiteDatabase#beginTransactionNonExclusive
でトランザクションが張られている場合に、別のスレッドで別のSQLiteDatabase
オブジェクトを使用して次のメソッドを呼び出した場合、一定時間 (2 秒程度?) が経過するとSQLiteDatabaseLockedException
例外 *2 が発生する - 一定時間が経過するまではロックが解除される (別スレッドでのトランザクションが閉じられる) のを待つ
- トランザクションを張るのに使用している
SQLiteDatabase
オブジェクトと同じオブジェクトを使って別スレッドで上のようなメソッドを呼び出した場合は、一定時間が経過しても例外が発生せず、トランザクションが閉じられるのを待ち続ける *3 - あるスレッドにおいて
SQLiteDatabase#beginTransaction
メソッドでトランザクションが張られている場合に、SQLiteOpenHelper
を使って新しいSQLiteDatabase
オブジェクトを生成しようとした場合は、やはり一定時間まではトランザクション終了を待つが、それを経過するとSQLiteDatabaseLockedException
例外 *4 が発生する SQLiteDatabase#beginTransactionNonExclusive
メソッドでトランザクションが張られている場合は、SQLiteOpenHelper
を使ってトランザクションが閉じられるのを待たずに新しいSQLiteDatabase
オブジェクトを生成できる- あるスレッドにおいて
SQLiteDatabase#beginTransaction
でトランザクションが張られている場合に、別のスレッドでSQLiteDatabase#query
メソッドを呼び出し、返り値のCursor
オブジェクトのgetCount
やmoveToFirst
メソッドを呼び出すという処理をする場合- トランザクションを張るのに使用した
SQLiteDatabase
オブジェクトを使用しているならば、query
メソッド呼び出しの時点でトランザクションが閉じられるのを待つ - トランザクションを張るのに使用した
SQLiteDatabase
オブジェクトと別のオブジェクトを使用しているならば、Cursor
に対するメソッド呼び出しの時点でトランザクションが閉じられるのを待つ - ちなみに数秒待ち続けても
SQLiteDatabaseLockedException
例外 *5 は発生しない
- トランザクションを張るのに使用した
- あるスレッドにおいて
SQLiteDatabase#beginTransactionNonExclusive
でトランザクションが張られている場合に、別のスレッドでSQLiteDatabase#query
メソッドを呼び出し、返り値のCursor
オブジェクトのgetCount
やmoveToFirst
メソッドを呼び出すという処理をする場合- トランザクションを張るのに使用した
SQLiteDatabase
オブジェクトを使用しているならば、query
メソッド呼び出しの時点でトランザクションが閉じられるのを待つ - トランザクションを張るのに使用した
SQLiteDatabase
オブジェクトと別のオブジェクトを使用しているならば、トランザクションが閉じられるのを待たずに結果を得られる
- トランザクションを張るのに使用した