Linux 内核中,多线程栈空间模型是怎样的?

2021-07-22 14:25:58 浏览数 (1)

作者:invalid s 链接:https://www.zhihu.com/question/323415592/answer/676335264 来源:知乎 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

这是进程内存空间分配/使用的基本功问题,和线程没多大关系。

对任何一个进程,它里面存在如下几个静态内存区域:

1、常量区

2、全局变量区

3、静态变量区

4、代码区

这几个区域是在执行单元载入时静态分配的,位置、大小均固定。

当进程运行起来后,产生另外两个动态区域,这就是堆和栈。

大多情况下,栈是CPU直接支持的一个内存区域。函数的局部变量便位于这个区域。

堆是一个没有严格定义的区域。一般情况下,用户手动申请/归还的内存区域都被称为堆。

对于传统的单线程模型,以上便是全部。这个模型必须搞得滚瓜烂熟,后面才好继续。


单线程模型里,函数调用是怎么回事呢?

很简单,通过CPU直接支持的栈区,自动维护“函数调用链”:

栈顶 printSth函数的局部变量 main函数里面调用printSth函数的那条指令的位置 main函数的局部变量 栈底

对于printSth函数,当它获得执行权时,只需知道当前栈顶位置,然后基于这个位置就能推断出属于自己的局部变量的位置。

当它执行结束之后,就要通过pop指令清除自己用过的局部变量,把main函数里面调用printf函数的那条指令的位置取出、然后通过ret指令跳转到下一条指令继续执行。

main() {

fun1();

printSth(...);

fun2();

}

比如,对上面这个场景,printSth执行结束后,下一条指令就是调用fun2.

如果printSth里面还调用了fun3,可依此类推:

栈顶 fun3的局部变量 printSth里面调用fun3的那条指令的位置 printSth函数的局部变量 main函数里面调用printSth函数的那条指令的位置 main函数的局部变量 栈底

这就是所谓的调用链。

只要维护好这个调用链信息,程序就可以有条不紊的按设计预想执行了。


彻底搞明白调用链如何维护之后,我们很容易想到:如果我另外再申请一块内存,把它的起始地址放进CPU的堆栈寄存器;那么,是不是就可以用这块地址另外维护一条调用链了呢?

这就是线程的原理。

所谓“新开一条线程”,实质上就是另外申请了一块内存,然后把这块内存当作堆栈,维护另外一条调用链。

而所谓“线程获得执行权”呢,实质上就是把对应线程的栈顶指针等信息载入CPU的栈指示器,使得它沿着这条调用链继续执行下去——执行一段时间,把它的栈顶指针等信息找个地方保存、然后载入另一个线程的栈顶指针等信息,这就是所谓的“线程切换”。

线程有两种。

如果维护调用链(以及执行现场)的任务全部放在用户空间,不让操作系统知道,这就叫“用户态线程”。

反之,如果操作系统自己提供了开辟新线程以及维护它的调用链的一整套方法,这就叫“内核态线程”。

两者的差别就是后者是操作系统管理的,可以得到多CPU之类的直接支持。

但在内存空间使用上,两者并无根本区别:它们都是另外申请了一块空间用作堆栈,然后像传统的单线程程序一样,用这个堆栈维护调用链(以及局部变量等信息)。

线程和进程的区别就在于,线程只有调用链,而进程还包含常量区、全局变量区等其他区域,同时还有各种资源的所有权。

换句话说,操作系统认为,诸如动态申请内存、内核对象等各种资源,哪怕是在某个线程里面申请的,它的所有权仍然属于进程所有——所以,线程退出除了会清理调用链信息外,并不释放其他资源;而进程退出就会自动归还它申请的各种资源(某些特殊资源除外:并不能盲目认为一旦进程退出一切就会变回原样)。


明白了这个之后,问题迎刃而解:

1、所有线程都是在各自独立的栈区维护的调用链(以及执行现场)

2、线程局部变量处于各自所属的栈区

3、不允许跨线程直接传递局部变量的引用/指针,因为它们随时可能失效

这点一定要注意。和单线程程序不同,跨线程传递局部变量指针给被调用者是没有丝毫保障的;传了,就一定会出事!

4、线程中取得的、进程生存期有效的资源,要么直接/间接挂载到全局变量/全局静态变量上,要么就一定要在线程结束前释放。不然就会造成资源泄露(搜索不被全局变量和局部变量索引的内存并主动释放,这正是垃圾回收的原理)。

5、线程由谁启动这个信息并不在调用链上。换句话说,所有线程都是平等的,它们各自独立使用自己的专属栈区(但主线程较为特殊,大多实现中,它的退出就意味着进程结束;除此之外,它们是平等的)。

0 人点赞