MMKV原始碼學習

sunshinfight發表於2019-03-16

kv資料持久化需要的功能

假設要設計一個kv的儲存功能:

  1. 首先是可靠性,在各種情況下能夠將kv儲存
  2. 效能的要求,當時是越快越好,儲存佔用的越少越好

MMKV號稱滿足這些特性:

  1. 可靠,實時寫入
  2. 高效能

MMKV原始碼學習

如果撇去高可靠性,可以採取記憶體緩衝的模式,例如先存入dic,然後在合適的時間同步到檔案。這種方式考慮同步的時機是一方面,而且在crash時可能dic未同步到檔案。

如果撇如高效能,可以採用直接的讀寫檔案,例如採用增量式的編碼,將kv寫入檔案,面臨的問題也很明顯,就是頻繁的磁碟io,效率是很低的。

MMKV的設計

MMKV 設計
mmap.png
在記憶體對映後,操作檔案使用指標就可以完成,檔案與對映區的同步由核心完成,MMKV維護著一個<String, AnyObject>的dic,在寫時同時寫入dic和對映區,也就是同時寫入dic和檔案,所以dic和持久化的資料是同步的,既然是同步的,所以讀時直接取dic中的值就好了。

下面對基本流程的總結:

  1. 記憶體對映 mmap
  2. crc校驗
  3. aes加密
  4. 執行緒安全
  5. 記憶體警告

mmap

有關mmap相關的知識和使用可以看這裡。對於常用kv儲存來說,兼顧效能和可靠性

所以由mmap的相關知識和MMKV的設計可以猜想,MMKV使用mmap要做什麼事情:

  1. 對映檔案到記憶體,儲存對映區的指標,方便寫操作(定義了MiniCodedOutputData實現了對data按位元組拷貝到指定區域記憶體)
  2. 從對映區為dic初始化,方便讀操作

mmap在MMKV中的使用:

//  MMKV.mm

