C++ 异步编程(三)

原文:zh.annas-archive.org/md5/f1e7911ac27c9e84fe6c2390cd2daf23

译者:飞龙

协议:CC BY-NC-SA 4.0

第八章:使用协程进行异步编程

在前面的章节中,我们看到了在 C++中编写异步代码的不同方法。我们使用了线程,这是执行的基本单元,以及一些高级异步代码机制,如 futures、promises 和std::async。我们将在下一章中查看 Boost.Asio 库。所有这些方法通常使用多个系统线程,由内核创建和管理。

例如,我们程序的主线程可能需要访问数据库。这种访问可能很慢,所以我们将在不同的线程中读取数据,以便主线程可以继续执行其他任务。另一个例子是生产者-消费者模型,其中一个或多个线程生成要处理的数据项,一个或多个线程以完全异步的方式处理这些项。

上述两个示例都使用了线程,也称为系统(内核)线程,并需要不同的执行单元,每个线程一个。

在本章中,我们将研究一种不同的异步代码编写方式——协程。协程是一个来自 20 世纪 50 年代末的老概念,直到 C++20 才被添加到 C++中。它们不需要单独的线程(当然,我们可以在不同的线程中运行协程)。协程是一种机制,它使我们能够在单线程中执行多个任务。

在本章中,我们将涵盖以下主要主题:

  • 协程是什么?它们是如何被 C++实现和支持的?

  • 实现基本协程以了解 C++协程的要求

  • 生成器协程和新的 C++23 std::generator

  • 用于解析整数的字符串解析器

  • 协程中的异常

本章介绍的是不使用任何第三方库实现的 C++协程。这种方式编写协程相当底层,我们需要编写代码来支持编译器。

技术要求

对于本章,你需要一个 C++20 编译器。对于生成器示例,你需要一个 C++23 编译器。我们已经测试了这些示例与 GCC 14.1 兼容。代码是平台无关的,因此尽管本书关注 Linux,但所有示例都应在 macOS 和 Windows 上运行。请注意,Visual Studio 17.11 还不支持 C++23 std::generator

本章的代码可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Asynchronous-Programming-with-CPP

协程

在我们开始用 C++实现协程之前,我们将从概念上介绍协程,并看看它们在我们的程序中如何有用。

让我们从定义开始。协程是一个可以暂停自己的函数。协程在等待输入值(在它们暂停时,它们不执行)或产生一个值,如计算的输出后暂停自己。一旦输入值可用或调用者请求另一个值,协程将恢复执行。我们很快将回到 C++中的协程,但让我们通过一个现实生活中的例子来看看协程是如何工作的。

想象一下有人在当助手。他们开始一天的工作是阅读电子邮件。

其中一封电子邮件是要求一份报告。在阅读电子邮件后,他们开始撰写所需的文档。一旦他们写完了引言段落,他们注意到他们需要从同事那里获取一份报告,以获取上一季度的会计结果。他们停止撰写报告,给同事写了一封电子邮件,请求所需的信息,然后阅读下一封电子邮件,这是一封要求预订下午重要会议的会议室的请求。他们打开公司开发的一个专门用于自动预订会议室以优化其使用的应用程序来预订会议室。

过了一段时间,他们从同事那里收到了所需的会计数据,然后继续撰写报告。

助手总是忙于处理他们的任务。撰写报告是协程的一个好例子:他们开始撰写报告,然后在等待所需信息时暂停写作,一旦信息到达,他们继续写作。当然,助手不想浪费时间,在等待时,他们会继续做其他任务。如果他们等待请求并发出适当的响应,他们的同事可以被视为另一个协程。

现在,让我们回到软件。假设我们需要编写一个函数,在处理一些输入信息后,将数据存储到数据库中。

如果数据一次性到达,我们只需实现一个函数。该函数将读取输入,对数据进行必要的处理,最后将结果写入数据库。但如果要处理的数据以块的形式到达,并且处理每个块都需要前一个块处理的结果(为了这个例子,我们可以假设第一个块的处理只需要一些默认值)呢?

解决我们问题的可能方法是在每个数据块到达时让函数等待,处理它,将结果存储在数据库中,然后等待下一个,依此类推。但如果我们这样做,我们可能会在等待每个数据块到达时浪费很多时间。

在阅读了前面的章节后,你可能正在考虑不同的潜在解决方案:我们可以创建一个线程来读取数据,将块复制到队列中,然后第二个线程(可能是主线程)将处理数据。这是一个可接受的解决方案,但使用多个线程可能有些过度。

另一种解决方案可能是实现一个只处理一个数据块的函数。调用者将等待输入传递给函数,并保留处理每个数据块所需的上一块处理的结果。在这个解决方案中,我们必须在另一个函数中保留数据处理函数所需的状态。对于简单的示例可能是可接受的,但一旦处理变得更为复杂(例如,需要保留不同中间结果的多步处理),代码可能难以理解和维护。

我们可以用协程解决这个问题。让我们看看处理数据块并保留中间结果的协程的一些可能的伪代码:

processing_result process_data(data_block data) {
    while (do_processing == true) {
        result_type result{ 0 };
        result = process_data_block(previous_result);
        update_database();
        yield result;
    }
}

前面的协程从调用者那里接收一个数据块,执行所有处理,更新数据库,并保留处理下一个数据块所需的结果。在将结果传回调用者(关于传回的更多内容稍后讨论)之后,它将自己暂停。当调用者再次调用协程请求处理新的数据块时,其执行将恢复。

这样的协程简化了状态管理,因为它可以在调用之间保持状态。

在对协程进行概念介绍之后,我们将开始使用 C++20 实现它们。

C++协程

正如我们所见,协程只是函数,但它们并不像我们习惯的函数。它们具有我们将在本章中学习的特殊属性。在本节中,我们将专注于 C++中的协程。

函数在调用时开始执行,并通常通过返回语句或当函数的末尾到达时正常终止。

函数从开始到结束运行。它可能调用另一个函数(或者如果是递归的,甚至可以调用自己),它可能抛出异常或具有不同的返回点。但它总是从开始到结束运行。

协程是不同的。协程是一个可以暂停自己的函数。协程的流程可能如下伪代码所示:

 void coroutine() {
    do_something();
    co_yield;
    do_something_else();
    co_yield;
    do_more_work();
    co_return;
}

我们很快就会看到那些带有**co_**前缀的术语的含义。

对于协程,我们需要一个机制来保持执行状态,以便能够暂停/恢复协程。这是由编译器为我们完成的,但我们必须编写一些辅助代码,以便让编译器帮助我们。

C++中的协程是无堆栈的。这意味着我们需要存储以能够暂停/恢复协程的状态存储在堆中,通过调用new/delete来分配/释放动态内存。这些调用是由编译器创建的。

新关键字

因为协程本质上是一个函数(具有一些特殊属性,但仍然是一个函数),编译器需要某种方式来确定给定的函数是否是协程。C++20 引入了三个新的关键字:co_yieldco_awaitco_return。如果一个函数使用了这三个关键字中的至少一个,那么编译器就知道它是一个协程。

下表总结了新关键字的函数:

关键字输入/输出协程状态
co_yield输出暂停
co_await输入暂停
co_return输出终止

表 8.1:新的协程关键字

在前面的表中,我们看到在 co_yieldco_await 之后,协程会暂停,而在 co_return 之后,它会终止(co_return 在 C++函数中相当于 return 语句)。协程不能有 return 语句;它必须始终使用 co_return。如果协程不返回任何值,并且使用了其他两个协程关键字之一,则可以省略 co_return 语句。

协程限制

我们已经说过,协程是使用新协程关键字的函数。但协程有以下限制:

  • 使用 varargs 的具有可变数量参数的函数不能是协程(一个变长函数模板可以是协程)

  • 类构造函数或析构函数不能是协程

  • constexprconsteval 函数不能是协程

  • 返回 auto 的函数不能是协程,但带有尾随返回类型的 auto 可以是

  • main() 函数不能是协程

  • Lambda 可以是协程

在学习了协程的限制(基本上是哪些 C++函数不能是协程)之后,我们将在下一节开始实现协程。

实现基本协程

在上一节中,我们学习了协程的基本知识,包括它们是什么以及一些用例。

在本节中,我们将实现三个简单的协程来展示实现和使用它们的基本方法:

  • 只返回的最简单协程

  • 协程向调用者发送值

  • 从调用者获取值的协程

最简单的协程

我们知道协程是一个可以暂停自己的函数,并且可以被调用者恢复。我们还知道,如果函数至少使用了一个 co_yieldco_awaitco_return 表达式,编译器会将该函数识别为协程。

编译器将转换协程源代码,并创建一些数据结构和函数,使协程能够正常工作,并能够暂停和恢复。这是为了保持协程状态并能够与协程进行通信。

编译器将处理所有这些细节,但请注意,C++对协程的支持相当底层。有一些库可以帮助我们在 C++中更轻松地处理协程。其中一些是 Lewis Baker 的 cppcoroBoost.CobaltBoost.Asio 库也支持协程。这些库是下一章的主题。

让我们从零开始。这里的“从零开始”是指绝对的零起点。我们将编写一些代码,并通过编译器错误和 C++参考来编写一个基本但功能齐全的协程。

以下代码是协程的最简单实现:

void coro_func() {
    co_return;
}
int main() {
    coro_func();
}

简单,不是吗?我们的第一个协程将只返回空值。它不会做任何其他事情。遗憾的是,前面的代码对于功能协程来说太简单了,无法编译。当使用 GCC 14.1 编译时,我们得到以下错误:

error: coroutines require a traits template; cannot find 'std::coroutine_traits'

我们还得到了以下提示:

note: perhaps '#include <coroutine>' is missing

编译器给我们一个提示:我们可能遗漏了包含一个必需的文件。让我们包含****头文件。我们将在一会儿处理关于 traits 模板的错误:

#include <coroutine>
void coro_func() {
    co_return;
}
int main() {
    coro_func();
}

在编译前面的代码时,我们遇到了以下错误:

 error: unable to find the promise type for this coroutine

我们协程的第一个版本给我们带来了一个编译错误,说找不到类型std::coroutine_traits模板。现在我们得到了一个与所谓的promise 类型有关的错误。

查看 C++参考,我们看到std::coroutine_traits模板决定了协程的返回类型和参数类型。参考还指出,协程的返回类型必须定义一个名为promise_type的类型。遵循参考建议,我们可以编写我们协程的新版本:

#include <coroutine>
struct return_type {
    struct promise_type {
    };
};
template<>
struct std::coroutine_traits<return_type> {
    using promise_type = return_type::promise_type;
};
return_type coro_func() {
    co_return;
}
int main() {
    coro_func();
}

请注意,协程的返回类型可以有任何名称(我们在这里将其称为return_type,因为这在这个简单示例中很方便)。

再次编译前面的代码时,我们遇到了一些错误(为了清晰起见,错误已被编辑)。所有错误都与promise_type结构中缺少的函数有关:

error: no member named 'return_void' in 'std::__n4861::coroutine_traits<return_type>::promise_type'
error: no member named 'initial_suspend' in 'std::__n4861::coroutine_traits<return_type>::promise_type'
error: no member named 'unhandled_exception' in 'std::__n4861::coroutine_traits<return_type>::promise_type'
error: no member named 'final_suspend' in 'std::__n4861::coroutine_traits<return_type>::promise_type'
error: no member named 'get_return_object' in 'std::__n4861::coroutine_traits<return_type>::promise_type'

我们到目前为止看到的所有编译错误都与我们的代码中缺少的功能有关。在 C++中编写协程需要遵循一些规则,并帮助编译器生成有效的代码。

以下是最简单的协程的最终版本:

#include <coroutine>
struct return_type {
    struct promise_type {
        return_type get_return_object() noexcept {
            return return_type{ *this };
        }
        void return_void() noexcept {}
        std::suspend_always initial_suspend() noexcept {
            return {};
        }
        std::suspend_always final_suspend() noexcept {
            return {};
        }
        void unhandled_exception() noexcept {}
    };
    explicit return_type(promise_type&) {
    }
    ~return_type() noexcept {
    }
};
return_type coro_func() {
    co_return;
}
int main() {
    coro_func();
}

你可能已经注意到我们已经移除了std::coroutine_traits模板。实现返回和 promise 类型就足够了。

前面的代码编译没有任何错误,你可以运行它。它确实…什么也不做!但这是我们第一个协程,我们已经了解到我们需要提供一些编译器所需的代码来创建协程。

promise 类型

promise 类型是编译器所要求的。我们需要始终定义此类型(它可以是类或结构体),它必须命名为promise_type,并且必须实现 C++参考中指定的某些函数。我们已经看到,如果我们不这样做,编译器会抱怨并给出错误。

promise 类型必须在协程返回的类型内部定义,否则代码将无法编译。返回的类型(有时也称为wrapper 类型,因为它封装了promise_type)可以任意命名。

一个产生结果的协程

一个什么也不做的协程对于说明一些基本概念很有用。我们现在将实现另一个可以将数据发送回调用者的协程。

在这个第二个例子中,我们将实现一个产生消息的协程。它将是协程的“hello world”。协程将说你好,调用函数将打印从协程接收到的消息。

为了实现该功能,我们需要从协程到调用者建立一个通信通道。这个通道是允许协程向调用者传递值并从它那里接收信息的机制。这个通道是通过协程的 承诺类型句柄 建立的,它们管理协程的状态。

通信通道按以下方式工作:

  • 协程帧 : 当协程被调用时,它创建一个 协程帧 ,其中包含暂停和恢复其执行所需的所有状态信息。这包括局部变量、承诺类型以及任何内部状态。

  • 承诺类型 : 每个协程都有一个相关的 承诺类型 ,它负责管理协程与调用函数之间的交互。承诺是存储协程返回值的地方,它提供了控制协程行为的函数。我们将在本章的示例中看到这些函数。承诺是调用者与协程交互的接口。

  • 协程句柄 : 协程句柄是一种类型,它提供了对协程帧(协程的内部状态)的访问权限,并允许调用者恢复或销毁协程。句柄是调用者可以在协程被挂起后(例如,在 co_awaitco_yield 之后)恢复协程的东西。句柄还可以用来检查协程是否完成或清理其资源。

  • 挂起和恢复机制 : 当协程 yield 一个值( co_yield )或等待异步操作( co_await )时,它挂起其执行,将其状态保存在协程帧中。然后调用者可以在稍后恢复协程,通过协程句柄检索 yielded 或 awaited 的值并继续执行。

我们将在以下示例中看到,这个通信通道需要我们在自己的这一侧编写相当数量的代码,以帮助编译器生成协程功能所需的全部代码。

以下代码是调用函数和协程的新版本:

return_type coro_func() {
    co_yield "Hello from the coroutine\n"s;
    co_return;
}
int main() {
    auto rt = coro_func();
    std::cout << rt.get() << std::endl;
    return 0;
}

变更如下:

  • [1] : 协程 yield 并向调用者发送一些数据(在这种情况下,一个 std::string 对象)

  • [2] : 调用者读取那些数据并将其打印出来

所需的通信机制在承诺类型和返回类型(这是一个承诺类型包装器)中实现。

当编译器读取 co_yield 表达式时,它将生成对在承诺类型中定义的 yield_value 函数的调用。

以下代码是我们版本的该函数的实现,该函数生成(或 yield)一个 std::string 对象:

std::suspend_always yield_value(std::string msg) noexcept {
    output_data = std::move(msg);
    return {};
}

函数获取一个 std::string 对象并将其移动到承诺类型的 output_data 成员变量中。但这只是将数据保留在承诺类型内部。我们需要一种机制来将那个字符串从协程中取出。

句柄类型

一旦我们需要一个协程的通信通道,我们需要一种方式来引用一个挂起或正在执行的协程。C++标准库在所谓的协程句柄中实现了这样的机制。它的类型是std::coroutine_handle,它是返回类型的成员变量。这个结构也负责句柄的完整生命周期,包括创建和销毁它。

以下代码片段是我们添加到返回类型中以管理协程句柄的功能:

std::coroutine_handle<promise_type> handle{};
explicit return_type(promise_type& promise) : handle{ std::coroutine_handle<promise_type>::from_promise(promise)} {
}
~return_type() noexcept {
    if (handle) {
        handle.destroy();
    }
}

前面的代码声明了一个类型为**std::coroutine_handle<promise_type>**的协程句柄,并在返回类型构造函数中创建句柄。句柄在返回类型析构函数中被销毁。

现在,回到我们的产生值的协程。唯一缺少的部分是调用函数的**get()**函数,以便能够访问协程生成的字符串:

std::string get() {
    if (!handle.done()) {
        handle.resume();
    }
    return std::move(handle.promise().output_data);
}

**get()**函数在协程未终止的情况下恢复协程,然后返回字符串对象。

以下是我们第二个协程的完整代码:

#include <coroutine>
#include <iostream>
#include <string>
using namespace std::string_literals;
struct return_type {
    struct promise_type {
        std::string output_data { };
        return_type get_return_object() noexcept {
            std::cout << "get_return_object\n";
            return return_type{ *this };
        }
        void return_void() noexcept {
            std::cout << "return_void\n";
        }
        std::suspend_always yield_value(
                         std::string msg) noexcept {
            std::cout << "yield_value\n";
            output_data = std::move(msg);
            return {};
        }
        std::suspend_always initial_suspend() noexcept {
            std::cout << "initial_suspend\n";
            return {};
        }
        std::suspend_always final_suspend() noexcept {
            std::cout << "final_suspend\n";
            return {};
        }
        void unhandled_exception() noexcept {
            std::cout << "unhandled_exception\n";
        }
    };
    std::coroutine_handle<promise_type> handle{};
    explicit return_type(promise_type& promise)
       : handle{ std::coroutine_handle<
                 promise_type>::from_promise(promise)}{
        std::cout << "return_type()\n";
    }
    ~return_type() noexcept {
        if (handle) {
            handle.destroy();
        }
        std::cout << "~return_type()\n";
    }
    std::string get() {
        std::cout << "get()\n";
        if (!handle.done()) {
            handle.resume();
        }
        return std::move(handle.promise().output_data);
    }
};
return_type coro_func() {
    co_yield "Hello from the coroutine\n"s;
    co_return;
}
int main() {
    auto rt = coro_func();
    std::cout << rt.get() << std::endl;
    return 0;
}

运行前面的代码会打印以下消息:

get_return_object
return_type()
initial_suspend
get()
yield_value
Hello from the coroutine
~return_type()

这个输出显示了协程执行期间发生的情况:

  1. return_type对象在调用get_return_object之后创建

  2. 协程最初是挂起的

  3. 调用者想要从协程中获取消息,因此调用get()

  4. yield_value被调用,协程被恢复,并且消息被复制到承诺的成员变量中

  5. 最后,调用函数打印消息,协程返回

注意,承诺(以及承诺类型)与在第六章中解释的 C++标准库std::promise类型无关。

等待中的协程

在前面的例子中,我们看到了如何实现一个可以通过发送std::string对象来回调者通信的协程。现在,我们将实现一个可以等待调用者发送的输入数据的协程。在我们的例子中,协程将等待直到它接收到一个std::string对象,然后打印它。当我们说协程“等待”时,我们的意思是它是挂起的(即,没有执行)直到数据接收。

让我们从协程和调用函数的更改开始:

return_type coro_func() {
    std::cout << co_await std::string{ };
    co_return;
}
int main() {
    auto rt = coro_func();
    rt.put("Hello from main\n"s);
    return 0;
}

在前面的代码中,调用函数调用put()函数(返回类型结构中的方法)和协程调用co_await等待从调用者那里来的std::string对象。

返回类型的更改很简单,即只是添加**put()**函数:

void put(std::string msg) {
    handle.promise().input_data = std::move(msg);
    if (!handle.done()) {
        handle.resume();
    }
}

我们需要将input_data变量添加到承诺结构中。但是,仅仅通过对我们第一个示例所做的更改(我们将它作为本章其余示例的起点,因为它是最少的代码来实现协程)以及上一个示例中的协程句柄,代码无法编译。编译器给我们以下错误:

error: no member named 'await_ready' in 'std::string' {aka 'std::__cxx11::basic_string<char>'}

回到 C++参考,我们看到当协程调用co_await时,编译器将生成代码来调用承诺对象中的函数await_transform,该函数的参数类型与协程等待的数据类型相同。正如其名所示,await_transform是一个将任何对象(在我们的例子中,std::string)转换为可等待对象的函数。std::string是不可等待的,因此之前的编译器错误。

await_transform必须返回一个awaiter对象。这只是一个简单的结构,实现了使编译器能够使用 awaiter 所需的基本接口。

以下代码展示了我们实现的await_transform函数和awaiter结构:

auto await_transform(std::string) noexcept {
    struct awaiter {
        promise_type& promise;
        bool await_ready() const noexcept {
            return true;
        }
        std::string await_resume() const noexcept {
            return std::move(promise.input_data);
        }
        void await_suspend(std::coroutine_handle<
                           promise_type>) const noexcept {
        }
   };
   return awaiter(*this);
}

编译器需要promise_type函数await_transform。我们不能为这个函数使用不同的标识符。参数类型必须与协程等待的对象类型相同。awaiter结构可以命名为任何名称。我们在这里使用awaiter是因为它具有描述性。awaiter结构必须实现三个函数:

  • await_ready:这个函数用于检查协程是否被挂起。如果是这种情况,它返回false。在我们的例子中,它总是返回true,表示协程没有被挂起。

  • await_resume:这个函数恢复协程并生成co_await表达式的结果。

  • await_suspend:在我们的简单 awaiter 中,这个函数返回void,意味着控制权传递给调用者,协程被挂起。await_suspend也可以返回一个布尔值。在这种情况下返回true就像返回void一样。返回false意味着协程被恢复。

这是等待协程完整示例的代码:

#include <coroutine>
#include <iostream>
#include <string>
using namespace std::string_literals;
struct return_type {
    struct promise_type {
        std::string input_data { };
        return_type get_return_object() noexcept {
            return return_type{ *this };
        }
        void return_void() noexcept {
        }
        std::suspend_always initial_suspend() noexcept {
            return {};
        }
        std::suspend_always final_suspend() noexcept {
            return {};
        }
        void unhandled_exception() noexcept {
        }
        auto await_transform(std::string) noexcept {
            struct awaiter {
                promise_type& promise;
                bool await_ready() const noexcept {
                    return true;
                }
                std::string await_resume() const noexcept {
                    return std::move(promise.input_data);
                }
                void await_suspend(std::coroutine_handle<
                                  promise_type>) const noexcept {
                }
            };
            return awaiter(*this);
        }
    };
    std::coroutine_handle<promise_type> handle{};
    explicit return_type(promise_type& promise)
      : handle{ std::coroutine_handle<
                         promise_type>::from_promise(promise)} {
    }
    ~return_type() noexcept {
        if (handle) {
            handle.destroy();
        }
    }
    void put(std::string msg) {
        handle.promise().input_data = std::move(msg);
        if (!handle.done()) {
            handle.resume();
        }
    }
};
return_type coro_func() {
    std::cout << co_await std::string{ };
    co_return;
}
int main() {
    auto rt = coro_func();
    rt.put("Hello from main\n"s);
    return 0;
}

在本节中,我们看到了协程的三个基本示例。我们实现了最简单的协程,然后是具有通信通道的协程,这些协程既为调用者生成数据(co_yield),又从调用者那里等待数据(co_await)。

在下一节中,我们将实现一种称为生成器的协程类型,并生成数字序列。

协程生成器

生成器是一个协程,通过反复从它被挂起的位置恢复自身来生成一系列元素。

生成器可以被视为一个无限序列,因为它可以生成任意数量的元素。调用函数可以从生成器获取它所需的所有新元素。

当我们说无限时,我们指的是理论上。生成器协程将产生元素,没有明确的最后一个元素(可以实现具有有限范围的生成器),但在实践中,我们必须处理诸如数值序列中的溢出等问题。

让我们从零开始实现一个生成器,应用我们在本章前几节学到的知识。

斐波那契序列生成器

想象我们正在实现一个应用程序,并且需要使用斐波那契序列。您可能已经知道,斐波那契序列是一个序列,其中每个数字都是前两个数字的和。第一个元素是 0,第二个元素是 1,然后我们应用定义并逐个生成元素。

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/async-prog-cpp/img/12.png

我们总是可以用一个 for 循环生成这些数字。但如果我们需要在程序的不同点生成它们,我们需要实现一种存储序列状态的方法。我们需要在我们的程序中某个地方保留我们生成的最后一个元素是什么。是第五个还是可能是第十个?

协程是解决这个问题的非常好的解决方案;它会自己保持所需的状态,并且它会在我们请求序列中的下一个数字时暂停。

下面是使用生成器协程的代码:

int main() {
    sequence_generator<int64_t> fib = fibonacci();
    std::cout << "Generate ten Fibonacci numbers\n"s;
    for (int i = 0; i < 10; ++i) {
        fib.next();
        std::cout << fib.value() << " ";
    }
    std::cout << std::endl;
    std::cout << "Generate ten more\n"s;
    for (int i = 0; i < 10; ++i) {
        fib.next();
        std::cout << fib.value() << " ";
    }
    std::cout << std::endl;
    std::cout << "Let's do five more\n"s;
    for (int i = 0; i < 5; ++i) {
        fib.next();
        std::cout << fib.value() << " ";
    }
    std::cout << std::endl;
    return 0;
}

如您在前面的代码中看到的,我们生成所需的数字时无需担心最后一个元素是什么。序列是由协程生成的。

注意,尽管在理论上序列是无限的,但我们的程序必须意识到非常大的斐波那契数可能存在溢出的潜在风险。

要实现生成器协程,我们遵循本章之前解释的原则。

首先,我们实现协程函数:

sequence_generator<int64_t> fibonacci() {
    int64_t a{ 0 };
    int64_t b{ 1 };
    int64_t c{ 0 };
    while (true) {
        co_yield a;
        c = a + b;
        a = b;
        b = c;
    }
}

协程通过应用公式生成斐波那契序列的下一个元素。元素在无限循环中生成,但协程在 co_yield 后会暂停自己。

返回类型是 sequence_generator 结构体(我们使用模板以便能够使用 32 位或 64 位整数)。它包含一个承诺类型,与我们在前一个部分中看到的产生式协程中的承诺类型非常相似。

sequence_generator 结构体中,我们添加了两个在实现序列生成器时有用的函数。

void next() {
    if (!handle.done()) {
        handle.resume();
    }
}

next() 函数用于恢复协程以生成序列中要生成的下一个斐波那契数。

int64_t value() {
    return handle.promise().output_data;
}

value() 函数返回最后一个生成的斐波那契数。

这样,我们就解耦了元素生成和其检索 Q 值。

请在本书的配套 GitHub 仓库中找到此示例的完整代码。

C++23 std::generator

我们已经看到,即使在 C++ 中实现最基础的协程也需要一定量的代码。这可能在 C++26 中改变,因为 C++ 标准库对协程的支持将更多,这将使我们能够更容易地编写协程。

C++23 引入了 std::generator 模板类。通过使用它,我们可以编写基于协程的生成器,而无需编写任何所需的代码,例如承诺类型、返回类型及其所有函数。要运行此示例,您需要一个 C++23 编译器。我们使用了 GCC 14.1。std::generator 在 Clang 中不可用。

让我们看看使用新的 C++23 标准库特性的斐波那契数列生成器:

#include <generator>
#include <iostream>
std::generator<int> fibonacci_generator() {
    int a{ };
    int b{ 1 };
    while (true) {
        co_yield a;
        int c = a + b;
        a = b;
        b = c;
    }
}
auto fib = fibonacci_generator();
int main() {
    int i = 0;
    for (auto f = fib.begin(); f != fib.end(); ++f) {
        if (i == 10) {
            break;
        }
        std::cout << *f << " ";
        ++i;
    }
    std::cout << std::endl;
}

第一步是包含 头文件。然后,我们只需编写协程,因为所有其他所需的代码都已经为我们编写好了。在前面的代码中,我们使用迭代器(由 C++ 标准库提供)访问生成的元素。这允许我们使用范围-for 循环、算法和范围。

还可以编写一个斐波那契生成器的版本,生成一定数量的元素而不是无限序列:

std::generator<int> fibonacci_generator(int limit) {
    int a{ };
    int b{ 1 };
    while (limit--) {
        co_yield a;
        int c = a + b;
        a = b;
        b = c;
    }
}

代码更改非常简单:只需传递我们希望生成器生成的元素数量,并在 while 循环中将其用作终止条件。

在本节中,我们实现了最常见的协程类型之一——生成器。我们从头开始实现了生成器,也使用了 C++23 的 std::generator 类模板。

在下一节中,我们将实现一个简单的字符串解析器协程。

简单的协程字符串解析器

在本节中,我们将实现我们的最后一个示例:一个简单的字符串解析器。协程将等待输入,一个 std::string 对象,并在解析输入字符串后产生输出,即一个数字。为了简化示例,我们将假设数字的字符串表示没有错误,并且数字的结尾由哈希字符,# 表示。我们还将假设数字类型是 int64_t,并且字符串不会包含该整数类型范围之外的任何值。

解析算法

让我们看看如何将表示整数的字符串转换为数字。例如,字符串 “-12321#” 表示数字 -12321。要将字符串转换为数字,我们可以编写一个像这样的函数:

int64_t parse_string(const std::string& str) {
    int64_t num{ 0 };
    int64_t sign { 1 };
    std::size_t c = 0;
    while (c < str.size()) {
        if (str[c] == '-') {
            sign = -1;
        }
        else if (std::isdigit(str[c])) {
            num = num * 10 + (str[c] - '0');
        }
        else if (str[c] == '#') {
            break;
        }
        ++c;
    }
    return num * sign;
}

由于假设字符串是良好形成的,代码相当简单。如果我们读取负号,-,则将符号更改为 -1(默认情况下,我们假设正数,如果有 + 符号,则简单地忽略它)。然后,逐个读取数字,并按以下方式计算数字值。

num 的初始值是 0。我们读取第一个数字,并将其数值加到当前 num 值乘以 10 上。这就是我们读取数字的方式:最左边的数字将乘以 10,次数等于其右侧数字的数量。

当我们使用字符来表示数字时,它们根据 ASCII 表示法有一定的值(我们假设没有使用宽字符或其他任何字符类型)。字符09具有连续的 ASCII 码,因此我们可以通过简单地减去0来轻松地将它们转换为数字。

即使对于前面的代码,最后的字符检查可能不是必要的,但我们还是在这里包含了它。当解析器例程找到**#**字符时,它将终止解析循环并返回最终的数值。

我们可以使用这个函数解析任何字符串并获取数值,但我们需要完整的字符串来将其转换为数字。

让我们考虑这个场景:字符串正在从网络连接接收,我们需要解析它并将其转换为数字。我们可能将字符保存到一个临时字符串中,然后调用前面的函数。

但还有一个问题:如果字符以每几秒一次的速度缓慢到达,那会怎样?因为这就是它们传输的方式?我们希望保持 CPU 忙碌,并在可能的情况下,在等待每个字符到达时执行其他任务(或多个任务)。

解决这个问题有不同的方法。我们可以创建一个线程并发处理字符串,但这对于这样一个简单的任务来说可能会在计算机时间上代价高昂。我们也可以使用std::async

解析协程

在本章中,我们正在使用协程,因此我们将使用 C++协程实现字符串解析。我们不需要额外的线程,并且由于协程的异步性质,在字符到达时执行任何其他处理将非常容易。

我们需要的解析协程的样板代码与我们在前面的示例中已经看到的代码几乎相同。解析器本身则相当不同。请看以下代码:

async_parse<int64_t, char> parse_string() {
    while (true) {
        char c = co_await char{ };
        int64_t number { };
        int64_t sign { 1 };
        if (c != '-' && c != '+' && !std::isdigit(c)) {
            continue;
        }
        if (c == '-') {
            sign = -1;
        }
        else if (std::isdigit(c)) {
            number = number * 10 + c - '0';
        }
        while (true) {
            c = co_await char{};
            if (std::isdigit(c)) {
                number = number * 10 + c - '0';
            }
            else {
                break;
            }
        }
        co_yield number * sign;
    }
}

我认为你现在可以轻松地识别返回类型(async_parse<int64_t, char>),并且知道解析协程会在等待输入字符时挂起。一旦解析完成,协程会在返回数字后挂起自己。

但你也会看到,前面的代码并不像我们第一次尝试将字符串解析为数字那样简单。

首先,解析协程逐个解析字符。它不获取完整的字符串来解析,因此有无限循环while (true)。我们不知道完整字符串中有多少个字符,因此我们需要继续接收和解析它们。

外层循环意味着协程将解析数字,一个接一个,随着字符的到达——永远。但请记住,它会挂起自己以等待字符,所以我们不会浪费 CPU 时间。

现在,一个字符到达。首先检查这个字符是否是我们数字的有效字符。如果字符既不是负号**-,也不是正号+**,也不是一个数字,那么解析器将等待下一个字符。

如果下一个字符是有效的,那么以下适用:

  • 如果是减号,我们将符号值更改为-1

  • 如果是加号,我们忽略它

  • 如果是数字,我们将其解析到数字中,使用与我们在解析器的第一个版本中看到的方法更新当前数字值。

