libfv:基于 C++20 的异步 HTTP 库

2022-04-23 20:52:46 +08:00
 fawdlstty

仓库地址:https://github.com/fawdlstty/libfv

介绍一款船新 HTTP 库。C++的 HTTP 库很多,但基于 C++20 的异步网络 HTTP 库几乎没有。我没找到好用的,因此写了一个。在讲解这个库之前,我先说说为什么我们需要这样的库。

C++ HTTP 库有两种主要的实现方式,第一种是同步 HTTP 网络访问,比如这样的代码:

// 伪代码
Response _r = HttpGet ("https://t.cn");
std::cout << _t.text;

这样的代码写起来很简单,但它存在一个问题:HTTP 网络访问比较耗时,可能需要几百毫秒,这么长时间,这个线程将阻塞在这里,比较消耗线程资源。假如遇到需要同时发起几十、几百个请求,将较大消耗系统资源。很显然,它不是一个较好的设计。

第二种是回调通知,比如这样的代码:

// 伪代码
HttpGet ("https://t.cn", [] (Response _r) {
	std::cout << _t.text;
});

这种方式解决了线程问题,也就是,几十、几百个请求可以同时发起,只需要极少量或者一个线程就行,HTTP 库内部实现了请求的内部管理,在收到请求的回复后,调用回调函数,从而实现请求的高效处理。但这种方式有个问题,假如我们需要根据请求结果内容转给下一个请求,这会带来一个回调地狱问题,比如这样的代码:

// 伪代码
HttpGet ("https://t.cn", [] (Response _r) {
    HttpGet (_t.text, [] (Response _r) {
        HttpGet (_t.text, [] (Response _r) {
            HttpGet (_t.text, [] (Response _r) {
                HttpGet (_t.text, [] (Response _r) {
                    std::cout << _t.text;
                });
            });
        });
    });
});

那么,有没更好的处理方式呢?有,通过 C++20 的 co_await 实现异步等待。下面给出 libfv 的发起请求的代码:

fv::Response _r = co_await fv::Get ("https://t.cn");

一方面它能获得回调方式的好处,也就是少量线程支撑同时大量的请求任务,同时它不会带来回调地狱问题。上面的代码通过 libfv 实现,代码可以这样写:

fv::Response _r = co_await fv::Get ("https://t.cn");
_r = co_await fv::Get (_r.text);
_r = co_await fv::Get (_r.text);
_r = co_await fv::Get (_r.text);
_r = co_await fv::Get (_r.text);
std::cout << _t.text;

这儿特别说明一下。单 CPU 处理效率来说,C++20 的异步性能比回调要低,大概 10%左右,也就是假设理论上跑满网络 IO 带宽情况,回调需要 10%的 CPU ,那么使用 C++20 的异步需要 11%,这是 stackless 需要付出的代价。当然,在我看来这个特性完全可以忽略,毕竟 IO 密集型应用首先需要考虑的是跑满网络带宽,一般不太需要关注 CPU 使用率。

libfv 使用方法见仓库:https://github.com/fawdlstty/libfv

4797 次点击
所在节点    C++
61 条回复
fawdlstty
2022-04-24 17:11:04 +08:00
@Calatrava 好吧。这个我后面加上
fawdlstty
2022-04-24 17:16:42 +08:00
@FrankHB 我用 c 艹 20 的东西不是因为我特别喜欢新特性,主要原因是我非常喜欢 c#的 await 语法,迫不及待想在 c 艹里用而已。毕竟对于 c 艹来说,能像 c#一样简单的开发异步程序是一件很有意思的事
ysc3839
2022-04-24 17:26:46 +08:00
@hankai17 C++20 的 coroutine 更像是回调函数,不一定要调度器。我写了一段对比 std::function 的代码,可以参考一下 https://godbolt.org/z/sd496fdxP
fawdlstty
2022-04-24 17:55:13 +08:00
@ysc3839 这个看起来像是,因为没有需要等待的东西,所以被编译器优化成了普通回调
ysc3839
2022-04-24 18:23:44 +08:00
@fawdlstty 针对这个问题,我简单改了下,先把所有“回调函数”保存到 vector 里,最后再逐个恢复执行 https://godbolt.org/z/noKM31KMf
Yain
2022-04-24 22:22:34 +08:00
难得在互联网上看见 C++ 人。分享一下我的协程异步框架吧: https://zhuanlan.zhihu.com/p/504313175

特点:面向系统调用,可以快速吸纳已有的上层库(包括 OP 这个 HTTP 库)

( C++ 新人求 Star ,不然连工作都没有 T.T ): https://github.com/Codesire-Deng/co_context
fawdlstty
2022-04-25 09:11:59 +08:00
@ysc3839 噢,不对,我看错了。应该这样说:类似回调函数的实现,但因为多做了一些事,导致性能要差很多
iqoo
2022-04-25 11:38:11 +08:00
支持一个,但 HTTP 这么复杂的协议造轮子太费精力了吧,而且 HTTP 一直在更新,比如目前大都用 brotli 解压缩、H/2 协议。
fawdlstty
2022-04-25 12:57:10 +08:00
HTTP1.1 还好吧,简单的协议。HTTP2 、HTTP3 得找其他轮子了
hez2010
2022-04-25 13:16:15 +08:00
async/await 这套原理上是非常高效的,并且是通用的异步方案,调度也并不依赖线程池。
MSVC 团队的人之前在 CppCon 上展示了用 C++ 20 的 coroutine 做 CPU prefetch 来提升 CPU 缓存命中率,性能和你人工写的高度优化的状态机没有任何差距: https://isocpp.org/blog/2019/09/cppcon-2018-nano-coroutines-to-the-rescue-using-coroutines-ts-of-course-g
这种层面的东西是 goroutine 、project loom 等 stackful coroutine 根本没法企及的,他们的作用只是减少 blocking ,而 async/awaiit stackless coroutine 出发点是设计出一套通用的异步方案。
hez2010
2022-04-25 13:29:45 +08:00
OP 里的测试之所以比回调慢 10%,估计也是因为有部分代码没被编译器成功 inline 掉,这个需要钻一下写法或者等待后续编译器的改进。
说“半成品”也是不妥当的,async/await 在语言层面上已经是完全体了,只不过 STL 里面没提供一个实现好的 Awaitable 罢了。并且在 Windows 上 WinRT API 里也有 `IAsyncAction` 和 `IAsyncOperation<T>`,都是按照 C++ 20 的 coroutine type trait 封装的 Awaitable ,因此如果你是做 Windows 开发( C++)的话,那也不需要等 STL 的 Awaitable 的,因为 Windows 的现代 API 本身就天然是异步的,并且已经提供了相关的实现。
hez2010
2022-04-25 13:33:21 +08:00
顺带附一个 Windows 系统开发者写的 C++ coroutine 教程系列:
https://devblogs.microsoft.com/oldnewthing/20210504-01/?p=105178
fawdlstty
2022-04-25 13:48:56 +08:00
@hez2010 赞同你的观点,原理上非常高效,并且通用。不过,回调在汇编层面,寄存器或线程栈里存入参数,call 就行了; co_await 复杂很多,一方面涉及保存 /加载现场( pushad/popad ),另一方面还得有状态机等等。在具有真正需要等待的任务面前实际上没法做比较明显的优化,顶多也就是 llvm 的那一套,逻辑一句也少不了。c#在异步这一块往前跑了很多年了,基于 Task<>的优化方案是通过 ValueTask<>,实际原理是没有真正异步等待的优化为同步调用实现。它也没法优化真正需要异步等待的情况
FrankHB
2022-04-25 20:56:38 +08:00
@fawdlstty 我能理解 async/await 有一些能好用的地方,所以大约能理解你的动机。
除此之外,如果你的目的是简化使用,照理来说应该赞扬,不过我不觉得能乐观。因为这方面不管是特性难产还是碎片化,很大程度来自 C++已有的设计,而频繁发版则在现实应用加速暴露了这些问题。

你说 u8string 之类的难用的可以不用,如果就是库,确实不难做到。然而这里更多是核心语言特性的设计缺乏前瞻性和可组合性的问题,特别是 await 更改的本质上是相当底层的东西( control effects )却极少有人预料到缺乏一个靠谱的全局设计的风险,未来什么时候跳出来会咬你一口,还真不好说。
只能到时候希望你能坚持初心。祝你好运。

@hez2010 我不得不跑点题(虽然跟之前说的也有关)。
async/await 在一些特定的场合是可用的方案,但距离通用差远了。
光是之前提到过的一些用户抱怨的关于 async 的传染性问题,就是不把 async/await 改掉不再 async ,就不可能解决的(原因下面说)。
另一方面,要是这个通用,这里按理根本就不用 stackful 的重量级玩意儿了(虽然这些东西能解决的问题比区区一个异步更广)。但现实呢?

现实的异步编程的一个关键问题就是怎么用看上去表面同步的 direct style 去干掉显式 CPS 。其中的一个重要场景是,已有 direct style 的大量源代码,怎么在尽量少的改动下引入异步,允许异步的好处。
直接 callback hell 显然不行。async/await 相对这种显然好,但还有明显的缺陷——要改的仍然太多了。具体来讲,async 和非 async 的部分原则上没法直接复用,这直接在逻辑上引入不必要的冗余。
为什么 async 需要被传染,即便“异步”自身的逻辑上不必要?因为这种方案根本上要求改变 direct style 函数的返回类型。通过函数签名上附加标记,实现才能通过 syntactic transformation 的方式直接魔改代码生成(比如你说的状态机)糊上不同 style 之间的 semantic gap 。
不过很遗憾,这就不是通用的需求:几乎所有现实异步编程的需求都不需要在乎返回类型和原先不同或者在乎生成的代码是否使用状态机(要是关心生成的代码,直接需求基本也就要开销小,尽量快);反倒是修改类型签名阻碍复用的作用暴露到对象语言用户面前,妥妥地绕不过(总不能逼用户自己糊状态机 8 )。所以这是一种具体实现造成的抽象泄露,断然不可能通用。

进一步地,钦定类型签名的变化实质上是在代码添加静态信息(即便被关键字掩盖了)。而现代 C++的传统并不买 type richer = better 的账,所以强推 async 实际是不大自然的。
例如,C++会强调 exception neutrality ,而和 Java 那种 checked exception 或者 Rust 的 Result 之类的机制(本质上都是庸俗的 union type )划清界限(即便有 expect ,也不会取代 exception ),因为不是所有情形都能对污染类型签名和 happy path 在现实中引起的修改的代价无所谓。
反过来,在不愿意影响类型签名的问题上,要让代码更 C 艹-ish ,就意味着允许用户自行把多余非预期类型擦掉(即便有一些开销)。对对象类型,擦除是相对容易的( any ),但是对隐藏在 async 这样的关键字下的类型,这里的修改开销现实缺乏可行性。
这意味着现实代码一旦引入 async 这样的特性,很可能在生命周期结束之前都没法甩掉,更加坐实了现实不可复用的缺陷。换句话说,要用了 async/await ,想要写语义上(而不只是表面的语法上)真正兼容同步和异步的代码,基本就不用想了。这又是一个不通用的例证。
考虑到 dynamic exception specification 这样非签名侵入式的特性都是这般下场,我实在无法看好 async 真被滥用之后的景观。(虽然或许不过直接分裂用户群体成会接受 async 和不接受 async 的就好了……)

