这篇文章是介绍一下线程与栈相关的话题,文章比较长,主要会聊聊下面这些话题:
- 进程与线程的本质区别,线程与内存共享
- Linux pthread 与 Guard 区域
- Hotspot 线程栈的 Guard 区域实现原理
- 你可能没有怎么听说过的 Yellow-Zone、Red-Zone
- Java 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 指定
- 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 内存的写入只是修改自己的内存副本,父进程看不到这一修改。
编译运行结果如下。
$ ./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 参数:
可以看到,线程创建的本质是共享进程的虚拟内存、文件系统属性、打开的文件列表、信号处理,以及将生成的线程加入父进程所属的线程组中。