【问题标题】:C++ force stack unwinding inside functionC ++强制堆栈在函数内部展开
【发布时间】:2012-04-21 07:06:16
【问题描述】:

我正在学习 C++,目前我正在摆弄以下代码:

class Bar;
struct Callback {
    virtual void Continue(Bar&) = 0;
};

// ...

void Foo(Bar& _x, Callback& result)
{
    // Do stuff with _x

    if(/* some condition */) {
        // TODO: Force unwind of stack
        result.Continue(_x);
        return;
    }

    // Do more stuff with _x

    if(/* some other condition */) {
        // TODO: Force unwind of stack
        result.Continue(_x);
        return;
    }

    // TODO: Force unwind of stack
    Bar y; // allocate something on the stack
    result.Continue(y);
}

主要思想是我知道在每个站点调用result.Continue 时,函数Foo 也会返回。因此堆栈可以在调用延续之前展开。

由于用户代码将以递归方式使用它,我担心此代码可能会导致堆栈溢出。据我了解,_xresult 参数在执行result.Continue 时保留在堆栈中,因为只有在Foo 返回时堆栈才会展开。

编辑Continue 函数可能(并且可能会)调用Foo 方法:导致递归。简单地尾调用优化 Continue 而不是 Foo 可能会导致堆栈溢出。

Foo 返回之前,我可以做些什么来强制展开堆栈,将result 保留在临时 (register?) 变量中,然后执行该继续?

【问题讨论】:

  • 为什么要强制展开堆栈?这对程序的执行流程意味着什么?
  • 正如我在问题中解释的那样:Foo 函数可以并且将以递归方式使用。如果函数不强制展开堆栈,则可能导致堆栈溢出。最后一条路径,即没有条件通过,它会分配一些东西,并且可能导致堆栈溢出:但这是因为我分配了一个要在延续中使用的对象。其他执行路径没有。
  • 这段代码看起来不常见,但我认为没有任何原因导致堆栈溢出。也许你必须发布更多代码
  • 如果那个额外的函数调用(即对result.continue())导致堆栈溢出,那么你已经遇到了问题!
  • 一种缓解方法:将 Foo 的局部变量包含在一个虚拟作用域 ({}) 中,然后在该作用域之外(之后)调用 Continue(这闻起来像是可怕的 @987654339 的工作@...):编译器可能注意到现在超出范围的变量的空间可以被回收。我不确定这是在什么优化级别(说到 g++)启用的。 -fconserve-stack 可能会有所帮助。

标签: c++ stack stack-overflow continuations


【解决方案1】:

您可以使用我发现的解决此问题的设计。该设计假定一个事件驱动程序(但您可以创建一个假的事件循环,否则)。

为了清楚起见,让我们忘记您的特定问题,而是关注两个对象之间的接口问题:sender 对象将数据包发送到 receiver 对象。发送方总是必须等待接收方完成对任何数据包的处理,然后再发送另一个数据包。该接口由两个调用定义:

  • Send() - 由发送方调用以开始发送数据包,由接收方实现
  • Done() - 由接收方调用以通知发送方发送操作已完成,可以发送更多数据包

这些调用都不会返回任何内容。接收方总是通过调用 Done() 报告操作完成。如您所见,此接口在概念上与您介绍的类似,并且存在相同的 Send() 和 Done() 之间的递归问题,可能导致堆栈溢出。

我的解决方案是在事件循环中引入一个作业队列。作业队列是等待调度的事件的LIFO 队列(堆栈)。事件循环将队列顶部的作业视为最高优先级事件。换句话说,当事件循环必须决定调度哪个事件时,如果队列不为空,它将总是调度作业队列中的顶部作业,而不是任何其他事件。

然后修改上述接口以使 Send() 和 Done() 调用排队。这意味着,当发送方调用 Send() 时,所发生的只是一个作业被推送到作业队列中,而该作业在被事件循环分派时,将调用接收方的 Send() 实际实现。 Done() 的工作方式相同 - 由接收者调用,它只是推送一个作业,该作业在分派时调用发送者的 Done() 实现。

看看队列设计如何提供三大好处。

  1. 它避免了堆栈溢出,因为在 Send() 和 Done() 之间没有显式递归。但是发送者仍然可以从它的 Done() 回调中再次调用 Send(),而接收者可以直接从它的 Send() 回调中调用 Done()。

  2. 它模糊了立即完成的(I/O)操作和那些需要一些时间的操作之间的区别,即接收者必须等待一些系统级事件。例如,当使用非阻塞套接字时,接收器中的 Send() 实现调用 send() 系统调用,该系统调用要么设法发送某些东西,要么返回 EAGAIN/EWOULDBLOCK,在这种情况下,接收器要求事件循环通知当套接字可写时。当事件循环通知它套接字是可写的时,它会重试 send() 系统调用,这可能会成功,在这种情况下,它会通过从此事件处理程序调用 Done() 来通知发送方操作已完成。无论发生哪种情况,从发送者的角度来看都是一样的——它的 Done() 函数在发送操作完成时调用,立即或一段时间后调用。

  3. 它使错误处理与实际 I/O 正交。错误处理可以通过让接收者调用一个以某种方式处理错误的 Error() 回调来实现。看看发送方和接收方如何成为独立的可重用模块对错误一无所知。如果发生错误(例如,send() 系统调用失败并显示真正的错误代码,而不是 EAGAIN/EWOULDBLOCK),发送者和接收者可以简单地从 Error() 回调中销毁,这可能是创建发送者的同一代码的一部分和接收者。

这些特性共同使flow-based programming 在事件驱动程序中变得优雅。我在我的BadVPN 软件项目中实现了队列设计和基于流的编程,取得了巨大的成功。

最后,澄清一下为什么作业队列应该是 LIFO。 LIFO 调度策略提供对作业调度顺序的粗粒度控制。例如,假设您正在调用某个对象的某个方法,并且想要在 这个方法执行之后做一些事情,并且在 它推送的所有作业 已经被递归调度之后.您所要做的就是在调用此方法之前推送您自己的作业,并从该作业的事件处理程序中完成您的工作。

还有一个很好的特性是,您始终可以通过使作业出队来取消此推迟的工作。例如,如果此函数所做的某些事情(包括它推送的作业)导致错误并随后破坏我们自己的对象,我们的析构函数可以使我们推送的作业出列,避免在作业执行和访问数据时发生的崩溃已经不存在了。

【讨论】:

    【解决方案2】:

    在函数结束之前,您不能在调用它时显式强制堆栈展开(在代码示例中破坏 _xresult)。如果你的递归(你没有展示它)适合尾调用优化,那么好的编译器将能够在不创建新堆栈帧的情况下处理递归。

    【讨论】:

    • 更好的是,在“尾递归”场景中,您可以将代码重写为迭代循环,而不必担心编译器是否“足够好”可以为您完成。
    • 如果Continue 是用户代码,我无法将其更改为交互循环。 Continue 调用Foo,而我的Foo 调用Continue
    【解决方案3】:

    除非我误解了,否则为什么不这样做(导致堆栈溢出的单个函数是 imo 的设计缺陷,但如果您原来的 Foo() 中有很多本地人,那么调用 DoFoo() 可能会缓解问题):

    class Bar;
    struct Callback {
        virtual void Continue(Bar&) = 0;
    };
    
    // ...
    
    enum { use_x, use_y };
    
    int DoFoo(Bar& _x)
    {
        // Do stuff with _x
    
        if(/* some condition */) {
            return use_x;
        }
    
        // Do more stuff with _x
    
        if(/* some other condition */) {
            return use_x;
        }
    
        return use_y;
    }
    
    void Foo(Bar& _x, Callback& result)
    {
        int result = DoFoo(_x);
        if (result == use_x)
        {
           result.Continue(_x);
           return;
        }
    
        Bar y; // allocate something on the stack
        result.Continue(y);
    }
    

    【讨论】:

    • 请注意,如果_xresult 保留在堆栈中并且调用Continue 并将再次调用Foo,这仍然会导致堆栈溢出。
    【解决方案4】:

    我找到了另一种方法,但这是特定于 Windows 和 Visual C++ 的:

    void* growstk(size_t sz, void (*ct)(void*))
    {
        void* p;
        __asm
        {
            sub esp, [sz]
            mov p, esp
        }
        ct(p);
        __asm
        {
            add esp, [sz]
        }
    }
    

    延续void (*ct)(void*) 将有权访问void* p; 堆栈分配的内存。每当继续返回时,通过将堆栈指针esp 恢复到通常的级别来释放内存。

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 2014-04-17
      • 1970-01-01
      • 1970-01-01
      • 2015-10-13
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2011-05-29
      相关资源
      最近更新 更多