Linux操作系統學習筆記(六)進程、線程的創建和派生

一. 前言

  在前文中,我們分析了內核中進程和線程的統一結構體task_struct,本文將繼續分析進程、線程的創建和派生的過程。首先介紹如何將一個程序編輯爲執行文件最後成爲進程執行,然後會介紹線程的執行,最後會分析如何通過已有的進程、線程實現多進程、多線程。因爲進程和線程有諸多相似之處,也有一些不同之處,因此本文會對比進程和線程來加深理解和記憶。

二. 進程的創建

  以C語言爲例,我們在Linux下編寫C語言代碼,然後通過gcc編譯和鏈接生成可執行文件後直接執行即可完成一個進程的創建和工作。下面將詳細展開介紹這個創建進程的過程。在 Linux 下面,二進制的程序也要有嚴格的格式,這個格式我們稱爲 ELF(Executable and Linkable Format,可執行與可鏈接格式)。這個格式可以根據編譯的結果不同,分爲不同的格式。主要包括

  1. 可重定位的對象文件(Relocatable file)

    由彙編器彙編生成的 .o 文件

  2. 可執行的對象文件(Executable file)

    可執行應用程序

  3. 可被共享的對象文件(Shared object file)

    動態庫文件,也即 .so 文件

    下面在進程創建過程中會詳細說明三種文件。

2. 1 編譯

  寫完C程序後第一步就是程序編譯(其實還有IDE的預編譯,那些屬於編輯器操作這裏不表)。編譯指令如下所示

gcc -c -fPIC xxxx.c

  -c表示編譯、彙編指定的源文件,不進行鏈接。-fPIC表示生成與位置無關(Position-Independent Code)代碼,即採用相對地址而非絕對地址,從而滿足共享庫加載需求。在編譯的時候,先做預處理工作,例如將頭文件嵌入到正文中,將定義的宏展開,然後就是真正的編譯過程,最終編譯成爲.o 文件,這就是 ELF 的第一種類型,可重定位文件(Relocatable File)。之所以叫做可重定位文件,是因爲對於編譯好的代碼和變量,將來加載到內存裏面的時候,都是要加載到一定位置的。比如說,調用一個函數,其實就是跳到這個函數所在的代碼位置執行;再比如修改一個全局變量,也是要到變量的位置那裏去修改。但是現在這個時候,還是.o 文件,不是一個可以直接運行的程序,這裏面只是部分代碼片段。因此.o 裏面的位置是不確定的,但是必須是可重新定位的以適應需求。

img

  ELF 文件的頭是用於描述整個文件的。這個文件格式在內核中有定義,分別爲 struct elf32_hdrstruct elf64_hdr。其他各個section作用如下所示

  • .text:放編譯好的二進制可執行代碼
  • .rodata:只讀數據,例如字符串常量、const 的變量
  • .data:已經初始化好的全局變量
  • .bss:未初始化全局變量,運行時會置 0
  • .symtab:符號表,記錄的則是函數和變量
  • .rel.text: .text部分的重定位表
  • .rel.data:.data部分的重定位表
  • .strtab:字符串表、字符串常量和變量名

  這些節的元數據信息也需要有一個地方保存,就是最後的節頭部表(Section Header Table)。在這個表裏面,每一個 section 都有一項,在代碼裏面也有定義 struct elf32_shdrstruct elf64_shdr。在 ELF 的頭裏面,有描述這個文件的節頭部表的位置,有多少個表項等等信息。

2.2 鏈接

  鏈接分爲靜態鏈接和動態鏈接。靜態鏈接庫會和目標文件通過鏈接生成一個可執行文件,而動態鏈接則會通過鏈接形成動態連接器,在可執行文件執行的時候動態的選擇並加載其中的部分或全部函數。二者的各自優缺點如下所示

  • 靜態鏈接庫的優點

    (1) 代碼裝載速度快,執行速度略比動態鏈接庫快;

    (2) 只需保證在開發者的計算機中有正確的.LIB文件,在以二進制形式發佈程序時不需考慮在用戶的計算機上.LIB文件是否存在及版本問題,可避免DLL地獄等問題。

  • 靜態鏈接庫的缺點

    使用靜態鏈接生成的可執行文件體積較大,包含相同的公共代碼,造成浪費

  • 動態鏈接庫的優點

    (1) 更加節省內存並減少頁面交換;

    (2) DLL文件與EXE文件獨立,只要輸出接口不變(即名稱、參數、返回值類型和調用約定不變),更換DLL文件不會對EXE文件造成任何影響,因而極大地提高了可維護性和可擴展性;

    (3) 不同編程語言編寫的程序只要按照函數調用約定就可以調用同一個DLL函數;

    (4)適用於大規模的軟件開發,使開發過程獨立、耦合度小,便於不同開發者和開發組織之間進行開發和測試。

  • 動態鏈接庫的缺點

    使用動態鏈接庫的應用程序不是自完備的,它依賴的DLL模塊也要存在,如果使用載入時動態鏈接,程序啓動時發現DLL不存在,系統將終止程序並給出錯誤信息。而使用運行時動態鏈接,系統不會終止,但由於DLL中的導出函數不可用,程序會加載失敗;速度比靜態鏈接慢。當某個模塊更新後,如果新模塊與舊的模塊不兼容,那麼那些需要該模塊才能運行的軟件均無法執行。這在早期Windows中很常見。

  下面分別介紹靜態鏈接和動態鏈接

