程序員的自我修養之線程與棧

這篇文章是介紹一下線程與棧相關的話題,文章比較長,主要會聊聊下面這些話題:java

  • 進程與線程的本質區別,線程與內存共享
  • Linux pthread 與 Guard 區域
  • Hotspot 線程棧的 Guard 區域實現原理
  • 你可能沒有怎麼據說過的 Yellow-Zone、Red-Zone
  • Java StackOverflowError 的實現原理

爲了講清楚線程與棧的關係,咱們要從進程和線程之間的關係講起,接下來開始第一部分。linux

第一部分:老生常談之進程線程

網上不少文章都說,線程比較輕量級 lightweight,進程比較重量級,首先咱們來看看這二者到底的區別和聯繫在哪裏。算法

clone 系統調用

在上層看來,進程和線程的區別確實有天壤之別,二者的建立、管理方式都很是不同。在 linux 內核中,不論是進程仍是線程都是使用同一個系統調用 clone,接下來咱們先來看看 clone 的使用。爲了表述的方便,接下來暫時用進程來表示進程和線程的概念。數組

clone 函數的函數簽名以下。bash

int clone(int (*fn)(void *),
          void *child_stack,
          int flags,
          void *arg, ...
          /* pid_t *ptid, struct user_desc *tls, pid_t *ctid */ );
複製代碼

參數釋義以下:ide

  • 第一個參數 fn 表示 clone 生成的子進程會調用 fn 指定的函數,參數由第四個參數 arg 指定
  • child_stack 表示生成的子進程的棧空間
  • 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 內存的寫入只是修改本身的內存副本,父進程看不到這一修改。ui

編譯運行結果以下。spa

$ ./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 區域

線程的棧是一個比較「奇怪」的產物,一方面線程的棧是線程獨有,裏面保存了線程運行狀態、局部變量、函數調用等信息。另一方面,從資源管理的角度而言,全部線程的棧都屬於進程的內存資源,線程和父進程共享資源,進程中其它線程天然能夠修改任意線程的棧內存。

如下面的代碼爲例,這段代碼建立了兩個線程 t一、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:StackYellowPages 來指定,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 最小值與平臺相關,具體的算法能夠參考上面的內容

有問題能夠掃描下面的二維碼關注個人公衆號到聯繫我。

相關文章
相關標籤/搜索