官术网_书友最值得收藏!

第1章 進程與線程

只要是計算機科班出身的技術人員,肯定都學過現代操作系統課程。一般在操作系統的書中都會有這樣的定義:

簡單來說,進程就是在操作系統中運行的程序,是操作系統資源管理的最小單位。一個進程可以管理多個線程,線程相對輕量,可以共享進程地址空間。

我在很多次面試的時候,向求職者提問過進程和線程在Linux中到底有什么區別,不只是科班出身的應屆生,連工作多年的老手,也有很多回答不準確。傳統的教育缺乏實踐環節,而計算機恰恰是一個實踐性很強的學科,假如只是知道一個概念,卻不知道它具體在代碼中的表現形式以及背后的實現原理,那么知道與不知道這個概念又有何分別呢?

那么,線程和進程到底有什么區別呢?既然進程可以管理線程,是否說明進程就特別牛呢?另外,搞出這些概念到底要解決什么問題,是否還具有副作用呢?本章將對這些問題一一解答。

1.1 進程和線程的概念

我覺得不管做什么工作,都需要搞明白所面臨工作的過去、現在和未來。我認為不懂歷史的程序員肯定寫不出好代碼。因為不知道這個技術被創造出來到底意味著什么,也無法理解未來這個技術要向哪里發展,僅僅是解決當下的問題,修修補補,做一天和尚撞一天鐘,僅此而已。下面我們就介紹進程的歷史。

1.1.1 進程的歷史

計算機發明出來是做邏輯運算的,但是當初計算機都是大型機,造價昂貴,只有有錢的政府機構、著名大學的數據中心才會有,一般人接觸不到。大家要想用,要去專門的機房。悲催的是,那時候代碼還是機器碼,直接穿孔把程序輸入到紙帶上面,然后再拿去機房排隊。那時候的計算機也沒什么進程管理之類的概念,它只知道根據紙帶里的二進制數據進行邏輯運算,一個人的紙帶輸入完了,就接著讀取下一個人的紙帶,要是程序有bug,不好意思,只有等到全部運算結束之后才能得到結果,然后回家慢慢改。

為了改進這種排隊等候的低效率問題,就有人發明了批處理系統。以前只能一個一個提交程序,現在好了,可以多人一起提交,計算機會集中處理,至于什么時候處理完,回家慢慢等吧。或者你可以多寫幾種可能,集中讓計算機處理,總有一個結果是好的。

懶人總會推動科技進步,為了提升效率,機器碼就被匯編語言替代了,從而再也不用一串串二進制數字來寫代碼了。便于記憶的英文指令會極大提升效率。然后,進程管理這樣的概念也被提出來了,為什么要提呢?因為當程序在運算的時候,不能一直占用著CPU資源,有可能此時還會進行寫磁盤數據、讀取網絡設備數據等,這時候完全可以把CPU的計算資源讓給其他進程,直到數據讀寫準備就緒后再切換回來。所以,進程管理的出現也標志著現代操作系統的進步。那么既然進程是運行中的程序,那么,到底什么是程序呢?運行和不運行又有什么區別呢?

先說程序,既然程序是人寫的,那么最終肯定會生成可執行文件,保存在磁盤里,而且這個文件可能會很大,有時候不一定是一個文件,可能會有多個文件,甚至文件夾,其包含圖片、音頻等各種數據。然而,CPU做邏輯運算的每條指令是從內存中讀取的,所以運行中的程序可以理解為內存中的代碼指令和運行相關的數據被CPU讀寫并計算的過程。我們都知道內存的大小是有限的,所以很可能裝不下磁盤中的整個程序。因此內存中運行的是當下需要運行的部分程序數據,等運算完就會繼續讀取后面一部分磁盤數據到內存,并繼續進行運算。

一個進程在運行的過程中,不可能一直占據著CPU進行邏輯運算,中間很可能在進行磁盤I/O或者網絡I/O,為了充分利用CPU運算資源,有人設計了線程的概念。我認為線程最大的特點就是和創建它的進程共享地址空間(關于地址空間的概念大家可以在第3章了解更多)。這時候有人就會認為,要提升CPU的利用率,開多個進程也可以達到,但是開多個進程的話,進程間通信又是個麻煩的事情,畢竟進程之間地址空間是獨立的,沒法像線程那樣做到數據的共享,需要通過其他的手段來解決,比如管道等。圖1-1描述了進程和線程的區別。

圖1-1 進程和線程的區別

1.1.2 線程的不同玩法

針對線程現在又有很多玩法,有內核線程、用戶級線程,還有協程。下面簡單介紹這些概念。

一般操作系統都會分為內核態和用戶態,用戶態線程之間的地址空間是隔離的,而在內核態,所有線程都共享同一內核地址空間。有時候,需要在內核態用多個線程進行一些計算工作,如異步回調場景的模型,就可以基于多個內核線程進行模擬,比如AIO機制,假如硬件不提供某種中斷機制的話,那么就只能通過線程自己去后臺模擬了,圖1-2說明了有中斷機制的寫磁盤后回調和沒有中斷機制的寫磁盤后線程模擬異步回調。

圖1-2 兩種異步回調場景

在用戶態,大多數場景下業務邏輯不需要一直占用CPU資源,這時候就有了用戶線程的用武之地。

不管是用戶線程還是內核線程,都和進程一樣,均由操作系統的調度器來統一調度(至少在Linux中是這樣子)。所以假如開辟太多線程,系統調度的開銷會很大,另外,線程本身的數據結構需要占用內存,頻繁創建和銷毀線程會加大系統的壓力。線程池就是在這樣的場景下提出的,圖1-3說明了常見的線程池實現方案,線程池可以在初始化的時候批量創建線程,然后用戶后續通過隊列等方式提交業務邏輯,線程池中的線程進行邏輯的消費工作,這樣就可以在操作的過程中降低線程創建和銷毀的開銷,但是調度的開銷還是存在的。

圖1-3 線程池實現原理

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

圖1-4 協程的實現方案

要在用戶線程上實現協程是一件很難受的事情,原理類似于調度器根據條件的改變不停地調用各個協程的callback機制,但是前提是大家都在一個用戶線程下。要注意,一旦有一個協程阻塞,其他協程也都不能運行了。因此要處理好協程。

下面我們來看一段PHP代碼,通過生產者-消費者程序來模擬實現協程的例子:

import time
def consumer()             // 消費者
    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):            // 生產者
    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)

執行結果:

[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(生產者)會依次生產5份數據n,并且發送給consumer(消費者),只有消費者執行完之后,生產者才會再次生產數據。可以把produce和cosumer理解為兩個協程,其中關鍵點是通過yield關鍵字來控制消費者,命令yield r會暫停消費者直到r被傳遞過來為止。

注意

關于yield關鍵字,可以參考PHP手冊:http://php.net/manual/zh/language.generators.syntax.php生成器函數的核心是yield關鍵字。它最簡單的調用形式看起來像一個return聲明,不同之處在于普通return會返回值并終止函數的執行,而yield會返回一個值給循環調用此生成器的代碼,并且只是暫停執行生成器函數。

最后我們進行一下總結,多進程的出現是為了提升CPU的利用率,特別是I/O密集型運算,不管是多核還是單核,開多個進程必然能有效提升CPU的利用率。而多線程則可以共享同一進程地址空間上的資源,為了降低線程創建和銷毀的開銷,又出現了線程池的概念,最后,為了提升用戶線程的最大利用效率,又提出了協程的概念。

1.2 Linux對進程和線程的實現

通過上一節的介紹,大家應該大致了解了進程和線程在操作系統中的概念和玩法,那么對應到具體的Linux系統中,是否就如上面描述的那樣呢?下面來分析Linux中對進程和線程的實現。為了便于理解,首先通過圖1-5來簡單介紹Linux進程相關的知識結構。

圖1-5 Linux進程相關的知識結構

從圖中可以發現,進程和線程(包括內核線程)的創建,都是通過系統調用來觸發的,而它們最終都會調用do_fork函數,系統調用通過libc這樣的庫函數封裝后提供給應用層調用,進程創建后會產生一個task_struct結構,schedule函數會通過時鐘中斷來觸發調度。后面會進行具體的分析。

1.2.1 Linux中的進程實現

Linux進程的創建是通過系統調用fork和vfork來實現的,參考內核源碼/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最終都調用do_fork函數,只是傳入的clone_flags參數不同而已,參見表1-1。

表1-1 clone_flags的參數及說明

因為進程創建的核心就是do_fork函數,所以來看一下它的相關參數:

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:創建子進程相關的參數,決定了父子進程之間共享的資源種類。

? stack_start:進程棧開始地址。

? stack_size:進程棧空間大小。

? parent_tidptr:父進程的pid。

? child_tidptr:子進程的pid。

? tls:線程局部存儲空間的地址,tls指thread local Storage。

圖1-6為do_fork函數的整個執行流程,在這個執行過程當中,比較關鍵的是調用copy_process函數,成功后創建子進程,然后在后面就可以獲取到pid。另外,我們在這里也發現了fork和vfork的一個區別,vfork場景下父進程會先休眠,等喚醒子進程后,再喚醒父進程。大家可以想一想,這樣做的好處是什么呢?我個人認為在vfork場景下,子進程被創建出來時,是和父進程共享地址空間的(這個后面介紹copy_process步驟的時候可以進行驗證),并且它是只讀的,只有執行exec創建新的內存程序映象時才會拷貝父進程的數據創建新的地址空間,假如這個時候父進程還在運行,就有可能產生臟數據或者發生死鎖。在還沒完全讓子進程運行起來的時候,讓其父進程休息是個比較好的辦法。

圖1-6 do_fork函數執行流程

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

圖1-7 copy_process執行流程

主要參數說明如下:

1)copy_semundo(clone_flags, p);拷貝系統安全相關的數據給子進程,如果clone_flags設置了CLONE_SYSVSEM,則復制父進程的sysvsem.undo_list到子進程;否則子進程的tsk->sysvsem.undo_list為NULL。

2)copy_files(clone_flags, p);如果clone_flags設置了CLONE_FILES,則父子進程共享相同的文件句柄;否則將父進程文件句柄拷貝給子進程。

3)copy_fs(clone_flags, p);如果clone_flags設置了CLONE_FS,則父子進程共享相同的文件系統結構體對象;否則調用copy_fs_struct拷貝一份新的fs_struct結構體,但是指向的還是進程0創建出來的fs,并且文件系統資源是共享的。

4)copy_sighand(clone_flags, p);如果clone_flags設置了CLONE_SIGHAND,則增加父進程的sighand引用計數;否則(創建的必定是子進程)將父進程的sighand_struct復制到子進程中。

5)copy_signal(clone_flags, p);如果clone_flags設置了CLONE_THREAD(是線程),則增加父進程的sighand引用計數;否則(創建的必定是子進程)將父進程的sighand_struct復制到子進程中。

6)copy_mm(clone_flags, p);如果clone_flags設置了CLONE_VM,則將子進程的mm指針和active_mm指針都指向父進程的mm指針所指結構;否則將父進程的mm_struct結構復制到子進程中,然后修改當中屬于子進程而有別于父進程的信息(如頁目錄)。

7)copy_io(clone_flags, p);如果clone_flags設置了CLONE_IO,則子進程的tsk->io_context為current->io_context;否則給子進程創建一份新的io_context。

8)copy_thread_tls(clone_flags, stack_start, stack_size, p, tls);其中需要重點關注copy_mm和copy_thread_tls這兩個步驟,copy_mm進行內存地址空間的拷貝,copy_thread_tls進行棧的分配。

1.寫時復制

copy_mm的主要工作就是進行子進程內存地址空間的拷貝,在copy_mm函數中,假如clone_flags參數中包含CLONE_VM,則父子進程共享同一地址空間;否則會為子進程新創建一份地址空間,代碼如下:

if (clone_flags & CLONE_VM) {    // vfork場景下,父子進程共享虛擬地址空間
    atomic_inc(&oldmm->mm_users);
    mm = oldmm;
    goto good_mm;
}
retval = -ENOMEM;
mm = dup_mm(tsk);
if (! mm)
    goto fail_nomem;

dup_mm函數雖然給進程創建了一個新的內存地址空間(關于進程地址空間的概念會在第3章再進行深入分析),但在復制過程中會通過copy_pte_range調用copy_one_pte函數進行是否啟用寫時復制的處理,代碼如下:

if (is_cow_mapping(vm_flags)) {
    ptep_set_wrprotect(src_mm, addr, src_pte);
    pte = pte_wrprotect(pte);
}

如果采用的是寫時復制(Copy On Write),若將父子頁均置為寫保護,即會產生缺頁異常。缺頁異常最終會調用do_page_fault, do_page_fault進而調用handle_mm_fault。一般所有的缺頁異常均會調用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最終會調用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);
}

即在缺頁異常中,如果遇到寫保護,則會調用do_wp_page,這里面會處理上面所說的寫時復制中父子進程區分的問題。

最后通過圖1-8來說明fork和vfork在地址空間分配上的區別。

圖1-8 fork和vfork的區別

2.進程棧的分配

copy_process中另一個比較重要的函數就是copy_thread_tls,在創建子進程的過程中,進程的內核棧空間是隨進程同時分配的,結構如圖1-9所示。代碼如下:

圖1-9 進程的內核棧空間分配

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);實際上就是childregs = ((struct pt_regs *) (THREAD_SIZE + (unsigned long) p)) -1;,也就是說,childregs指向的地方是:子進程的棧頂再減去一個sizeof(struct pt_regs)的大小。

1.2.2 進程創建之后

通過上面的分析我們知道,不管是fork還是vfork,創建一個進程最終都是通過do_fork函數來實現的。

在進程剛剛創建完成之后,子進程和父進程執行的代碼是相同的,并且子進程從父進程代碼的fork返回處開始執行,這個代碼可以參考copy_thread_tls函數的實現:

childregs->ax = 0;
p->thread.ip = (unsigned long) ret_from_fork;

同時可以發現,上面代碼返回的pid為0。

假如創建出來的子進程只是和父進程做一樣的事情,那能做的事情就很有限了,所以Linux另外提供了一個系統調用execve,該調用可以替換掉內存當中的現有程序,以達到執行新邏輯的目的。execve的實現在/linux-4.5.2/fs/exec.c文件中,下面簡單來分析它的實現,該系統調用聲明為:

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函數最終調用了do_execveat_common,下面是其流程的說明:

1)file = do_open_execat(fd, filename, flags);打開可執行文件。

2)初始化用于在加載二進制可執行文件時存儲與其相關的所有信息的linux_binprm數據結構:bprm_mm_init(bprm);,其中會初始化一份新的mm_struct給該進程使用。

3)prepare_binprm(bprm);從文件inode中獲取信息填充binprm結構,檢查權限,讀取最初的128個字節(BINPRM_BUF_SIZE)。

4)將運行所需的參數和環境變量收集到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);該過程調用search_binary_handler加載可執行文件。

注意

Linux可執行文件的裝載和運行必須遵循ELF(Executable and Linkable Format)格式的規范,關于可運行程序的裝載是個獨立的話題,這里不再進行展開。大家有興趣可以閱讀《程序員的自我修養:鏈接、裝載與庫》。

1.2.3 內核線程和進程的區別

前面我們介紹了內核線程的概念,現在來分析Linux對內核線程的實現,在Linux中,創建內核線程可以通過create_kthread來實現,其代碼如下:

static void create_kthread(struct kthread_create_info *create)
{
    int pid;
...
    pid = kernel_thread(kthread, create, CLONE_FS | CLONE_FILES | SIGCHLD);
...
}

kernel_thread也會和fork一樣最終調用_do_fork函數,所以該函數的實現在/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);
}

通過這個函數可以創建內核線程,運行一個指定函數fn。

但是這個fn是如何運行的呢?為什么do_fork函數的stack_start和stack_size參數變成了fn和arg呢?

繼續往下看,因為我們知道do_fork函數最終會調用copy_thread_tls。在內核線程的情況下,代碼如下:

if (unlikely(p->flags & PF_KTHREAD)) {
    //  內核線程
    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; //  函數
    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設置成了ret_from_kernel_thread,函數指針傳遞給了bx寄存器,參數傳遞給了bp寄存器。

然后繼續來看ret_from_kernel_thread做了些什么:

ENTRY(ret_from_kernel_thread)
    pushl %eax
    call   schedule_tail
    GET_THREAD_INFO(%ebp)
    popl   %eax
    pushl $0x0202                           //  重置內核eflags寄存器
    popfl
    movl   PT_EBP(%esp), %eax
    call  *PT_EBX(%esp)                    // 這里就是調用fn的過程
    movl   $0, PT_EAX(%esp)
…
    movl     %esp, %eax
    call     syscall_return_slowpath
    jmp       restore_all
ENDPROC(ret_from_kernel_thread)

通過對內核線程的分析可以發現,內核線程的地址空間和父進程是共享的(CLONE_VM),它也沒有自己的棧,和整個內核共用同一個棧,另外,可以自己指定回調函數,允許線程創建后執行自己定義好的業務邏輯。可以通過ps-fax命令來觀察內核線程,下面顯示了執行ps-fax命令的結果,在[]號中的進程即為內核線程:

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庫函數中,pthread庫用于創建用戶線程,其代碼在libc目錄下的nptl中。該函數的聲明為:

int __pthread_create_2_1 (pthread_t *newthread,
    const pthread_attr_t *attr,
    void *(*start_routine) (void *), void *arg);

libc庫為了考慮不同系統兼容性問題,里面有一堆條件編譯信息,這里忽略了這些信息,就寫了簡單地調用pthread庫創建一個線程來測試:

#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命令生成可執行文件后用strace來跟蹤系統調用:

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產生的結果,可以得到pthread創建線程的流程,大概如下:

1)mmap分配用戶空間的棧大小。

2)mprotect設置內存頁的保護區(大小為4KB),這個頁面用于監測棧溢出,如果對這片內存有讀寫操作,那么將會觸發一個SIGSEGV信號。

