[线程杂谈]pthread实现线程动态栈

前言: 栈的基本逻辑

进行函数调用时, 会将当前指令处的地址压入(push)到栈顶, 从函数返回时, 从栈顶弹出(pop)返回地址, 并跳转过去, 这便是栈的一个使用方法。其次, 在c语言中, 声明一个局部变量, 也会将其压入栈顶(但不是一定的, 我们也可以声明一个寄存器变量, 此时它就代表一个寄存器而不占用栈空间)。然而过深的函数调用会使得栈压入过多的内容, 声明一个较大的局部变量(例如char a[999999])也会占用大量的栈空间。因此有必要在程序中保持栈的平衡, 比如尽量规避使用递归调用, 因为它是不可控的, 会大量push而不及时pop, 使栈占用过多, 造成栈溢出的后果。一个设计良好的算法, 应该避免递归调用, 避免在一个函数中声明一个大的变量。

linux的”main线程“的栈安排

在linux中, pthread和最初的main函数代表的线程(这里我们用main线程来归纳)使用不同的栈。其中main线程的栈由系统内核维护, 可以通过getrlimit得到main线程限制, 下面的代码演示了这一点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#define _GNU_SOURCE

#include <sys/resource.h>
#include <stdio.h>

int main() {
struct rlimit stack_info;
getrlimit(RLIMIT_STACK, &stack_info);
printf("%lu %lu\n", stack_info.rlim_cur, stack_info.rlim_max);
}

/* output: 我的机子是x86_64架构, 打印的输出大约是8M, 2^64-1
8388608 18446744073709551615
*/

来观察输出, 当前使用的限制是8M, 因此我们目前的main线程最多使用8M的栈空间。如果我们要支持更大的栈空间, 可以使用setrlimit来设置当前限制, 你可以将其改为一个极大的数字(甚至大于你的电脑ram, 但要求小于rlit_max, 这个rlit_max是你最大能设置的数值)。每当栈空间不够时, 内核负责给你申请新的页表, 使你能使用更大的栈(俗称懒加载)。当你的栈空间使用已经达到了rlim_cur, 此时内核就不再为你申请新的页表, 而是直接把这个进程杀死了, 下面的程序演示了栈溢出时进程被杀死的情形。

1
2
3
4
5
6
7
8
9
#define _GNU_SOURCE

int main() {
main();
}

/* output:
'./main' terminated by signal SIGSEGV (Address boundary error)
*/

值得注意的是, setrlimit只能root用户才能使用, 如果你以普通用户的权限运行setrlimit函数, 那么这个函数将不会起作用且返回Permission Denied错误。

pthread线程的栈安排

当我们运行pthread_create时, 我们可以指定attr, 我们一般写多线程程序时直接将其指定为NULL, 表示使用默认的配置, 下面说明了一个普通的pthread线程的运行方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#define _GNU_SOURCE

#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
#include <stdlib.h>

void* new_routine(void *arg) {
while(1) {
printf("%d\n", *(int*)arg);
}
}

int main() {
int *arg = malloc(sizeof(int));
*arg = 200;
pthread_t tid;
pthread_create(&tid, NULL, new_routine, arg);
sleep(10);
}

// 结果: 打印10秒的200

那么, 以默认配置创建的pthread, 栈在哪里呢? 阅读gnu pthread源码, 可以发现, pthread创建的线程使用mmap匿名映射为其分配栈空间, 且分配的栈空间较小。阅读linux的内存安排相关文章可以知道内核为mmap在内存模型中占有某部分区域, 我们知道堆从低地址处向上增长, 栈在高地址处向下增长, 而mmap映射区就在它们之间, 下面的图片可以看的很清楚。

linux进程的内存安排

