标签 Thread 下的文章

Go语言是如何处理栈的

Go 1.4Beta1刚刚发布,在Go 1.4Beta1中,Go语言的stack处理方式由之前的"segmented stacks"改为了"continuous stacks"。关于Go语言对stack的处理机制、发展历史、存在问题等,CloudFlare的一篇官方blog进行了系统的阐述,这里的内容就是 翻译自CloudFlare的那篇blog:《How Stacks are Handled in Go》。

在CloudFlare,我们使用Go语言实现各种服务和应用。在这篇博文中,我们将带领大家深入挖掘一些Go的某些纷繁复杂的技术细节。

Go语言的重要特性之一是goroutines。它们是代价低廉、协同调度的执行线程,被用于实现各种操作,诸如timeout、生成器、相互竞 争的后端程序。为了使goroutines可以适应更多地任务,我们不仅需要保证每个goroutines的内存最小占用量,还要保证人们可以使 用最低配置将它们启动起来。

为了实现这个目标,Go语言采用了栈管理,这一与其他编程语言类似的方案,但在具体实现层面,又与其他语言有着较大的不同。

一、线程栈(thread stacks)介绍

在我们研究Go的栈处理方式之前,我们先来看看传统语言,比如C是如何进行栈管理的。

当你启动一个C实现的thread时,C标准库会负责分配一块内存作为这个线程的栈。标准库分配这块内存,告诉内核它的位置并让内核处理这个线程 的执行。不过当这块内存不够用时,问题就来了,我们来看一下下面这个函数:

int a(int m, int n) {
    if (m == 0) {
        return n + 1;
    } else if (m > 0 && n == 0) {
        return a(m – 1, 1);
    } else {
        return a(m – 1, a(m, n – 1));
    }
}

这个函数大量使用递归,执行a(4, 5)就会降所有栈内存耗尽。要解决这个问题,你可以调整标准库给线程栈分配的内存块的大小。但是全线提高栈大小意味着每个线程都会提高栈的内存使用量,即 便它们不是大量采用递归方式的。这样一来,你将用光所有内存,即便你的程序还尚未使用栈上的内存。

另外一种可选的解决方法则是为每个线程单独确定栈大小。这样一来你就不得不完成这样的任务:根据每个线程的需要,估算它们的栈内存的大小。这将是 创建线程的难度超出我们的期望。想搞清楚一般情况下一个线程栈需要多少内存是不可行的,即便是通常情况也是非常困难的。

二、Go是如何应对这个问题的

Go运行时会试图按需为goroutine提供它们所需要的栈空间,而不是为每个goroutine分配一个固定大小的栈空间。这样可以把程序员 们从决定栈空间大小的烦心事中解脱了出来。不过Go核心团队正在尝试切换到另外一种方案,这里我将尝试阐述旧方案以及它的缺点,新方案以及为何要 做出如此改变。

三、分段栈(Segmented Stacks)

分段栈(segmented stacks)是Go语言最初用来处理栈的方案。当创建一个goroutine时,Go运行时会分配一段8K字节的内存用于栈供goroutine运行使 用,我们让goroutine在这个栈上完成其任务处理。

当我们用光这8K字节的栈空间后,问题随之而来。为了解决这个问题,每个go函数在函数入口处都会有一小段代码(called prologue),这段代码会检查是否用光了已分配的栈空间,如果用光了,这段代码会调用morestack函数。

morestack函数会分配一段新内存用作栈空间,接下来它会将有关栈的各种数据信息写入栈底的一个struct中(译注:下图中Stack info),包括上一段栈的地址。有点我们拥有了一个新的栈段(stack segment),我们将重启goroutine,从导致栈空间用光的那个函数(译注:下图中的Foobar)开始执行。这就是所谓的“栈分裂 (stack split)”。

下面的栈示意图刚好是我们进行栈分裂后的情形:

在新栈的底部,我们插入了一个栈入口函数lessstack。我们不会调用该函数,设置这个函数就是用于我们从那个导致我们用光栈空间的函数(译 注:Foobar)返回时用的。当那个函数(译注:Foobar)返回时,我们回到lessstack(这个栈帧),lessstack会查找 stack底部的那个struct,并调整栈指针(stack pointer),使得我们返回到前一段栈空间。这样做之后,我们就可以将这个新栈段(stack segment)释放掉,并继续执行我们的程序了。

