GCD實(shí)戰(zhàn)二:資源競(jìng)爭(zhēng)
概述
我將分四步來(lái)帶大家研究研究程序的并發(fā)計(jì)算。第一步是基本的串行程序,然后使用GCD把它并行計(jì)算化。如果你想順著步驟來(lái)嘗試這些程序的話,可以下載源碼。注意,別運(yùn)行imagegcd2.m,這是個(gè)反面教材。
源碼下載:http://down.51cto.com/data/872222
原始程序
我們的程序只是簡(jiǎn)單地遍歷~/Pictures然后生成縮略圖。這個(gè)程序是個(gè)命令行程序,沒(méi)有圖形界面(盡管是使用Cocoa開(kāi)發(fā)庫(kù)的),主函數(shù)如下:
- 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
- IntermediateDirectories: 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];
- }
如果你要看到所有的副主函數(shù)的話,到文章頂部下載源代碼吧。當(dāng)前這個(gè)程序是imagegcd1.m。程序中重要的部分都在這里了。. Start
函數(shù)和 End
函數(shù)只是簡(jiǎn)單的計(jì)時(shí)函數(shù)(內(nèi)部實(shí)現(xiàn)是使用的gettimeofday函數(shù)
)。ThumbnailDataForData函數(shù)使用Cocoa庫(kù)來(lái)加載圖片數(shù)據(jù)生成Image對(duì)象,然后將圖片縮小到320×320大小,最后將其編碼為JPEG格式。
簡(jiǎn)單而天真的并發(fā)
乍一看,我們感覺(jué)將這個(gè)程序并發(fā)計(jì)算化,很容易。循環(huán)中的每個(gè)迭代器都可以放入GCD global queue中。我們可以使用dispatch queue來(lái)等待它們完成。為了保證每次迭代都會(huì)得到唯一的文件名數(shù)字,我們使用OSAtomicIncrement32來(lái)原子操作級(jí)別的增加count數(shù):
- 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);
這個(gè)就是imagegcd2.m,但是,注意,別運(yùn)行這個(gè)程序,有很大的問(wèn)題。
如果你無(wú)視我的警告還是運(yùn)行這個(gè)imagegcd2.m了,你現(xiàn)在很有可能是在重啟了電腦后,又打開(kāi)了我的頁(yè)面。。如果你乖乖地沒(méi)有運(yùn)行這個(gè)程序的話,運(yùn)行這個(gè)程序發(fā)生的情況就是(如果你有很多很多圖片在~/Pictures中):電腦沒(méi)反應(yīng),好久好久都不動(dòng),假死了。。
問(wèn)題在哪
問(wèn)題出在哪?就在于GCD的智能上。GCD將任務(wù)放到全局線程池中運(yùn)行,這個(gè)線程池的大小根據(jù)系統(tǒng)負(fù)載來(lái)隨時(shí)改變。例如,我的電腦有四核,所以如果我使用GCD加載任務(wù),GCD會(huì)為我每個(gè)cpu核創(chuàng)建一個(gè)線程,也就是四個(gè)線程。如果電腦上其他任務(wù)需要進(jìn)行的話,GCD會(huì)減少線程數(shù)來(lái)使其他任務(wù)得以占用cpu資源來(lái)完成。
但是,GCD也可以增加活動(dòng)線程數(shù)。它會(huì)在其他某個(gè)線程阻塞時(shí)增加活動(dòng)線程數(shù)。假設(shè)現(xiàn)在有四個(gè)線程正在運(yùn)行,突然某個(gè)線程要做一個(gè)操作,比如,讀文件,這個(gè)線程就會(huì)等待磁盤響應(yīng),此時(shí)cpu核心會(huì)處于未充分利用的狀態(tài)。這是GCD就會(huì)發(fā)現(xiàn)這個(gè)狀態(tài),然后創(chuàng)建另一個(gè)線程來(lái)填補(bǔ)這個(gè)資源浪費(fèi)空缺。
現(xiàn)在,想想上面的程序發(fā)生了啥?主線程非常迅速地將任務(wù)不斷放入global queue中。GCD以一個(gè)少量工作線程的狀態(tài)開(kāi)始,然后開(kāi)始執(zhí)行任務(wù)。這些任務(wù)執(zhí)行了一些很輕量的工作后,就開(kāi)始等待磁盤資源,慢得不像話的磁盤資源。
我們別忘記磁盤資源的特性,除非你使用的是SSD或者牛逼的RAID,否則磁盤資源會(huì)在競(jìng)爭(zhēng)的時(shí)候變得異常的慢。。
剛開(kāi)始的四個(gè)任務(wù)很輕松地就同時(shí)訪問(wèn)到了磁盤資源,然后開(kāi)始等待磁盤資源返回。這時(shí)GCD發(fā)現(xiàn)CPU開(kāi)始空閑了,它繼續(xù)增加工作線程。然后,這些線程執(zhí)行更多的磁盤讀取任務(wù),然后GCD再創(chuàng)建更多的工資線程。。。
可能在某個(gè)時(shí)間文件讀取任務(wù)有完成的了?,F(xiàn)在,線程池中可不止有四個(gè)線程,相反,有成百上千個(gè)。。。GCD又會(huì)嘗試將工作線程減少(太多使用CPU資源的線程),但是減少線程是由條件的,GCD不可以將一個(gè)正在執(zhí)行任務(wù)的線程殺掉,并且也不能將這樣的任務(wù)暫停。它必須等待這個(gè)任務(wù)完成。所有這些情況都導(dǎo)致GCD無(wú)法減少工作線程數(shù)。
然后所有這上百個(gè)線程開(kāi)始一個(gè)個(gè)完成了他們的磁盤讀取工作。它們開(kāi)始競(jìng)爭(zhēng)CPU資源,當(dāng)然CPU在處理競(jìng)爭(zhēng)上比磁盤先進(jìn)多了。問(wèn)題在于,這些線程讀完文件后開(kāi)始編碼這些圖片,如果你有很多很多圖片,那么你的內(nèi)存將開(kāi)始爆倉(cāng)。。然后內(nèi)存耗盡咋辦?虛擬內(nèi)存啊,虛擬內(nèi)存是啥,磁盤資源啊。Oh shit!~
然后進(jìn)入了一個(gè)惡性循環(huán),磁盤資源競(jìng)爭(zhēng)導(dǎo)致更多的線程被創(chuàng)建,這些線程導(dǎo)致更多的內(nèi)存使用,然后內(nèi)存爆倉(cāng)導(dǎo)致虛擬內(nèi)存交換,直至GCD創(chuàng)建了系統(tǒng)規(guī)定的線程數(shù)上限(可能是512個(gè)),而這些線程又沒(méi)法被殺掉或暫停。。。
這就是使用GCD時(shí),要注意的。GCD能智能地根據(jù)CPU情況來(lái)調(diào)整工作線程數(shù),但是它卻無(wú)法監(jiān)視其他類型的資源狀況。如果你的任務(wù)牽涉大量IO或者其他會(huì)導(dǎo)致線程block的東西,你需要把握好這個(gè)問(wèn)題。
修正
問(wèn)題的根源來(lái)自于磁盤IO,然后導(dǎo)致惡性循環(huán)。解決了磁盤資源碰撞,就解決了這個(gè)問(wèn)題。
GCD的custom queue使得這個(gè)問(wèn)題易于解決。Custom queue是串行的。如果我們創(chuàng)建一個(gè)custom queue然后將所有的文件讀寫任務(wù)放入這個(gè)隊(duì)列,磁盤資源的同時(shí)訪問(wèn)數(shù)會(huì)大大降低,資源訪問(wèn)碰撞就避免了。
蝦米是我們修正后的代碼,使用IO queue(也就是我們創(chuàng)建的custom queue專門用來(lái)讀寫磁盤):
- 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);
這個(gè)就是我們的 imagegcd3.m
.
GCD使得我們很容易就將任務(wù)的不同部分放入相同的隊(duì)列中去(簡(jiǎn)單地嵌套一下dispatch)。這次我們的程序?qū)?huì)表現(xiàn)地很好。。。我是說(shuō)多數(shù)情況。。。。
問(wèn)題在于任務(wù)中的不同部分不是同步的,導(dǎo)致了整個(gè)程序的不穩(wěn)定。我們的新程序的整個(gè)流程如下:
Main Thread IO Queue Concurrent Queue find paths ------> read -----------> process ... write <----------- process
圖中的箭頭是非阻塞的,并且會(huì)簡(jiǎn)單地將內(nèi)存中的對(duì)象進(jìn)行緩沖。
現(xiàn)在假設(shè)一個(gè)機(jī)器的磁盤足夠快,快到比CPU處理任務(wù)(也就是圖片處理)要快。其實(shí)不難想象:雖然CPU的動(dòng)作很快,但是它的工作更繁重,解碼、壓縮、編碼。從磁盤讀取的數(shù)據(jù)開(kāi)始填滿IO queue,數(shù)據(jù)會(huì)占用內(nèi)存,很可能越占越多(如果你的~/Pictures中有很多很多圖片的話)。
然后你就會(huì)內(nèi)存爆倉(cāng),然后開(kāi)始虛擬內(nèi)存交換。。。又來(lái)了。。
這就會(huì)像第一次一樣導(dǎo)致惡性循環(huán)。一旦任何東西導(dǎo)致工作線程阻塞,GCD就會(huì)創(chuàng)建更多的線程,這個(gè)線程執(zhí)行的任務(wù)又會(huì)占用內(nèi)存(從磁盤讀取的數(shù)據(jù)),然后又開(kāi)始交換內(nèi)存。。
結(jié)果:這個(gè)程序要么就是運(yùn)行地很順暢,要么就是很低效。
注意如果磁盤速度比較慢的話,這個(gè)問(wèn)題依舊會(huì)出現(xiàn),因?yàn)榭s略圖會(huì)被緩沖在內(nèi)存里,不過(guò)這個(gè)問(wèn)題導(dǎo)致的低效比較不容易出現(xiàn),因?yàn)榭s略圖占的內(nèi)存少得多。
真正的修復(fù)
由于上一次我們的嘗試出現(xiàn)的問(wèn)題在于沒(méi)有同步不同部分的操作,所以讓我寫出同步的代碼。最簡(jiǎn)單的方法就是使用信號(hào)量來(lái)限制同時(shí)執(zhí)行的任務(wù)數(shù)量。
那么,我們需要限制為多少呢?
顯然我們需要根據(jù)CPU的核數(shù)來(lái)限制這個(gè)量,我們又想馬兒好又想馬兒不吃草,我們就設(shè)置為cpu核數(shù)的兩倍吧。不過(guò)這里只是簡(jiǎn)單地這樣處理,GCD的作用之一就是讓我們不用關(guān)心操作系統(tǒng)的內(nèi)部信息(比如cpu數(shù)),現(xiàn)在又來(lái)讀取cpu核數(shù),確實(shí)不太妙。也許我們?cè)趯?shí)際應(yīng)用中,可以根據(jù)其他需求來(lái)定義這個(gè)限制量。
現(xiàn)在我們的主循環(huán)代碼就是這樣了:
- 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
- atch_semaphore_signal(jobSemaphore);
- }));
- }));
- }
- });
- }
- dispatch_group_wait(group, DISPATCH_TIME_FOREVER);
最終我們寫出了一個(gè)能平滑運(yùn)行且又快速處理的程序。
基準(zhǔn)測(cè)試
我測(cè)試了一些運(yùn)行時(shí)間,對(duì)7913張圖片:
程序 | 處理時(shí)間 (秒) |
---|---|
imagegcd1.m |
984 |
imagegcd2.m |
沒(méi)運(yùn)行,這個(gè)還是別運(yùn)行了 |
imagegcd3.m |
300 |
imagegcd4.m |
279 |
注意,因?yàn)槲冶容^懶。所以我在運(yùn)行這些測(cè)試的時(shí)候,沒(méi)有關(guān)閉電腦上的其他程序。。。嚴(yán)格的進(jìn)行對(duì)照的話,實(shí)在是太蛋疼了。。
所以這個(gè)數(shù)值我們只是參考一下。
比較有意思的是,3和4的執(zhí)行狀況差不多,大概是因?yàn)槲译娔X有15g可用內(nèi)存吧。。。內(nèi)存比較小的話,這個(gè)imagegcd3應(yīng)該跑的很吃力,因?yàn)槲野l(fā)現(xiàn)它使用最多的時(shí)候,占用了10g內(nèi)存。而4的話,沒(méi)有占多少內(nèi)存。
結(jié)論
GCD是個(gè)比較范特西的技術(shù),可以辦到很多事兒,但是它不能為你辦所有的事兒。所以,對(duì)于進(jìn)行IO操作并且可能會(huì)使用大量?jī)?nèi)存的任務(wù),我們必須仔細(xì)斟酌。當(dāng)然,即使這樣,GCD還是為我們提供了簡(jiǎn)單有效的方法來(lái)進(jìn)行并發(fā)計(jì)算。