MMKV的原理-如何实现跨进程(2)

接上一篇:https://blog.youkuaiyun.com/lin20044140410/article/details/104450727

在mmkv的使用中,肯定是有多线程,多进程的同步问题,有同步问题就肯定会用到锁,所以先从mmkv中锁的使用说起,mmkv处理线程的同步使用了mutex互斥锁, 比如在从集合中获取mmkv的c++层的对象时,就加了锁,因为可能会有多个线程同时操作的情况;

处理进程间的同步时使用了flock文件锁,比如在处理写指针的同步,内存重整时.

以下锁的使用都是在native层.

1,先看下互斥锁是怎么用的,这个demo是展示线程锁的使用,进一步引出mmkv对锁的使用时怎么封装的。

queue<int> m_queue;

void *dequeue(void *args) {
    if (!m_queue.empty()) {
        printf("dequeue data :%d \n", m_queue.front());
        __android_log_print(ANDROID_LOG_DEBUG,"mmap","nativeThreadTest,dequeue data :%d \n", m_queue.front());
        m_queue.pop();
    } else {
        __android_log_print(ANDROID_LOG_DEBUG,"mmap","nativeThreadTest,no data");
    }
    return 0;
}

extern "C"
JNIEXPORT void JNICALL
Java_com_test_mmkvdemo_MainActivity_nativeThreadTest(JNIEnv *env, jobject instance) {
    for (int i = 0; i < 5; ++i) {
        m_queue.push(i);
    }

    pthread_t tid[10];
    for (int i = 0; i < 10; ++i) {
        pthread_create(&tid[i], 0, dequeue, &m_queue);
    }

   __android_log_print(ANDROID_LOG_DEBUG,"mmap","nativeThreadTest,m_queue," );
}

在这个demo中,先往一个队列放了5个元素,然后起了10个线程来取队列中的元素,

2020-02-24 21:49:29.213 20930-20949/? D/mmap: nativeThreadTest,dequeue data :0 ,tid= 3529775472
2020-02-24 21:49:29.214 20930-20948/? D/mmap: nativeThreadTest,dequeue data :1 ,tid= 3530815856
2020-02-24 21:49:29.214 20930-20950/? D/mmap: nativeThreadTest,dequeue data :1 ,tid= 3528735088
2020-02-24 21:49:29.214 20930-20951/? D/mmap: nativeThreadTest,dequeue data :3 ,tid= 3527694704
2020-02-24 21:49:29.215 20930-20953/? D/mmap: nativeThreadTest,dequeue data :4 ,tid= 3525613936
2020-02-24 21:49:29.215 20930-20954/? D/mmap: nativeThreadTest,dequeue data :4 ,tid= 3524573552
2020-02-24 21:49:29.215 20930-20952/? D/mmap: nativeThreadTest,dequeue data :-374526740 ,tid= 3526654320
2020-02-24 21:49:29.216 20930-20956/? D/mmap: nativeThreadTest,dequeue data :-374526724 ,tid= 3522492784
2020-02-24 21:49:29.216 20930-20930/? D/mmap: nativeThreadTest,m_queue,
2020-02-24 21:49:29.216 20930-20955/? D/mmap: nativeThreadTest,dequeue data :-374526708 ,tid= 3523533168
2020-02-24 21:49:29.216 20930-20957/? D/mmap: nativeThreadTest,dequeue data :-374526692 ,tid= 3521452400

从输出看,线程取出的数据出现了重复,并且还可能取出未知的数据,这是因为多线程去操作共享数据没有加锁,导致的脏读。

给操作pop的方法加上互斥锁,可以解决脏读的问题,这里用的是pthead_mutex_t互斥量,这个锁默认是非递归锁,也就是不可重入,对于不可重入锁,如果一个线程内多次获取同一个这样的锁,就会产生死锁,所以在pthread_mutex_t锁初始化时,需要设置属性pthread_mutexattr_t,把它设置为递归锁。 pthread_mutexattr_settype(&m_attr, PTHREAD_MUTEX_RECURSIVE);