四、分段栈(Segmented stacks)的问题

分段栈给了我们具备按需伸缩能力的栈。程序员们无需担心计算栈的大小了,启动一个新的goroutine代价低廉并且程序员不会知道栈将增长多 大。

这就是直到目前Go语言处理stack增长的方法,但是这个方法有个瑕疵。那就是栈缩小会是一个相对代价高昂的操作。如果你在一个循环遇到栈分裂 (stack split),你会最有感触。一个函数会增加栈空间,做栈分裂,返回并释放栈段(stack segment)。如果你在一个循环中进行这些,你会付出很大的代价(性能方面)。

这就是所谓的“hot split”问题。它也是Go核心开发组更换到一个新的栈管理方案-栈拷贝(stack copying)的主要原因。

五、栈拷贝(stack copying)

栈拷贝初始阶段与分段栈类似。goroutine在栈上运行着,当用光栈空间,它遇到与旧方案中相同的栈溢出检查。但是与旧方案采用的保留一个返 回前一段栈的link不同,新方案创建一个两倍于原stack大小的新stack,并将旧栈拷贝到其中。这意味着当栈实际使用的空间缩小为原先的 大小时,go运行时不用做任何事情。栈缩小是一个无任何代价的操作。此外,当栈再次增长时,运行时也无需做任何事情,我们只需要重用之前分配的空 闲空间即可。

六、栈是怎么拷贝的

拷贝栈听起来简单,但实际上它是一件有难度的事情。因为Go中栈上的变量都有自己的地址,一旦你拥有指向栈上变量的指针,这种情况下你就无法如你 所愿。当你移动栈时,指向原栈的指针都将变为无效指针。

幸运的是,只有在栈上分配的指针才能指向栈上的地址。这点对于内存安全是极其必要的,否则,程序可能会访问到已不再使用了的栈上的地址。

由于我们需要知道那些需要被垃圾收集器回收的指针的位置,因此我们知道栈上哪些部分是指针。当我们移动栈时,我们可以更新栈里地指针使其指向新的 目标地址,并且所有相关的指针都要被照顾到。

由于我们使用垃圾回收的信息来协助完成栈拷贝,因此所有出现在栈上的函数都必须具备这些信息。但事情不总是这样的。因为Go运行时的大部分代码是 用C编写的,大量的运行时调用没有指针信息可用,这样就无法进行拷贝。一旦这种情况发生,我们又不得不退回到分段栈方案,并接受为其付出的高昂代 价。

这就是当前Go运行时开发者大规模重写Go runtime的原因。那些无法用Go重写的代码,比如调度器和垃圾收集器的内核,将在一个特殊的栈上执行,这个特殊栈的size由runtime开发者 单独计算确定。

除了让栈拷贝成为可能之外,这个方法还会使得我们在未来能够实现出并发垃圾回收等特性。

七、关于虚拟内存

另外一种不同的栈处理方式就是在虚拟内存中分配大内存段。由于物理内存只是在真正使用时才会被分配,因此看起来好似你可以分配一个大内存段并让操 作系统处理它。下面是这种方法的一些问题

首先,32位系统只能支持4G字节虚拟内存,并且应用只能用到其中的3G空间。由于同时运行百万goroutines的情况并不少见,因此你很可 能用光虚拟内存,即便我们假设每个goroutine的stack只有8K。

第二,然而我们可以在64位系统中分配大内存,它依赖于过量内存使用。所谓过量使用是指当你分配的内存大小超出物理内存大小时,依赖操作系统保证 在需要时能够分配出物理内存。然而,允许过量使用可能会导致一些风险。由于一些进程分配了超出机器物理内存大小的内存,如果这些进程使用更多内存 时,操作系统将不得不为它们补充分配内存。这会导致操作系统将一些内存段放入磁盘缓存,这常常会增加不可预测的处理延迟。正是考虑到这个原因,一 些新系统关闭了对过量使用的支持。

八、结论

为了使goroutine使用代价更加低廉,更快速,适合更多task情况,Go开发组做出了很多努力。栈管理只是其中一小部分。如果你想了解更 多关于栈拷贝的细节,可以参考其设计文档。此外,如果你想了解更多有关Go运行 时重写的细节,这里有一个mail list

