- Linux內(nèi)核分析及應(yīng)用
- 陳科
- 271字
- 2019-01-05 06:07:59
第1章 進(jìn)程與線程
只要是計(jì)算機(jī)科班出身的技術(shù)人員,肯定都學(xué)過現(xiàn)代操作系統(tǒng)課程。一般在操作系統(tǒng)的書中都會有這樣的定義:
簡單來說,進(jìn)程就是在操作系統(tǒng)中運(yùn)行的程序,是操作系統(tǒng)資源管理的最小單位。一個進(jìn)程可以管理多個線程,線程相對輕量,可以共享進(jìn)程地址空間。
我在很多次面試的時(shí)候,向求職者提問過進(jìn)程和線程在Linux中到底有什么區(qū)別,不只是科班出身的應(yīng)屆生,連工作多年的老手,也有很多回答不準(zhǔn)確。傳統(tǒng)的教育缺乏實(shí)踐環(huán)節(jié),而計(jì)算機(jī)恰恰是一個實(shí)踐性很強(qiáng)的學(xué)科,假如只是知道一個概念,卻不知道它具體在代碼中的表現(xiàn)形式以及背后的實(shí)現(xiàn)原理,那么知道與不知道這個概念又有何分別呢?
那么,線程和進(jìn)程到底有什么區(qū)別呢?既然進(jìn)程可以管理線程,是否說明進(jìn)程就特別牛呢?另外,搞出這些概念到底要解決什么問題,是否還具有副作用呢?本章將對這些問題一一解答。
1.1 進(jìn)程和線程的概念
我覺得不管做什么工作,都需要搞明白所面臨工作的過去、現(xiàn)在和未來。我認(rèn)為不懂歷史的程序員肯定寫不出好代碼。因?yàn)椴恢肋@個技術(shù)被創(chuàng)造出來到底意味著什么,也無法理解未來這個技術(shù)要向哪里發(fā)展,僅僅是解決當(dāng)下的問題,修修補(bǔ)補(bǔ),做一天和尚撞一天鐘,僅此而已。下面我們就介紹進(jìn)程的歷史。
1.1.1 進(jìn)程的歷史
計(jì)算機(jī)發(fā)明出來是做邏輯運(yùn)算的,但是當(dāng)初計(jì)算機(jī)都是大型機(jī),造價(jià)昂貴,只有有錢的政府機(jī)構(gòu)、著名大學(xué)的數(shù)據(jù)中心才會有,一般人接觸不到。大家要想用,要去專門的機(jī)房。悲催的是,那時(shí)候代碼還是機(jī)器碼,直接穿孔把程序輸入到紙帶上面,然后再拿去機(jī)房排隊(duì)。那時(shí)候的計(jì)算機(jī)也沒什么進(jìn)程管理之類的概念,它只知道根據(jù)紙帶里的二進(jìn)制數(shù)據(jù)進(jìn)行邏輯運(yùn)算,一個人的紙帶輸入完了,就接著讀取下一個人的紙帶,要是程序有bug,不好意思,只有等到全部運(yùn)算結(jié)束之后才能得到結(jié)果,然后回家慢慢改。
為了改進(jìn)這種排隊(duì)等候的低效率問題,就有人發(fā)明了批處理系統(tǒng)。以前只能一個一個提交程序,現(xiàn)在好了,可以多人一起提交,計(jì)算機(jī)會集中處理,至于什么時(shí)候處理完,回家慢慢等吧。或者你可以多寫幾種可能,集中讓計(jì)算機(jī)處理,總有一個結(jié)果是好的。
懶人總會推動科技進(jìn)步,為了提升效率,機(jī)器碼就被匯編語言替代了,從而再也不用一串串二進(jìn)制數(shù)字來寫代碼了。便于記憶的英文指令會極大提升效率。然后,進(jìn)程管理這樣的概念也被提出來了,為什么要提呢?因?yàn)楫?dāng)程序在運(yùn)算的時(shí)候,不能一直占用著CPU資源,有可能此時(shí)還會進(jìn)行寫磁盤數(shù)據(jù)、讀取網(wǎng)絡(luò)設(shè)備數(shù)據(jù)等,這時(shí)候完全可以把CPU的計(jì)算資源讓給其他進(jìn)程,直到數(shù)據(jù)讀寫準(zhǔn)備就緒后再切換回來。所以,進(jìn)程管理的出現(xiàn)也標(biāo)志著現(xiàn)代操作系統(tǒng)的進(jìn)步。那么既然進(jìn)程是運(yùn)行中的程序,那么,到底什么是程序呢?運(yùn)行和不運(yùn)行又有什么區(qū)別呢?
先說程序,既然程序是人寫的,那么最終肯定會生成可執(zhí)行文件,保存在磁盤里,而且這個文件可能會很大,有時(shí)候不一定是一個文件,可能會有多個文件,甚至文件夾,其包含圖片、音頻等各種數(shù)據(jù)。然而,CPU做邏輯運(yùn)算的每條指令是從內(nèi)存中讀取的,所以運(yùn)行中的程序可以理解為內(nèi)存中的代碼指令和運(yùn)行相關(guān)的數(shù)據(jù)被CPU讀寫并計(jì)算的過程。我們都知道內(nèi)存的大小是有限的,所以很可能裝不下磁盤中的整個程序。因此內(nèi)存中運(yùn)行的是當(dāng)下需要運(yùn)行的部分程序數(shù)據(jù),等運(yùn)算完就會繼續(xù)讀取后面一部分磁盤數(shù)據(jù)到內(nèi)存,并繼續(xù)進(jìn)行運(yùn)算。
一個進(jìn)程在運(yùn)行的過程中,不可能一直占據(jù)著CPU進(jìn)行邏輯運(yùn)算,中間很可能在進(jìn)行磁盤I/O或者網(wǎng)絡(luò)I/O,為了充分利用CPU運(yùn)算資源,有人設(shè)計(jì)了線程的概念。我認(rèn)為線程最大的特點(diǎn)就是和創(chuàng)建它的進(jìn)程共享地址空間(關(guān)于地址空間的概念大家可以在第3章了解更多)。這時(shí)候有人就會認(rèn)為,要提升CPU的利用率,開多個進(jìn)程也可以達(dá)到,但是開多個進(jìn)程的話,進(jìn)程間通信又是個麻煩的事情,畢竟進(jìn)程之間地址空間是獨(dú)立的,沒法像線程那樣做到數(shù)據(jù)的共享,需要通過其他的手段來解決,比如管道等。圖1-1描述了進(jìn)程和線程的區(qū)別。

圖1-1 進(jìn)程和線程的區(qū)別
1.1.2 線程的不同玩法
針對線程現(xiàn)在又有很多玩法,有內(nèi)核線程、用戶級線程,還有協(xié)程。下面簡單介紹這些概念。
一般操作系統(tǒng)都會分為內(nèi)核態(tài)和用戶態(tài),用戶態(tài)線程之間的地址空間是隔離的,而在內(nèi)核態(tài),所有線程都共享同一內(nèi)核地址空間。有時(shí)候,需要在內(nèi)核態(tài)用多個線程進(jìn)行一些計(jì)算工作,如異步回調(diào)場景的模型,就可以基于多個內(nèi)核線程進(jìn)行模擬,比如AIO機(jī)制,假如硬件不提供某種中斷機(jī)制的話,那么就只能通過線程自己去后臺模擬了,圖1-2說明了有中斷機(jī)制的寫磁盤后回調(diào)和沒有中斷機(jī)制的寫磁盤后線程模擬異步回調(diào)。

圖1-2 兩種異步回調(diào)場景
在用戶態(tài),大多數(shù)場景下業(yè)務(wù)邏輯不需要一直占用CPU資源,這時(shí)候就有了用戶線程的用武之地。
不管是用戶線程還是內(nèi)核線程,都和進(jìn)程一樣,均由操作系統(tǒng)的調(diào)度器來統(tǒng)一調(diào)度(至少在Linux中是這樣子)。所以假如開辟太多線程,系統(tǒng)調(diào)度的開銷會很大,另外,線程本身的數(shù)據(jù)結(jié)構(gòu)需要占用內(nèi)存,頻繁創(chuàng)建和銷毀線程會加大系統(tǒng)的壓力。線程池就是在這樣的場景下提出的,圖1-3說明了常見的線程池實(shí)現(xiàn)方案,線程池可以在初始化的時(shí)候批量創(chuàng)建線程,然后用戶后續(xù)通過隊(duì)列等方式提交業(yè)務(wù)邏輯,線程池中的線程進(jìn)行邏輯的消費(fèi)工作,這樣就可以在操作的過程中降低線程創(chuàng)建和銷毀的開銷,但是調(diào)度的開銷還是存在的。