最后,上面说的不可复用其实隐含了一点:不考虑就是否存在 async 实现 ad-hoc polymorphism 的扩展特性。
实际上,对类型系统加以魔改,引入类似 Haskell 的 levity polymorphism ,理论上是可以直接解决复用问题的。
但是,在此我旗帜鲜明地站在这种设计的对立面上,因为:
它解决的问题和背后的复杂性不成比例实在过于离谱;
C 艹的 value category 自带 subtyping ,直觉上就比 Haskell 的那几坨 kind 破烂难搞多了,瞎折腾就得寄,更别说做编译器的能理解了;
会把 C 艹程序的不(可)可(读)理(性)喻(垃)程(圾)度连跳几级,推高到船新的阶段;
async/await 说穿了也就是在以相当 syntactic 的方式实现区区那么一种 control effect ,甚至都没一般的 concurrency 排面,有什么脸占那么大核心语言特性演化资源?
这种复杂度已经失控的边缘的设计再下去是不是甭想扩展了?

最后的最后这点是我刻意要提到其它异步实现方式的直接理由;相对地,编程习惯的问题解决起来就小儿科多了。
ysc3839
2022-04-25 21:09:30 +08:00
@fawdlstty C++ 的 coroutine 并不复杂,主流的实现基本上是把 co_await 拆成 switch case 。简单举例的话,下面这段代码:
```
promise async_func() {
int i = 1;
int cross = test1(i);
co_await awaitable();
test2(cross);
}

void main() {
async_func();
}
```
编译后的结果类似于:
```
struct coro_async_func {
void run() {
switch (current) {
case 0:
{
int i = 1;
cross = test1(i);

current = 1;
awaitable(this);
break;
}
case 1:
test2(cross);
delete this;
break;
}
}
int current = 0;
int cross;
};

void main() {
(new coro_async_func)->run();
}

首先并不涉及“加载现场”,因为要跨越 co_await 的数据本来就保存在非易失的地方,更不涉及 pushad/popad 这种平台相关的操作,因为这套模式本来就是平台无关的。
其次“状态机”并不会很影响性能,主流编译器选择使用状态机实现,而不是拆分成多个函数估计也是有评估过的。
而 coroutine_handle.resume()也就是调用一下其中的 run()函数,和回调函数的性能一致。
至于你后面说的优化那些我就不懂了,我没了解过别的语言的实现。但如果说 C++ coroutine 不如别的语言性能更好的话,那普通回调函数的性能也会不如。
FrankHB
2022-04-25 21:37:01 +08:00
@ysc3839 真说性能,其实状态机一般用途上还是比较快的,伺候好 cache 的话。现代 CPU 的 jmp 多少能投机,没以前那么坑了;反倒 call 还是那么麻烦。
一般这类命令式语言中用的 IR 也对保留一整坨函数进行分析更不友好,扩大了偏好的影响。所以某种意义上,首选编译成状态机算是各家心照不宣的常识了。
相比之下,stackful 的东西得搞 activation record ,即便有 CPU 的 stack engine 加成也是得在 key path 上多吃 cycle 的,极端性能就没法打了(虽然场景其实不大公平)。
然而 C 艹现在这样的设计还是有损失性能的地方,就是一些东西扔 heap 这个投降得太早了,明摆着打不过 segmented stack+shared stack record 之类的。只不过现在的 C 艹也基本没法在这里走回头路。想在没搞定更基本的缺少怎么 reify the stack 的情况下还想折腾 ABI ,那基本就是找死。
fawdlstty
2022-04-25 22:59:20 +08:00
@ysc3839 你是对的。我记错了。co_yield 涉及保存 /加载现场
ysc3839
2022-04-26 09:07:15 +08:00
@fawdlstty co_yield 就只是 co_await promise.yield_value(expr) 的语法糖,也不涉及加载现场
https://en.cppreference.com/w/cpp/language/coroutines#co_yield
fawdlstty
2022-04-26 09:37:35 +08:00
@ysc3839 好吧,我没认真看过,凭理解我感觉有 stackful 和 stackless 方案,然后默认它是 stackful 方案了
fawdlstty
2022-04-26 11:33:17 +08:00

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/848811

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX