时光:“你说你以前为什么没有女朋友。”
我:“因为你没有出现呀 “
时光:” 那你以后为什么没有。“
时光:”emmm... 因为我不让她出现。”

时光来了南京,今晚接她下班的时候,我们在路上买了两份面回去,一份鸡腿面,一份呢,是青菜的。在夫子庙地铁站旁上海商业储蓄银行旧址拐角的地方发现一只喵,猫很乖,我们路过的时候它乖兮兮地向我们咪咪叫,眼睛看着我们。我和时光都觉得它是饿了,刚好我们手里拿着打包的面,想喂它鸡腿。猫也闻到了味道,一直意欲走向我们的面。打包的袋子还没打开,它就想往上边蹭。我们便一人挡着猫咪,一人去开袋子,银行墙上有许多的窗子,我们便在一个个窗子上与猫做游击战,它跳过来,我们就换个窗户开袋子。两人一猫,交相流转,好不笑闹。费了好大的功夫,我们终于打开了饭盒,用筷子夹了鸡腿放在窗台上给猫主子。结果猫主子闻了闻,走在了一边,没有吃。当时我就慌了,妈耶,这可是我俩唯一的鸡腿呀,没有吃喂给主子了主子却不待见,我俩只有青菜了,TAT。我和时光用手指指着鸡腿,想告诉猫咪来这里吃鸡腿,它闻了闻,还是没有次。我们猜可能是整个鸡腿太大又有些烫,它不好下嘴,时光又特意用窗台上的签子给它分开。时光欺负起我来的时候凶巴巴,这时候却是温柔的要死,我站在一旁注视着她。晚风刚好,时间像是暂停了,满心的幸福。这曾是异地时幻想多少次的场景呀,晚上接她下班,回家的路上牵着手,想着一天的生活。一霎时,突然想写篇文章来记录和时光在一起的生活,记录下生活中那些触动心灵的点点滴滴。

阅读全文 »

本文是我学习 C++ chrono 库时的学习、思考的临时笔记。很多地方可能存在谬误,请读者带着批判性的目光阅读。

学习 chrono 库重要点是了解其中三个重要概念及它们之间的联系:

  • 时钟 (clock)
  • 时间点 (time_point)
  • 时间间隔 (duration)

上面三个重要概念是我依靠自己感觉总结出来的,名字也是自己起的,可能有失偏颇,但大体应该如此。个人觉得这三个名字也可以很直观地来理解他们的意义。在 C++ 的 chrono 库中,他们相互之间的联系也很直觉,如时钟可以获得时间点,两个时间点的差为时间间隔,时间点加上一个时间间隔即是另外一个时间点。

时钟 (clock)

时钟的用途就是用来获取时间点。chrono 标准库中提供了三种(我目前知道的)时钟,分别为:

  • std::chrono::system_clock :系统时钟,用于表示现实世界时间的时钟,如现在是几点等。该时钟不保证单调递增,受系统时间的影响,当系统时间发生调整时,system_clock 的读数可能会发生跳变 / 倒退。
  • std::chrono::steady_clock:单调递增时钟,主要用于测量时间间隔,如程序运行计时等。不受系统时间的影响。类似于 clock_gettime 中的 CLOCK_MONOTONIC
  • std::chrono::high_resolution_check:最高精度时钟,拥有最高的时间精度,适合用于性能计时和测量时间间隔。
    在某些实现中,high_resolution_clock 可能是 system_clocksteady_clock 的别名。因此,它可能具有 system_clocksteady_clock 的特点,具体取决于实现。

这三种时钟类型的精度和特性可能因平台和实现而异。

对于时钟来说,最常用的成员函数应该是 now(),即获取当前的时钟时间。

对于 system_clock,还有两个较为常用的成员函数,为 from_time_tto_time_t,即通过 C 风格下的 time_t(即以秒计的时间戳),来获取该时间戳下的时间点 (time_point)。或将时间点转换为 C 风格下的时间戳。

时间点 (time_point)