圖1-3 線程池實(shí)現(xiàn)原理
在多核場景下,如果是I/O密集型場景,就算開多個線程來處理,也未必能提升CPU的利用率,反而會增加線程切換的開銷。另外,多線程之間假如存在臨界區(qū)或者共享數(shù)據(jù),那么同步的開銷也是不可忽視的。協(xié)程恰恰就是用來解決該問題的。協(xié)程是輕量級線程,在一個用戶線程上可以跑多個協(xié)程,這樣就可以提升單核的利用率。在實(shí)際場景下,假如CPU有N個核,就只要開N+1個線程,然后在這些線程上面跑協(xié)程就行了。但是,協(xié)程不像進(jìn)程或者線程,可以讓系統(tǒng)負(fù)責(zé)相關(guān)的調(diào)度工作,協(xié)程是處于一個線程當(dāng)中的,系統(tǒng)是無感知的,所以需要在該線程中阻塞某個協(xié)程的話,就需要手工進(jìn)行調(diào)度。假如需要設(shè)計(jì)一套通用的解決方案,那么就需要一番精心的設(shè)計(jì)。圖1-4是一種簡單的用戶線程上的協(xié)程解決方案。

圖1-4 協(xié)程的實(shí)現(xiàn)方案
要在用戶線程上實(shí)現(xiàn)協(xié)程是一件很難受的事情,原理類似于調(diào)度器根據(jù)條件的改變不停地調(diào)用各個協(xié)程的callback機(jī)制,但是前提是大家都在一個用戶線程下。要注意,一旦有一個協(xié)程阻塞,其他協(xié)程也都不能運(yùn)行了。因此要處理好協(xié)程。
下面我們來看一段PHP代碼,通過生產(chǎn)者-消費(fèi)者程序來模擬實(shí)現(xiàn)協(xié)程的例子:
import time def consumer() // 消費(fèi)者 r = '' while True: n = yield r // yield條件 if not n: return print('[CONSUMER] Consuming %s...' % n) time.sleep(1) r = '200 OK' def produce(c): // 生產(chǎn)者 c.next() n = 0 while n < 5: n = n + 1 print('[PRODUCER] Producing %s...' % n) r = c.send(n) print('[PRODUCER] Consumer return: %s' % r) c.close() if __name__=='__main__': c = consumer() produce(c)
執(zhí)行結(jié)果:
[PRODUCER] Producing 1... [CONSUMER] Consuming 1... [PRODUCER] Consumer return: 200 OK [PRODUCER] Producing 2... [CONSUMER] Consuming 2... [PRODUCER] Consumer return: 200 OK [PRODUCER] Producing 3... [CONSUMER] Consuming 3... [PRODUCER] Consumer return: 200 OK [PRODUCER] Producing 4... [CONSUMER] Consuming 4... [PRODUCER] Consumer return: 200 OK [PRODUCER] Producing 5... [CONSUMER] Consuming 5... [PRODUCER] Consumer return: 200 OK
以上代碼中,produce(生產(chǎn)者)會依次生產(chǎn)5份數(shù)據(jù)n,并且發(fā)送給consumer(消費(fèi)者),只有消費(fèi)者執(zhí)行完之后,生產(chǎn)者才會再次生產(chǎn)數(shù)據(jù)。可以把produce和cosumer理解為兩個協(xié)程,其中關(guān)鍵點(diǎn)是通過yield關(guān)鍵字來控制消費(fèi)者,命令yield r會暫停消費(fèi)者直到r被傳遞過來為止。
注意
關(guān)于yield關(guān)鍵字,可以參考PHP手冊:http://php.net/manual/zh/language.generators.syntax.php生成器函數(shù)的核心是yield關(guān)鍵字。它最簡單的調(diào)用形式看起來像一個return聲明,不同之處在于普通return會返回值并終止函數(shù)的執(zhí)行,而yield會返回一個值給循環(huán)調(diào)用此生成器的代碼,并且只是暫停執(zhí)行生成器函數(shù)。
最后我們進(jìn)行一下總結(jié),多進(jìn)程的出現(xiàn)是為了提升CPU的利用率,特別是I/O密集型運(yùn)算,不管是多核還是單核,開多個進(jìn)程必然能有效提升CPU的利用率。而多線程則可以共享同一進(jìn)程地址空間上的資源,為了降低線程創(chuàng)建和銷毀的開銷,又出現(xiàn)了線程池的概念,最后,為了提升用戶線程的最大利用效率,又提出了協(xié)程的概念。
1.2 Linux對進(jìn)程和線程的實(shí)現(xiàn)
通過上一節(jié)的介紹,大家應(yīng)該大致了解了進(jìn)程和線程在操作系統(tǒng)中的概念和玩法,那么對應(yīng)到具體的Linux系統(tǒng)中,是否就如上面描述的那樣呢?下面來分析Linux中對進(jìn)程和線程的實(shí)現(xiàn)。為了便于理解,首先通過圖1-5來簡單介紹Linux進(jìn)程相關(guān)的知識結(jié)構(gòu)。

圖1-5 Linux進(jìn)程相關(guān)的知識結(jié)構(gòu)
從圖中可以發(fā)現(xiàn),進(jìn)程和線程(包括內(nèi)核線程)的創(chuàng)建,都是通過系統(tǒng)調(diào)用來觸發(fā)的,而它們最終都會調(diào)用do_fork函數(shù),系統(tǒng)調(diào)用通過libc這樣的庫函數(shù)封裝后提供給應(yīng)用層調(diào)用,進(jìn)程創(chuàng)建后會產(chǎn)生一個task_struct結(jié)構(gòu),schedule函數(shù)會通過時(shí)鐘中斷來觸發(fā)調(diào)度。后面會進(jìn)行具體的分析。
1.2.1 Linux中的進(jìn)程實(shí)現(xiàn)
Linux進(jìn)程的創(chuàng)建是通過系統(tǒng)調(diào)用fork和vfork來實(shí)現(xiàn)的,參考內(nèi)核源碼/linux-4.5.2/kernel/fork.c:
fork: SYSCALL_DEFINE0(fork) { … return _do_fork(SIGCHLD, 0, 0, NULL, NULL, 0); … } vfork: SYSCALL_DEFINE0(vfork) { return _do_fork(CLONE_VFORK | CLONE_VM | SIGCHLD, 0, 0, NULL, NULL, 0); }
注意
fork和vfork最終都調(diào)用do_fork函數(shù),只是傳入的clone_flags參數(shù)不同而已,參見表1-1。
表1-1 clone_flags的參數(shù)及說明

因?yàn)檫M(jìn)程創(chuàng)建的核心就是do_fork函數(shù),所以來看一下它的相關(guān)參數(shù):
long _do_fork(unsigned long clone_flags, unsigned long stack_start, unsigned long stack_size, int __user *parent_tidptr, int __user *child_tidptr, unsigned long tls)
其中:
? clone_flags:創(chuàng)建子進(jìn)程相關(guān)的參數(shù),決定了父子進(jìn)程之間共享的資源種類。
? stack_start:進(jìn)程棧開始地址。
? stack_size:進(jìn)程棧空間大小。
? parent_tidptr:父進(jìn)程的pid。
? child_tidptr:子進(jìn)程的pid。
? tls:線程局部存儲空間的地址,tls指thread local Storage。
圖1-6為do_fork函數(shù)的整個執(zhí)行流程,在這個執(zhí)行過程當(dāng)中,比較關(guān)鍵的是調(diào)用copy_process函數(shù),成功后創(chuàng)建子進(jìn)程,然后在后面就可以獲取到pid。另外,我們在這里也發(fā)現(xiàn)了fork和vfork的一個區(qū)別,vfork場景下父進(jìn)程會先休眠,等喚醒子進(jìn)程后,再喚醒父進(jìn)程。大家可以想一想,這樣做的好處是什么呢?我個人認(rèn)為在vfork場景下,子進(jìn)程被創(chuàng)建出來時(shí),是和父進(jìn)程共享地址空間的(這個后面介紹copy_process步驟的時(shí)候可以進(jìn)行驗(yàn)證),并且它是只讀的,只有執(zhí)行exec創(chuàng)建新的內(nèi)存程序映象時(shí)才會拷貝父進(jìn)程的數(shù)據(jù)創(chuàng)建新的地址空間,假如這個時(shí)候父進(jìn)程還在運(yùn)行,就有可能產(chǎn)生臟數(shù)據(jù)或者發(fā)生死鎖。在還沒完全讓子進(jìn)程運(yùn)行起來的時(shí)候,讓其父進(jìn)程休息是個比較好的辦法。

