這篇文章是介紹一下線程與棧相關的話題,文章比較長,主要會聊聊下面這些話題:
Hotspot 線程棧的 Guard 區域實現原理你可能沒有怎麼聽說過的 Yellow-Zone、Red-ZoneJava StackOverflowError 的實現原理為了講清楚線程與棧的關係,我們要從進程和線程之間的關係講起,接下來開始第一部分。
第一部分:老生常談之進程線程網上很多文章都說,線程比較輕量級 lightweight,進程比較重量級,首先我們來看看這兩者到底的區別和聯繫在哪裡。
clone 系統調用在上層看來,進程和線程的區別確實有天壤之別,兩者的創建、管理方式都非常不一樣。在 linux 內核中,不管是進程還是線程都是使用同一個系統調用 clone,接下來我們先來看看 clone 的使用。為了表述的方便,接下來暫時用進程來表示進程和線程的概念。
clone 函數的函數籤名如下。
int clone(int (*fn)(void *),
void *child_stack,
int flags,
void *arg, ...
/* pid_t *ptid, struct user_desc *tls, pid_t *ctid */ );參數釋義如下:
第一個參數 fn 表示 clone 生成的子進程會調用 fn 指定的函數,參數由第四個參數 arg 指定flags 參數非常關鍵,正是這個參數區分了生成的子進程與父進程如何共享資源(內存、打開文件描述符等)剩下的參數,ptid、tls、ctid 與線程實現有關,這裡先不展開接下來我們來看一個實際的例子,看看 flag 對新生成的「進程」行為的影響。
clone 參數的影響接下來演示 CLONE_VM 參數對父子進程行為的影響,這段代碼當運行時的命令行參數包含 "clone_vm" 時,給 clone 函數的 flags 會增加 CLONE_VM。代碼如下。
static int child_func(void *arg) {
char *buf = (char *)arg;
// 修改 buf 內容
strcpy(buf, "hello from child");
return 0;
}
const int STACK_SIZE = 256 * 1024;
int main(int argc, char **argv) {
char *stack = malloc(STACK_SIZE);
int clone_flags = 0;
// 如果第一個參數是 clone_vm,則給 clone_flags 增加 CLONE_VM 標記
if (argc > 1 && !strcmp(argv[1], "clone_vm")) {
clone_flags |= CLONE_VM;
}
char buf[] = "msg from parent";
if (clone(child_func, stack + STACK_SIZE, clone_flags, buf) == -1) {
exit(1);
}
sleep(1);
printf("in parent, buf:\"%s\"\n", buf);
return 0;
}上面的代碼在 clone 調用時,將父進程的 buf 指針傳遞到 child 進程中,當不帶任何參數時,CLONE_VM 標記沒有被設置,表示不共享虛擬內存,父子進程的內存完全獨立,子進程的內存是父進程內存的拷貝,子進程對 buf 內存的寫入只是修改自己的內存副本,父進程看不到這一修改。
編譯運行結果如下。
$ ./clone_test
in parent, buf:"msg from parent"可以看到 child 進程對 buf 的修改,父進程並沒有生效。
再來看看運行時增加 clone_vm 參數時結果:
$ ./clone_test clone_vm
in parent, buf:"hello from child"可以看到這次 child 進程對 buf 修改,父進程生效了。當設置了 CLONE_VM 標記時,父子進程會共享內存,子進程對 buf 內存的修改也會直接影響到父進程。
講這個例子是為後面介紹進程和線程的區別打下基礎,接下來我們來看看進程和線程的本質區別是什麼。
進程與 clone以下面的代碼為例。
pid_t gettid() {
return syscall(__NR_gettid);
}
int main() {
pid_t pid;
pid = fork();
if (pid == 0) {
printf("in child, pid: %d, tid:%d\n", getpid(), gettid());
} else {
printf("in parent, pid: %d, tid:%d\n", getpid(), gettid());
}
return 0;
}使用 strace 運行輸出結果如下:
clone(child_stack=NULL,
flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD,
child_tidptr=0x7f75b83b4a10) = 16274可以看到 fork 創建進程對應 clone 使用的 flags 中唯一需要值得注意的 flag 是 SIGCHLD,當設置這個 flag 以後,子進程退出時,系統會給父進程發送 SIGCHLD 信號,讓父進程使用 wait 等函數獲取到子進程退出的原因。
可以看到 fork 調用時,父子進程沒有共享內存、打開文件等資源,這樣契合進程是資源的封裝單位這個說法,資源獨立是進程的顯著特徵。接下來我們來看看線程與 clone 的關係。
線程與 clone這裡以一段最簡單的 C 代碼來看看創建一個線程時,底層到底發生了什麼,代碼如下。
#include <pthread.h>
#include <unistd.h>
#include <stdio.h>
void *run(void *args) {
sleep(10000);
}
int main() {
pthread_t t1;
pthread_create(&t1, NULL, run, NULL);
pthread_join(t1, NULL);
return 0;
}使用 gcc 編譯上面的代碼
gcc -o thread_test thread_test.c -lpthread然後使用 strace 執行 thread_test,系統調用如下所示。
mmap(NULL, 8392704, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0) = 0x7fefb3986000
clone(child_stack=0x7fefb4185fb0, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID, parent_tidptr=0x7fefb41869d0, tls=0x7fefb4186700, child_tidptr=0x7fefb41869d0) = 12629
mprotect(0x7fefb3986000, 4096, PROT_NONE) = 0比較重要的是下面這些 flags 參數:
標記含義CLONE_VM共享虛擬內存CLONE_FS共享與文件系統相關的屬性CLONE_FILES共享打開文件描述符表CLONE_SIGHAND共享對信號的處置CLONE_THREAD置於父進程所屬的線程組中可以看到,線程創建的本質是共享進程的虛擬內存、文件系統屬性、打開的文件列表、信號處理,以及將生成的線程加入父進程所屬的線程組中。
值得注意的是 mmap 申請的內存大小不是 8M 而是 8M + 4K
8392704 = 8 * 1024 * 1024 + 4096為什麼會多這 4K,我們接下來的第二部分線程與棧中會詳細闡述。
第二部分:線程與棧前面內容中,我們看到通過 strace 查看線程創建過程中的 8M 的棧大小,實際上會分配多 4k 的空間,這是一個很有意思的問題,我們來詳細看看。
線程與 Guard 區域線程的棧是一個比較「奇怪」的產物,一方面線程的棧是線程獨有,裡面保存了線程運行狀態、局部變量、函數調用等信息。另外一方面,從資源管理的角度而言,所有線程的棧都屬於進程的內存資源,線程和父進程共享資源,進程中其它線程自然可以修改任意線程的棧內存。
以下面的代碼為例,這段代碼創建了兩個線程 t1、t2,對應的運行函數是 runnable1 和 runnable2。t1 線程將 buf 數組的地址複製給全局指針 p,t1 線程每隔 1s 列印一次 buf 數組的內容,t2 線程每隔 3s 修改一次 p 指針指向地址的內容。
#include <pthread.h>
#include <unistd.h>
#include <string.h>
#include <stdio.h>
static char *p;
void *runnable1(void *args) {
char buf[10] = {0};
p = buf;
while (1) {
printf("buffer: %s\n", buf);
sleep(1);
}
}
void *runnable2(void *args) {
int index = 0;
while (1) {
if (p) {
strcpy(p, index++ % 2 == 0 ? "say hello" : "say world");
}
sleep(3);
}
}
int main() {
pthread_t t1, t2;
pthread_create(&t1, NULL, runnable1, NULL);
pthread_create(&t2, NULL, runnable2, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
return 0;
}編譯運行上面的代碼,結果輸出如下
$ ./thread_stack_test
buf:
buf:
buf:
buf: say hello
buf: say hello
buf: say hello
buf: say world
buf: say world
buf: say world
buf: say hello
buf: say hello可以看到線程 2 直接修改了線程 1 棧中數組的內容。這種行為是 linux 中完全合法,不會報任何錯誤。如果可以這麼隨意的訪問到其它線程的內容是一個非常危險的事情,比如棧越界,將會造成其它線程的數據錯亂。
為了能減緩棧越界帶來的影響,作業系統引入了 stack guard 的概念,就是給每個線程棧多分配一頁(4k)或多頁內存,這片內存不可讀、不可寫、不可執行,只要訪問就會造成段錯誤。
我們以一個實際的例子來看棧越界,代碼如下所示。
static void *thread_illegal_access(void *arg) {
sleep(1);
char p[1];
int i;
for (i = 0; i < 1024; ++i) {
printf("[%d] access address: %p\n", i, &p[i * 1024]);
p[i * 1024] = 'a';
}
}
static void *thread_nothing(void *arg) {
sleep(1000);
return NULL;
}
int main() {
pthread_t t1;
pthread_t t2;
pthread_create(&t1, NULL, thread_nothing, NULL);
pthread_create(&t2, NULL, thread_illegal_access, NULL);
char str[100];
sprintf(str, "cat /proc/%d/maps > proc_map.txt", getpid());
system(str);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
return 0;
}編譯上面的 c 文件,使用 strace 執行,部分系統調用如下所示。
// thread 1
mmap(NULL, 8392704,
PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK,
-1, 0) = 0x7f228d615000
mprotect(0x7f228d615000, 4096, PROT_NONE) = 0
clone(child_stack=0x7f228de14fb0, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID, parent_tidptr=0x7f228de159d0, tls=0x7f228de15700, child_tidptr=0x7f228de159d0) = 9696
// thread 2
mmap(NULL, 8392704,
PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0) = 0x7f228ce14000
mprotect(0x7f228ce14000, 4096, PROT_NONE) = 0
clone(child_stack=0x7f228d613fb0, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID, parent_tidptr=0x7f228d6149d0, tls=0x7f228d614700, child_tidptr=0x7f228d6149d0) = 9697在 linux 中,一個線程棧的默認大小是 8M(8388608),但是這裡 mmap 分配的內存塊大小卻是 8392704(8M+4k),這裡多出來的 4k 就是 stack guard 大小。
分配了 8M+4k 的內存以後,隨即使用 mprotect 將剛分配的內存塊的 4k 地址的權限改為了 PROT_NONE, PROT_NONE 表示拒絕所有訪問,不可讀、不可寫、不可執行。第二個線程創建的過程一模一樣,這裡不再贅述,兩個線程的內存布局如下所示。
$ ./thread_test
[0] access address: 0x7ffff6feef0b
[1] access address: 0x7ffff6fef30b
[2] access address: 0x7ffff6fef70b
[3] access address: 0x7ffff6fefb0b
[4] access address: 0x7ffff6feff0b
[5] access address: 0x7ffff6ff030b
[1] 18133 segmentation fault ./thread_test我們可以看到最後 access 導致段錯誤的地址是 0x7ffff6ff030b,這個地址正好位於線程 1 的 guard 區域內。最後一個合法的範圍還處於 t2 的線程棧的合法區域中,如下所示。
Java 線程棧溢出是如何處理的前面介紹過,Linux 的線程通過 4k 的 Guard 區域實現了棧溢出的簡單預防,只要讀寫 Guard 區域就會出現段錯誤。那有沒有想過 Java 是如何處理棧溢出的呢?
Java 線程的棧溢出時,進程不會退出,StackOverflowError 異常還可以被捕獲,程序可以繼續運行,以下面的代碼為例。
public class ThreadStackTest0 {
private static void foo(int i) {
foo(i + 1);
}
public static void main(String[] args) throws IOException {
System.out.println("in main");
new Thread(new Runnable() {
@Override
public void run() {
foo(0);
}
}).start();
System.in.read();
}
}編譯運行上面的代碼,可以看到
$ javac ThreadStackTest0.java; java -cp . ThreadStackTest0
in main
Exception in thread "Thread-0" java.lang.StackOverflowError
at ThreadStackTest0.foo(ThreadStackTest0.java:8)
at ThreadStackTest0.foo(ThreadStackTest0.java:8)
// ...首先解決第一個疑惑,Java 的普通線程有沒有 Linux 原生線程的那種 4k 的 Guard 區域呢?
首先來說答案,答案是沒有的。Hotspot 源碼中創建線程的代碼在 os_linux.cpp 中,
bool os::create_thread(Thread* thread, ThreadType thr_type, size_t stack_size) {
// ...
// glibc guard page
pthread_attr_setguardsize(&attr, os::Linux::default_guard_size(thr_type));
// ...
}guard 區域的大小是由 os::Linux::default_guard_size 這個方法確定的,這個方法的內部實現比較簡單,判斷線程的類型是不是普通的 Java 線程,如果是的話,guard 的大小設置為 0,如果不是則設置為 4k。
什麼是 Java 的普通線程呢?除了用戶手動 new Thread() 方式創建的 java 線程,其實還有不少 JVM 運行需要的額外的輔助線程,比如 GC 線程、編譯線程、watcher 線程等。從源碼調試的結果可以看到,對於 Java 線程,guard 區域大小被設置為 0,其他類型的線程都被設置為默認的 4k。
不要高興的太早,沒有 Linux 原生線程標準的 guard 區域,不代表 Java 線程沒有自己實現。實際上 Hotspot 不光自己接管了 Guard 區域,它還實現了兩個,一個叫 Yellow Zone,一個叫 Red Zone,如下所示。
java_thread_stack其中 Yellow Zone 的默認大小為 8k,可以通過 -XX:StackRedPages 來指定,Red Zone 的默認大小為 4k,可以通過 -XX:StackRedPages 來指定。這 12k 的權限都是 PROT_NONE,也就是不可讀不可寫不可執行,讀寫這一塊區域都會觸發 Segmentation Fault(SIGSEGV),JVM 為了能自己處理棧溢出異常,它處理了 SIGSEGV 這個信號。
接下來介紹一下這兩塊區域:
Yellow zone:這一塊區域是用來處理可恢復的棧溢出的,當棧溢出發生在這一塊區域時,會把這 8k 的內存區域的權限改為可讀可寫,隨後 JVM 會拋出 StackOverflowError 異常,StackOverflowError 這個異常應用層可以被捕獲進行處理。當異常拋出處理完以後,這 8k 內存區域的權限又會恢復為不可讀、不可寫、不可執行的狀態。Red zone:這一塊的區域是用來處理不可恢復的棧溢出的,算是線程棧最後的防線了。這個區域的棧溢出,JVM 會視為致命錯誤,進程會退出並生成 hs_err_pid.log 文件。當棧溢出在這個區域時,會首先把這 4k 的權限改為可讀可寫,以便留一些棧空間生成 hs_err_pid.log 文件。完整的代碼見 os_linux_x86.cpp,如下所示。
extern "C" JNIEXPORT int
JVM_handle_linux_signal(int sig, siginfo_t* info, void* ucVoid, int abort_if_unrecognized) {
// Handle ALL stack overflow variations here
if (sig == SIGSEGV) {
address addr = (address) info->si_addr;
// 檢查發生段錯誤的地址是不是在棧內存的有效範圍內 [stack_base-stack_size, stack_base]
if (addr < thread->stack_base() &&
addr >= thread->stack_base() - thread->stack_size()) {
// stack overflow
// 發生段錯誤的地址處於 yellow 區域
if (thread->in_stack_yellow_zone(addr)) {
// 先把 yellow zone 的 8k 權限改為可讀可寫,以便調用拋出 STACK_OVERFLOW 異常
thread->disable_stack_yellow_zone();
if (thread->thread_state() == _thread_in_Java) {
// Throw a stack overflow exception. Guard pages will be reenabled
// while unwinding the stack.
stub = SharedRuntime::continuation_for_implicit_exception(thread, pc, SharedRuntime::STACK_OVERFLOW);
} else {
// Thread was in the vm or native code. Return and try to finish.
return 1;
}
} else if (thread->in_stack_red_zone(addr)) { // 如果地址在 red zone
// Fatal red zone violation. Disable the guard pages and fall through
// to handle_unexpected_exception way down below.
// 先 disable 掉 red zone,把權限改為可讀可寫,方便留出 4k 的棧給生成 hs_err_pid.log 文件的代碼使用
thread->disable_stack_red_zone();
tty->print_raw_cr("An irrecoverable stack overflow has occurred.");
// This is a likely cause, but hard to verify. Let's just print
// it as a hint.
tty->print_raw_cr("Please check if any of your loaded .so files has "
"enabled executable stack (see man page execstack(8))");
} else {
}
}
}
Java 線程棧的大小最小是多少?這是一個比較有意思的問題,之前也沒有怎麼多想過,只知道默認的棧大小為 1M,那我們隨便試一下:
可以看到在我的 64 位 Centos7 系統上,這個值為棧大小最小要指定 228k,這個值怎麼來的呢?我們來看看源碼。
os::Linux::min_stack_allowed = MAX2(os::Linux::min_stack_allowed,
(size_t)(StackYellowPages+StackRedPages+StackShadowPages) * Linux::page_size() +
(2*BytesPerWord COMPILER2_PRESENT(+1)) * Linux::vm_default_page_size());其中 MAX2 函數表示取兩個入參的最大值,os::Linux::min_stack_allowed 的值為 64k,StackYellowPages=2,StackRedPages=1,StackShadowPages=20,Linux::page_size() 的值為 4k,BytesPerWord=8,Linux::vm_default_page_size() 的值為 8k。
min_stack_allowed = max(64k, (2 + 1 + 20) * 4k + (2 * 8 + 1) * 8k)
= max(64k, 228k) = 228k在 Mac 上 Xss 的最小值為 160k,它的計算規則有一點不太一樣,源碼如下:
os::Bsd::min_stack_allowed = MAX2(os::Bsd::min_stack_allowed,
(size_t)(StackYellowPages+StackRedPages+StackShadowPages+
2*BytesPerWord COMPILER2_PRESENT(+1)) * Bsd::page_size());計算的過程也就是:
min_stack_allowed = (2 + 1 + 20 + 16 + 1) * 4k = 160k
小結這篇文章希望你能夠了解到下面這些知識:
進程與線程的生成,底層都是由 clone 系統調用生成進程與線程的一大區別在於進程擁有各自獨立的進程資源,線程則是共享進程的資源linux 線程棧的默認大小為 8M,除了線程棧的內存,每個線程還會額外多 4k 的 guard 區域防止棧溢出JHotspot 的普通線程的 guard 區域大小為 0,不過自己接管了 Guard 區域的實現Hotspot 通過 Yellow-Zone、Red-Zone 這兩個區域和自定義 SIGSEGV 信號的處理實現了棧溢出的處理JVM 的 XSS 最小值與平臺相關,具體的算法可以參考上面的內容