在第一个有效字符之后,我们进入一个新的循环来接收其余的字符,无论是数字还是分隔符字符(#)。注意,当我们说有效字符时,我们是指对数值转换好的。我们仍然假设输入字符形成一个有效的数字,并且正确终止。

一旦数字被转换,它就会被协程产生,外层循环再次执行。这里需要一个终止字符,因为输入字符流在理论上是无尽的,它可以包含许多数字。

协程其余部分的代码可以在 GitHub 仓库中找到。它遵循任何其他协程相同的约定。首先,我们定义返回类型:

template <typename Out, typename In>
struct async_parse {
// …
};

我们使用模板以提高灵活性,因为它允许我们参数化输入和输出数据类型。在这种情况下,这些类型分别是int64_tchar

输入和输出数据项如下:

std::optional<In> input_data { };
Out output_data { };

对于输入,我们使用std::optional,因为我们需要一种方式来知道我们是否收到了一个字符。我们使用**put()**函数将字符发送到解析器:

 void put(char c) {
    handle.promise().input_data = c;
    if (!handle.done()) {
        handle.resume();
    }
}

这个函数只是将值赋给std::optional input_data变量。为了管理等待字符,我们实现以下 awaiter 类型:

auto await_transform(char) noexcept {
    struct awaiter {
        promise_type& promise;
        [[nodiscard]] bool await_ready() const noexcept {
            return promise.input_data.has_value();
        }
        [[nodiscard]] char await_resume() const noexcept {
            assert (promise.input_data.has_value());
            return *std::exchange(
                            promise.input_data,
                            std::nullopt);
        }
        void await_suspend(std::coroutine_handle<
                           promise_type>) const noexcept {
        }
    };
    return awaiter(*this);
}

awaiter结构体实现了两个函数来处理输入数据:

  • await_ready():如果可选的input_data变量包含有效值,则返回true。否则返回false

  • await_resume():返回存储在可选input_data变量中的值,并将其清空,赋值为std::nullopt

在本节中,我们看到了如何使用 C++协程实现一个简单的解析器。这是我们最后的示例,展示了使用协程的一个非常基本的流处理函数。在下一节中,我们将看到协程中的异常。

协程和异常

在前面的章节中,我们实现了一些基本示例来学习主要的 C++协程概念。我们首先实现了一个非常基本的协程,以了解编译器对我们有什么要求:返回类型(有时称为包装类型,因为它包装了承诺类型)和承诺类型。

即使对于这样一个简单的协程,我们也必须实现我们在编写示例时解释的一些函数。但有一个函数尚未解释:

void unhandled_exception() noexcept {}

我们当时假设协程不能抛出异常,但事实是它们可以。我们可以在**unhandled_exception()**函数体中添加处理异常的功能。

协程中的异常可能在创建返回类型或承诺类型对象时发生,也可能在协程执行时发生(就像正常函数一样,协程可以抛出异常)。

差别在于,如果在协程执行之前抛出异常,创建协程的代码必须处理该异常,而如果在协程执行时抛出异常,则调用unhandled_exception()

第一种情况只是通常的异常处理,没有调用特殊函数。我们可以在try-catch块中放置协程创建,并像我们通常在代码中那样处理可能的异常。

如果另一方面,调用了unhandled_exception()(在 promise 类型内部),我们必须在该函数内部实现异常处理功能。

处理此类异常有不同的策略。其中之一如下:

  • 重新抛出异常,这样我们就可以在 promise 类型之外(即在我们的代码中)处理它。

  • 终止程序(例如,调用std::terminate)。

  • 留下函数为空。在这种情况下,协程将崩溃,并且它很可能导致程序崩溃。

因为我们实现了非常简单的协程,所以我们留下了函数为空。

在本节的最后,我们介绍了协程的异常处理机制。正确处理异常非常重要。例如,如果你知道协程内部发生异常后无法恢复;那么,可能更好的做法是让协程崩溃,并在程序的另一部分(通常是从调用函数)处理异常。

概述

在本章中,我们介绍了协程,这是 C++中最近引入的一个特性,允许我们编写不需要创建新线程的异步代码。我们实现了一些简单的协程来解释 C++协程的基本要求。此外,我们还学习了如何实现生成器和字符串解析器。最后,我们看到了协程中的异常。

协程在异步编程中很重要,因为它们允许程序在特定点挂起执行并在稍后恢复,同时允许在此期间运行其他任务,所有这些都在同一个线程中运行。它们允许更好的资源利用,减少等待时间,并提高应用程序的可扩展性。

在下一章中,我们将介绍 Boost.Asio – 一个用于在 C++中编写异步代码的非常强大的库。

进一步阅读

  • C++协程入门 ,Andreas Fertig,Meeting C++在线,2024

  • 解码协程 ,Andreas Weiss,CppCon 2022

第四部分:使用 Boost 库的高级异步编程

在这部分,我们将学习使用强大的 Boost 库进行高级异步编程技术,使我们能够高效地管理与外部资源和系统级服务交互的任务。我们将探索Boost.AsioBoost.Cobalt库,了解它们如何简化异步应用程序的开发,同时提供对复杂过程(如任务管理和协程执行)的精细控制。通过实际示例,我们将看到 Boost.Asio 如何在单线程和多线程环境中处理异步 I/O 操作,以及 Boost.Cobalt 如何抽象出 C++20 协程的复杂性,使我们能够专注于功能而不是低级协程管理。

本部分包含以下章节:

  • 第九章使用 Boost.Asio 进行异步编程

  • 第十章使用 Boost.Cobalt 的协程

第九章:使用 Boost.Asio 进行异步编程

Boost.Asio 是 Boost 库家族中包含的一个 C++ 库,它简化了处理由操作系统(OS)管理的异步 输入/输出I/O)任务解决方案的开发,使得开发处理内部和外部资源(如网络通信服务或文件操作)的异步软件变得更加容易。

为了这个目的,Boost.Asio 定义了操作系统服务(属于并受操作系统管理的服务)、I/O 对象(提供对操作系统服务的接口)以及 I/O 执行上下文对象(一个充当服务注册表和代理的对象)。

在以下页面中,我们将介绍 Boost.Asio,描述其主要构建块,并解释一些在工业界广泛使用的开发异步软件的常见模式。

在本章中,我们将涵盖以下主要主题:

  • Boost.Asio 是什么,以及它是如何简化使用外部资源的异步编程的

  • I/O 对象和 I/O 执行上下文是什么,以及它们如何与操作系统服务以及彼此交互

  • Proactor 和 Reactor 设计模式是什么,以及它们与 Boost.Asio 的关系

  • 如何保持程序线程安全以及如何使用 strands 序列化任务

  • 如何使用缓冲区有效地将数据传递给异步任务

  • 如何取消异步操作

  • 使用计时器和网络应用程序的常见实践示例

技术要求

对于本章,我们需要安装 Boost C++ 库。本书编写时的最新版本是 Boost 1.85.0。以下是发布说明:

www.boost.org/users/history/version_1_85_0.html

对于 Unix 变体系统(Linux、macOS)的安装说明,请查看以下链接:

www.boost.org/doc/libs/1_85_0/more/getting_started/unix-variants.html

对于 Windows 系统,请查看以下链接:

www.boost.org/doc/libs/1_85_0/more/getting_started/windows.html

此外,根据我们想要开发的项目,我们可能需要配置 Boost.Asio 或安装依赖项:

www.boost.org/doc/libs/1_85_0/doc/html/boost_asio/using.html

本章中展示的所有代码都将由 C++20 版本支持。请查阅第三章中的技术要求部分,其中包含有关如何安装 GCC 13 和 Clang 8 编译器的指导。

您可以在以下 GitHub 仓库中找到完整的代码:

github.com/PacktPublishing/Asynchronous-Programming-with-CPP

本章的示例位于Chapter_09文件夹下。所有源代码文件都可以使用 CMake 编译,如下所示:

cmake . && cmake —build .

可执行二进制文件将在bin目录下生成。

什么是 Boost.Asio?

Boost.Asio是由 Chris Kohlhoff 创建的跨平台 C++库,它提供了一个可移植的网络和低级 I/O 编程,包括套接字、定时器、主机名解析、套接字 iostreams、串行端口、文件描述符和 Windows HANDLEs,提供了一个一致的异步模型。它还提供了协程支持,但正如我们在上一章所学,它们现在在 C++20 中可用,所以我们将只在本章中简要介绍。

Boost.Asio 允许程序在不显式使用线程和锁的情况下管理长时间运行的操作。此外,因为它在操作系统服务之上实现了一层,所以它允许可移植性、效率、易用性和可扩展性,使用最合适的底层操作系统机制来实现这些目标,例如,分散-聚集 I/O 操作或在移动数据的同时最小化昂贵的复制。

让我们从学习 Boost.Asio 的基本块、I/O 对象和 I/O 执行上下文对象开始。

I/O 对象

有时,应用程序需要访问操作系统服务,在这些服务上运行异步任务,并收集结果或错误。Boost.Asio提供了一个由 I/O 对象和 I/O 执行上下文对象组成的机制,以允许这种功能。

I/O 对象是表示执行 I/O 操作的实体任务的面向任务的 I/O 对象。正如我们在图 9.1中可以看到,Boost.Asio 提供了核心类来管理并发、流、缓冲区或其他核心功能,并为库提供通过传输控制协议/互联网协议TCP/IP)、用户数据报协议UDP)或互联网控制消息协议ICMP)进行网络通信的可移植网络类,还包括定义安全层、传输协议和串行端口等任务的类,以及针对特定平台设置的特定类,以处理底层操作系统。

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/async-prog-cpp/img/B22219_09_1.jpg

图 9.1 – I/O 对象

I/O 对象不会直接在操作系统中执行其任务。它们需要通过 I/O 执行上下文对象与操作系统进行通信。上下文对象的一个实例作为 I/O 对象构造函数的第一个参数传递。在这里,我们定义了一个 I/O 对象(一个三秒后到期的定时器)并通过其构造函数传递一个 I/O 执行上下文对象(io_context):

#include <boost/asio.hpp>
#include <chrono>
using namespace std::chrono_literals;
boost::asio::io_context io_context;
boost::asio::steady_timer timer(io_context, 3s);

大多数 I/O 对象都有以 async_ 开头的方法名。这些方法触发异步操作,当操作完成时将调用完成处理程序,这是一个作为方法参数传递的可调用对象。这些方法立即返回,不会阻塞程序流程。当前线程可以在任务未完成时继续执行其他任务。一旦完成,完成处理程序将被调用并执行,处理异步任务的结果或错误。

I/O 对象还提供了阻塞的对应方法,这些方法将阻塞直到完成。这些方法不需要作为参数接收处理程序。

如前所述,请注意,I/O 对象不直接与操作系统交互;它们需要一个 I/O 执行上下文对象。让我们来了解这类对象。

I/O 执行上下文对象

要访问 I/O 服务,程序至少使用一个表示操作系统 I/O 服务的网关的 I/O 执行上下文对象。它使用 boost::asio::io_context 类实现,为 I/O 对象提供操作系统服务的核心 I/O 功能。在 Windows 上,boost::asio::io_context 基于 I/O completion ports ( IOCP ),在 Linux 上,它基于 epoll,在 FreeBSD/macOS 上,它基于 kqueue

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/async-prog-cpp/img/B22219_09_2.jpg

图 9.2 – Boost.Asio 架构

boost::asio::io_contextboost::asio::execution_context 的子类,它是函数对象执行的基础类,也被其他执行上下文对象继承,例如 boost::asio::thread_poolboost::asio::system_context。在本章中,我们将使用 boost::asio::io_context 作为我们的执行上下文对象。

自 1.66.0 版本以来,boost::asio::io_context 类已经取代了 boost::asio::io_service 类,采用了更多来自 C++ 的现代特性和实践。boost::asio::io_service 仍然可用于向后兼容。

如前所述,Boost.Asio 对象可以使用以 async_ 开头的方法来调度异步操作。当所有异步任务都调度完毕后,程序需要调用 boost::asio::io_context::run() 函数来执行事件处理循环,允许操作系统处理任务并将结果传递给程序,并触发处理程序。

回到我们之前的例子,我们现在将设置完成处理程序,on_timeout(),这是一个可调用对象(在这种情况下是一个函数),我们在调用异步的 async_wait() 函数时将其作为参数传递。以下是代码示例:

#include <boost/asio.hpp>
#include <iostream>
void on_timeout(const boost::system::error_code& ec) {
    if (!ec) {
        std::cout << "Timer expired.\n" << std::endl;
    } else {
        std::cerr << "Error: " << ec.message() << '\n';
    }
}
int main() {
    boost::asio::io_context io_context;
    boost::asio::steady_timer timer(io_context,
                              std::chrono::seconds(3));
    timer.async_wait(&on_timeout);
    io_context.run();
    return 0;
}

运行此代码后,我们应该在三个秒后在控制台看到消息 Timer expired.,或者在异步调用因任何原因失败时显示错误消息。

**boost::io_context::run()**是一个阻塞调用。这是为了保持事件循环运行,允许异步操作运行,并防止程序退出。显然,这个函数可以在新线程中调用,并让主线程保持未阻塞以继续其他任务,正如我们在前面的章节中看到的。

当没有挂起的异步操作时,boost::io_context::run()将返回。有一个模板类,boost::asio::executor_work_guard,可以在需要时保持io_context忙碌并避免其退出。让我们通过一个示例看看它是如何工作的。

让我们先定义一个后台任务,该任务将在等待两秒钟后通过io_context使用**boost::asio::io_context::post()**函数发布一些工作:

#include <boost/asio.hpp>
#include <chrono>
#include <iostream>
#include <thread>
using namespace std::chrono_literals;
void background_task(boost::asio::io_context& io_context) {
    std::this_thread::sleep_for(2s);
    std::cout << "Posting a background task.\n";
    io_context.post([]() {
        std::cout << "Background task completed!\n";
    });
}

main()函数中,创建了io_context对象,并使用该io_context对象构造了一个work_guard对象。

然后,创建了两个线程,io_thread,其中io_context运行,和worker,其中background_task()将运行。我们还像之前解释的那样,将io_context作为引用传递给后台任务以发布工作。

在此基础上,主线程进行了一些工作(等待五秒钟),然后通过调用其reset()函数移除工作保护,让io_context退出其**run()**函数,并在退出之前加入两个线程,如所示:

int main() {
    boost::asio::io_context io_context;
    auto work_guard = boost::asio::make_work_guard(
                      io_context);
    std::thread io_thread([&io_context]() {
        std::cout << "Running io_context.\n";
        io_context.run();
        std::cout << "io_context stopped.\n";
    });
    std::thread worker(background_task,
                       std::ref(io_context));
    // Main thread doing some work.
    std::this_thread::sleep_for(5s);
    std::cout << "Removing work_guard." << std::endl;
    work_guard.reset();
    worker.join();
    io_thread.join();
    return 0;
}

如果我们运行前面的代码,这是输出:

Running io_context.
Posting a background task.
Background task completed!
Removing work_guard.
io_context stopped.

我们可以看到后台线程如何正确地发布后台任务,并且这个任务在移除工作保护并停止 I/O 上下文对象的执行之前完成。

另一种保持io_context对象活跃并处理请求的方法是通过不断调用**async_**函数或从完成处理程序发布工作。这在读取或写入套接字或流时是一个常见的模式:

#include <boost/asio.hpp>
#include <chrono>
#include <functional>
#include <iostream>
using namespace std::chrono_literals;
int main() {
    boost::asio::io_context io_context;
    boost::asio::steady_timer timer(io_context, 3s);
    std::function<void(const boost::system::error_code&)>
                  timer_handler;
    timer_handler = &timer, &timer_handler {
        if (!ec) {
            std::cout << "Handler: Timer expired.\n";
            timer.expires_after(1s);
            timer.async_wait(timer_handler);
        } else {
            std::cerr << "Handler error: "
                      << ec.message() << std::endl;
        }
    };
    timer.async_wait(timer_handler);
    io_context.run();
    return 0;
}

在这种情况下,timer_handler是一个作为 lambda 函数定义的完成处理程序,它捕获了计时器和自身。每秒钟,当计时器到期时,它打印处理程序:计时器已过期的消息,并通过将新的异步任务(使用async_wait()函数)入队到io_context对象中通过计时器对象来重启自己。

如我们所见,io_context对象可以从任何线程运行。默认情况下,此对象是线程安全的,但在某些场景中,如果我们想要更好的性能,我们可能想要避免这种安全性。这可以在其构造过程中进行调整,正如我们将在下一节中看到的。

并发提示

io_context构造函数接受一个并发提示作为参数,建议实现使用多少个活动线程来运行完成处理程序。

默认情况下,此值为BOOST_ASIO_CONCURRENCY_HINT_SAFE(值1),表示io_context对象将从一个线程运行,由于这个事实,可以启用一些优化。但这并不意味着io_context只能从单个线程使用;它仍然提供线程安全,并且可以使用来自多个线程的 I/O 对象。

可以指定的其他值如下:

  • BOOST_ASIO_CONCURRENCY_HINT_UNSAFE:禁用锁定,因此对io_context或 I/O 对象的操作必须在同一线程中发生。

  • BOOST_ASIO_CONCURRENCY_HINT_UNSAFE_IO:在反应器中禁用锁定,但在调度器中保持锁定,因此io_context对象中的所有操作都可以使用除**run()**函数和其他与执行事件处理循环相关的方法之外的不同线程。我们将在解释库背后的设计原则时了解调度器和反应器。

现在我们来了解事件处理循环是什么以及如何管理它。

事件处理循环

使用**boost::asio::io_context::run()**方法,io_context会阻塞并持续处理 I/O 异步任务,直到所有任务都已完成并且通知了完成处理程序。这个 I/O 请求处理是在内部事件处理循环中完成的。

有其他方法可以控制事件循环并避免在所有异步事件处理完毕之前阻塞。这些方法如下:

  • poll:运行事件处理循环以执行就绪处理程序

  • poll_one:运行事件处理循环以执行一个就绪处理程序

  • run_for:运行事件处理循环以指定的时间段

  • run_until:与上一个相同,但仅限于指定的时间

  • run_one:运行事件处理循环以执行最多一个处理程序

  • run_one_for:与上一个相同,但仅限于指定的时间段

  • run_one_until:与上一个相同,但仅限于指定的时间

事件循环也可以通过调用**boost::asio::io_context::stop()方法或通过调用boost:asio::io_context::stopped()**来检查其状态是否已停止来停止。

当事件循环没有运行时,已经安排的任务将继续执行。其他任务将保持挂起。可以通过再次使用前面提到的方法之一启动事件循环来恢复挂起的任务并收集挂起的结果。

在之前的示例中,应用程序通过调用异步方法或使用post()函数将一些工作发送到io_context。现在让我们了解**dispatch()及其与post()**的区别。

向 io_context 分配一些工作

除了通过来自不同 I/O 对象的异步方法或使用executor_work_guard(下面将解释)将工作发送到io_context之外,我们还可以使用boost::asio::post()boost::asio::dispatch()模板方法。这两个函数都用于将一些工作调度到io_context对象中。

post() 函数保证任务将被执行。它将完成处理程序放入执行队列,最终将被执行:

boost::asio::io_context io_context;
io_context.post([] {
    std::cout << "This will always run asynchronously.\n";
});

另一方面,如果 io_context 或 strand(本章后面将详细介绍 strand)与任务被派发的同一线程相同,则 dispatch() 可能会立即执行任务,否则将其放入队列以异步执行:

boost::asio::io_context io_context;
io_context.dispatch([] {
    std::cout << "This might run immediately or be queued.\n";
});

因此,使用 dispatch(),我们可以通过减少上下文切换或队列延迟来优化性能。

已派发的事件可以直接从当前工作线程执行,即使队列中还有其他挂起的事件。必须始终由 I/O 执行上下文管理已发布的事件,等待其他处理程序完成,然后才能执行。

现在我们已经学习了某些基本概念,让我们了解同步和异步操作在底层是如何工作的。

与操作系统交互

Boost.Asio 可以使用同步和异步操作与 I/O 服务交互。让我们了解它们的行为以及主要区别是什么。

同步操作

如果程序想以同步方式使用 I/O 服务,通常,它将创建一个 I/O 对象并使用其同步操作方法:

boost::asio::io_context io_context;
boost::asio::steady_timer timer(io_context, 3s);
timer.wait();

当调用 timer.wait() 时,请求被发送到 I/O 执行上下文对象(io_context),该对象调用操作系统执行操作。一旦操作系统完成任务,它将结果返回给 io_context,然后 io_context 将结果或错误(如果有任何问题)转换回 I/O 对象(定时器)。错误类型为 boost::system::error_code。如果发生错误,将抛出异常。

如果我们不希望抛出异常,我们可以通过引用将错误对象传递给同步方法以捕获操作状态并在之后进行检查:

boost::system::error_code ec;
Timer.wait(server_endpoint, ec);

异步操作

在异步操作的情况下,我们还需要向异步方法传递一个完成处理程序。这个完成处理程序是一个可调用对象,当异步操作完成时,I/O 上下文对象将调用它,通知程序结果或操作错误。其签名如下:

void completion_handler(
     const boost::system::error_code& ec);

继续以定时器为例,现在,我们需要调用异步操作:

socket.async_wait(completion_handler);

再次强调,I/O 对象(定时器)将请求转发到 I/O 执行上下文对象(io_context)。io_context 向操作系统请求启动异步操作。

当操作完成时,操作系统将结果放入队列,其中 io_context 正在监听。然后,io_context 取出结果,将错误转换为错误代码对象,并触发完成处理程序以通知程序任务完成和结果。

为了允许 io_context 跟进这些步骤,程序必须执行 boost::asio::io_context::run()(或之前介绍过的类似函数,这些函数管理事件处理循环)并阻塞当前线程,以处理任何未完成的异步操作。如前所述,如果没有挂起的异步操作,boost::asio::io_context::run() 将退出。

完成处理器(Completion handlers)需要是可复制的,这意味着必须有一个复制构造函数可用。如果需要临时资源(如内存、线程或文件描述符),则在调用完成处理器之前释放该资源。这允许我们在不重叠资源使用的情况下调用相同的操作,从而避免增加系统的峰值资源使用。

错误处理

如前所述,Boost.Asio 允许用户以两种不同的方式处理错误:使用错误代码或抛出异常。如果我们调用 I/O 对象方法时传递一个对 boost::system::error_code 对象的引用,则实现将通过该变量传递错误;否则,将抛出异常。

我们已经通过检查错误代码实现了第一个方法的一些示例。现在让我们看看如何捕获异常。

以下示例创建了一个持续三秒钟的计时器。io_context 对象由后台线程 io_thread 运行。当计时器通过调用其 async_wait() 函数启动异步任务时,它传递了 boost::asio::use_future 参数,因此函数返回一个未来对象 fut,稍后在该 try-catch 块内部调用其 get() 函数以检索存储的结果或异常,正如我们在 第六章 中所学。在启动异步操作后,主线程等待一秒钟,然后计时器通过调用其 cancel() 函数取消操作。由于这发生在其到期时间(三秒钟)之前,因此会抛出异常:

#include <boost/asio.hpp>
#include <chrono>
#include <future>
#include <iostream>
#include <thread>
using namespace std::chrono_literals;
int main() {
    boost::asio::io_context io_context;
    boost::asio::steady_timer timer(io_context, 1s);
    auto fut = timer.async_wait(
                     boost::asio::use_future);
    std::thread io_thread([&io_context]() {
                        io_context.run();
    });
    std::this_thread::sleep_for(3s);
    timer.cancel();
    try {
        fut.get();
        std::cout << "Timer expired successfully!\n";
    } catch (const boost::system::system_error& e) {
        std::cout << "Timer failed: "
                  << e.code().message() << '\n';
    }
    io_thread.join();
    return 0;
}

类型为 boost::system::system_error 的异常被捕获,并打印出其消息。如果在异步操作完成后(在这个例子中,通过让主线程休眠超过三秒钟),计时器取消其操作,计时器将成功到期,不会抛出异常。

现在我们已经看到了 Boost.Asio 的主要构建块以及它们是如何相互作用的,让我们回顾一下并理解其实现背后的设计模式。

反应器(Reactor)和执行者(Proactor)设计模式

当使用事件处理应用程序时,我们可以遵循两种方法来设计并发解决方案:反应器(Reactor)和执行者(Proactor)设计模式。

这些模式描述了处理事件所遵循的机制,表明了这些事件是如何被发起、接收、解多路复用和分派的。当系统收集和排队来自不同资源的 I/O 事件时,解多路复用这些事件意味着将它们分离以分派到正确的处理程序。

Reactor 模式同步和串行地解多路复用和调度服务请求。它通常遵循非阻塞同步 I/O 策略,如果操作可以执行,则返回结果;如果系统没有资源完成操作,则返回错误。

另一方面,Proactor 模式允许通过立即将控制权返回给调用者,以高效异步的方式解多路复用和调度服务请求,表明操作已启动。然后,被调用的系统将在操作完成时通知调用者。

因此,Proactor 模式在两个任务之间分配责任:执行异步的长时操作和完成处理程序,处理结果并通常调用其他异步操作。

Boost.Asio 通过以下元素实现 Proactor 设计模式:

  • 发起者:一个 I/O 对象,用于启动异步操作。

  • 异步操作:由操作系统异步运行的任务。

  • 异步操作处理器:这执行异步操作,并将结果排队在完成事件队列中。

  • 完成事件队列:一个事件队列,异步操作处理器将事件推入其中,而异步事件从队列中取出。

  • 异步事件解多路复用器:这会阻塞 I/O 上下文,等待事件,并将完成的事件返回给调用者。

  • 完成处理程序:一个可调用的对象,将处理异步操作的结果。

  • Proactor:这调用异步事件解多路复用器来出队事件并将它们分派给完成处理程序。这正是 I/O 执行上下文所做的事情。

图 9 .3 清楚地显示了所有这些元素之间的关系:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/async-prog-cpp/img/B22219_09_3.jpg

图 9.3 – Proactor 设计模式

Proactor 模式在封装并发机制的同时,增加了关注点的分离,简化了应用程序的同步,并提高了性能。

另一方面,我们无法控制异步操作是如何或何时被调度,以及操作系统将如何高效地执行这些操作。此外,由于完成事件队列和调试和测试的复杂性增加,内存使用量也有所增加。

Boost.Asio 设计的另一个方面是执行上下文对象的线程安全性。现在让我们深入了解 Boost.Asio 中的线程是如何工作的。

使用 Boost.Asio 的多线程

I/O 执行上下文对象是线程安全的;它们的方法可以从不同的线程安全调用。这意味着我们可以使用单独的线程来运行阻塞的 io_context.run() 方法,并让主线程保持未阻塞状态,以便继续执行其他无关任务。

现在我们来解释如何根据使用线程的方式配置异步应用程序的不同方法。

单线程方法

任何 Boost.Asio 应用程序的起点和首选解决方案都应遵循单线程方法,其中 I/O 执行上下文对象在处理完成处理程序的同一线程中运行。这些处理程序必须是短小且非阻塞的。以下是一个在 I/O 上下文和主线程中运行的稳定定时器完成处理程序的示例:

#include <boost/asio.hpp>
#include <chrono>
#include <iostream>
using namespace std::chrono_literals;
void handle_timer_expiry(
            const boost::system::error_code& ec) {
    if (!ec) {
        std::cout << "Timer expired!\n";
    } else {
        std::cerr << "Error in timer: "
                  << ec.message() << std::endl;
    }
}
int main() {
    boost::asio::io_context io_context;
    boost::asio::steady_timer timer(io_context,
                              std::chrono::seconds(1));
    timer.async_wait(&handle_timer_expiry);
    io_context.run();
    return 0;
}

如我们所见,steady_timer 定时器在执行 io_context.run() 函数的同一线程中调用异步的 async_wait() 函数,设置 handle_timer_expiry() 完成处理程序。当异步函数完成后,其完成处理程序将在同一线程中运行。

由于完成处理程序在主线程中运行,其执行应该快速,以避免冻结主线程和其他程序应执行的相关任务。在下一节中,我们将学习如何处理长时间运行的任务或完成处理程序,并保持主线程的响应性。

线程化长时间运行的任务

对于长时间运行的任务,我们可以保留主线程中的逻辑,但使用其他线程传递工作和将结果返回到主线程:

#include <boost/asio.hpp>
#include <iostream>
#include <thread>
void long_running_task(boost::asio::io_context& io_context,
                       int task_duration) {
    std::cout << "Background task started: Duration = "
              << task_duration << " seconds.\n";
    std::this_thread::sleep_for(
                      std::chrono::seconds(task_duration));
    io_context.post([&io_context]() {
        std::cout << "Background task completed.\n";
        io_context.stop();
    });
}
int main() {
    boost::asio::io_context io_context;
    auto work_guard = boost::asio::make_work_guard
                                        (io_context);
    io_context.post([&io_context]() {
        std::thread t(long_running_task,
                      std::ref(io_context), 2);
        std::cout << "Detaching thread" << std::endl;
        t.detach();
    });
    std::cout << "Running io_context...\n";
    io_context.run();
    std::cout << "io_context exit.\n";
    return 0;
}

在此示例中,在创建 io_context 之后,使用工作保护来避免在发布任何工作之前立即返回 io_context.run() 函数。

发布的工作包括创建一个 t 线程以在后台运行 long_running_task() 函数。在 lambda 函数退出之前,该 t 线程被分离;否则,程序将终止。

在后台任务函数中,当前线程会暂停给定的时间,然后向 io_context 对象中发布另一个任务以打印消息并停止 io_context 本身。如果我们不调用 io_context.stop(),事件处理循环将无限期地继续运行,程序将无法结束,因为 io_context.run() 将由于工作保护而继续阻塞。

每个线程一个 I/O 执行上下文对象

这种方法类似于单线程方法,其中每个线程都有自己的 io_context 对象,并处理短小且非阻塞的完成处理程序:

#include <boost/asio.hpp>
#include <chrono>
#include <iostream>
#include <syncstream>
#include <thread>
#define sync_cout std::osyncstream(std::cout)
using namespace std::chrono_literals;
void background_task(int i) {
    sync_cout << "Thread " << i << ": Starting...\n";
    boost::asio::io_context io_context;
    auto work_guard =
              boost::asio::make_work_guard(io_context);
    sync_cout << "Thread " << i << ": Setup timer...\n";
    boost::asio::steady_timer timer(io_context, 1s);
    timer.async_wait(
        & {
            if (!ec) {
                sync_cout << "Timer expired successfully!"
                          << std::endl;
            } else {
                sync_cout << "Timer error: "
                          << ec.message() << ‚\n';
        }
        work_guard.reset();
    });
    sync_cout << "Thread " << i << ": Running
                      io_context...\n";
    io_context.run();
}
int main() {
    const int num_threads = 4;
    std::vector<std::jthread> threads;
    for (auto i = 0; i < num_threads; ++i) {
        threads.emplace_back(background_task, i);
    }
    return 0;
}

在此示例中,创建了四个线程,每个线程运行 background_task() 函数,其中创建了一个 io_context 对象,并设置了一个定时器,在经过一秒后超时,并与其完成处理程序一起停止。

单个 I/O 执行上下文对象的多线程

现在,只有一个io_context对象,但它从不同的线程启动不同的 I/O 对象异步任务。在这种情况下,完成处理程序可以从这些线程中的任何一个被调用。以下是一个例子:

#include <boost/asio.hpp>
#include <chrono>
#include <iostream>
#include <syncstream>
#include <thread>
#include <vector>
#define sync_cout std::osyncstream(std::cout)
using namespace std::chrono_literals;
void background_task(int task_id) {
    boost::asio::post([task_id]() {
        sync_cout << "Task " << task_id
                  << " is being handled in thread "
                  << std::this_thread::get_id()
                  << std::endl;
        std::this_thread::sleep_for(2s);
        sync_cout << "Task " << task_id
                  << " complete.\n";
    });
}
int main() {
    boost::asio::io_context io_context;
    auto work_guard = boost::asio::make_work_guard(
                                   io_context);
    std::jthread io_context_thread([&io_context]() {
        io_context.run();
    });
    const int num_threads = 4;
    std::vector<std::jthread> threads;
    for (int i = 0; i < num_threads; ++i) {
        background_task(i);
    }
    std::this_thread::sleep_for(5s);
    work_guard.reset();
    return 0;
}

在这个例子中,只创建并运行了一个io_context对象,并在一个单独的线程io_context_thread中执行。然后,创建了另外四个后台线程,工作被提交到io_context对象中。最后,主线程等待五秒钟,让所有线程完成它们的工作,并重置工作保护器,如果没有任何待处理的工作,则让io_context.run()函数返回。当程序退出时,所有线程自动合并,因为它们是std::jthread的实例。

并行化一个 I/O 执行上下文所做的工作

在上一个例子中,使用了一个独特的 I/O 执行上下文对象,其**run()**函数从不同的线程中被调用。然后,每个线程提交了一些工作,这些工作由完成处理程序在完成时在可用的线程中执行。

这是一种常见的并行化一个 I/O 执行上下文所做工作的方法,通过从多个线程调用其run()函数,将异步操作的处理分配给这些线程。这是可能的,因为io_context对象提供了一个线程安全的事件分发系统。

这里还有一个例子,其中创建了一个线程池,每个线程运行io_context.run(),使这些线程竞争从队列中拉取任务并执行它们。在这种情况下,仅使用一个在两秒后到期的计时器创建了一个异步任务。其中一个线程将拾取该任务并执行它:

#include <boost/asio.hpp>
#include <iostream>
#include <thread>
#include <vector>
using namespace std::chrono_literals;
int main() {
    boost::asio::io_context io_context;
    boost::asio::steady_timer timer(io_context, 2s);
    timer.async_wait(
        [](const boost::system::error_code& /*ec*/) {
            std::cout << "Timer expired!\n";
    });
    const std::size_t num_threads =
                std::thread::hardware_concurrency();
    std::vector<std::thread> threads;
    for (std::size_t i = 0;
         i < std::thread::hardware_concurrency(); ++i) {
            threads.emplace_back([&io_context]() {
                io_context.run();
            });
    }
    for (auto& t : threads) {
        t.join();
    }
    return 0;
}

这种技术提高了可伸缩性,因为应用程序更好地利用了多个核心,并通过并发处理异步任务来降低延迟。此外,通过减少单线程代码处理许多同时进行的 I/O 操作时产生的瓶颈,可以减少竞争并提高吞吐量。

注意,完成处理程序也必须使用同步原语,并且如果它们在不同的线程之间共享或修改共享资源,则必须是线程安全的。

此外,不能保证完成处理程序执行的顺序。由于可以同时运行许多线程,任何一个线程都可能先完成并调用其相关的完成处理程序。

由于线程正在竞争从队列中拉取任务,如果线程池的大小不是最优的,可能会出现潜在的锁竞争或上下文切换开销,理想情况下应与硬件线程的数量相匹配,就像在这个例子中所做的那样。

现在,是时候了解对象的生存期如何影响我们使用 Boost.Asio 开发的异步程序稳定性了。

管理对象的生存期

