C ++强制堆栈内部函数展开

时间:2012-04-08 16:06:24

标签: c++ stack stack-overflow continuations

我正在学习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也会返回。因此,可以在调用延续之前解开堆栈。

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

编辑Continue函数可能(可能会)调用Foo方法:导致递归。只需尾调用优化Continue而不是Foo就可以导致堆栈溢出。

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

4 个答案:

答案 0 :(得分:5)

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

为清楚起见,让我们忘记您的特定问题,而是关注两个对象之间的接口问题: 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()syscall失败并带有真正的错误代码,而不是EAGAIN / EWOULDBLOCK),发送者和接收者可以简单地从Error()回调中销毁,这可能是创建发送者的相同代码的一部分和接收者。

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

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

    还有一个很好的属性,你总是可以通过出列作业取消这个推迟的工作。例如,如果此函数执行了某些操作(包括它推送的作业)导致错误并导致我们自己的对象被破坏,我们的析构函数可以使我们推送的作业出列,从而避免在作业执行和访问数据时发生的崩溃不再存在。

答案 1 :(得分:0)

在函数结束之前,您无法显式强制执行堆栈展开(在代码示例中销毁_xresult)。如果您的递归(您没有显示它)适合尾部调用优化,那么好的编译器将能够处理递归而无需创建新的堆栈帧。

答案 2 :(得分:0)

除非我误解,为什么不是这样的(导致stackoverflow的单个函数是一个设计缺陷imo,但如果你的原始Foo()中有很多本地人,那么调用DoFoo()可以缓解这个问题): / p>

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);
}

答案 3 :(得分:0)

我找到了另一种方法,但这是针对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恢复到通常的级别来释放内存。