queue<int> m_queue;
pthread_mutex_t m_mutex;
pthread_mutexattr_t m_attr;

void *dequeue(void *args) {
    pthread_mutex_lock(&m_mutex);
    pthread_t tid = pthread_self();
    if (!m_queue.empty()) {
        printf("dequeue data :%d \n", m_queue.front());
        __android_log_print(ANDROID_LOG_DEBUG,"mmap","nativeThreadTest,dequeue data :%d ,tid= %u\n",
                m_queue.front(), (unsigned int)tid);
        m_queue.pop();
    } else {
        __android_log_print(ANDROID_LOG_DEBUG,"mmap","nativeThreadTest,no data,tid= %u\n",(unsigned int)tid);
    }
    pthread_mutex_unlock(&m_mutex);
    return 0;
}

extern "C"
JNIEXPORT void JNICALL
Java_com_test_mmkvdemo_MainActivity_nativeThreadTest(JNIEnv *env, jobject instance) {
    pthread_mutexattr_init(&m_attr);
    pthread_mutexattr_settype(&m_attr, PTHREAD_MUTEX_RECURSIVE);
    pthread_mutex_init(&m_mutex, &m_attr);
    pthread_mutexattr_destroy(&m_attr);
    for (int i = 0; i < 5; ++i) {
        m_queue.push(i);
    }

    pthread_t tid[10];
    for (int i = 0; i < 10; ++i) {
        pthread_create(&tid[i], 0, dequeue, &m_queue);
    }
    pthread_mutex_destroy(&m_mutex);

   __android_log_print(ANDROID_LOG_DEBUG,"mmap","nativeThreadTest,m_queue," );
}

锁使用过程中,调用pthread_mutex_lock(&m_mutex); 上锁,一定要记得调用pthread_mutex_unlock(&m_mutex);解锁,如果不小心忘记了解锁,那肯定就悲剧了,而且如果要加个锁,需要写那么多代码,肯定也会不爽,所以就有了对锁的封装,实现锁的自动管理。

先用c++类的构造方法和析构方法进行封装,在构造函数中进行锁及属性的初始化,在析构函数中销毁锁对象。

class ThreadLock {
private:
    pthread_mutex_t m_lock;

public:
    ThreadLock() {
        pthread_mutexattr_t attr;
        pthread_mutexattr_init(&attr);
        pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);
        pthread_mutex_init(&m_lock, &attr);
        pthread_mutexattr_destroy(&attr);
    }

    ~ThreadLock() {
        pthread_mutex_destroy(&m_lock);
    }

    void lock() {
        auto result = pthread_mutex_lock(&m_lock);
        if (result != 0) {
            //failed.
        }
    }

    bool try_lock() {
        auto result = pthread_mutex_lock(&m_lock);
        return  (result == 0);
    }

    void unlock() {
        auto result = pthread_mutex_unlock(&m_lock);
        if (0 != result){
            //failed.
        }
    }
};

这样封装了以后,尽管简单了很多,但是依然可能会因为忘记unlock,导致悲剧,为了确保使用过程中不至于lock之后,忘记unlock,就需要更进一步的封装,就是在创建一个class,让他持有一个ThreadLock的对象,在这个类的构造方法中上锁,在其析构方法中解锁.

template <typename T>
class ScopedLock {
    T *m_lock;

    // just forbid it for possibly misuse
    ScopedLock(const ScopedLock<T> &other) = delete;

    ScopedLock &operator=(const ScopedLock<T> &other) = delete;

public:
    ScopedLock(T *oLock) : m_lock(oLock) {
        assert(m_lock);
        lock();
    }

    ~ScopedLock() {
        unlock();
        m_lock = nullptr;
    }

    void lock() {
        if (m_lock) {
            m_lock->lock();
        }
    }

    bool try_lock() {
        if (m_lock) {
            return m_lock->try_lock();
        }
        return false;
    }

    void unlock() {
        if (m_lock) {
            m_lock->unlock();
        }
    }
};

如此封装后,使用方法就是在代码块中仅需要创建锁对象即可实现上锁,在代码块结束,会随着锁对象生命周期结束,调用其析构函数释放锁.

{
   ThreadLocak * thread_lock = new ThreadLock();
   ScopedLock scoped_lock(thread_lock);
}

ScopedLock类是一个模板类,实际在mmkv中,文件锁中读锁和写锁,及线程锁都是通过这个模板类使用的.

上面的封装对锁的使用已经非常方便了,然后mmkv又让锁的使用更加的便捷,那就是又定义了宏函数简化了锁的使用.

#define SCOPEDLOCK(lock) _SCOPEDLOCK(lock, __COUNTER__)
#define _SCOPEDLOCK(lock, counter) __SCOPEDLOCK(lock, counter)
#define __SCOPEDLOCK(lock, counter) ScopedLock<decltype(lock)> __scopedLock##counter(&lock)

第一个宏函数,把ThreadLock对象传进去,会调用第二个宏函数,

第二个宏函数,除了传入的ThreadLock参数外,还加了一个编译器中宏定义,__COUNTER__在编译时会被替换成具体的值0,1,2,...

它就相当于一个计数器,记录了这个宏函数在一个编译单元中被调用了多少次.

第三个宏函数,就是创建了一个具体的锁对象,

ScopedLock<decltype(lock)> __scopedLock##counter(&lock)

decltype(lock)获取lock的具体类型,加上计数值,就得到了一个ScopedLock类型的锁对象,

第一次调用 __scopedLock0,

第二次调用 __scopedLock1,

第三次调用 __scopedLock2 ,....

 

2,接着看下文件锁.

尽管pthread_mutex可以用作进程锁,但是因为andorid版本的pthread_mutex不够健壮,如果加了pthread_mutex锁的进程被kill了,系统不会进行清理工作,这个锁会一直存在下去,其他等待锁的进程会被饿死.

https://github.com/Tencent/MMKV/wiki/android_ipc

多进程的实现中用到了文件锁,多进程同时操作一个文件时,可能存在A进程去写这个文件,而B进程去读这个文件,就会导致B进程读取到的是脏数据.为了保证B进程可以读取到最新的数据,并且保证数据的完整性,使用文件锁来完成多进程操作文件的同步.

文件锁的使用:

#include <sys/file.h>
//通过open方法打开一个文件
string m_path;
int m_fd = open(m_path.c_str(), O_RDWR | O_CREAT, S_IRWXU);
//通过文件句柄对文件上锁
int flock(m_fd, operation);

其中的参数operation是上锁的类型,flock支持两中锁类型LOCK_SH,LOCK_EX

LOCK_SH, 共享锁,多个进程可以同时使用,可以作为读锁.

LOCK_EX,排他锁,同时只允许一个进程使用,可以作为写锁.

LOCK_UN, 解锁

LOCK_BN, 非阻塞请求, 与读写锁配合使用.

使用flock对一个文件上读锁,或者写锁,都是会阻塞的,比如A进程对持有一个文件的写锁,B进程想要对这个文件上写锁,就会阻塞住,如果不想被阻塞,可以配合LOCK_BN属性使用,即LOCK_BN | LOCK_EX.

flock有几个特点:

1)flock支持对一个文件多次上锁,并且因为是状态锁,没有计数器,不管加了多少次锁,都只需要解锁一次.所以,mmkv中对flock封装时,加了计数器,就是保证上了几次锁,就要执行几次解锁.

2)锁升级,降级,当一个进程对一个文件加了读锁后,如果再次执行flock操作,传入的operation是LOCK_EX,那么这个进程对文件的读锁就升级为了写锁,这就是锁升级,反之,就是锁降级,但是文件锁降级是无法进行的,因为他不支持递归,导致一降级就没锁了.

为了解决上面的问题,mmkv对文件锁进行了封装,增加了读写锁计数器,支持递归.

结合源码看mmkv是怎么多的.

InterProcessLock.h

