線程模型的綜述

ElvinMontgo 8年前發布 | 18K 次閱讀 線程

本文首先介紹了一些線程基礎,比如并發、并行、內存分配、系統調用、POSIX線程。接著通過strace分析了線程與進程的區別。最后以Android、Golang等線程模型進行了分析。

基礎

1. 什么是并發(Concurrent),什么是并行(Parallels)?

并發指同時進行多個計算任務。

 

并行指通過切換時間片模擬進行多個計算任務。

 

詳細可以參考Difference between concurrent programming and parallel programming - stackoverflow

2. OS下的內存分配、用戶區與內核區

在32位的Linux操作系統中,當一個進程啟動后,將被分配4G的虛擬內存。內存可以分為兩個空間,一個是用戶空間(0~3G),另一個是內核空間(3G~4G)。其中用戶空間就是代碼運行的空間,比如堆棧、BSS(未初始化數據段)、DATA(已經初始化數據段)、TEXT(代碼二進制段);而在內核空間中,是OS內核的映射,只有在執行syscall系統調用時,才能進行重寫。

線程模型的綜述

32 Bit OS Virtual Memory

在用戶態中,執行用戶代碼,比如直接運行C程序、或者運行JVM虛擬機等。

在內核中,主要負責I/O(顯示,層三以下的網絡,FS),Memory(虛擬內存,頁面替換/緩存), Process(信號、線程/進程管理,CPU調度)的管理,直接控制CPU、內存等硬件,權限(privilege)非常大;

3. 系統調用中斷(SCI)

系統調用是用戶與內核間的一個樁(stub),當在用戶態執行高權限任務,需要通過系統調用切換入內核態去執行最底層任務。比如在C語言中調用getTime()時,大致流程如下

1. app method(User Application)
    |
    |調用stdlibc標準庫
    |
2. systemcall_stub(std libc)
    |
    |系統調用,進入內核態
    |
3. system_call_table[call_number](Kernel)
    |
    |通過查表調用硬件函數
    |
4. hardware_call(Kernel)
  1. 在App層面,開發者不需要自己寫系統調用,系統會提供相關C標準庫的SDK供開發者使用,比如開發者調用getTime()時,實際是使用了標準庫的time.h頭文件。
  2. 代碼在執行時,OS自動加載標準庫。比如在android的bionic庫中,實際執行getTime的系統調用是這里的平臺相關的匯編代碼,將系統調用的ID、參數傳入內核。
  3. 內核通過系統調用ID進行表的索引,尋找真正的硬件調用函數
  4. 進行硬件相關的調用

在Mac下打開ActivityManager或者在Terminal中運行top,就可以顯示地看到用戶與系統的CPU占用

線程模型的綜述

User and Kernel CPU usage

4. POSIX線程模型

POSIX是IEEE P1003.1中的線程標準,目前所有的系統,甚至windows都支持POSIX。它提供了用戶態下的線程編程接口,開發者在進行線程開發時,只用引用pthread.h頭文件調用即可。程序在運行時通過系統調用,在內核中進行線程的實現。它有很多函數,比如create, exit, join, yield等,具體可以去各個平臺下的libc源碼/sdk中去看Header文件中方法的定義,比如android中使用biolibc中pthread.h的代碼在這里,這里的頭文件是對內核線程的包裝。

線程與進程的區別

以下特指32位下使用glibc的Linux系統中POSIX模型,即用戶面模型

本測試基于Ubuntu 14.04 i386

1. 測試代碼設計

1.1. 線程測試代碼

//modified from https://computing.llnl.gov/tutorials/pthreads/samples/hello.c

//todo run:
//clang -Wall -g pthread.c -o pthread.out -lpthread
//strace -Cfo ./pthread.strace.log ./pthread.out

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

void*
PrintHello(void *threadid)
{
   long tid;
   tid = (long)threadid;
   printf("Hello World! It's me, thread #%ld!\n", tid);
   pthread_exit(NULL);
}

int 
main(int argc, char *argv[]){
   pthread_t thread;
   int rc = 0;
   long t = 0;
   printf("In main: creating thread %ld\n", t);
   rc = pthread_create(&thread, NULL, PrintHello, (void *)t);
   if (rc){
     exit(-1);
   }
}

1.2. 進程測試代碼

//todo run:
//clang -Wall -g fork.c -o fork.out
//strace -Cfo ./fork.strace.log ./fork.out

#include <unistd.h>

int 
main(int argc, char *argv[])
{
  pid_t pid;
  pid = fork();
  if(pid < 0){
    return -1;
  }    
  return 0;
}

2. 測試結果

調用strace命令后,結果如下

2.1. 進程的strace路線如下

19948 execve("./fork.out", ["./fork.out"], [/* 68 vars */]) = 0
19948 brk(0)                            = 0x9bc000
19948 open("/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
19948 read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\320\37\2\0\0\0\0\0"..., 832) = 832
.....
19948 clone(child_stack=0, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7f5adac4ca10) = 19949
....
19949 +++ exited with 0 +++

2.2. 線程的strace路線如下

21958 execve("./pthread.out", ["./pthread.out"], [/* 68 vars */]) = 0
21958 open("/lib/x86_64-linux-gnu/libpthread.so.0", O_RDONLY|O_CLOEXEC) = 3
....
21958 access("/etc/ld.so.nohwcap", F_OK) = -1 ENOENT (No such file or directory)
21958 open("/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
21958 read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\320\37\2\0\0\0\0\0"..., 832) = 832
21958 fstat(3, {st_mode=S_IFREG|0755, st_size=1845024, ...}) = 0
21958 mmap(NULL, 3953344, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f34229e4000
....
21958 clone(child_stack=0x7f34229e2fb0, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID, parent_tidptr=0x7f34229e39d0, tls=0x7f34229e3700, child_tidptr=0x7f34229e39d0) = 21959
....
21958 +++ exited with 0 +++

3. 測試結論

通過上述的調用棧分析,可以得知均是通過調用x86_64-linux-gnu下的libc庫,接著通過systemcall函數clone()實現對內核Process的控制,主要區別在函數參數中FLAG上有所不同,clone_flag指定了可以共享的資源

//clone flag between thread and process
//??: 省略了`CLONE_`前綴

//進程的FLAG參數
flags=CHILD_CLEARTID|CHILD_SETTID|SIGCHLD

//線程的FLAG參數
flags=VM|FS|FILES|SIGHAND|THREAD|SYSVSEM|SETTLS|PARENT_SETTID|CHILD_CLEARTID

通過對clone進行man查詢

進程的參數解釋:

  • CLONE_CHILD_CLEARTID: Erase child thread ID at location ctid in child memory when the child exits, and do a wakeup on the futex at that address。
  • CLONE_SETTLS: thread local storage (TLS) area,注意這個不可移植
  • CLONE_SIGHAND: 共享signal handlers

線程的一些參數解釋:

  • CLONE_VM: the calling process and the child process run in the same memory space. (注意這里說的是memory space,指通過mmap()分配的內存。再多說一點,線程中的棧內存由pthread_attr_t屬性中的pthread_attr_setstacksize函數實現,默認可能為8MB,當然在實際中我們使用棧內存大多都是幾KB而已;堆內存是共享的,這里不討論)
  • CLONE_FS: 共享文件系統,如下函數chroot(2), chdir(2), or umask(2)會被影響。
  • CLONE_FILES: 共享file descriptor table
  • CLONE_SIGHAND: 共享signal handlers
  • CLONE_THREAD: 共享thread group,即有相同的PID,獨立的TID;
  • CLONE_SYSVSEM: 共享System V semaphore undo values列表,俺表示目前還不懂。
  • CLONE_SETTLS: thread local storage (TLS) area,注意這個不可移植
  • CLONE_PARENT_SETTID: Store child thread ID at location ptid in parent and child memory.
  • CLONE_CHILD_CLEARTID: Erase child thread ID at location ctid in child memory when the child exits, and do a wakeup on the futex at that address。

接著結合一些教科書,可以得知

  進程 線程
用戶層函數 fork() pthread_create()
內核實現 clone() clone()
內存 新復制的內存(Copy-on-Write),獨立4G(1G+3G) 共享4G內存:其中8M左右的棧內存是私有的,可以通過參數決定;共享堆內存
創建耗時 復制的flag少,所以耗時多
上下文切換耗時 switching the memory address 幾乎只有進出內核的損失
內部通信 IPC 共享的內存區(更簡單)

高級語言對內核線程的封裝實現

除了通過POSIX標準外,高級語言也可以自己通過系統調用對內核的線程進行實現,主要有如下三種。

1. 純內核線程實現(1:1)

此線程模型將內核線程與App線程一一對應,可以看作為一種簡單的映射關系,這里的代表有POSIX線程模型(pthread),以及依賴pThread標準庫的Java與Ruby(1.9+)線程模型。

以在Android/ArtJvm下創建線程為例,具體實現調用棧如下

java.lang.Thread
    |
POSIX thread(user mode){
    0. art.runtime.Thread::CreateNativeThread(cpp, in jvm)
    1. pthread_create(pthread.h,標準庫頭文件)
    2. bionic標準庫下的so文件,進行SystemCall(libc)
    3. 用戶態陷入內核態
}
    |
Kernal thread(kernal mode)

可以看出,在JVM下的實現主要是對POSIX線程的包裝與映射,自己本身只是做了點微小的工作,特點如下:

  1. 移植性較差,需要適配各種libc庫,但是由于被OS直接管理,因此在分配任務上可以充分借用內核的高效調度,能夠高效利用物理核并實現真正的并行。
  2. 用戶態與內核態切換有一定的消耗損失

2. 純用戶態實現(1:N)

將線程的調度在用戶態實現,也稱green thread,自己寫調度算法,可以將一個native線程映射為多個app thread(這里也可以叫做線程包),這里的代表有Ruby(1.8-),Java等老版本,特點如下:

  1. 移植性好,沒有切換、映射到內核的損失
  2. 需要自己維護Scheduler
  3. 由于內核并不了解調度細節,很難進行多核利用

3. 混合實現(M:N)

可以同時運行M個kernel線程下管理N個app線程,比如golang。通過設置GOMAXPROCS個native線程,然后通過go關鍵詞創建app線程,它的特點如下:

  1. 調度器實現比較困難
  2. 通過語法糖與管道簡化了并發編程,切換損失低
  3. 部分調度需要自己主動釋放時間片
golang threading model(N)
    ↓
    ↓ goroutine
    ↓
Kernal thread model(M)

詳見libtask與許式偉的《go語言編程》

總結

  1. Concurrent是多個任務同時進行,而Parallels是分享時間片
  2. 在啟動一個程序后,將分配用戶態與內核態任務,通過系統調用執行內核中的高權限任務
  3. POSIX是一種線程標準,或者是一種接口,由libc庫實現
  4. 線程與進程最大的區別在于clone操作時的flag不同,導致共享資源不同。最終創建、切換耗時不同;以及內存分配、內部通信復雜度不同。
  5. 在Java中,java.lang.Thread與內核線程一一對應;在某些舊版語言中,實現了一個內核線程對應多個高層線程;在golang中,通過goroutine實現M個內核線程對應N個高層線程;

Ref

  1. https://www.zhihu.com/question/21461752
  2. https://blog.codinghorror.com/understanding-user-and-kernel-mode/
  3. http://stackoverflow.com/questions/1311402/differences-between-user-and-kernel-modes
  4. https://zh.wikipedia.org/wiki/%E5%BF%99%E7%A2%8C%E7%AD%89%E5%BE%85
  5. https://www.ibm.com/developerworks/cn/linux/l-system-calls/


 

文/BlackSwift(簡書)
 

 

 本文由用戶 ElvinMontgo 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!