圖1-6 do_fork函數(shù)執(zhí)行流程
現(xiàn)在已經(jīng)知道了創(chuàng)建子進(jìn)程的時(shí)候,copy_process這個步驟很重要,所以,我用圖1-7總結(jié)了其主要的執(zhí)行流程,這段代碼非常長,大家可以自己閱讀源碼,這里只撿重點(diǎn)的講。copy_process先一模一樣地拷貝一份父進(jìn)程的task_struct結(jié)構(gòu),并通過一些簡單的配置來初始化,設(shè)置好調(diào)度策略優(yōu)先級等參數(shù)之后,一系列的拷貝函數(shù)就會開始執(zhí)行,這些函數(shù)會根據(jù)clone_flags中的參數(shù)進(jìn)行相應(yīng)的工作。

圖1-7 copy_process執(zhí)行流程
主要參數(shù)說明如下:
1)copy_semundo(clone_flags, p);拷貝系統(tǒng)安全相關(guān)的數(shù)據(jù)給子進(jìn)程,如果clone_flags設(shè)置了CLONE_SYSVSEM,則復(fù)制父進(jìn)程的sysvsem.undo_list到子進(jìn)程;否則子進(jìn)程的tsk->sysvsem.undo_list為NULL。
2)copy_files(clone_flags, p);如果clone_flags設(shè)置了CLONE_FILES,則父子進(jìn)程共享相同的文件句柄;否則將父進(jìn)程文件句柄拷貝給子進(jìn)程。
3)copy_fs(clone_flags, p);如果clone_flags設(shè)置了CLONE_FS,則父子進(jìn)程共享相同的文件系統(tǒng)結(jié)構(gòu)體對象;否則調(diào)用copy_fs_struct拷貝一份新的fs_struct結(jié)構(gòu)體,但是指向的還是進(jìn)程0創(chuàng)建出來的fs,并且文件系統(tǒng)資源是共享的。
4)copy_sighand(clone_flags, p);如果clone_flags設(shè)置了CLONE_SIGHAND,則增加父進(jìn)程的sighand引用計(jì)數(shù);否則(創(chuàng)建的必定是子進(jìn)程)將父進(jìn)程的sighand_struct復(fù)制到子進(jìn)程中。
5)copy_signal(clone_flags, p);如果clone_flags設(shè)置了CLONE_THREAD(是線程),則增加父進(jìn)程的sighand引用計(jì)數(shù);否則(創(chuàng)建的必定是子進(jìn)程)將父進(jìn)程的sighand_struct復(fù)制到子進(jìn)程中。
6)copy_mm(clone_flags, p);如果clone_flags設(shè)置了CLONE_VM,則將子進(jìn)程的mm指針和active_mm指針都指向父進(jìn)程的mm指針?biāo)附Y(jié)構(gòu);否則將父進(jìn)程的mm_struct結(jié)構(gòu)復(fù)制到子進(jìn)程中,然后修改當(dāng)中屬于子進(jìn)程而有別于父進(jìn)程的信息(如頁目錄)。
7)copy_io(clone_flags, p);如果clone_flags設(shè)置了CLONE_IO,則子進(jìn)程的tsk->io_context為current->io_context;否則給子進(jìn)程創(chuàng)建一份新的io_context。
8)copy_thread_tls(clone_flags, stack_start, stack_size, p, tls);其中需要重點(diǎn)關(guān)注copy_mm和copy_thread_tls這兩個步驟,copy_mm進(jìn)行內(nèi)存地址空間的拷貝,copy_thread_tls進(jìn)行棧的分配。
1.寫時(shí)復(fù)制
copy_mm的主要工作就是進(jìn)行子進(jìn)程內(nèi)存地址空間的拷貝,在copy_mm函數(shù)中,假如clone_flags參數(shù)中包含CLONE_VM,則父子進(jìn)程共享同一地址空間;否則會為子進(jìn)程新創(chuàng)建一份地址空間,代碼如下:
if (clone_flags & CLONE_VM) { // vfork場景下,父子進(jìn)程共享虛擬地址空間 atomic_inc(&oldmm->mm_users); mm = oldmm; goto good_mm; } retval = -ENOMEM; mm = dup_mm(tsk); if (! mm) goto fail_nomem;
dup_mm函數(shù)雖然給進(jìn)程創(chuàng)建了一個新的內(nèi)存地址空間(關(guān)于進(jìn)程地址空間的概念會在第3章再進(jìn)行深入分析),但在復(fù)制過程中會通過copy_pte_range調(diào)用copy_one_pte函數(shù)進(jìn)行是否啟用寫時(shí)復(fù)制的處理,代碼如下:
if (is_cow_mapping(vm_flags)) { ptep_set_wrprotect(src_mm, addr, src_pte); pte = pte_wrprotect(pte); }
如果采用的是寫時(shí)復(fù)制(Copy On Write),若將父子頁均置為寫保護(hù),即會產(chǎn)生缺頁異常。缺頁異常最終會調(diào)用do_page_fault, do_page_fault進(jìn)而調(diào)用handle_mm_fault。一般所有的缺頁異常均會調(diào)用handle_mm_fault的核心代碼如下:
pud = pud_alloc(mm, pgd, address); if (! pud) return VM_FAULT_OOM; pmd = pmd_alloc(mm, pud, address); if (! pmd) return VM_FAULT_OOM; pte = pte_alloc_map(mm, pmd, address); if (! pte) return VM_FAULT_OOM;
handle_mm_fault最終會調(diào)用handle_pte_fault,其主要代碼如下:
if (flags & FAULT_FLAG_WRITE) { if (! pte_write(entry)) return do_wp_page(mm, vma, address, pte, pmd, ptl, entry); entry = pte_mkdirty(entry); }
即在缺頁異常中,如果遇到寫保護(hù),則會調(diào)用do_wp_page,這里面會處理上面所說的寫時(shí)復(fù)制中父子進(jìn)程區(qū)分的問題。
最后通過圖1-8來說明fork和vfork在地址空間分配上的區(qū)別。

圖1-8 fork和vfork的區(qū)別
2.進(jìn)程棧的分配
copy_process中另一個比較重要的函數(shù)就是copy_thread_tls,在創(chuàng)建子進(jìn)程的過程中,進(jìn)程的內(nèi)核棧空間是隨進(jìn)程同時(shí)分配的,結(jié)構(gòu)如圖1-9所示。代碼如下:

圖1-9 進(jìn)程的內(nèi)核棧空間分配
struct pt_regs *childregs = task_pt_regs(p); p->thread.sp = (unsigned long) childregs; p->thread.sp0 = (unsigned long) (childregs+1);
其中,task_pt_regs(p)的代碼如下:
#define task_pt_regs(task) \ ({ \ unsigned long __ptr = (unsigned long)task_stack_page(task); \ __ptr += THREAD_SIZE - TOP_OF_KERNEL_STACK_PADDING; \ ((struct pt_regs *)__ptr) -1; \ })
childregs = task_pt_regs(p);實(shí)際上就是childregs = ((struct pt_regs *) (THREAD_SIZE + (unsigned long) p)) -1;,也就是說,childregs指向的地方是:子進(jìn)程的棧頂再減去一個sizeof(struct pt_regs)的大小。
1.2.2 進(jìn)程創(chuàng)建之后
通過上面的分析我們知道,不管是fork還是vfork,創(chuàng)建一個進(jìn)程最終都是通過do_fork函數(shù)來實(shí)現(xiàn)的。
在進(jìn)程剛剛創(chuàng)建完成之后,子進(jìn)程和父進(jìn)程執(zhí)行的代碼是相同的,并且子進(jìn)程從父進(jìn)程代碼的fork返回處開始執(zhí)行,這個代碼可以參考copy_thread_tls函數(shù)的實(shí)現(xiàn):
childregs->ax = 0; p->thread.ip = (unsigned long) ret_from_fork;
同時(shí)可以發(fā)現(xiàn),上面代碼返回的pid為0。
假如創(chuàng)建出來的子進(jìn)程只是和父進(jìn)程做一樣的事情,那能做的事情就很有限了,所以Linux另外提供了一個系統(tǒng)調(diào)用execve,該調(diào)用可以替換掉內(nèi)存當(dāng)中的現(xiàn)有程序,以達(dá)到執(zhí)行新邏輯的目的。execve的實(shí)現(xiàn)在/linux-4.5.2/fs/exec.c文件中,下面簡單來分析它的實(shí)現(xiàn),該系統(tǒng)調(diào)用聲明為:
SYSCALL_DEFINE3(execve, const char __user *, filename, const char __user *const __user *, argv, const char __user *const __user *, envp) { return do_execve(getname(filename), argv, envp); }
execve通過do_execve函數(shù)最終調(diào)用了do_execveat_common,下面是其流程的說明:
1)file = do_open_execat(fd, filename, flags);打開可執(zhí)行文件。
2)初始化用于在加載二進(jìn)制可執(zhí)行文件時(shí)存儲與其相關(guān)的所有信息的linux_binprm數(shù)據(jù)結(jié)構(gòu):bprm_mm_init(bprm);,其中會初始化一份新的mm_struct給該進(jìn)程使用。
3)prepare_binprm(bprm);從文件inode中獲取信息填充binprm結(jié)構(gòu),檢查權(quán)限,讀取最初的128個字節(jié)(BINPRM_BUF_SIZE)。
4)將運(yùn)行所需的參數(shù)和環(huán)境變量收集到bprm中:
retval = copy_strings_kernel(1, &bprm->filename, bprm); if (retval < 0) goto out; bprm->exec = bprm->p; retval = copy_strings(bprm->envc, envp, bprm); if (retval < 0) goto out; retval = copy_strings(bprm->argc, argv, bprm); if (retval < 0) goto out;
5)retval = exec_binprm(bprm);該過程調(diào)用search_binary_handler加載可執(zhí)行文件。
注意
Linux可執(zhí)行文件的裝載和運(yùn)行必須遵循ELF(Executable and Linkable Format)格式的規(guī)范,關(guān)于可運(yùn)行程序的裝載是個獨(dú)立的話題,這里不再進(jìn)行展開。大家有興趣可以閱讀《程序員的自我修養(yǎng):鏈接、裝載與庫》。
1.2.3 內(nèi)核線程和進(jìn)程的區(qū)別
前面我們介紹了內(nèi)核線程的概念,現(xiàn)在來分析Linux對內(nèi)核線程的實(shí)現(xiàn),在Linux中,創(chuàng)建內(nèi)核線程可以通過create_kthread來實(shí)現(xiàn),其代碼如下:
static void create_kthread(struct kthread_create_info *create) { int pid; ... pid = kernel_thread(kthread, create, CLONE_FS | CLONE_FILES | SIGCHLD); ... }
kernel_thread也會和fork一樣最終調(diào)用_do_fork函數(shù),所以該函數(shù)的實(shí)現(xiàn)在/linux-4.5.2/kernel/fork.c文件中:
pid_t kernel_thread(int (*fn)(void *), void *arg, unsigned long flags) { return _do_fork(flags|CLONE_VM|CLONE_UNTRACED, (unsigned long)fn, (unsigned long)arg, NULL, NULL, 0); }
通過這個函數(shù)可以創(chuàng)建內(nèi)核線程,運(yùn)行一個指定函數(shù)fn。
但是這個fn是如何運(yùn)行的呢?為什么do_fork函數(shù)的stack_start和stack_size參數(shù)變成了fn和arg呢?
繼續(xù)往下看,因?yàn)槲覀冎纃o_fork函數(shù)最終會調(diào)用copy_thread_tls。在內(nèi)核線程的情況下,代碼如下:
if (unlikely(p->flags & PF_KTHREAD)) {
// 內(nèi)核線程
memset(childregs, 0, sizeof(struct pt_regs));
p->thread.ip = (unsigned long) ret_from_kernel_thread;
task_user_gs(p) = __KERNEL_STACK_CANARY;
childregs->ds = __USER_DS;
childregs->es = __USER_DS;
childregs->fs = __KERNEL_PERCPU;
childregs->bx = sp; // 函數(shù)
childregs->bp = arg; // 傳參
childregs->orig_ax = -1;
childregs->cs = __KERNEL_CS | get_kernel_rpl();
childregs->flags = X86_EFLAGS_IF | X86_EFLAGS_FIXED;
p->thread.io_bitmap_ptr = NULL;
return 0;
}
這里把ip設(shè)置成了ret_from_kernel_thread,函數(shù)指針傳遞給了bx寄存器,參數(shù)傳遞給了bp寄存器。
然后繼續(xù)來看ret_from_kernel_thread做了些什么:
ENTRY(ret_from_kernel_thread) pushl %eax call schedule_tail GET_THREAD_INFO(%ebp) popl %eax pushl $0x0202 // 重置內(nèi)核eflags寄存器 popfl movl PT_EBP(%esp), %eax call *PT_EBX(%esp) // 這里就是調(diào)用fn的過程 movl $0, PT_EAX(%esp) … movl %esp, %eax call syscall_return_slowpath jmp restore_all ENDPROC(ret_from_kernel_thread)
通過對內(nèi)核線程的分析可以發(fā)現(xiàn),內(nèi)核線程的地址空間和父進(jìn)程是共享的(CLONE_VM),它也沒有自己的棧,和整個內(nèi)核共用同一個棧,另外,可以自己指定回調(diào)函數(shù),允許線程創(chuàng)建后執(zhí)行自己定義好的業(yè)務(wù)邏輯。可以通過ps-fax命令來觀察內(nèi)核線程,下面顯示了執(zhí)行ps-fax命令的結(jié)果,在[]號中的進(jìn)程即為內(nèi)核線程:
chenke@chenke1818:~$ ps -fax PID TTY STAT TIME COMMAND 2 ? S 0:34 [kthreadd] 3 ? S 1276:07 \_ [ksoftirqd/0] 5 ? S< 0:00 \_ [kworker/0:0H] 6 ? S 2:38 \_ [kworker/u4:0] 7 ? S 396:12 \_ [rcu_sched] 8 ? S 0:00 \_ [rcu_bh] 9 ? S 12:51 \_ [migration/0]
1.2.4 用戶線程庫pthread
在libc庫函數(shù)中,pthread庫用于創(chuàng)建用戶線程,其代碼在libc目錄下的nptl中。該函數(shù)的聲明為:
int __pthread_create_2_1 (pthread_t *newthread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);
libc庫為了考慮不同系統(tǒng)兼容性問題,里面有一堆條件編譯信息,這里忽略了這些信息,就寫了簡單地調(diào)用pthread庫創(chuàng)建一個線程來測試:
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <pthread.h> void* test_fn(void* arg) { printf("hello pthread.\n"); sleep(5); return((void *)0); } int main(int argc, char **argv) { pthread_t id; int ret; ret = pthread_create(&id, NULL, test_fn, NULL); if(ret ! = 0) { printf("create pthread error! \n"); exit(1); } printf("in main process.\n"); pthread_join(id, NULL); return 0; }
用gcc命令生成可執(zhí)行文件后用strace來跟蹤系統(tǒng)調(diào)用:
gcc -g -lpthread -Wall -o test_pthread test_pthread.c strace ./test_pthread.c mmap(NULL, 8392704, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_ STACK, -1, 0) = 0x7fb6ade8a000 brk(0) = 0x93d000 brk(0x95e000) = 0x95e000 mprotect(0x7fb6ade8a000, 4096, PROT_NONE) = 0 clone(child_stack=0x7fb6ae689ff0, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_ SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_ CHILD_CLEARTID, parent_tidptr=0x7fb6ae68a9d0, tls=0x7fb6ae68a700, child_tidptr=0x7fb6ae68a9d0) = 6186
分析上面strace產(chǎn)生的結(jié)果,可以得到pthread創(chuàng)建線程的流程,大概如下:
1)mmap分配用戶空間的棧大小。
2)mprotect設(shè)置內(nèi)存頁的保護(hù)區(qū)(大小為4KB),這個頁面用于監(jiān)測棧溢出,如果對這片內(nèi)存有讀寫操作,那么將會觸發(fā)一個SIGSEGV信號。
3)通過clone調(diào)用創(chuàng)建線程。
通過對pthread分析,我們也可以知道用戶線程的堆棧可以通過mmap從用戶空間自行分配。
分析Linux中對進(jìn)程和線程創(chuàng)建的幾個系統(tǒng)調(diào)用可發(fā)現(xiàn),創(chuàng)建時(shí)最終都會調(diào)用do_fork函數(shù),不同之處是傳入的參數(shù)不同(clone_flags),最終結(jié)果就是進(jìn)程有獨(dú)立的地址空間和棧,而用戶線程可以自己指定用戶棧,地址空間和父進(jìn)程共享,內(nèi)核線程則只有和內(nèi)核共享的同一個棧,同一個地址空間。當(dāng)然不管是進(jìn)程還是線程,do_fork最終會創(chuàng)建一個task_struct結(jié)構(gòu)。
1.3 進(jìn)程的調(diào)度
在一個CPU中,同一時(shí)刻最多只能支持有限的進(jìn)程或者線程同時(shí)運(yùn)行(這取決于CPU核數(shù)量),但是在一個運(yùn)行的操作系統(tǒng)上往往可以運(yùn)行很多進(jìn)程,假如運(yùn)行的進(jìn)程占據(jù)CPU進(jìn)程時(shí)間很長,就有可能讓其他進(jìn)程餓死。為了解決這種問題,操作系統(tǒng)引入了進(jìn)程調(diào)度器來進(jìn)行進(jìn)程的切換,目的是輪流讓其他進(jìn)程獲取CPU資源。
1.3.1 進(jìn)程調(diào)度機(jī)制的架構(gòu)
在每個進(jìn)程運(yùn)行完畢時(shí),系統(tǒng)可以進(jìn)行調(diào)度的工作,但是系統(tǒng)不可能總是在進(jìn)程運(yùn)行完才調(diào)度,不然其他進(jìn)程估計(jì)還沒被調(diào)度就餓死了。系統(tǒng)還需要一個重要的機(jī)制:中斷機(jī)制,來周期性地觸發(fā)調(diào)度算法進(jìn)行進(jìn)程的切換。
Linux進(jìn)程的切換是通過schedule函數(shù)來完成的,其主要邏輯由_schedule函數(shù)實(shí)現(xiàn):
static void __sched notrace __schedule(bool preempt) { // 階級1 struct task_struct *prev, *next; unsigned long *switch_count; struct rq *rq; int cpu; cpu = smp_processor_id(); // 獲取當(dāng)前CPU的id rq = cpu_rq(cpu); rcu_note_context_switch(); // 標(biāo)識當(dāng)前CPU發(fā)生任務(wù)切換,通過RCU更新狀態(tài) prev = rq->curr; … //階段2 switch_count = &prev->nivcsw; if (! preempt && prev->state) { if (unlikely(signal_pending_state(prev->state, prev))) { prev->state = TASK_RUNNING; } else { deactivate_task(rq, prev, DEQUEUE_SLEEP); prev->on_rq = 0; if (prev->flags & PF_WQ_WORKER) { struct task_struct *to_wakeup; to_wakeup = wq_worker_sleeping(prev, cpu); if (to_wakeup) try_to_wake_up_local(to_wakeup); } } switch_count = &prev->nvcsw; } // 階段3 if (task_on_rq_queued(prev)) update_rq_clock(rq); // 階段4 next = pick_next_task(rq, prev); // 選取下一個將要執(zhí)行的進(jìn)程 clear_tsk_need_resched(prev); clear_preempt_need_resched(); rq->clock_skip_update = 0; if (likely(prev ! = next)) { rq->nr_switches++; rq->curr = next; ++*switch_count; … // 階段5 rq = context_switch(rq, prev, next); //進(jìn)行進(jìn)程上下文切換 cpu = cpu_of(rq); } else { lockdep_unpin_lock(&rq->lock); raw_spin_unlock_irq(&rq->lock); } balance_callback(rq); }
_schedule執(zhí)行過程主要分為以下幾個階段:
1)關(guān)閉內(nèi)核搶占,初始化一部分變量。獲得當(dāng)前CPU的ID號,并賦值給局部變量CPU。使rq指向CPU對應(yīng)的運(yùn)行隊(duì)列(runqueue)。標(biāo)識當(dāng)前CPU發(fā)生任務(wù)切換,通知RCU更新狀態(tài),如果當(dāng)前CPU處于rcu_read_lock狀態(tài),當(dāng)前進(jìn)程將會放入rnp->blkd_tasks阻塞隊(duì)列,并呈現(xiàn)在rnp->gp_tasks鏈表中。(關(guān)于RCU機(jī)制,在第2章中介紹)。關(guān)閉本地中斷,獲取所要保護(hù)的運(yùn)行隊(duì)列(runqueue)的自旋鎖(spinlock),為查找可運(yùn)行進(jìn)程做準(zhǔn)備。
2)檢查prev的狀態(tài)。如果不是可運(yùn)行狀態(tài),而且沒有在內(nèi)核態(tài)被搶占,就應(yīng)該從運(yùn)行隊(duì)列中刪除prev進(jìn)程。但是,如果它是非阻塞掛起信號,而且狀態(tài)為TASK_INTER-RUPTIBLE,函數(shù)就把該進(jìn)程的狀態(tài)設(shè)置為TASK_RUNNING,并將它插入到運(yùn)行隊(duì)列。
3)task_on_rq_queued(prev)將pre進(jìn)程插入到運(yùn)行隊(duì)列的隊(duì)尾。
4)pick_next_task選取下一個將要執(zhí)行的進(jìn)程。
5)context_switch(rq, prev, next)進(jìn)行進(jìn)程上下文切換。
通過上述步驟可以發(fā)現(xiàn),調(diào)度無非就是找一個已有的進(jìn)程,然后進(jìn)行上下文切換,并讓它執(zhí)行而已。
注意
挑選next進(jìn)程的過程相對復(fù)雜,分析起來也比較麻煩,限于篇幅和時(shí)間有限,暫時(shí)不介紹具體挑選的調(diào)度算法實(shí)現(xiàn),這里僅介紹Linux調(diào)度的架構(gòu),圖1-10是Linux的調(diào)度架構(gòu)圖。

圖1-10 調(diào)度的架構(gòu)圖
Linux調(diào)度架構(gòu)的核心概念如下:
1)rq:可運(yùn)行的隊(duì)列,每個CPU對應(yīng)一個,包含自旋鎖、進(jìn)程數(shù)量、用于公平調(diào)度的CFS信息結(jié)構(gòu)、當(dāng)前正在運(yùn)行的進(jìn)程描述符等。實(shí)際的進(jìn)程隊(duì)列用紅黑樹來維護(hù)(通過CFS信息結(jié)構(gòu)來訪問)。
2)cfs_rq:cfs調(diào)度的運(yùn)行隊(duì)列信息,包含紅黑樹的根結(jié)點(diǎn)、正在運(yùn)行的進(jìn)程指針、用于負(fù)載均衡的葉子隊(duì)列等。
3)sched_entity:把需要調(diào)度的東西抽象成調(diào)度實(shí)體,調(diào)度實(shí)體可以是進(jìn)程、進(jìn)程組、用戶等。這里包含負(fù)載權(quán)重值、對應(yīng)紅黑樹結(jié)點(diǎn)、虛擬運(yùn)行時(shí)vruntime等。
4)sched_class:把調(diào)度策略(算法)抽象成調(diào)度類,包含一組通用的調(diào)度操作接口,將接口和實(shí)現(xiàn)分離。你可以根據(jù)這組接口實(shí)現(xiàn)不同的調(diào)度算法,使得一個Linux調(diào)度程序可以有多個不同的調(diào)度策略。
1.3.2 進(jìn)程切換的原理
在挑選完next進(jìn)程之后,就開始準(zhǔn)切換到next進(jìn)程。
可以將進(jìn)程理解為正在利用CPU工作的任務(wù)。因?yàn)樵谙到y(tǒng)中同時(shí)運(yùn)行的進(jìn)程有很多,CPU不能僅僅被同一個進(jìn)程使用,所以,這時(shí)候就需要進(jìn)程切換機(jī)制,另外,假如某進(jìn)程的工作大部分為I/O操作,占用CPU空跑會導(dǎo)致資源浪費(fèi),這樣的進(jìn)程需要主動放棄CPU。
需要進(jìn)程切換的場景有以下幾種:
?該進(jìn)程分配的CPU時(shí)間片用完。
?該進(jìn)程主動放棄CPU(例如IO操作)。
?某一進(jìn)程搶占CPU獲得執(zhí)行機(jī)會。
Linux并沒有使用x86 CPU自帶的任務(wù)切換機(jī)制,而是通過手工的方式實(shí)現(xiàn)了切換,切換過程通過以下switch_to宏來定義:
#define switch_to(prev, next, last) do { unsigned long ebx, ecx, edx, esi, edi; asm volatile("pushfl\n\t" // 步驟1 "pushl %%ebp\n\t" // 步驟2 "movl %%esp, %[prev_sp]\n\t" // 步驟3 "movl %[next_sp], %%esp\n\t" // 步驟4 "movl $1f, %[prev_ip]\n\t" // 步驟5 "pushl %[next_ip]\n\t" // 步驟6 __switch_canary "jmp __switch_to\n" // 步驟7 "1:\t" "popl %%ebp\n\t" // 從棧恢復(fù)EBP "popfl\n" // 從棧恢復(fù)flags // asm內(nèi)嵌匯編的輸出參數(shù) [prev_sp] "=m" (prev->thread.sp), [prev_ip] "=m" (prev->thread.ip), "=a" (last), "=b" (ebx), "=c" (ecx), "=d" (edx), "=S" (esi), "=D" (edi) __switch_canary_oparam // asm內(nèi)嵌匯編的輸入?yún)?shù) [next_sp] "m" (next->thread.sp), [next_ip] "m" (next->thread.ip), [prev] "a" (prev), [next] "d" (next) __switch_canary_iparam "memory"); } while (0)
該切換過程分為以下幾個步驟:
1)pushfl保存eflags寄存器中的數(shù)據(jù)到進(jìn)程本身的堆棧。
2)保存堆棧指針ebp寄存器地址。
3)把堆棧寄存器esp的地址保存到prev->thread.sp中。
4)把next->thread.sp的地址送入到sp寄存器中,這個時(shí)候其實(shí)已經(jīng)跑在新的next進(jìn)程的上下文中了。
5)把當(dāng)前的eip地址保存到prev->thread.ip中。
6)pushfl把next->thread.ip的地址壓入到當(dāng)前堆棧中。
7)通過jmp__switch_to指令,不管__switch_to做了什么,ret返回地址之前已經(jīng)被設(shè)置成了next->thread.ip的地址,所以將會執(zhí)行之前在copy_thread_tls中設(shè)置的ret_from_fork。
通過這個過程,可以了解到在Linux中,我們并沒有對TSS進(jìn)行特殊處理,而是每個CPU持有唯一一份TSS,它的作用也僅僅是在權(quán)限級做躍遷的時(shí)候保存堆棧上下文,可以通過圖1-11理解進(jìn)程切換機(jī)制。