- (void)loadFromFile {
	m_fd = open(m_path.UTF8String, O_RDWR, S_IRWXU);    // open  得到檔案描述符m_fd
	if (m_fd < 0) {
		MMKVError(@"fail to open:%@, %s", m_path, strerror(errno));
	} else {
		m_size = 0;
		struct stat st = {};
		if (fstat(m_fd, &st) != -1) {
			m_size = (size_t) st.st_size;   // 獲取檔案大小,為按頁對齊做準備
		}
		// round up to (n * pagesize)  按頁對齊
		if (m_size < DEFAULT_MMAP_SIZE || (m_size % DEFAULT_MMAP_SIZE != 0)) {
			m_size = ((m_size / DEFAULT_MMAP_SIZE) + 1) * DEFAULT_MMAP_SIZE;
			if (ftruncate(m_fd, m_size) != 0) { //  按頁對齊
				MMKVError(@"fail to truncate [%@] to size %zu, %s", m_mmapID, m_size, strerror(errno));
				m_size = (size_t) st.st_size;
				return;
			}
		}
		//  1: 對映記憶體,獲取記憶體中的指標m_ptr
		m_ptr = (char *) mmap(nullptr, m_size, PROT_READ | PROT_WRITE, MAP_SHARED, m_fd, 0);    
		if (m_ptr == MAP_FAILED) {
			MMKVError(@"fail to mmap [%@], %s", m_mmapID, strerror(errno));
		} else {    
			const int offset = pbFixed32Size(0);
			NSData *lenBuffer = [NSData dataWithBytesNoCopy:m_ptr length:offset freeWhenDone:NO];
			@try {
			// 檔案中真正使用的空間有多大,因為檔案被按頁對齊後,真正使用的空間清楚,所以在檔案開始做了記錄
				m_actualSize = MiniCodedInputData(lenBuffer).readFixed32(); 
			} @catch (NSException *exception) {
				MMKVError(@"%@", exception);
			}
			MMKVInfo(@"loading [%@] with %zu size in total, file size is %zu", m_mmapID, m_actualSize, m_size);
			if (m_actualSize > 0) { // 當檔案中有記錄時,如果第一次使用或是已經清理過,實際使用空間將為0
				bool loadFromFile, needFullWriteback = false;
				if (m_actualSize < m_size && m_actualSize + offset <= m_size) { // 檢查檔案是否正常
					if ([self checkFileCRCValid] == YES) {  
						loadFromFile = true;
					} else {    // 校驗失敗後的行為
						loadFromFile = false;
						if (g_callbackHandler && [g_callbackHandler respondsToSelector:@selector(onMMKVCRCCheckFail:)]) {
							auto strategic = [g_callbackHandler onMMKVCRCCheckFail:m_mmapID];
							if (strategic == MMKVOnErrorRecover) {  // 如果校驗失敗後要繼續使用
								loadFromFile = true;    
								needFullWriteback = true;
							}
						}
					}
				} else {    // 根據檔案中記錄,檔案不正常
					MMKVError(@"load [%@] error: %zu size in total, file size is %zu", m_mmapID, m_actualSize, m_size);
					loadFromFile = false;
					if (g_callbackHandler && [g_callbackHandler respondsToSelector:@selector(onMMKVFileLengthError:)]) {
						auto strategic = [g_callbackHandler onMMKVFileLengthError:m_mmapID];
						if (strategic == MMKVOnErrorRecover) {  // 檔案不正常後要繼續使用
							loadFromFile = true;
							needFullWriteback = true;
							[self writeAcutalSize:m_size - offset]; // 重新記錄下檔案的相關資訊
						}
					}
				}
				if (loadFromFile) { // 假定檔案是正常的,從檔案中讀取
					NSData *inputBuffer = [NSData dataWithBytesNoCopy:m_ptr + offset length:m_actualSize freeWhenDone:NO];
					if (m_cryptor) {
						inputBuffer = decryptBuffer(*m_cryptor, inputBuffer);
					}
					// 2. 初始化m_dic
					//  如果檔案存在錯誤(例如crc校驗不通過),會導致資料錯誤或是丟失
					m_dic = [MiniPBCoder decodeContainerOfClass:NSMutableDictionary.class withValueClass:NSData.class fromData:inputBuffer];
					//  定位到檔案尾部
					m_output = new MiniCodedOutputData(m_ptr + offset + m_actualSize, m_size - offset - m_actualSize);
					// 如果檔案存在錯誤,decode到m_dic過程中可能會丟棄部分資料,所以要將m_dic,保證m_dic與檔案的同步
					if (needFullWriteback) {    
						[self fullWriteback];
					}
				} else {    // 檔案不正常且不打算恢復,需要重建,丟棄原來的資料
					[self writeAcutalSize:0];
					m_output = new MiniCodedOutputData(m_ptr + offset, m_size - offset);
					[self recaculateCRCDigest];
				}
			} else {    //  檔案中沒有kv,沒有必要讀入dic
				m_output = new MiniCodedOutputData(m_ptr + offset, m_size - offset);
				[self recaculateCRCDigest];
			}
			MMKVInfo(@"loaded [%@] with %zu values", m_mmapID, (unsigned long) m_dic.count);
		}
	}
	if (m_dic == nil) {
		m_dic = [NSMutableDictionary dictionary];
	}

    
	if (![self isFileValid]) { 
		MMKVWarning(@"[%@] file not valid", m_mmapID);
	}

    // 修改檔案的屬性
	tryResetFileProtection(m_path);
	tryResetFileProtection(m_crcPath);
	m_needLoadFromFile = NO;
}
複製程式碼

寫入

為了保證效能,採用增量寫入的方式,這需要編解碼支援,MMKV的實現了一套增量編解碼方案。增量編碼是基於效能的考慮,不用將m_dic中的資料全部寫回。

所以寫入要實現的功能:

  1. 將kv寫入m_dic
  2. 檢查檔案剩餘空間是否夠,不夠的話按照一定的策略分配(分配策略會選擇犧牲少量磁碟空間換取效率,並且會整理kv防止佔用過大儲存空間),將kv寫入記憶體對映區域,保持兩者同步