时间点即为特定时钟下的某个时刻。也就是说,chrono 库中,如果要指定一个时间,需要确定三个元素。

  1. 是哪种时钟下的时间点
  2. 这种时钟下的纪元(即零点时间)是什么,(如我们熟知的 UTC 1970-01-01 00:00:00
  3. 该时间点距离纪元的时间间隔是多久。

时间间隔 (duration)

时间间隔即两个时间点相距多久。chrono 库中,要表示时间间隔,要确定以下两点:

  • Rep:数据表示方式(存储类型),如 intfloatuint64_t
  • Period:时间间隔单位,如秒、毫秒、分钟。
    在库内实现中,以 std::ratio 为表示,如 std::ratio<1, 1> (即 1),表示秒;std::ratio<1, 1000>,即 0.001 ,表示毫秒;std::ratio<60, 1>,即 60 ,表示分钟。

对于常用的时间间隔类型,chrono 中已包装好,拿来即用就可,如 std::chrono::seconds 表示秒,std::chrono::hours 表示小时。 std::chrono::seconds dur {5} ,这样,我们就定义了一个 5 秒的时间间隔变量。在 C++ 20 中,又加入了天(days)、月(months)、年(years)的类型。

从 C++14 开始,chrono 加入了 literals,可以帮我们更方便地表达时间间隔,更符合人类的语义习惯。如 auto dur = 3s,就定义了一个 3 秒的时间间隔。(有些小吐槽,见后面的吐槽部分)

不同时间间隔类型之间当然是可以相互转换的,它们通过 std::chrono::duration_cast 来进行转换,如,auto dur = std::chrono::duration<std::chrono::minutes>(120s),这样,我们可以将 120 秒转换为 2 分钟。

对一个时间间隔变量使用 count 成员函数,可以获得其单位下的间隔时间,如 (120s).count(),我们则可以获得到 120 。

三个概念关系

时钟可获得时间点。

时间点是特定时钟下距离时钟纪元一定时间间隔的时刻。

时间点 加 / 减 时间间隔得到另一个时间点。时间间隔之间可以加减,得到新的时间间隔。时间间隔也可以乘除一个倍数,用于放大 / 倍缩该时间间隔。

这些都很直觉。C++ chrono 中的类型设计还是很符合人类语义习惯的,这也是我很喜欢 C++ 的一个地方。

使用 fmtlib 格式化时间

TODO

一些小吐槽

上面我们提到,C++11 中,就有了 secondsminuteshours 等类型的时间时间,但是对于天、月、年这个级别的时间间隔的支持,拖延到了 C++20 才进入标准。

chronoliterals 支持上,C++14 中我们可以方便地使用 60s30min12h 来表示时间间隔,但却不支持天、月、年。C++ 20 中加入了 d, y,来表示天和年,但注意,这是日期上的日和年,而不是时间间隔上的!(如 23d 表示某一个月的 23 号,而非时间长度上的 23 天)年可以理解,毕竟不同年份的时间不一致,但没有加入天数上的字面词,在代码风格上不能与秒、分钟达成统一,(如,我表达 3 秒可以写作 3s,表达 10 天却要写成 days {10})我感觉还是有些不完美、略有遗憾吧。

我们可以通过 24h * 10 来表达 10 天,却不能够使用 (10 * 24)h,我觉得也是在语言设计上的一点缺陷,后者这明明是 constexpr 的,也有些遗憾吧。

感悟

我之所以写这篇学习笔记,是因为在新的工作中,我需要记录时间,用于计算特定时间内处理了多少条数据等,并打印出日志来进行方案评估。

我自诩为看过一些 C++ 书的,了解一些 C++,却从未正式地写过 C++。于是知道有这种 API,却没有真实地使用过这个 API,没有实践的经历及由此而来的肌肉记忆。因此在写代码时,不能够很流畅地写出,感觉很生涩。真实地感受到了 “纸上得来终觉浅,绝知此事要躬行。”

我们总说,编程语言没那么重要,数据结构、算法、系统架构这些才是重要的。这句话我是认可的,但令一方面,我觉得对程序员来说,编码是我们表达我们思想的方式。语言决定者对语言底层思想的设计,我们对该语言的理解、应用能力,(一定程度上)决定了我们在写代码时表达我们思想的能力。

写这篇笔记时,刚好是小满(虽然写完不是了)。我很喜欢这个节气,万物至此,小得圆满,很有中国传统哲学的智慧。

这是 Lewis Baker C++ 协程介绍的第一篇文章(从 0 开始计数)。现在因为看不太懂第二篇,所以打算将第一篇翻译出来,以便后续回来反复学习。第 0 篇文章讲的通用的协程理论,比较容易理解,不涉及 C++20 协程内容,暂时没有翻译计划。翻译错误及不太好的地方,欢迎大家指正。

原文链接

C++ 协程:理解 co_await 操作符

在上一篇协程理论文章中,我描述了函数和协程之间的高层区别,但并没有详细介绍 C++ Coroutines TS (N4680) 所描述的协程的语法和语义。

Coroutines TS 向 C++ 语言添加了暂停一个协程,并允许它稍后恢复这个重要的新功能。TS 通过新的 co_await 操作符来提供该机制。

理解 co_await 是如何工作的,可以帮助我们揭开协程神秘的面纱,让我们理解它们是如何挂起和恢复的。在这篇文章中,我将解释 co_await 操作符的机制,并介绍与之相关的 AwaitableAwaiter 这两个类型的概念。

在我们深入 co_await 操作符之前,我想提供一个 Coroutines TS 所提供内容的概览。

Coroutines 带给我们了什么?

  • 三个语言关键字:co_await, co_yieldco_return
  • std::experimental 名字空间的几个新类型:(译注:原作者文章写于 2017 年,因此位于 std::experimental 名字空间内)
    • corotine_handle<P>
    • corotine_traits<Ts...>
    • suspend_always
    • suspend_never
  • 一套通用的机制,协程库作者可以使用它们来与协程进行交互并自定义他们协程的行为。
  • 一种使编写异步代码更加容易的语言基础设施!

C++ Coroutines TS 在语言中提供的设施可以被认为是用于协程的底层汇编语言。这些设施可以通过安全的方式被不同地使用,并主要是面向协程库开发者,让他们构建更高层的抽象,来使应用开发者们可以安全地使用、工作。

我们的计划是将这些新的底层工具交付到即将到来的语言标准(希望是 C++20)中,以及标准库中一些附带的高级类型,这些类型包装了这些底层的构建块,并使应用程序开发者以一种安全的方式更容易地使用协程。

编译器 <-> 库 交互

有趣的是,Coroutines TS 实际上并没有定义协程的语义。他没有定义如何产生返回给调用者的值。它没有定义返回值如何被传递给 co_return 语句,或者如何处理从协程传播出去的异常。它没有定义协程在哪个线程上恢复运行。

与之代替的是,它为库代码指定了一种通用机制,通过实现符合特定接口类型来定制协程的行为。然后,编译器生成代码,在库提供的类型实例上调用方法。这种方法与库开发者可以通过定义 begin() / end() 方法和一个 iterator 类型来自定义基于范围的 for 循环行为相似。

Coroutines TS 没有为协程机制规定任何特定的语义,这使得它成为一个强大的工具。它允许库开发者定义许多不同种类的协程,用于各种不同的目的。

举例来说,你可以定义一个协程来异步生产一个有符号的数值,或者定义一个协程来惰性产生一系列地值,或者定义一个协程来简化用于消费 optional<T> ,如果遇到 nullopt 值则提前退出的控制流。

Coroutines TS 定义了两类接口:Promise 接口和 Awaitable 接口。

Promise 接口指定了定制协程本身行为的方法。协程库开发者可以定制当调用协程时会发生什么,协程返回时发生什么(要么通过正常方式,要么通过未处理的异常),并定制协程中任何 co_awaitco_yield 表达式的行为。

Awaitable 接口指定了控制 co_await 表达式语义的方法。当一个值被 await 时,代码被转换为对 awaitable 对象上方法的一系列调用,这些方法允许它进行指定:是否挂起当前协程,在挂起后执行一些用于后续恢复的逻辑,以及在协程恢复后执行一些逻辑来产生 co_await 表达式的结果。

我将在后续的文章中介绍 Promise 接口的细节,现在让我们看向 Awaitable 接口。

Awaiters 和 Awaitable:理解 operator co_await

co_await 是一个新的可以用于一个值的一元操作符,如 co_await someValue

co_await 操作符只可以在协程上下文中使用。这有点像一个重复,因为在定义上,任何函数体中使用了 co_await 操作符,他将被编译成为一个协程。

一个支持 co_await 操作符的类型被称作 Awaitable 类型。

注意,是否可以将 co_await 操作符应用于一个类型依赖于 co_await 表达式出现的上下文。用于协程的 promise 类型可以通过它的 await_transform 方法(后续详细介绍)在协程内部改变 co_await 表达式的含义。

更具体地说,我喜欢使用 Normally Awaitable 来描述在协程上下文中支持 co_await 操作符的类型,该类型的 promise 类型中不含有 await_transform 成员。我喜欢使用术语 Contextually Awaitable 来描述仅在特定类型的协程的上下文中支持 co_await 操作符的类型,(因这种类型协程具有 await_transform 方法才支持)。(我期待对这两个名字更好的建议)

Awaiter 类型实现了三个特定的方法,await_ready, await_suspendawait_resume,这些方法作为 co_await 表达式的一部分被调用。

注意,我在这里 “毫不羞耻” 地从 C# async 关键字的机制中借用了术语 ‘Awaiter’,该机制是通过 GetAwaiter() 方法实现的,该方法返回一个具有接口的对象,该接口与 C++ Awaiter 概念惊人似的对象。有关 C# awaiters 的更多细节,请参阅这篇文章

注意,一个类型,可以既是 Awaitable 类型,又是 Awaiter 类型。

当编译器遇到一个 co_await <expr> 表达式时,根据所涉及到的类型,实际上可以将其转换为许多可能的内容。

获取 Awaiter

编译器做的第一件事是生成代码以获取被 await 值的 Awaiter 对象。在 N4680 中的第 5.3.8 (3) 节中介绍了获取 await 对象的许多步骤。

让我们假设正在异步等待的协程的 promise 对象类型为 P,并且这个 promise 是对当前协程 promise 对象的左值引用。

如果 promise 类型 P 有 await_transform 成员,那么 <expr> 会首先被传到 promise.await_transform(<expr>) 调用中来获取 Awaitable 值,(我们称其名字为)awaitable。否则,如果 promise 类型没有 await_transform 成员,我们会使用求值后的 <expr> 直接作为 Awaitable 对象,awaitable。

然后,如果这个 Awaitable 对象,awaitable,有一个合适的 operaor co_await() 重载,那么它将会被调用来获取 Awaiter 对象。否则,这个 awaitable 对象,会被作为 awaiter 对象来使用。

如果我们将这些规则编码到函数 get_awaitable()get_awaiter() 中,它们看上去像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
template<typename P, typename T>
decltype(auto) get_awaitable(P& promise, T&& expr)
{
if constexpr (has_any_await_transform_member_v<P>)
return promise.await_transform(static_cast<T&&>(expr));
else
return static_cast<T&&>(expr);
}

template<typename Awaitable>
decltype(auto) get_awaiter(Awaitable&& awaitable)
{
if constexpr (has_member_operator_co_await_v<Awaitable>)
return static_cast<Awaitable&&>(awaitable).operator co_await();
else if constexpr (has_non_member_operator_co_await_v<Awaitable&&>)
return operator co_await(static_cast<Awaitable&&>(awaitable));
else
return static_cast<Awaitable&&>(awaitable);
}

Awaiting the Awaiter

因此,假设我们已经将 <expr> 结果转换为一个 Awaiter 对象的逻辑封装进了上述函数中,那么,co_await <expr> 的语义将会被(大概)翻译成以下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
{
auto&& value = <expr>;
auto&& awaitable = get_awaitable(promise, static_cast<decltype(value)>(value));
auto&& awaiter = get_awaiter(static_cast<decltype(awaitable)>(awaitable));
if (!awaiter.await_ready())
{
using handle_t = std::experimental::coroutine_handle<P>;

using await_suspend_result_t =
decltype(awaiter.await_suspend(handle_t::from_promise(p)));

<suspend-coroutine>

if constexpr (std::is_void_v<await_suspend_result_t>)
{
awaiter.await_suspend(handle_t::from_promise(p));
<return-to-caller-or-resumer>
}
else
{
static_assert(
std::is_same_v<await_suspend_result_t, bool>,
"await_suspend() must return 'void' or 'bool'.");

if (awaiter.await_suspend(handle_t::from_promise(p)))
{
<return-to-caller-or-resumer>
}
}

<resume-point>
}

return awaiter.await_resume();
}

当调用 await_supsend() 返回时, 返回 void 版本的 await_suspend() 会无条件地将执行转移回调用者 / 恢复者,而返回 bool 版本的会允许 awaiter 对象在满足一定条件下立即恢复协程而不返回调用者 / 恢复者。

awaiter 启动异步操作而该异步操作可以同步完成而不需要等待时,await_suspend() 返回 bool 的 版本会非常有用。在这种情况下,它可以同步完成, await_suspend() 方法可以返回 false 来指定协程应该立即恢复并继续执行。

<suspend-coroutine> 点,编译器生成一些代码来保存当前协程的状态,并为恢复做准备。这包括储存 <resume-point> 点位置,以及将当前保存在寄存器中的任何值保存到协程帧内存中。

<suspend-coroutine> 操作完成后,当前协程会被认为挂起。你可以观察到挂起协程的第一个点是在 await_suspend() 调用的内部。一旦协程被挂起,就可以恢复或销毁它。

await_ready() 方法的目的是,如果了解操作可以被同步地完成而不需要挂起,在这种情况下,你可以避免 <suspend_coroutine> 操作的消耗。

<return-to-caller-or-resumer> 点,执行权将被转移回调用者或者恢复者,弹出本地栈帧,但要保持协程帧存活。

当(或者如果)被挂起的协程最终被恢复时,执行会在 <resume-point> 恢复,即在调用 await_resume() 方法以获取操作结果之前。

await_resume 方法调用的返回值会成为 co_await 表达式的结果。await_resume() 方法也可以抛出异常,在这种情况下,异常从 co_await 表达式传播出去。

注意,如果异常在 await_suspend() 调用中传播出去,那么会自动恢复协程,并且在 co_await 表达式中传播而无需调用 await_resume()

Coroutine Handles

或许你已经注意到了 coroutine_handle<P> 类型的使用,该类型传递给了 co_await 表达式的 await_suspend() 调用。

这个类型表示一个非拥有权的协程帧的句柄,并且可以被用于恢复协程的运行或者销毁协程帧。它也可以被用于访问协程的 promise 对象。

coroutine_handle 类型具有以下(缩写)接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
namespace std::experimental
{
template<typename Promise>
struct coroutine_handle;

template<>
struct coroutine_handle<void>
{
bool done() const;

void resume();
void destroy();

void* address() const;
static coroutine_handle from_address(void* address);
};

template<typename Promise>
struct coroutine_handle : coroutine_handle<void>
{
Promise& promise() const;
static coroutine_handle from_promise(Promise& promise);

static coroutine_handle from_address(void* address);
};
}

当实现 Awaitable 类型时,你将在 coroutine_handle 上使用的关键的方法是 .resume(),当操作完成并且你想恢复一个正在等待中的协程的执行时,应该调用它。在一个 coroutine_handle 上调用 .resume() 将会在 <resume-point> 激活一个被挂起的协程。当协程遇到下一个 <return-to-caller-or-resumer> 时,对 .resume() 的调用将会返回。

.destory() 方法将会销毁协程帧,调用所有作用域内变量(in-scope variables)的析构函数,并且释放协程帧使用的内存。你一般不需要(实际上应该避免)调用 .destroy() 除非你是一个在实现协程 promise 类型的库作者。通常,协程帧将由调用协程返回的某种 RAII 类型所拥有。因此,在不与 RAII 对象合作的情况下调用 .destory() 可能会导致 double free bug。

.promise() 方法的返回一个协程 promise 对象的引用。然后,像 .destory() 方法,它通常只在你正编写协程 promise 类型时才有用。你应该将协程的 promise 对象视为协程的内部实现细节。对于大多数 Normally Awaitable 类型,你应该使用 coroutine_handle<void> 作为 await_suspend() 方法的参数类型,而不是 coroutine_handle<Promise>

coroutine_handle<P>::from_promise(P& promise) 函数允许从一个协程 promise 对象的引用重建它的协程句柄。注意,你必须要保证类型 P 精确地匹配协程帧使用的 promise 类型。尝试从 Derived 的 promise 类型创建 coroutine_handle<Base> 会导致未定义行为。

.address()/from_address 函数允许协程句柄与 void * 指针间的相互转换。这主要是为了传递一个上下文参数到现有的 C 风格的 API 中,因此你会发现这在一些实现 Awaitable 类型的场景中是有用的。然而,在大多数情况下,我发现有必要将附加信息传递给这个上下文参数中回调函数,因此,我一般将协程句柄存储在结构体里,并在上下文参数中传递指向该结构体的指针,而不是使用 .address() 的返回值。

无同步的异步代码

co_await 操作符一上强大的设计特性就是可以在暂停协程之后、在返回调用者 / 恢复者之前执行代码。

这允许一个 Awaitable 对象在协程挂起后初始化一个异步操作,将挂起的协程的 coroutine_handle 传递给这个操作,当操作完成(可能在另一个线程上)时,它可以安全的恢复协程,而不需要额外的同步。

例如,当协程已经挂起时,在 await_suspend() 中启动异步读操作,意味着我们可以在操作完成时恢复协程,而不需要任何线程同步来协调启动该操作的线程和完成该操作的线程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Time     Thread 1                           Thread 2
| -------- --------
| .... Call OS - Wait for I/O event
| Call await_ready() |
| <supend-point> |
| Call await_suspend(handle) |
| Store handle in operation |
V Start AsyncFileRead ---+ V
+-----> <AsyncFileRead Completion Event>
Load coroutine_handle from operation
Call handle.resume()
<resume-point>
Call to await_resume()
execution continues....
Call to AsyncFileRead returns
Call to await_suspend() returns
<return-to-caller/resumer>

在利用这种方法时需要非常小心的一件事是,一旦你启动了将协程句柄发布到其他线程的操作,那么可能会在 await_suspend() 返回之前在另一个线程上恢复协程,并可能继续与 await_suspend() 方法的其余部分并发执行。

协程在恢复时做的第一件事就是调用 await_resume 来获取结果,然后它一般会立即析构 Awaiter 对象(如,await_suspend() 调用中的 this 指针)。之后,协程可能运行到完成,销毁协程和 promise 对象,这些都在 await_suspend() 返回之前完成。

因此,在 await_suspend() 方法中,一旦协程可以在另一个线程上并发地恢复,您需要确保避免访问 this 或协程的 .promise() 对象,因为这两个对象都可能已经被销毁。一般来说,在启动操作和调度恢复协程之后,唯一可以安全访问的对象是 await suspend() 中的本地变量。

与有栈协程比较

我想简单地比较一下 Coroutines TS 无栈协程在协程挂起后执行逻辑的能力,以及一些现有的常用的有栈协程设施,如 Win32 纤程或 boost::context。

在许多有栈协程框架中,一个协程的挂起操作与另一个协程的恢复结合在一起,形成上下文切换操作。使用这种上下文切换操作,通常没有机会在挂起当前协程后,恢复执行另一个协程之前执行逻辑。

这意味着如果我们想在有栈协程之上实现类似的异步文件读取操作,那么我们必须在挂起协程之前启动该操作。因此,在协程挂起并有资格恢复之前,操作可能在另一个线程上完成。在另一个线程上完成的操作和协程挂起之间的潜在竞争需要某种线程同步来仲裁和决定赢家。

通过使用蹦床上下文,可以在初始上下文挂起之后启动操作来代表初始上下文。(不太理解这,原文为 There are probably ways around this by using a trampoline context that can start the operation on behalf of the initiating context after the initiating context has been suspended. )然而,这将需要额外的基础设施和额外的上下文切换来使其工作,这可能带来的开销将大于它试图避免的同步成本。

避免分配内存

异步操作通常需要存储一些每个操作的状态,以跟踪操作的进度。这个状态通常需要在操作期间持续,并且只应在操作完成后立即释放。

例如,调用异步 Win32 I/O 函数需要您分配并将指针传递给 OVERLAPPED 结构。调用者负责确保此指针在操作完成前保持有效。

对于传统的基于回调的 api,这种状态通常需要在堆上分配,以确保它具有合适的生命周期。如果您正在执行许多操作,则可能需要为每个操作分配和释放此状态。如果性能是一个问题,那么可以使用自定义分配器从池中分配这些状态对象。

然而,当我们使用协程时,我们可以利用协程帧内的局部变量在协程挂起时保持活跃的事实,从而避免为操作状态分配堆上存储。

通过将每个操作状态放在 Awaiter 对象中,我们可以高效地从协程帧中借用内存,用于存储 co_await 表达式期间的每个操作状态。一旦操作完成,协程将恢复,Awaiter 对象将被销毁,释放协程帧中的内存供其他局部变量使用。

最终,协程帧仍可能被分配到堆上。然而,一旦分配了,这个协程帧就可以使用这个单一的堆分配来执行许多异步操作。

如果你仔细想想,协程帧就像一种真正高性能的 arena 内存分配器。编译器在编译时计算出所有局部变量所需的总 arena 大小,然后能够根据需要将这些内存分配给局部变量,zero overhead!尝试使用自定义分配器来击败它;)

