前言

之前写了 《协程框架(libcopp)v2优化、自适应栈池和同类库的Benchmark对比》《C++20 Coroutine》 ,但是一直没写 C++20 Coroutine 的测试报告。

现在的草案版本比我当时写 《C++20 Coroutine》 的时候有了一点点更新,cppreference 上有文档了(https://en.cppreference.com/w/cpp/language/coroutines) 。里面列举的标准文档是P0912R5,这个文档目前还没完工,详情可以看他的来源N4775。不过内容上暂时还没有太大的变化,今天我就照着之前的方式来benchmark一波 C++20 Coroutine 吧。

压力测试机环境

为了方便比较,我更新了一下之前在 《协程框架(libcopp)v2优化、自适应栈池和同类库的Benchmark对比》 里的测试项目的版本。Windows环境仅仅是为了测试MSVC下的性能,因为GCC还不支持所以Linux下是使用Clang编译的。

环境名称
系统Linux kernel 3.10.107(Docker)
CPUIntel(R) Xeon(R) Gold 61xx CPU @ 2.50GHz * 48
L1 Cache64Bytes*64sets*8ways=32KB
系统负载0.19 0.25 0.27
内存占用3.5GB(used)/125GB(sum)
CMake3.15.2
GCC版本9.2.0
Clang版本9.0.0
Golang版本1.13.1 (20190903)
Boost版本(libgo依赖)1.71.1 (20190819)
libco版本03ba1a453c266b76e1c01aa519621ef7db861500 (20190902)
libcopp1.2.1 (20191004)
libgocbdf26bbf568a72e81fdd7ec390ddbcae5d5dd92 (20190822)
环境名称
系统Windows 10 Pro 1903 (2019 Sept)
CPUIntel(R) Core(TM) i7-8700 @ 3.20GHz * 12
L1 Cache64Bytes*64sets*8ways=32KB
系统负载低于 10%
内存占用8.2GB(used)/16.7GB(cached)/38.7GB(free)
MSVC版本MSVC v142 - VS 2019 C++ x86/x64 (14.23)

测试代码

C++20 Coroutine 上手比较麻烦,所以测试代码那是真滴长。 co_await 的原理和 co_yield 是一样的,只是 co_await 多了一点点对封装类似 libcotask 的支持,单纯的上下文切换仅使用 co_yield 就可以了。这样也更能公平地拿来和其他几个协程库对比。

Clang编译命令: $LLVM_CLANG_PREFIX/bin/clang++ -std=c++2a -O2 -g -ggdb -stdlib=libc++ -fcoroutines-ts -lc++ -lc++abi -Wl,-rpath=$LLVM_CLANG_PREFIX/lib/ test.cpp

MSVC编译命令: cl /nologo /O2 /std:c++latest /Zi /MDd /Zc:__cplusplus /EHsc /await test.cpp

#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <ctime>
#include <inttypes.h>
#include <stdint.h>
#include <vector>
#include <memory>
#include <iostream>

#include <experimental/coroutine>

#include <chrono>
#define CALC_CLOCK_T std::chrono::system_clock::time_point
#define CALC_CLOCK_NOW() std::chrono::system_clock::now()
#define CALC_MS_CLOCK(x) static_cast<int>(std::chrono::duration_cast<std::chrono::milliseconds>(x).count())
#define CALC_NS_AVG_CLOCK(x, y) static_cast<long long>(std::chrono::duration_cast<std::chrono::nanoseconds>(x).count() / (y ? y : 1))


static std::vector<std::pair<int*, std::experimental::coroutine_handle<> > > g_test_rpc_manager;

struct test_custom_coroutine_data;
struct test_custom_coroutine {
    using data_ptr = std::unique_ptr<test_custom_coroutine_data>;

    struct promise_type {
        data_ptr refer_data;
        char fake_cache_miss_[64 - sizeof(test_custom_coroutine_data*)];
        promise_type();

        static test_custom_coroutine get_return_object_on_allocation_failure();

        test_custom_coroutine get_return_object();

        std::experimental::suspend_always initial_suspend();

        std::experimental::suspend_always final_suspend();

        void unhandled_exception();

        // 用以支持 co_return
        void return_void();

        // 用以支持 co_yield
        std::experimental::suspend_always yield_value(test_custom_coroutine_data*&);
    };

    // 下面的接入用侵入式的方式支持 co_await test_rpc_generator
    // MSVC 目前支持使用非侵入式的方式实现,但是clang不支持
    bool await_ready() noexcept;

    void await_resume();

    void await_suspend(std::experimental::coroutine_handle<promise_type>);

    int resume();
    void set_sum_times(int);
    bool is_done() const;
    test_custom_coroutine_data* data();
private:
    test_custom_coroutine(test_custom_coroutine_data*);
    test_custom_coroutine_data* data_;
    char fake_cache_miss_[64 - sizeof(test_custom_coroutine_data*)];
};

struct test_custom_coroutine_data {
    int sum_times;
    int yield_times;

    std::experimental::coroutine_handle<test_custom_coroutine::promise_type> handle;
};

test_custom_coroutine::promise_type::promise_type() {
    refer_data = std::make_unique<test_custom_coroutine_data>();
    refer_data->sum_times = 0;
    refer_data->yield_times = 0;
}
test_custom_coroutine test_custom_coroutine::promise_type::get_return_object_on_allocation_failure() {
    return test_custom_coroutine{ nullptr };
}

test_custom_coroutine test_custom_coroutine::promise_type::get_return_object() {
    return test_custom_coroutine{ refer_data.get() };
}

std::experimental::suspend_always test_custom_coroutine::promise_type::initial_suspend() {
    refer_data->handle = std::experimental::coroutine_handle<promise_type>::from_promise(*this);
    return std::experimental::suspend_always{}; // STL提供了一些自带的awaiter实现,我们其实很多情况下也不需要另外写,直接用STL就好了
}

std::experimental::suspend_always test_custom_coroutine::promise_type::final_suspend() {
    return std::experimental::suspend_always{}; // 和上面一样,也是STL自带的awaiter实现
}

void test_custom_coroutine::promise_type::unhandled_exception() {
    std::terminate();
}

// 用以支持 co_return
void test_custom_coroutine::promise_type::return_void() {
    refer_data->handle = nullptr;
}

// 用以支持 co_yield
std::experimental::suspend_always test_custom_coroutine::promise_type::yield_value(test_custom_coroutine_data*& coro_data) {
    // 每次调用都会执行,创建handle用以后面恢复数据
    if (nullptr != refer_data) {
        refer_data->handle = std::experimental::coroutine_handle<promise_type>::from_promise(*this);
        ++refer_data->yield_times;
    }

    coro_data = refer_data.get();
    return std::experimental::suspend_always{};
}

// 下面的接入用侵入式的方式支持 co_await test_custom_coroutine , 实际上benchmark过程中并没有用到
// MSVC 目前支持使用非侵入式的方式实现,但是clang不支持
bool test_custom_coroutine::await_ready() noexcept {
    // 准备好地标志是协程handle执行完毕了
    return !data_ || !data_->handle || data_->handle.done();
}

void test_custom_coroutine::await_resume() {
    // do nothing when benchmark
}

void test_custom_coroutine::await_suspend(std::experimental::coroutine_handle<promise_type>) {
    // do nothing when benchmark
    // 被外部模块 co_await , 这里完整的协程任务链流程应该是要把coroutine_handle记录到test_custom_coroutine
    // 在return_void后需要对这些coroutine_handle做resume操作,但是这里为了减少benchmark的额外开销和保持干净
    // 所以留空
}

int test_custom_coroutine::resume() {
    if (!await_ready()) {
        data_->handle.resume();
        return 1;
    }

    return 0;
}

void test_custom_coroutine::set_sum_times(int times) {
    if (data_) {
        data_->sum_times = times;
    }
}

bool test_custom_coroutine::is_done() const {
    return !(data_ && data_->handle);
}

test_custom_coroutine_data* test_custom_coroutine::data() {
    return data_;
}

test_custom_coroutine::test_custom_coroutine(test_custom_coroutine_data* d) : data_(d) {}

// 异步协程函数
test_custom_coroutine coroutine_start_main(test_custom_coroutine_data*& coro_data) {
    // create done

    // begin to yield
    while (coro_data != nullptr && coro_data->yield_times < coro_data->sum_times) {
        co_yield coro_data;
    }

    // finish all yield
    co_return;
}

// 这里模拟生成数据
bool coroutine_resume(std::vector<test_custom_coroutine>& in, long long& real_switch_times) {
    bool ret = false;
    for (auto& co : in) {
        real_switch_times += co.resume();
        if (!co.is_done()) {
            ret = true;
        }
    }

    return ret;
}

int main(int argc, char* argv[]) {
#ifdef __cpp_coroutines
    std::cout << "__cpp_coroutines: " << __cpp_coroutines << std::endl;
#endif
    puts("###################### C++20 coroutine ###################");
    printf("########## Cmd:");
    for (int i = 0; i < argc; ++i) {
        printf(" %s", argv[i]);
    }
    puts("");

    int switch_count = 100;
    int max_coroutine_number = 100000; // 协程数量

    if (argc > 1) {
        max_coroutine_number = atoi(argv[1]);
    }

    if (argc > 2) {
        switch_count = atoi(argv[2]);
    }

    std::vector<test_custom_coroutine> co_arr;
    std::vector<test_custom_coroutine_data*> co_data_arr;
    co_arr.reserve(static_cast<size_t>(max_coroutine_number));
    co_data_arr.resize(static_cast<size_t>(max_coroutine_number), nullptr);

    time_t       begin_time = time(NULL);
    CALC_CLOCK_T begin_clock = CALC_CLOCK_NOW();

    // create coroutines
    for (int i = 0; i < max_coroutine_number; ++i) {
        co_arr.emplace_back(coroutine_start_main(co_data_arr[i]));
        co_arr.back().set_sum_times(switch_count);
        co_data_arr[i] = co_arr.back().data();
    }

    time_t       end_time = time(NULL);
    CALC_CLOCK_T end_clock = CALC_CLOCK_NOW();
    printf("create %d coroutine, cost time: %d s, clock time: %d ms, avg: %lld ns\n", max_coroutine_number,
        static_cast<int>(end_time - begin_time), CALC_MS_CLOCK(end_clock - begin_clock),
        CALC_NS_AVG_CLOCK(end_clock - begin_clock, max_coroutine_number));

    begin_time = end_time;
    begin_clock = end_clock;

    // yield & resume from runner
    long long real_switch_times = static_cast<long long>(0);

    bool is_continue = true;
    while (is_continue) {
        is_continue = coroutine_resume(co_arr, real_switch_times);
    }
    // sub create - resume
    real_switch_times -= max_coroutine_number;

    end_time = time(NULL);
    end_clock = CALC_CLOCK_NOW();
    printf("switch %d coroutine contest %lld times, cost time: %d s, clock time: %d ms, avg: %lld ns\n", max_coroutine_number,
        real_switch_times, static_cast<int>(end_time - begin_time), CALC_MS_CLOCK(end_clock - begin_clock),
        CALC_NS_AVG_CLOCK(end_clock - begin_clock, real_switch_times));

    begin_time = end_time;
    begin_clock = end_clock;

    co_arr.clear();

    end_time = time(NULL);
    end_clock = CALC_CLOCK_NOW();
    printf("remove %d coroutine, cost time: %d s, clock time: %d ms, avg: %lld ns\n", max_coroutine_number,
        static_cast<int>(end_time - begin_time), CALC_MS_CLOCK(end_clock - begin_clock),
        CALC_NS_AVG_CLOCK(end_clock - begin_clock, max_coroutine_number));

    return 0;
}

其他库的测试代码在 https://gist.github.com/owent/1842b56ac1edd5a7db54590d41af1c44

测试结果及对比

组件(Avg)协程数:1 切换开销协程数:1000 创建开销协程数:1000 切换开销协程数:30000 创建开销协程数:30000 切换开销
C++20 Coroutine - Clang5 ns130 ns6 ns136 ns9 ns
C++20 Coroutine - MSVC10 ns407 ns14 ns369 ns28 ns

C++20 裸测试的性能真是非常夸张地高,基本上性能已经赶上 call_in_stack 这种对分支预测做优化的版本,并且还有不错的灵活性。这里性能测试的结果很好看一方面是 coroutine_handle<T> 的成员是个指针,再里面的管理上下文的部分我没法控制它的实现,所以没法模拟cache miss。另一方面也是由于它是使用operator new并且分析调用的函数需要多少栈来分配栈空间的,这样不会有内存缺页的问题(因为和其他的逻辑共享内存块),而且地址空间使用量也很小并且是按需分配的,也减少了系统调用的次数。还有一点影响比较大的是这次测试的C++20 Coroutine的代码全部是非线程安全的。而 libcopp 在实际应用中是搭配上了线程安全检查和一些防止误用的状态检查的,全是atomic操作,甚至 libgo 那种加锁的线程安全的检查,性能会会受到一定影响。如果在实际应用C++20 Coroutine的时候也加上这些检查,估计性能会下降几倍,但是应该还是会比现在的成熟方案要快一些。

不过现阶段 《C++20 Coroutine》 使用上还有些限制,所有使用 co_await 或者 co_yield 的函数返回值必须有 promise_type 。 也就是说如果嵌套使用或者递归调用的话不能直接用上层的协程对象,一旦出现嵌套使用只能 co_await 然后新创建一个协程对象。比如调用链 func1()->func2()->func3()->func4() , 如果 func1 和 func4 是需要使用协程调用,要么得 func2 和 func3 也实现成协程,然后 func1 里 co_await func2() , 要么 func2 和 func3 把 func4 产生的 awaitable 对象透传回来,然后由 func1 来 co_await func2().awaitable 。 也就是说 func2 和 func3 对 func4 不能完全透明。这是 《C++20 Coroutine》 比不上 libcopp 的地方。 这也是我前段时间思考给 libcopp 接入 《C++20 Coroutine》 做Context管理的最大困难。

我们拿之前 《协程框架(libcopp)v2优化、自适应栈池和同类库的Benchmark对比》 对比过的其他库放一起来看:

组件(Avg)协程数:1 切换开销协程数:1000 创建开销协程数:1000 切换开销协程数:30000 创建开销协程数:30000 切换开销
栈大小(如果可指定)16 KB2 MB2 MB64 KB64 KB
C++20 Coroutine - Clang5 ns130 ns6 ns136 ns9 ns
C++20 Coroutine - MSVC10 ns407 ns14 ns369 ns28 ns
libcopp34 ns4.1 us80 ns3.8 us223 ns
libcopp+动态栈池32 ns96 ns77 ns212 ns213 ns
libcopp+libcotask50 ns4.1 us141 ns4.2 us389 ns
libcopp+libcotask+动态栈池49 ns134 ns134 ns256 ns371 ns
libco+静态栈池84 ns3.9 us168 ns4.2 us450 ns
libco(共享栈4K占用)83 ns3.9 us529 ns3.9 us1073 ns
libco(共享栈8K占用)86 ns4.0 us828 ns3.9 us1596 ns
libco(共享栈32K占用)-4.0 us9152 ns3.9 us11.5 us
libgo 2019年9月master分支53 ns8.3 us120 us5.5 us237 ns
libgo 2018年版本 with boost197 ns5.3 us124 ns2.3 us441 ns
libgo 2018年版本 with ucontext539 ns7.0 us482 ns2.7 us921 ns
goroutine(golang)425 ns1.0 us710 ns1.0 us1047 ns
linux ucontext439 ns4.4 us505 ns4.8 us890 ns

来个直观一点的图:

Chart 0

结论就不多说了,和 《协程框架(libcopp)v2优化、自适应栈池和同类库的Benchmark对比》 差不多,需要稍微提一下的是上面的 创建耗时 的时间不是线性而是对数的,因为几个库差距有点大,等差的图示太难区分了;另外测试条目里并不全在一个层面,有些是比较底层的接口,有的是已经接近工程化了。还有上面的测试结果受代码缓存命中率和数据缓存命中率影响比较大,除了 C++20 Coroutine 的测试以外,其他的都使用了一定的手段来让cache miss(更接近实际应用)。所以实际使用 C++20 Coroutine 的话切换性能应该是会比这个结果看起来差一些。不过参考 boost.context 的裸调用fcontext的上下文切换,cache不miss的时候大约是30ns左右,相比起来 C++20 Coroutine 还是很有优势的,而且 C++20 Coroutine 更大的优势在于创建性能和内存占用。

最后

欢迎有兴趣的小伙伴们一起交流哈。