在iOS的中,當app進入後臺後,記憶體可能會被swap出,提供給活躍的app,這樣會降低效率(因為要再換進記憶體呀),MMKV提供了後臺防寫的功能(基於效能考慮):

// MMKV.mm

/// 提供對對映記憶體的保護,防止被系統交換

- (BOOL)protectFromBackgroundWritting:(size_t)size writeBlock:(void (^)(MiniCodedOutputData *output))block {
	if (m_isInBackground) { // 如果在後臺,鎖定要寫入的記憶體,防止被換出,影響效率
        // 因為mlock的offset是以頁為單位的,所以要計算鎖定的頁偏移
		static const int offset = pbFixed32Size(0);
		static const int pagesize = getpagesize();
		size_t realOffset = offset + m_actualSize - size;
		size_t pageOffset = (realOffset / pagesize) * pagesize;
		size_t pointerOffset = realOffset - pageOffset;
		size_t mmapSize = offset + m_actualSize - pageOffset;
		char *ptr = m_ptr + pageOffset;
        // 鎖定要寫入的記憶體區域
		if (mlock(ptr, mmapSize) != 0) {
			MMKVError(@"fail to mlock [%@], %s", m_mmapID, strerror(errno));
			// just fail on this condition, otherwise app will crash anyway
			//block(m_output);
			return NO;
		} else {
			@try {
				MiniCodedOutputData output(ptr + pointerOffset, size);
				block(&output);
				m_output->seek(size);
			} @catch (NSException *exception) {
				MMKVError(@"%@", exception);
				return NO;
			} @finally {
				munlock(ptr, mmapSize);
			}
		}
	} else {
		block(m_output);    // 未在後臺,不需要鎖定
	}

	return YES;
}
複製程式碼
// MMKV.mm 
// 檢查檔案剩餘空間是否夠,不夠的話按照一定的策略分配

- (BOOL)ensureMemorySize:(size_t)newSize {
	[self checkLoadData];

	if (![self isFileValid]) {
		MMKVWarning(@"[%@] file not valid", m_mmapID);
		return NO;
	}

	if (newSize >= m_output->spaceLeft()) {
		// try a full rewrite to make space
		static const int offset = pbFixed32Size(0);
		NSData *data = [MiniPBCoder encodeDataWithObject:m_dic];
		size_t lenNeeded = data.length + offset + newSize;
		size_t avgItemSize = lenNeeded / std::max<size_t>(1, m_dic.count);
        // 將要使用的空間,持續擴容一半直到足夠,並在擴容後,重新對映
		size_t futureUsage = avgItemSize * std::max<size_t>(8, m_dic.count / 2);
		// 1. no space for a full rewrite, double it
		// 2. or space is not large enough for future usage, double it to avoid frequently full rewrite
		if (lenNeeded >= m_size || (lenNeeded + futureUsage) >= m_size) {
			size_t oldSize = m_size;
			do {
				m_size *= 2;
			} while (lenNeeded + futureUsage >= m_size);
			MMKVInfo(@"extending [%@] file size from %zu to %zu, incoming size:%zu, futrue usage:%zu",
			         m_mmapID, oldSize, m_size, newSize, futureUsage);

			// if we can't extend size, rollback to old state
			if (ftruncate(m_fd, m_size) != 0) { //  擴充檔案
				MMKVError(@"fail to truncate [%@] to size %zu, %s", m_mmapID, m_size, strerror(errno));
				m_size = oldSize;
				return NO;
			}
            
            // 檔案大小變了,所以要重新對映,先關閉原來的
			if (munmap(m_ptr, oldSize) != 0) {
				MMKVError(@"fail to munmap [%@], %s", m_mmapID, strerror(errno));
			}
            
            // 從老位置開始對映,因為可能系統沒把這塊記憶體分配出去,可能效率會高一些,沒有找到證明此寫法的詳細資料
			m_ptr = (char *) mmap(m_ptr, m_size, PROT_READ | PROT_WRITE, MAP_SHARED, m_fd, 0);
			if (m_ptr == MAP_FAILED) {
				MMKVError(@"fail to mmap [%@], %s", m_mmapID, strerror(errno));
			}

			// check if we fail to make more space
			if (![self isFileValid]) {
				MMKVWarning(@"[%@] file not valid", m_mmapID);
				return NO;
			}
			// keep m_output consistent with m_ptr -- writeAcutalSize: may fail
			delete m_output;
			m_output = new MiniCodedOutputData(m_ptr + offset, m_size - offset);
			m_output->seek(m_actualSize);
		}

        // 加密
		if (m_cryptor) {
			m_cryptor->reset();
			auto ptr = (unsigned char *) data.bytes;
			m_cryptor->encrypt(ptr, ptr, data.length);
		}

		if ([self writeAcutalSize:data.length] == NO) {
			return NO;
		}

		delete m_output;
		m_output = new MiniCodedOutputData(m_ptr + offset, m_size - offset);
		BOOL ret = [self protectFromBackgroundWritting:m_actualSize // 全量寫回,實現kv的排重
		                                    writeBlock:^(MiniCodedOutputData *output) {
			                                    output->writeRawData(data);
		                                    }];
		if (ret) {
			[self recaculateCRCDigest];
		}
		return ret;
	}
	return YES;
}

