linux線程,進程經典文章

來源:互聯網
上載者:User

 一.基礎知識:線程和進程 

 按照教科書上的定義,進程是資源管理的最小單位,線程是程式執行的最小單位。在作業系統設計上,從進程演化出線程,最主要的目的就是更好的支援SMP以及減小(進程/線程)環境切換開銷。 

 無論按照怎樣的分法,一個進程至少需要一個線程作為它的指令執行體,進程管理著資源(比如cpu、記憶體、檔案等等),而將線程分配到某個cpu上執行。一個進程當然可以擁有多個線程,此時,如果進程運行在SMP機器上,它就可以同時使用多個cpu來執行各個線程,達到最大程度的並行,以提高效率;同時,即使是在單cpu的機器上,採用多執行緒模式來設計程式,正如當年採用多進程模型代替單進程模型一樣,使設計更簡潔、功能更完備,程式的執行效率也更高,例如採用多個線程響應多個輸入,而此時多執行緒模式所實現的功能實際上也可以用多進程模型來實現,而與後者相比,線程的環境切換開銷就比進程要小多了,從語義上來說,同時響應多個輸入這樣的功能,實際上就是共用了除cpu以外的所有資源的。 

 針對執行緒模式的兩大意義,分別開發出了核心級線程和使用者級線程兩種執行緒模式,分類的標準主要是線程的調度者在核內還是在核外。前者更利於並發使用多處理器的資源,而後者則更多考慮的是環境切換開銷。在目前的商用系統中,通常都將兩者結合起來使用,既提供核心線程以滿足smp系統的需要,也支援用線程庫的方式在使用者態實現另一套線程機制,此時一個核心線程同時成為多個使用者態線程的調度者。正如很多技術一樣,"混合"通常都能帶來更高的效率,但同時也帶來更大的實現難度,出於"簡單"的設計思路,Linux從一開始就沒有實現混合模型的計劃,但它在實現上採用了另一種思路的"混合"。 

 線上程機制的具體實現上,可以在作業系統核心上實現線程,也可以在核外實現,後者顯然要求核內至少實現了進程,而前者則一般要求在核內同時也支援進程。核心級執行緒模式顯然要求前者的支援,而使用者級執行緒模式則不一定基於後者實現。這種差異,正如前所述,是兩種分類方式的標準不同帶來的。 

 當核內既支援進程也支援線程時,就可以實現線程-進程的"多對多"模型,即一個進程的某個線程由核內調度,而同時它也可以作為使用者級線程池的調度者,選擇合適的使用者級線程在其空間中運行。這就是前面提到的"混合"執行緒模式,既可滿足多處理機系統的需要,也可以最大限度的減小調度開銷。絕大多數商業作業系統(如Digital Unix、Solaris、Irix)都採用的這種能夠完全實現POSIX1003.1c標準的執行緒模式。在核外實現的線程又可以分為"一對一"、"多對一"兩種模型,前者用一個核心進程(也許是輕量進程)對應一個線程,將線程調度等同於進程調度,交給核心完成,而後者則完全在核外實現多線程,調度也在使用者態完成。後者就是前面提到的單純的使用者級執行緒模式的實現方式,顯然,這種核外的線程調度器實際上只需要完成線程運行棧的切換,調度開銷非常小,但同時因為核心訊號(無論是同步的還是非同步)都是以進程為單位的,因而無法定位到線程,所以這種實現方式不能用於多處理器系統,而這個需求正變得越來越大,因此,在現實中,純使用者級線程的實現,除演算法研究目的以外,幾乎已經消失了。 

 Linux核心只提供了輕量進程的支援,限制了更高效的執行緒模式的實現,但Linux著重最佳化了進程的調度開銷,一定程度上也彌補了這一缺陷。目前最流行的線程機制LinuxThreads所採用的就是線程-進程"一對一"模型,調度交給核心,而在使用者級實現一個包括訊號處理在內的線程管理機制。Linux-LinuxThreads的運行機制正是本文的描述重點。 

 二.Linux 2.4核心中的輕量進程實現 

 最初的進程定義都包含程式、資源及其執行三部分,其中程式通常指代碼,資源在作業系統層面上通常包括記憶體資源、IO資源、訊號處理等部分,而程式的執行通常理解為執行內容,包括對cpu的佔用,後來發展為線程。線上程概念出現以前,為了減小進程切換的開銷,作業系統設計者逐漸修正進程的概念,逐漸允許將進程所佔有的資源從其主體剝離出來,允許某些進程共用一部分資源,例如檔案、訊號,資料記憶體,甚至代碼,這就發展出輕量進程的概念。Linux核心在2.0.x版本就已經實現了輕量進程,應用程式可以通過一個統一的clone()系統調用介面,用不同的參數指定建立輕量進程還是普通進程。在核心中,clone()調用經過參數傳遞和解釋後會調用do_fork(),這個核內函數同時也是fork()、vfork()系統調用的最終實現: 

 
 <linux-2.4.20/kernel/fork.c> 
 int do_fork(unsigned long clone_flags, unsigned long stack_start,  
 struct pt_regs *regs, unsigned long stack_size) 

 其中的clone_flags取自以下宏的"或"值: 

 
 <linux-2.4.20/include/linux/sched.h> 
 #define CSIGNAL 0x000000ff /* signal mask to be sent at exit */ 
 #define CLONE_VM 0x00000100 /* set if VM shared between processes */ 
 #define CLONE_FS        0x00000200 /* set if fs info shared between processes */ 
 #define CLONE_FILES     0x00000400 /* set if open files shared between processes */ 
 #define CLONE_SIGHAND 0x00000800 /* set if signal handlers and blocked signals shared */ 
 #define CLONE_PID 0x00001000 /* set if pid shared */ 
 #define CLONE_PTRACE 0x00002000 /* set if we want to let tracing continue on the child too */ 
 #define CLONE_VFORK 0x00004000 /* set if the parent wants the child to wake it up on mm_release */ 
 #define CLONE_PARENT 0x00008000 /* set if we want to have the same parent as the cloner */ 
 #define CLONE_THREAD 0x00010000 /* Same thread group? */ 
 #define CLONE_NEWNS 0x00020000 /* New namespace group? */ 
 #define CLONE_SIGNAL  (CLONE_SIGHAND | CLONE_THREAD) 

 在do_fork()中,不同的clone_flags將導致不同的行為,對於LinuxThreads,它使用(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND)參數來調用clone()建立"線程",表示共用記憶體、共用檔案系統訪問計數、共用檔案描述符表,以及共用訊號處理方式。本節就針對這幾個參數,看看Linux核心是如何?這些資源的共用的。 

 1.CLONE_VM 

 do_fork()需要調用copy_mm()來設定task_struct中的mm和active_mm項,這兩個mm_struct資料與進程所關聯的記憶體空間相對應。如果do_fork()時指定了CLONE_VM開關,copy_mm()將把新的task_struct中的mm和active_mm設定成與current的相同,同時提高該mm_struct的使用者數目(mm_struct::mm_users)。也就是說,輕量級進程與父進程共用記憶體位址空間,由示意可以看出mm_struct在進程中的地位: 

 
 2.CLONE_FS 

 task_struct中利用fs(struct fs_struct *)記錄了進程所在檔案系統的根目錄和目前的目錄資訊,do_fork()時調用copy_fs()複製了這個結構;而對於輕量級進程則僅增加fs->count計數,與父進程共用相同的fs_struct。也就是說,輕量級進程沒有獨立的檔案系統相關的資訊,進程中任何一個線程改變目前的目錄、根目錄等資訊都將直接影響到其他線程。 

 3.CLONE_FILES 

 一個進程可能開啟了一些檔案,在進程結構task_struct中利用files(struct files_struct *)來儲存進程開啟的檔案結構(struct file)資訊,do_fork()中調用了copy_files()來處理這個進程屬性;輕量級進程與父進程是共用該結構的,copy_files()時僅增加files->count計數。這一共用使得任何線程都能訪問進程所維護的開啟檔案,對它們的操作會直接反映到進程中的其他線程。 

 4.CLONE_SIGHAND 

 每一個Linux進程都可以自行定義對訊號的處理方式,在task_struct中的sig(struct signal_struct)中使用一個struct k_sigaction結構的數組來儲存這個配置資訊,do_fork()中的copy_sighand()負責複製該資訊;輕量級進程不進行複製,而僅僅增加signal_struct::count計數,與父進程共用該結構。也就是說,子進程與父進程的訊號處理方式完全相同,而且可以相互更改。 

 do_fork()中所做的工作很多,在此不詳細描述。對於SMP系統,所有的進程fork出來後,都被分配到與父進程相同的cpu上,一直到該進程被調度時才會進行cpu選擇。 

 儘管Linux支援輕量級進程,但並不能說它就支援核心級線程,因為Linux的"線程"和"進程"實際上處於一個調度層次,共用一個進程標識符空間,這種限制使得不可能在Linux上實現完全意義上的POSIX線程機制,因此眾多的Linux線程庫實現嘗試都只能儘可能實現POSIX的絕大部分語義,並在功能上儘可能逼近。 

 三.LinuxThread的線程機制 

 LinuxThreads是目前Linux平台上使用最為廣泛的線程庫,由Xavier Leroy (Xavier.Leroy@inria.fr)負責開發完成,並已綁定在GLIBC中發行。它所實現的就是基於核心輕量級進程的"一對一"執行緒模式,一個線程實體對應一個核心輕量級進程,而線程之間的管理在核外函數庫中實現。 

 1.線程描述資料結構及實現限制 

 LinuxThreads定義了一個struct _pthread_descr_struct資料結構來描述線程,並使用全域陣列變數__pthread_handles來描述和引用進程所轄線程。在__pthread_handles中的前兩項,LinuxThreads定義了兩個全域的系統線程:__pthread_initial_thread和__pthread_manager_thread,並用__pthread_main_thread表徵__pthread_manager_thread的父線程(初始為__pthread_initial_thread)。 

 struct _pthread_descr_struct是一個雙環鏈表結構,__pthread_manager_thread所在的鏈表僅包括它一個元素,實際上,__pthread_manager_thread是一個特殊線程,LinuxThreads僅使用了其中的errno、p_pid、p_priority等三個域。而__pthread_main_thread所在的鏈則將進程中所有使用者線程串在了一起。經過一系列pthread_create()之後形成的__pthread_handles數組將如所示: 

 圖2 __pthread_handles數組結構 

 

 新建立的線程將首先在__pthread_handles數組中佔據一項,然後通過資料結構中的鏈指標連入以__pthread_main_thread為首指標的鏈表中。這個鏈表的使用在介紹線程的建立和釋放的時候將提到。 

 LinuxThreads遵循POSIX1003.1c標準,其中對線程庫的實現進行了一些範圍限制,比如進程最大線程數,線程私人資料區大小等等。在LinuxThreads的實現中,基本遵循這些限制,但也進行了一定的改動,改動的趨勢是放鬆或者說擴大這些限制,使編程更加方便。這些限定宏主要集中在sysdeps/unix/sysv/linux/bits/local_lim.h(不同平台使用的檔案位置不同)中,包括如下幾個: 

 每進程的私人資料key數,POSIX定義_POSIX_THREAD_KEYS_MAX為128,LinuxThreads使用PTHREAD_KEYS_MAX,1024;私人資料釋放時允許執行的運算元,LinuxThreads與POSIX一致,定義PTHREAD_DESTRUCTOR_ITERATIONS為4;每進程的線程數,POSIX定義為64,LinuxThreads增大到1024(PTHREAD_THREADS_MAX);線程運行棧最小空間大小,POSIX未指定,LinuxThreads使用PTHREAD_STACK_MIN,16384(位元組)。 

 2.管理線程 

 "一對一"模型的好處之一是線程的調度由核心完成了,而其他諸如線程取消、線程間的同步等工作,都是在核外線程庫中完成的。在LinuxThreads中,專門為每一個進程構造了一個管理線程,負責處理線程相關的管理工作。當進程第一次調用pthread_create()建立一個線程的時候就會建立(__clone())並啟動管理線程。 

 在一個進程空間內,管理線程與其他線程之間通過一對"管理管道(manager_pipe[2])"來通訊,該管道在建立管理線程之前建立,在成功啟動了管理線程之後,管理管道的讀端和寫端分別賦給兩個全域變數__pthread_manager_reader和__pthread_manager_request,之後,每個使用者線程都通過__pthread_manager_request向管理線程發請求,但管理線程本身並沒有直接使用__pthread_manager_reader,管道的讀端(manager_pipe[0])是作為__clone()的參數之一傳給管理線程的,管理線程的工作主要就是監聽管道讀端,並對從中取出的請求作出反應。 

 建立管理線程的流程如下所示: 
 (全域變數pthread_manager_request初值為-1) 

 圖3 建立管理線程的流程 

 初始化結束後,在__pthread_manager_thread中記錄了輕量級進程號以及核外分配和管理的線程id,2*PTHREAD_THREADS_MAX+1這個數值不會與任何常規使用者線程id衝突。管理線程作為pthread_create()的調用者線程的子線程運行,而pthread_create()所建立的那個使用者線程則是由管理線程來調用clone()建立,因此實際上是管理線程的子線程。(此處子線程的概念應該當作子進程來理解。) 

 __pthread_manager()就是管理線程的主迴圈所在,在進行一系列初始化工作後,進入while(1)迴圈。在迴圈中,線程以2秒為timeout查詢(__poll())管理管道的讀端。在處理請求前,檢查其父線程(也就是建立manager的主線程)是否已退出,如果已退出就退出整個進程。如果有退出的子線程需要清理,則調用pthread_reap_children()清理。 

 然後才是讀取管道中的請求,根據請求類型執行相應操作(switch-case)。具體的請求處理,源碼中比較清楚,這裡就不贅述了。 

 3.線程棧 

 在LinuxThreads中,管理線程的棧和使用者線程的棧是分離的,管理線程在進程堆中通過malloc()分配一個THREAD_MANAGER_STACK_SIZE位元組的地區作為自己的運行棧。 

 使用者線程的棧分配辦法隨著體繫結構的不同而不同,主要根據兩個宏定義來區分,一個是NEED_SEPARATE_REGISTER_STACK,這個屬性僅在IA64平台上使用;另一個是FLOATING_STACK宏,在i386等少數平台上使用,此時使用者線程棧由系統決定具體位置並提供保護。與此同時,使用者還可以通過線程屬性結構來指定使用使用者自訂的棧。因篇幅所限,這裡只能分析i386平台所使用的兩種棧組織方式:FLOATING_STACK方式和使用者自訂方式。 

 在FLOATING_STACK方式下,LinuxThreads利用mmap()從核心空間中分配8MB空間(i386系統預設的最大棧空間大小,如果有運行限制(rlimit),則按照運行限制設定),使用mprotect()設定其中第一頁為非訪問區。該8M空間的功能分配如: 

 圖4 棧結構示意 

 低地址被保護的頁面用來監測棧溢出。 

 對於使用者指定的棧,在按照指標對界後,設定線程棧頂,並計算出棧底,不做保護,正確性由使用者自己保證。 

 不論哪種組織方式,線程描述結構總是位於棧頂緊鄰堆棧的位置。 

 4.線程id和進程id 

 每個LinuxThreads線程都同時具有線程id和進程id,其中進程id就是核心所維護的進程號,而線程id則由LinuxThreads分配和維護。 

 __pthread_initial_thread的線程id為PTHREAD_THREADS_MAX,__pthread_manager_thread的是2*PTHREAD_THREADS_MAX+1,第一個使用者線程的線程id為PTHREAD_THREADS_MAX+2,此後第n個使用者線程的線程id遵循以下公式: 

 
 tid=n*PTHREAD_THREADS_MAX+n+1 

 
 這種分配方式保證了進程中所有的線程(包括已經退出)都不會有相同的線程id,而線程id的類型pthread_t定義為無符號長整型(unsigned long int),也保證了有理由的已耗用時間內線程id不會重複。 

 從線程id尋找線程資料結構是在pthread_handle()函數中完成的,實際上只是將線程號按PTHREAD_THREADS_MAX模數,得到的就是該線程在__pthread_handles中的索引。 

 5.線程的建立 

 在pthread_create()向管理線程發送REQ_CREATE請求之後,管理線程即調用pthread_handle_create()建立新線程。分配棧、設定thread屬性後,以pthread_start_thread()為函數入口調用__clone()建立並啟動新線程。pthread_start_thread()讀取自身的進程id號存入線程描述結構中,並根據其中記錄的調度方法配置調度。一切準備就緒後,再調用真正的線程執行函數,並在此函數返回後調用pthread_exit()清理現場。 

 6.LinuxThreads的不足 

 由於Linux核心的限制以及實現難度等等原因,LinuxThreads並不是完全POSIX相容的,在它的發行README中有說明。 

 1)進程id問題 

 這個不足是最關鍵的不足,引起的原因牽涉到LinuxThreads的"一對一"模型。 

 Linux核心並不支援真正意義上的線程,LinuxThreads是用與普通進程具有同樣核心調度視圖的輕量級進程來實現線程支援的。這些輕量級進程擁有獨立的進程id,在進程調度、訊號處理、IO等方面享有與普通進程一樣的能力。在源碼閱讀者看來,就是Linux核心的clone()沒有實現對CLONE_PID參數的支援。 

 在核心do_fork()中對CLONE_PID的處理是這樣的: 

 
           if (clone_flags & CLONE_PID) { 
                 if (current->pid) 
                         goto fork_out; 
         } 
          

 這段代碼錶明,目前的Linux核心僅在pid為0的時候認可CLONE_PID參數,實際上,僅在SMP初始化,手工建立進程的時候才會使用CLONE_PID參數。 

 按照POSIX定義,同一進程的所有線程應該共用一個進程id和父進程id,這在目前的"一對一"模型下是無法實現的。 

 2)訊號處理問題 

 由於非同步訊號是核心以進程為單位分發的,而LinuxThreads的每個線程對核心來說都是一個進程,且沒有實現"線程組",因此,某些語義不符合POSIX標準,比如沒有實現向進程中所有線程發送訊號,README對此作了說明。 

 如果核心不提供即時訊號,LinuxThreads將使用SIGUSR1和SIGUSR2作為內部使用的restart和cancel訊號,這樣應用程式就不能使用這兩個原本為使用者保留的訊號了。在Linux kernel 2.1.60以後的版本都支援擴充的即時訊號(從_SIGRTMIN到_SIGRTMAX),因此不存在這個問題。 

 某些訊號的預設動作難以在現行體繫上實現,比如SIGSTOP和SIGCONT,LinuxThreads只能將一個線程掛起,而無法掛起整個進程。 

 3)線程總數問題 

 LinuxThreads將每個進程的線程最大數目定義為1024,但實際上這個數值還受到整個系統的總進程數限制,這又是由於線程其實是核心進程。 

 在kernel 2.4.x中,採用一套全新的總進程數計算方法,使得總進程數基本上僅受限於實體記憶體的大小,計算公式在kernel/fork.c的fork_init()函數中: 

 
 max_threads = mempages / (THREAD_SIZE/PAGE_SIZE) / 8 

 
 在i386上,THREAD_SIZE=2*PAGE_SIZE,PAGE_SIZE=2^12(4KB),mempages=實體記憶體大小/PAGE_SIZE,對於256M的記憶體的機器,mempages=256*2^20/2^12=256*2^8,此時最大線程數為4096。 

 但為了保證每個使用者(除了root)的進程總數不至於佔用一半以上實體記憶體,fork_init()中繼續指定: 

 
     init_task.rlim[RLIMIT_NPROC].rlim_cur = max_threads/2; 
     init_task.rlim[RLIMIT_NPROC].rlim_max = max_threads/2; 
      

 這些進程數目的檢查都在do_fork()中進行,因此,對於LinuxThreads來說,線程總數同時受這三個因素的限制。 

 4)管理線程問題 

 管理線程容易成為瓶頸,這是這種結構的通病;同時,管理線程又負責使用者線程的清理工作,因此,儘管管理線程已經屏蔽了大部分的訊號,但一旦管理線程死亡,使用者線程就不得不手工清理了,而且使用者線程並不知道管理線程的狀態,之後的線程建立等請求將無人處理。 

 5)同步問題 

 LinuxThreads中的線程同步很大程度上是建立在訊號基礎上的,這種通過核心複雜的訊號處理機制的同步方式,效率一直是個問題。 

 6)其他POSIX相容性問題 

 Linux中很多系統調用,按照語義都是與進程相關的,比如nice、setuid、setrlimit等,在目前的LinuxThreads中,這些調用都僅僅影響調用者線程。 

 7)即時性問題 

 線程的引入有一定的即時性考慮,但LinuxThreads暫時不支援,比如調度選項,目前還沒有實現。不僅LinuxThreads如此,標準的Linux在即時性上考慮都很少。

相關文章

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.