——Lilytask2.5基於Win32thread的實現
段孟成([email protected])
Lilytask是以任務爲單位的並行編程模型,Lilytask2.5β版最初是在Linux系統上基於POSIX thread實現的,爲了更好的適應並行計算環境中的異構性,又在β版的基礎上實現了for Windows版,在實現過程中,需要用Win32thread library替換POSIXthread library,下文將主要描述POSIX thread(下文稱之pthread)與Win32thread的關係以及在Lilytask2.5 for Windows中的具體實現。
一.什麼是線程。
線程(thread)是爲了提高系統內程序的併發(concurrency)執行程度而提出來的概念,它是比進程更小的能夠獨立運行的基本單位。在引入線程的系統中,線程是處理器調度(schedule)的基本單位,而傳統的進程則只是資源分配的基本單位。同一進程中的線程共享這個進程的全部資源與地址空間,除此之外,線程基本上不擁有其他任何系統資源,當然,每個線程也必須擁有自己的程序計數器(Program Counter),寄存器堆(register file)和棧(stack)等等。即線程是一個輕量級實體(light-weight entity),它的結構(thread structure)相對簡單,在切換速度上非常得快,同一進程中的線程切換不會引起進程的切換,對於並行計算來講,有效的利用線程能夠改善計算的效率,簡化計算的複雜性,所以Lilytask正是基於線程實現的。
二.線程的標準。
目前,主要有三種不同的線程庫的定義,分別是Win32,OS/2,以及POSIX,前兩種定義只適合於他們各自的平臺,而POSIX 定義的線程庫是適用於所有的計算平臺的,目前基本上所有基於UNIX的系統都實現了pthread。本文主要討論Win32和POSIX的線程定義。
線程的實現一般有兩種方法,一是用戶級的線程(user-level thread library),另一種是核心級的線程(kernel-level library)。對於用戶級的線程來說,它只存在於用戶空間中(user space),它的創建,撤銷和切換都不利用系統調用,與核心無關;而核心級的線程依賴於核心,它的創建,撤銷和切換都是由核心完成的,系統通過核心中保留的線程控制塊來感知線程的存在並對線程進行控制。Win32thread是基於核心級線程實現的,而pthread部分是基於用戶級線程實現的。
三.pthread和Win32thread的具體實現。
1.關於線程創建和消亡的操作。
1.1 創建和撤銷一個POSIX線程
pthread_create(&tid, NULL, start_fn, arg); pthread_exit(status); |
1.2 創建和撤銷一個Win32線程
CreateThread(NULL, NULL, start_fn, arg, NULL, NULL); ExitThread(status); |
start_fn是該線程要執行的代碼的入口。線程創建後,就擁有了一個TID(thread ID),以後對於該線程的操作都是通過TID來進行的。Win32雖然也定義了TID,但是它對於線程的操作是通過另外定義的一個句柄(handle)來進行的,總之,在線程創建完畢後,都有一個唯一了標識符來確定對該線程的引用。線程的撤銷可以顯式的調用上面列舉的函數來實現,如果沒有顯式調用撤銷函數,則該線程執行的函數(即start_fn)返回時,該線程被撤銷。關於創建和撤銷線程,POSIX和Win32並無太大的區別。
2.關於線程的等待(join or wait for)的操作。
在多線程模型下,一個線程有可能必須等待其他的線程結束了才能繼續運行。比如說司機和售票員,司機只有當售票員確定所有的人都上車了,即售票員的行動結束以後才能開車,在這之前司機必須等待。
2.1等待一個POSIX線程
pthread_join(T1); |
2.2等待一個Win32線程
WaitForSingleObject(T1); |
當調用上面的函數是,調用者會被阻塞起來,直到要等待的線程結束。對於POSIX,線程分爲可等待(non-detached)和不可等待(detached),只能對可等待的線程調用pthread_join(),而對不可等待的線程調用pthread_join()時,會返回一個錯誤。對於不可等待的線程,當線程消亡時,它的線程結構,棧,堆等資源會自動的歸還給操作系統;而對於可等待的線程,當它消亡時並不自動歸還,而需要程序員顯式的調用等待函數來等待這個線程,由等待函數負責資源的歸還。在線程創建的時候,你可以且定該線程是否爲不可等待,如果沒有顯式確定,則默認爲可等待。當然也可以通過調用pthread_detach()來動態的修改線程是否可等待。在Win32的線程中沒有不可等待這個概念,所有的線程都是可以等待的,另外,Win32還提供一個調用WaitForMulitpleObject(T[]),可以用來等待多個線程。
關於爲什麼要使用等待操作,上面解釋的很清楚,但實際上並不是如此,我們之所以要使用等待操作,是因爲我們認爲序關係中的前驅線程結束以後,後續線程才能從阻塞態恢復執行,在這裏我們要明白一個問題,我們等待的僅僅是前驅線程執行的任務結束而不是前驅線程本身的結束,或許前驅線程在執行完任務後會有一些其它的操作,那麼等待前驅線程的結束會浪費我們的時間,所以通常不是利用上面的等待函數,而是利用下面要提到的同步機制(synchronization)來解決這個問題。
3.關於線程掛起(suspend)的操作。
線程的掛起是指線程停止執行,進入睡眠狀態,直到其他線程調用一個恢複函數時,該線程才脫離睡眠狀態,恢復執行。
3.1 掛起和恢復一個Win32線程
SuspendThread(T1); ResumeThread(T1); |
POSIX並沒有實現線程的掛起和恢復操作,對於某些場合,掛起操作可能非常有用,但在大多數情況下,掛起操作可能會帶來致命的錯誤,如果被掛起的線程正擁有一個互斥量(mutex)或一個臨界區(critical section),則不可避免的會出現死鎖狀態,所以通常也不使用掛起操作,如果一定要使用,必須檢查會不會出現死鎖情況。
4.關於線程的強制撤銷(cancellation or killing)的操作。
一個線程有可能會通知另一個線程執行撤銷操作,比如一個發送信息線程和一個接收信息線程,當發送方法送完所有信息,自身需要撤銷時,它必須通知接受方發送完畢並且要求接受方也要撤銷。對於上面的這種情況,POSIX稱爲cancellation,Win32稱爲killing,在實質上二者並沒有多大區別。
4.1 撤銷一個POSIX線程
pthread_cancel(T1); |
4.2 撤銷一個Win32線程
TerminateThread(T1); |
5.線程的調度(scheduling)
線程的調度機制通常分爲兩種:一種是進程局部調度(process local scheduling),一種是系統全局調度(system global scheduling)。局部調度是指線程的調度機制都是線程庫自身在進程中完成的,與核心沒有關係。POSIX對於兩種調度機制都實現了,而Win32由於實現的是核心級線程,所以它的調度機制是全局的。線程的調度機制相當複雜,但對於線程庫的使用者而不是開發者而言,線程的調度並不是最重要的東西,因爲它主要是由操作系統和線程庫來實現,並不需要使用者使用多少。
6.線程的同步機制(synchronization)
線程的同步是一個非常重要的概念,也是使用者最需要注意的地方之一。如果線程的同步機制使用不當,非常容易造成死鎖。同步機制是基於原子操作(atomic action)實現的,所謂原子操作是指該操作本身是不可分割的。爲什麼要使用線程同步機制?因爲在程序中,可能會有共享數據和共享代碼,對於共享數據,我們要確保對該數據的訪問(通常是對數據的修改)是互斥的,不能兩個線程同時訪問這個共享數據,否則會造成錯誤;而對於共享的代碼,如果這段代碼要求的是互斥執行(通常把這段代碼稱爲臨界區),則也需要同步機制來實現。另外,對於一個線程,可能會需要等待另一個線程完成一定的任務才能繼續執行,在這種情況下,也需要同步機制來控制線程的執行流程。通常,同步機制是由同步變量來實現的,一般說來,同步變量分爲互斥量,信號量和條件量。
6.1互斥量mutex是最簡單的同步變量,它實現的操作實際上就是一把互斥鎖,如果一個線程擁有了這個mutex,其他線程在申請擁有這個mutex的時候,就會被阻塞,直到等到先那個線程釋放這個mutex。在任何時候,mutex至多隻有一個擁有者,它的操作是完全排他性的。
6.1.1 POSIX的mutex操作
pthread_mutex_init(MUTEX, NULL); pthread_mutex_lock(MUTEX); pthread_mutex_trylock(MUTEX); pthread_mutex_timedlock(MUTEX, ABSTIME); pthread_mutex_unlock(MUTEX); pthread_mutex_destroy(MUTEX); |
6.1.2 Win32的mutex操作
CreateMutex(NULL, FALSE, NULL); WaitForSingleObject(MUTEX); ReleaseMutex(MUTEX); CloseHandle(MUTEX); |
POSIX的mutex操作提供了trylock和timedlock的調用,目的是爲了防止死鎖,Win32的wait操作本身可以設定超時,因此可以用設定超時的方法來模擬POSIX中的trylock,雖然二者在操作的集合上不等勢,但顯然二者在功能上是等價的。另外,Win32還提供一個叫做CriticalSeciton的互斥量,簡單說來,它就是一個輕量級的mutex,並且只能實現統一進程中的線程的同步,不能實現跨進程的線程間的同步。CriticalSection較之mutex來說,更快更高效,而且與POSIX相似,CriticalSection操作提供一個TryEnterCriticalSection的操作,用來監測該CriticalSection是否被鎖上。但它沒有實現與timedlock相似的功能。
6.1.3 Win32的CriticalSection操作
InitializeCriticalSection(&cs); EnterCriticalSection(&cs); TryEnterCriticalSection(&cs); LeaveCriticalSection(&cs); DeleteCriticalSection(&cs); |
6.2信號量semaphore最初是由E.W.Dijkstra於20世紀60年代引入的。通常,信號量是一個計數器和對於這個計數器的兩個操作(分別稱之爲P,V操作),以及一個等待隊列的總和。一個P操作使得計數器減少一次,如果計數器大於零,則執行P操作的線程繼續執行,如果小於零,那麼該線程就會被放入到等待隊列中;一個V操作使得計數器增加一次,如果等待隊列中由等待的線程,便釋放一個線程。簡單的,我們可以通過一個圖示來了解P,V操作的意義:
P操作: V操作:
continue
從圖中可以看出,信號量的操作實際上是包括了互斥量的。一般地說來,信號量的操作可以在不同的線程中進行,而互斥量只能在同一個線程中操作,當互斥量和信號量要同時操作時,一定要注意互斥量的lock操作和信號量的P操作的順序,通常應該是信號量的P操作在互斥量的lock操作之前,否則容易出現死鎖。而互斥量的unlock操作和信號量的V操作則不存在這種序關係。
6.2.1 POSIX的信號量操作
sem_init(SEM, 0, VALUE); sem_wait(SEM); sem_trywait(SEM); sem_destroy(SEM); |
6.2.2 Win32的信號量操作
CreateSemaphore(NULL, 0, MaxVal, NULL); WaitForSingleObject(SEM); ReleaseSemaphore(SEM); CloseHandle(SEM); |
6.3條件量condition variables是一種非常類似於信號量的同步變量,不同的是,信號量關注的是counter的計數是多少,而條件量關注的僅僅是條件是否滿足,換一句話說,條件量可以簡單看作是計數器最大取值不超過1的信號量,但在它絕對不是信號量的簡單實現,某些情況下,它比信號量更直觀。同信號量一樣,條件量是由一個待測條件,一組PV操作和一個等待隊列組成的。它的PV操作和信號量的PV操作也非常的類似。
但是必須注意一點,在信號量中,lock和unlock是在信號量內部完成的,也就是說不需要使用者顯式指定一個互斥量來進行互斥操作,而對於條件量來說,就必須顯式地指定一個互斥量來保證操作的原子性。所以條件量總是與一個相關的互斥量成對出現的。
6.3.1 POSIX的條件量的操作
phtread_cond_init(COND, NULL); phtread_cond_wait(COND, MUTEX); phtread_cond_timedwait(COND, MUTEX, TIME); phtread_cond_signal(COND); phtread_cond_broadcast(COND); phtread_cond_destroy(COND); |
其中broadcast是用來喚醒所有等在該條件量上的線程。
Win32中並沒有條件量這個概念,但是它實現了一種叫做Event的同步變量,實質上和條件量是差不多的。
總體上來講,POSIX和Win32實現的線程庫在功能上基本上重疊的,也就是說用其中一種線程庫實現的程序大多數的時候都能夠比較容易的用另一種線程庫來實現。下面列出了一張表,對比了一下兩個線程庫的異同:
|
POSIX thread library |
Win32 thread library |
設計思想 |
簡單 |
複雜 |
級別 |
用戶級/核心級 |
核心級 |
調度策略 |
進程局部/系統全局 |
系統全局 |
線程掛起/恢復 |
未實現 |
實現 |
互斥量 |
實現 |
實現 |
信號量 |
實現 |
實現 |
條件量 |
實現 |
實現(事件對象Event) |
線程創建/撤銷 |
實現 |
實現 |
線程等待 |
實現 |
實現 |
四.Lilytask2.5的Win32thread實現。
Lilytask中涉及到的Win32thread,主要表現在線程的創建和同步兩個方面上,下面就簡單的講述以下這兩個方面的實現。
1.線程的創建以及相關的處理。
在每一個節點上,主線程要創建相應的線程:接收消息線程,發送消息線程,如果同一節點上的taskpool數目超過一個,則還要創建從處理線程,在lily_initial函數裏邊,要創建這幾個線程:
//創建接收線程,啓動_thread_routine_recv thread_id=CreateThread(NULL,0,(LPTHREAD_START_ROUTINE)_thread_routine_recv, NULL, 0, NULL); //創建發送線程,啓動_thread_routine_send thread_id2=CreateThread(NULL,0,(LPTHREAD_START_ROUTINE)_thread_routine_recv, NULL, 0, NULL); //創建從處理線程 for(i=0; i<numofthrs-1; i++) { thrid=CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)lily_run_ready_task, (void*)i, 0, NULL); } |
在創建完線程後,主線程和從線程都要求取得本線程的tid以及handle。線程的id用來確定該線程對應的taskpool是哪一個,線程的handle用來引用其自身,在wait的過程中會用到。在pthread的實現中, tid和handle是一體的,由pthread_self調用就可以得到;而對於Win32thread來說,要稍微的麻煩一點,tid和handle是不同的,所以要分別保存線程的id和handle。得到線程的id用GetCurrentThreadId,而與此對應的有GetCurrentThread,是不是用該函數就能得到線程的handle呢?答案不一定的。GetCurrentThread返回的是一個pseudo-handle,是一個僞句柄,只能由GetCurrentThread的調用者引用,即thread本身才能引用這個pseudo-handle,而我們的要求是其他的線程也可以引用這個handle,所以GetCurrentThread並不能滿足我們的要求。但是,我們可以通過調用DuplicateHandle來複制一個真實的句柄,DuplicateHandle可以把一個進程中的一個handle(可以是pseudo-handle)複製給另一個進程的一個handle,得到是一個真實的其他線程也可以引用的handle:
//保存線程的id taskpool[numofthrs-1].pthid2=GetCurrentThreadId(); //保存線程的handle DuplicateHandle(GetCurrentProcess(),GetCurrentThread(),GetCurrentProcess(), &taskpool[numofthrs-1].pthid, 0, FALSE, DUPLICATE_SAME_ ACCESS); |
2.線程的同步問題。
Lilytask基於任務並行來實現,並且定義了任務的序關係,這就使得任務間必然存在等待的情形,所以在Lilytask中,線程的同步問題非常的重要。在Lilytask中,互斥量被大量使用,一方面是大量臨界資源的存在,另一方面是配合條件量的使用,上文已經指出,Win32 thread庫中並沒有實現條件量,與此對應的是Event Object,但是在Lilytask2.5βforWindows的實現中,我們並沒有用Event Object,一是因爲利用Event Object的實現相對麻煩一些,而信號量則相對簡單易懂;另一原因是在Lilytask中用到的條件量,在大多數時候可以看作是一個最大值不超過1的信號量,基於上面的兩個原因,Lilytask for Windows的主要的同步機制是利用互斥量和信號量來實現的。在Win32 thread中提供一個API:SignalObjectAndWait,可以用這個函數來模擬條件量的wait。條件量的wait操作在上文中的圖示已經畫出來了,其關鍵是開始時要lock臨界區,然後判斷條件,如果條件不成立,則unlock和sleep,其中unlock和sleep必須是原子操作的,正好SignalObjectAndWait也具有這個特性,所以用來模擬條件量非常的方便。
//互斥量作爲臨界區的鎖來使用 WaitForSingleObject(taskpool[i].mutex_readylist_access, INFINITE); taskpool[i].isSignal=TRUE; …… //臨界區操作 ReleaseMutex(taskpool[i].mutex_readylist_access);
//互斥量與信號量配合使用,實現的條件量的Wait操作 WaitForSingleObject(taskpool[i].mutex_readylist_access, INFINITE); …… //臨界區操作 SignalObjectAndWait(taskpool[i].mutex_readylist_access, taskpool[i].cond_readylist_access, INFINITE, FALSE); WaitForSingleObject(taskpool[i].mutex_readylist_access, INFINITE); …… //臨界區操作 ReleaseMutex(taskpool[i].mutex_readylist_access); |
信號量的釋放操作,相對而言就比較簡單,與pthread下的實現並無二樣。
除此之外,線程的同步還涉及到lily_finalize時要等待所有從線程的結束,雖然我們說過完全可以用信號量計數的方法取代wati(join)線程的方法,但就Lilytask這個實例來講,用wait線程的方法更簡單明瞭。
//等待從線程的結束 for(i=0; i<numofthrs-1; i++) { WaitForSingleObject(taskpool[i].pthid, INFINITE); …… } |
另外還有一些關於線程的同步操作,比如pthread中trylock, timedlock等等,在上文的討論中已經詳細的說明了在Win32thread環境中的解決方法,就不一一贅述了。
五.總結。
總的說來,這二者在實現上是不一樣的,但在提供給用戶的接口上,基本上是一樣的(當然,你可以說API的名字是不一樣的,但我們探討僅僅是API的實質即它提供給用戶的功能接口)。對於Lilytask,之所以要做for Windows的版本,是基於系統的異構性的原因,Lilytask可以向上爲用戶屏蔽掉系統異構的差異,提供給用戶一個不透明的編程模式,用戶只需用Lilytask的原語寫並行程序,不需要考慮系統的異構性,即用戶寫得程序無需做任何改動就可以在不同的系統上運行,而這些解決異構這些繁瑣的問題這是由Lilytask的預編譯器調用不同的庫來實現的,大大的減輕了用戶的負擔。
參考資料:
[1]BilLiews & Daniel J. Berg, Pthreads Primer——AGuide to MultiThreaded Programming.