Cpp与Callback


概述

callback的问题可以说老生常谈了,以及怎么通过协程这个语法糖来让异步的代码编写方式变成同步的风格,这个话题在前面的文章已经说过了,这次就谈一谈异步编程必须要跨过的一个槛,沟槽的callback(也是我为什么这样讨厌js的一大原因),以及怎么让callback写法现代一点(怎么在正儿八经的项目里面用又是另外一个问题了)。

callback

一般来说,callback有两种做法:

  • 一次设置回调,多次调用:主要用于网络编程里面(也是callback最常见的一个地方),好处显然是不用浪费每次设置回调的开销。但对于复杂逻辑来说,就不太友好了。因为回调入口只有一个,要实现复杂的逻辑,只能自己在回调里想方设法折腾(函数调用链冗长甚至交叉调用)。
  • 一次设置回调,一次调用:这种模式常用于文件IO,用于异步的写等等,因为这种类似的操作不会有多次结果。坏处是会在设置回调上多了开销,但应对复杂的逻辑来说,具有相当好的深度可供挖掘,同时一次设置,一次调用,还可以根据对失败的处理,细分下使用方式:
    • 设置时失败,则返回错误信息,同时不会调用回调接口;
    • 异步操作失败,不会调用回调接口;
    • 异步操作失败会调用回调接口,但是取消异步操作则不会调用回调接口;
    • 任何情况下都会调用回调接口;

怎么处理逻辑就看怎么设计,但是为了接口调用的清爽建议按照第四种情况来,这样就不用将失败处理的逻辑抛给对调用链一无所知的api调用者。

从例子说起

通常情况下的异步回调是这个样子的:

template<typename _Input_t, typename _Callable_t>
void tostring_async(_Input_t&& value, _Callable_t&& callback) {
    std::thread([callback = std::forward(callback), value = std::forward<_Input_t>(value)]
        {
            // do something
            callback(std::to_string(value));
        }).detach();
}

具体线程里面开什么按照具体业务来定,如果不想麻烦单独开个线程也可以使用std::async配合std::launch::async来使用,他干的事其实就是帮你封装好开个线程。是不是觉得很简单?当业务复杂的时候就不是这样美好了,怎么处理深度连续回调就是当务之急。

通常是使用调用链来解决,就是future+then方案。代码大致如下:

template<typename _Input_t>
std::future<std::string> tostring_async(_Input_t&& value){
    return std::async([value = std::forward<_Input_t>(value)]
        {
            return std::to_string(value);
        });
}

//使用范例代码
tostring_async(1.0).then([](std::string && result){
    std::cout << result << std::endl;
});

可惜标准库里面并没有支持这样的写法,如果使用Boost或者Facebook的folly这样的任务链库就能支持这样的写法。当然这些不是关键,如果只是回调函数比较深,那么做好标记,配合lambda食用,一点点看总能看到头的,然而问题在于用回调模拟循环,然后再涉及到分支去调用其他回调的时候问题就比较大了,同样的这样的逻辑非常丑陋,代码也有一股bad smell,这样的回调函数内部通常是这个样的:

void do_accept(...){
    if (!ec)
        do_read(...);
}
void do_read(...){
    if (!ec)
        do_write(...);
    else
        do_accept(...);
}
void do_write(...){
    if (!ec)
        do_read(...);
}

对于经常写异步网络的人来说这样的模式应该是不陌生的,这种代码应该怎么改造,似乎很难回答,当然一个解决方案我们一开始也说过了,利用协程以及co_await改造,但是这样的话就是消灭callback而不是让callback变得更加友好的这个话题了,并且关于现阶段gcc提供的协程栈协议已经在前面说到过了,其实并不完善,就如同gccc++20的另一大特性module其实根本用不了一样(import std的支持实在是太差),其次如果用了一个第三方的异步库,怎么去拓展这个库,怎么适配这个库都是额外引入的开销,所以并没有想象的那么没好。

解决方案

之前说了使用原旨callback的异步库所面临的困难:callback本身难于使用,要将异步库改造得支持future-then范式,或者支持协程,需要更改每一个异步函数。而future库/协程库又选择繁多,要全部支持就需要一一的改写每个异步函数,导致工作量是乘法数量级。先回到最初的例子:

template<typename _Input_t, typename _Callable_t>
void tostring_async(_Input_t&& value, _Callable_t&& callback){
    std::thread([callback = std::move(callback), value = std::forward<_Input_t>(value)]
        {
            callback(std::to_string(value));
        }).detach();
}

//使用范例
tostring_async(-1.0, [](std::string && value){
    std::cout << value << std::endl;
});

怎么改造成一个更加modern的回调,首先要干的事情和改造function一样,我们得先type erasure

//回调适配器的模板类
//这个默认类以_Callable_t作为真正的回调
//返回无意义的int,以便于编译通过
template<typename _Callable_t, typename _Result_t>
struct modern_callback_adapter_t
{
    using return_type = int;
    using callback_type = _Callable_t;

    static std::tuple<callback_type, return_type> traits(_Callable_t&& callback)
    {
        return { std::forward<_Callable_t>(callback), 0 };
    }
};

然后是他的核心部分

//一个使用回调处理结果的异步函数,会涉及以下概念:
//_Input_t...:异步函数的输入参数;
//_Signature_t: 此异步回调的函数签名;应当满足‘void(_Exception_t, _Result_t...)’或者‘void(_Result_t...)’类型;
//_Callable_t:回调函数或标记,如果是回调函数,则需要符合_Signature_t的签名类型。这个回调,必须调用一次,且只能调用一次;
//_Return_t:异步函数的返回值;
//_Result_t...:异步函数完成后的结果值,作为回调函数的入参部分;这个参数可以有零至多个;
//_Exception_t:回调函数的异常, 如果不喜欢异常的则忽略这个部分,但就得异步代码将异常处置妥当;
//
//在回调适配器模型里,_Input_t/_Result_t/_Exception_t(可选)是异步函数提供的功能所固有的部分;_Callable_t/_Return_t
//部分并不直接使用,而是通过适配器去另外处理。这样给予适配器一次扩展到future模式,调用链模式的机会,以及支持协程的机会。
//
//tostring_async 演示了在其他线程里,将_Input_t的输入值,转化为std::string类型的_Result_t。
//然后调用_Signature_t为 ‘void(std::string &&)’ 类型的 _Callable_t。
//忽视异常处理,故没有_Exception_t。
template<typename _Input_t, typename _Callable_t>
auto tostring_async(_Input_t&& value, _Callable_t&& callback)
//-> typename modern_callback_adapter_t<std::decay_t<_Callable_t>, std::string>::return_type
{
    using _Result_t = std::string;
    //适配器类型
    using _Adapter_t = modern_callback_adapter_t<std::decay_t<_Callable_t>, _Result_t>;
    //通过适配器获得兼容_Callable_t类型的真正的回调,以及返回值_Return_t
    auto adapter = typename _Adapter_t::traits(std::forward<_Callable_t>(callback));

    //real_callback与callback未必是同一个变量,甚至未必是同一个类型
    std::thread([real_callback = std::move(std::get<0>(adapter)), value = std::forward<_Input_t>(value)]
        {
            real_callback(std::to_string(value));
        }).detach();

    //返回适配器的return_type变量
    return std::move(std::get<1>(adapter));
}

看似增加了不少代码,但这些代码很模式化,完全可以用宏来简化

#define MODERN_CALLBACK_TRAITS(type) \
    using _Result_t = type; \
    using _Adapter_t = modern_callback_adapter_t<std::decay_t<_Callable_t>, _Result_t>; \
    auto adapter = typename _Adapter_t::traits(std::forward<_Callable_t>(callback))
#define MODERN_CALLBACK_CALL() callback = std::move(std::get<0>(adapter))
#define MODERN_CALLBACK_RETURN() return std::move(std::get<1>(adapter)) 

template<typename _Input_t, typename _Callable_t>
auto tostring_async(_Input_t&& value, _Callable_t&& callback)
{
    MODERN_CALLBACK_TRAITS(std::string);

    std::thread([MODERN_CALLBACK_CALL(), value = std::forward<_Input_t>(value)]
        {
            callback(std::to_string(value));
        }).detach();

    MODERN_CALLBACK_RETURN();
}

这样就能之前怎么用现在还是怎么用,这时候可能就要问了,这样只是换了个包装而已,甚至多了一层开销没有改变本质啊,接下来需要支持future。首先,看看不采用morden callback方案,需要如何支持future-then范式:

template<typename _Input_t>
auto tostring_async(_Input_t&& value)
{
    std::promise<std::string> _promise;
    std::future<std::string> _future = _promise.get_future();

    std::thread([_promise = std::move(_promise), value = std::forward<_Input_t>(value)]() mutable
        {
            _promise.set_value(std::to_string(value));
        }).detach();

    return std::move(_future);
}

然后是继续拓展之前的写法:

//一、做一个辅助类
struct use_future_t {};
//二、申明这个辅助类的全局变量。不申明这个变量也行,就是每次要写use_future_t{},麻烦些。
//以后就使用use_future,替代tostring_async的callback参数了。
//这个参数其实不需要实质传参,最后会被编译器优化没了。
//仅仅是要指定_Callable_t的类型为use_future_t,
//从而在tostring_async函数内,使用偏特化的modern_callback_adapter_t<use_future_t, ...>版本而已。
inline constexpr use_future_t use_future{};

//将替换use_future_t的,真正的回调类。
//此回调类,符合tostring_async的_Callable_t函数签名。
//生成此类的实例作为real_callback交给tostring_async作为异步回调。
//
//future模式下,此类持有一个std::promise<_Result_t>,便于设置值和异常
//而将与promise关联的future作为返回值_Return_t,让tostring_async返回。
template<typename _Result_t>
struct use_future_callback_t
{
    using promise_type = std::promise<_Result_t>;

    mutable promise_type _promise;

    void operator()(_Result_t&& value) const
    {
        _promise.set_value(value);
    }

    void operator()(_Result_t&& value, std::exception_ptr&& eptr) const
    {
        if (eptr != nullptr)
            _promise.set_exception(std::forward<std::exception_ptr>(eptr));
        else
            _promise.set_value(std::forward<_Result_t>(value));
    }
};

//偏特化_Callable_t为use_future_t类型的modern_callback_adapter_t
//真正的回调类型是use_future_callback_t,返回类型_Return_t是std::future<_Result_t>。
//配合use_future_callback_t的std::promise<_Result_t>,正好组成一对promise/future对。
//promise在真正的回调里设置结果值;
//future返回给调用者获取结果值。
template<typename _Result_t>
struct modern_callback_adapter_t<use_future_t, _Result_t>
{
    using return_type = std::future<_Result_t>;
    using callback_type = use_future_callback_t<_Result_t>;

    static std::tuple<callback_type, return_type> traits(const use_future_t&/*没人关心这个变量*/)
    {
        callback_type real_callback{};
        return_type future = real_callback._promise.get_future();

        return { std::move(real_callback), std::move(future) };
    }
};

然后改成宏定义就能到处用了,这样的代码只需要针对所选择的future库写一次,就可以支持全部的N个异步函数了。使用范例如下:

std::future<std::string> f2 = tostring_async(6.0f, use_future);
std::cout << f2.get() << std::endl;

协程中使用

只有使用了协程后,才可以非常容易的支持循环+分支逻辑,这个没办法,别的都是治标不治本。以librf为例,看看如何去支持协程:

//同理,可以制作支持C++20的协程的下列一系列类(其实,这才是我的最终目的)
struct use_awaitable_t {};
inline constexpr use_awaitable_t use_awaitable{};

template<typename _Result_t>
struct use_awaitable_callback_t
{
    using promise_type = librf::promise_t<_Result_t>;
    using state_type = typename promise_type::state_type;

    librf::counted_ptr<state_type> _state;

    void operator()(_Result_t&& value) const
    {
        _state->set_value(std::forward<_Result_t>(value));
    }
    void operator()(_Result_t&& value, std::exception_ptr&& eptr) const
    {
        if (eptr != nullptr)
            _state->set_exception(std::forward<std::exception_ptr>(eptr));
        else
            _state->set_value(std::forward<_Result_t>(value));
    }
};

template<typename _Result_t>
struct modern_callback_adapter_t<use_awaitable_t, _Result_t>
{
    using promise_type = librf::promise_t<_Result_t>;
    using return_type = librf::future_t<_Result_t>;
    using callback_type = use_awaitable_callback_t<_Result_t>;

    static std::tuple<callback_type, return_type> traits(const use_awaitable_t&)
    {
        promise_type promise;
        return { callback_type{ promise._state }, promise.get_future() };
    }
};

然后就能直接用了:

std::string result = co_await tostring_async(10.0, use_awaitable);
std::cout << result << std::endl;

仅仅是替换callback参数,就达到了使用协程的目的。并且,只需要为选择的协程库写一次适配代码就可以了,假设异步库有N个异步函数,有三种异步支持方案:callback,future-then,coroutine,其中,可选择的库有folly,libco等K个。

则原旨主义的回调方案,需要做N*K次修改。
而采用modern callback方案,只需要做N+K次适配。

显然,这是一个更好的方案,并且,当异步库修改了M个异步函数后:

原旨主义的callback方案,需要做M*K次修改;
而modern callback不需要在适配上做任何修改,编译一次就好了。

所以我们进行类型擦除以及包装的最大目的还是为了解耦合,如果有确切的使用的异步库或者不需要支持那么多的适配,其实只用使用最原始的方法就行,没必要过渡包装,毕竟N*1还是N。


文章作者: JoyTsing
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 JoyTsing !
评论
  目录