异步操作可能引发的主要灾难性问题之一是,当操作进行时,一些必需的对象已经被销毁。因此,管理对象的生命周期至关重要。

在 C++中,一个对象的生命周期从构造函数结束开始,到析构函数开始结束。

保持对象存活的一个常用模式是让对象为自己创建一个指向自身的共享指针实例,确保只要存在指向该对象的共享指针,对象就保持有效,这意味着有持续进行的异步操作需要该对象。

这种技术被称为shared-from-this,它使用 C++11 以来可用的std::enable_shared_from_this模板基类,该基类提供了对象用来获取自身共享指针的**shared_from_this()**方法。

实现回声服务器 – 示例

让我们通过创建一个回声服务器来看看它是如何工作的。同时,我们将讨论这项技术,我们还将学习如何使用 Boost.Asio 进行网络编程。

在网络中传输数据可能需要很长时间才能完成,并且可能会发生几个错误。这使得网络 I/O 服务成为 Boost.Asio 处理的一个特殊良好案例。网络 I/O 服务是库中最早包含的服务之一。

在工业界,Boost.Asio 的主要常见用途是开发网络应用程序,因为它支持互联网协议 TCP、UDP 和 ICMP。该库还提供了一个基于伯克利软件发行版BSD)套接字 API 的套接字接口,以允许使用低级接口开发高效和可扩展的应用程序。

然而,由于在这本书中我们关注的是异步编程,让我们专注于使用高级接口实现回声服务器。

回声服务器是一个监听特定地址和端口的程序,并将从该端口读取的所有内容写回。为此,我们将创建一个 TCP 服务器。

主程序将简单地创建一个io_context对象,通过传递io_context对象和一个要监听的端口号来设置EchoServer对象,并调用**io_context.run()**来启动事件处理循环:

#include <boost/asio.hpp>
#include <memory>
constexpr int port = 1234;
int main() {
    try {
        boost::asio::io_context io_context;
        EchoServer server(io_context, port);
        io_context.run();
    } catch (std::exception& e) {
        std::cerr << "Exception: " << e.what() << "\n";
    }
    return 0;
}

EchoServer初始化时,它将开始监听传入的连接。它是通过使用一个boost::asio::tcp::acceptor对象来做到这一点的。这个对象通过其构造函数接受一个io_context对象(对于 I/O 对象来说通常是这样的)和一个boost::asio::tcp::endpoint对象,该对象指示用于监听的连接协议和端口号。由于使用了boost::asio::tcp::v4()对象来初始化端点对象,因此EchoServer将使用的协议是 IPv4。没有指定给端点构造函数的 IP 地址,因此端点 IP 地址将是任何地址(IPv4 的INADDR_ANY或 IPv6 的in6addr_any)。接下来,实现EchoServer构造函数的代码如下:

using boost::asio::ip::tcp;
class EchoServer {
   public:
    EchoServer(boost::asio::io_context& io_context,
               short port)
        : acceptor_(io_context,
                    tcp::endpoint(tcp::v4(),
                    port)) {
        do_accept();
    }
   private:
    void do_accept() {
        acceptor_.async_accept(this {
            if (!ec) {
                std::make_shared<Session>(
                    std::move(socket))->start();
            }
            do_accept();
        });
    }
    tcp::acceptor acceptor_;
};

EchoServer 构造函数在设置好接受者对象后调用 do_accept() 函数。do_accept() 函数调用 async_accept() 函数等待传入的连接。当客户端连接到服务器时,操作系统通过 io_context 对象返回连接的套接字(boost::asio::tcp::socket)或错误。

如果没有错误并且建立了连接,就创建一个 Session 对象的共享指针,将套接字移动到 Session 对象中。然后,Session 对象运行 start() 函数。

Session 对象封装了特定连接的状态,在本例中是 socket_ 对象和 data_ 缓冲区。它还通过使用 do_read()do_write() 管理对该缓冲区的异步读取和写入,我们将在稍后实现它们。但在那之前,注释说明 Session 继承自 std::enable_shared_from_this,允许 Session 对象创建指向自身的共享指针,确保会话对象在整个异步操作的生命周期中保持活跃,只要至少有一个共享指针指向管理该连接的 Session 实例。这个共享指针是 EchoServer 对象中的 do_accept() 函数在建立连接时创建的。以下是 Session 类的实现:

class Session
    : public std::enable_shared_from_this<Session>
{
   public:
    Session(tcp::socket socket)
        : socket_(std::move(socket)) {}
    void start() { do_read(); }
   private:
    static const size_t max_length = 1024;
    void do_read();
    void do_write(std::size_t length);
    tcp::socket socket_;
    char data_[max_length];
};

使用 Session 类允许我们将管理连接的逻辑与管理服务器的逻辑分开。EchoServer 只需要接受连接并为每个连接创建一个 Session 对象。这样,服务器可以管理多个客户端,保持它们的连接独立并异步管理。

Session 是使用 do_read()do_write() 函数管理该连接行为的对象。当 Session 开始时,它的 start() 函数调用 do_read() 函数,如下所示:

void Session::do_read() {
    auto self(shared_from_this());
    socket_.async_read_some(boost::asio::buffer(data_,
                                          max_length),
        this, self {
            if (!ec) {
                do_write(length);
            }
        });
}

do_read() 函数创建当前会话对象(self)的共享指针,并使用套接字的 async_read_some() 异步函数将一些数据读取到 data_ 缓冲区。如果操作成功,此操作将返回复制到 data_ 缓冲区的数据以及读取的字节数存储在 length 变量中。

然后,使用那个 length 变量调用 do_write(),通过使用 async_write() 函数异步地将 data_ 缓冲区的内容写入套接字。当这个异步操作成功时,它通过再次调用 do_read() 函数来重启循环,如下所示:

void Session::do_write(std::size_t length) {
    auto self(shared_from_this());
    boost::asio::async_write(socket_,
                             boost::asio::buffer(data_,
                                                length),
        this, self {
            if (!ec) {
                do_read();
            }
        });
}

你可能会想知道为什么定义了self但没有使用它。它看起来self是多余的,但作为 lambda 函数按值捕获它,会创建一个副本,增加对this对象的共享指针的引用计数,确保如果 lambda 是活跃的,会话不会被销毁。this对象被捕获以在 lambda 函数中提供对其成员的访问。

作为练习,尝试实现一个stop()函数,以中断do_read()do_write()之间的循环。一旦所有异步操作完成并且 lambda 函数退出,self对象将被销毁,并且没有其他共享指针指向Session对象,因此会话将被销毁。

这种模式确保在异步操作期间对对象生命周期的健壮和安全管理,避免了悬垂指针或过早销毁,这可能导致不期望的行为或崩溃。

要测试此服务器,只需启动服务器,打开一个新的终端,并使用telnet命令连接到服务器并向其发送数据。作为参数,我们可以传递localhost地址,表示我们正在连接到同一台机器上运行的服务器(IP 地址为127.0.0.1)和端口号,在这种情况下,1234

telnet命令将启动并显示一些关于连接的信息,并指示我们需要按下Ctrl + *}*键来关闭连接。

输入任何内容并按Enter键将发送输入的行到回显服务器,服务器将监听并发送回相同的内容;在这个例子中,将是Hello world!

只需关闭连接并使用quit命令退出telnet回到终端:

$ telnet localhost 1234
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
Hello world!
Hello world!
telnet> quit
Connection closed.

在这个例子中,我们已经使用了一个缓冲区。让我们在下一节中了解更多关于它们的信息。

使用缓冲区传输数据

缓冲区是在 I/O 操作期间用于传输数据的连续内存区域。

Boost.Asio 定义了两种类型的缓冲区:可变缓冲区boost::asio::mutable_buffer),其中可以写入数据,和常量缓冲区boost::asio::const_buffers),用于创建只读缓冲区。可变缓冲区可以转换为常量缓冲区,但不能反向转换。这两种类型的缓冲区都提供了防止越界的保护。

此外,还有boost::buffer函数,用于从不同数据类型(原始内存的指针和大小、字符串(std::string)、或原始数据POD)结构(意味着一个没有用户定义的复制赋值运算符或析构函数的类型、结构或类,并且没有私有或受保护的非静态数据成员)的数组或向量)创建可变或常量缓冲区。例如,要从字符数组创建缓冲区,我们可以使用以下代码:

char data[1024];
mutable_buffer buffer = buffer(data, sizeof(data));

此外,请注意,缓冲区的所有权和生命周期是程序的责任,而不是 Boost.Asio 库的责任。

Scatter-gather 操作

通过使用 scatter-gather 操作,可以有效地使用缓冲区,其中多个缓冲区一起用于接收数据(scatter-read)或发送数据(gather-write)。

Scatter-read是从唯一源读取数据到不同的非连续内存缓冲区的过程。

Gather-write是相反的过程;数据从不同的非连续内存缓冲区中收集并写入单个目标。

这些技术通过减少系统调用或数据复制的次数来提高效率和性能。它们不仅用于 I/O 操作,还用于其他用例,例如数据处理、机器学习或并行算法,如排序或矩阵乘法。

为了允许 scatter-gather 操作,可以将多个缓冲区一起传递到容器中的异步操作内部(std::vectorstd::liststd::arrayboost::array)。

这里是一个 scatter-read 的示例,其中套接字异步地将一些数据读取到buf1buf2缓冲区中:

std::array<char, 128> buf1, buf2;
std::vector<boost::asio::mutable_buffer> buffers = {
    boost::asio::buffer(buf1),
    boost::asio::buffer(buf2)
};
socket.async_read_some(buffers, handler);

这里是如何实现 gather-read 的:

std::array<char, 128> buf1, buf2;
std::vector<boost::asio::const_buffer> buffers = {
    boost::asio::buffer(buf1),
    boost::asio::buffer(buf2)
};
socket.async_write_some(buffers, handler);

现在,套接字执行相反的操作,将两个缓冲区中的数据写入套接字缓冲区以进行异步发送。

流缓冲区

我们还可以使用流缓冲区来管理数据。Stream buffersboost::asio::basic_streambuf类定义,基于std::basic_streambuf C++类,并在****头文件中定义。它允许一个动态缓冲区,其大小可以适应传输的数据量。

让我们看看以下示例中流缓冲区如何与 scatter-gather 操作一起工作。在这种情况下,我们正在实现一个 TCP 服务器,该服务器监听并接受来自给定端口的客户端连接,将客户端发送的消息读取到两个流缓冲区中,并将它们的内容打印到控制台。由于我们感兴趣的是理解流缓冲区和 scatter-gather 操作,让我们通过使用同步操作来简化示例。

如前一个示例所示,在main()函数中,我们使用一个boost::asio::ip::tcp::acceptor对象来设置 TCP 服务器将用于接受连接的协议和端口。然后,在一个无限循环中,服务器使用该 acceptor 对象附加一个 TCP 套接字(boost::asio::ip::tcp::socket)并调用**handle_client()**函数:

#include <array>
#include <iostream>
#include <boost/asio.hpp>
#include <boost/asio/streambuf.hpp>
using boost::asio::ip::tcp;
constexpr int port = 1234;
int main() {
    try {
        boost::asio::io_context io_context;
        tcp::acceptor acceptor(io_context,
                      tcp::endpoint(tcp::v4(), port));
        std::cout << "Server is running on port "
                  << port << "...\n";
        while (true) {
            tcp::socket socket(io_context);
            acceptor.accept(socket);
            std::cout << "Client connected...\n";
            handle_client(socket);
            std::cout << "Client disconnected...\n";
        }
    } catch (std::exception& e) {
        std::cerr << "Exception: " << e.what() << '\n';
    }
    return 0;
}

handle_client()函数创建了两个流缓冲区:buf1buf2,并将它们添加到一个容器中,在这种情况下是std::array,用于 scatter-gather 操作。

然后,调用套接字的同步read_some()函数。此函数返回从套接字读取的字节数并将它们复制到缓冲区中。如果套接字连接出现任何问题,错误将返回到错误代码对象ec中。在这种情况下,服务器将打印错误消息并退出。

这里是实现方式:

void handle_client(tcp::socket& socket) {
    const size_t size_buffer = 5;
    boost::asio::streambuf buf1, buf2;
    std::array<boost::asio::mutable_buffer, 2> buffers = {
        buf1.prepare(size_buffer),
        buf2.prepare(size_buffer)
    };
    boost::system::error_code ec;
    size_t bytes_recv = socket.read_some(buffers, ec);
    if (ec) {
        std::cerr << "Error on receive: "
                  << ec.message() << '\n';
        return;
    }
    std::cout << "Received " << bytes_recv << " bytes\n";
    buf1.commit(5);
    buf2.commit(5);
    std::istream is1(&buf1);
    std::istream is2(&buf2);
    std::string data1, data2;
    is1 >> data1;
    is2 >> data2;
    std::cout << "Buffer 1: " << data1 << std::endl;
    std::cout << "Buffer 2: " << data2 << std::endl;
}

如果没有错误,流缓冲区的 commit() 函数用于将五个字节传输到每个流缓冲区,即 buf1buf2。这些缓冲区的内容通过使用 std::istream 对象提取并打印到控制台。

要执行此示例,我们需要打开两个终端。在一个终端中,我们执行服务器,在另一个终端中执行 telnet 命令,如前所述。在 telnet 终端中,我们可以输入一条消息(例如,Hello World)。这条消息将被发送到服务器。然后服务器终端将显示以下内容:

Server is running on port 1234...
Client connected...
Received 10 bytes
Buffer 1: Hello
Buffer 2: Worl
Client disconnected...

如我们所见,只有 10 个字节被处理并分配到两个缓冲区中。两个单词之间的空格在通过 iostream 对象解析输入时被处理但被丢弃。

当接收到的数据大小可变且事先未知时,流缓冲区非常有用。这些类型的缓冲区可以与固定大小的缓冲区一起使用。

信号处理

信号处理允许我们捕获操作系统发送的信号,并在操作系统决定杀死应用程序进程之前优雅地关闭应用程序。

Boost.Asio 提供了 boost::asio::signal_set 类来实现此目的,该类启动对一个或多个信号发生的异步等待。

这是如何处理 SIGINTSIGTERM 信号的示例:

#include <boost/asio.hpp>
#include <iostream>
int main() {
    try {
        boost::asio::io_context io_context;
        boost::asio::signal_set signals(io_context,
                                  SIGINT, SIGTERM);
        auto handle_signal = & {
            if (!ec) {
                std::cout << "Signal received: "
                          << signal << std::endl;
                // Code to perform cleanup or shutdown.
                io_context.stop();
            }
        };
        signals.async_wait(handle_signal);
        std::cout << "Application is running. "
                  << "Press Ctrl+C to stop...\n";
        io_context.run();
        std::cout << "Application has exited cleanly.\n";
    } catch (std::exception& e) {
        std::cerr << "Exception: " << e.what() << '\n';
    }
    return 0;
}

signals 对象是 signal_set,列出了程序等待的信号,SIGINTSIGTERM。此对象有一个 async_wait() 方法,它异步等待这些信号中的任何一个发生,并触发完成处理程序,handle_signal()

如同在完成处理程序中通常所做的那样,handle_signal() 检查错误代码,ec,如果没有错误,可能会执行一些清理代码以干净和优雅地退出程序。在这个例子中,我们只是通过调用 io_context.stop() 来停止事件处理循环。

我们也可以通过使用 signals.wait() 方法同步等待信号。

如果应用程序是多线程的,信号事件处理程序必须在与 io_context 对象相同的线程中运行,通常是主线程。

在下一节中,我们将学习如何取消操作。

取消操作

一些 I/O 对象,如套接字或定时器,可以通过调用它们的 close()cancel() 方法来取消未完成的异步操作。如果异步操作被取消,完成处理程序将接收到一个带有 boost::asio::error::operation_aborted 代码的错误。

在以下示例中,创建了一个定时器,并将其超时时间设置为五秒。但是,在主线程仅休眠两秒后,通过调用其 cancel() 方法取消定时器,使得完成处理程序以 boost::asio::error::operation_aborted 错误代码被调用:

#include <boost/asio.hpp>
#include <chrono>
#include <iostream>
#include <thread>
using namespace std::chrono_literals;
void handle_timeout(const boost::system::error_code& ec) {
    if (ec == boost::asio::error::operation_aborted) {
        std::cout << "Timer canceled.\n";
    } else if (!ec) {
        std::cout << "Timer expired.\n";
    } else {
        std::cout << "Error: " << ec.message()
                  << std::endl;
    }
}
int main() {
    boost::asio::io_context io_context;
    boost::asio::steady_timer timer(io_context, 5s);
    timer.async_wait(handle_timeout);
    std::this_thread::sleep_for(2s);
    timer.cancel();
    io_context.run();
    return 0;
}

但如果我们想实现按操作取消,我们需要设置一个在取消信号发出时被触发的取消槽。这个取消信号/槽对构成了一个轻量级通道,用于通信取消操作,就像在第六章中解释的承诺和未来之间创建的那样。取消框架自 Boost.Asio 1.75 版本以来就可用。

这种方法实现了一种更灵活的取消机制,其中可以使用相同的信号取消多个操作,并且它与 Boost.Asio 的异步操作无缝集成。同步操作只能通过使用前面描述的**cancel()close()**方法来取消;它们不受取消槽机制的支持。

