以前在學校的時候,多線程這一部分是屬於那種充滿好奇但是又感覺很難掌握的部分。原因嘛我覺得是這玩意兒和編程語言無關,主要和操作系統的有關,所以這部分內容主要出現在講原理的操作系統書的某一章,看完原理是懂了,但是呢很難和編程實際結合起來,感覺很抽象,因為一般操作系統的書都沒有啥代碼。換到編程相關的書籍上 ...
以前在學校的時候,多線程這一部分是屬於那種充滿好奇但是又感覺很難掌握的部分。原因嘛我覺得是這玩意兒和編程語言無關,主要和操作系統的有關,所以這部分內容主要出現在講原理的操作系統書的某一章,看完原理是懂了,但是呢很難和編程實際結合起來,感覺很抽象,因為一般操作系統的書都沒有啥代碼。換到編程相關的書籍上面,講語言的自然不會講這個,講編程技巧的也很難涉及到這一塊。感覺就是這部分知識本來就不是很多,而且還散落在各個地方,想要原理實踐都掌握有點收集七龍珠的感覺。這段時間工作正好接觸這部分還挺多,遂覺得把我這一點點在編程上的領悟寫出來,也許也有和當年一樣需要的人。
上面說了多線程這東西多是和操作系統有關的知識,但是畢竟我主要目的是想通過具體的程式來具體化這個原理,爭取能做到能從實踐的角度說明白原理,所以我選擇了windows那一套線程函數來說明這一切,雖然windows下麵的線程也有很多弊端,但是說起來個人感覺要簡單點。為了不成為枯燥的windows函數說明文章,我儘力想了一個小的場景來說明這一切,希望能夠讓文字有點吸引力。
為了不要文章太長,我決定分成兩個部分,第一個部分從簡單的開始,扯扯兩個線程之間的二人世界,第二部分開始插入第三者,扯扯多個線程之間的複雜關係。
一、線程世界,星球與母星。
為了說明進程,線程之間的關係,我決定把我的這個命令行程式命名為“MyThrWorld”,Thr是Thread的前三個字母,隨著要介紹的內容,我會慢慢的介紹我腦子裡這個世界的信息。說是世界,但是世界上元素不會超過10個,畢竟先起個霸氣的名字比較重要!
這個世界主要組成部分是“星球”,每個星球由以下幾個主要屬性:
struct Planet { int type; //星球類型 int resource; //資源數目 int speed; //行進速度 int index; //編號 };
這個世界的創造者是一個母星,home planet,我把它初始化成這樣。初始化的順序和上面struct的順序相同,用1代表母星類型,我是有點懶,最好是定義一個常量,這樣可讀性更強一些:
struct Planet homePlanet = InitPlanet(1,nMaxResource,0,0);
nMaxResoure是整個輸出的DOS視窗的最大寬度,這個的目的是為下麵的程式裡面畫的圖不要找不到去哪了,如何取得windows console的輸出DOS視窗的最大寬度以及和這個視窗相關的更多信息呢?請搜索console api windows,在這裡畢竟這不是重點,我們使用全局變數hout來標識輸出的dos console視窗:
HANDLE hOut=GetStdHandle(STD_OUTPUT_HANDLE); CONSOLE_SCREEN_BUFFER_INFO bInfo; GetConsoleScreenBufferInfo(hOut, &bInfo ); nMaxResource = bInfo.dwSize.X;
假設這個home planet是這個世界的創造者,他的作用是分離出新的星球或者鏈接新的星球。 母星目前唯一的運動是旋轉,我設計了一個十分簡單的旋轉邏輯,主要代碼如下:
void PlanetSpin(const struct Planet &p,int spinInterval,int picType) { char spin[4] = {'|','/','-','\\'}; if( p.type == 1 ) { MoveOutputToPos(0,0,&spin[picType%4],false); MoveOutputToPos(0,1,&spin[picType%4],false); MoveOutputToPos(1,0,&spin[picType%4],false); MoveOutputToPos(1,1,&spin[picType%4],false); ::Sleep(spinInterval); } else { ... } }
else部分的代碼後面再說明,大體意思就是讓一個planet以某一個間隔旋轉(spinInterval),其實也就是刷新時間,其中我把所有和在dos視窗中指定地點輸出指定圖形的內容分離出來用一個單獨的函數,因為這部分在後面使用的還是挺頻繁的並且會讓我們遇到多線程編程的第一個重要知識點。MoveOutputToPos前兩個參數是DOS視窗的橫坐標和縱坐標,第三個是輸出的字元,最後一個是為了後面輸出string準備的,第一版代碼大體是下麵這個樣子的,用到的還是console api。
void MoveOutputToPos(int x,int y,char* c,bool bString) { csbiInfo.dwCursorPosition.X = x; csbiInfo.dwCursorPosition.Y = y; if (!SetConsoleCursorPosition(hOut,csbiInfo.dwCursorPosition)) { printf("SetConsoleCursorPosition error!!!!! \r\n"); return; } if(!bString) printf("%c",c[0]); else printf("%s",c); }
二、分離星球。
母星的第一個作用是分離出子星球,既然是子星球這麼直白朴實的名字,我相信你也猜到了,我就是用這個表示子線程。一個子星球線上程世界裡面就是一個獨立的,但是又和母星有關聯的個體,這也符合線程的基本特點。同樣子星球也有資源屬性並且都和母星球共用一個資源庫,而且也會旋轉。但是畢竟他不完全是母星球,還是有一點不同的,子星球可以前進。既然文章主要是介紹多線程,那麼我們就從創建一個子星球來開始第一個多線程相關的函數。
HANDLE hThread1 = CreateThread(0, // SECURITY_ATTRIBUTES, 線程的安全屬性,可以寫一個專門的主題,0表示預設值 16 * 1024L, //為線程分配的堆棧大小,會在最後特別提一下這個,在PC上沒啥感覺,但是我在嵌入式上經常被坑 ThreadProc, //線程執行函數,也就是你具體需要這個線程幹嘛 &Planet1, //主線程傳給線程函數的參數(地址塊) 0, //一些標誌位,比如你可以創建一個線程不讓他立即執行 NULL);//返回線程id,如果你需要,可以定義一個dword,在這個函數調用成功後會寫入線程的id
看到上面的函數,不得不自然說的是這個第三個參數,threadproc,這個參數的定義類型是”_In_ LPTHREAD_START_ROUTINE lpStartAddress”,_In_表示是一個輸入值,中間這個LPTHREAD_START_ROUTINE是一個函數指針,定義如下:
typedef DWORD (__stdcall *LPTHREAD_START_ROUTINE) ( [in] LPVOID lpThreadParameter );
這個看起來超級複雜的式子其實就是指向一個返回值是dword並且沒有參數的函數的一個指針,本質上是一個地址,所以用lpStartAddress作為參數的名字。有了這個說明,所以ThreadProc的函數原型一定是這樣的了:
DWORD WINAPI ThreadProc( _In_ LPVOID lpParameter );
在這個ThreadProc函數裡面,目前只讓子星球做兩件事情,前進和旋轉。而前進的動力來自於資源,但是你會發現貌似以目前介紹過的函數,你還沒有辦法控制這個資源和母星球資源之間的增減,所以我就先簡單的假設子星球創建出來之後其資源是可以自己獲取自己增加,這樣他就可以不斷的前進了。
DWORD WINAPI ThreadProc(LPVOID lpParameter) { struct Planet* p = (struct Planet*)lpParameter; int nSpinType = 0; while(true) { PlanetMove(*p); PlanetSpin(*p,500,nSpinType); nSpinType++; } return 1; } void PlanetMove(struct Planet &p) { char cLine[100]; memset(cLine,0,100*sizeof(char)); if( p.resource < nMaxResource-1 ) { p.resource++; memset(cLine,'-',p.resource*sizeof(char)); MoveOutputToPos(0,p.index*3+1,cLine,true);
} }
到這裡,我們就可以補足PlanetSpin裡面else的那一部分了,這一部分是為給子星球寫的旋轉代碼。
else { char sep = '*'; MoveOutputToPos(0,p.index*3-1,&sep,false); MoveOutputToPos(0,p.index*3,&sep,false); MoveOutputToPos(p.resource,p.index*3+1,&spin[picType%4],false); ::Sleep(spinInterval); }
目前看來,我們已經可以自由的創建出一個星球,事實上,只要你願意,你可以創建出多個。但是為了不要讓問題搞的特別複雜,我還是先限定為一個。目前創建出來的星球一個最大的特點是不聽使喚無法控制,你無法控制它前進與否,旋轉與否,什麼時候開始啟動等等。因為一旦你僅僅是創建一個線程,你能對它做的很少很少。剛創建出來的線程就像一個熊孩子,無法控制,你得通過一些手段和方法來讓它能夠聽從主線程的指揮或者和主線程互動。這也是後面慢慢要寫的內容,也是多線程編程的核心內容了。
【更多】這裡我最想討論的是CreateThread裡面的第二個參數,以及線程id和線程handle的區別。 CreateThread的第二個參數表示給線程分配的起始堆棧大小,這個大小是耗用進程的虛擬記憶體空間的,在PC系統上一個進程的虛擬記憶體空間大小至少都是4G,所以大多數時候這個參數所造成的影響基本真的是沒有。而我經常碰到的嵌入式平臺,一個進程的虛擬記憶體地址有的時候非常的小,比如wince5只有32mb(雖然系統是老的不能再老了,但是還是有用),如果一旦不小心幾個線程的這個參數值沒有註意,很容易一起來就掛了。而一個線程除了起始堆棧大小,還有reserved堆棧大小,你可以理解為reserved的堆棧大小是一個用來保護作用的線程最大能夠使用的堆棧大小,這個reserved的預設值一般是設置在exe的頭部的,預設值一般是1MB。那麼如果你第二個參數設置的大於1MB會怎麼樣呢,這樣系統會預設增長reserved的記憶體大小到離起始大小最近的1MB。 特別註意的是如果在倒數第二個參數中使用STACK_SIZE_PARAM_IS_A_RESERVATION,那麼你就可以設置這個reserved的堆棧大小,這個時候,起始堆棧大小就是exe程式頭中的一個預設值。另外值得特別註意的是,線程的堆棧空間只有線上程自己退出的情況下才會被釋放,如果這個線程是被其他線程終止的,那麼這部分堆棧空間將不會被釋放。忘了說了,終止線程的函數是TerminateThread,只需要一個線程的handle,和一個exit code作為參數就可以。說到handle和線程id的區別,簡單的說可以認為handle是我們寫程式所使用的線程標識符而id是一個用戶可讀的線程標識符。
三、不友好的顯示。
如果你使用上面的邏輯以及代碼去運行一下這個程式,試圖查看一下運行起來到底是什麼樣子的。我可以大膽預測你第一次運行看到的和你腦海裡想的完全不一樣,那你要說了,難道第二次就一樣了嗎?答案是還是不一樣,第三次到第n次都和你想象的不一樣並且很可能這n次相互之間也完全不一樣。一種滿屏的雜亂感,你會發現本有些該在指定位置輸出的符號並沒有輸出,而是在另外一個風馬牛不相及的地方輸出了。調試一下吧,你會發現特別的不順,甚至每一次運行結果行為都不一樣。這就是多線程編程的一個特點和難點,難以調試並且預測,所以我們就需要對這種雜亂無章的行為進行約束和控制。
首先,分析一下為何會出現這樣的行為,我們的函數不多,可以用註釋一部分的辦法來試試看看能不能找到問題在哪裡。第一步,首先把createthread註釋掉,或者倒數第二個參數使用CREATE_SUSPENDED,這樣線程會被掛起,並不會被執行。你會發現,在只有一個主線程的情況下,一切運行如你所料,說明創建一個線程確實會導致不友好的顯示問題。按照這個思路,接下來我們應該到線程函數threadproc裡面找一找原因,一共也就倆函數,想到是顯示出了問題,所以最大的懷疑對象是在函數MoveOutputToPos上面,既然這樣,我們把move和spin裡面MoveOutputToPos函數註釋掉再試試,發現也沒有問題。那麼可以把註意點放在這個函數裡面了。這個函數主要由兩個部分組成,SetConsoleCursorPosition和printf,另外還有一個給CONSOLE_SCREEN_BUFFER_INFO變數賦值。為了一探究竟到底發生了啥,我們是稍微臨時改造一下這個函數,傳入星球的類型,這樣我們就可以做一些輸出了。第一步,現在變數CONSOLE_SCREEN_BUFFER_INFO賦值後面加入printf(“%d->x:%d,y:%d\r\n”,type,csbiInfo.dwCursorPosition.X ,csbiInfo.dwCursorPosition.Y)。運行一下你會發現,這個基本沒有什麼錯誤,坐標值對於每個類型基本都是你傳入的都是正確的,那麼為什麼感覺設置的輸出位置總是不對呢?只能把目光放在SetConsoleCursorPosition另外一個參數handle hout上面了,這個參數是是一個全局變數,那麼從代碼的角度出發也就是說作用域是整個代碼文件,也就是說不管是main還是threadproc都是使用的這一個值,而在調用SetConsoleCursorPosition時,就可能發生主線程調用的這個函數但是下一秒切換成子線程的hout拷貝到寄存器上,造成了混亂。這就是多線程編程中的一個永恆而又核心的問題,資源的競爭。
資源,在任何世界里都是核心問題,無論是現實還是虛擬,一個世界的運行最本質的就是建立在資源的基礎上。所有的戰爭本質上就是資源分配的不合理,無法滿足自我的需求或者覬覦別人的資源。在電腦多線程的世界里,資源的問題嚴重的會導致程式崩潰,輕一點的會導致程式不當運行,雖然虛擬記憶體保證了資源是無法繞過進程的底線但是這也是無法容忍的。在我們目前的程式裡面,有兩個線程,主線程和子線程都能訪問到這個進程的資源,也就是這個console windows。而電腦在運行多線程的時候實際上是以很快的速度不停的切換當前運行的進程,這樣每個線程都可以分配到一點CPU的時間從而運行自己。為什麼作為終端用戶我們感覺不出來,是因為這個切換速度十分的快並且十分的頻繁,導致我們根本感覺不到,所以給你一個假象是兩個線程在並行(同時)的運行。這種切換是一個複雜的過程,但是可以推理的到就是他們會使用一些公用的空間,因為畢竟只有一個進程。在我們的這個例子裡面,主線程和子線程在運行的時候都想在這個dos視窗上輸出,所以他們會不停的試圖搶占這個dos視窗,但是他們的輸出坐標是各自的。所以會導致一個問題,在主線程計算出自己坐標之後突然發生了切換,這時候子線程取得dos視窗的控制權,於是本來該畫在下麵子星球位置上的符號畫在了一個看起來很奇怪的位置上。
要解決這個問題的在多線程編程中有很多,而且後面遇到的時候也很多,我準備每次用一個不同的辦法,在這裡先介紹一個最簡單的,CRITICAL_SECTION。CRITICAL_SECTION從名字中就能看出其霸氣的作用,關鍵區域,讀多了有種緊迫感。CRITICAL_SECTION其實簡單的我覺得他就像一個看門人,在它鎖定的區域它一次只放進來一個線程,如果屋裡有人了,就不在允許第二個線程進入了。在本程式中,首先你得聲明一個全局的CRITICAL_SECTION cs;然後得在main函數的最開始調用InitializeCriticalSection(&cs);來初始化,然後在離開main的時候得使用DeleteCriticalSection(&cs);來銷毀這個對象。然後就是要更新一下MoveOutputToPos函數:
void MoveOutputToPos(int x,int y,char* c,bool bString) { CONSOLE_SCREEN_BUFFER_INFO csbiInfo; csbiInfo.dwCursorPosition.X = x; csbiInfo.dwCursorPosition.Y = y; EnterCriticalSection(&cs); if (!SetConsoleCursorPosition(hOut,csbiInfo.dwCursorPosition)) { printf("SetConsoleCursorPosition error!!!!! \r\n"); LeaveCriticalSection(&cs); return; } if(!bString) printf("%c",c[0]); else printf("%s",c); LeaveCriticalSection(&cs); }
裡面有兩個主要函數 EnterCriticalSection(&cs);和LeaveCriticalSection(&cs);第一個可以理解為開前門放一個線程進來然後關前門,第二個是開後門讓一個線程出去然後關後門開前門等下一個進程。所以這兩個函數要成對出現,無論你調用多少次,但是一定要配對,不然就出現前門關了再也沒開過導致其他線程進不來或者前門一直大開沒有任何效果。如此運行一下你會發現一切變得如你所料的顯示了,因為在這裡我們把主要之前的競爭資源hout做了管理。
【更多】本來感覺這一節感覺我應該深入的說明一下CRITICAL_SECTION以及各種相關函數的原理,但是我覺得我怎麼寫也不可能比這一篇更通俗易懂了:http://my.oschina.net/myspaceNUAA/blog/81244,所以我決定我稍微扯一下windows的進程。首先要明確的一點是進程並不會執行任何代碼,操作系統調度和執行的最小單位是線程,而進程都至少有一個線程。那麼進程到底有什麼用呢?進程給線程的執行提供了環境,他是線程執行的容器,可以保證線程一直在每個進程空間中執行。如果兩個線程在同一個進程中執行,那麼這兩個線程可以共用進程的地址空間,他們可以共用進程裡面的代碼和數據。所以你可以看到在我們的例子裡面,主線程和子線程可以共同擁有hout,還可以共同獲得home planet和planet1結構體裡面的信息。打個比方,進程就像一個房子,線程就像住進房子裡面的人,世界的運行並不是由房子發動的,而是由房子中的人來進行的,但是房子提供了人活動的邊界和各種房間以及功能性的資源,在同一個房子中所有人原則上都能運用裡面所有的房間和東西但是用不了別人房子的內容。就算互相交流也是以人與人為單位互相交流甚至和鄰居交流,房子是不能互相交流的。
四、另一種形式的美好。
CRITICAL_SECTION提供了一種非常方便的同步方法並且是在user mode下的而不是kernel mode下實現的,幾個相關函數也表達出了特別通俗易懂的解決方式。解決一個問題的方法往往不止一個,我一直都感覺如果把兩個相似的東西放在一起對比一下最能體會到二者的不同與奧秘,所以在這一個部分,還是第三部分同樣的問題,換一種方式一樣能解決這個問題。
我想要在這裡介紹的叫做mutex,mutex是Mutual Exclude的縮寫,Mutual是彼此的意思,所以這個東西直白的翻譯就是彼此的排斥,常見的中文翻譯名稱叫做互斥體。這個名稱就很好理解了,其作用基本和CRITICAL_SECTION基本很像,也是在某一時間內,只有一個單位能夠擁有資源(這裡用單位沒有用線程,因為Mutex是可以跨進程使用的)。如果想使用mutex,首先你的創建一個,ghMutex是一個global的handle對象:
ghMutex = CreateMutex(NULL,FALSE,NULL);
CreateMutex有三個參數,第一個是和createthread第一個參數的含義相同,第二個參數是表示創建者是否是這個mutex的初始擁有者,最後一個參數是你可以給這個mutex對象起一個名字,這樣在以後的調用中,你可以使用這個名字唯一的找到這個mutex對象,於是在其他進程中你一樣能得到這個mutex對象的句柄。說到獲得一個已創建的mutex句柄,你還可以使用OpenMutex函數,這個函數提供獲取一個命名mutex的功能並且能指定對這個mutex的許可權。對於這部分內容我會在後面稍微扯扯我所知道的。當你有一個mutex對象了之後,想做到同步與控制,你還需要一個重要的函數,WaitForSingleObject,這個函數用來等待一個內核對象的狀態變成被激活或者說獲取一個激活狀態的內核對象,其作用在多線程編程中差不多相當於封裝對於C++的地位差不多。那麼在介紹怎麼用這個函數之前,先來解釋一下什麼叫激活狀態。簡單的來說,當你創建一個內核對象的時候,比如Mutex,那麼在內核中它就擁有一個初始狀態,在我們這個例子中,這是被激活的一種狀態,也就是差不多等於我沒有被任何人使用,現在十分的空閑。這時候調用WaitForSingleObject,那麼一等就等到了,因為這個時候Mutex沒事幹,此時,內核會將這個mutex變成非激活狀態。然後如果有其他線程再等待這個內核對象,那麼這個忙碌的,非激活的Mutex就不能被等待,這個線程只能先登在關鍵資源的外面直至Mutex變成激活狀態之後它才能擁有它。那麼既然WaitForSingleObject之後Mutex會變成非激活狀態,怎麼讓它重新變成激活狀態呢?你只需要調用ReleaseMutex。
說了這麼多,現在來具體介紹一下WaitForSingleObject和它的返回值,WaitForSingleObject需要兩個參數,一個是對象的handle,一個是最大等待時間。如果在指定時間內等到一個激活的對象,那麼就會返回WAIT_OBJECT_0,一般來說這個返回值就意味著你該做和關鍵資源相關的事情。如果最大等待時間都過了還沒有等到一個激活對象,那麼將會返回WAIT_TIMEOUT。另外,這個函數還可以返回WAIT_ABANDONED,這個返回值會在一個擁有mutex的線程在沒有釋放這個mutex的使用權的情況下就終止了,那麼這個mutex的狀態就變成了abandoned。如果這個函數返回的是一個WAIT_FAILED,那麼就是說這個函數在執行的過程中出現了某種錯誤。所以稍微修改一下MoveOutputToPos函數,利用Mutex替換CRITICAL_SECTION。
void MoveOutputToPos(int x,int y,char* c,bool bString) { CONSOLE_SCREEN_BUFFER_INFO csbiInfo; csbiInfo.dwCursorPosition.X = x; csbiInfo.dwCursorPosition.Y = y; DWORD dStatus = WaitForSingleObject(ghMutex,1000*30); if ( dStatus == WAIT_OBJECT_0 ) { if (!SetConsoleCursorPosition(hOut,csbiInfo.dwCursorPosition)) { printf("SetConsoleCursorPosition error!!!!! \r\n"); ReleaseMutex(ghMutex); return; } if(!bString) printf("%c",c[0]); else printf("%s",c); ReleaseMutex(ghMutex); } else { ReleaseMutex(ghMutex); } }
最後,在main函數中應該用CloseHandle(ghMutex);來釋放這個mutex對象。
【更多】這裡我想扯的有兩個,一個是CRITICAL_SECTION和Mutex之間的區別,第二個是Mutex這類東西是怎麼實現對於資源的同步與控制的。
首先CRITICAL_SECTION是在用戶模式下的一個實現,而Mutex是工作在內核模式的。這兩個的主要差別就是在速度上,如果只工作和實現在用戶模式,那麼操作系統不需要進行上下文保存,系統調用或者中斷等等,需要的指令自然就少,那麼結果肯定是快。而Mutex得經歷從application的代碼用戶模式到內核模式的轉換,那麼需要的工作就多了。其實用戶模式和內核模式就是執行的CPU指令,為了不至於讓應用程式跑著跑著就搞出了嚴重的系統錯誤,CPU一般把指令分成兩種模式,特權模式和非特權模式,你可以理解為一些非常關鍵的指令,比如讀取或改變諸如程式狀態字之類控制寄存器的指令、原始I/O 指令和與記憶體管理相關的指令等等只能是像操作系統這樣級別的總體調度者才能執行,如果誰都能執行,那麼操作系統就亂了。這兩個另外一個差別是Mutex是可以跨進程使用,而CRITICAL_SECTION肯定是不行的。最後一個我覺得重要的是如果擁有Mutex的線程非法的結束了,那麼其狀態會變成abandoned狀態,這個時候你用WaitForSingleObject依然可以取得這個鎖並且做一些工作,而CRITICAL_SECTION只會變成未知狀態,你可能能不能再進入資源變成了未知。
關於Mutex是怎樣實現資源的同步與控制的,按理說他也是一條條的指令,為啥它就不能被線程切換打斷而SetConsoleCursorPosition就能呢?這裡首先要說到的是原子操作,對於原子操作的定義是這樣的:如果這個操作所處的層(layer)的更高層不能發現其內部實現與結構。原子操作可以是一個步驟,也可以是多個操作步驟,但是其順序是不可以被打亂,或者切割掉只執行部分。簡單點的說就是原子操作不同於普通的指令操作,它是不可以被打斷的,要麼一次性執行完,要麼一個也不執行。原子操作的實現是一個很深度也很具有技術難度的問題,具體的有興趣的話可以查看http://www.cnblogs.com/fanzhidongyzby/p/3654855.html。而對於Mutex對象包含: 一個線程 ID ,使用計數和遞歸計數 。線程 ID 表示當前占用該互斥量的線程 ID ,遞歸計數表示該線程占用互斥量的次數,使用計數表示使用互斥量對象的不同線程的個數。通過這三個元素,Mutex才能完成同步與控制。這裡還想扯扯的就是CreateMutex的第二個參數,如果第二個參數是ture,那麼表示當前創建Mutex的線程擁有該mutex對象,也就是說當前線程先占用了該Mutex對象,在該線程調用ReleaseMutex之前,別的線程是無法通過WaitForSingleObject等待這個Mutex的。而ReleaseMutex的具體作用是每調用它一次將互斥對象的計數器減一,直到減到零為止,此時釋放互斥對象,並將互斥對象中的線程id 置零。它的使用條件是,互斥對象在哪個線程中被創建,就在哪個線程裡面釋放。因為調用的時候會檢查當前線程的id是不是與互斥對象中保存的id一致,若一致,則此次操作有效,不一致,則無效。
五、命令與控制。
從前面到這裡,planet裡面定義的resource屬性到現在還沒有使用,那麼在這一節,我準備使用上這個屬性。在我任性的邏輯裡面我準備這樣做,子星球每前進一步消耗一點資源,然後將這個資源輸出在console的屏幕上的某個位置。那末就先構造好這個資源顯示函數:
void UpdatePlanetResource(struct Planet &p) { char cRes[3]; itoa(p.resource,cRes,10); if( p.type == 1 ) { MoveOutputToPos(2,0,cRes,true); } else { MoveOutputToPos(1,p.index*3,cRes,true); } }
這個函數就是根據不同的星球類型顯示目前他的資源數字。在我們的程式中,我們需要時時的更新這些資源數據,那麼看起來應該很簡單的一個問題,分別在主線程和子線程中相應位置調用這個函數就好了。仔細看一下這個想法,會發現一個問題,在我們的設計中,母星球旋轉的速度和子星球前進的速度並不一樣,母星球顯示刷新周期是80ms而子星球是500ms,也就是說子星球沒500ms消耗一點資源而這個時候母星球已經刷新了四個周期了。這樣的話會有一個很現實的問題,你無法實現兩個資源消耗額度的同步刷新。如果你單純的只是在兩個星球的while迴圈中調用上面的函數,你會發現兩個資源的增加與減少並不同步。這時候會想到有沒有一種辦法,當子星球消耗了一點資源的時候才去通知母星球,然後讓它去更新一下自己的資源?
為瞭解決成千上萬和這個問題一樣的問題,windows多線程編程中提供了一個叫Event的對象來實現這一通知響應機制。要使用Event,首先還得創建一個handle對象作為CreateEvent的返回值,類似於CreateMutex:
hEvent = CreateEvent( NULL , FALSE , FALSE , NULL );
相比於CreateMutex函數,CreateEvent有四個參數,第一個和最後一個參數的意義和CreateMutex相同。那麼就只需要介紹一下第二個,第三個參數,第二個參數是是否手工設置狀態,第三個是Event的初始狀態是啥。要具體解釋一下這兩個參數就要先解釋一下Event的狀態,和Mutex一樣作為一個內核對象,Event也有激活態和非激活態。如果第二個參數是false,那麼在每次獲得激活狀態的Event對象之後,Event對象會自動設置為未激活狀態,反之,則需要手工調用ResetEvent將其設置為未激活狀態。第三個參數如果是True,那麼這個Event的初始狀態就是激活的,反之則是未激活的。
下麵我們就要讓子星球給母星球發送更新通知了,採用的函數是SetEvent,將event設置為激活狀態,相當於給母星球發送一個通知,我們在子星球的PlanetMove裡面加入如下代碼:
void PlanetMove(struct Planet &p) { char cLine[100]; memset(cLine,0,100*sizeof(char)); if( p.resource < nMaxResource-1 ) { p.resource++; memset(cLine,'-',p.resource*sizeof(char)); MoveOutputToPos(0,p.index*3+1,cLine,true); UpdatePlanetResource(p); SetEvent(hEvent); } }
在更新resource狀態之後將event設置為激活狀態,此時母星球線程中的wait函數就會等到這個event,然後更新自己的resource信息並顯示,所以在main的迴圈中加入如下的代碼:
while(true) { PlanetSpin(homePlanet,80,nSpinType); nSpinType++; DWORD dStatus = WaitForSingleObject(hEvent,500); if ( dStatus == WAIT_OBJECT_0 ) { homePlanet.resource -= 1; UpdatePlanetResource(homePlanet); // ResetEvent(hEvent); If the senconde para. is TRUE } }
在wait函數中,如果等到了激活態的event,homeplanet的resource將會減一,同時更新顯示resource。因為我們設置的event的auto reset,所以這裡不需要調用resetevent再將其變成非激活狀態。這裡的resource雖然也是一個全局的資源,但是因為使用event進行了控制,所以在這裡不需要mutex之類進行同步。這種event的控制,通知機制可以很好處理此類問題。
【更多】這一次我想扯的是CreateEvent和CreateMutex的第一個參數,無論在這兩個裡面哪一個,我們都是傳遞的NULL。首先看一下這裡的NULL的含義是這樣的:一個指向SECURITY_ATTRIBUTES結構的指針,確定返回的句柄是否可被子進程繼承。如果lpEventAttributes是NULL,此句柄不能被繼承(百度里複製的)。NULL還有一個含義就是這個對象獲得預設的access rights,那麼到底什麼是access rights呢?windows有安全模型,這個模型規定用戶去訪問這些內核對象的許可權,比如mutex,event還有後面要介紹的semaphore以及其他等等,如果有興趣,請看這個https://msdn.microsoft.com/zh-cn/data/aa374876(v=vs.100) ,又是一個值得深入瞭解的知識。用簡單的話說,這個模型裡面規定了一些許可權,比如說SYNCHRONIZE允許該對象可以做同步,允許一個線程可以等待直到這個對象編程激活狀態,關於這個其實是一個非常非常值得深入瞭解的知識,其中涉及的ACLs,DACL,SACL等等,瞭解這個可以對windows怎樣控制安全有一定的幫助,我建議可以看看https://msdn.microsoft.com/en-us/library/windows/desktop/ms686670(v=vs.85).aspx
到目前為止的代碼我都放在https://github.com/rogerzhu0710/MyTheWorld/ 這裡了。