iOS快取的總結

Jani發表於2018-07-24

iOS快取的總結

記憶體的分類

在iOS中快取主要分為兩種,一種是記憶體快取一種是磁碟快取。記憶體快取提供容量小但是高速的存取功能,磁碟快取提供容量大但是低速的存取功能。在使用的時候一般是將最近的資料(如一天)儲存在記憶體快取中;將超出最近時間而又在合適時間內的資料(如超過一天在一週內)從記憶體快取中清除,將其儲存在磁碟快取中;將超出最大時間(如超過一週)的資料從磁碟中銷燬。

記憶體快取

NSCache

蘋果提供了NSCache作為一個簡單的記憶體快取,它有著和NSDictionary相似的API,但是它是執行緒安全的,並不retain key。它的底層直接呼叫了 libcache.dylib,通過pthead_mutex(互斥鎖)完成了執行緒安全。但是由於它的效能和key值的相似度有關,如果有大量相似的key的話,NSCache的存取效能下降地厲害。——ibireme

TMMemoryCache

TMMemoryCache 是TMMemory的記憶體快取實現,它提供了很多NSCache沒有的快取功能,如數量限制,總容量限制,存活時間限制,記憶體警告以及應用退到後臺清空快取。它在設計的時候主要採用了執行緒安全,他將所有的讀寫操作放在了一個concurrent queue中,使用dispatch_barier_async來保證任務可以順序執行,但是使用了大量的非同步Block實現了存取功能,造成了很大的效能和死鎖問題。

dispatch_barier_async : 它的非同步體現在會把後面的任務也先新增在佇列中,然後在執行的時候體現出barier的特性,必須先它加入佇列中的任務執行完畢之後,它的任務才執行,必須它的任務執行完之後,後它加入佇列中的任務才可以開始執行。

dispatch_barrier_sync: 它的同步體現在,它不會現將後面的任務加入佇列,即不會執行後續的程式碼,直到先它加入佇列的任務執行完畢,以及它執行完畢後才開始執行後續程式碼。
複製程式碼
- (void)setObject:(id)object forKey:(NSString *)key withCost:(NSUInteger)cost block:(TMMemoryCacheObjectBlock)block
{
    NSDate *now = [[NSDate alloc] init];
    if (!key || !object)
        return;

    __weak TMMemoryCache *weakSelf = self;
	// 1. 使用 dispatch_barrier_async實現執行緒安全
    dispatch_barrier_async(_queue, ^{
        TMMemoryCache *strongSelf = weakSelf;
        if (!strongSelf)
            return;
            
		// 2. 執行willAddObjectBlock
        if (strongSelf->_willAddObjectBlock)
            strongSelf->_willAddObjectBlock(strongSelf, key, object);
		
		// 3.儲存key對應的時間,資料,大小
        [strongSelf->_dictionary setObject:object forKey:key];
        [strongSelf->_dates setObject:now forKey:key];
        [strongSelf->_costs setObject:@(cost) forKey:key];

        _totalCost += cost;
        
		// 4.執行新增完成後的Block
        if (strongSelf->_didAddObjectBlock)
            strongSelf->_didAddObjectBlock(strongSelf, key, object);
            
		// 5.根據時間排序來清空指定快取大小的記憶體
        if (strongSelf->_costLimit > 0)
            [strongSelf trimToCostByDate:strongSelf->_costLimit block:nil];
            
		//6.非同步回撥
        if (block) {
            __weak TMMemoryCache *weakSelf = strongSelf;
            dispatch_async(strongSelf->_queue, ^{
                TMMemoryCache *strongSelf = weakSelf;
                if (strongSelf)
                    block(strongSelf, key, object);
            });
        }
    });
}
複製程式碼

以上通過GCD中的dispatch_barrier_async來實現了執行緒安全。如果需要實現同同步儲存操作,那該怎麼辦呢?作者使用了dispatch_semaphore_t的方式來實現了同步儲存操作:

- (void)setObject:(id)object forKey:(NSString *)key withCost:(NSUInteger)cost
{
    if (!object || !key)
        return;
	// 1. 建立訊號量(訊號量為0)
    dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);

    [self setObject:object forKey:key withCost:cost block:^(TMMemoryCache *cache, NSString *key, id object) {
	    // 3.發出訊號(訊號量-1)
        dispatch_semaphore_signal(semaphore);
    }];
    
	// 2.設定等待時間(訊號量+1)
    dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
}
複製程式碼

注意使用的時候:呼叫 dispatch_semaphore_wait 方法會使訊號量的值-1, 表示增加一個執行緒等待處理共用資源, 當 dispatch_semaphore_signal 時會使訊號量的值+1, 表示該執行緒不再佔用共用資源,不佔據訊號量了。訊號量使用的目的是等待非同步儲存產生結果之後,才執行後續的操作。即dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER),後續的操作需要等待dispatch_semaphore_signal(semaphore)執行使訊號量+1才執行。

PINMemoryCache

PINMemoryCache它的功能和介面和TMMemoryCache類似,但是它修復了效能和死鎖的問題,採用的是pthread_mutex_lock來保證執行緒的安全,去掉了dispatch_barier_async,避免了執行緒切換產生的巨大的開銷,避免了可能的死鎖。

- (void)setObject:(id)object forKey:(NSString *)key withCost:(NSUInteger)cost ageLimit:(NSTimeInterval)ageLimit
{

	······ 
	  
    // 1.加鎖
    [self lock];
    
	// 2. 根據key儲存資料,日期,大小
     NSNumber* oldCost = _costs[key];
     if (oldCost)
         _totalCost -= [oldCost unsignedIntegerValue];

     NSDate *now = [NSDate date];
     _dictionary[key] = object;
     _createdDates[key] = now;
     _accessDates[key] = now;
     _costs[key] = @(cost);

     if (ageLimit > 0.0) {
         _ageLimits[key] = @(ageLimit);
     } else {
         [_ageLimits removeObjectForKey:key];
     }

      _totalCost += cost;
     
     // 3. 解鎖   
    [self unlock];
    
	······
}
複製程式碼

關鍵就是接下來如何實現lock方法來滿足執行緒安全呢?

- (void)lock
{
    __unused int result = pthread_mutex_lock(&_mutex);
    NSAssert(result == 0, @"Failed to lock PINMemoryCache %@. Code: %d", self, result);
}

- (void)unlock
{
    __unused int result = pthread_mutex_unlock(&_mutex);
    NSAssert(result == 0, @"Failed to unlock PINMemoryCache %@. Code: %d", self, result);
}
複製程式碼

在這裡它使用的是pthread_mutex來實現的執行緒安全,這個鎖是互斥鎖(鎖的型別我們後面簡要來做一下總結)。

####YYMemoryCache 相對於 PINMemoryCache 來說,去掉了非同步訪問的介面,儘量優化了同步訪問的效能,用 pthread_mutex保證執行緒安全(注意 之前使用的是OSSpinLock但是會有bug,所以後來更換為了pthread_mutex)。而且在快取內部還使用了雙向連結串列和 NSDictionary 實現了 LRU 淘汰演算法。這裡程式碼就不貼了,大家有興趣自己去研究研究吧。

不過這裡可以提一提什麼是LRU演算法(least recently used 最近最少使用): 如果一個資料在最近一段時間內沒有被訪問,那麼在以後他被訪問的可能性也會比較小。 (也就是說在限定空間已滿的情況下,應該把最久沒有被訪問到的資料淘汰!)

如何實現呢:

在需要插入新資料的時候,如果資料在連結串列中存在,那麼就將該節點移動到連結串列的頭部,如果該資料在連結串列中不存在,那麼新建節點,將節點插入到連結串列的頭部,如果快取滿了,就要將連結串列中最後一個節點刪除;訪問資料的時候,如果資料存在,那麼就將節點移動到連結串列的頭部,這樣的話連結串列最尾部的資料就是最久未被訪問的資料了。
複製程式碼

iOS快取的總結

###磁碟快取 根據YY大神的解析,磁碟快取的實現技術大致分為三類:基於檔案讀寫;基於mmap檔案記憶體對映;基於資料庫。