一个栗子:实现一个简单的线程同步原语

现在我们已经介绍了 co_await 操作符的许多机制,我想通过实现一个基本的 awaitable 同步原语(异步手动重置事件)来展示如何将这些知识应用到实践中。

此事件的基本需求是它需要被多个并发执行的协程可等待(Awaitable),当等待时需要挂起等待的协程,直到一些线程调用 .set() 方法,此时任何等待的协程都将恢复。如果某个线程已经调用了 .set(),那么协程应该继续而不挂起。

理想情况下,我们还希望使它成为 noexcept,不需要堆分配,并有一个无锁的实现。

2017./11/23 修改:添加 async_manual_reset_event 使用示例

使用示例类似这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
T value;
async_manual_reset_event event;

// 单个产生值
void producer()
{
value = some_long_running_computation();

// 通过 set 事件来发布值
event.set();
}

// 支持多个并发消费
task<> consumer()
{
// 等待,直到 producer() 函数通过 event.set() 来设定事件
co_await event;

// 现在,可以安全地消费 'value'
// 这个保证在 `value` 赋值后发生
std::cout << value << std::endl;
}

让我们首先考虑下事件可能的状态:未设置已设置

当它处于未设置状态时,会有一个 (可能为空的) 等待中的协程列表等待它被设置。