让我们修改之前的示例,并使用取消信号/槽来取消计时器。我们只需要修改**main()函数中取消计时器的方式。现在,当执行异步async_wait()操作时,通过使用boost::asio::bind_cancellation_slot()**函数将取消信号的槽绑定到完成处理程序,将创建一个取消槽。

与之前一样,计时器的到期时间为五秒,再次强调,主线程只睡眠两秒。这次,通过调用cancel_signal.emit()函数发出取消信号。该信号将触发对应的取消槽,并使用boost::asio::error::operation_aborted错误代码执行完成处理程序,在控制台打印**Timer canceled.**消息;请参见以下内容:

int main() {
    boost::asio::io_context io_context;
    boost::asio::steady_timer timer(io_context, 5s);
    boost::asio::cancellation_signal cancel_signal;
    timer.async_wait(boost::asio::bind_cancellation_slot(
        cancel_signal.slot(),
        handle_timeout
    ));
    std::this_thread::sleep_for(2s);
    cancel_signal.emit(
        boost::asio::cancellation_type::all);
    io_context.run();
    return 0;
}

当发出信号时,必须指定取消类型,让目标操作知道应用程序的要求和操作保证,从而控制取消的范围和行为。

取消的各类别如下:

  • :不执行取消。如果我们想测试是否应该发生取消,这可能很有用。

  • 终止:操作具有未指定的副作用,因此取消操作的唯一安全方法是关闭或销毁 I/O 对象,因为其结果是最终的,例如,完成任务或事务。

  • 部分:操作具有定义良好的副作用,因此完成处理程序可以采取必要的行动来解决该问题,这意味着操作已部分完成,可以恢复或重试。

  • 全部全部:操作没有副作用。取消终止和部分操作,通过停止所有正在进行的异步操作实现全面取消。

如果异步操作不支持取消类型,则取消请求将被丢弃。例如,计时器操作支持所有取消类别,但套接字只支持 TotalAll,这意味着如果我们尝试使用 Partial 取消来取消套接字异步操作,则此取消请求将被忽略。这防止了如果 I/O 系统尝试处理不受支持的取消请求时出现未定义的行为。

此外,在操作开始之前或完成之后提出的取消请求没有任何效果。

有时,我们需要按顺序运行一些工作。接下来,我们将介绍如何通过使用线程来实现这一点。

使用线程序列化工作负载

线程是一种严格的顺序和非并发调用完成处理器的机制。使用线程,异步操作可以在不使用互斥锁或其他之前在本书中看到的同步机制的情况下进行排序。线程可以是隐式的或显式的。

如本章前面所示,如果我们只从一个线程执行 boost::asio::io_context::run(),所有的事件处理器都将在一个隐式线程中执行,因为它们将一个接一个地按顺序排队并从 I/O 执行上下文中触发。

当存在链式异步操作时,其中一个异步操作安排下一个异步操作,依此类推,会发生另一个隐式线程。本章中的一些先前示例已经使用了这种技术,但这里还有一个例子。

在这种情况下,如果没有错误,计时器将通过在 handle_timer_expiry() 事件处理器中递归地设置过期时间并调用 async_wait() 方法来不断重启自己:

#include <boost/asio.hpp>
#include <chrono>
#include <iostream>
using namespace std::chrono_literals;
void handle_timer_expiry(boost::asio::steady_timer& timer,
                        int count) {
    std::cout << "Timer expired. Count: " << count
              << std::endl;
    timer.expires_after(1s);
    timer.async_wait(&timer, count {
        if (!ec) {
            handle_timer_expiry(timer, count + 1);
        } else {
            std::cerr << „Error:<< ec.message()
                      << std::endl;
        }
    });
}
int main() {
    boost::asio::io_context io_context;
    boost::asio::steady_timer timer(io_context, 1s);
    int count = 0;
    timer.async_wait(& {
        if (!ec) {
            handle_timer_expiry(timer, count);
        } else {
            std::cerr << "Error: " << ec.message()
                      << std::endl;
        }
    });
    io_context.run();
    return 0;
}

运行此示例将每秒打印一次 Timer expired. Count: 行,计数器在每一行上递增。

如果某些工作需要序列化,但这些方法不适用,我们可以通过使用 boost::asio::strand 或其针对 I/O 上下文执行对象的特化,boost::asio::io_context::strand 来使用显式线程。使用这些线程对象发布的作业将按它们进入 I/O 执行上下文队列的顺序序列化其处理器的执行。

在以下示例中,我们将创建一个记录器,它将多个线程中的写入操作序列化到一个单个日志文件中。我们将从四个线程中记录消息,每个线程写入五条消息。我们期望输出是正确的,但这次不使用任何互斥锁或其他同步机制。

让我们先定义 Logger 类:

#include <boost/asio.hpp>
#include <chrono>
#include <fstream>
#include <iostream>
#include <memory>
#include <string>
#include <thread>
#include <vector>
using namespace std::chrono_literals;
class Logger {
   public:
    Logger(boost::asio::io_context& io_context,
           const std::string& filename)
        : strand_(io_context), file_(filename
        , std::ios::out | std::ios::app)
    {
        if (!file_.is_open()) {
            throw std::runtime_error(
                      "Failed to open log file");
        }
    }
    void log(const std::string message) {
        strand_.post([this, message](){
            do_log(message);
        });
    }
   private:
    void do_log(const std::string message) {
        file_ << message << std::endl;
    }
    boost::asio::io_context::strand strand_;
    std::ofstream file_;
};

Logger构造函数接受一个 I/O 上下文对象,用于创建一个线程对象(boost::asio::io_context::strand),以及std::string,指定用于打开日志文件或创建它的日志文件名。日志文件用于追加新内容。如果构造函数完成前文件未打开,意味着在访问或创建文件时出现问题,构造函数将抛出异常。

记录器还提供了一个公共的log()函数,该函数接受std::string,指定一个消息作为参数。此函数使用线程对象将新工作提交到io_context对象。它是通过使用 lambda 函数实现的,通过值捕获记录器实例(对象this)和消息,并调用私有的do_log()函数,在该函数中使用std::fstream对象将消息写入输出文件。

程序中只有一个Logger类的实例,由所有线程共享。这样,线程将写入同一个文件。

让我们定义一个worker()函数,每个线程将运行此函数以将num_messages_per_thread条消息写入输出文件:

void worker(std::shared_ptr<Logger> logger, int id) {
    for (unsigned i=0; i < num_messages_per_thread; ++i) {
        std::ostringstream oss;
        oss << "Thread " << id << " logging message " << i;
        logger->log(oss.str());
        std::this_thread::sleep_for(100ms);
    }
}

此函数接受对Logger对象的共享指针和一个线程标识符。它使用前面解释的Logger的公共**log()**函数打印所有消息。

为了交错线程执行并严格测试线程的工作方式,每个线程在写入每条消息后都将睡眠 100 毫秒。

最后,在main()函数中,我们启动一个io_context对象和一个工作保护器,以避免从io_context中提前退出。然后,创建一个指向Logger实例的共享指针,传递前面解释的必要参数。

通过使用**worker()函数并传递对记录器的共享指针以及每个线程的唯一标识符,创建了一个线程池(std::jthread对象向量)。此外,还添加了一个运行io_context.run()**函数的线程到线程池中。

在以下示例中,由于我们知道所有消息将在两秒内打印出来,我们使io_context只运行那个时间段,使用io_context.run_for(2s)

当**run_for()函数退出时,程序将打印Done!**到控制台并结束:

const std::string log_filename = "log.txt";
const unsigned num_threads = 4;
const unsigned num_messages_per_thread = 5;
int main() {
    try {
        boost::asio::io_context io_context;
        auto work_guard = boost::asio::make_work_guard(
                                 io_context);
        std::shared_ptr<Logger> logger =
             std::make_shared<Logger>(
                  io_context, log_filename);
        std::cout << "Logging "
                  << num_messages_per_thread
                  << " messages from " << num_threads
                  << " threads\n";
        std::vector<std::jthread> threads;
        for (unsigned i = 0; i < num_threads; ++i) {
            threads.emplace_back(worker, logger, i);
        }
        threads.emplace_back([&]() {
            io_context.run_for(2s);
        });
    } catch (std::exception& e) {
        std::cerr << "Exception: " << e.what() << '\n';
    }
    std::cout << "Done!" << std::endl;
    return 0;
}

运行此示例将显示以下输出:

Logging 5 messages from 4 threads
Done!

这是生成的log.txt日志文件的内容。由于每个线程的睡眠时间相同,所有线程和消息都是顺序排列的:

Thread 0 logging message 0
Thread 1 logging message 0
Thread 2 logging message 0
Thread 3 logging message 0
Thread 0 logging message 1
Thread 1 logging message 1
Thread 2 logging message 1
Thread 3 logging message 1
Thread 0 logging message 2
Thread 1 logging message 2
Thread 2 logging message 2
Thread 3 logging message 2
Thread 0 logging message 3
Thread 1 logging message 3
Thread 2 logging message 3
Thread 3 logging message 3
Thread 0 logging message 4
Thread 1 logging message 4
Thread 2 logging message 4
Thread 3 logging message 4

如果我们移除工作保护器,日志文件只包含以下内容:

Thread 0 logging message 0
Thread 1 logging message 0
Thread 2 logging message 0
Thread 3 logging message 0

这是因为第一个工作批次被及时提交并排队到每个线程的io_object中,但在第二个消息批次提交之前,io_object在完成工作保护器的调度和通知完成处理程序后退出。

如果我们也在工作线程中移除**sleep_for()**指令,现在,日志文件的内容如下:

Thread 0 logging message 0
Thread 0 logging message 1
Thread 0 logging message 2
Thread 0 logging message 3
Thread 0 logging message 4
Thread 1 logging message 0
Thread 1 logging message 1
Thread 1 logging message 2
Thread 1 logging message 3
Thread 1 logging message 4
Thread 2 logging message 0
Thread 2 logging message 1
Thread 2 logging message 2
Thread 2 logging message 3
Thread 2 logging message 4
Thread 3 logging message 0
Thread 3 logging message 1
Thread 3 logging message 2
Thread 3 logging message 3
Thread 3 logging message 4

之前,内容是按消息标识符排序的,现在则是按线程标识符排序。这是因为现在,当一个线程开始运行 worker() 函数时,它会一次性发布所有消息,没有任何延迟。因此,第一个线程(线程 0)在第二个线程有机会这样做之前,就将其所有工作入队,依此类推。

在进行进一步实验时,当我们向 strand 中发布内容时,我们通过以下指令捕获了日志记录器实例和消息的值:

strand_.post([this, message]() { do_log(message); });

通过值捕获允许 lambda 函数运行 do_log() 时使用所需对象的副本,保持它们存活,正如在本章前面讨论对象生命周期时注释的那样。

假设,由于某种原因,我们决定使用以下指令通过引用捕获:

strand_.post([&]() { do_log(message); });

然后,生成的日志文件将包含不完整的日志消息,甚至可能包含错误的字符,因为日志记录器是从属于不再存在的消息对象的内存区域打印的,当 do_log() 函数执行时。

因此,始终假设异步更改;操作系统可能会执行一些我们无法控制的变化,所以始终要知道我们控制的是什么,最重要的是,什么不是。

最后,我们不仅可以使用 lambda 表达式并通过值捕获 thismessage 对象,还可以像下面这样使用 std::bind

strand_.post(std::bind(&Logger::do_log, this, message));

让我们学习如何通过使用协程简化我们之前实现的 echo 服务器,并通过添加一个命令从客户端退出连接来改进它。

协程

自 1.56.0 版本以来,Boost.Asio 也包括了协程的支持,并从 1.75.0 版本开始支持原生协程。

正如我们在上一章中学到的,使用协程简化了程序的编写方式,因为不需要添加完成处理程序并将程序的流程分割成不同的异步函数和回调。相反,使用协程,程序遵循顺序结构,异步操作调用会暂停协程的执行。当异步操作完成时,协程会恢复,让程序从之前暂停的地方继续执行。

在新版本(1.75.0 之后的版本)中,我们可以通过 co_await 使用原生 C++ 协程,在协程中等待异步操作,使用 boost::asio::co_spawn 来启动协程,以及使用 boost::asio::use_awaitable 来让 Boost.Asio 知道异步操作将使用协程。在早期版本(从 1.56.0 开始),可以通过 boost::asio::spawn()yield 上下文使用协程。由于新方法更受欢迎,不仅因为它支持原生 C++20 协程,而且代码也更现代、简洁、易读,我们将在这个部分专注于这种方法。

让我们再次实现 echo 服务器,但这次使用 Boost.Asio 的 awaitable 接口和协程。我们还将添加一些改进,例如支持在发送 QUIT 命令时从客户端关闭连接,展示如何在服务器端处理数据或命令,以及在抛出任何异常时停止处理连接并退出。

让我们先实现 main() 函数。程序开始时使用 boost::asio::co_spawn 创建一个新的基于协程的线程。这个函数接受一个执行上下文(io_context,也可以使用 strand),一个返回类型为 boost::asio::awaitable<R,E> 的函数,该函数将用作协程的入口点(我们将实现并解释的 listener() 函数),以及一个完成令牌,当线程完成时将被调用。如果我们想在不通知其完成的情况下运行协程,我们可以传递 boost::asio::detached 令牌。

最后,我们通过调用 io_context.run() 开始处理异步事件。

如果有任何异常发生,它将被 try-catch 块捕获,并且通过调用 io_context.stop() 来停止事件处理循环:

#include <boost/asio.hpp>
#include <iostream>
#include <sstream>
#include <string>
using boost::asio::ip::tcp;
int main() {
    boost::asio::io_context io_context;
    try {
        boost::asio::co_spawn(io_context,
                    listener(io_context, 12345),
                    boost::asio::detached);
        io_context.run();
    } catch (std::exception& e) {
        std::cerr << "Error: " << e.what() << std::endl;
        io_context.stop();
    }
    return 0;
}

listener() 函数接收一个 io_context 对象和监听器将接受的端口号作为参数,使用前面解释的 acceptor 对象。它还必须有一个返回类型为 boost::asio::awaitable<R,E> ,其中 R 是协程的返回类型,E 是可能抛出的异常类型。在这个例子中,E 被设置为默认值,因此没有明确指定。

通过调用 async_accept 接受器函数来接受连接。由于我们现在使用协程,我们需要将 boost::asio::use_awaitable 指定给异步函数,并使用 co_await 来停止协程执行,直到异步任务完成时恢复。

当监听协程任务恢复时,acceptor.async_accept() 返回一个套接字对象。协程继续通过使用 boost::asio::co_spawn 函数创建一个新的线程,执行 echo() 函数,并将 socket 对象传递给它:

boost::asio::awaitable<void> listener(boost::asio::io_context& io_context, unsigned short port) {
    tcp::acceptor acceptor(io_context,
                           tcp::endpoint(tcp::v4(), port));
    while (true) {
        std::cout << "Accepting connections...\n";
        tcp::socket socket = co_await
                acceptor.async_accept(
                    boost::asio::use_awaitable);
        std::cout << "Starting an Echo "
                  << "connection handler...\n";
        boost::asio::co_spawn(io_context,
                              echo(std::move(socket)),
                              boost::asio::detached);
    }
}

echo() 函数负责处理单个客户端连接。它必须遵循与 listener() 函数相似的签名;它需要一个返回类型为 boost::asio::awaitable<R,E> 。如前所述,socket 对象是从监听器移动到这个函数中的。

函数异步地从套接字读取内容,并在一个无限循环中将其写回,只有当它接收到 QUIT 命令或抛出异常时循环才会结束。

异步读取是通过使用socket.async_read_some()函数完成的,该函数使用boost::asio::buffer将数据读入数据缓冲区,并返回读取的字节数(bytes_read)。由于异步任务由协程管理,因此将boost::asio::use_awaitable传递给异步操作。然后,co_wait只是指示协程引擎暂停执行,直到异步操作完成。

一旦接收到一些数据,协程的执行就会继续,检查是否真的有数据需要处理,如果没有,它将通过退出循环来结束连接,从而结束**echo()**函数。

如果读取到数据,它将其转换为std::string以便于操作。如果存在,它会移除**\r\n结束符,并将字符串与QUIT**进行比较。

如果存在QUIT,它将执行异步写入,发送Good bye!消息,并退出循环。否则,它将发送接收到的数据回客户端。在这两种情况下,都使用boost::asio::async_write()函数执行异步写入操作,传递套接字、boost:asio::buffer包装要发送的数据缓冲区,以及与异步读取操作相同的boost::asio::use_awaitable

然后,再次使用co_await来挂起协程的执行,同时进行操作。一旦完成,协程将恢复,并在新的循环迭代中重复这些步骤:

boost::asio::awaitable<void> echo(tcp::socket socket) {
    char data[1024];
    while (true) {
        std::cout << "Reading data from socket...\n";
        std::size_t bytes_read = co_await
                 socket.async_read_some(
                        boost::asio::buffer(data),
                        boost::asio::use_awaitable);
        if (bytes_read == 0) {
            std::cout << "No data. Exiting loop...\n";
            break;
        }
        std::string str(data, bytes_read);
        if (!str.empty() && str.back() == '\n') {
            str.pop_back();
        }
        if (!str.empty() && str.back() == '\r') {
            str.pop_back();
        }
        if (str == "QUIT") {
            std::string bye("Good bye!\n");
            co_await boost::asio::async_write(socket,
                         boost::asio::buffer(bye),
                         boost::asio::use_awaitable);
            break;
        }
        std::cout << "Writing '" << str
                  << "' back into the socket...\n";
        co_await boost::asio::async_write(socket,
                     boost::asio::buffer(data,
                                         bytes_read),
                     boost::asio::use_awaitable);
    }
}

协程循环直到没有读取到数据,这发生在客户端关闭连接、接收到QUIT命令或发生异常时。

异步操作被广泛应用于确保服务器在同时处理多个客户端时仍保持响应。

摘要

在本章中,我们学习了 Boost.Asio 以及如何使用这个库来管理由操作系统管理的资源的外部资源的异步任务。

为了这个目的,我们介绍了 I/O 对象和 I/O 执行上下文对象,并深入解释了它们是如何工作以及如何相互交互的,它们如何访问和与操作系统服务进行通信,它们背后的设计原则是什么,以及如何在单线程和多线程应用程序中正确使用它们。

我们还展示了 Boost.Asio 中可用于使用 strands 序列化工作、管理异步操作使用的对象的生命周期、如何启动、中断或取消任务、如何管理库使用的处理事件循环,以及如何处理操作系统发送的信号的不同技术。

还介绍了与网络和协程相关的其他概念,并使用这个强大的库实现了一些有用的示例。

所有这些概念和示例都使我们能够更深入地了解如何在 C++中管理异步任务,以及一个广泛使用的库是如何在底层实现这一目标的。

在下一章中,我们将学习另一个 Boost 库,Boost.Cobalt,它提供了一个丰富且高级的接口,用于基于协程开发异步软件。

进一步阅读

第十章:使用 Boost.Cobalt 的协程

前几章介绍了 C++20 协程和 Boost.Asio 库,后者是使用 Boost 编写异步 输入/输出 ( I/O ) 操作的基础。在本章中,我们将探讨 Boost.Cobalt,这是一个基于 Boost.Asio 的高级抽象,它简化了使用协程的异步编程。

Boost.Cobalt 允许你编写清晰、可维护的异步代码,同时避免在 C++ 中手动实现协程的复杂性(如第 第八章 中所述)。Boost.Cobalt 与 Boost.Asio 完全兼容,允许你在项目中无缝结合这两个库。通过使用 Boost.Cobalt,你可以专注于构建你的应用程序,而无需担心协程的低级细节。

在本章中,我们将涵盖以下 Boost.Cobalt 主题:

  • 介绍 Boost.Cobalt 库

  • Boost.Cobalt 生成器

  • Boost.Cobalt 任务和承诺

  • Boost.Cobalt 通道

  • Boost.Cobalt 同步函数

技术要求

要构建和执行本章的代码示例,需要一个支持 C++20 的编译器。我们使用了 Clang 18 和 GCC 14.2

确保你使用的是 Boost 版本 1.84 或更高版本,并且你的 Boost 库是用 C++20 支持编译的。在撰写本书时,Cobalt 在 Boost 中的支持相对较新,并非所有预编译的分发版都可能提供此组件。在阅读本书时,情况通常会得到改善。如果由于任何原因,你系统中的 Boost 库不满足这些要求,你必须从其源代码构建它。使用更早的版本,如 C++17,编译将不会包含 Boost.Cobalt,因为它严重依赖于 C++20 协程。

你可以在以下 GitHub 仓库中找到完整的代码:

github.com/PacktPublishing/Asynchronous-Programming-with-CPP

本章的示例位于 Chapter_10 文件夹下。

介绍 Boost.Cobalt 库

我们在 第八章 中介绍了 C++20 对协程的支持。很明显,由于两个主要原因,编写协程并不是一件容易的事情:

  • 在 C++ 中编写协程需要一定量的代码才能使协程工作,但这与我们要实现的功能无关。例如,我们编写的用于生成斐波那契序列的协程相当简单,但我们必须实现包装类型、承诺以及所有使其可用的函数。

  • 开发 plain C++20 协程需要了解 C++ 中协程实现的底层细节,包括编译器如何将我们的代码转换为实现保持协程状态所需的所有机制,以及我们必须实现的功能的调用方式和时机。

异步编程本身就足够复杂,无需那么多细节。如果我们能专注于我们的程序,并从底层概念和代码中隔离出来,那就更好了。我们看到了 C++23 如何引入 std::generator 来实现这一点。让我们只写生成器代码,让 C++ 标准库和编译器处理其余部分。预计在下一个 C++ 版本中,这种协程支持将得到改进。

Boost.Cobalt 是 Boost C++ 库中包含的库之一,它允许我们做到这一点——避免协程的细节。Boost.Cobalt 在 Boost 1.84 中引入,并需要 C++20,因为它依赖于语言协程功能。它基于 Boost.Asio,我们可以在程序中使用这两个库。

Boost.Cobalt 的目标是让我们能够使用协程编写简单的单线程异步代码——可以在单个线程中同时执行多项任务的应用程序。当然,当我们说“同时”时,我们是指并发,而不是并行,因为只有一个线程。通过使用 Boost.Asio 的多线程功能,我们可以在不同的线程中执行协程,但在这个章节中,我们将专注于单线程应用程序。

急切协程和懒协程

在介绍 Boost.Cobalt 实现的协程类型之前,我们需要定义两种协程类型:

  • 急切协程:急切协程在调用时立即开始执行。这意味着协程逻辑会立即开始运行,并一直运行到遇到挂起点(例如 co_awaitco_yield)。协程的创建实际上启动了其处理过程,并且其主体中的任何副作用都会立即执行。

    当你希望协程在创建时立即开始其工作,急切协程是有益的,例如启动异步网络操作或准备数据。

  • 懒协程:懒协程会延迟其执行,直到被显式地等待或使用。协程对象可以在其主体中的任何代码运行之前被创建,直到调用者决定与之交互(通常是通过使用co_await来等待它)。

    当你需要设置一个协程但希望延迟其执行,直到满足某个条件,或者需要与其他任务协调其执行时,懒协程非常有用。

在定义了急切协程和懒协程之后,我们将描述 Boost.Cobalt 中实现的不同类型的协程。

Boost.Cobalt 协程类型

Boost.Cobalt 实现了四种类型的协程。我们将在本节中介绍它们,并在本章后面的部分给出一些示例:

  • 承诺:这是 Boost.Cobalt 中的主要协程类型。它用于实现返回单个值的异步操作(调用 co_return)。它是一个急切协程。它支持 co_await,允许异步挂起和继续。例如,承诺可以用来执行网络调用,当完成时,将返回其结果而不会阻塞其他操作。

  • 任务:任务是对承诺的懒实现。它将不会开始执行,直到被显式等待。它提供了更多的灵活性来控制协程何时以及如何运行。当被等待时,任务开始执行,允许延迟处理异步操作。

  • 生成器:在 Boost.Cobalt 中,生成器是唯一可以产生值的协程类型。每个值都是通过 co_yield 单独产生的。它的功能类似于 C++23 中的 std::generator,但它允许使用 co_await 等待(std::generator 不支持)。

  • 分离的:这是一个急切协程,可以使用 co_await 但不能返回 co_return 值。它不能被恢复,通常也不被等待。

到目前为止,我们介绍了 Boost.Cobalt。我们定义了急切和懒协程是什么,然后我们定义了库中的四种主要协程类型。

在下一节中,我们将深入探讨与 Boost.Cobalt 相关的最重要的主题之一——生成器。我们还将实现一些简单的生成器示例。

Boost.Cobalt 生成器

如在第 第八章 中所述,生成器协程是专门设计的协程,用于逐步产生值。在产生每个值之后,协程会暂停自身,直到调用者请求下一个值。在 Boost.Cobalt 中,生成器以相同的方式工作。它们是唯一可以产生值的协程类型。这使得生成器在您需要协程在一段时间内产生多个值时变得至关重要。

Boost.Cobalt 生成器的一个关键特性是它们默认是急切的,这意味着它们在被调用后立即开始执行。此外,这些生成器是异步的,允许它们使用 co_await,这是与 C++23 中引入的 std::generator 的重要区别,后者是懒的且不支持 co_await

查看基本示例

让我们从最简单的 Boost.Cobalt 程序开始。这个例子不是生成器的例子,但我们将借助它解释一些重要细节:

#include <iostream>
#include <boost/cobalt.hpp>
boost::cobalt::main co_main(int argc, char* argv[]) {
    std::cout << "Hello Boost.Cobalt\n";
    co_return 0;
}