圖1-11 進(jìn)程切換原理圖
注意
關(guān)于x86架構(gòu)CPU的任務(wù)切換機(jī)制,可以參考閱讀《Intel開發(fā)手冊》,可以從Intel官網(wǎng)下載。另外,本人也編寫了代碼來模擬兩個進(jìn)程切換的過程,供大家參考,便于加深理解:https://github.com/lingqi1818/analysis_linux/tree/master/ch01/test03關(guān)于asm內(nèi)嵌匯編語法可以參考:https://www.ibm.com/developerworks/cn/linux/sdk/assemble/inline/index.html
1.3.3 調(diào)度中的CPU親和度
我們已經(jīng)知道,進(jìn)程創(chuàng)建出來后在內(nèi)核中的數(shù)據(jù)結(jié)構(gòu)為task_struct,該結(jié)構(gòu)中有掩碼屬性cpus_allowed,這個掩碼由n位組成,與CPU中的每個邏輯核心一一對應(yīng)。具有4個核的CPU可以有4位。假如CPU啟用了超線程,那么剛才這個CPU就有一個8位的掩碼,進(jìn)程可以運(yùn)行在掩碼位設(shè)置為1的CPU上。
Linux內(nèi)核API提供了兩個系統(tǒng)調(diào)用,讓用戶可以修改位掩碼或查看當(dāng)前的位掩碼:
? sched_setaffinity():用來修改位掩碼。
? sched_getaffinity():用來查看當(dāng)前的位掩碼。
這兩個調(diào)用實(shí)現(xiàn)的僅僅就是修改或者獲取cpus_allowed的值。
在下次task被喚醒的時(shí)候,select_task_rq_fair根據(jù)cpu_allowed里的掩碼來確定將其置于哪個CPU的運(yùn)行隊(duì)列,一個進(jìn)程在某一時(shí)刻只能存在于一個CPU的運(yùn)行隊(duì)列里。
在Nginx中,就使用了CPU親和度來完成某些場景的工作:
worker_processes 4; worker_cpu_affinity 0001001001001000;
上面這個配置說明了4個工作進(jìn)程中的每一個和一個CPU核掛鉤。
worker_processes 2; worker_cpu_affinity 01011010;
上面這個配置則說明了兩個工作進(jìn)程中的每一個和2個核掛鉤。
看Nginx的實(shí)現(xiàn),核心函數(shù)為ngx_setaffinity:
void ngx_setaffinity(uint64_t cpu_affinity, ngx_log_t *log) { cpu_set_t mask; ngx_uint_t i; … CPU_ZERO(&mask); i = 0; do { if (cpu_affinity & 1) { CPU_SET(i, &mask); } i++; cpu_affinity >>= 1; } while (cpu_affinity); if (sched_setaffinity(0, sizeof(cpu_set_t), &mask) == -1) { ngx_log_error(NGX_LOG_ALERT, log, ngx_errno, "sched_setaffinity() failed"); } }
這里主要的操作就是sched_setaffinity。
再結(jié)合Nginx文檔中的例子和Nginx的源碼來看:
worker_processes 4; worker_cpu_affinity 0001001001001000;
如果這個內(nèi)容寫入Nginx的配置文件中,然后Nginx啟動或者重新加載配置的時(shí)候,若worker_process是4,就會啟用4個worker,然后把worker_cpu_affinity后面的4個值當(dāng)作4個cpu affinity mask,分別調(diào)用ngx_setaffinity,然后就把4個worker進(jìn)程分別綁定到CPU0~3上。
1.4 在應(yīng)用程序中管理進(jìn)程和線程
在了解了Linux對進(jìn)程和線程的實(shí)現(xiàn)之后,我們首要的目的還是要學(xué)習(xí)如何在實(shí)際應(yīng)用程序開發(fā)中使用這些技術(shù),不同的應(yīng)用程序?qū)崿F(xiàn)了不同的進(jìn)程或線程的管理模型,而每一種模型的背后,都體現(xiàn)了作者對業(yè)務(wù)的理解和場景化的考慮。下面我們介紹兩種不同軟件的管理模型。
1.4.1 Memcached線程池模型分析
Memcached是一款服務(wù)器內(nèi)存管理軟件,它主要是由pthread創(chuàng)建的用戶工作線程池模型來處理主要邏輯的,圖1-12是Memcached的線程模型圖。