TMDiskCache, PINDiskCache, SDWebImage 等快取,都是基於檔案系統的,即一個 Value 對應一個檔案,通過檔案讀寫來快取資料。他們的實現都比較簡單,效能也都相近,缺點也是同樣的:不方便擴充套件、沒有後設資料、難以實現較好的淘汰演算法、資料統計緩慢。

FastImageCache 採用的是 mmap 將檔案對映到記憶體。用過 MongoDB 的人應該很熟悉 mmap 的缺陷:熱資料的檔案不要超過實體記憶體大小,不然 mmap 會導致記憶體交換嚴重降低效能;另外記憶體中的資料是定時 flush 到檔案的,如果資料還未同步時程式掛掉,就會導致資料錯誤。拋開這些缺陷來說,mmap 效能非常高。

NSURLCache、FBDiskCache 都是基於 SQLite 資料庫的。基於資料庫的快取可以很好的支援後設資料、擴充套件方便、資料統計速度快,也很容易實現 LRU 或其他淘汰演算法,唯一不確定的就是資料庫讀寫的效能,當單條資料小於 20K 時,資料越小 SQLite 讀取效能越高;單條資料大於 20K 時,直接寫為檔案速度會更快一些。

所以基於 SQLite 的這種表現,磁碟快取最好是把 SQLite 和檔案儲存結合起來:key-value 後設資料儲存在 SQLite 中,而 value 資料則根據大小不同選擇 SQLite 或檔案儲存。

//  YYCache 中的磁碟快取
- (BOOL)saveItemWithKey:(NSString *)key value:(NSData *)value filename:(NSString *)filename extendedData:(NSData *)extendedData {
    if (key.length == 0 || value.length == 0) return NO;
    if (_type == YYKVStorageTypeFile && filename.length == 0) {
        return NO;
    }
    
    // 1.檔名不為空
    if (filename.length) {
	    // 2.將資料存入檔案中,存入則返回
        if (![self _fileWriteWithName:filename data:value]) {
            return NO;
        }
        // 3.將資料存入資料庫中,存入則返回
        if (![self _dbSaveWithKey:key value:value fileName:filename extendedData:extendedData]) {
            [self _fileDeleteWithName:filename];
            return NO;
        }
        return YES;
    } else {
        if (_type != YYKVStorageTypeSQLite) {
            NSString *filename = [self _dbGetFilenameWithKey:key];
            if (filename) {
                [self _fileDeleteWithName:filename];
            }
        }
        // 4. 將資料存入到資料庫中
        return [self _dbSaveWithKey:key value:value fileName:nil extendedData:extendedData];
    }
}
複製程式碼

什麼是基於mmap的檔案記憶體對映

將一個檔案或者其他物件對映到程式的地址空間,實現磁碟記憶體地址和程式虛擬地址空間中的一段虛擬的地址的對應關係。 程式就可以採用指標的方式讀寫操作這一段記憶體,而系統會自動回寫髒頁面到對應的檔案磁碟上,即完成了對檔案的操作而不必再呼叫read,write等系統呼叫函式。相反,核心空間對這段區域的修改也直接反映使用者空間,從而可以實現不同程式間的檔案共享。

iOS快取的總結

鎖的分類

首先來看一組圖片,關於鎖的速度比較,然後我再簡單介紹介紹不同型別的鎖:

iOS快取的總結

訊號量

什麼是訊號量?

在進入一段程式碼之前,要先獲取一個訊號量,在結束程式碼之前,釋放該訊號量,只要訊號量滿足一定的條件,那麼其他想執行此程式碼執行緒就可以執行該程式碼了。

DispatchSemaphore

在swift中訊號量的實現方式就是DispatchSemaphore,設定訊號量的值為多少,就意味著最多有多少執行緒可以同時處理執行程式碼,所以如果將訊號量初始化為1,那就意味著同時只有一個執行緒可以使用該資源. 注意:wait()首先檢查訊號量的大小,如果為1,那麼執行下方的程式碼,並且執行訊號量-1操作, 如果為0,那麼不執行後續程式碼,等待訊號量變為1才開始執行後續程式碼。singal()使訊號量+1

