天天看點

GCD實戰2:資源競争

概述

我将分四步來帶大家研究研究程式的并發計算。第一步是基本的串行程式,然後使用GCD把它并行計算化。如果你想順着步驟來嘗試這些程式的話,可以下載下傳源碼。注意,别運作imagegcd2.m,這是個反面教材。。

GCD實戰2:資源競争

  imagegcd.zip (8.4 KB, 33 次)

原始程式

我們的程式隻是簡單地周遊~/Pictures然後生成縮略圖。這個程式是個指令行程式,沒有圖形界面(盡管是使用Cocoa開發庫的),主函數如下:

int main(int argc, char **argv)
    {
        NSAutoreleasePool *outerPool = [NSAutoreleasePool new];
        
        NSApplicationLoad();
        
        NSString *destination = @"/tmp/imagegcd";
        [[NSFileManager defaultManager] removeItemAtPath: destination error: NULL];
        [[NSFileManager defaultManager] createDirectoryAtPath: destination
                                        withIntermediateDirectories: YES
                                        attributes: nil
                                        error: NULL];
        
        
        Start();
        
        NSString *dir = [@"~/Pictures" stringByExpandingTildeInPath];
        NSDirectoryEnumerator *enumerator = [[NSFileManager defaultManager] enumeratorAtPath: dir];
        int count = 0;
        for(NSString *path in enumerator)
        {
            NSAutoreleasePool *innerPool = [NSAutoreleasePool new];
            
            if([[[path pathExtension] lowercaseString] isEqual: @"jpg"])
            {
                path = [dir stringByAppendingPathComponent: path];
                
                NSData *data = [NSData dataWithContentsOfFile: path];
                if(data)
                {
                    NSData *thumbnailData = ThumbnailDataForData(data);
                    if(thumbnailData)
                    {
                        NSString *thumbnailName = [NSString stringWithFormat: @"%d.jpg", count++];
                        NSString *thumbnailPath = [destination stringByAppendingPathComponent: thumbnailName];
                        [thumbnailData writeToFile: thumbnailPath atomically: NO];
                    }
                }
            }
            
            [innerPool release];
        }
        
        End();
        
        [outerPool release];
    }
 
           

如果你要看到所有的副主函數的話,到文章頂部下載下傳源代碼吧。目前這個程式是imagegcd1.m。程式中重要的部分都在這裡了。. 

Start

 函數和 

End

 函數隻是簡單的計時函數(内部實作是使用的

gettimeofday函數

)。ThumbnailDataForData函數使用Cocoa庫來加載圖檔資料生成Image對象,然後将圖檔縮小到320×320大小,最後将其編碼為JPEG格式。

簡單而天真的并發

乍一看,我們感覺将這個程式并發計算化,很容易。循環中的每個疊代器都可以放入GCD global queue中。我們可以使用dispatch queue來等待它們完成。為了保證每次疊代都會得到唯一的檔案名數字,我們使用OSAtomicIncrement32來原子操作級别的增加count數:

dispatch_queue_t globalQueue = dispatch_get_global_queue(0, 0);
    dispatch_group_t group = dispatch_group_create();
    __block uint32_t count = -1;
    for(NSString *path in enumerator)
    {
        dispatch_group_async(group, globalQueue, BlockWithAutoreleasePool(^{
            if([[[path pathExtension] lowercaseString] isEqual: @"jpg"])
            {
                NSString *fullPath = [dir stringByAppendingPathComponent: path];
                
                NSData *data = [NSData dataWithContentsOfFile: fullPath];
                if(data)
                {
                    NSData *thumbnailData = ThumbnailDataForData(data);
                    if(thumbnailData)
                    {
                        NSString *thumbnailName = [NSString stringWithFormat: @"%d.jpg",
                                                   OSAtomicIncrement32(&count;)];
                        NSString *thumbnailPath = [destination stringByAppendingPathComponent: thumbnailName];
                        [thumbnailData writeToFile: thumbnailPath atomically: NO];
                    }
                }
            }
        });
    }
    dispatch_group_wait(group, DISPATCH_TIME_FOREVER);
           

這個就是imagegcd2.m,但是,注意,别運作這個程式,有很大的問題。 

如果你無視我的警告還是運作這個imagegcd2.m了,你現在很有可能是在重新開機了電腦後,又打開了我的頁面。。如果你乖乖地沒有運作這個程式的話,運作這個程式發生的情況就是(如果你有很多很多圖檔在~/Pictures中):電腦沒反應,好久好久都不動,假死了。。

問題在哪

問題出在哪?就在于GCD的智能上。GCD将任務放到全局線程池中運作,這個線程池的大小根據系統負載來随時改變。例如,我的電腦有四核,是以如果我使用GCD加載任務,GCD會為我每個cpu核建立一個線程,也就是四個線程。如果電腦上其他任務需要進行的話,GCD會減少線程數來使其他任務得以占用cpu資源來完成。

但是,GCD也可以增加活動線程數。它會在其他某個線程阻塞時增加活動線程數。假設現在有四個線程正在運作,突然某個線程要做一個操作,比如,讀檔案,這個線程就會等待磁盤響應,此時cpu核心會處于未充分利用的狀态。這是GCD就會發現這個狀态,然後建立另一個線程來填補這個資源浪費空缺。

現在,想想上面的程式發生了啥?主線程非常迅速地将任務不斷放入global queue中。GCD以一個少量工作線程的狀态開始,然後開始執行任務。這些任務執行了一些很輕量的工作後,就開始等待磁盤資源,慢得不像話的磁盤資源。

我們别忘記磁盤資源的特性,除非你使用的是SSD或者牛逼的RAID,否則磁盤資源會在競争的時候變得異常的慢。。

剛開始的四個任務很輕松地就同時通路到了磁盤資源,然後開始等待磁盤資源傳回。這時GCD發現CPU開始空閑了,它繼續增加工作線程。然後,這些線程執行更多的磁盤讀取任務,然後GCD再建立更多的工資線程。。。

可能在某個時間檔案讀取任務有完成的了。現在,線程池中可不止有四個線程,相反,有成百上千個。。。GCD又會嘗試将工作線程減少(太多使用CPU資源的線程),但是減少線程是由條件的,GCD不可以将一個正在執行任務的線程殺掉,并且也不能将這樣的任務暫停。它必須等待這個任務完成。所有這些情況都導緻GCD無法減少工作線程數。

然後所有這上百個線程開始一個個完成了他們的磁盤讀取工作。它們開始競争CPU資源,當然CPU在處理競争上比磁盤先進多了。問題在于,這些線程讀完檔案後開始編碼這些圖檔,如果你有很多很多圖檔,那麼你的記憶體将開始爆倉。。然後記憶體耗盡咋辦?虛拟記憶體啊,虛拟記憶體是啥,磁盤資源啊。Oh shit!~

然後進入了一個惡性循環,磁盤資源競争導緻更多的線程被建立,這些線程導緻更多的記憶體使用,然後記憶體爆倉導緻虛拟記憶體交換,直至GCD建立了系統規定的線程數上限(可能是512個),而這些線程又沒法被殺掉或暫停。。。

這就是使用GCD時,要注意的。GCD能智能地根據CPU情況來調整工作線程數,但是它卻無法監視其他類型的資源狀況。如果你的任務牽涉大量IO或者其他會導緻線程block的東西,你需要把握好這個問題。

修正

問題的根源來自于磁盤IO,然後導緻惡性循環。解決了磁盤資源碰撞,就解決了這個問題。

GCD的custom queue使得這個問題易于解決。Custom queue是串行的。如果我們建立一個custom queue然後将所有的檔案讀寫任務放入這個隊列,磁盤資源的同時通路數會大大降低,資源通路碰撞就避免了。

蝦米是我們修正後的代碼,使用IO queue(也就是我們建立的custom queue專門用來讀寫磁盤):

dispatch_queue_t globalQueue = dispatch_get_global_queue(0, 0);
    dispatch_queue_t ioQueue = dispatch_queue_create("com.mikeash.imagegcd.io", NULL);
    dispatch_group_t group = dispatch_group_create();
    __block uint32_t count = -1;
    for(NSString *path in enumerator)
    {
        if([[[path pathExtension] lowercaseString] isEqual: @"jpg"])
        {
            NSString *fullPath = [dir stringByAppendingPathComponent: path];
            
            dispatch_group_async(group, ioQueue, BlockWithAutoreleasePool(^{
                NSData *data = [NSData dataWithContentsOfFile: fullPath];
                if(data)
                    dispatch_group_async(group, globalQueue, BlockWithAutoreleasePool(^{
                        NSData *thumbnailData = ThumbnailDataForData(data);
                        if(thumbnailData)
                        {
                            NSString *thumbnailName = [NSString stringWithFormat: @"%d.jpg",
                                                       OSAtomicIncrement32(&count;)];
                            NSString *thumbnailPath = [destination stringByAppendingPathComponent: thumbnailName];
                            dispatch_group_async(group, ioQueue, BlockWithAutoreleasePool(^{
                                [thumbnailData writeToFile: thumbnailPath atomically: NO];
                            }));
                        }
                    }));
            }));
        }
    }
    dispatch_group_wait(group, DISPATCH_TIME_FOREVER);
           

 這個就是我們的 

imagegcd3.m

.

GCD使得我們很容易就将任務的不同部分放入相同的隊列中去(簡單地嵌套一下dispatch)。這次我們的程式将會表現地很好。。。我是說多數情況。。。。

問題在于任務中的不同部分不是同步的,導緻了整個程式的不穩定。我們的新程式的整個流程如下:

Main Thread          IO Queue            Concurrent Queue
    
    find paths  ------>  read  ----------->  process
                                             ...
                         write <-----------  process
           

圖中的箭頭是非阻塞的,并且會簡單地将記憶體中的對象進行緩沖。

 現在假設一個機器的磁盤足夠快,快到比CPU處理任務(也就是圖檔處理)要快。其實不難想象:雖然CPU的動作很快,但是它的工作更繁重,解碼、壓縮、編碼。從磁盤讀取的資料開始填滿IO queue,資料會占用記憶體,很可能越占越多(如果你的~/Pictures中有很多很多圖檔的話)。

然後你就會記憶體爆倉,然後開始虛拟記憶體交換。。。又來了。。

這就會像第一次一樣導緻惡性循環。一旦任何東西導緻工作線程阻塞,GCD就會建立更多的線程,這個線程執行的任務又會占用記憶體(從磁盤讀取的資料),然後又開始交換記憶體。。

結果:這個程式要麼就是運作地很順暢,要麼就是很低效。

注意如果磁盤速度比較慢的話,這個問題依舊會出現,因為縮略圖會被緩沖在記憶體裡,不過這個問題導緻的低效比較不容易出現,因為縮略圖占的記憶體少得多。

真正的修複

由于上一次我們的嘗試出現的問題在于沒有同步不同部分的操作,是以讓我寫出同步的代碼。最簡單的方法就是使用信号量來限制同時執行的任務數量。

那麼,我們需要限制為多少呢?

顯然我們需要根據CPU的核數來限制這個量,我們又想馬兒好又想馬兒不吃草,我們就設定為cpu核數的兩倍吧。不過這裡隻是簡單地這樣處理,GCD的作用之一就是讓我們不用關心作業系統的内部資訊(比如cpu數),現在又來讀取cpu核數,确實不太妙。也許我們在實際應用中,可以根據其他需求來定義這個限制量。

現在我們的主循環代碼就是這樣了:

dispatch_queue_t ioQueue = dispatch_queue_create("com.mikeash.imagegcd.io", NULL);
    
    int cpuCount = [[NSProcessInfo processInfo] processorCount];
    dispatch_semaphore_t jobSemaphore = dispatch_semaphore_create(cpuCount * 2);
    
    dispatch_group_t group = dispatch_group_create();
    __block uint32_t count = -1;
    for(NSString *path in enumerator)
    {
        WithAutoreleasePool(^{
            if([[[path pathExtension] lowercaseString] isEqual: @"jpg"])
            {
                NSString *fullPath = [dir stringByAppendingPathComponent: path];
                
                dispatch_semaphore_wait(jobSemaphore, DISPATCH_TIME_FOREVER);
            
                dispatch_group_async(group, ioQueue, BlockWithAutoreleasePool(^{
                    NSData *data = [NSData dataWithContentsOfFile: fullPath];
                    dispatch_group_async(group, globalQueue, BlockWithAutoreleasePool(^{
                        NSData *thumbnailData = ThumbnailDataForData(data);
                        if(thumbnailData)
                        {
                            NSString *thumbnailName = [NSString stringWithFormat: @"%d.jpg",
                                                       OSAtomicIncrement32(&count;)];
                            NSString *thumbnailPath = [destination stringByAppendingPathComponent: thumbnailName];
                            dispatch_group_async(group, ioQueue, BlockWithAutoreleasePool(^{
                                [thumbnailData writeToFile: thumbnailPath atomically: NO];
                                dispatch_semaphore_signal(jobSemaphore);
                            }));
                        }
                        else
                            dispatch_semaphore_signal(jobSemaphore);
                    }));
                }));
            }
        });
    }
    dispatch_group_wait(group, DISPATCH_TIME_FOREVER);
           

最終我們寫出了一個能平滑運作且又快速處理的程式。

基準測試

我測試了一些運作時間,對7913張圖檔:

程式 處理時間 (秒)

imagegcd1.m

984

imagegcd2.m

沒運作,這個還是别運作了

imagegcd3.m

300

imagegcd4.m

279

注意,因為我比較懶。是以我在運作這些測試的時候,沒有關閉電腦上的其他程式。。。嚴格的進行對照的話,實在是太蛋疼了。。

是以這個數值我們隻是參考一下。

比較有意思的是,3和4的執行狀況差不多,大概是因為我電腦有15g可用記憶體吧。。。記憶體比較小的話,這個imagegcd3應該跑的很吃力,因為我發現它使用最多的時候,占用了10g記憶體。而4的話,沒有占多少記憶體。

結論

GCD是個比較範特西的技術,可以辦到很多事兒,但是它不能為你辦所有的事兒。是以,對于進行IO操作并且可能會使用大量記憶體的任務,我們必須仔細斟酌。當然,即使這樣,GCD還是為我們提供了簡單有效的方法來進行并發計算。

本文出自夢維,原文位址