複製程式碼
// MMKV.mm 
// 2. 檢查檔案剩餘空間是否夠,不夠的話按照一定的策略分配,將kv寫入記憶體對映區域,保持兩者同步
- (BOOL)appendData:(NSData *)data forKey:(NSString *)key {
	size_t keyLength = [key lengthOfBytesUsingEncoding:NSUTF8StringEncoding];
	size_t size = keyLength + pbRawVarint32Size((int32_t) keyLength); // size needed to encode the key
	size += data.length + pbRawVarint32Size((int32_t) data.length);   // size needed to encode the value

	BOOL hasEnoughSize = [self ensureMemorySize:size];
	if (hasEnoughSize == NO || [self isFileValid] == NO) {
		return NO;
	}
    // 檔案是空的,全量寫入,case與編碼方式相關
	if (m_actualSize == 0) {
		NSData *allData = [MiniPBCoder encodeDataWithObject:m_dic];
		if (allData.length > 0) {
			if (m_cryptor) {
				m_cryptor->reset();
				auto ptr = (unsigned char *) allData.bytes;
				m_cryptor->encrypt(ptr, ptr, allData.length);
			}
			BOOL ret = [self writeAcutalSize:allData.length];
			if (ret) {
				ret = [self protectFromBackgroundWritting:m_actualSize
				                               writeBlock:^(MiniCodedOutputData *output) {
					                               output->writeRawData(allData); // note: don't write size of data
				                               }];
				if (ret) {
					[self recaculateCRCDigest];
				}
			}
			return ret;
		}
		return NO;
	} else {    // case與編碼方式相關,增量寫入
		BOOL ret = [self writeAcutalSize:m_actualSize + size];
		if (ret) {
			static const int offset = pbFixed32Size(0);
			ret = [self protectFromBackgroundWritting:size
			                               writeBlock:^(MiniCodedOutputData *output) {
				                               output->writeString(key);
				                               output->writeData(data); // note: write size of data
			                               }];
			if (ret) {
				auto ptr = (uint8_t *) m_ptr + offset + m_actualSize - size;
				if (m_cryptor) {    // 這裡是在寫入記憶體對映區後才做的加密,因為寫入的data加入了其他需要的bit(data長度)
					m_cryptor->encrypt(ptr, ptr, size);
				}
				[self updateCRCDigest:ptr withSize:size];
			}
		}
		return ret;
	}
}
複製程式碼
// MMKV.mm 
// 寫入方法
- (BOOL)setRawData:(NSData *)data forKey:(NSString *)key {
	if (data.length <= 0 || key.length <= 0) {
		return NO;
	}
	CScopedLock lock(m_lock);

	[m_dic setObject:data forKey:key];  // 1. 寫入m_dic
	m_hasFullWriteBack = NO;

	return [self appendData:data forKey:key];   // 2. 寫入檔案
}
複製程式碼

