GCD實戰2:資源競爭
概述
我將分四步來帶大家研究研究程序的併發計算。第一步是基本的串行程序,然後使用GCD把它並行計算化。如果你想順着步驟來嘗試這些程序的話,可以下載源碼。注意,別運行imagegcd2.m,這是個反面教材。。
imagegcd.zip (8.4 KB, 143 次)
原始程序
我們的程序只是簡單地遍歷~/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還是爲我們提供了簡單有效的方法來進行併發計算。
本文轉自: