SharedPreferences と MODE_MULTI_PROCESS がイマイチよろしくない件

運悪くAndroidで複数プロセスのアプリを作ったり、アプリ間で SharedPreferences を相互参照するハメになってしまった場合に役に立つ…いや、たぶん立たないメモ。

Context#MODE_MULTI_PROCESS フラグはどのように使われているか

このフラグはContext#getSharedPreferences(String name, int mode) の第二引数に設定するもので、API Level 11で設定された。Context  |  Android Developersではこう説明されている。

SharedPreference loading flag: when set, the file on disk will be checked for modification even if the shared preferences instance is already loaded in this process. This behavior is sometimes desired in cases where the application has multiple processes, all writing to the same SharedPreferences file. Generally there are better forms of communication between processes, though.

This was the legacy (but undocumented) behavior in and before Gingerbread (Android 2.3) and this flag is implied when targetting such releases. For applications targetting SDK versions greater than Android 2.3, this flag must be explicitly set if desired.

実際にこのフラグが参照されているのは ContextImpl#getSharedPreferences(String name, int mode) の中だけだ。他の箇所では一切参照されていない。

  @Override
    public SharedPreferences getSharedPreferences(String name, int mode) {
        SharedPreferencesImpl sp;
        synchronized (sSharedPrefs) {
            sp = sSharedPrefs.get(name);
            if (sp == null) {
                File prefsFile = getSharedPrefsFile(name);
                sp = new SharedPreferencesImpl(prefsFile, mode);
                sSharedPrefs.put(name, sp);
                return sp;
            }
        }
        if ((mode & Context.MODE_MULTI_PROCESS) != 0 ||
            getApplicationInfo().targetSdkVersion < android.os.Build.VERSION_CODES.HONEYCOMB) {
            // If somebody else (some other process) changed the prefs
            // file behind our back, we reload it. This has been the
            // historical (if undocumented) behavior.
            sp.startReloadIfChangedUnexpectedly();
        }
        return sp;
    }

SharedPreferencesImpl#startReloadIfChangedUnexpectedly は内部で hasFileChangedUnexpectedly() を呼び出していて、そこでは前回から自分が書き込みを行ったかどうか確認したり、ファイルのmtimeとsize が前回ロードしたものと同じかどうか確認したりしている。

まとめるど、このフラグは「getSharedPreferences を呼び出した時にmtimeとsizeを見て適当にロードし直す」というものだ。他の効果は一切ないし、自プロセスが書き込みを行なっていた場合もロードし直さないし、単に0を1に変えるような変更を前回のロードから1秒未満に行った場合もロードし直さない。端末の時計が補正された場合なんかもきっとうまくないケースがあるだろう。穴だらけとしか思えない実装なのだが、今後改善される可能性もあるのでフラグの存在自体は尊重するべきかもしれない。

ついでに言うと、もしあなたが取得した SharedPreferences をどこかクラス内のフィールドに保存しているのなら、それは再利用の際に別プロセスからのアクセスをチェックしていないまずいコードだということになる。

…困ったことに、PreferenceManager がまさにそういう実装になっている。

嫌な汗が流れてきたが、まだ話は終わらない。

では PreferenceManager を使っていないアプリでは MODE_MULTI_PROCESS をちゃんと設定して、かつ頻繁にgetSharedPreferencesを呼び出していれば問題はないのかというと、そんなことはなかった。

バックアップファイルの取り扱い

SharedPreferencesImpl#loadFromDiskLocked() と SharedPreferencesImpl#writeToFile() の以下のコードを見てほしい。

private void loadFromDiskLocked() {
//(snip)
    if (mBackupFile.exists()) {
        mFile.delete();
        mBackupFile.renameTo(mFile);
    }
//(snip)
    if (FileUtils.getFileStatus(mFile.getPath(), stat) && mFile.canRead()) {
//(snip) ここでファイルから読み込む
    }
//(snip)
}