讀:

因為m_dic已經保證與檔案同步了,所以直接讀m_dic就可以了,在需要讀資料時進行解碼,所以在讀時是要明確知道資料型別,如果搞錯了,行為是不確定的

// MMKV.mm

// 從m_dic中獲取value(NSData型別)
- (NSData *)getRawDataForKey:(NSString *)key {
	CScopedLock lock(m_lock);
	[self checkLoadData];
	return [m_dic objectForKey:key];
}

- (id)getObjectOfClass:(Class)cls forKey:(NSString *)key {
	if (key.length <= 0) {
		return nil;
	}
	NSData *data = [self getRawDataForKey:key]; // 從獲取data
	if (data.length > 0) {  // 解碼, 支援NSObject<NSCoding>的型別和自定義解碼器支援的型別

		if ([MiniPBCoder isMiniPBCoderCompatibleType:cls]) {
			return [MiniPBCoder decodeObjectOfClass:cls fromData:data]; 
		} else {
			if ([cls conformsToProtocol:@protocol(NSCoding)]) {
				return [NSKeyedUnarchiver unarchiveObjectWithData:data];
			}
		}
	}
	return nil;
}
複製程式碼

crc校驗

對於大檔案的寫入,可能發生錯誤的機率較大,所以對儲存kv的檔案使用crc32進行校驗(可靠性),產生crc碼也需要儲存,但是因為crc比較小,所以發生錯誤的機率是比較小的,如果crc檔案也要校驗,那就是個無盡的迴圈了。在每次對映結束後都會做crc校驗。每次寫入時要更新crc碼。crc碼的更新方式有兩種:

  1. 重新計算全部資料的crc碼
  2. 做增量的crc碼計算
// MMKV
- (BOOL)checkFileCRCValid {
	if (m_ptr != nullptr && m_ptr != MAP_FAILED) {
		int offset = pbFixed32Size(0);
		m_crcDigest = (uint32_t) crc32(0, (const uint8_t *) m_ptr + offset, (uint32_t) m_actualSize);   // 獲取檔案的crc碼

		// for backward compatibility
		if (!isFileExist(m_crcPath)) {
			MMKVInfo(@"crc32 file not found:%@", m_crcPath);
			return YES;
		}
		NSData *oData = [NSData dataWithContentsOfFile:m_crcPath];
		uint32_t crc32 = 0;
		@try {
			MiniCodedInputData input(oData);
			crc32 = input.readFixed32();    // 獲取已經記錄的crc碼
		} @catch (NSException *exception) {
			MMKVError(@"%@", exception);
		}
		if (m_crcDigest == crc32) {
			return YES; // 校驗通過
		}
		MMKVError(@"check crc [%@] fail, crc32:%u, m_crcDigest:%u", m_mmapID, crc32, m_crcDigest);
	}
	return NO;
}
複製程式碼
// MMKV.mm
// 通過增量更新crc碼
- (void)updateCRCDigest:(const uint8_t *)ptr withSize:(size_t)length {
	if (ptr == nullptr) {
		return;
	}
	// 將原來crc碼傳入,進行增量的crc碼計算,第一個引數是原來的crc碼,如果原來的crc碼為0,則相當於全量
	m_crcDigest = (uint32_t) crc32(m_crcDigest, ptr, (uint32_t) length);    

	if (m_crcPtr == nullptr || m_crcPtr == MAP_FAILED) {
		[self prepareCRCFile];
	}
	if (m_crcPtr == nullptr || m_crcPtr == MAP_FAILED) {
		return;
	}

	static const size_t bufferLength = pbFixed32Size(0);
	if (m_isInBackground) {
		if (mlock(m_crcPtr, bufferLength) != 0) {
			MMKVError(@"fail to mlock crc [%@]-%p, %d:%s", m_mmapID, m_crcPtr, errno, strerror(errno));
			// just fail on this condition, otherwise app will crash anyway
			return;
		}
	}

	@try {
		MiniCodedOutputData output(m_crcPtr, bufferLength);
		output.writeFixed32((int32_t) m_crcDigest);
	} @catch (NSException *exception) {
		MMKVError(@"%@", exception);
	}
	if (m_isInBackground) {
		munlock(m_crcPtr, bufferLength);
	}
}
複製程式碼

aes加密

MMKV 使用了 AES CFB-128 演算法來加密/解密。具體是採用了 OpenSSL(1.1.0i 版)的實現。我們選擇 CFB 而不是常見的 CBC 演算法,主要是因為 MMKV 使用 append-only 實現插入/更新操作,流式加密演算法更加合適。-- 摘自MMKV github wiki

執行緒安全

MMKV是執行緒安全的

MMKV使用c++的類初始化和析構的特性定義了ScopedLock(作用域鎖):

class CScopedLock {
    NSRecursiveLock *m_oLock;

public:
    CScopedLock(NSRecursiveLock *oLock) : m_oLock(oLock) { [m_oLock lock]; }    // 初始化時加鎖

    ~CScopedLock() {    // 析構時解鎖
        [m_oLock unlock];
        m_oLock = nil;
    }
};

/*
{
    CScopedLock lock(g_instanceLock);
    操作臨界資源。。。
} 超出作用域,呼叫lock的解構函式,解鎖
*/

複製程式碼

使用了NSRecursiveLock進行加鎖,這降低了死鎖的風險,但是對效能會有少量的消耗

  1. 對於每個mmkv例項都會放入一個global的dic儲存(快取),來避免每次都要走做初始化,並且為該物件新增了強引用,防止被釋放,並新增了g_instanceLock鎖來保障,每次dic進行寫操作時,加鎖保護,而且保證初始化行為執行緒安全
  2. 在mmkv中的例項變數是臨界資源,所以每次都要加鎖,這裡需要注意的是在多執行緒的其情況下,close之後再使用,其行為是不確定的,原因如下:
// call this method if the instance is no longer needed in the near future
// any subsequent call to the instance is undefined behavior
- (void)close;

- (void)close {
	CScopedLock g_lock(g_instanceLock);
	CScopedLock lock(m_lock);
	MMKVInfo(@"closing %@", m_mmapID);

	[self clearMemoryCache];

    // 這裡從dic中移除了該例項,所以引用計數會-1,不同執行緒有不同autoreleasepool,所以可能被釋放
	[g_instanceDic removeObjectForKey:m_mmapID];    
}
複製程式碼

記憶體警告

因為記憶體過高肯能會OOM,而且會降低app執行速度(記憶體交換),所以在記憶體警告時對記憶體釋放

// MMKV

// 主要是兩個工作:1. 清理記憶體中m_dic 2. 關閉對映
- (void)clearMemoryCache {
	CScopedLock lock(m_lock);

	if (m_needLoadFromFile) {
		MMKVInfo(@"ignore %@", m_mmapID);
		return;
	}
	m_needLoadFromFile = YES;

	[m_dic removeAllObjects];   // 清理m_dic
	m_hasFullWriteBack = NO;

	if (m_output != nullptr) {
		delete m_output;
	}
	m_output = nullptr;

	if (m_ptr != nullptr && m_ptr != MAP_FAILED) {
		if (munmap(m_ptr, m_size) != 0) {   // 關閉對映
			MMKVError(@"fail to munmap [%@], %s", m_mmapID, strerror(errno));
		}
	}
	m_ptr = nullptr;

	if (m_fd >= 0) {
		if (close(m_fd) != 0) { // 關閉檔案
			MMKVError(@"fail to close [%@], %s", m_mmapID, strerror(errno));
		}
	}
	m_fd = -1;
	m_size = 0;
	m_actualSize = 0;

	if (m_cryptor) {
		m_cryptor->reset();
	}
}
複製程式碼

相關文章