圖1-12 Memcached線程模型圖
其主要概念如下:
? mthread主線程,主要用于監(jiān)聽socket事件,并建立連接,然后把連接和相應(yīng)的事件分發(fā)到cq連接隊(duì)列中(每個分線程都擁有一個連接隊(duì)列)。
? cthread分線程,用于從連接隊(duì)列中獲取連接的讀寫事件,并進(jìn)行業(yè)務(wù)邏輯的處理工作。
從Memcached的線程池初始化邏輯中我們可以發(fā)現(xiàn),cthread是個線程池,用戶可以指定池子的大小:
void thread_init(int nthreads, struct event_base *main_base) { int i; int power; pthread_mutex_init(&cache_lock, NULL); pthread_mutex_init(&stats_lock, NULL); pthread_mutex_init(&init_lock, NULL); pthread_cond_init(&init_cond, NULL); pthread_mutex_init(&cqi_freelist_lock, NULL); cqi_freelist = NULL; … dispatcher_thread.base = main_base; dispatcher_thread.thread_id = pthread_self(); … // 在設(shè)置完libevent后,創(chuàng)建線程 for (i = 0; i < nthreads; i++) { create_worker(worker_libevent, &threads[i]); } // 等待,直到所有線程設(shè)置完畢并返回 pthread_mutex_lock(&init_lock); wait_for_thread_registration(nthreads); pthread_mutex_unlock(&init_lock); }
Memcached在創(chuàng)建工作線程的時(shí)候,同樣會用pipe調(diào)用創(chuàng)建管道,用于和主線程之間的通信。
create_worker函數(shù)最終通過pthread_create來創(chuàng)建工作線程:
static void create_worker(void *(*func)(void *), void *arg) { pthread_t thread; pthread_attr_t attr; int ret; pthread_attr_init(&attr); if ((ret = pthread_create(&thread, &attr, func, arg)) ! = 0) { fprintf(stderr, "Can't create thread: %s\n", strerror(ret)); exit(1); } }
該模型假設(shè)在業(yè)務(wù)邏輯繁忙,并且I/O開銷比較大的情況下,多線程模型能提高系統(tǒng)的吞吐率。但缺點(diǎn)是當(dāng)多線程同時(shí)訪問同一數(shù)據(jù)的時(shí)候就存在競爭,需要額外的并發(fā)解決開銷(比如鎖)。另外其實(shí)Memcached大部分操作都是基于內(nèi)存的讀寫,應(yīng)該速度很快,引入并發(fā)反而在競爭中存在效率降低的風(fēng)險(xiǎn),另外假如系統(tǒng)中線程數(shù)量開得太多,那么線程切換的開銷也會上升,需要根據(jù)實(shí)際場景謹(jǐn)慎設(shè)置線程池的大小。而Redis的作者認(rèn)為內(nèi)存的操作速度是很快的,所以實(shí)現(xiàn)了單線程的服務(wù)器模型,在下一章介紹并發(fā)的時(shí)候再詳細(xì)介紹。
1.4.2 Nginx進(jìn)程模型分析
剛才介紹的Memcached是比較經(jīng)典的服務(wù)器線程池模型,比如老牌靜態(tài)服務(wù)器軟件Apache就是采用這樣的模型,而Nginx的作者則對該模型進(jìn)行了改進(jìn)。
Nginx只要創(chuàng)建CPU核心數(shù)量相等的工作進(jìn)程,即可滿足高并發(fā)、高吞吐量的需求,原因是它的每個工作進(jìn)程都持有一個基于I/O多路復(fù)用的epoll池子(見圖1-13),這樣每個進(jìn)程只有在事件被觸發(fā)的場景下才進(jìn)行工作,否則就會讓出CPU進(jìn)行其他事件的處理,特別是在upstream的場景下,工作進(jìn)程可以悠閑地等待后端數(shù)據(jù)準(zhǔn)備好之后再進(jìn)行工作,CPU的利用率也大大提升。

圖1-13 Nginx工作進(jìn)程模型
在Nginx中master進(jìn)程通過fork調(diào)用派生完子進(jìn)程后,又通過socketpair創(chuàng)建了管道來進(jìn)行父子進(jìn)程之間的通信。
通過了解Memcached和Nginx的線程池和工作進(jìn)程模型,我們發(fā)現(xiàn)有多種選擇,既多線程與單線程,線程池模型與工作進(jìn)程模型,選擇哪種模型好?答案不是絕對的,需要根據(jù)業(yè)務(wù)場景具體分析后,找到問題的癥結(jié)在哪里,才能給出具體的答案。
1.5 處理進(jìn)程和線程的相關(guān)工具
在了解了Linux進(jìn)程和線程的實(shí)現(xiàn)后,在具體的開發(fā)和運(yùn)維場景下如何駕馭它們呢?下面我們簡單介紹幾個工具,在Linux下可用于調(diào)試、追蹤系統(tǒng)調(diào)用并進(jìn)行性能分析。
1.5.1 開發(fā)環(huán)境調(diào)試線程
當(dāng)使用gdb調(diào)試C程序的時(shí)候,比如Nginx、Nginx的子進(jìn)程都是fork出來的,所以當(dāng)開發(fā)完并定義模塊設(shè)置斷點(diǎn)調(diào)試的時(shí)候,默認(rèn)是無法進(jìn)入斷點(diǎn)的,gdb提供了調(diào)試線程的方法。
跟蹤子進(jìn)程:
(gdb)set follow-fork-mode child
跟蹤父進(jìn)程:
(gdb)set follow-fork-mode parent
設(shè)置gdb在fork時(shí)詢問跟蹤哪一個進(jìn)程:
(gdb)set follow-fork-mode ask
根據(jù)以上方法進(jìn)行設(shè)置之后,我們就可以在相應(yīng)的線程實(shí)現(xiàn)處設(shè)置斷點(diǎn)并進(jìn)行跟蹤了。
1.5.2 進(jìn)程崩潰調(diào)試方法
在C程序崩潰的時(shí)候往往會留下coredump文件,供我們分析問題到底出在哪里。下面我們用一個Nginx崩潰的場景來分析如何調(diào)試coredump文件。
曾經(jīng)遇到個問題,因?yàn)楹蠖祟I(lǐng)取獎品的接口存在并發(fā)操作,有可能會出現(xiàn)超領(lǐng)的情況,但是在分析請求日志的過程中發(fā)現(xiàn),在Tomcat中,出現(xiàn)了2條領(lǐng)取記錄,并且已經(jīng)成功,但是,在Nginx中卻只有一條記錄。感覺很奇怪,困擾了很久。
不過在分析Nginx的error.log的時(shí)候,發(fā)現(xiàn)了一些蛛絲馬跡:
[alert] 92648#0: worker process 22459 exited on signal 11
原來在被多領(lǐng)取的時(shí)候,還發(fā)生過Nginx的woker進(jìn)程退出的情況。signal 11也就是SIGSEGV信號,說明有非法內(nèi)存訪問的情況。
那么,為什么會有這樣的問題呢?難道編寫的Nginx模塊中有潛在的bug?于是在Nginx配置中,設(shè)置打開coredump的功能:
worker_rlimit_core 500m; working_directory /tmp;
然后,用gdb來調(diào)試產(chǎn)生的coredump文件:
gdb /usr/sbin/nginx core.23161