3)通過clone調用創建線程。

通過對pthread分析,我們也可以知道用戶線程的堆棧可以通過mmap從用戶空間自行分配。

分析Linux中對進程和線程創建的幾個系統調用可發現,創建時最終都會調用do_fork函數,不同之處是傳入的參數不同(clone_flags),最終結果就是進程有獨立的地址空間和棧,而用戶線程可以自己指定用戶棧,地址空間和父進程共享,內核線程則只有和內核共享的同一個棧,同一個地址空間。當然不管是進程還是線程,do_fork最終會創建一個task_struct結構。

1.3 進程的調度

在一個CPU中,同一時刻最多只能支持有限的進程或者線程同時運行(這取決于CPU核數量),但是在一個運行的操作系統上往往可以運行很多進程,假如運行的進程占據CPU進程時間很長,就有可能讓其他進程餓死。為了解決這種問題,操作系統引入了進程調度器來進行進程的切換,目的是輪流讓其他進程獲取CPU資源。

1.3.1 進程調度機制的架構

在每個進程運行完畢時,系統可以進行調度的工作,但是系統不可能總是在進程運行完才調度,不然其他進程估計還沒被調度就餓死了。系統還需要一個重要的機制:中斷機制,來周期性地觸發調度算法進行進程的切換。

Linux進程的切換是通過schedule函數來完成的,其主要邏輯由_schedule函數實現:

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();          // 獲取當前CPU的id
    rq = cpu_rq(cpu);
    rcu_note_context_switch();        // 標識當前CPU發生任務切換,通過RCU更新狀態
    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); // 選取下一個將要執行的進程
    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);  //進行進程上下文切換
        cpu = cpu_of(rq);
    } else {
        lockdep_unpin_lock(&rq->lock);
        raw_spin_unlock_irq(&rq->lock);
    }
    balance_callback(rq);
}

_schedule執行過程主要分為以下幾個階段:

1)關閉內核搶占,初始化一部分變量。獲得當前CPU的ID號,并賦值給局部變量CPU。使rq指向CPU對應的運行隊列(runqueue)。標識當前CPU發生任務切換,通知RCU更新狀態,如果當前CPU處于rcu_read_lock狀態,當前進程將會放入rnp->blkd_tasks阻塞隊列,并呈現在rnp->gp_tasks鏈表中。(關于RCU機制,在第2章中介紹)。關閉本地中斷,獲取所要保護的運行隊列(runqueue)的自旋鎖(spinlock),為查找可運行進程做準備。

2)檢查prev的狀態。如果不是可運行狀態,而且沒有在內核態被搶占,就應該從運行隊列中刪除prev進程。但是,如果它是非阻塞掛起信號,而且狀態為TASK_INTER-RUPTIBLE,函數就把該進程的狀態設置為TASK_RUNNING,并將它插入到運行隊列。

3)task_on_rq_queued(prev)將pre進程插入到運行隊列的隊尾。

4)pick_next_task選取下一個將要執行的進程。

5)context_switch(rq, prev, next)進行進程上下文切換。

通過上述步驟可以發現,調度無非就是找一個已有的進程,然后進行上下文切換,并讓它執行而已。

注意

挑選next進程的過程相對復雜,分析起來也比較麻煩,限于篇幅和時間有限,暫時不介紹具體挑選的調度算法實現,這里僅介紹Linux調度的架構,圖1-10是Linux的調度架構圖。

圖1-10 調度的架構圖

Linux調度架構的核心概念如下:

1)rq:可運行的隊列,每個CPU對應一個,包含自旋鎖、進程數量、用于公平調度的CFS信息結構、當前正在運行的進程描述符等。實際的進程隊列用紅黑樹來維護(通過CFS信息結構來訪問)。

2)cfs_rq:cfs調度的運行隊列信息,包含紅黑樹的根結點、正在運行的進程指針、用于負載均衡的葉子隊列等。

3)sched_entity:把需要調度的東西抽象成調度實體,調度實體可以是進程、進程組、用戶等。這里包含負載權重值、對應紅黑樹結點、虛擬運行時vruntime等。

4)sched_class:把調度策略(算法)抽象成調度類,包含一組通用的調度操作接口,將接口和實現分離。你可以根據這組接口實現不同的調度算法,使得一個Linux調度程序可以有多個不同的調度策略。

1.3.2 進程切換的原理

在挑選完next進程之后,就開始準切換到next進程。

可以將進程理解為正在利用CPU工作的任務。因為在系統中同時運行的進程有很多,CPU不能僅僅被同一個進程使用,所以,這時候就需要進程切換機制,另外,假如某進程的工作大部分為I/O操作,占用CPU空跑會導致資源浪費,這樣的進程需要主動放棄CPU。

需要進程切換的場景有以下幾種:

?該進程分配的CPU時間片用完。

?該進程主動放棄CPU(例如IO操作)。

?某一進程搶占CPU獲得執行機會。

Linux并沒有使用x86 CPU自帶的任務切換機制,而是通過手工的方式實現了切換,切換過程通過以下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" // 從棧恢復EBP
        "popfl\n"                           // 從棧恢復flags
        //  asm內嵌匯編的輸出參數
        [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內嵌匯編的輸入參數
            [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寄存器中的數據到進程本身的堆棧。

2)保存堆棧指針ebp寄存器地址。

3)把堆棧寄存器esp的地址保存到prev->thread.sp中。

4)把next->thread.sp的地址送入到sp寄存器中,這個時候其實已經跑在新的next進程的上下文中了。

5)把當前的eip地址保存到prev->thread.ip中。

6)pushfl把next->thread.ip的地址壓入到當前堆棧中。

7)通過jmp__switch_to指令,不管__switch_to做了什么,ret返回地址之前已經被設置成了next->thread.ip的地址,所以將會執行之前在copy_thread_tls中設置的ret_from_fork。

通過這個過程,可以了解到在Linux中,我們并沒有對TSS進行特殊處理,而是每個CPU持有唯一一份TSS,它的作用也僅僅是在權限級做躍遷的時候保存堆棧上下文,可以通過圖1-11理解進程切換機制。

圖1-11 進程切換原理圖

注意

關于x86架構CPU的任務切換機制,可以參考閱讀《Intel開發手冊》,可以從Intel官網下載。另外,本人也編寫了代碼來模擬兩個進程切換的過程,供大家參考,便于加深理解:https://github.com/lingqi1818/analysis_linux/tree/master/ch01/test03關于asm內嵌匯編語法可以參考:https://www.ibm.com/developerworks/cn/linux/sdk/assemble/inline/index.html

1.3.3 調度中的CPU親和度

我們已經知道,進程創建出來后在內核中的數據結構為task_struct,該結構中有掩碼屬性cpus_allowed,這個掩碼由n位組成,與CPU中的每個邏輯核心一一對應。具有4個核的CPU可以有4位。假如CPU啟用了超線程,那么剛才這個CPU就有一個8位的掩碼,進程可以運行在掩碼位設置為1的CPU上。

Linux內核API提供了兩個系統調用,讓用戶可以修改位掩碼或查看當前的位掩碼:

? sched_setaffinity():用來修改位掩碼。

? sched_getaffinity():用來查看當前的位掩碼。

這兩個調用實現的僅僅就是修改或者獲取cpus_allowed的值。

在下次task被喚醒的時候,select_task_rq_fair根據cpu_allowed里的掩碼來確定將其置于哪個CPU的運行隊列,一個進程在某一時刻只能存在于一個CPU的運行隊列里。

在Nginx中,就使用了CPU親和度來完成某些場景的工作:

worker_processes       4;
worker_cpu_affinity 0001001001001000;

上面這個配置說明了4個工作進程中的每一個和一個CPU核掛鉤。

worker_processes       2;
worker_cpu_affinity 01011010;

上面這個配置則說明了兩個工作進程中的每一個和2個核掛鉤。

看Nginx的實現,核心函數為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。

再結合Nginx文檔中的例子和Nginx的源碼來看:

worker_processes     4;
worker_cpu_affinity 0001001001001000;

如果這個內容寫入Nginx的配置文件中,然后Nginx啟動或者重新加載配置的時候,若worker_process是4,就會啟用4個worker,然后把worker_cpu_affinity后面的4個值當作4個cpu affinity mask,分別調用ngx_setaffinity,然后就把4個worker進程分別綁定到CPU0~3上。

1.4 在應用程序中管理進程和線程

在了解了Linux對進程和線程的實現之后,我們首要的目的還是要學習如何在實際應用程序開發中使用這些技術,不同的應用程序實現了不同的進程或線程的管理模型,而每一種模型的背后,都體現了作者對業務的理解和場景化的考慮。下面我們介紹兩種不同軟件的管理模型。

1.4.1 Memcached線程池模型分析

Memcached是一款服務器內存管理軟件,它主要是由pthread創建的用戶工作線程池模型來處理主要邏輯的,圖1-12是Memcached的線程模型圖。

圖1-12 Memcached線程模型圖

其主要概念如下:

? mthread主線程,主要用于監聽socket事件,并建立連接,然后把連接和相應的事件分發到cq連接隊列中(每個分線程都擁有一個連接隊列)。

? cthread分線程,用于從連接隊列中獲取連接的讀寫事件,并進行業務邏輯的處理工作。

從Memcached的線程池初始化邏輯中我們可以發現,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();
    …
    // 在設置完libevent后,創建線程
    for (i = 0; i < nthreads; i++) {
        create_worker(worker_libevent, &threads[i]);
    }
    // 等待,直到所有線程設置完畢并返回
    pthread_mutex_lock(&init_lock);
    wait_for_thread_registration(nthreads);
    pthread_mutex_unlock(&init_lock);
}

Memcached在創建工作線程的時候,同樣會用pipe調用創建管道,用于和主線程之間的通信。

create_worker函數最終通過pthread_create來創建工作線程:

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);
    }
}

該模型假設在業務邏輯繁忙,并且I/O開銷比較大的情況下,多線程模型能提高系統的吞吐率。但缺點是當多線程同時訪問同一數據的時候就存在競爭,需要額外的并發解決開銷(比如鎖)。另外其實Memcached大部分操作都是基于內存的讀寫,應該速度很快,引入并發反而在競爭中存在效率降低的風險,另外假如系統中線程數量開得太多,那么線程切換的開銷也會上升,需要根據實際場景謹慎設置線程池的大小。而Redis的作者認為內存的操作速度是很快的,所以實現了單線程的服務器模型,在下一章介紹并發的時候再詳細介紹。

1.4.2 Nginx進程模型分析

剛才介紹的Memcached是比較經典的服務器線程池模型,比如老牌靜態服務器軟件Apache就是采用這樣的模型,而Nginx的作者則對該模型進行了改進。

Nginx只要創建CPU核心數量相等的工作進程,即可滿足高并發、高吞吐量的需求,原因是它的每個工作進程都持有一個基于I/O多路復用的epoll池子(見圖1-13),這樣每個進程只有在事件被觸發的場景下才進行工作,否則就會讓出CPU進行其他事件的處理,特別是在upstream的場景下,工作進程可以悠閑地等待后端數據準備好之后再進行工作,CPU的利用率也大大提升。

圖1-13 Nginx工作進程模型

在Nginx中master進程通過fork調用派生完子進程后,又通過socketpair創建了管道來進行父子進程之間的通信。

通過了解Memcached和Nginx的線程池和工作進程模型,我們發現有多種選擇,既多線程與單線程,線程池模型與工作進程模型,選擇哪種模型好?答案不是絕對的,需要根據業務場景具體分析后,找到問題的癥結在哪里,才能給出具體的答案。

1.5 處理進程和線程的相關工具

在了解了Linux進程和線程的實現后,在具體的開發和運維場景下如何駕馭它們呢?下面我們簡單介紹幾個工具,在Linux下可用于調試、追蹤系統調用并進行性能分析。

1.5.1 開發環境調試線程

當使用gdb調試C程序的時候,比如Nginx、Nginx的子進程都是fork出來的,所以當開發完并定義模塊設置斷點調試的時候,默認是無法進入斷點的,gdb提供了調試線程的方法。

跟蹤子進程:

(gdb)set follow-fork-mode child

跟蹤父進程:

(gdb)set follow-fork-mode parent

設置gdb在fork時詢問跟蹤哪一個進程:

(gdb)set follow-fork-mode ask

根據以上方法進行設置之后,我們就可以在相應的線程實現處設置斷點并進行跟蹤了。

1.5.2 進程崩潰調試方法

在C程序崩潰的時候往往會留下coredump文件,供我們分析問題到底出在哪里。下面我們用一個Nginx崩潰的場景來分析如何調試coredump文件。

曾經遇到個問題,因為后端領取獎品的接口存在并發操作,有可能會出現超領的情況,但是在分析請求日志的過程中發現,在Tomcat中,出現了2條領取記錄,并且已經成功,但是,在Nginx中卻只有一條記錄。感覺很奇怪,困擾了很久。

不過在分析Nginx的error.log的時候,發現了一些蛛絲馬跡:

[alert] 92648#0: worker process 22459 exited on signal 11

原來在被多領取的時候,還發生過Nginx的woker進程退出的情況。signal 11也就是SIGSEGV信號,說明有非法內存訪問的情況。

那么,為什么會有這樣的問題呢?難道編寫的Nginx模塊中有潛在的bug?于是在Nginx配置中,設置打開coredump的功能:

worker_rlimit_core 500m;
working_directory /tmp;