当它处于设置状态时,将不会有任何等待协程,因为在此状态下等待事件的协程可以继续而不暂停。

这个状态实际上可以用一个 std::atomic<void *> 来表示。

  • 已设置 状态的指针保留一个特殊的指针值。在这种情况下,我们使用事件的 this 指针,因为我们知道它不能与任何列表项的地址相同。
  • 否则,事件处于未设置状态,值是指向等待状态中的协程结构的单链表头部的指针。

通过将节点存储在协程帧上的 ‘awaiter’ 对象内,我们可以避免为堆上的链表分配节点的额外调用。

让我们从一个类接口开始,它看起来像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class async_manual_reset_event
{
public:

async_manual_reset_event(bool initiallySet = false) noexcept;

// 没有拷贝/移动构造函数
async_manual_reset_event(const async_manual_reset_event&) = delete;
async_manual_reset_event(async_manual_reset_event&&) = delete;
async_manual_reset_event& operator=(const async_manual_reset_event&) = delete;
async_manual_reset_event& operator=(async_manual_reset_event&&) = delete;

bool is_set() const noexcept;

struct awaiter;
awaiter operator co_await() const noexcept;

void set() noexcept;
void reset() noexcept;

private:

friend struct awaiter;

// - 'this' => 已设置状态
// - otherwise => 未设置状态, awaiter * 的链表头节点
mutable std::atomic<void*> m_state;

};

这里我们有一个相当直接和简单的接口。此时需要注意的主要事情是,它有一个操作符 co_await() 方法,该方法返回一个尚未定义的类型 awaiter

让我们现在来定义 awaiter 类型。

定义 Awaiter

首选,我们需要知道它将等待哪一个 async_manual_reset_event,因此它需要一个对事件的引用和一个构造函数来初始化它。

它还需要充当一个由 awaiter 值组成的链表的节点,因此它需要保存一个指向列表中下一个 awaiter 对象的指针。

它还需要存储正在执行 co_await 表达式的等待中协程的 coroutine_handle,以便事件可以在被设置时恢复协程。我们不关心协程的 promise 类型是什么,因此我们使用 coroutine_handle 就好,(coroutine_handle<void> 的编写)。

最终, 它需要实现 Awaiter 接口,因此,它需要三个特定的方法 await_spendawait_readyawait_resume。我们不需要从 co_await 表达式中返回值,因此 await_resume 可以返回 void

一旦我们把所有这些放在一起,一个服务生的基本类接口看起来像这样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct async_manual_reset_event::awaiter
{
awaiter(const async_manual_reset_event& event) noexcept
: m_event(event)
{}

bool await_ready() const noexcept;
bool await_suspend(std::experimental::coroutine_handle<> awaitingCoroutine) noexcept;
void await_resume() noexcept {}

private:

const async_manual_reset_event& m_event;
std::experimental::coroutine_handle<> m_awaitingCoroutine;
awaiter* m_next;
};

现在,当我们 co_await 一个事件时,如果事件已经被设置,我们不想挂起让协程挂起。所以,如果事件已经被设置,我们可以将 await_ready() 返回为 true

1
2
3
4
bool async_manual_reset_event::awaiter::await_ready() const noexcept
{
return m_event.is_set();
}

接下来,让我们看看 await_suspend() 方法。这通常是 awaitable 类型中最神奇的事情发生的地方。

首先,它需要将等待中协程的协程句柄存储到 m_awaitingCoroutine 成员中,以便事件稍后可以对其调用 .resume()

完成这些之后,我们需要尝试将 awaiter 原子地入队到等待者的链表中。如果我们成功地将其入队,那么我们返回 true,表示我们不想立即恢复协程,否则,如果我们发现事件同时被更改为已设置状态,那么我们返回 false,表示应该立即恢复协程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
bool async_manual_reset_event::awaiter::await_suspend(
std::experimental::coroutine_handle<> awaitingCoroutine) noexcept
{
// 特定的 m_state 值用于表示事件牌 ‘已设置’ 状态
const void* const setState = &m_event;

// 存储等待中协程的句柄
m_awaitingCoroutine = awaitingCoroutine;

// 尝试原子性地将 awaiter 放到链表头
void* oldValue = m_event.m_state.load(std::memory_order_acquire);
do
{
// 如果已经在 ‘已设置’ 状态,立即恢复
if (oldValue == setState) return false;

// 更新 next 指针,指向当前头部
m_next = static_cast<awaiter*>(oldValue);

// Finally, try to swap the old list head, inserting this awaiter
// as the new list head.
// 最后,尝试交换旧的列表头节点,将这个 awaiter 作为新的链表头节点
} while (!m_event.m_state.compare_exchange_weak(
oldValue,
this,
std::memory_order_release,
std::memory_order_acquire));

// 成功入队,保持挂起。
return true;
}

注意,当我们加载旧的状态时,我们使用 acquire 内存顺序,因此,如果我们读到了特定的 ‘已设置’ 值,我们可以看到调用 set() 之前发生的写操作。

如果比较 - 替换成功,我们需要 release 语义,因此后续调用 set() 将看到我们写入 m_awaitingCoroutine 和写入之前协程状态。

补充事件类的其他部分

现在我们已经定义了 awaiter 类型,让我们回过头来看看 async_manual_reset_evnet 方法的实现。

首先,构造函数。它需要初始化到未设置状态,并使用空的等待者列表(如 nullptr)或初始化到 ‘已设置’ 状态(即 this)。

1
2
3
4
async_manual_reset_event::async_manual_reset_event(
bool initiallySet) noexcept
: m_state(initiallySet ? this : nullptr)
{}

然后,is_set() 方法非常直接 —— 通过判断它是否有特殊的 this 值表示它是否被设置。

1
2
3
4
bool async_manual_reset_event::is_set() const noexcept
{
return m_state.load(std::memory_order_acquire) == this;
}

接下来,reset() 方法。如果它处于 ‘已设置’ 状态,我们希望转换回未设置时空链表的状态,而不是维持原样。

1
2
3
4
5
void async_manual_reset_event::reset() noexcept
{
void* oldValue = this;
m_state.compare_exchange_strong(oldValue, nullptr, std::memory_order_acquire);
}

set 方法中,我们希望通过将当头状态与特殊设定值 this 交换来转换到 ‘已设置’ 状态。然后检查原来的值是什么。如果有任务等待中的协程,那么我们希望在返回之前依次恢复每个协程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void async_manual_reset_event::set() noexcept
{
// Needs to be 'release' so that subsequent 'co_await' has
// visibility of our prior writes.
// Needs to be 'acquire' so that we have visibility of prior
// writes by awaiting coroutines.
void* oldValue = m_state.exchange(this, std::memory_order_acq_rel);
if (oldValue != this)
{
// Wasn't already in 'set' state.
// Treat old value as head of a linked-list of waiters
// which we have now acquired and need to resume.
auto* waiters = static_cast<awaiter*>(oldValue);
while (waiters != nullptr)
{
// Read m_next before resuming the coroutine as resuming
// the coroutine will likely destroy the awaiter object.
auto* next = waiters->m_next;
waiters->m_awaitingCoroutine.resume();
waiters = next;
}
}
}

最后,我们需要实现 operator co_await() 方法。这只需要构造一个 awaiter 对象。

1
2
3
4
5
async_manual_reset_event::awaiter
async_manual_reset_event::operator co_await() const noexcept
{
return awaiter{ *this };
}

我们创造了它。一个可异步等待的手动重置事件,它具有无锁、无内存分配、noexcept 的实现。

如果您想使用代码玩耍,或查看其在 MSVC 和 Clang 上的编译,可以在 godbolt 上查看源代码。

你也可以在 cppcoro 库中找到该类的实现,以及其他一些有用的 awaitable 类型,如 async_mutexasync_auto_reset_event

结束语

本文介绍了 operator co_await 是如何实现的,以及如何根据 AwaitableAwaiter 概念定义的。

本文还介绍了如何实现一个可等待的异步线程同步原语,该原语利用了在协程帧上分配等待对象以避免额外堆分配的事实。

我希望这篇文章可以帮助你揭开新 co_await 操作符的神秘面纱。

在下一篇文章中,我将探讨 Promise 概念,以及协程类型作者如何定制他们协程的行为。

致谢

我想特别感谢 Gor Nishanov 在过去几年中耐心和热情地回答了我关于协程的许多问题。

并向 Eric Niebler 审查并提供有关本文早期草案的反馈。


留言

非常欢迎在 这个 Github issue 上进行留言。

见字如晤。

岁聿云暮,律回春渐,又是新的一年。每至年关,我总想写些什么东西记录下过去的一年,才不使匆匆没有痕迹。但去年好像都是一件件零碎的事情,碎碎念要更多一些,感觉不能统筹为正式的文章。大年二十九晚上,我在 B 站 [^B 站] 看到一位父亲给孩子写的信,便想着不如以后将每年的总结改为写给你们的信,将我的碎碎念说给未来的你们听,假使你们到了 24 岁时,如果能感受到共鸣或有所收获,那实在是太棒了,这种穿越时空的情感,不知是否能让你们砰然心动。

阅读全文 »

松松的题目集中有 $n$ 道题目 $a_1, a_2, \dots, a_n$,每道题目都只考查一个知识点。松松要按顺序做完其中的所有题目,他有两种做题方法:

  • 无论当前题目考查什么知识点,都可以在 $c_0$ 时间内做完该道题目。
  • 使用含有 $k$ 种知识点的小抄,可以在总共 $c_k$ 的时间内完成所有接下来可做的题目。即当遇到小抄中不包含的知识点题目或已经做完所有题目时,认为当前方法停止,此次做题方法用时 $c_k$ 。每次使用小抄都可以使用与之前不同的小抄,每份小抄最多有 $m$ 个知识点,即 $1 \le k \le m$ 。

聪明的您需要编写一个算法,使得松松能在最短时间内按顺序完成所有的练习题。同时,您还需要计算出在最短时间内做完练习题的方案数。如果做完题目的使用上面两种方法的次数不同,或存在某一个整数 $i$ 满足两种做题方案中的第 $i$ 次做题使用了不同的做题方法或不同的小抄,则称两种做题方案是不同的。如果两份小抄的大小不同,或含有不同的知识点,则称两份小抄是不同的。

阅读全文 »

题目传送门

Solution

题意

给定一个长度为偶数 ($n < 20000$)、均为小写字母的字符串,Alice 和 Bob 轮流从中取一个字母,可以选择从左面取一个,也可以选择从右面取,追加到自己已有的字符串末尾(两人初始 r 字符串为空)。字符串被取空后,两个自己的字符串字典序小的胜利。

阅读全文 »

略微清闲的时候,我喜欢去邂逅一些个人博客网站。你在一个时间记录下自己生活、感受或是感悟,而后不知道在哪个时间里,有人细细读你的文章。像是老友,两人交谈着自己的故事,或有一刻,两人在不同的时空中有某种感情是相同的。对于我来说,这种感觉很棒。可惜的是,现在社会中似乎愿意如此的人少了些,人们似乎更青睐更具有趣味和视觉冲击力的短视频,而那些能让人产发深思的文章逐渐失宠。因此,能够遇到一个较为优质的博客,里面有着还算不错的文章,都能让我开心一段时间。

前一段时间,偶然遇到一个不错的博客,博客的名称不是当前主流的网名,而就是博主的名字,谢益辉。阅读他的文章目录时,名为《二千里外故人心》的标题冲击着我的心灵。凭借着对月特有的情感与对文字的直觉,我感受到这七个字不是一般人所能写出,必出自大家之手。也许是什么东西的召唤,我自然的、没有太多意识的就点进去读了这篇文章。文章讲了身在异国的博主与中国邻居的分别 —— 大人与大人的分别、孩子与孩子的分别,讲了现代科技对人们对于分别这种情感的影响。但这篇文章让我记忆最深的,还是标题中所引用的那首诗

三五夜中新月色,
二千里外故人心。

伟大诗人厉害的地方就在此,如此质朴简练的十四个字,却将最深厚、真挚的感情写了出来。这样的句子,不是出自名家之手,就是出自普通人感悟迸发的一刻,妙手偶得。不过能写出这十四个字,即使是普通人也基本上离大家不远了。我查了下这首诗,果真是白居易写的。诗人并没有细说新月色为何色,何种美丽,故人心为何心,心中念何,但单是如此,就让人不由得感受到两人之间的深情。如此美丽的月色,我不由得想念起你来,身在远方的你看到这月色,也会如我这般吗?两人间的深情留给读者自由发挥,留白让读者结合读者自己的经历来体验。如同中国菜谱上的少许、适量一般,具体是多少,还得你自己去体会。中国人表达感情向来是倾向含蓄的,美也在含蓄之中诞生,如曹植写洛神情态时写 “若将飞而未翔”,具体是什么样子呢,自己想象吧,单这六字就已经把美感传递给你了。

我在中秋前一周看到这句诗,当时就在想,中秋的文案准备好了。又仔细看了看,兀地发现这首诗刚好就是白居易在中秋写的,他所想念的人也不必多说了。《八月十五日夜禁中独直对月忆元九》。千年前,白在中秋用简练的文字写下对元稹的思念,千年后的中秋附近,我偶尔在另一人有关离别的博客中遇到了这首诗,心中又思念起谁。这种感情穿越千年,在三个时间、三个地方的三人心中浮现,却又是那样的相同。千年间,我感觉到有种东西、有种文化不变地传承了下来,会在人心中偶尔泛起。三五二八时,千里与君同。

昨天是中秋,吃完火锅和父母视频后,我下楼喂猫赏月思故人,这首诗一直挂念在心头。喂猫时发现地上已经有五六摞猫粮了,这是之前很少遇到的,这个小区也有着和我一样的人,在中秋时想着也让猫猫过个节呢。人类的温情,就这样在这座冰冷的钢铁城市中流淌。喂的猫各异,但都是橘,有只狸花并不在编制内。猫猫或许大多都挺喜欢我,会跟着我走,我停下它们也会趴在我周围。中秋月圆,橘猫们也很悠闲地打闹舔毛。真想把他们全拍下来,带上中秋的月亮,照张全家福。奈何猫猫们三百六十度地围着我,月和猫虽都是橘色,但又有着上下之遥,猫们又活泼好玩,光线暗淡,绝不易拍下。反倒是假若有人在看我,我成了别人眼中的一幕,旁观者倒或许能将我与月与猫一同拍下,不过我可以会遮住几只猫猫吧。又觉得此刻之心已足以让人满足,人生在世,难能有几场如此的体验,单纯地、尽情地去享受就好,来拍照反倒耽误了如此良宵。在这种心情的安慰下,也就放下了拍照的念头。

思念故人,不知从何时起成了我心头的一位常客,或许是自己在深圳的生活大多带着匆匆与压抑,总会想起过去的朋友,想和他们聊聊天,再见见他们,问问他们现在的生活如何,过去的心愿是否实现。把自己的生活讲给他们,不知是否如北岛所说 “那时我们有梦,关于文学,关于爱情,关于穿越世界的旅行。如今我们深夜饮酒,杯子碰在一起,都是梦碎的声音”。

月下,我想起昨天故乡的小伙伴给我发的 “古鸡鸣寺” 的牌坊,他去了南京读研,我在吃午饭,在 B 站看视频刚好是雪中的药师塔,我拍照发给他屏幕上的鸟瞰雪中鸡鸣药师塔,他发给我正在拍的昨日晴天仰视的药师塔视角。我说 “想念”,他说 “等你”。我是个向来不好意思表达感情的木讷男生,看着这段聊天记录,我觉得有些深情不用过多的繁饰,就如同前段台海时期我们一同挂念着另一个小伙伴一样。我又想起铭毓在秋天写成的春末想我的词。

春朽繁花瘦。似尘缘,聚时无意,盛情别后。

这是这首《金缕曲》的开头。虽是在他笔下写成的,却诉尽了我的心声。春暮,你看到将谢的花,一股春愁涌上你的心头,是呀,这股春愁就如同尘缘一般,初见相聚时并没有太多的心绪,却在花将谢时,人分别后,一股愁、一股盛情在心中徘徊,方知这段感情在自己心中的重量。

翻朋友圈,我看着室友和他女朋友在学校南操上的照片,大学里没有和舍友一起过过中秋,那时的中秋不过是普通的一天,回忆时却无由觉得当时是幸福的。

想起还未曾见过面的妹妹。之前也有一次月色很美,我们聊着月亮,我下楼在等点的烧烤做好的间隙里陪着猫,她说很很久之前在某个地方看到,月亮很圆的时候,如果用食指指着月亮许愿,不管是什么愿望都会实现,不过玉兔会取下你许愿的食指,拿去捣药。那段时间她心情好像不是很好,我照着她说的做,许愿她能够一直快乐。我告诉她我没有听后面的,手指也就不会去捣药。初中高中的时候,我和妹妹在网络游戏里度过了两三个寒暑假,真是美好的记忆。后面我报了南京的志愿,与她最近的时候只隔了一辆车,她在前面的地铁 2 号线回学校,我在后面一趟 2 号线回家。她手机剩余电不多,学校还很远;我要回家陪猫,内向的我也没有鼓起勇气做好见她的准备。那一次,我们终是没在大行宫见到,她回了学校,我回了家。后来 2020 我毕业,想到将离南京,问她有没有空,可恶的疫情是又一次的梦碎。看着中秋前她收到好多朋友的礼物,一时有些羡慕,想着她现在应该有着很多朋友,可能不再是初中时那个天天跟我在游戏里一块,骗我在城内开红然后被守卫打死就开心好久的小姑娘。想起西贝写的诗,又觉得有点悲伤。望月思人,不知道后面还有没有机会再见她。

周四晚快睡时,我想起汪曾祺写的《黄油烙饼》,想到里面描写萧胜奶奶的去世,“奶奶是饿死的,人不是一下饿死的,是慢慢饿死的”。想起萧胜奶奶在最后给他做了两双鞋,一双正合脚,一双大一些。之前读的时候便已如泉涌,那晚想起时,不免又动了些情感。又想起自己外面的老奶奶,想起她对自己的疼爱,每次回老家见到她,都会用熟悉地声音喊我一声 “明浩”,只是上高中后,能回老家的次数逐渐减少。我想着再见到她,却猛地想起老奶奶已逝,我再也见不到了。老奶奶去世在 2020,由于疫情我没能回去见最后一眼,没能参加葬礼,或许是这个原因我总对老奶奶的去世记忆有些模糊,她疼爱我的样子却又那么真实可见,我一直记得小时每次过年,去田野中请完祖先后,到她院子的树旁放一挂小鞭,再挡上木棍。想来老奶奶已经去世两年了,我却没有留意过竟这么快,疫情后的时间是用年来计了。

近十二点,我与猫猫告别,与那轮明月告别,回到了家中,想写这篇文章,却觉得时间已晚,明天再写,又怕明天思绪不在,不能再写出来。好在这份感情算深算真,即使是今天写时,那种感觉犹在。

这是我的故事,如果你愿意的话,我也很期待能够读到你最近的故事。

2022 年

4 月

4 月 9 日

春天有一种魔力
一种让人想 “成为更好的自己” 的魔力

森见登美彦《春眠晓日记》

破执 —— 线程池设计

6 月

6 月 9 日

open () 系统调用如果没有指定权限,默认是什么?

手册难读的原因

  • 手册里面有些解释,是针对特定问题而说的。如果不知道有这个问题,就不能理解到这句话的意义。感觉这么设计似乎是对的,但不清楚这样设计的用意。不知道为什么要在这里插入这么一句话。
  • 需要前置知识,比如是 A 的手册,但里面有一些其他方面的知识。(dfs 学习,狗头)
  • 手册内容太全面了,对于小白来说不知道哪里是重点,哪里不是当前重点,可以日后再了解。
  • 手册不是教科书,内容设计上不是先易后难,逻辑上不是层层铺垫递进,而是知识的陈列。

这是一篇两年的年终总结,做两年的年终总结很奇怪,两年不像一年或者三年、五年,是一个较有意义时间段。这次写两年年终总结的原因,是因为 2020 年没来得及完成,后续想补的时候,又少了写下去的理由。现在来做两年的总结,而不是 2021 一年的,是因为 2020 年发生了许多对我来说很重要的事情,不做总结的话总感觉少了些什么,有些遗憾,觉得这一年还是有必要作记录的。2020 的事情其实已经不再清晰了,经历事情时的感受当时也变得模糊,现在来回忆总归还是不如当时吧。此情可待成追忆,只是当时已惘然。

阅读全文 »
0%