1

我正在学习 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和在执行result时保留在堆栈result.Continue中,因为堆栈仅在Foo返回时才展开。

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

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

4

4 回答 4

5

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

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

  • 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() 回调中销毁,这可能是创建发送者的同一代码的一部分和接收器。

总之,这些功能可以在事件驱动程序中实现优雅的基于流的编程。我在我的BadVPN软件项目中实现了队列设计和基于流的编程,并取得了巨大的成功。

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

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

于 2012-04-08T19:51:42.183 回答
0

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

于 2012-04-08T16:13:21.553 回答
0

除非我误解了,否则为什么不这样做(导致堆栈溢出的单个函数是 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);
}
于 2012-04-08T16:22:26.117 回答
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每当继续返回时,通过将堆栈指针恢复到通常级别来释放内存。

于 2012-04-09T23:08:32.797 回答