无栈协程和有栈协程有何不同?

94

背景:

我问这个问题是因为我目前有一个应用程序,其中有很多(数百到数千个)线程。大多数线程在大部分时间内都是空闲的,等待工作项被放入队列中。当工作项可用时,就会通过调用一些任意复杂的现有代码来进行处理。在某些操作系统配置下,应用程序会遇到最大用户进程数的内核参数限制,因此我想尝试通过减少worker线程数量来解决问题。

我的建议解决方案:

似乎采用基于协程的方法,将每个worker线程替换为协程,可以帮助实现这一目标。然后我可以拥有由一组实际(内核)worker线程支持的工作队列。当将一项工作放入特定协程的队列进行处理时,将在线程池的队列中放置一个条目。然后它将恢复相应的协程,处理其排队数据,然后再次挂起它,释放worker线程去做其他工作。

实施细节:

在考虑如何实现这一点时,我很难理解无栈协程和有栈协程之间的功能区别。我有使用Boost.Coroutine库使用有栈协程的经验。从概念层面上来说,我发现它比较容易理解:每个协程都维护CPU上下文和栈的副本,当您切换到一个协程时,它会切换到该保存的上下文(就像内核模式调度程序一样)。

对于无栈协程,对我来说不太清楚它与此的功能区别是什么。在我的应用程序中,与上述工作项排队相关的开销非常重要。我看到大多数实现,例如the new CO2 library建议无栈协程提供更低开销的上下文切换。

因此,我想更清楚地了解无栈协程和有栈协程之间的功能差异。具体而言,我想到以下问题:

  • 像这样的参考文献表明,区别在于你可以在堆栈协程和非堆栈协程中哪里yield/resume。这是真的吗?有没有简单的例子展示在堆栈协程中可以做什么而在非堆栈协程中无法做到?

  • 使用自动存储变量(即“在栈上”的变量)有什么限制吗?

  • 我能从非堆栈协程中调用哪些函数,有限制吗?

  • 如果非堆栈协程不保存堆栈上下文,那么当协程运行时自动存储变量会去哪里?


4
大部分线程在大部分时间内处于空闲状态,等待工作项被放入队列中。如果是这种情况,为什么会有这么多线程? - Martin James
4
由于历史原因,我并不认为现在的设计很好,因此我希望改进它。但是彻底重构整个应用程序并不是一个近期的选项,所以我正在寻找相对简单的改进方法。更加复杂的是,阻塞调用队列通常发生在调用栈的几个深层级中(即不在工作线程的顶层函数中)。我认为这将排除在这种特定情况下使用无栈线程的可能性。 - Jason R
另请参阅 boost::asio - abhiarora
2个回答

79

首先,感谢您查看CO2 :)

Boost.Coroutine 文档很好地描述了具有堆栈的协程的优点:

堆栈性质

与无堆栈协程相比,堆栈协程可以从嵌套堆栈帧中挂起。执行会在挂起之前恰好在代码的同一点上恢复。 对于无堆栈协程,只有顶级例程可以挂起。由该顶级例程调用的任何例程都不能自行挂起。 这禁止在通用库中的例程中提供挂起/恢复操作。

第一类续体