2.2.1 靜態鏈接

  靜態鏈接庫.a文件(Archives)的執行指令如下

ar cr libXXX.a XXX.o XXXX.o 

  當需要使用該靜態庫的時候,會將.o文件從.a文件中依次抽取並鏈接到程序中,指令如下

gcc -o XXXX XXX.O -L. -lsXXX

  -L表示在當前目錄下找.a 文件,-lsXXXX會自動補全文件名,比如加前綴 lib,後綴.a,變成libXXX.a,找到這個.a文件後,將裏面的 XXXX.o 取出來,和 XXX.o 做一個鏈接,形成二進制執行文件XXXX。在這裏,重定位會從.o中抽取函數並和.a中的文件抽取的函數進行合併,找到實際的調用位置,形成最終的可執行文件(Executable file),即ELF的第二種格式文件

img

  對比ELF第一種格式可重定位文件,這裏可執行文件略去了重定位表相關段落。此處將ELF文件分爲了代碼段、數據段和不加載到內存中的部分,並加上了段頭表(Segment Header Table)用以記錄管理,在代碼中定義爲struct elf32_phdrstruct elf64_phdr,這裏面除了有對於段的描述之外,最重要的是 p_vaddr,這個是這個段加載到內存的虛擬地址。這部分會在內存篇章詳細介紹。

2.2.2 動態鏈接

  動態鏈接庫(Shared Libraries)的作用主要是爲了解決靜態鏈接大量使用會造成空間浪費的問題,因此這裏設計成了可以被多個程序共享的形式,其執行命令如下

gcc -shared -fPIC -o libXXX.so XXX.o

  當一個動態鏈接庫被鏈接到一個程序文件中的時候,最後的程序文件並不包括動態鏈接庫中的代碼,而僅僅包括對動態鏈接庫的引用,並且不保存動態鏈接庫的全路徑,僅僅保存動態鏈接庫的名稱。

gcc -o XXX XXX.O -L. -lXXX

  當運行這個程序的時候,首先尋找動態鏈接庫,然後加載它。默認情況下,系統在 /lib/usr/lib 文件夾下尋找動態鏈接庫。如果找不到就會報錯,我們可以設定 LD_LIBRARY_PATH環境變量,程序運行時會在此環境變量指定的文件夾下尋找動態鏈接庫。動態鏈接庫,就是 ELF 的第三種類型,共享對象文件(Shared Object)。

  動態鏈接的ELF相對於靜態鏈接主要多了以下部分

  • .interp段,裏面是ld-linux.so,負責運行時的鏈接動作
  • .plt(Procedure Linkage Table),過程鏈接表
  • .got.plt(Global Offset Table),全局偏移量表

當程序編譯時,會對每個函數在PLT中建立新的項,如PLT[n],而動態庫中則存有該函數的實際地址,記爲GOT[m]。整體尋址過程如下所示

  1. PLT[n]GOT[m]尋求地址
  2. GOT[m]初始並無地址,需要採取以下方式獲取地址
    1. 回調PLT[0]
    2. PLT[0]調用GOT[2],即ld-linux.so
    3. ld-linux.so查找所需函數實際地址並存放在GOT[m]

由此,我們建立了PLT[n]GOT[m]的對應關係,從而實現了動態鏈接。

2.3 加載運行

  完成了上述的編譯、彙編、鏈接,我們最終形成了可執行文件,並加載運行。在內核中,有這樣一個數據結構,用來定義加載二進制文件的方法。

struct linux_binfmt {
        struct list_head lh;
        struct module *module;
        int (*load_binary)(struct linux_binprm *);
        int (*load_shlib)(struct file *);
        int (*core_dump)(struct coredump_params *cprm);
        unsigned long min_coredump;     /* minimal dump size */
} __randomize_layout;

  對於ELF文件格式,其對應實現爲

static struct linux_binfmt elf_format = {
        .module         = THIS_MODULE,
        .load_binary    = load_elf_binary,
        .load_shlib     = load_elf_library,
        .core_dump      = elf_core_dump,
        .min_coredump   = ELF_EXEC_PAGESIZE,
};

  其中加載的函數指針指向的函數和內核鏡像加載是同一份函數,實際上通過exec函數完成調用。exec 比較特殊,它是一組函數:

  • 包含 p 的函數(execvp, execlp)會在 PATH 路徑下面尋找程序;不包含 p 的函數需要輸入程序的全路徑;
  • 包含 v 的函數(execv, execvp, execve)以數組的形式接收參數;
  • 包含 l 的函數(execl, execlp, execle)以列表的形式接收參數;
  • 包含 e 的函數(execve, execle)以數組的形式接收環境變量。

  當我們通過shell運行可執行文件或者通過fork派生子類,均是通過該類函數實現加載。

三. 線程的創建之用戶態

  線程的創建對應的函數是pthread_create(),線程不是一個完全由內核實現的機制,它是由內核態和用戶態合作完成的。pthread_create()不是一個系統調用,是 Glibc 庫的一個函數,所以我們還要從 Glibc 說起。但是在開始之前,我們先要提一下**,線程的創建到了內核態和進程的派生會使用同一個函數:__do_fork()**,這也很容易理解,因爲對內核態來說,線程和進程是同樣的task_struct結構體。本節介紹線程在用戶態的創建,而內核態的創建則會和進程的派生放在一起說明。

  在Glibc的ntpl/pthread_create.c中定義了__pthread_create_2_1()函數,該函數主要進行了以下操作

  1. 處理線程的屬性參數。例如前面寫程序的時候,我們設置的線程棧大小。如果沒有傳入線程屬性,就取默認值。
const struct pthread_attr *iattr = (struct pthread_attr *) attr;
struct pthread_attr default_attr;
//c11 thrd_create
bool c11 = (attr == ATTR_C11_THREAD);
if (iattr == NULL || c11)
{
  ......
  iattr = &default_attr;
}
  1. 就像在內核裏每一個進程或者線程都有一個 task_struct 結構,在用戶態也有一個用於維護線程的結構,就是這個 pthread 結構。
struct pthread *pd = NULL;
  1. 凡是涉及函數的調用,都要使用到棧。每個線程也有自己的棧,接下來就是創建線程棧了。
int err = ALLOCATE_STACK (iattr, &pd);

ALLOCATE_STACK 是一個宏,對應的函數allocate_stack()主要做了以下這些事情:

  • 如果在線程屬性裏面設置過棧的大小,則取出屬性值;
  • 爲了防止棧的訪問越界在棧的末尾添加一塊空間 guardsize,一旦訪問到這裏就會報錯;
  • 線程棧是在進程的堆裏面創建的。如果一個進程不斷地創建和刪除線程,我們不可能不斷地去申請和清除線程棧使用的內存塊,這樣就需要有一個緩存。get_cached_stack 就是根據計算出來的 size 大小,看一看已經有的緩存中,有沒有已經能夠滿足條件的。如果緩存裏面沒有,就需要調用__mmap創建一塊新的緩存,系統調用那一節我們講過,如果要在堆裏面 malloc 一塊內存,比較大的話,用__mmap
  • 線程棧也是自頂向下生長的,每個線程要有一個pthread 結構,這個結構也是放在棧的空間裏面的。在棧底的位置,其實是地址最高位;
  • 計算出guard內存的位置,調用 setup_stack_prot 設置這塊內存的是受保護的;
  • 填充pthread 這個結構裏面的成員變量 stackblock、stackblock_size、guardsize、specific。這裏的 specific 是用於存放Thread Specific Data 的,也即屬於線程的全局變量;
  • 將這個線程棧放到 stack_used 鏈表中,其實管理線程棧總共有兩個鏈表,一個是 stack_used,也就是這個棧正被使用;另一個是stack_cache,就是上面說的,一旦線程結束,先緩存起來,不釋放,等有其他的線程創建的時候,給其他的線程用。
# define ALLOCATE_STACK(attr, pd) allocate_stack (attr, pd, &stackaddr)

static int
allocate_stack (const struct pthread_attr *attr, struct pthread **pdp,
                ALLOCATE_STACK_PARMS)
{
  struct pthread *pd;
  size_t size;
  size_t pagesize_m1 = __getpagesize () - 1;
......
  /* Get the stack size from the attribute if it is set.  Otherwise we
     use the default we determined at start time.  */
  if (attr->stacksize != 0)
    size = attr->stacksize;
  else
    {
      lll_lock (__default_pthread_attr_lock, LLL_PRIVATE);
      size = __default_pthread_attr.stacksize;
      lll_unlock (__default_pthread_attr_lock, LLL_PRIVATE);
    }
......
  /* Allocate some anonymous memory.  If possible use the cache.  */
  size_t guardsize;
  void *mem;
  const int prot = (PROT_READ | PROT_WRITE
                   | ((GL(dl_stack_flags) & PF_X) ? PROT_EXEC : 0));
  /* Adjust the stack size for alignment.  */
  size &= ~__static_tls_align_m1;
  /* Make sure the size of the stack is enough for the guard and
  eventually the thread descriptor.  */
  guardsize = (attr->guardsize + pagesize_m1) & ~pagesize_m1;
  size += guardsize;
......    
  /* Try to get a stack from the cache.  */  
  pd = get_cached_stack (&size, &mem);
  if (pd == NULL)
  {
    /* If a guard page is required, avoid committing memory by first
    allocate with PROT_NONE and then reserve with required permission
    excluding the guard page.  */
    mem = __mmap (NULL, size, (guardsize == 0) ? prot : PROT_NONE,
      MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0);
    /* Place the thread descriptor at the end of the stack.  */
#if TLS_TCB_AT_TP
    pd = (struct pthread *) ((char *) mem + size) - 1;
#elif TLS_DTV_AT_TP
    pd = (struct pthread *) ((((uintptr_t) mem + size - __static_tls_size) & ~__static_tls_align_m1) - TLS_PRE_TCB_SIZE);
#endif
    /* Now mprotect the required region excluding the guard area. */
    char *guard = guard_position (mem, size, guardsize, pd, pagesize_m1);
    setup_stack_prot (mem, size, guard, guardsize, prot);
    pd->stackblock = mem;
    pd->stackblock_size = size;
    pd->guardsize = guardsize;
    pd->specific[0] = pd->specific_1stblock;
    /* And add to the list of stacks in use.  */
    stack_list_add (&pd->list, &stack_used);
  }
  
  *pdp = pd;
  void *stacktop;
# if TLS_TCB_AT_TP
  /* The stack begins before the TCB and the static TLS block.  */
  stacktop = ((char *) (pd + 1) - __static_tls_size);
# elif TLS_DTV_AT_TP
  stacktop = (char *) (pd - 1);
# endif
  *stack = stacktop;
...... 
}

四. 線程的內核態創建及進程的派生

  多進程是一種常見的程序實現方式,採用的系統調用爲fork()函數。前文中已經詳細敘述了系統調用的整個過程,對於fork()來說,最終會在系統調用表中查找到對應的系統調用sys_fork完成子進程的生成,而sys_fork 會調用 _do_fork()

SYSCALL_DEFINE0(fork)
{
......
  return _do_fork(SIGCHLD, 0, 0, NULL, NULL, 0);
}

  關於__do_fork()先按下不表,再接着看看線程。我們接着pthread_create ()看。其實有了用戶態的棧,接着需要解決的就是用戶態的程序從哪裏開始運行的問題。start_routine() 就是給線程的函數,start_routine(), 參數 arg,以及調度策略都要賦值給 pthread。接下來 __nptl_nthreads 加一,說明又多了一個線程。

pd->start_routine = start_routine;
pd->arg = arg;
pd->schedpolicy = self->schedpolicy;
pd->schedparam = self->schedparam;
/* Pass the descriptor to the caller.  */
*newthread = (pthread_t) pd;
atomic_increment (&__nptl_nthreads);
retval = create_thread (pd, iattr, &stopped_start, STACK_VARIABLES_ARGS, &thread_ran);

  真正創建線程的是調用 create_thread() 函數,這個函數定義如下。同時,這裏還規定了當完成了內核態線程創建後回調的位置:start_thread()

static int
create_thread (struct pthread *pd, const struct pthread_attr *attr,
bool *stopped_start, STACK_VARIABLES_PARMS, bool *thread_ran)
{
  const int clone_flags = (CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SYSVSEM | CLONE_SIGHAND | CLONE_THREAD | CLONE_SETTLS | CLONE_PARENT_SETTID | CLONE_CHILD_CLEARTID | 0);
  ARCH_CLONE (&start_thread, STACK_VARIABLES_ARGS, clone_flags, pd, &pd->tid, tp, &pd->tid)/* It's started now, so if we fail below, we'll have to cancel it
and let it clean itself up.  */
  *thread_ran = true;
}

  在 start_thread() 入口函數中,才真正的調用用戶提供的函數,在用戶的函數執行完畢之後,會釋放這個線程相關的數據。例如,線程本地數據 thread_local variables,線程數目也減一。如果這是最後一個線程了,就直接退出進程,另外 __free_tcb() 用於釋放 pthread

#define START_THREAD_DEFN \
  static int __attribute__ ((noreturn)) start_thread (void *arg)

START_THREAD_DEFN
{
    struct pthread *pd = START_THREAD_SELF;
    /* Run the code the user provided.  */
    THREAD_SETMEM (pd, result, pd->start_routine (pd->arg));
    /* Call destructors for the thread_local TLS variables.  */
    /* Run the destructor for the thread-local data.  */
    __nptl_deallocate_tsd ();
    if (__glibc_unlikely (atomic_decrement_and_test (&__nptl_nthreads)))
        /* This was the last thread.  */
        exit (0);
    __free_tcb (pd);
    __exit_thread ();
}

  __free_tcb ()會調用 __deallocate_stack()來釋放整個線程棧,這個線程棧要從當前使用線程棧的列表 stack_used 中拿下來,放到緩存的線程棧列表 stack_cache中,從而結束了線程的生命週期。

void
internal_function
__free_tcb (struct pthread *pd)
{
  ......
  __deallocate_stack (pd);
}

void
internal_function
__deallocate_stack (struct pthread *pd)
{
  /* Remove the thread from the list of threads with user defined
     stacks.  */
  stack_list_del (&pd->list);
  /* Not much to do.  Just free the mmap()ed memory.  Note that we do
     not reset the 'used' flag in the 'tid' field.  This is done by
     the kernel.  If no thread has been created yet this field is
     still zero.  */
  if (__glibc_likely (! pd->user_stack))
    (void) queue_stack (pd);
}

  ARCH_CLONE其實調用的是 __clone()

# define ARCH_CLONE __clone

/* The userland implementation is:
   int clone (int (*fn)(void *arg), void *child_stack, int flags, void *arg),
   the kernel entry is:
   int clone (long flags, void *child_stack).

   The parameters are passed in register and on the stack from userland:
   rdi: fn
   rsi: child_stack
   rdx: flags
   rcx: arg
   r8d: TID field in parent
   r9d: thread pointer
%esp+8: TID field in child

   The kernel expects:
   rax: system call number
   rdi: flags
   rsi: child_stack
   rdx: TID field in parent
   r10: TID field in child
   r8:  thread pointer  */
        .text
ENTRY (__clone)
        movq    $-EINVAL,%rax
......
        /* Insert the argument onto the new stack.  */
        subq    $16,%rsi
        movq    %rcx,8(%rsi)

        /* Save the function pointer.  It will be popped off in the
           child in the ebx frobbing below.  */
        movq    %rdi,0(%rsi)

        /* Do the system call.  */
        movq    %rdx, %rdi
        movq    %r8, %rdx
        movq    %r9, %r8
        mov     8(%rsp), %R10_LP
        movl    $SYS_ify(clone),%eax
......
        syscall
......
PSEUDO_END (__clone)

  內核中的clone()定義如下。如果在進程的主線程裏面調用其他系統調用,當前用戶態的棧是指向整個進程的棧,棧頂指針也是指向進程的棧,指令指針也是指向進程的主線程的代碼。此時此刻執行到這裏,調用 clone的時候,用戶態的棧、棧頂指針、指令指針和其他系統調用一樣,都是指向主線程的。但是對於線程來說,這些都要變。因爲我們希望當 clone 這個系統調用成功的時候,除了內核裏面有這個線程對應的 task_struct,當系統調用返回到用戶態的時候,用戶態的棧應該是線程的棧,棧頂指針應該指向線程的棧,指令指針應該指向線程將要執行的那個函數。所以這些都需要我們自己做,將線程要執行的函數的參數和指令的位置都壓到棧裏面,當從內核返回,從棧裏彈出來的時候,就從這個函數開始,帶着這些參數執行下去。

SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
     int __user *, parent_tidptr,
     int __user *, child_tidptr,
     unsigned long, tls)
{
    return _do_fork(clone_flags, newsp, 0, parent_tidptr, child_tidptr, tls);
}

  線程和進程到了這裏殊途同歸,進入了同一個函數__do_fork()工作。其源碼如下所示,主要工作包括複製結構copy_process()和喚醒新進程wak_up_new()兩部分。其中線程會根據create_thread()函數中的clone_flags完成上文所述的棧頂指針和指令指針的切換,以及一些線程和進程的微秒區別。

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)
{
    struct task_struct *p;
    int trace = 0;
    long nr;

......
    p = copy_process(clone_flags, stack_start, stack_size,
       child_tidptr, NULL, trace, tls, NUMA_NO_NODE);
......
	if (IS_ERR(p))
		return PTR_ERR(p);
    struct pid *pid;
    pid = get_task_pid(p, PIDTYPE_PID);
    nr = pid_vnr(pid);

    if (clone_flags & CLONE_PARENT_SETTID)
      put_user(nr, parent_tidptr);
	if (clone_flags & CLONE_VFORK) {
		p->vfork_done = &vfork;
		init_completion(&vfork);
		get_task_struct(p);
	}
    wake_up_new_task(p);
......
    put_pid(pid);
	return nr;
};

4.1 任務結構體複製

  如下所示爲copy_process()函數源碼精簡版,task_struct結構複雜也註定了複製過程的複雜性,因此此處省略了很多,僅保留了各個部分的主要調用函數

static __latent_entropy struct task_struct *copy_process(
          unsigned long clone_flags,
          unsigned long stack_start,
          unsigned long stack_size,
          int __user *child_tidptr,
          struct pid *pid,
          int trace,
          unsigned long tls,
          int node)
{
    int retval;
    struct task_struct *p;
......
    //分配task_struct結構
    p = dup_task_struct(current, node);  
......
    //權限處理
    retval = copy_creds(p, clone_flags);
......
    //設置調度相關變量
    retval = sched_fork(clone_flags, p);    
......
    //初始化文件和文件系統相關變量
    retval = copy_files(clone_flags, p);
    retval = copy_fs(clone_flags, p);  
......
    //初始化信號相關變量
    init_sigpending(&p->pending);
    retval = copy_sighand(clone_flags, p);
    retval = copy_signal(clone_flags, p);  
......
    //拷貝進程內存空間
    retval = copy_mm(clone_flags, p);
...... 
    //初始化親緣關係變量
    INIT_LIST_HEAD(&p->children);
    INIT_LIST_HEAD(&p->sibling);
......
    //建立親緣關係
	//源碼放在後面說明  
};
  1. copy_process()首先調用了dup_task_struct()分配task_struct結構,dup_task_struct() 主要做了下面幾件事情:
  • 調用 alloc_task_struct_node 分配一個 task_struct結構;
  • 調用 alloc_thread_stack_node 來創建內核棧,這裏面調用 __vmalloc_node_range 分配一個連續的 THREAD_SIZE 的內存空間,賦值給 task_structvoid *stack成員變量;
  • 調用 arch_dup_task_struct(struct task_struct *dst, struct task_struct *src),將 task_struct 進行復制,其實就是調用 memcpy
  • 調用setup_thread_stack設置 thread_info
static struct task_struct *dup_task_struct(struct task_struct *orig, int node)
{
    struct task_struct *tsk;
	unsigned long *stack;
......
   	tsk = alloc_task_struct_node(node);
	if (!tsk)
		return NULL;

	stack = alloc_thread_stack_node(tsk, node);
	if (!stack)
		goto free_tsk; 
    if (memcg_charge_kernel_stack(tsk))
		goto free_stack;

	stack_vm_area = task_stack_vm_area(tsk);

	err = arch_dup_task_struct(tsk, orig);
......    
 	setup_thread_stack(tsk, orig);
......    
};    
  1. 接着,調用copy_creds處理權限相關內容
  • 調用prepare_creds,準備一個新的 struct cred *new。如何準備呢?其實還是從內存中分配一個新的 struct cred結構,然後調用 memcpy 複製一份父進程的 cred;
  • 接着 p->cred = p->real_cred = get_cred(new),將新進程的“我能操作誰”和“誰能操作我”兩個權限都指向新的 cred
/*
 * Copy credentials for the new process created by fork()
 *
 * We share if we can, but under some circumstances we have to generate a new
 * set.
 *
 * The new process gets the current process's subjective credentials as its
 * objective and subjective credentials
 */
int copy_creds(struct task_struct *p, unsigned long clone_flags)
{
	struct cred *new;
	int ret;
......
	new = prepare_creds();
	if (!new)
		return -ENOMEM;
......
	atomic_inc(&new->user->processes);
	p->cred = p->real_cred = get_cred(new);
	alter_cred_subscribers(new, 2);
	validate_creds(new);
	return 0;
}
  1. 設置調度相關的變量。該部分源碼先不展示,會在進程調度中詳細介紹。sched_fork主要做了下面幾件事情:
  • 調用__sched_fork,在這裏面將on_rq設爲 0,初始化sched_entity,將裏面的 exec_start、sum_exec_runtime、prev_sum_exec_runtime、vruntime 都設爲 0。這幾個變量涉及進程的實際運行時間和虛擬運行時間。是否到時間應該被調度了,就靠它們幾個;
  • 設置進程的狀態 p->state = TASK_NEW
  • 初始化優先級 prio、normal_prio、static_prio
  • 設置調度類,如果是普通進程,就設置爲 p->sched_class = &fair_sched_class
  • 調用調度類的 task_fork 函數,對於 CFS 來講,就是調用 task_fork_fair。在這個函數裏,先調用 update_curr,對於當前的進程進行統計量更新,然後把子進程和父進程的 vruntime 設成一樣,最後調用 place_entity,初始化 sched_entity。這裏有一個變量 sysctl_sched_child_runs_first,可以設置父進程和子進程誰先運行。如果設置了子進程先運行,即便兩個進程的 vruntime 一樣,也要把子進程的 sched_entity 放在前面,然後調用 resched_curr,標記當前運行的進程 TIF_NEED_RESCHED,也就是說,把父進程設置爲應該被調度,這樣下次調度的時候,父進程會被子進程搶佔。
  1. 初始化文件和文件系統相關變量
  • copy_files 主要用於複製一個任務打開的文件信息。
    • 對於進程來說,這些信息用一個結構 files_struct 來維護,每個打開的文件都有一個文件描述符。在 copy_files 函數裏面調用 dup_fd,在這裏面會創建一個新的 files_struct,然後將所有的文件描述符數組 fdtable 拷貝一份。
    • 對於線程來說,由於設置了CLONE_FILES 標識位變成將原來的files_struct 引用計數加一,並不會拷貝文件
static int copy_files(unsigned long clone_flags, struct task_struct *tsk)
{
	struct files_struct *oldf, *newf;
	int error = 0;

	/*
	 * A background process may not have any files ...
	 */
	oldf = current->files;
	if (!oldf)
		goto out;
	if (clone_flags & CLONE_FILES) {
		atomic_inc(&oldf->count);
		goto out;
	}
	newf = dup_fd(oldf, &error);
	if (!newf)
		goto out;

	tsk->files = newf;
	error = 0;
out:
	return error;
}
  • copy_fs 主要用於複製一個任務的目錄信息。
    • 對於進程來說,這些信息用一個結構 fs_struct 來維護。一個進程有自己的根目錄和根文件系統 root,也有當前目錄 pwd 和當前目錄的文件系統,都在 fs_struct 裏面維護。copy_fs 函數裏面調用 copy_fs_struct,創建一個新的 fs_struct,並複製原來進程的 fs_struct
    • 對於線程來說,由於設置了CLONE_FS 標識位變成將原來的fs_struct 的用戶數加一,並不會拷貝文件系統結構
static int copy_fs(unsigned long clone_flags, struct task_struct *tsk)
{
	struct fs_struct *fs = current->fs;
	if (clone_flags & CLONE_FS) {
		/* tsk->fs is already what we want */
		spin_lock(&fs->lock);
		if (fs->in_exec) {
			spin_unlock(&fs->lock);
			return -EAGAIN;
		}
		fs->users++;
		spin_unlock(&fs->lock);
		return 0;
	}
	tsk->fs = copy_fs_struct(fs);
	if (!tsk->fs)
		return -ENOMEM;
	return 0;
}
  1. 初始化信號相關變量
  • 整個進程裏的所有線程共享一個shared_pending,這也是一個信號列表,是發給整個進程的,哪個線程處理都一樣。由此我們可以做到發給進程的信號雖然可以被一個線程處理,但是影響範圍應該是整個進程的。例如,kill 一個進程,則所有線程都要被幹掉。如果一個信號是發給一個線程的 pthread_kill,則應該只有線程能夠收到。
  • copy_sighand
    • 對於進程來說,會分配一個新的 sighand_struct。這裏最主要的是維護信號處理函數,在 copy_sighand 裏面會調用 memcpy,將信號處理函數 sighand->action 從父進程複製到子進程。
    • 對於線程來說,由於設計了CLONE_SIGHAND標記位,會對引用計數加一併退出,沒有分配新的信號變量。
static int copy_sighand(unsigned long clone_flags, struct task_struct *tsk)
{
	struct sighand_struct *sig;
	if (clone_flags & CLONE_SIGHAND) {
		refcount_inc(&current->sighand->count);
		return 0;
	}
	sig = kmem_cache_alloc(sighand_cachep, GFP_KERNEL);
	rcu_assign_pointer(tsk->sighand, sig);
	if (!sig)
		return -ENOMEM;
	refcount_set(&sig->count, 1);
	spin_lock_irq(&current->sighand->siglock);
	memcpy(sig->action, current->sighand->action, sizeof(sig->action));
	spin_unlock_irq(&current->sighand->siglock);
	return 0;
}
  • init_sigpendingcopy_signal 用於初始化信號結構體,並且複製用於維護髮給這個進程的信號的數據結構。copy_signal 函數會分配一個新的 signal_struct,並進行初始化。對於線程來說也是直接退出並未複製。
static int copy_signal(unsigned long clone_flags, struct task_struct *tsk)
{
	struct signal_struct *sig;
	if (clone_flags & CLONE_THREAD)
		return 0;
	sig = kmem_cache_zalloc(signal_cachep, GFP_KERNEL);
......
     /* list_add(thread_node, thread_head) without INIT_LIST_HEAD() */
	sig->thread_head = (struct list_head)LIST_HEAD_INIT(tsk->thread_node);
	tsk->thread_node = (struct list_head)LIST_HEAD_INIT(sig->thread_head);
	init_waitqueue_head(&sig->wait_chldexit);
	sig->curr_target = tsk;
	init_sigpending(&sig->shared_pending);
	INIT_HLIST_HEAD(&sig->multiprocess);
	seqlock_init(&sig->stats_lock);
	prev_cputime_init(&sig->prev_cputime);
......
};
  1. 複製進程內存空間

    • 進程都有自己的內存空間,用 mm_struct 結構來表示。copy_mm() 函數中調用 dup_mm(),分配一個新的 mm_struct 結構,調用 memcpy 複製這個結構。dup_mmap() 用於複製內存空間中內存映射的部分。前面講系統調用的時候,我們說過,mmap 可以分配大塊的內存,其實 mmap 也可以將一個文件映射到內存中,方便可以像讀寫內存一樣讀寫文件,這個在內存管理那節我們講。
    • 線程不會複製內存空間,因此因爲CLONE_VM標識位而直接指向了原來的mm_struct
    static int copy_mm(unsigned long clone_flags, struct task_struct *tsk)
    {
    	struct mm_struct *mm, *oldmm;
    	int retval;
    ......
    	/*
    	 * Are we cloning a kernel thread?
    	 * We need to steal a active VM for that..
    	 */
    	oldmm = current->mm;
    	if (!oldmm)
    		return 0;
    	/* initialize the new vmacache entries */
    	vmacache_flush(tsk);
    	if (clone_flags & CLONE_VM) {
    		mmget(oldmm);
    		mm = oldmm;
    		goto good_mm;
    	}
    	retval = -ENOMEM;
    	mm = dup_mm(tsk);
    	if (!mm)
    		goto fail_nomem;
    good_mm:
    	tsk->mm = mm;
    	tsk->active_mm = mm;
    	return 0;
    fail_nomem:
    	return retval;
    }
    
  2. 分配 pid,設置 tidgroup_leader,並且建立任務之間的親緣關係。

  • group_leader:進程的話 group_leader就是它自己,和舊進程分開。線程的話則設置爲當前進程的group_leader
  • tgid: 對進程來說是自己的pid,對線程來說是當前進程的pid
  • real_parent : 對進程來說即當前進程,對線程來說則是當前進程的real_parent
static __latent_entropy struct task_struct *copy_process(......) {
......    
    p->pid = pid_nr(pid);
    if (clone_flags & CLONE_THREAD) {
        p->exit_signal = -1;
        p->group_leader = current->group_leader;
        p->tgid = current->tgid;
    } else {
        if (clone_flags & CLONE_PARENT)
          p->exit_signal = current->group_leader->exit_signal;
        else
          p->exit_signal = (clone_flags & CSIGNAL);
        p->group_leader = p;
        p->tgid = p->pid;
    }
......
    if (clone_flags & (CLONE_PARENT|CLONE_THREAD)) {
        p->real_parent = current->real_parent;
        p->parent_exec_id = current->parent_exec_id;
    } else {
        p->real_parent = current;
        p->parent_exec_id = current->self_exec_id;
    } 
......  
};