let semaphore = DispatchSemaphore.init(value: 1)

DispatchQueue.global(qos: DispatchQoS.QoSClass.default).async {
    semaphore.wait()
    print("執行緒一開始")
    sleep(2)
    print("執行緒一結束")
    semaphore.signal()
}

DispatchQueue.global(qos: DispatchQoS.QoSClass.default).async {
    print("執行緒二開始")
    sleep(1)
    semaphore.wait()
    print("執行緒二結束")
    semaphore.signal()
}
複製程式碼

互斥鎖

什麼是互斥鎖?

當一個執行緒對某資源在進行訪問時,鎖定此資源,其他對該資源產生訪問的執行緒會被掛起,直到該執行緒解鎖了互斥量。iOS中的互斥鎖:NSLock,pthread_mutex,@synchronized

NSLock

let lock = NSLock.init()
func ?() {
    lock.lock()
    for index in 0..<3 {
         print("\(index) I am a dog")
    }
    lock.unlock()
}

DispatchQueue.global(qos: DispatchQoS.QoSClass.default).async {
    print("執行緒一開始")
    ?()
    print("執行緒一結束")
}

DispatchQueue.global(qos: DispatchQoS.QoSClass.default).async {
    print("執行緒二開始")
    ?()
    print("執行緒二結束")
}

輸出:
執行緒二開始
執行緒一開始
0 I am a dog
1 I am a dog
2 I am a dog
執行緒二結束
0 I am a dog
1 I am a dog
2 I am a dog
執行緒一結束
複製程式碼

pthread_mutex

**注意:**在Swift中pthread_mutex的初始化和正常的swift型別不一樣

var mutex = pthread_mutex_t()
pthread_mutex_init(&mutex, nil)
複製程式碼
var mutex = pthread_mutex_t()
pthread_mutex_init(&mutex, nil)

func ?() {
    pthread_mutex_lock(&mutex)
    for index in 0..<3 {
         print("\(index) I am a dog")
    }
    pthread_mutex_unlock(&mutex)
}

DispatchQueue.global(qos: DispatchQoS.QoSClass.default).async {
    print("執行緒一開始")
    ?()
    print("執行緒一結束")
}

DispatchQueue.global(qos: DispatchQoS.QoSClass.default).async {
    print("執行緒二開始")
    ?()
    print("執行緒二結束")
}

輸出:
執行緒一開始
執行緒二開始
0 I am a dog
1 I am a dog
2 I am a dog
執行緒一結束
0 I am a dog
1 I am a dog
2 I am a dog
執行緒二結束
複製程式碼

@synchronize

在OC和Swift中的@synchronize是截然不同的,不過這個不同主要是語法上的不一致,鎖的型別和性質都是一樣的,使用如下:

obj_sync_enter(lock)
obj_sync_exit(lock)
複製程式碼
func ?(_ lock: Any) {
    objc_sync_enter(lock)
    for index in 0..<3 {
         print("\(index) I am a dog")
    }
    objc_sync_exit(lock)
}

DispatchQueue.global(qos: DispatchQoS.QoSClass.default).async {
    print("執行緒一開始")
    ?(PlaygroundPage.current)
    print("執行緒一結束")
}

DispatchQueue.global(qos: DispatchQoS.QoSClass.default).async {
    print("執行緒二開始")
    ?(PlaygroundPage.current)
    print("執行緒二結束")
}

輸出:
執行緒一開始
執行緒二開始
0 I am a dog
1 I am a dog
2 I am a dog
執行緒一結束
0 I am a dog
1 I am a dog
2 I am a dog
執行緒二結束
複製程式碼

遞迴鎖

什麼是遞迴鎖?

顧名思議,可以當前執行緒多次獲取的鎖就稱為遞迴鎖。它記錄了當前執行緒獲得鎖的次數,每一次加鎖,都要對應一次解鎖,這樣才不會產生死鎖,一旦鎖全部被釋放完畢,資源才可以被其它執行緒鎖使用。上圖中的NSRecursiveLockpthread_mutex(recusiveLock),而NSRecursiveLock內部封裝的就是pthread_mutex(recursiveLock)