第一类续体可以作为参数传递、由函数返回并存储在数据结构中以便以后使用。 在某些实现中(例如C# yield),不可直接访问或直接操作续体。

如果没有堆栈性质和第一类语义,就无法支持一些有用的执行控制流(例如协作式多任务处理或检查点)。

这对您意味着什么?例如,想象一下您有一个接受访问者的函数:

template<class Visitor>
void f(Visitor& v);

你想将它转换为迭代器,使用堆栈式协程,可以这样做:

asymmetric_coroutine<T>::pull_type pull_from([](asymmetric_coroutine<T>::push_type& yield)
{
    f(yield);
});

但是使用无栈协程,就没有办法这样做:

generator<T> pull_from()
{
    // yield can only be used here, cannot pass to f
    f(???);
}
一般而言,带有堆栈的协程比无堆栈的协程更加强大。那么为什么我们要使用无堆栈的协程呢?简短的答案是:效率。
通常情况下,带有堆栈的协程需要分配一定数量的内存来容纳其运行时堆栈(必须足够大),而与无堆栈的协程相比,上下文切换的代价更高,例如 Boost.Coroutine 在我的机器上平均需要 40 个周期,而 CO2 只需要 7 个周期,因为无堆栈协程需要恢复的唯一内容是程序计数器。
也就是说,有了语言支持,可能带有堆栈的协程也可以利用编译器计算的堆栈最大大小,只要协程中没有递归,内存使用情况也可以得到改善。
谈到无堆栈的协程,请记住这并不意味着根本没有运行时堆栈,它只是使用与主机侧相同的运行时堆栈,因此您也可以调用递归函数,只是所有递归将在主机的运行时堆栈上发生。相比之下,对于带有堆栈的协程,当您调用递归函数时,递归将在协程自己的堆栈上发生。
回答问题:
- 对于自动存储变量(即“堆栈上”的变量)有限制吗?
没有。这是 CO2 的模拟限制。有了语言支持后,可见于协程的自动存储变量将被放置在协程的内部存储器中。请注意我对“可见于协程”的强调,如果协程调用使用内部自动存储变量的函数,则这些变量将被放置在运行时堆栈上。更具体地说,无堆栈协程只需要保留可以在恢复后使用的变量/临时变量。
明确一下,在 CO2 的协程主体中也可以使用自动存储变量:
auto f() CO2_RET(co2::task<>, ())
{
    int a = 1; // not ok
    CO2_AWAIT(co2::suspend_always{});
    {
        int b = 2; // ok
        doSomething(b);
    }
    CO2_AWAIT(co2::suspend_always{});
    int c = 3; // ok
    doSomething(c);
} CO2_END

只要定义不在任何await之前,就可以调用任何函数。

  • 对于无栈协程,我可以调用哪些函数?

没有限制。

  • 如果无栈协程没有保存堆栈上下文,那么当协程运行时自动存储的变量会放在哪里?

如上所述,无栈协程不关心所调用函数中使用的自动存储变量,它们只会被放置在正常的运行时堆栈上。

如果您有任何疑问,请查看CO2的源代码,它可能会帮助您了解底层机制;)


6
你需要的是用户空间线程/纤程 - 通常情况下,你希望在深嵌套的调用堆栈中暂停代码(例如从TCP连接解析消息)。在这种情况下,你无法使用无栈上下文切换(应用程序堆栈在无栈协程之间共享 -> 被调用子例程的堆栈帧将被覆盖)。
你可以使用类似boost.fiber的东西,它基于boost.context实现了用户空间线程/纤程。

我在使用纤程或协程实现时遇到的主要挑战是调度问题。我想实现一个M:N线程模型,其中N个纤维/协程由M个内核线程服务,但我希望这些M个线程能够根据需要为任何N个纤维/协程提供服务。是否可以从与之前暂停不同的内核线程恢复boost::fiber?这样做会有性能损失吗?boost::asymmetric_coroutine呢? - Jason R
将光纤从一个线程迁移到另一个线程目前正在开发中,但我不建议这样做。只有在光纤在另一个CPU上执行时,移动光纤才有意义。将光纤从一个CPU移动到另一个CPU会导致缓存未命中。boost.fiber为光纤提供了同步类,如mutext/condition variables等。 - olk
感谢提供信息。我确实觉得这将是个有用的功能。就像我所说,在我的应用程序中,我想要做M:N线程。我将拥有N个纤程,每个代表一个处理管道。这些管道都是异步地从另一个线程中获取数据。因此,我希望能够使用M(远小于N)个内核线程服务于每个N个纤程。这意味着,每当特定纤程准备好运行时,因为有新的输入数据可用,我想要使用池中的一个内核线程来服务于纤程。 - Jason R
如果我将纤程特定地绑定到线程,那么如果纤程的线程在它准备好时正忙着(但池中的另一个线程可用于运行它),这可能会导致并发性降低。我不知道是否需要纤程间同步,因此Boost.Coroutine本身可能是一个选项(我了解它确实支持在线程之间迁移纤程)。 - Jason R
以下是有关编程的内容:FWIW,这是一个CO2/ASIO示例:https://github.com/jamboree/co2/blob/master/example/asio_threadpool.cpp - Jamboree

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接