【问题标题】:C++ threads stack address rangeC++线程栈地址范围
【发布时间】:2019-07-11 08:50:47
【问题描述】:

C++ 标准是否提供关于线程堆栈的非重叠性质的保证(如由std::thread 启动的那样)?特别是是否可以保证线程在线程堆栈的进程地址空间中有自己的、独占的、分配的范围?这在标准中在哪里描述?

例如

std::uintptr_t foo() {
    auto integer = int{0};
    return std::bit_cast<std::uintptr_t>(&integer); 
    ... 
}

void bar(std::uint64_t id, std::atomic<std::uint64_t>& atomic) {
    while (atomic.load() != id) {}
    cout << foo() << endl;
    atomic.fetch_add(1);
}

int main() {
    auto atomic = std::atomic<std::uint64_t>{0};
    auto one = std::thread{[&]() { bar(0, atomic); }};
    auto two = std::thread{[&]() { bar(1, atomic); }};

    one.join();
    two.join();
}

这可以打印两次相同的值吗?感觉标准应该在某个地方提供这种保证。但不确定..

【问题讨论】:

  • 这是一个有趣的问题。我总是用常识来判断堆栈永远不会重叠。想象一下,如果它们可以重叠——你怎么能期望程序的行为是明确定义的?
  • @paddy 我同意,但很好奇你的意思,如果你有一些情况会导致程序爆炸。你有一个例子吗?一旦你将协程引入混合中,这些事情就会开始变得奇怪。突然间,如果 foo() 是一个协程,这变得可能,因为协程框架是堆分配的
  • 堆和栈完全不同。即使foo 是一个lambda 或其他什么,实际上调用 foo 从一个线程将使用该线程的堆栈。堆栈的目的是提供有保证的非重叠存储,其中进程存储执行所需的临时数据,包括函数调用的返回地址。如果两个线程的堆栈指针可能发生冲突或交叉,那么您将处于非常艰难的时期。
  • 作为 pthreads 的旁白,我过去必须在入口处设置堆栈大小。例如,请参阅unix.stackexchange.com/questions/127602/…
  • 当然,如果定义明确,您的意思是“将打印一个值”。但是 what 将打印的值根本没有定义。我认为,如果foo 是一个使用co_return 返回其值的协程,则没有什么可以阻止多个线程中对它的两个非重叠调用返回相同的值。事实上,即使没有协程,您的示例也不要求两个线程同时存在。可以想象一个线程可以在另一个线程创建之前完成,因此第二个线程可以继承与第一个线程相同的堆栈地址范围。

标签: c++ multithreading stack pthreads language-lawyer


【解决方案1】:

C++ 标准甚至不要求函数调用使用堆栈实现(或者线程在这个意义上具有堆栈)。

当前的 C++ 草案是这么说的 overlapping objects:

如果一个对象嵌套在另一个对象中,或者至少一个是大小为零的子对象并且它们属于不同类型,则两个具有重叠生命周期且不是位域的对象可能具有相同的地址;否则,它们具有不同的地址并占用不相交的存储字节。

在(非规范性)脚注中:

根据“as-if”规则,允许实现将两个对象存储在同一机器地址,或者如果程序无法观察到差异([intro.execution]),则根本不存储对象。

在您的示例中,我认为线程未按预期正确同步,因此 integer 对象的生命周期不一定重叠,因此两个对象可以放在同一个地址。

如果代码被修复为正确同步并且foo被手动内联到bar中,这样integer对象在打印其地址时仍然存在,那么必须分配两个对象在不同的地址,因为差异可观察到的。

但是,这些都不能告诉您是否可以在没有编译器帮助的情况下在 C++ 中实现堆栈式协程。现实世界的编译器对执行环境做出假设,这些假设未反映在 C++ 标准中,仅由 ABI 标准暗示。与堆栈切换协程特别相关的是,线程描述符和线程局部变量的地址在执行函数时不会改变(因为它们的计算成本可能很高,并且编译器会发出代码以将它们缓存在寄存器中或在堆栈)。

这是可能发生的事情:

  1. 协程在线程 A 上运行并访问 errno

  2. 协程从线程 A 挂起。

  3. 协程在线程 B 上恢复。

  4. 协程再次访问errno

此时,线程 B 将访问线程 A 的 errno 值,此时它可能正在做一些完全不同的事情。

如果一个协程只在它被挂起的同一个线程上恢复,这个问题是可以避免的,这是非常严格的,可能不是大多数协程库作者的想法。最糟糕的是,在大多数情况下,在错误的线程上恢复可能会起作用,因为一些广泛使用的线程局部变量(例如errno)不是线程局部的,不会立即导致明显的结果错误的程序。

【讨论】:

  • 我在这里谈论了更多关于无堆栈协同程序的内容(即,正在积极开发的语言功能,下周在 Kona 中等待合并到 C++20)我理解你的上述观点。不知道该怎么做 - 你是说堆栈协程(纤维)作者应该考虑线程局部性吗?
  • 如果代码被修复以正确同步并且 foo 被手动内联到 bar 中 - 嗯,为什么需要内联 foo 以禁止两者使用相同的地址?如果它们正确同步,我们知道两个 foo() 调用同时发生,因此integer 的地址值不可能相同?
【解决方案2】:

对于所有标准问题,当foo() 需要堆栈帧时,实现会调用new __StackFrameFoo。那些结束,谁知道呢。

主要规则是不同的对象有不同的地址,这包括“住在堆栈上”的对象。但是该规则仅适用于同时存在的两个对象,然后仅在使用适当的线程同步进行比较时才适用。当然,比较地址确实会阻碍优化器,它可能需要为一个可能被优化出来的对象分配一个地址。

【讨论】:

  • 对于所有标准的关心,当 foo() 需要一个堆栈帧时,实现会调用 new __StackFrameFoo。那些结束,谁知道 - 你这是什么意思?堆栈指针移位?
  • @Curious:在这样的实现中,堆栈帧将形成一个链表,列表元素分散在内存中。
猜你喜欢
  • 2012-08-09
  • 1970-01-01
  • 1970-01-01
  • 2015-03-27
  • 2011-08-21
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多