4.2 新進程的喚醒

  _do_fork 做的第二件大事是通過調用 wake_up_new_task()喚醒進程。

void wake_up_new_task(struct task_struct *p)
{
    struct rq_flags rf;
    struct rq *rq;
......
    p->state = TASK_RUNNING;
......
    activate_task(rq, p, ENQUEUE_NOCLOCK);
    trace_sched_wakeup_new(p);
    check_preempt_curr(rq, p, WF_FORK);
......
}

  首先,我們需要將進程的狀態設置爲 TASK_RUNNINGactivate_task() 函數中會調用 enqueue_task()

void activate_task(struct rq *rq, struct task_struct *p, int flags)
{
	if (task_contributes_to_load(p))
		rq->nr_uninterruptible--;

	enqueue_task(rq, p, flags);

	p->on_rq = TASK_ON_RQ_QUEUED;
}

static inline void enqueue_task(struct rq *rq, struct task_struct *p, int flags)
{
.....
    p->sched_class->enqueue_task(rq, p, flags);
}

  如果是 CFS 的調度類,則執行相應的 enqueue_task_fair()。在 enqueue_task_fair() 中取出的隊列就是 cfs_rq,然後調用 enqueue_entity()。在 enqueue_entity() 函數裏面,會調用 update_curr(),更新運行的統計量,然後調用 __enqueue_entity,將 sched_entity 加入到紅黑樹裏面,然後將 se->on_rq = 1 設置在隊列上。回到 enqueue_task_fair 後,將這個隊列上運行的進程數目加一。然後,wake_up_new_task 會調用 check_preempt_curr,看是否能夠搶佔當前進程。

static void
enqueue_task_fair(struct rq *rq, struct task_struct *p, int flags)
{
    struct cfs_rq *cfs_rq;
    struct sched_entity *se = &p->se;
......
	for_each_sched_entity(se) {
		if (se->on_rq)
			break;
		cfs_rq = cfs_rq_of(se);
		enqueue_entity(cfs_rq, se, flags);

		cfs_rq->h_nr_running++;
		cfs_rq->idle_h_nr_running += idle_h_nr_running;

		/* end evaluation on encountering a throttled cfs_rq */
		if (cfs_rq_throttled(cfs_rq))
			goto enqueue_throttle;

		flags = ENQUEUE_WAKEUP;
	}
......
}

  在 check_preempt_curr 中,會調用相應的調度類的 rq->curr->sched_class->check_preempt_curr(rq, p, flags)。對於CFS調度類來講,調用的是 check_preempt_wakeup。在 check_preempt_wakeup函數中,前面調用 task_fork_fair的時候,設置 sysctl_sched_child_runs_first 了,已經將當前父進程的 TIF_NEED_RESCHED 設置了,則直接返回。否則,check_preempt_wakeup 還是會調用 update_curr 更新一次統計量,然後 wakeup_preempt_entity 將父進程和子進程 PK 一次,看是不是要搶佔,如果要則調用 resched_curr 標記父進程爲 TIF_NEED_RESCHED。如果新創建的進程應該搶佔父進程,在什麼時間搶佔呢?別忘了 fork 是一個系統調用,從系統調用返回的時候,是搶佔的一個好時機,如果父進程判斷自己已經被設置爲 TIF_NEED_RESCHED,就讓子進程先跑,搶佔自己。

static void check_preempt_wakeup(struct rq *rq, struct task_struct *p, int wake_flags)
{
    struct task_struct *curr = rq->curr;
    struct sched_entity *se = &curr->se, *pse = &p->se;
    struct cfs_rq *cfs_rq = task_cfs_rq(curr);
......
    if (test_tsk_need_resched(curr))
        return;
......
    find_matching_se(&se, &pse);
    update_curr(cfs_rq_of(se));
    if (wakeup_preempt_entity(se, pse) == 1) {
        goto preempt;
    }
    return;
preempt:
    resched_curr(rq);
......
}

  至此,我們就完成了任務的整個創建過程,並根據情況喚醒任務開始執行。

五. 總結

  本文十分之長,因爲內容極多,源碼複雜,本來想拆分爲兩篇文章,但是又因爲過於緊密的聯繫因此合在了一起。本文介紹了進程的創建和線程的創建,而多進程的派生因爲使用和線程內核態創建一樣的函數因此放在了一起變對比邊說明。由此,進程、線程的結構體以及創建過程就全部分析完了,下文將繼續分析進程、線程的調度。

源碼資料

[1] kernel/fork.c

[2] glibc/nptl/pthread_create.c

參考文獻

[1] wiki

[2] elixir.bootlin.com/linux

[3] woboq

[3] Linux-insides

[4] 深入理解Linux內核

[5] Linux內核設計的藝術

[6] 極客時間 趣談Linux操作系統

150講輕鬆搞定Python網絡爬蟲

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章