这篇文章是介绍一下线程与栈相关的话题,文章比较长,主要会聊聊下面这些话题:
为了讲清楚线程与栈的关系,我们要从进程和线程之间的关系讲起,接下来开始第一部分。
网上很多文章都说,线程比较轻量级 lightweight,进程比较重量级,首先我们来看看这两者到底的区别和联系在哪里。
在上层看来,进程和线程的区别确实有天壤之别,两者的创建、管理方式都非常不一样。在 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 */ ); 复制代码
参数释义如下:
接下来我们来看一个实际的例子,看看 flag 对新生成的「进程」行为的影响。
接下来演示 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 内存的修改也会直接影响到父进程。
讲这个例子是为后面介绍进程和线程的区别打下基础,接下来我们来看看进程和线程的本质区别是什么。
以下面的代码为例。
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 的关系。
这里以一段最简单的 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 的空间,这是一个很有意思的问题,我们来详细看看。
线程的栈是一个比较“奇怪”的产物,一方面线程的栈是线程独有,里面保存了线程运行状态、局部变量、函数调用等信息。另外一方面,从资源管理的角度而言,所有线程的栈都属于进程的内存资源,线程和父进程共享资源,进程中其它线程自然可以修改任意线程的栈内存。
以下面的代码为例,这段代码创建了两个线程 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 的线程栈的合法区域中,如下所示。
前面介绍过,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,如下所示。
其中 Yellow Zone 的默认大小为 8k,可以通过 -XX:StackYellowPages
来指定,Red Zone 的默认大小为 4k,可以通过 -XX:StackRedPages
来指定。
这 12k 的权限都是 PROT_NONE,也就是不可读不可写不可执行,读写这一块区域都会触发 Segmentation Fault(SIGSEGV),JVM 为了能自己处理栈溢出异常,它处理了 SIGSEGV 这个信号。
接下来介绍一下这两块区域:
完整的代码见 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 { } } } 复制代码
这是一个比较有意思的问题,之前也没有怎么多想过,只知道默认的栈大小为 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 复制代码
这篇文章希望你能够了解到下面这些知识:
有问题可以扫描下面的二维码关注我的公众号到联系我。