MMKV的原理-如何實現跨進程(2)

接上一篇:https://blog.csdn.net/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();
        }
    }
}

所以,通過校驗文件,在讀取數據時,來做校驗,就實現了多個進程的數據同步.

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章