mmap可以将文件映射到文件映射区, 这里不展开这种用法。此外它还能进行匿名映射, 这种映射专用于申请内存, 大小必须为系统页大小的整数倍, 并只有写入某个页的时候, 系统内核才会分配这个页来实现懒加载, 下面是一个实验, 说明mmap的懒加载机制。打开资源监视器, 运行程序, 查看内存占用情况。注释掉程序中标记的一行, 再运行程序查看内存占用情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#define _GNU_SOURCE

#include <stdio.h>
#include <sys/mman.h>
#include <unistd.h>
#include <stdlib.h>

// 262144个4K, 等于1G, 4k为页大小
#define SIZE ((unsigned long)262144)


int main() {
// mmap(addr, size, prot, flags, fd, offset)
// 使用mmap, 一般将addr写为NULL, 这代表由内核决定使用哪块内存
// size代表申请内存的大小, 为4k(也就是页大小)的倍数
// prot代表这块映射区域的权限保护, WRITE代表可写, READ代表可读
// MAP_ANONYMOUS|MAP_PRIVATE 代表使用匿名映射, 不过多展开, 自行百度了解mmap的具体用法
// fd, offset代表要映射的文件以及下标, 匿名映射不需要它们, 这里设置成-1和0即可
char *addr = mmap(NULL, SIZE*4096, PROT_WRITE|PROT_READ, MAP_ANONYMOUS|MAP_PRIVATE, -1, 0);
if (addr == MAP_FAILED) {
perror("mmap error");
exit(-1);
}

sleep(5);
for (size_t i = 0; i < SIZE; i++) {
if (i == SIZE/2) {
sleep(3);
}
addr[i] = 1; // 注释我
printf("%d\n", addr[i]);
}

sleep(10);

// 不注释addr[i] = 1行实验结果: 5秒后内存多占用0.5G, 再过3秒又占用0.5G, 最终总共占用1G内存
// 注释掉: 没有更多的内存占用
}

值得留意的是, mmap的第三个参数prot(代表protect), 是该段内存的访问权限, 除了可以通过mmap设置, 在mmap后, 还能通过mprotect修改。当然你可以选择修改映射的全部部分, 也可以只修改映射的一部分, 在本文的末尾, 我们将利用mprotect实现动态栈。

既然pthread能用自己申请的mmap匿名映射作为栈, 那么我们能否自己指定栈大小呢? 答案是肯定的, 下面的程序展示了这种自己设置栈的思路。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#define _GNU_SOURCE

#include <stdio.h>
#include <sys/mman.h>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
#include <pthread.h>
#include <sys/signal.h>

void do_func(int i) {
printf("%d\n", i);
do_func(i+1);
}

void *new_func(void *) {
do_func(1);
}

int main() {
// 尝试给更大的空间, 观察输出的最大数值
void *new_stack = malloc(PTHREAD_STACK_MIN*2);
printf("%p\n", new_stack);

pthread_attr_t attr;
pthread_attr_init(&attr);
pthread_attr_setstack(&attr, new_stack, PTHREAD_STACK_MIN*2);
pthread_t tid;
pthread_create(&tid, &attr, new_func, NULL);
pthread_join(tid, NULL);
}

可以使用mmapmalloc等函数获得的空间作为栈, 只要是一块可用的内存空间, 都能成为pthread的栈。

实现动态栈的思路

现在有了前面的知识储备, 我们能够开始实现动态栈了, 下面将展示如何利用页错误(SEGMENT FAULT)信号处理函数来实现栈的动态增长, 步骤如下。

  1. 使用mmap一次性映射超大内存(可以大于自身机器的ram大小, 我们这里规定栈最大为4000K, 你可以自行调大), 由此来为pthread预留足够的栈空间
  2. 使用mprotect修改之前映射的内存, 开放最顶端的一部分区域为可读写, 并将紧邻的一个页空间作为警戒区(权限设置为NONE)
  3. 创建使用该mmap的线程, attr中指定这个映射地址
  4. 线程运行程序中, 设置信号处理函数, 发生SIGSEGV时, 此信号处理函数被调用, 在此处重新调用mprotect扩大mmap可读写区域的大小, 并重新设置警戒区