// Note: must hold mWritingToDiskLock
private void writeToFile(MemoryCommitResult mcr) {
    // Rename the current file so it may be used as a backup during the next read
    if (mFile.exists()) {
//(snip)
        if (!mBackupFile.exists()) {
            if (!mFile.renameTo(mBackupFile)) {
                Log.e(TAG, "Couldn't rename file " + mFile
                      + " to backup file " + mBackupFile);
                mcr.setDiskWriteResult(false);
                return;
            }
        } else {
            mFile.delete();
        }
    }

    // Attempt to write the file, delete the backup and return true as atomically as
    // possible. If any exception occurs, delete the new file; next time we will restore
    // from the backup.
    try {
        FileOutputStream str = createFileOutputStream(mFile);
//(snip)
        // Writing was successful, delete the backup file if there is one.
        mBackupFile.delete();
//(snip)
        return;
    } catch (XmlPullParserException e) {
        Log.w(TAG, "writeToFile: Got exception:", e);
    } catch (IOException e) {
        Log.w(TAG, "writeToFile: Got exception:", e);
    }
    // Clean up an unsuccessfully written file
    if (mFile.exists()) {
        if (!mFile.delete()) {
            Log.e(TAG, "Couldn't clean up partially-written file " + mFile);
        }
    }
//(snip)
}

この間、プロセス間で有効なロックの類は全く行われていない。書き込みに関しては synchronized(mWritingToDiskLock){ ... } で外側を囲まれているが、それはスレッド間のロックであってプロセス間のロックではない。

ロード中に mFile.delete(); やセーブ中に mBackupFile.delete(); が行われていて別プロセスからファイルの存在をチェックする際に状態が安定しないし、出力中のファイルを入力用にopen,readしてしまう可能性も排除できない。片方のプロセスがロードしか行わない場合であっても、ロード側がdeleteやrenameToを行なっているのでダメだ。つまりこのコードだと、複数プロセスからのロード/セーブのタイミングが重なるとマトモに動作する訳がない

実際にコレで問題が発生したケースも体験した。

回避策

SharedPreferences はクラスではなくインタフェースなので、派生クラスの実装を独自に行うことで、既存コードへの影響を軽微に抑えつつ問題の改善を図ることができる。

実装の内容としてはSQLiteデータベースを使うのも手だが、あれはあれでプロセス間の排他は問題ないのだがスレッド間の排他でいくつか考慮しないといけないことがある。更新チェックが頻繁にかかるような場合はコスト的にも厳しい。

今回はflockでロックして複数プロセスからの読み書きを同時に行わないようにして、さらに他プロセスからの更新の確認をmmapを使うことで高速化するような SharedPreferences を実際に書いてみた。Android 1.6 から 4.0.2 まで動作する。

GitHub - tateisu/android-MultiProcessPreferences

ただし、短時間ででっち上げたせいでいくつか制限がある。

  • 呼び出し元のスレッドでファイルアクセスを行うからSTRICTモードだと警告が出る
  • API Level 9 で追加されたapply() メソッド、つまりデータ保存をバックグラウンド処理するメソッドに対応していない。
  • データ更新リスナーに対応していない

まあPreference のUI系クラスを使わなければ概ね許容できる…と言えなくもないし、SharedPreferences#getAll で取得したデータをインポートできるようになっていれば、設定画面のonPauseをフックしてデータのインポート/エクスポートを行うなどして対応できなくもない。

まとめ

そもそもAndroidアプリで複数プロセスから SharedPreferences にアクセスするケースは限られている。アプリ間の連携で必要になるか、プロセスヒープの制限を回避したいか、常駐プロセスのサイズを軽減したいかのいずれかだ。もし運悪くあなたがそういったアプリの開発に関わった場合、 SharedPreferences がプロセス間の排他をマトモに実装していないことは覚えておいた方がいいかもしれない。