線程模型的綜述
本文首先介紹了一些線程基礎,比如并發、并行、內存分配、系統調用、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)
- 在App層面,開發者不需要自己寫系統調用,系統會提供相關C標準庫的SDK供開發者使用,比如開發者調用
getTime()
時,實際是使用了標準庫的time.h
頭文件。 - 代碼在執行時,OS自動加載標準庫。比如在android的bionic庫中,實際執行getTime的系統調用是這里的平臺相關的匯編代碼,將系統調用的ID、參數傳入內核。
- 內核通過系統調用ID進行表的索引,尋找真正的硬件調用函數
- 進行硬件相關的調用
在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 tableCLONE_SIGHAND
: 共享signal handlersCLONE_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線程的包裝與映射,自己本身只是做了點微小的工作,特點如下:
- 移植性較差,需要適配各種libc庫,但是由于被OS直接管理,因此在分配任務上可以充分借用內核的高效調度,能夠高效利用物理核并實現真正的并行。
- 用戶態與內核態切換有一定的消耗損失
2. 純用戶態實現(1:N)
將線程的調度在用戶態實現,也稱green thread
,自己寫調度算法,可以將一個native線程映射為多個app thread(這里也可以叫做線程包),這里的代表有Ruby(1.8-),Java等老版本,特點如下:
- 移植性好,沒有切換、映射到內核的損失
- 需要自己維護Scheduler
- 由于內核并不了解調度細節,很難進行多核利用
3. 混合實現(M:N)
可以同時運行M個kernel線程下管理N個app線程,比如golang。通過設置GOMAXPROCS
個native線程,然后通過go
關鍵詞創建app線程,它的特點如下:
- 調度器實現比較困難
- 通過語法糖與管道簡化了并發編程,切換損失低
- 部分調度需要自己主動釋放時間片
golang threading model(N)
↓
↓ goroutine
↓
Kernal thread model(M)
詳見libtask與許式偉的《go語言編程》
總結
- Concurrent是多個任務同時進行,而Parallels是分享時間片
- 在啟動一個程序后,將分配用戶態與內核態任務,通過系統調用執行內核中的高權限任務
- POSIX是一種線程標準,或者是一種接口,由libc庫實現
- 線程與進程最大的區別在于
clone
操作時的flag不同,導致共享資源不同。最終創建、切換耗時不同;以及內存分配、內部通信復雜度不同。 - 在Java中,
java.lang.Thread
與內核線程一一對應;在某些舊版語言中,實現了一個內核線程對應多個高層線程;在golang中,通過goroutine
實現M個內核線程對應N個高層線程;
Ref
- https://www.zhihu.com/question/21461752
- https://blog.codinghorror.com/understanding-user-and-kernel-mode/
- http://stackoverflow.com/questions/1311402/differences-between-user-and-kernel-modes
- https://zh.wikipedia.org/wiki/%E5%BF%99%E7%A2%8C%E7%AD%89%E5%BE%85
- https://www.ibm.com/developerworks/cn/linux/l-system-calls/
文/BlackSwift(簡書)