class FileLock {
    int m_fd;
    size_t m_sharedLockCount;
    size_t m_exclusiveLockCount;

    bool doLock(LockType lockType, bool wait);
}

增加了读写锁计数器,通过调用doLock对文件上锁.locktype决定了上锁的类型,第二个参数是否阻塞.所以重点就是doLock是怎么实现的.

上锁的处理逻辑:

1),//先判断上的是什么锁
//上读锁,计数器先加1,如果加1后,读锁计数器大于1了,说明已经上过读锁了,为了实现递归,这种情况就不在执行上锁,只是增加了计数器,如果这个文件的写锁计数器大于0,说明已经上过写锁了,当前还在用写锁,所以不
//执行上读锁的操作,会导致锁降级,所以直接返回
//如果是第一次上读锁,后面会执行加锁操作

2),/上写锁,计数器先加1,判断如果已经上过写锁,直接return.
//如果读锁计数器大于0,这时会先尝试上写锁,如果失败了,说明有别的进程在使用这个文件的读锁,
//这种情况就要先把自己的读锁释放掉,再去以阻塞的形式上写锁,这么做的原因是为了避免死锁.
//假设,A进程没有解自己的读锁,直接上写锁在这里阻塞着,如果别的进程B进程刚好释放了读锁,那么当前进程//A进程上写锁
//就成功了,但是,如果别的进程B集成也要上写锁,因为A进程的读锁没有释放,会导致B进程上写锁的操作一直
//阻塞着,这就是A,B进程都在等对方的读锁释放,而导致死锁.

bool FileLock::doLock(LockType lockType, bool wait) {
    if (!isFileLockValid()) {
        return false;
    }
    bool unLockFirstIfNeeded = false;
   //先判断上的是什么锁
//上读锁,计数器先加1,如果加1后,读锁计数器大于1了,说明已经上过读锁了,为
了实现递归,这种情况就不在执行上锁,只是增加了计数器,如果这个文件的写锁计数器大于0,
说明已经上过写锁了,当前还在用写锁,所以不
//执行上读锁的操作,会导致锁降级,所以直接返回
//如果是第一次上读锁,后面会执行加锁操作
    if (lockType == SharedLockType) {
        m_sharedLockCount++;
        // don't want shared-lock to break any existing locks
        if (m_sharedLockCount > 1 || m_exclusiveLockCount > 0) {
            return true;
        }
    } else {
//上写锁,计数器先加1,判断如果已经上过写锁,直接return.
//如果读锁计数器大于0,这时会先尝试上写锁,如果失败了,说明有别的进程在使用这个文件的读锁,
//这种情况就要先把自己的读锁释放掉,再去以阻塞的形式上写锁,这么做的原因是为了避免死锁.
//假设,A进程没有解自己的读锁,直接上写锁在这里阻塞着,如果别的进程B进程刚好释放了读锁,那么当前进程//A进程上写锁
//就成功了,但是,如果别的进程B集成也要上写锁,因为A进程的读锁没有释放,会导致B进程上写锁的操作一直
//阻塞着,这就是A,B进程都在等对方的读锁释放,二导致死锁.
        m_exclusiveLockCount++;
        // don't want exclusive-lock to break existing exclusive-locks
        if (m_exclusiveLockCount > 1) {
            return true;
        }
        // prevent deadlock
        if (m_sharedLockCount > 0) {
            unLockFirstIfNeeded = true;
        }
    }
    return platformLock(lockType, wait, unLockFirstIfNeeded);
}

再看解锁的处理逻辑:

1),//如果是解读锁,读锁计数器为0,直接返回,
//如果读锁计数器 减1后,还大于了,为了实现递归锁,那么上几次读锁,就要解几次读锁,所以直接
//返回,
//如果这里有写锁,也直接返回,因为如果执行LOCK_UN,就把写锁也解了.

2),//如果解写锁,计数器为0,直接返回,
//如果计数器减1后大于0,直接返回
//否则,如果写锁计数器为0了,但是有读锁,也不能直接LOCK_UN,而是要做锁降级,

bool FileLock::unlock(LockType lockType) {
    if (!isFileLockValid()) {
        return false;
    }
    bool unlockToSharedLock = false;
//如果是解读锁,读锁计数器为0,直接返回,
//如果读锁计数器 减1后,还大于了,为了实现递归锁,那么上几次读锁,就要解几次读锁,所以直接
//返回,
//如果这里有写锁,也直接返回,因为如果执行LOCK_UN,就把写锁也解了.
    if (lockType == SharedLockType) {
        if (m_sharedLockCount == 0) {
            return false;
        }
        m_sharedLockCount--;
        // don't want shared-lock to break any existing locks
        if (m_sharedLockCount > 0 || m_exclusiveLockCount > 0) {
            return true;
        }
    } else {
//如果解写锁,计数器为0,直接返回,
//如果计数器减1后大于0,直接返回
//否则,如果写锁计数器为0了,但是有读锁,也不能直接LOCK_UN,而是要做锁降级,
        if (m_exclusiveLockCount == 0) {
            return false;
        }
        m_exclusiveLockCount--;
        if (m_exclusiveLockCount > 0) {
            return true;
        }
        // restore shared-lock when all exclusive-locks are done
        if (m_sharedLockCount > 0) {
            unlockToSharedLock = true;
        }
    }
    return platformUnLock(unlockToSharedLock);
}

3,这样文件锁就封装好了,利用这个文件锁就可以实现同一时间只有一个进程对file进行操作了,但是A进程修改了文件后,B进程怎么知道这个修改呢?

针对上面的问题,mmkv是做的呢? 实际跟我们想象的并不一样,mmkv并没有去对保存key-value数据的那个default文件枷锁,而是锁了个.crc校验文件.这个校验文件就是来解决上面的问题的.

struct MMKVMetaInfo {
    uint32_t m_crcDigest = 0;
    uint32_t m_version = 1;
    uint32_t m_sequence = 0; // full write-back count
    unsigned char m_vector[AES_KEY_LEN] = {0};
}

这个mmkv.default.crc文件中有一个校验码,就是mmkv.default文件的MD5值,通过他可以判断文件是否合法.

还有一个序列号,当序列化文件进行了去重,扩容操作,这个序列号就会增加,

具体看源码是怎么做的?

1)当mmkv初始化时,会读取crc文件,记录其中的校验码,序列号,,

2)当要读取数据时,会通过checkLoadData来做校验.

void MMKV::checkLoadData() {
   //读取校验文件,对比序列号,如果序列号不一致,说明发生了内存重整,
//这时,重新读取整个文件,
    MMKVMetaInfo metaInfo;
    metaInfo.read(m_metaFile.getMemory());
    if (m_metaInfo.m_sequence != metaInfo.m_sequence) {
        SCOPEDLOCK(m_sharedProcessLock);

        clearMemoryState();
        loadFromFile();
        notifyContentChanged();
    }else if (m_metaInfo.m_crcDigest != metaInfo.m_crcDigest) {
    //校验码不匹配,说明数据文件被修改了,可能时数据增加,也可能是去重操作,还可能时文件扩容了
//如果文件大小不一致了,可能发生了扩容,这时重新加载整个文件,
//文件大小一致,说明只是新增了k-v,也即是增量更新,这时只要加载新增加的数据.
        size_t fileSize = 0;
        if (m_isAshmem) {
            fileSize = m_size;
        } else {
            struct stat st = {0};
            if (fstat(m_fd, &st) != -1) {
                fileSize = (size_t) st.st_size;
            }
        }

        if (m_size != fileSize) {
            MMKVInfo("file size has changed [%s] from %zu to %zu",             
           m_mmapID.c_str(), m_size,
                     fileSize);
            clearMemoryState();
            loadFromFile();
        } else {
            partialLoadFromFile();
        }
    }
}

所以,通过校验文件,在读取数据时,来做校验,就实现了多个进程的数据同步.

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值