var mutexattr = pthread_mutexattr_t()
pthread_mutexattr_init(&mutexattr)
pthread_mutexattr_settype(&mutexattr, PTHREAD_MUTEX_RECURSIVE)

var mutex: pthread_mutex_t = pthread_mutex_t()
let lock = pthread_mutex_init(&mutex, &mutexattr)
pthread_mutexattr_destroy(&mutexattr)

func dog(count: Int) {
    for _ in 0..<count{
        pthread_mutex_lock(&mutex)
        dog(count: count - 1)
        pthread_mutex_unlock(&mutex)
    }
}
    
DispatchQueue.global(qos: DispatchQoS.QoSClass.default).async {
    print("start")
    dog(count: 3)
    print("end")
}
複製程式碼

讀寫鎖

什麼是讀寫鎖?

讀寫鎖,在多執行緒程式設計的環境之下,寫鎖是具備排他性的,一旦多個執行緒同時對同一個檔案進行寫操作,那麼帶來的副作用是災難性的,但是讀操作:多個執行緒是可以同時對同一檔案進行讀操作的。那麼寫操作可以使用是鎖來實現呢?條件鎖,訊號量,互斥鎖其實理論上都是可以的。

var lock = pthread_rwlock_t()
// 讀鎖
pthread_rwlock_rdlock(&lock)
// 寫鎖
pthread_rwlock_wrlock(&lock)
// 解鎖
pthread_rwlock_unlock(&lock)
複製程式碼

條件鎖

什麼是條件鎖?

條件鎖是一種比較特殊的鎖,一般使用線上程之間中的排程,一個執行緒阻塞另一個執行緒,直到其中一個執行緒中的條件滿足時,傳送訊號給另一執行緒使得另一執行緒正常的執行。比如說你開啟一個下載圖片執行緒,還有一個執行緒處理圖片,那麼這兩個執行緒就可以通過條件鎖來實現執行緒之間的排程。

NSConditionLock

lock() 表示該執行緒希望獲得該鎖,如果沒有其他執行緒獲取該鎖,那麼它就可以執行後續程式碼了,如果有其他的執行緒已經獲取該鎖了,那麼它需要等待

lock(WhenCondition: 條件a): 如果沒有其他執行緒獲得該鎖,但是該鎖內部的condition不等於A條件,它依然不能獲得鎖,仍然等待。如果內部的condition等於A條件,並且沒有其他執行緒獲得該鎖,則進入程式碼區,同時設定它獲得該鎖,其他任何執行緒都將等待它程式碼的完成,直至它解鎖。

unLock(WhenCondition: 條件b): 表示立即釋放鎖,而且把條件設定為b

複製程式碼

自旋鎖

什麼是自旋鎖呢?

自旋鎖是一種"忙等待",如果一個執行緒獲得該鎖之後,其他需要該資源的執行緒不會掛起,而是做一個忙等迴圈,直到此執行緒獲得的這個自旋鎖被釋放之後,其他執行緒根據優先順序獲取到這個自旋鎖,未獲得這個鎖的執行緒繼續進行忙等待。 YYKit 作者 @ibireme 的文章說到自旋鎖存在優先順序反轉問題:不再安全的 OSSpinLock

鎖的總結

好了,我們最後來總結一下鎖吧:

  1. 如果在進行檔案的讀寫是,還是應該使用讀寫鎖:pthread_rwlock
  2. 當對效能的要求比較高的時候,應該使用DispatchSemaphore或者pthread_mutex
  3. 因為蘋果的自旋鎖OSSpinLock有bug問題,所以在使用自旋鎖的時候需要謹慎

我們學到了什麼?

最後,看完這篇文章你應該學會什麼,或者說我寫完之後,應該記住什麼?

  1. iOS中一共存在幾種不同型別的鎖,以及他們在Swift中的基本實現
  2. 具體的開發環境中應該如何使用這些鎖
  3. 快取的分類以及定義
  4. 記憶體快取目前有幾種實現方案,以及它們內部是如何做到執行緒安全的?
  5. 什麼是LRU演算法,基本的原理是什麼?

相關文章