线程函数参数引发的问题

上午我们的一个实施组从现网发回来一封邮件,接到这种邮件一般都是报告问题的,果然不出所料,现场出现一个core,经过分析这是个由于线程函数参数存储位置不当造成的,从中我们可以总结出一些经验,以避免以后再犯。

我采用下面的一个例子来模拟问题的出现:

#include <pthread.h>
#include <errno.h>
#include <stdio.h>

typedef struct foo {
        char c[10];
        int  *p;
} foo;

void *thread_func(void *para) {
        foo     *p      = (foo*)para;
        sleep(5); //等待以让gen_thread先退出
        printf("[thr2-1]: the foo’s str is %s\n", p->c);
        printf("[thr2-1]: the foo’s p is %d\n", *(p->p));
        *(p->p) = 10;
        strcpy(p->c, "Bye, Tony");
        printf("[thr2-2]: the foo’s str is %s\n", p->c);
        printf("[thr2-2]: the foo’s p is %d\n", *(p->p));
        return;
}

pthread_t gen_thread() {
        pthread_t       id;
        int             rv;
        int             i       = 0;
        foo             f;

        memset(&f, 0, sizeof(foo));
        strcpy(f.c, "HelloTony");
        f.p = &i;

        printf("[thr1]: the foo’s str is %s\n", f.c);
        printf("[thr1]: the foo’s p is %d\n", *(f.p));

        rv = pthread_create(&id, NULL, (void*)thread_func, (void*)&f);
        if (rv != 0) {
                printf("create pthread error, errno is %d!\n", errno);
                exit (1);
        }

        return id;
}

int main() {
        pthread_join(gen_thread(), NULL);
        return 0;
}

编译执行:
a.out
[thr1]: the foo’s str is HelloTony
[thr1]: the foo’s p is 0
[thr2-1]: the foo’s str is 旷
[thr2-1]: the foo’s p is 0
[thr2-2]: the foo’s str is Bye, Tony
[thr2-2]: the foo’s p is 10
段错误 ((主存储器)信息转储)

我们来分析一下出现core的过程,gen_thread函数在创建一个新的线程后退出,而在创建新的线程时,传给线程函数的参数是存储在gen_thread函数的栈上的局部变量。而在gen_thread退出后,新线程的线程函数对线程参数进行了修改,其结果就相当于修改了主线程的栈上的数据,而当系统调用访问主线程的栈数据时,这些数据已经被修改,导致系统调用访问到’非法地址’而Dump Core。

当然上面的例子是’臆造’出来的,这也是我们的系统在一个特殊情况下出现的问题,在以前的测试中从未发生。但是我们系统使用栈上变量作为线程函数参数,这确是一潜在的问题,尽管这种问题的发生几率很小。

那么如何解决这一问题呢?眼前就有两个办法:
1、使用全局变量或者是STATIC变量
在上面的例子中,如果我们把foo f拿到函数外,并声明为static foo f,那么Core就不会出现,因为STATIC变量存储在BSS段中,其Scope也是全局的(文件Scope的全局)。所以即使gen_thread返回,存储f的区域仍然是合法的。但是这样做的一个缺点就是:如果新创建多个线程的话,那么这些线程就会共享该参数了,这是一个需要考虑的问题,但是这种情况也许会是用于某些场合。

2、在堆上动态分配变量
在堆上分配变量,既可以避免使用局部变量的’非法访问’问题,也可以避免多个线程共享的问题,针对每创建一个新线程,我们都malloc一块内存,将这块内存地址作为参数传给线程函数。这样做也不是没有弊端,因为动态分配内存,所以你就需要自己管理内存,找到时机释放它。

还有一种方法叫’线程局部存储(Thread Local Storage,TLS)’,应该专门针对第一种办法的,针对声明为全局的或者STATIC的变量,给每个线程提供一份COPY,保证互不干扰。当然这种技术需要编译器的扩展支持,目前不常用,这里也就不多说了。

总之,通过对上面这个问题地分析,我们应该在使用线程的时候注意线程参数的存储方式,这才是我们讨论这个问题的目的。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats