前段时间和同事一块追一个多线程下栈溢出的bug,究其原因是引用的外部静态库中某个局部的字符串buffer太大导致(1MB),而程序中反复review并没有申请太大的栈空间,当时有怀疑栈空间本身太小,最后通过修改静态库的该buffer大小定义解决,问题看似解决,实则反应对进程和线程的理解不扎实,代码中潜在的问题并没有彻底消除(后来在分析thrift nonblockserver源码时发现是因为默认调用创建IO线程栈指定了大小为1MB),由此本文讨论Linux 下fork或exec调用创建的进程对应栈以及clone调用创建的线程栈。
先从操作系统的角度来复习下基础知识,进程是操作系统进行资源分配的基本单位,线程是CPU进行调度和分派的基本单位。我们通常意义下的进程包括:由二进制代码组成的应用程序、单线程、分配给该应用程序的一组资源(如内存、文件等),一个linux下进程对应的代码中并没有通过NPTL pthred_create显示调用创建线程实际看做只有一个主线程,one thread‘s process,通常多线程编程下默认是依附于该进程的众多线程(当然也可以分离detach);再回过来看栈这个说法,wikipedia上叫callstack调用栈,并不是孤立的有进程栈这个说法,进程只是在分配时通过如随机化方式指定了栈底的初始地址,进程的子线程们有各自的私有栈,可以共享父进程分配的堆内存空间,只有一个主线程的进程也就是有主线程对应的栈,所以栈这个说法通常只有线程栈,并没有明确的进程栈之说,那就更没有所谓的进程栈大小了。
我们通常使用ulimit -s可以看到”进程对应栈“的大小(现代linux系统下通常是8MB大小),不论在只有一个主线程的进程中,还是包含许多子线程的进程,都是指线程栈大小,默认8MB,pthread_create创建的线程就是对应这个值。
进程栈:
一、进程栈
进程栈,就是进程地址空间当中的栈区!
进程栈的初始化大小是由编译器和链接器计算出来的,Linux内核会根据入栈情况对栈区进行动态增长(其实也就是添加新的页表)。进程栈是有最大值的,此值由rlimit的值进行限制,可通过shell命令ulimit -s
查看,默认是8Mb。关于rlimit,可以查看其linux手册!
【扩展阅读】:进程栈的动态增长实现
进程在运行的过程中,通过不断向栈区压入数据,当超出栈区容量时,就会耗尽栈所对应的内存区域,这将触发一个 缺页异常 (page fault)。通过异常陷入内核态后,异常会被内核的 expand_stack()
函数处理,进而调用 acct_stack_growth()
来检查是否还有合适的地方用于栈的增长。
如果栈的大小低于 RLIMIT_STACK
(通常为8MB),那么一般情况下栈会被加长,程序继续执行,感觉不到发生了什么事情,这是一种将栈扩展到所需大小的常规机制。然而,如果达到了最大栈空间的大小,就会发生 栈溢出(stack overflow),进程将会收到内核发出的 段错误(segmentation fault) 信号。
动态栈增长是唯一一种访问未映射内存区域而被允许的情形,其他任何对未映射内存区域的访问都会触发页错误,从而导致段错误。一些被映射的区域是只读的,因此企图写这些区域也会导致段错误。
如今的内核是如何处理的需要阅读源码,但是原理应该大差不多
线程栈:
2. 线程栈
从 Linux 内核的角度来说,其实它并没有线程的概念(这句话是错误的,仅仅是实现上)。Linux 把所有线程都当做进程来实现,它将线程和进程不加区分的统一到了 task_struct 中。线程仅仅被视为一个与其他进程共享某些资源的进程,而是否共享地址空间几乎是进程和 Linux 中所谓线程的唯一区别。线程创建的时候,加上了 CLONE_VM 标记,这样 线程的内存描述符 将直接指向 父进程的内存描述符。
Linux可以说很好的贯彻了,线程是任务的调度单位,进程是资源分配单位的理念,在实现上,其实是只有task的概念,并对应了task_struct
结构,而进程其实是一个task组,也就是他是一组task的集合,所有的task共享内存资源,即mm_struct,其中task组的第一个task,我们称之为task组的leader,即主线程!(这个讲的更好)
因此线程栈,其实是通过mmap在文件映射区申请的匿名页。
此外需要注意的是,线程的栈是不能动态增长的,指定了多大,用完就完了!当然了,默认情况下是和主线程保持一致,都是8Mb
虽然线程的地址空间和进程一样,但是对待其地址空间的 stack 还是有些区别的。对于 Linux 进程或者说主线程,其 stack 是在 fork 的时候生成的,实际上就是复制了父亲的 stack 空间地址,然后写时拷贝 (cow) 以及动态增长。然而对于主线程生成的子线程而言,其 stack 将不再是这样的了,而是事先固定下来的,使用 mmap 系统调用(实际上是进程的堆的一部分),它不带有 VM_STACK_FLAGS 标记。这个可以从 glibc 的nptl/allocatestack.c 中的 allocate_stack() 函数中看到:
点击(此处)折叠或打开
由于线程的 mm->start_stack 栈地址和所属进程相同,所以线程栈的起始地址并没有存放在 task_struct 中,应该是使用 pthread_attr_t 中的 stackaddr 来初始化 task_struct->thread->sp(sp 指向 struct pt_regs 对象,该结构体用于保存用户进程或者线程的寄存器现场)。这些都不重要,重要的是,线程栈不能动态增长,一旦用尽就没了,这是和生成进程的 fork 不同的地方。由于线程栈是从进程的地址空间中 map 出来的一块内存区域,原则上是线程私有的。但是同一个进程的所有线程生成的时候浅拷贝生成者的 task_struct 的很多字段,其中包括所有的 vma,如果愿意,其它线程也还是可以访问到的,于是一定要注意。
3. 进程栈和线程栈大小的调整
进程和线程的栈分别是多大呢?首先从我们熟悉的ulimit -s说起,熟悉linux的人都应该知道通过ulimit -s可以修改栈的大小,除此之外还有getrlimit/setrlimit两个函数:
可以看出,线程默认栈大小和进程栈大小的关系:
- 如果ulimit(setrlimit)设置大小大于16k,则线程栈默认大小由ulimit(setrlimit)决定;
- 如果ulimit(setrlimit)设置大小小于16k,则线程栈默认大小为16;
- 如果ulimit(setrlimit)设置大小为无限制,则线程栈默认大小为2M;
所以我们如果使用ulimit设置进程栈大小是无限大其实栈大小反而相对比较小,这是为什么呢?前面我们已经讲过线程栈和进程栈的位置不同,线程栈其实是在进程的堆上分配的,并且不会动态增加,所以不可能设置一个无限大小的线程栈。
最后,我们再对进程栈和线程栈做一下总结和说明:
- ulimit -s决定进程栈的大小,但不是严格相等(实际测试稍大于ulimit -s设置);
- 创建线程时如果通过pthread_attr_setstacksize设置了线程栈大小,则使用该属性创建的线程栈大小就为其设置的值,但不影响线程默认属性的栈大小值,也不影响ulimit -s的值。
- 线程一旦创建就无法在修改其栈大小了,即使使用setrlimit。
- pthread_attr_setstacksize/pthread_attr_getstacksize的作用是获取和设置线程属性中的栈大小的,而不获取设置线程栈大小的。可以再创建前设置好线程属性,这样使用该属性创建线程就能影响线程的栈大小了。但通过pthread_attr_init,pthread_attr_getstacksize是无法获取当前线程栈大小的,只能获取默认属性的线程栈大小,其值未必就是当前线程栈大小。
以
参考:
https://zhuanlan.zhihu.com/p/344350873
https://www.jianshu.com/p/8f98b6e69063
原创文章,作者:ItWorker,如若转载,请注明出处:https://blog.ytso.com/288517.html