对使用redis++协程接口的一些探索
本文最后更新于 2025年5月22日 下午
最近在学习C++的协程,正巧redis++库也提供了协程接口,那就记录一下折腾的过程吧。
编译带有异步和协程接口的libredis++
在redis++的github主页上已经对此进行详尽的说明,故下述内容基本为转述。
安装libuv(版本要求为1.x)
redis++的异步接口需要使用第三方的事件库,但到目前为止仅支持libuv。
绝大部分的Linux发行版都可以通过包管理器直接安装,且版本满足要求, 以archlinux为例:
1 | |
如果对版本抱有疑惑,可以使用:
1 | |
会得到以下输出:
1 | |
libuv.so.x其中的x标志其主版本号,但实际版本可能为1.x.x
检查hiredis库的版本号
由于hiredis在1.0.0对其异步接口进行大幅度变更,而redis++是基于1.0.0以后的hiredis编写的。需要注意,debian使用apt默认安装的hiredis版本号为0.14。
如版本号不正确,建议拉取源码自行编译。
编译redis++
1 | |
如果你的uv和hiredis库不在PATH中,需要通过-DCMAKE_PREFIX_PATH=/installation/path/to/libuv/and/hiredis 该参数指定查找路径
尝试编译官方示例(不推荐)
官方示例中使用了cppcoro,但cppcoro已经不再维护且编译依赖于cake而难以编译,因此在此文中不过多提及。如果这仍不能抑制你的好奇心,你可以拉取该源码,该源码可以让你使用cmake编译cppcoro库。但当你尝试编译官方示例时,仍会遇到错误,不过你可以通过对cppcoro头文件的一个简单修改解决这个错误。
尝试在boost.asio中使用
boost::asio中一般通过使用co_spawn用来启动一个协程,它接受一个可调用对象,且其返回值必须是boost::asio::awaitable<>或者满足asio中协程定义的条件和约定的await对象。
1 | |
通过观察redis++的CoRedis内置的awaiter类代码,其满足了C++20对于可等待体的定义,可以在协程中使用co_await操作符暂停协程去等待其完成自身定义的操作。但当我们使用co_spawn开启一个协程,并在其中等待这个awaiter时:
1 | |
会提示错误:No matching member function for call to 'await_transform' 这显然是因为我们上面提到的问题,当前的awaiter并不满足asio中对awaiter的某些定义,编译器尝试寻找一个合适的await_transform将awaiter转换为合适的awaiter,但显然我们没有提供这个函数。
那么似乎解决方法就很显而易见了,只需要编写一个合适的await_transform,为coredis的awaiter加上对this_coro::executor、cancellation_state 等asio内部类型的处理就可以了。但我目前并没有找到有关这些内容的文章或示例,或许得深入研究asio::awaitable的实现才得以解决,如果您有相关的思路,欢迎在评论区讨论。
使用boost::asio::async_initiate将异步接口转换为与asio相适应的协程函数接口
asio提供的async_initiate函数可以自定义异步操作,也可以将同步操作转换为异步操作,并与asio的异步模型进行集成。我们可以利用该函数将redis++的协程接口与asio集成(事实上,我们在该过程中并不再使用redis++的协程接口,而是直接对redis++的异步接口进行包装)。
由此我们可以得到下面的一个函数并将其添加到Awaiter的public成员函数中:
1 | |
其中co_exec是一个模板函数,其接受一个名为CompletionToken的模板参数,这是asio的一个重要概念,用于定制异步操作完成时行为,即异步函数如何以及何时传递结果给调用者。asio内置了很多CompletionToken,在此我们介绍以下三个:
- 回调函数
你可以直接将一个符合你提供的异步操作函数的可调用对象作为CompletionToken,当异步操作完成后,asio会在关联的io_context上调用该回调函数,并将异步操作的结果作为参数传递给它,与常见的基于回调的异步函数行为基本一致
- asio::use_future
会为异步操作的结果构造一个promise,并返回关联的future,可以在适合的时机调用get()阻塞地等待结果,但需要在initiate函数中手动向异步操作传入相应的回调将结果传递给根据token生成的handler
- asio::use_awaitable
使异步操作构造并返回一个可等待对象,其类型为asio::awaitable,我们可以使用co_await去等待结果,但同样的,我们需要在initiate函数中进行结果传递
对于initiate,这是一个模板lambda(C++20引入),作为async_initiate的期望的第一个参数,接受一个handler的右值引用作为参数,handler的类型是由CompletionToken和async_initiate接受的函数签名(其第二个模板参数,在我们这里是void或void(Result))推导而来,并根据类型生成对应的回调函数。
我们需要在initiate函数中执行实际的异步操作并在其的回调中正确处理得到的结果并将结果传递给handler。
最后,启动async_initiate函数并返回其结果。async_initiate是一个模板函数,其第一个模板参数是一个CompletionToken,先前已经多次介绍,接下来着重介绍第二个参数。
第二个参数是一个完成函数签名,用于指导handler应该是一个什么样的函数。在我们的例子中,当Result的类型是void时,我们的函数签名是void(),意味着需要生成一个返回值为void且没有任意参数的handler;Result不是void时,我们的函数签名是void(Result),那么就生成返回值为void有一个Result类型参数的handler。对于函数签名,需要注意其中的返回值类型应该为void,虽然其他任意类型也可能会通过编译,但一般来说非void的返回值是提供给asio内部的调用点使用的,因此除非你非常了解asio的内部运行机制那么返回类型应该为void;对于签名的参数,如果有多个参数的情况下,比如:void(Result1,Result2….),那么当我们在调用处获取结果时,会得到一个tuple<Result1,Result2…..>,可以使用以下方式获取tuple中的值:
1 | |
至此,我们已经把当前实现的大部分细节做了简单介绍,但需要注意的是,我们上面提供的例子并不完善。
比如,一般来说我们应该将完成签名设置为void(boost::system::error_code,Result),以便在捕获错误后将错误传递给调用者,且不同CompletionToken的传递方式也不尽相同。
此外,如果您仔细阅读就会发现上面的方法不是特化的,任何可以传入回调的异步函数我们都可以通过上面的流程将其与asio的异步模型进行整合。
使用boost::cobalt::task
有没有不用修改源码,直接使用redis++提供的协程支持的方法呢?有的,cobalt是对C++20协程的高级封装,提供了很多基础设施,相对于asio::awaitable来说,其抽象层级要高,可以理解为asio::awaitable是协程等待的对象,而cobalt::task是协程本身(个人的不可靠结论)。
我们可以在cobalt::task中等待所有实现了c++20约定的awaiter的对象,而且cobalt::task实现了我们之前提到了的await_transform,所以可以在cobalt::task中等待asio::awaitable对象。
因此,我们可以直接在一个返回值为cobalt::task
1 | |
结尾
上面就是我个人在尝试使用redis++协程接口的过程的一些总结,希望能给大家一些参考和启发。