下面是这种方法的代码, 运行并修改info.max_alloc_page_num = 1000;, 将1000改的更大, 观察资源消耗情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
#define _GNU_SOURCE

#include <stdio.h>
#include <sys/mman.h>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
#include <pthread.h>
#include <sys/signal.h>

struct stack_info {
// mmap获得的地址
void *mmap_addr;
// 已分配的页的数目
unsigned long alloc_page_num;
// 最多分配的页数
unsigned long max_alloc_page_num;
};

struct stack_info info;

void do_func(int i) {
printf("%d\n", i);
do_func(++i);
}

void handler(int signo, siginfo_t *siginfo, void *) {
info.alloc_page_num *= 2;
if(info.alloc_page_num > info.max_alloc_page_num) {
printf("segment overflow\n");
// 这里修改3秒, 可以观察资源管理器的内存状况
sleep(3);
exit(1);
}

// 将顶端的若干页设置为读写权限
mprotect((void*)(info.mmap_addr+(info.max_alloc_page_num+1-info.alloc_page_num)*4096), info.alloc_page_num*4096, PROT_READ|PROT_WRITE);
// 设置警戒区域, 当尝试访问此区域时, 产生SIGSEGV
mprotect((void*)(info.mmap_addr+(info.max_alloc_page_num-info.alloc_page_num)*4096), 4096, PROT_NONE);
}

void *new_routine(void *data) {
// 必须为信号处理函数设置单独的栈空间
stack_t s = {
.ss_flags = 0,
.ss_size = 4096,
.ss_sp = malloc(4096)
};
int n = sigaltstack(&s, NULL);
struct sigaction action;
action.sa_flags = SA_SIGINFO|SA_RESTART|SA_ONSTACK;
sigfillset(&action.sa_mask);
action.sa_sigaction = handler;
sigaction(SIGSEGV, &action, NULL);

do_func(0);
}

int main() {
// 最初有10页, 也就是40k
info.alloc_page_num = 10;
info.max_alloc_page_num = 1000;
// 多申请一个页, 用来作为guard
info.mmap_addr = mmap(NULL, (info.max_alloc_page_num+1)*4096, PROT_NONE, MAP_ANONYMOUS|MAP_PRIVATE,-1, 0);
// 设置栈
mprotect((void*)(info.mmap_addr+(info.max_alloc_page_num+1-info.alloc_page_num)*4096), info.alloc_page_num*4096, PROT_READ|PROT_WRITE);
// 设置警戒区域
mprotect((void*)(info.mmap_addr+(info.max_alloc_page_num+1-info.alloc_page_num-1)*4096), 4096, PROT_NONE);

pthread_attr_t attr;
pthread_attr_init(&attr);
pthread_attr_setstack(&attr, info.mmap_addr, (info.max_alloc_page_num+1)*4096);
pthread_t tid;
int n = pthread_create(&tid, &attr, new_routine, NULL);
pthread_join(tid, NULL);
}

写在最后, 一些QA

Q: 为什么要给信号处理函数单独的栈?
A: 如果不单独设置, 内核将使用程序当前的栈作为信号处理函数的栈, 然而这里已经没有空间, 内核发现后会直接杀死进程

Q: 为什么可以给mmap指定超大的空间, 甚至大于机器?
A: mmap匿名映射是懒加载的, 只有当程序尝试写入某个4k区间, 内核才会从内存中申请物理空间

Q: 把栈搞这么大, 有什么意义?
A: 没有意义, 我认为可以优化算法, 避免递归的算法, 而不是想着用更大的栈空间, 良好的程序设计, 栈总是平衡的

Q: 为什么要一次性先申请足够大的mmap匿名映射?
A: 栈的空间必须保证连续, 在一次mmap后, 操作系统不能保下一个mmap分配的内存块紧挨者第一块

参考文献