在前面的代码中,我们观察到以下内容:

  • 要使用 Boost.Cobalt,必须包含 <boost/cobalt.hpp> 头文件。

  • 您还必须将 Boost.Cobalt 库链接到您的应用程序。我们提供了一个 CMakeLists.txt 文件来完成这项工作,不仅适用于 Boost.Cobalt,还适用于所有必需的 Boost 库。要显式地链接 Boost.Cobalt(即不是所有必需的 Boost 库),只需将以下行添加到您的 CMakeLists.txt 文件中:

    target_link_libraries(${EXEC_NAME} Boost::cobalt)
    
  • 使用co_main函数。与常规的main函数不同,Boost.Cobalt 引入了一个基于协程的入口点,称为co_main。这个函数可以使用协程特定的关键字,如co_return。Boost.Cobalt 内部实现了所需的main函数。

    使用co_main将允许您将程序的main函数(入口点)实现为协程,从而能够调用co_awaitco_return。记住,从第八章中,main函数不能是协程。

    如果您无法更改当前的函数,可以使用 Boost.Cobalt。您只需从main函数中调用一个函数,这个函数将成为您使用 Boost.Cobalt 的异步代码的最高级函数。实际上,这正是 Boost.Cobalt 所做的事情:它实现了一个函数,这是程序的入口点,并且(对您隐藏的)这个函数调用了co_main

    使用您自己的main函数的最简单方法可能如下所示:

    cobalt::task<int> async_task() {
        // your code here
        // …
        return 0;
    }
    int main() {
        // main function code
        // …
        return cobalt::run(async_code();
    }
    

示例简单地打印一条问候消息,然后通过调用co_await返回 0。在所有未来的例子中,我们将遵循这个模式:包含**<boost/cobalt.hpp>头文件,并使用co_main而不是main**。

Boost.Cobalt 简单生成器

在我们之前的基本例子中获得的知识的基础上,我们将实现一个非常简单的生成器协程:

#include <chrono>
#include <iostream>
#include <boost/cobalt.hpp>
using namespace std::chrono_literals;
using namespace boost;
cobalt::generator<int> basic_generator()
{
    std::this_thread::sleep_for(1s);
    co_yield 1;
    std::this_thread::sleep_for(1s);
    co_return 0;
}
cobalt::main co_main(int argc, char* argv[]) {
    auto g = basic_generator();
    std::cout << co_await g << std::endl;
    std::cout << co_await g << std::endl;
    co_return 0;
}

上述代码展示了一个简单的生成器,它产生一个整数值(使用co_yield)并返回另一个值(使用co_return)。

cobalt::generator是一个struct模板:

template<typename Yield, typename Push = void>
struct generator

两个参数类型如下:

  • Yield:生成的对象类型

  • Push:输入参数类型(默认为void

co_main函数在通过co_await获取数值后打印这两个数(调用者等待数值可用)。我们已经引入了一些延迟来模拟生成器必须执行的处理以生成这些数字。

我们的第二个生成器将产生一个整数的平方:

#include <chrono>
#include <iostream>
#include <boost/cobalt.hpp>
using namespace std::chrono_literals;
using namespace boost;
cobalt::generator<int, int> square_generator(int x){
    while (x != 0) {
        x = co_yield x * x;
    }
    co_return 0;
}
cobalt::main co_main(int argc, char* argv[]){
    auto g = square_generator(10);
    std::cout << co_await g(4) << std::endl;
    std::cout << co_await g(12) << std::endl;
    std::cout << co_await g(0) << std::endl;
    co_return 0;
}

在这个例子中,square_generator产生x参数的平方。这展示了我们如何将值推送到 Boost.Cobalt 生成器。在 Boost.Cobalt 中,将值推送到生成器意味着传递参数(在先前的例子中,传递的参数是整数)。

在这个例子中,尽管生成器是正确的,但可能会令人困惑。请看以下代码行:

auto g = square_generator(10);

这创建了一个初始值为10的生成器对象。然后,看看以下代码行:

std::cout << co_await g(4) << std::endl;

这将打印10的平方并将4推送到生成器。正如你所看到的,打印的值不是传递给生成器的值的平方。这是因为生成器初始化时有一个值(在这个例子中,10),当调用者调用co_await传递另一个值时,它将生成平方值。当接收到新值4时,生成器将产生100,然后当接收到12的值时,它将产生16,依此类推。

我们说过,Boost.Cobalt 生成器是急切的,但它们在开始执行时可以等待(co_await)。以下示例展示了如何做到这一点:

#include <iostream>
#include <boost/cobalt.hpp>
boost::cobalt::generator<int, int> square_generator() {
    auto x = co_await boost::cobalt::this_coro::initial;
    while (x != 0) {
        x = co_yield x * x;
    }
    co_return 0;
}
boost::cobalt::main co_main(int, char*[]) {
    auto g = square_generator();
    std::cout << co_await g(4) << std::endl;
    std::cout << co_await g(10) << std::endl;
    std::cout << co_await g(12) << std::endl;
    std::cout << co_await g(0) << std::endl;
    co_return 0;
}

代码与上一个示例非常相似,但有一些不同:

  • 我们创建生成器时没有传递任何参数给它:

    auto g = square_generator();
    
  • 看一下生成器代码的第一行:

    auto x = co_await boost::cobalt::this_coro::initial;
    

    这使得生成器等待第一个推入的整数。这表现得像一个惰性生成器(实际上,它立即开始执行,因为生成器是急切的,但它首先做的事情是等待一个整数)。

  • 产生的值是我们从代码中期望得到的:

    std::cout << co_await g(10) << std::endl;
    

    这将打印100而不是之前推入整数的平方。

让我们在这里总结一下示例做了什么:co_main函数调用square_generator协程生成整数的平方。生成器协程在开始时挂起等待第一个整数,并在产生每个平方后再次挂起。这个例子故意简单,只是为了说明如何使用 Boost.Cobalt 编写生成器。

前一个程序的一个重要特性是它在单个线程中运行。这意味着co_main和生成器协程一个接一个地运行。

一个斐波那契数列生成器

在本节中,我们将实现一个类似于我们在第八章中实现的斐波那契数列生成器。这将让我们看到使用 Boost.Cobalt 编写生成器协程比使用纯 C++20(不使用任何协程库)要容易多少。

我们编写了两个版本的生成器。第一个计算斐波那契数列的任意项。我们推入我们想要生成的项,然后我们得到它。这个生成器使用 lambda 作为斐波那契计算器:

boost::cobalt::generator<int, int> fibonacci_term() {
    auto fibonacci = [](int n) {
        if (n < 2) {
            return n;
        }
        int f0 = 0;
        int f1 = 1;
        int f;
        for (int i = 2; i <= n; ++i) {
            f = f0 + f1;
            f0 = f1;
            f1 = f;
        }
        return f;
    };
    auto x = co_await boost::cobalt::this_coro::initial;
    while (x != -1) {
        x = co_yield fibonacci(x);
    }
    co_return 0;
 }

在前面的代码中,我们看到这个生成器与我们之前章节中用于计算数字平方的生成器非常相似。在协程的开始,我们有以下内容:

auto x = co_await boost::cobalt::this_coro::initial;

这行代码使协程挂起以等待第一个输入值。

然后我们有以下内容:

while (x != -1) {
        x = co_yield fibonacci(x);
    }

这生成所需的斐波那契数列项,并在请求下一个项之前挂起。当请求的项不等于**-1时,我们可以继续请求更多值,直到推入-1**终止协程。

下一个版本的斐波那契生成器将在需要时产生无限多个项。当我们说“无限”时,我们是指“潜在无限”。将这个生成器想象成总是准备好产生下一个斐波那契数列的数字:

boost::cobalt::generator<int> fibonacci_sequence() {
    int f0 = 0;
    int f1 = 1;
    int f = 0;
    while (true) {
        co_yield f0;
        f = f0 + f1;
        f0 = f1;
        f1 = f;
    }
}

前面的代码很容易理解:协程产生一个值并暂停自己,直到另一个值被请求,然后协程计算新值并产生它,再次在无限循环中暂停自己。

在这种情况下,我们可以看到协程的优势:我们可以在需要时逐个生成斐波那契数列的项。我们不需要保持任何状态来生成下一个项,因为状态被保存在协程中。

还要注意,即使函数执行了无限循环,因为它是一个协程,它会暂停并再次恢复,从而避免阻塞当前线程。

Boost.Cobalt 任务和承诺

正如我们在本章中已经看到的,Boost.Cobalt 的承诺是急切协程,它们返回一个值,而 Boost.Cobalt 的任务是承诺的懒版本。

我们可以将其视为只是函数,不像生成器那样产生多个值。我们可以多次调用承诺以获取多个值,但调用之间不会保持状态(就像生成器中那样)。基本上,承诺是一个可以使用co_await(它也可以使用co_return)的协程。

承诺的不同用法可能是一个套接字监听器,用于接收网络数据包,处理它们,对数据库进行查询,然后从数据中生成一些结果。一般来说,它们的功能需要异步等待某个结果,然后对该结果进行一些处理(或者可能只是将其返回给调用者)。

我们的第一个例子是一个简单的承诺,它生成一个随机数(这也可以用生成器来完成):

#include <iostream>
#include <random>
#include <boost/cobalt.hpp>
boost::cobalt::promise<int> random_number(int min, int max) {
    std::random_device rd;
    std::mt19937 gen(rd());
    std::uniform_int_distribution<> dist(min, max);
    co_return dist(gen);
}
boost::cobalt::promise<int> random(int min, int max) {
    int res = co_await random_number(min, max);
    co_return res;
}
boost::cobalt::main co_main(int, char*[]) {
    for (int i = 0; i < 10; ++i) {
        auto r = random(1, 100);
        std::cout << "random number between 1 and 100: "
                  << co_await r << std::endl;
    }
    co_return 0;
}

在前面的代码中,我们已经编写了三个协程:

  • co_main:记住在 Boost.Cobalt 中,co_main是一个协程,它调用co_return来返回一个值。

  • random():这个协程返回一个随机数给调用者。它使用co_await调用**random()**来生成随机数。它异步等待随机数的生成。

  • random_number():这个协程生成两个值minmax之间的均匀分布随机数,并将其返回给调用者。**random_number()**也是一个承诺。

下面的协程返回一个包含随机数的std::vector。在循环中调用co_await random_number()来生成一个包含n个随机数的向量:

boost::cobalt::promise<std::vector<int>> random_vector(int min, int max, int n) {
    std::vector<int> rv(n);
    for (int i = 0; i < n; ++i) {
        rv[i] = co_await random_number(min, max);
    }
    co_return rv;
}

前面的函数返回一个std::vector的承诺。要访问这个向量,我们需要调用get()

auto v = random_vector(1, 100, 20);
for (int n : v.get()) {
    std::cout << n << " ";
}
std::cout << std::endl;

之前的代码打印了v向量的元素。要访问这个向量,我们需要调用v.get()

我们将实现第二个示例来展示承诺和任务的执行有何不同:

#include <chrono>
#include <iostream>
#include <thread>
#include <boost/cobalt.hpp>
void sleep(){
    std::this_thread::sleep_for(std::chrono::seconds(2));
}
boost::cobalt::promise<int> eager_promise(){
    std::cout << "Eager promise started\n";
    sleep();
    std::cout << "Eager promise done\n";
    co_return 1;
}
boost::cobalt::task<int> lazy_task(){
    std::cout << "Lazy task started\n";
    sleep();
    std::cout << "Lazy task done\n";
    co_return 2;
}
boost::cobalt::main co_main(int, char*[]){
    std::cout << "Calling eager_promise...\n";
    auto promise_result = eager_promise();
    std::cout << "Promise called, but not yet awaited.\n";
    std::cout << "Calling lazy_task...\n";
    auto task_result = lazy_task();
    std::cout << "Task called, but not yet awaited.\n";
    std::cout << "Awaiting both results...\n";
    int promise_value = co_await promise_result;
    std::cout << "Promise value: " << promise_value
              << std::endl;
    int task_value = co_await task_result;
    std::cout << "Task value: " << task_value
              << std::endl;
    co_return 0;
}

在这个例子中,我们实现了两个协程:一个承诺(promise)和一个任务(task)。正如我们之前所说的,承诺是急切的,它一旦被调用就开始执行。任务则是懒加载的,在被调用后会被挂起。

当我们运行程序时,它会打印出所有消息,这让我们确切地知道协程是如何执行的。

执行完 co_main() 的前三行后,打印的输出如下:

Calling eager_promise...
Eager promise started
Eager promise done
Promise called, but not yet awaited.

从这些消息中,我们知道承诺已经执行到调用 co_return 的位置。

执行完 co_main() 的下一三行后,打印的输出有这些新消息:

Calling lazy_task...
Task called, but not yet awaited.

在这里,我们看到任务尚未执行。它是一个懒加载的协程,因此,在被调用后立即挂起,并且这个协程还没有打印任何消息。

执行了三行更多的 co_main() 代码,这些是新消息,程序输出的内容如下:

Awaiting both results...
Promise value: 1

在承诺上调用 co_await 会给我们其结果(在这个例子中,设置为 1)并且执行结束。

最后,我们在任务上调用 co_await,然后它执行并返回其值(在这个例子中,设置为 2)。输出如下:

Lazy task started
Lazy task done
Task value: 2

这个例子展示了任务是如何懒加载的,开始时是挂起的,并且只有在调用者对它们调用 co_await 时才会恢复执行。

在本节中,我们看到了,就像生成器的情况一样,使用 Boost.Cobalt 比仅仅使用纯 C++ 更容易编写承诺和任务协程。我们不需要编写 C++ 实现协程所需的所有支持代码。我们也看到了任务和承诺之间的主要区别。

在下一节中,我们将研究一个通道的例子,这是一个在生产者/消费者模型中两个协程之间的通信机制。

Boost.Cobalt 通道

在 Boost.Cobalt 中,通道为协程提供了异步通信的方式,允许生产者和消费者协程之间以安全且高效的方式进行数据传输。它们受到了 Golang 通道的启发,并允许通过消息传递进行通信,促进了一种“通过通信共享内存”的范式。

通道是一种机制,通过它,值可以从一个协程(生产者)异步地传递到另一个协程(消费者)。这种通信是非阻塞的,这意味着协程在等待通道上有可用数据时可以挂起它们的执行,或者在向具有有限容量的通道写入数据时也可以挂起。让我们澄清一下:如果“阻塞”意味着协程被挂起,那么读取和写入操作可能会根据缓冲区大小而阻塞,但另一方面,从线程的角度来看,这些操作不会阻塞线程。

如果缓冲区大小为零,读取和写入将需要同时发生并作为 rendezvous(同步通信)。如果通道大小大于零且缓冲区未满,写入操作不会挂起协程。同样,如果缓冲区不为空,读取操作也不会挂起。

类似于 Golang 的通道,Boost.Cobalt 的通道是强类型的。通道为特定类型定义,并且只能通过它发送该类型的数据。例如,int 类型的通道(boost::cobalt::channel)只能传输整数。

现在让我们看看一个通道的示例:

#include <iostream>
#include <boost/cobalt.hpp>
#include <boost/asio.hpp>
boost::cobalt::promise<void> producer(boost::cobalt::channel<int>& ch) {
    for (int i = 1; i <= 10; ++i) {
        std::cout << "Producer waiting for request\n";
        co_await ch.write(i);
        std::cout << "Producing value " << i << std::endl;
    }
    std::cout << "Producer end\n";
    ch.close();
    co_return;
}
boost::cobalt::main co_main(int, char*[]) {
    boost::cobalt::channel<int> ch;
    auto p = producer(ch);
    while (ch.is_open()) {
        std::cout << "Consumer waiting for next number \n";
        std::this_thread::sleep_for(std::chrono::seconds(5));
        auto n = co_await ch.read();
        std::cout << "Consuming value " << n << std::endl;
        std::cout << n * n << std::endl;
    }
    co_await p;
    co_return 0;
}

在这个示例中,我们创建了一个大小为 0 的通道和两个协程:生产者承诺和作为消费者的 co_main()。生产者将整数写入通道,消费者读取它们并将它们平方后打印出来。

我们添加了 **std::this_thread::sleep** 来延迟程序执行,从而能够看到程序运行时的状态。让我们看看示例输出的摘录,看看它是如何工作的:

Producer waiting for request
Consumer waiting for next number
Producing value 1
Producer waiting for request
Consuming value 1
1
Consumer waiting for next number
Producing value 2
Producer waiting for request
Consuming value 2
4
Consumer waiting for next number
Producing value 3
Producer waiting for request
Consuming value 3
9
Consumer waiting for next number

消费者和生产者都等待下一个动作发生。生产者将始终等待消费者请求下一个项目。这基本上是生成器的工作方式,并且在使用协程的异步代码中是一个非常常见的模式。

消费者执行以下代码行:

auto n = co_await ch.read();

然后,生产者将下一个数字写入通道并等待下一个请求。这是在以下代码行中完成的:

co_await ch.write(i);

你可以在上一段输出摘录的第四行中看到生产者如何返回等待下一个请求。

Boost.Cobalt 通道使得编写这种异步代码非常清晰且易于理解。

示例显示了两个协程通过通道进行通信。

这部分内容就到这里。下一部分将介绍同步函数——等待多个协程的机制。

Boost.Cobalt 同步函数

之前,我们实现了协程,并且在每次调用 **co_await** 的时候,我们只为一个协程调用。这意味着我们只等待一个协程的结果。Boost.Cobalt 有机制允许我们等待多个协程。这些机制被称为 同步函数

Boost.Cobalt 实现了四个同步函数:

  • racerace 函数等待一组协程中的一个完成,但它以伪随机的方式进行。这种机制有助于避免协程的饥饿,确保一个协程不会在执行流程上主导其他协程。当你有多个异步操作,并且想要第一个完成以确定流程时,race 将允许任何准备就绪的协程以非确定性的顺序继续执行。

    当你有多个任务(在通用意义上,不是 Boost.Cobalt 任务)并且对完成其中一个感兴趣,没有偏好哪个,但想要防止在准备就绪同时发生的情况下一个协程总是获胜时,你会使用race

  • joinjoin函数等待给定集合中的所有协程完成,并返回它们的值。如果任何一个协程抛出异常,join将把异常传播给调用者。这是一种从多个异步操作中收集结果的方法,这些操作必须在继续之前全部完成。

    当你需要多个异步操作的结果一起,并且如果任何一个操作失败则想要抛出错误时,你会使用join

  • gather:与join类似,gather函数等待一组协程完成,但它处理异常的方式不同。当其中一个协程失败时,gather不会立即抛出异常,而是单独捕获每个协程的结果。这意味着你可以独立检查每个协程的输出(成功或失败)。

    当你需要所有异步操作都完成,但想要单独捕获所有结果和异常以分别处理时,你会使用gather

  • left_raceleft_race函数类似于race,但具有确定性行为。它从左到右评估协程,并等待第一个协程准备好。当协程完成的顺序很重要,并且你想要基于它们提供的顺序确保可预测的结果时,这可能很有用。

    当你有多个潜在的结果,并且需要优先考虑提供的顺序中的第一个可用的协程,使行为比race更可预测时,你会使用left_race

在本节中,我们将探讨joingather函数的示例。正如我们所见,这两个函数都等待一组协程完成。它们之间的区别在于,如果任何一个协程抛出异常,join将抛出一个异常,而gather总是返回所有等待的协程的结果。在gather函数的情况下,每个协程的结果将要么是一个错误(缺失值),要么是一个值。join返回一个值元组或抛出一个异常;gather返回一个可选值元组,在发生异常的情况下没有值(可选变量未初始化)。

以下示例的完整代码在 GitHub 仓库中。在这里,我们将关注主要部分。

我们定义了一个简单的函数来模拟数据处理,它仅仅是一个延迟。如果传递的延迟大于 5,000 毫秒,该函数将抛出一个异常:

boost::cobalt::promise<std::chrono::milliseconds::rep> process(std::chrono::milliseconds ms) {
    if (ms > std::chrono::milliseconds(5000)) {
        throw std::runtime_error("delay throw");
    }
    boost::asio::steady_timer tmr{ co_await boost::cobalt::this_coro::executor, ms };
    co_await tmr.async_wait(boost::cobalt::use_op);
    co_return ms.count();
}

该函数是一个 Boost.Cobalt 承诺。

现在,在代码的下一节中,我们将等待这个承诺的三个实例运行:

auto result = co_await boost::cobalt::join(process(100ms),
                                           process(200ms),
                                           process(300ms));
std::cout << "First coroutine finished in: "
          <<  std::get<0>(result) << "ms\n";
std::cout << "Second coroutine took finished in: "
          <<  std::get<1>(result) << "ms\n";
std::cout << "Third coroutine took finished in: "
         <<  std::get<2>(result) << "ms\n";

前面的代码调用join等待三个协程完成,然后打印它们所花费的时间。正如你所看到的,结果是元组,为了使代码尽可能简单,我们只为每个元素调用std::get(result)。在这种情况下,所有处理时间都在有效范围内,没有抛出异常,因此我们可以获取所有已执行协程的结果。

如果抛出异常,则我们不会得到任何值:

try {
    auto result throw = co_await
    boost::cobalt::join(process(100ms),
                        process(20000ms),
                        process(300ms));
}
catch (...) {
    std::cout << "An exception was thrown\n";
}

前面的代码将抛出异常,因为第二个协程接收到的处理时间超出了有效范围。它将打印一条错误信息。

当调用join函数时,我们希望所有协程都被视为处理的一部分,并且在发生异常的情况下,整个处理失败。

如果我们需要获取每个协程的所有结果,我们将使用gather函数:

try
    auto result throw =
    boost::cobalt::co_await lt::gather(process(100ms),
                                       process(20000ms),
                                       process(300ms));
    if (std::get<0>(result throw).has value()) {
        std::cout << "First coroutine took: "
                  <<  *std::get<0>(result throw)
                  << "msec\n";
    }
    else {
        std::cout << "First coroutine threw an exception\n";
    }
    if (std::get<1>(result throw).has value()) {
        std::cout << "Second coroutine took: "
                  <<  *std::get<1>(result throw)
                  << "msec\n";
    }
    else {
        std::cout << "Second coroutine threw an exception\n";
    }
    if (std::get<2>(result throw).has value()) {
        std::cout << "Third coroutine took: "
                  <<  *std::get<2>(result throw)
                  << "msec\n";
    }
    else {
        std::cout << "Third coroutine threw an exception\n";
    }
}
catch (...) {
    // this is never reached because gather doesn't throw exceptions
    std::cout << "An exception was thrown\n";
}

我们将代码放在了try-catch块中,但没有抛出异常。gather函数返回一个可选值的元组,我们需要检查每个协程是否返回了值(可选值是否有值)。

当我们希望协程在成功执行时返回一个值时,我们使用gather

这些joingather函数的例子结束了我们对 Boost.Cobalt 同步函数的介绍。

摘要

在本章中,我们看到了如何使用 Boost.Cobalt 库实现协程。它最近才被添加到 Boost 中,关于它的信息并不多。它简化了使用协程异步代码的开发,避免了编写 C++20 协程所需的底层代码。

我们研究了主要库概念,并开发了一些简单的示例来理解它们。

使用 Boost.Cobalt,使用协程编写异步代码变得简单。C++中编写协程的所有底层细节都由库实现,我们可以专注于我们想要在程序中实现的功能。

在下一章中,我们将看到如何调试异步代码。

进一步阅读

第五部分:异步编程中的调试、测试和性能优化

在本最终部分,我们专注于调试、测试和优化多线程和异步程序性能的基本实践。我们将首先使用日志记录和高级调试工具和技术,包括反向调试和代码清理器,来识别和解决异步应用程序中的微妙错误,例如崩溃、死锁、竞态条件、内存泄漏和线程安全问题,随后使用 GoogleTest 框架针对异步代码制定测试策略。最后,我们将深入性能优化,理解诸如缓存共享、伪共享以及如何缓解性能瓶颈等关键概念。掌握这些技术将为我们提供一套全面的工具集,用于识别、诊断和改进异步应用程序的质量和性能。

本部分包含以下章节:

  • 第十一章异步软件的日志记录和调试

  • 第十二章清理和测试异步软件

  • 第十三章提高异步软件性能

STM32电机库无感代码注释无传感器版本龙贝格观测电阻双AD采样前馈控制弱磁控制斜坡启动内容概要:本文档为一份关于STM32电机控制的无传感器版本代码注释资源,聚焦于龙贝格观测器在永磁同步电机(PMSM)无感控制中的应用。内容涵盖电阻双通道AD采样技术、前馈控制、弱磁控制及斜坡启动等关键控制策略的实现方法,旨在通过详细的代码解析帮助开发者深入理解基于STM32平台的高性能电机控制算法设计与工程实现。文档适用于从事电机控制开发的技术人员,重点解析了无位置传感器控制下的转子初始定位、速度估算与系统稳定性优化等问题。; 适合人群:具备一定嵌入式开发基础,熟悉STM32平台及电机控制原理的工程师或研究人员,尤其适合从事无感FOC开发的中高级技术人员。; 使用场景及目标:①掌握龙贝格观测器在PMSM无感控制中的建模与实现;②理解电阻采样与双AD同步采集的硬件匹配与软件处理机制;③实现前馈补偿提升动态响应、弱磁扩速控制策略以及平稳斜坡启动过程;④为实际项目中调试和优化无感FOC系统提供代码参考和技术支持; 阅读建议:建议结合STM32电机控制硬件平台进行代码对照阅读与实验验证,重点关注观测器设计、电流采样校准、PI参数整定及各控制模块之间的协同逻辑,建议配合示波器进行信号观测以加深对控制时序与性能表现的理解。
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值