SharedPreferences 多进程问题
SharedPreferences作为一种数据持久化的方式,是处理简单的key-value类型数据时的首选。但是有时候需要在多进程中共享数据时,如果用SharedPreferences会不会有什么问题?即SharedPreferences是否是进程安全的?
SharedPreferences如何实现多进程访问?
谈论进程安全前,先看下SharedPreferences如何实现多进程。我们知道每个SharedPreferences都对应了当前package的data/data/package_name/share_prefs/目录下的一个文件,要让多个进程可访问这个文件,必然需要修改其访问权限,看下SharedPreferences提供了哪些选项, 在Context.java中提供了以下几个mode:
int MODE_PRIVATE = 0x0000;
这是默认模式,仅caller uid的进程可访问
int MODE_WORLD_READABLE = 0x0001;
所有人可写,也就是任何应用都可修改它,这是极其危险的,因此改选项已被Deprected
int MODE_WORLD_READABLE = 0x0002
所有人可读,这个参数同样非常危险,可能导致隐私数据泄漏
int MODE_MULTI_PROCESS = 0x0004;
设置该参数后,每次获取对应的SharedPreferences时都会尝试从磁盘中读取修改过的文件
多进程访问主要有两种情况:
1.不同apk(不同packageName,且不具有相同uid)的多进程:
由于linux文件权限是根据uid设置访问权限,因此必须设置mode为MODE_WORLD_READABLE或MODE_WORLD_WRITABLE,取决于别的应用是否有需要需改该SharedPreferences,由于这种方式需要修改文件权限,且会让所有人都具访问权限,无法只对某个应用授权,所以非常危险,android N上对targetsdk >= 24的应用已明确禁止这两个mode,本文不再做过多解释
2.同一个packageName或具有相同uid的package里面存在多个进程:
这种情况下多个进程具有相同的uid,因此不需要修改文件权限,没有权限安全性问题。目前很多apk都支持多进程,例如后台服务与前台页面运行在独立的进程。这种情况是本文重点分析的。
是否需要设置MODE_MULTI_PROCESS?
先看下这个mode具体描述
/**
* 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.
*
* <p>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 <em>greater than</em> Android 2.3, this flag must be
* explicitly set if desired.
*
* @see #getSharedPreferences
*
* @deprecated MODE_MULTI_PROCESS does not work reliably in
* some versions of Android, and furthermore does not provide any
* mechanism for reconciling concurrent modifications across
* processes. Applications should not attempt to use it. Instead,
* they should use an explicit cross-process data management
* approach such as {@link android.content.ContentProvider ContentProvider}.
*/
当设置这个参数的时候,即使当前进程内已经创建了该SharedPreferences,仍然在每次获取的时候都会尝试从本地文件中刷新。在同一个进程中,同一个文件只有一个实例。MODE_MULTI_PROCESS的作用
ContextImpl.java
public SharedPreferences getSharedPreferences(String name, int mode) {
//code: new instance if not exists
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;
}
这个方法先判断是否已创建SharedPreferences实例,若未创建,则先创建。之后判断mode如果为MODE_MULTI_PROCESS, 则调用startReloadIfChangeUnexpectedly(),看下其实现:
SharedPreferencesImpl.java
void startReloadIfChangedUnexpectedly() {
synchronized (this) {
// TODO: wait for any pending writes to disk?
if (!hasFileChangedUnexpectedly()) {
return;
}
startLoadFromDisk();
}
}
最终调用startLoadFromDisk()
private void startLoadFromDisk() {
synchronized (this) {
mLoaded = false;
}
new Thread("SharedPreferencesImpl-load") {
public void run() {
synchronized (SharedPreferencesImpl.this) {
loadFromDiskLocked();
}
}
}.start();
}
可以看出MODE_MULTI_PROCESS的作用就是在每次获取SharedPreferences实例的时候尝试从磁盘中加载修改过的数据,并且读取是在异步线程中,因此一个线程的修改最终会反映到另一个线程,但不能立即反映到另一个进程,所以通过SharedPreferences无法实现多进程同步。
综合: 如果仅仅让多进程可访问同一个SharedPref文件,不需要设置MODE_MULTI_PROCESS, 如果需要实现多进程同步,必须设置这个参数,但也只能实现最终一致,无法即时同步。
为什么不推荐使用MODE_MULTI_PROCESS?
android文档已经Deprected了这个flag,并且说明不应该通过SharedPreference做进程间数据共享?这是为啥呢?从前面但分析可看到当设置这个flag后,每次获取(获取而不是初次创建)SharedPreferences实例的时候,会判断shared_pref文件是否修改过:
private boolean hasFileChangedUnexpectedly() {
synchronized (this) {
if (mDiskWritesInFlight > 0) {
// If we know we caused it, it's not unexpected.
if (DEBUG) Log.d(TAG, "disk write in flight, not unexpected.");
return false;
}
}
final StructStat stat;
try {
/*
* Metadata operations don't usually count as a block guard
* violation, but we explicitly want this one.
*/
BlockGuard.getThreadPolicy().onReadFromDisk();
stat = Os.stat(mFile.getPath());
} catch (ErrnoException e) {
return true;
}
synchronized (this) {
return mStatTimestamp != stat.st_mtime || mStatSize != stat.st_size;
}
}
这里先判断mDiskWritesInFlight>0,如果成立,说明是当前进程修改了文件,不需要重新读取。然后通过文件最后修改时间,判断文件是否修改过。如果修改了,则重新读取:
private void startLoadFromDisk() {
synchronized (this) {
mLoaded = false;
}
new Thread("SharedPreferencesImpl-load") {
public void run() {
synchronized (SharedPreferencesImpl.this) {
loadFromDiskLocked();
}
}
}.start();
}
private void loadFromDiskLocked() {
if (mLoaded) {
return;
}
if (mBackupFile.exists()) {
mFile.delete();
mBackupFile.renameTo(mFile);
}
Map map = null;
StructStat stat = null;
try {
stat = Os.stat(mFile.getPath());
if (mFile.canRead()) {
BufferedInputStream str = null;
try {
str = new BufferedInputStream(
new FileInputStream(mFile), 16*1024);
map = XmlUtils.readMapXml(str);
} finally {
IoUtils.closeQuietly(str);
}
}
} catch (ErrnoException e) {
}
mLoaded = true;
if (map != null) {
mMap = map;
mStatTimestamp = stat.st_mtime;
mStatSize = stat.st_size;
} else {
mMap = new HashMap<String, Object>();
}
notifyAll();
}
重点是这段:
if (mBackupFile.exists()) {
mFile.delete();
mBackupFile.renameTo(mFile);
}
重新读取时,如果发现存在mBackupFile,则将原文件mFile删除,并将mBackupFile重命名为mFile。mBackupFile又是如何创建的呢?答案是在修改SharedPreferences时将内存中的数据写会磁盘时创建的:
private void writeToFile(MemoryCommitResult mcr) {
// Rename the current file so it may be used as a backup during the next read
if (mFile.exists()) {
if (!mBackupFile.exists()) {
if (!mFile.renameTo(mBackupFile)) {
mcr.setDiskWriteResult(false);
return;
}
} else {
mFile.delete();
}
}
FileOutputStream str = createFileOutputStream(mFile);
XmlUtils.writeMapXml(mcr.mapToWriteToDisk, str);
FileUtils.sync(str);
str.close();
ContextImpl.setFilePermissionsFromMode(mFile.getPath(), mMode, 0);
final StructStat stat = Os.stat(mFile.getPath());
synchronized (this) {
mStatTimestamp = stat.st_mtime;
mStatSize = stat.st_size;
}
// Writing was successful, delete the backup file if there is one.
mBackupFile.delete();
mcr.setDiskWriteResult(true);
return;
}
这段代码只保留了核心流程,忽略了错误处理流程。可以看到,写文件的步骤大致是:
- 将原文件重命名为mBackupFile
- 重新创建原文件mFile, 并将内容写入其中
- 删除mBackupFile
所以,只有当一个进程正处于写文件的过程中的时候,如果另一个进程读文件,才会看到mBackupFile, 这时候读进程会将mBackupFile重命名为mFile, 这样读结果是,读进程只能都到修改前的文件,同时,由于mBackupFile重命名为了mFile, 所以写进程写那个文件就没有文件名引用了,因此其写入的内容无法再被任何进程访问到。所以其内容丢失了,可认为写入失败了,而SharedPreferences对这种失败情况没有任何重试机制,所以就可能出现数据丢失的情况。
回到这段的重点:为什么不推荐用MODE_MULTI_PROCESS?从前面分析可知,这种模式下,每次获取SharedPreferences都会检测文件是否改变,只要读的时候另一进程在写,就会导致写丢失。这样失败概率就会大幅度提高。反之,若不设置这个模式,则只在第一次创建SharedPreferences的时候读取,导致写失败的概率就会大幅度降低,当然,仍然存在失败的可能。
为什么不做写失败重试?
为毛android不做写失败重试呢?原因是写进程并不能发现写失败的情况。难道写的过程中,目标文件被删不会抛异常吗?答案是不会。删除文件只是从文件系统中删除了一个节点信息而已,重命名也是新建了一个具有相同名称的节点信息,并把文件地址指向另一个磁盘地址而已,原来,之前的写过程仍然会成功写到原来的磁盘地址。所以目前的实现方案并不能检测到失败。
有没有办法解决写失败呢?
个人觉得是可以做到的,读里面读那段关键操作:
if (mBackupFile.exists()) {
mFile.delete();
mBackupFile.renameTo(mFile);
}
mBackupFile存在,意味着当前正处于写读过程中,这时候是不是可以考虑直接读mBackupFile文件,而不删除mFile呢?这样读话,读取效果一样,都是读的mBackupFile,同时写进程写的mFile也不会被mBacupFile覆盖,写也就能成功了。即使通过这段代码重命名,写进程写完后发现mBackupFile不存在了,其实也能认为发生了读重命名,大可以重试一次。
读文件过程中,文件被删除会导致读失败吗?
不会!与重命名一样,文件被删除只是删掉节点信息,磁盘上的文件仍然存在,知道所有打开文件的fd都释放,文件才会真正被删除。