發(fā)現(xiàn)問題出在get_root_domain函數(shù),但是由于Nginx沒有debug信息,無法獲取具體文件和行號,查看Nginx官方文檔,編譯的時(shí)候產(chǎn)生debug調(diào)試信息可以如下操作:“編譯器需要使用正確的參數(shù)。假如你使用的是GCC, -g參數(shù),會在代碼編譯后加入調(diào)試信息,另外,你需要禁用編譯器優(yōu)化,通過使用-O0參數(shù),可以讓調(diào)試器輸出容易看懂的信息。”
我們可以重新編譯Nginx:
CFLAGS="-g -O0" ./configure ....
然后,重新執(zhí)行:
gdb /usr/sbin/nginx core.23161
顯示如下:
sysop@api-1:~$ gdb /usr/sbin/nginx core.23176 GNU gdb (GDB) 7.4.1-debian Copyright (C) 2012 Free Software Foundation, Inc. License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html> This is free software: you are free to change and redistribute it. There is NO WARRANTY, to the extent permitted by law. Type "show copying" and "show warranty" for details. This GDB was configured as "x86_64-linux-gnu". For bug reporting instructions, please see: <http://www.gnu.org/software/gdb/bugs/>... Reading symbols from /usr/sbin/nginx...Reading symbols from /usr/lib/debug/ usr/sbin/nginx...done. done. [New LWP 23176] Core was generated by `nginx: worker pr'. Program terminated with signal 11, Segmentation fault. #0 0x000000000050267c in get_root_domain (domain=<error reading variable: Cannot access memory at address 0x8>) at src/http/modules/ngx_http_beacon_ module.c:298
get_root_domain代碼如下:
size_t get_root_domain(u_char **p, ngx_str_t *domain){ *p = domain->data; int i = domain->len -1; ngx_flag_t is_first = 0; ...
我們發(fā)現(xiàn)domain指針指向的是0x8這個地址,這么低的地址理論上應(yīng)該是系統(tǒng)保護(hù)的地址,不能被程序訪問,那么domain是如何來的呢?
domain_len = get_root_domain(&domain, &r->headers_in.host->value);
返現(xiàn)直接取的是HTTP協(xié)議頭中的host信息。
然后再看了一下當(dāng)時(shí)的錯誤信息,有一些獲取驗(yàn)證碼的請求是用httpclient構(gòu)建的,不是通過瀏覽器發(fā)起的請求,那么host信息必然是空的。所以導(dǎo)致這個空指針的異常。
而在當(dāng)時(shí)Nginx的進(jìn)程退出,正好影響了正常的請求,導(dǎo)致返回的時(shí)候沒打印日志以及吐數(shù)據(jù)給客戶就掛了。
最后程序?qū)@種情況做了兼容,修復(fù)了這個詭異的問題。
1.5.3 strace工具
strace是Linux提供的一個工具,常用來跟蹤進(jìn)程執(zhí)行時(shí)的系統(tǒng)調(diào)用和所接收的信號。比如:
[root@lingqi1818~]# strace cat /dev/null execve("/bin/cat", ["cat", "/dev/null"], [/* 27 vars */]) = 0 brk(0) = 0x250d000 mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f1e0c0bf000 access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory) open("/etc/ld.so.cache", O_RDONLY) = 3 fstat(3, {st_mode=S_IFREG|0644, st_size=26432, ...}) = 0 mmap(NULL, 26432, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f1e0c0b8000 close(3) = 0 open("/lib64/libc.so.6", O_RDONLY) = 3 read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0000\356\1\0\0\0\0\0"... , 832) = 832 fstat(3, {st_mode=S_IFREG|0755, st_size=1921216, ...}) = 0 mmap(NULL, 3750152, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f1e0bb0d000 mprotect(0x7f1e0bc98000, 2093056, PROT_NONE) = 0 mmap(0x7f1e0be97000, 20480, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ DENYWRITE, 3, 0x18a000) = 0x7f1e0be97000 mmap(0x7f1e0be9c000, 18696, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ ANONYMOUS, -1, 0) = 0x7f1e0be9c000 close(3) = 0 mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f1e0c0b7000 mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f1e0c0b6000 mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f1e0c0b5000 arch_prctl(ARCH_SET_FS, 0x7f1e0c0b6700) = 0 mprotect(0x7f1e0be97000, 16384, PROT_READ) = 0 mprotect(0x7f1e0c0c0000, 4096, PROT_READ) = 0 munmap(0x7f1e0c0b8000, 26432) = 0 brk(0) = 0x250d000 brk(0x252e000) = 0x252e000 open("/usr/lib/locale/locale-archive", O_RDONLY) = 3 fstat(3, {st_mode=S_IFREG|0644, st_size=99158720, ...}) = 0 mmap(NULL, 99158720, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f1e05c7c000 close(3) = 0 fstat(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 0), ...}) = 0 open("/dev/null", O_RDONLY) = 3 fstat(3, {st_mode=S_IFCHR|0666, st_rdev=makedev(1, 3), ...}) = 0 read(3, "", 32768) = 0 close(3) = 0 close(1) = 0 close(2) = 0 exit_group(0) = ?
以上代碼每一行都是一個系統(tǒng)調(diào)用,等號左邊是系統(tǒng)調(diào)用的函數(shù)名及其參數(shù),右邊是該調(diào)用的返回值。
strace的具體參數(shù)含義可以通過man指令來查詢,比如我們常用的-c參數(shù)可以統(tǒng)計(jì)每一次系統(tǒng)調(diào)用所執(zhí)行的時(shí)間、次數(shù)和出錯的次數(shù)等:
[root@lingqi1818~]# strace -c cat /dev/null % time seconds usecs/call calls errors syscall ------ ----------- ----------- --------- --------- ---------------- 0.00 0.000000 0 2 read 0.00 0.000000 0 4 open 0.00 0.000000 0 6 close 0.00 0.000000 0 5 fstat 0.00 0.000000 0 9 mmap 0.00 0.000000 0 3 mprotect 0.00 0.000000 0 1 munmap 0.00 0.000000 0 3 brk 0.00 0.000000 0 1 1 access 0.00 0.000000 0 1 execve 0.00 0.000000 0 1 arch_prctl ------ ----------- ----------- --------- --------- ---------------- 100.00 0.000000 36 1 total
1.5.4 SystemTap工具
SystemTap是基于kprobe的實(shí)現(xiàn)(關(guān)于kprobe網(wǎng)上資料較多,大家可以自行研究),其功能非常強(qiáng)大,可以監(jiān)控內(nèi)核和用戶程序。
下面以實(shí)際場景為例,來監(jiān)控運(yùn)行中程序指定函數(shù)的調(diào)用參數(shù)值。
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <pthread.h> void print(char *p){ printf("%s.\n", p); } void* test_fn(void* arg) { while(1){ print("hello pthread"); sleep(5); } return((void *)0); } int main(int argc, char **argv) { pthread_t id; int ret; ret = pthread_create(&id, NULL, test_fn, NULL); if(ret ! = 0) { printf("create pthread error! \n"); exit(1); } printf("in main process.\n"); pthread_join(id, NULL); return 0; }
以上程序每5秒鐘會調(diào)用一次print,我要是想知道print的輸入?yún)?shù)是什么,那么編寫SystemTap腳本如下:
function myprint:string (val) %{ char *str = (char *)STAP_ARG_val; snprintf(STAP_RETVALUE, MAXSTRINGLEN, "%s", str); %} probe process(3266).function("print") {printf("%s\n", myprint($p)); }
運(yùn)行結(jié)果為:
stap -g test.stp
這樣就可以獲得監(jiān)控的結(jié)果了,因?yàn)槲矣玫搅藘?nèi)嵌C來獲取字符串的值,所以就需要加上-g參數(shù)。
1.5.5 DTrace工具
DTrace是Oracle旗下的一款基于Linux的監(jiān)控程序,它可以基于D語言編寫腳本來實(shí)現(xiàn)你想要的監(jiān)控功能,由于功能比較復(fù)雜,這里不做過多闡述,大家可以有興趣到下面的網(wǎng)址了解更多信息:
? http://www.oracle.com/technetwork/cn/articles/servers-storage-admin/dtrace-on-linux-1956556-zhs.html
? http://docs.oracle.com/cd/E24847_01/html/E22192/toc.html
下面的腳本用于監(jiān)控指定pid下指定的系統(tǒng)調(diào)用是否發(fā)生:
test.d: pid$1::$2:entry { self->trace = 1; } pid$1::$2:return /self->trace/ { self->trace = 0; } pid$1:::entry, pid$1:::return /self->trace/ { }
然后我們打開Redis進(jìn)程:
localhost:src chenke$ ./redis-server
其pid為:
chenke 7276 0.0 0.024650801892 s003 S+ 12:32下午 0:00.01 ./redis-server *:6379
現(xiàn)在進(jìn)行寫入操作:
localhost:~ chenke$ telnet localhost 6379 Trying ::1... Connected to localhost. Escape character is '^]'. set a 1 +OK
我們來看監(jiān)控腳本產(chǎn)生的數(shù)據(jù)如下:
dtrace: script 'test.d' matched 33829 probes CPU ID FUNCTION:NAME 0 257241 write:entry 0 257241 write:entry 0 257241 write:entry 0 257241 write:entry 0 257241 write:entry 0 257241 write:entry
當(dāng)然,更復(fù)雜的功能需要自己研究手冊和D語言,而且DTrace的好處是可以監(jiān)控用戶態(tài)的程序。
1.6 本章小結(jié)
進(jìn)程和線程是計(jì)算機(jī)發(fā)展歷史上為解決特定問題而產(chǎn)生的解決方案。本章開頭介紹了進(jìn)程管理的歷史,以及實(shí)現(xiàn)原理,特別是內(nèi)核線程、用戶線程和協(xié)程,只有了解了這些原理,才能更好地編寫應(yīng)用程序。
隨后介紹了Linux對進(jìn)程和線程的實(shí)現(xiàn),還介紹了內(nèi)核對進(jìn)程和線程的調(diào)度機(jī)制,調(diào)度機(jī)制的好壞決定了一個操作系統(tǒng)是否能流暢響應(yīng)不同用戶的實(shí)時(shí)請求。
我們平時(shí)使用了很多開源的軟件,進(jìn)程和線程的模型是服務(wù)器實(shí)現(xiàn)必須要考慮的問題,在了解了原理以及Linux的實(shí)現(xiàn)后,再對Memcached和Nginx相關(guān)模型進(jìn)行分析,有助于更好地理解進(jìn)程和線程。
最后,通過對gdb、coredump、strace、SystemTap、DTrace等工具的介紹,有助于我們進(jìn)行開發(fā)調(diào)試、故障診斷、監(jiān)控分析等,建議大家可以多動手實(shí)踐。
- Linux運(yùn)維之道(第3版)
- Linux運(yùn)維實(shí)戰(zhàn):CentOS7.6操作系統(tǒng)從入門到精通
- 構(gòu)建可擴(kuò)展分布式系統(tǒng):方法與實(shí)踐
- 混沌工程:復(fù)雜系統(tǒng)韌性實(shí)現(xiàn)之道
- RESS Essentials
- STM32庫開發(fā)實(shí)戰(zhàn)指南:基于STM32F4
- Linux系統(tǒng)安全基礎(chǔ):二進(jìn)制代碼安全性分析基礎(chǔ)與實(shí)踐
- Learning Magento 2 Administration
- jQuery UI Cookbook
- Red Hat Enterprise Linux 6.4網(wǎng)絡(luò)操作系統(tǒng)詳解
- 精解Windows 10
- Hands-On GPU Programming with Python and CUDA
- μC/OS-III內(nèi)核實(shí)現(xiàn)與應(yīng)用開發(fā)實(shí)戰(zhàn)指南:基于STM32
- Unity AR/VR開發(fā):實(shí)戰(zhàn)高手訓(xùn)練營
- Android Telephony原理解析與開發(fā)指南