然后,用gdb來調試產生的coredump文件:

gdb /usr/sbin/nginx core.23161

發現問題出在get_root_domain函數,但是由于Nginx沒有debug信息,無法獲取具體文件和行號,查看Nginx官方文檔,編譯的時候產生debug調試信息可以如下操作:“編譯器需要使用正確的參數。假如你使用的是GCC, -g參數,會在代碼編譯后加入調試信息,另外,你需要禁用編譯器優化,通過使用-O0參數,可以讓調試器輸出容易看懂的信息。”

我們可以重新編譯Nginx:

CFLAGS="-g -O0" ./configure ....

然后,重新執行:

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;
...

我們發現domain指針指向的是0x8這個地址,這么低的地址理論上應該是系統保護的地址,不能被程序訪問,那么domain是如何來的呢?

domain_len = get_root_domain(&domain, &r->headers_in.host->value);

返現直接取的是HTTP協議頭中的host信息。

然后再看了一下當時的錯誤信息,有一些獲取驗證碼的請求是用httpclient構建的,不是通過瀏覽器發起的請求,那么host信息必然是空的。所以導致這個空指針的異常。

而在當時Nginx的進程退出,正好影響了正常的請求,導致返回的時候沒打印日志以及吐數據給客戶就掛了。

最后程序對這種情況做了兼容,修復了這個詭異的問題。

1.5.3 strace工具

strace是Linux提供的一個工具,常用來跟蹤進程執行時的系統調用和所接收的信號。比如:

[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)                                       = ?

以上代碼每一行都是一個系統調用,等號左邊是系統調用的函數名及其參數,右邊是該調用的返回值。

strace的具體參數含義可以通過man指令來查詢,比如我們常用的-c參數可以統計每一次系統調用所執行的時間、次數和出錯的次數等:

[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的實現(關于kprobe網上資料較多,大家可以自行研究),其功能非常強大,可以監控內核和用戶程序。

下面以實際場景為例,來監控運行中程序指定函數的調用參數值。

#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秒鐘會調用一次print,我要是想知道print的輸入參數是什么,那么編寫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)); }

運行結果為:

stap -g test.stp

這樣就可以獲得監控的結果了,因為我用到了內嵌C來獲取字符串的值,所以就需要加上-g參數。

1.5.5 DTrace工具

DTrace是Oracle旗下的一款基于Linux的監控程序,它可以基于D語言編寫腳本來實現你想要的監控功能,由于功能比較復雜,這里不做過多闡述,大家可以有興趣到下面的網址了解更多信息:

? 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

下面的腳本用于監控指定pid下指定的系統調用是否發生:

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進程:

localhost:src chenke$ ./redis-server

其pid為:

chenke 7276 0.0 0.024650801892 s003 S+ 12:32下午 0:00.01 ./redis-server *:6379

現在進行寫入操作:

localhost:~ chenke$ telnet localhost 6379
Trying ::1...
Connected to localhost.
Escape character is '^]'.
set a 1
+OK

我們來看監控腳本產生的數據如下:

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語言,而且DTrace的好處是可以監控用戶態的程序。

1.6 本章小結

進程和線程是計算機發展歷史上為解決特定問題而產生的解決方案。本章開頭介紹了進程管理的歷史,以及實現原理,特別是內核線程、用戶線程和協程,只有了解了這些原理,才能更好地編寫應用程序。

隨后介紹了Linux對進程和線程的實現,還介紹了內核對進程和線程的調度機制,調度機制的好壞決定了一個操作系統是否能流暢響應不同用戶的實時請求。

我們平時使用了很多開源的軟件,進程和線程的模型是服務器實現必須要考慮的問題,在了解了原理以及Linux的實現后,再對Memcached和Nginx相關模型進行分析,有助于更好地理解進程和線程。

最后,通過對gdb、coredump、strace、SystemTap、DTrace等工具的介紹,有助于我們進行開發調試、故障診斷、監控分析等,建議大家可以多動手實踐。

主站蜘蛛池模板: 遂平县| 屏东县| 张家界市| 柳州市| 宜宾市| 忻城县| 冷水江市| 大连市| 博乐市| 礼泉县| 五大连池市| 宁强县| 隆子县| 永福县| 崇信县| 行唐县| 中卫市| 临海市| 洛南县| 密山市| 临沭县| 新和县| 泊头市| 万年县| 贵德县| 西和县| 绥中县| 远安县| 丽江市| 抚宁县| 大英县| 武鸣县| 安化县| 湘潭市| 巨野县| 淅川县| 灯塔市| 福海县| 澄江县| 崇州市| 潜山县|