博客

  • 打破舒适圈,做没做过的事

    亡灵集结号在TapTap正式上架了,我这次好像跟前几次一样,到功能完成之后就没有太多动力和能力去改进了。

    我之前做了不少玩法都是如此,大都是做一半就没做了,我明显更擅长的是开发功能,所以我几乎每一款游戏都是要么抄一个玩法,要么想到一个想法然后简单落地。

    但是具体怎么让这个想法变得更好玩,我每次都是浅尝辄止,像害怕失败一样……没有完整通关过亡灵集结号一次就上线了,相比玩自己的游戏,做玩法上的思考,我更经常的是一头扎进代码重构里,乐呵呵的搞一整天。

    我好像很害怕做玩法的创新和调整,所以每次越靠近完成,我就越摆烂…

    复刻玩法我擅长,灵感乍现我擅长,写代码我擅长,但是自己开始创作的时候就会踌躇不前,经常会在深层心里滋生出抗拒

    我只习惯做我擅长的事,但我们应该做正确的事,什么是正确的事,就是做好玩的东西,就是服务玩家。

    不是让自己开心,而是让玩游戏的人觉得开心,抓住最核心的东西,不重要的该放就放。

    最近跟猫哥聊完感觉清晰了一些,核心要做的是把东西做到好玩!以好玩为目标去构建其他的东西。

  • asio竟然支持reactor

    https://think-async.com/Asio/asio-1.30.2/doc/asio/overview/core/reactor.html

    竟然直接支持reactor的形式,虽然支持好像不太难,但这个小细节确实挺牛的。

  • [asio]学习笔记2. 概述-异步模型(Executors, Associators)

    前言

    虽然开始尝试自己基于asio写一些东西,同时也在看asio的源码,但真的想学还是应该先看作者本人写的概述:
    https://think-async.com/Asio/asio-1.30.2/doc/asio/overview.html

    这个老哥抽象能力真的很强,这些概念第一遍根本理不清楚,索性开一篇文章专门记录。

    本文记录的内容对应asio概述中这一段的内容:
    file

    其实写asio的时候会觉得很顺,但是细想才会发现这里是有大量的概念交织下才能够实现的表层写的如此顺,而且效率非常之高。

    理解asio的核心就在于理解它最核心的设计概念,但是这些概念真的很抽象,所以我在这里就是先把概念用语言梳理一遍,然后再用具体的例子把所有的概念变成代码里具体的demo,加深理解。

    异步模型

    asio的核心其实不是一个网络库,而是一个异步模型。

    asio将异步操作确立为异步组合的基本构建单元,目前支持回调函数,兼容future、纤程、协程等,也可以在上下文中混合使用这些模型和单元。

    异步操作

    如前文所述,异步操作就是具体的回调、future和协程等,具体来说用户可以在启动异步操作之后继续做其他的事情,它由一个异步操作是由启动函数和完成句柄构成的:

    file

    其中InitFunc是用户可以调用的函数,用于启动一个异步操作;Completion handler是完成句柄。为什么叫完成句柄而不是完成回调呢?

    可以从这个例子来看:

    socket.async_receive(boost::asio::buffer(buffer_), [this](const boost::system::error_code &e, std::size_t bytes) {
        if (!e) {
            // do something
        }
    });
    co_await socket.async_receive(asio::buffer(buffer), asio::use_awaitable);

    这里就启动了两个异步操作:从套接字中读取数据,两个操作分别是异步函数和C++20协程,其中async_receive就是异步操作的启动函数,它的第二个参数就是completion handler。

    对于异步函数这个异步操作而言,完成句柄就是函数本身;但是对于C++20的协程来说,完成句柄是一个asio::use_awaitable占位符,它代表两个意思:

    1. 这是一个C++20协程的占位符
    2. 会返回一个可等待对象

    这也就是完成句柄不叫完成函数的原因,除了定义完成时的行为,还需要根据句柄改变函数本身的返回值,以适应不同的异步操作。
    在这里不展开,有兴趣可以读这一篇文章,看看这种动态是如何实现的。

    异步代理

    这个概念在文档里反复出现和提及,但是因为没有很具体的实体,对我而言有点难理解,但是不理解这个概念后面围绕这个概念的设计就很难吸收。我读了三次才明白这个概念,我先按原文梳理作者的定义,后面再给出我的理解。

    file

    作者抽象了一个新的概念叫做异步代理,它是由多个异步操作通过顺序组合形成的逻辑单元,所以每个异步操作都被视为某个代理的组成部分,如下图所示:
    file

    首先按照作者的定义,异步代理有两个特点:

    1. 组成的异步操作严格串行
    2. 可以和其他的代理并行

    那例如一个socket的所有操作就不能是一个异步代理,因为读和写是可以并行的,那再深入想一下,对于一个socket的所有读操作是可以构成一个异步代理的,对于一个socket的所有写操作也是同理。

    为什么要这么区分呢?因为所有的读操作可以共用同一块内存,所有的写操作也可以公用同一块内存,这个抽象的概念是为了在开发时清晰每个buffer作用的范围,cancel实际断开的异步代理对应的异步操作链条。

    到这里就能更清晰的理解这个概念抽象的目的,异步代理不是藏在asio这个库下层的概念,而是需要深入使用者的脑子里的概念!在用的时候我们需要知道每个异步操作是在哪个代理里,同一个代理的所有操作是严格串行的,它们共用同一块资源。

    那例如我存在一个这样的需求:将某个文件按照csv格式修改一块数据并且存盘,这一整个就是一个异步代理链条,我们需要:

    1. 打开文件
    2. 按照csv格式解析
    3. 读出数据
    4. 修改数据
    5. 写入文件

    这里读写文件可以用同一片内存进行操作,数据不会存在冲突。

    但是例如前面提到的全双工的套接字,它本质上是两条异步代理,这两条异步代理的前面操作是重叠的:
    接收代理:

    1. 建立连接
    2. 接收数据
    3. 解析数据
    4. 回到2继续接受数据

    发送代理:

    1. 建立连接
    2. 等待可发送数据
    3. 发送数据
    4. 回到2继续等待

    在这个例子里,需要两套buffer,一套接收使用,一套发送使用。

    当然,如果是严格的类似于http协议的过程,请求-回复,则一条连接的收发是一个完整的异步代理。

    所以本质上作者在这里提到异步代理是希望开发者在开发时,深入考察是否哪些异步操作可以并行,哪些异步操作之间存在临界区。在抽象出来这个概念之后,一个异步代理的所有操作就像是一个线程里的同步操作。

    异步代理的特指和关联器

    异步代理关联的异步操作具有一些关联性的特征,是需要开发者开发或者处理的,例如:

    1. 分配器,决定异步操作如何获得内存资源
    2. 取消槽,如何支持取消异步操作
    3. 执行器,决定了异步完成代理是如何调度和执行的

    子代理

    file

    如上图所示,这个更像是例如多个异步操作之间存在层的概念的一种封装,类似于HTTP层服务端等待数据抵达,会触发ssl层的数据收发,ssl层又等待tcp层,ssl层在tcp层数据抵达之后可能要等待多次ssl层的握手,才告诉http层,收到数据了。

    这里子代理的概念是类似于这样的实例的抽象,可以将一整个代理封装成一个简单的异步事件。

    执行器

    执行器就是在完成之后决定如何执行完成句柄,对应到代码里就是executor的概念,对应到库里面其实就是io_context, strand这些,在构造socket对象的时候会传入的那个参数。

    内存分配器

    这里的内存分配器是特指库里的一个接口,每个异步操作都会关联一个分配器,用来给异步操作获取每次可稳定操作的内存资源(POSMs)。这个名字本身也反映了两个特质,内存是按每次操作进行分配的(因为内存在该操作的生命周期内保留),并且是稳定的。

    异步操作可以有多种方式来分配POMs,用户可以忽略分配器,使用默认的分配器,也可以根据需要去定制分配器。

    取消

    定时器和套接字都支持close或者cancel取消操作,同时某些异步操作还支持对单次操作。
    为了支持取消操作,需要向代理对象的插槽中注册一个取消处理函数。取消处理函数是当用户发出取消信号时会被触发执行,由于取消插槽和单个代理体绑定,所以同时只支持一个处理程序。
    可以注册覆盖新的函数去覆盖旧函数。

    End.

  • ImGui天下第一

    之前听油管的up提过imgui,然后今天看到几个地方都说这玩意儿好用,在维护的时候就拉下来跑了一下。
    (跑完)
    ImGui天下第一!
    开发太方便了!不需要设置任何回调,按钮按下在下次tick就会返回True

                static float f = 0.0f;
                static int counter = 0;
    
                ImGui::Begin("Hello, world!");                          // Create a window called "Hello, world!" and append into it.
    
                ImGui::Text("This is some useful text.");               // Display some text (you can use a format strings too)
                ImGui::Checkbox("Demo Window", &show_demo_window);      // Edit bools storing our window open/close state
                ImGui::Checkbox("Another Window", &show_another_window);
    
                ImGui::SliderFloat("float", &f, 0.0f, 1.0f);            // Edit 1 float using a slider from 0.0f to 1.0f
                ImGui::ColorEdit3("clear color", (float*)&clear_color); // Edit 3 floats representing a color
    
                if (ImGui::Button("Button"))                            // Buttons return true when clicked (most widgets return true when edited/activated)
                    counter++;
                ImGui::SameLine();
                ImGui::Text("counter = %d", counter);
    
                ImGui::Text("Application average %.3f ms/frame (%.1f FPS)", 1000.0f / io.Framerate, io.Framerate);
                ImGui::End();

    只要让这段代码一直循环,当按钮按下的时候counter就会自动++
    是从来没有设想过的开发方式…!

    它利用了一个很基本的原理,延迟一帧处理用户感知不到,然后将所有的输入缓存然后直接存到对应的输入组件里。。

    这个设计思想太牛了!!

  • 从网速的角度对网络协议(IPv4, IPv6, TCP)查漏补缺

    IPv4

    在IPv4的协议头中:
    file
    存在一个Type of Service,IETF在之后将字段改为Differentiated Service, 即区分服务。该字段用来表达发送端对服务质量的期望程度,例如可以通过在该字段中设置标志位表达发送方希望该IP Datagram低延迟(Low Delay)的效果送达发送端;或希望以高可靠性(High Relibility)的效果送达发送端。

    IPv6

    提出了 Flow Label 的概念, 可以将一个序列的 IPv6 分组标记为属于某个流, 在传输链路上保证该流的服务质量;
    Flow Label,流标签,长度为20比特。由发送端随机生成,用于标识Datagram,IPv6使用源地址(Source Address)、目标地址(Destination Address)和流标签(Flow Label)构成的三元组唯一确定一个流,因而属于同一个流的Datagram都拥有相同的Flow Label。运营商可以为特定的流保证指明的服务质量QoS,对于流量有限的网络,QoS可以保证优先传输高优先级的数据。

    总结

    看起来这两个好像实际都还是要么得花钱找ISP买,要么其实本质上还是没有效果的。

  • C++20协程写法细节

    前言

    深入看了coroutine提案作者写的几篇文章,从协程的抽象到实现细节,真的有学到一些东西。但其实我是大概率不会自己基于C++20原生的接口,但是看到几个一定要记的东西,感觉还是可以写篇博客记录一下。

    文章原文:
    https://lewissbaker.github.io/2017/11/17/understanding-operator-co-await
    https://lewissbaker.github.io/2018/09/05/understanding-the-promise-type

    协程基础类

    struct coroutine : std::coroutine_handle<promise>
    {
        using promise_type = ::promise;
    };
    
    struct promise
    {
        coroutine get_return_object() { return {coroutine::from_promise(*this)}; }
        std::suspend_always initial_suspend() noexcept { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        void return_void() {}
        void unhandled_exception() {}
    };
    coroutine bad2()
    {
        S s{0};
        return s.f(); // returned coroutine can't be resumed without committing use after free
    }

    在上面这个例子里,首先协程本身的定义是有co_await, co_yield, co_return关键字的函数就是协程,是协程就需要满足这个条件:函数返回一个用户自定义的写成类型,我们叫做coroutine类型吧。

    coroutine::promise_type是定义的Promise类,这个类是协程跟三个关键字交互的主要类,同时为了能够让已有的类(例如std::future<>)容易被融入协程,20提供了coroutine_traits这个模板。

    对于一个协程:

    task<float> foo(std::string x, bool flag);

    编译器并不是用task<float>::promise_type用作Promise类,而是用的:typename coroutine_traits<task<float>, std::string, bool>::promise_type。

    这个的好处可以在cpprefernece的文档里看到好处:

    // Utilize the infrastructure we have established.
    std::future<int> compute(as_coroutine)
    {
        int a = co_await std::async([] { return 6; });
        int b = co_await std::async([] { return 7; });
        co_return a * b;
    }

    我可以通过重写coroutine_traits的模板特化,实现将原有的普通类直接改造成异步类,然后不用重写原本的类,直接:

    template<typename T, typename... Args>
        requires(!std::is_void_v<T> && !std::is_reference_v<T>)
    struct std::coroutine_traits<std::future<T>, as_coroutine, Args...>
    {
        struct promise_type : std::promise<T>
        {
            std::future<T> get_return_object() noexcept
            {
                return this->get_future();
            }
    
            std::suspend_never initial_suspend() const noexcept { return {}; }
            std::suspend_never final_suspend() const noexcept { return {}; }
    
            void return_value(const T& value)
                noexcept(std::is_nothrow_copy_constructible_v<T>)
            {
                this->set_value(value);
            }
    
            void return_value(T&& value) noexcept(std::is_nothrow_move_constructible_v<T>)
            {
                this->set_value(std::move(value));
            }
    
            void unhandled_exception() noexcept
            {
                this->set_exception(std::current_exception());
            }
        };
    };

    这样就能修改promise类,而不用修改原本的普通类。

    这大概也是设计者将return_type和promise_type区分开,并且通过coroutine_traits::promise_type拿到Promise类的目的。

    coroutine_handle

    coroutine_handle是C++底层的对象,它不是上面定义的std::future<int>,也不是它的promise_type,它是C++底层的协程栈相关的数据结构。

    我们获取它只有两个途径,一个是在awaitable对象的await_suspend函数里能拿到被暂停协程的上下文,等需要恢复原协程时去控制。另外一个就是通过Promise对象的引用,通过静态函数coroutine::from_promise能获取到coroutine_handle对象。

    coroutine_handle的主要功能就是控制写成本身的暂停、销毁和恢复。

    这里有一个一定要注意的就是coroutine_handle对象不是raii对象,但是是一个类似于void*的类型,需要手动调用destroy。!!!!

    awaitable

    co_await 后面跟的必须是一个awaitable对象,而awaitable对象是定义了这三个接口的类:

    bool await_ready() { return false; }
    void await_suspend(std::coroutine_handle<> h)
    {
        std::jthread& out = *p_out;
        if (out.joinable())
            throw std::runtime_error("Output jthread parameter not empty");
        out = std::jthread([h] { h.resume(); });
        // Potential undefined behavior: accessing potentially destroyed *this
        // std::cout << "New thread ID: " << p_out->get_id() << '\n';
        std::cout << "New thread ID: " << out.get_id() << '\n'; // this is OK
    }
    void await_resume() {}

    分别代表本次是否挂起(await_ready),挂起前的准备操作(await_suspend),并且将coroutine_handle传递进来,在稍后某些条件满足之后,如何通过coroutine_handle重新唤起这个被挂起的写成。而await_resume就是在协程重新恢复的时候需要执行的逻辑。

    内存申请和释放

    在这个提案处于争论状态的时候,攻击者就提到了这套协程会频繁在栈上申请空间,作者就提出了两个方案:

    1. 编译器判断不需要栈空间,就直接不特殊申请;
    2. 用户可以定制new和delete;

    但是用户定制new和delete比较复杂,文章里是说当执行到delete之前,部分内存就已经被销毁了,所以在new和delete时都需要额外多分配一小段内存存储allocator对象本身。

    比较复杂,记录一下。

    总结

    这是关于协程的第三篇文章了,OK不再继续看协程的内容了。
    核心的思想理解了,核心的设计理解了,我也不会用原生的语义开发协程,毕竟asio等库有良好的封装。

    经过不公正的测试,协程本身速度会比纯异步慢5%左右,但是拥有巨大的好处!
    就是同步的方式写逻辑,同步的方式写逻辑最大的好处感觉有两个:

    1. 不用写特别多的lambda和函数;
    2. 部分变量可以放在协程上下文做管理,就不用关心生命周期了。

    End

  • 协程是泛化的函数——再读协程

    推荐文章:
    https://lewissbaker.github.io/2017/09/25/coroutine-theory

    是看到上面这篇文章决定简单记录一下,他写的这个描述非常准确而且清晰:普通函数有两个操作,执行和返回。在执行的时候,会创建一个函数帧,同时暂停原函数,在执行返回操作之后恢复原函数。

    这句话就有点醒到我,暂停函数的概念不是协程才有,函数本身就有的。或者进一步说,协程是泛化的函数,函数是限定了协程在调用时暂停原函数,返回时恢复原函数;而协程是可以在调用时选择暂停原函数还是新函数,同时可以在函数执行中被暂停挂起,甚至可以调度之前被暂停的其他协程。

    当然二者还是有差别的,因为函数是存在帧栈的,但是其实细想,帧栈的概念也是为了简化开发者而实现的,为了能够找回调用本函数的函数,以及传递数据给调用函数。

    但是为什么一定要返回原函数呢?只是为了简化问题,函数本身就是一个功能,一个行为,一个动作,我们通常要等这个函数执行完成获取某些参数才进行下一个函数。

    比如我如果有一个调用f(a(), b()),我们通常的执行顺序是执行完a()函数之后返回这个上下文再执行b()函数然后再将栈上两个的结果执行f函数,但是为什么a()执行完成之后还需要返回这个上下文呢?

    只是基于同步原语的模型下,这个逻辑非常的简单。

    但是我们也完全可以不用帧栈,直接跳转执行a()的函数,完成之后不跳转会上一层,而是直接执行b(),然后最终调度执行f()。

    但是在这种模型之下,调度和数据传输就会成为新的问题。

    C++20的协程就看穿了这个本质,作为一种更抽象的函数,它通过引入无栈协程实现了一种更加抽象的函数,每个函数是一个可以独立被调度的功能,一个协程可以被暂停几次是基于这种模型的“额外功能”。

    但是这个模型下解决的最重要的问题是,将同步原语彻底干掉了,函数调用是一个独立的过程,执行完之后不会自动返回上一层帧栈,而是执行另外一段逻辑,在这一段逻辑里可以决定再继续恢复哪个协程,

    因为不用返回上一层帧栈,所以在这个函数调用模型下,栈的概念就被干掉了,同时这样的概念下,函数就不存在返回值了,被调用函数返回不一定会回到它的调用者那里,因此a()的值就不是返回值,而是这个需要被调度的协程本身。

    因此f(a(), b())是不被允许的,因为这个调用是包含了f的调用需要等待a(), b()完成之后,将结果拿出来再作为f的参数。

    因此在无栈协程概念下这段伪代码应该这么写:

    coroutine_a = launch(a())
    coroutine_b = launch(b())
    co_await (coroutine_a, coroutine_b)
    f(coroutine_a.get_a_result(), coroutine_b.get_b_result())

    如果能看懂这个例程,你应该就能完全理解我心中所想的抽象概念,以及C++20协程的抽象根本。

    然后再回到C++20的协程本身,网上的教程和demo很多了,不过我还是想吐槽一下,抽象程度太高导致C++20原本的协程真的很不好用,std::coroutine_traits_base, awaitable, promise_type, 这几个概念真的很抽象。

    但是这才是C++啊,极致的性能极致的抽象,更何况asio还有很优秀的抽象:

    //
    // echo_server.cpp
    // ~~~~~~~~~~~~~~~
    //
    // Copyright (c) 2003-2024 Christopher M. Kohlhoff (chris at kohlhoff dot com)
    //
    // Distributed under the Boost Software License, Version 1.0. (See accompanying
    // file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt)
    //
    
    #include <asio/co_spawn.hpp>
    #include <asio/detached.hpp>
    #include <asio/io_context.hpp>
    #include <asio/ip/tcp.hpp>
    #include <asio/signal_set.hpp>
    #include <asio/write.hpp>
    #include <cstdio>
    
    using asio::ip::tcp;
    using asio::awaitable;
    using asio::co_spawn;
    using asio::detached;
    using asio::use_awaitable;
    namespace this_coro = asio::this_coro;
    
    #if defined(ASIO_ENABLE_HANDLER_TRACKING)
    # define use_awaitable \
      asio::use_awaitable_t(__FILE__, __LINE__, __PRETTY_FUNCTION__)
    #endif
    
    awaitable<void> echo(tcp::socket socket)
    {
      try
      {
        char data[1024];
        for (;;)
        {
          std::size_t n = co_await socket.async_read_some(asio::buffer(data), use_awaitable);
          co_await async_write(socket, asio::buffer(data, n), use_awaitable);
        }
      }
      catch (std::exception& e)
      {
        std::printf("echo Exception: %s\n", e.what());
      }
    }
    
    awaitable<void> listener()
    {
      auto executor = co_await this_coro::executor;
      tcp::acceptor acceptor(executor, {tcp::v4(), 55555});
      for (;;)
      {
        tcp::socket socket = co_await acceptor.async_accept(use_awaitable);
        co_spawn(executor, echo(std::move(socket)), detached);
      }
    }
    
    int main()
    {
      try
      {
        asio::io_context io_context(1);
    
        asio::signal_set signals(io_context, SIGINT, SIGTERM);
        signals.async_wait([&](auto, auto){ io_context.stop(); });
    
        co_spawn(io_context, listener(), detached);
    
        io_context.run();
      }
      catch (std::exception& e)
      {
        std::printf("Exception: %s\n", e.what());
      }
    }

    End。

  • std::map学习笔记

    本来只是想草草读一下这篇文章:https://142857.red/book/stl_map/
    但是还是发现一些值得记录的点,在这里记录一下:
    []读取不存在的key会报错

    map<string, int> config = {
        {"timeout", 985},
        {"delay", 211},
    };
    print(config["timeout"]); // 985
    print(config["tmeout"]);  // 默默返回 0

    推荐所有的读取都用at:

    map<string, int> config = {
        {"timeout", 985},
        {"delay", 211},
    };
    print(config.at("timeout"));  // 985
    print(config.at("tmeout"));   // 该键不存在!响亮地出错

    []会自动创建不存在键值

    map<string, int> config = {
        {"delay", 211},
    };
    config.at("timeout") = 985;  // 键值不存在,报错!
    config["timeout"] = 985;     // 成功创建并写入 985

    因此它引入了一条原则:

    • 读取元素时,统一用 at()
    • 写入元素时,统一用 []

    C++不是python,auto要显示指定引用

    void PeiXunCpp(string stuName) {
        auto stu = stus.at(stuName);  // 这是在栈上拷贝了一份完整的 Student 对象
        stu.money -= 2650;
        stu.skills.insert("C++");
    }

    在python类似的写法是没有问题的,但是在C++中这个stu左边的auto没有显示指定&为引用,则在实际构造的过程中会发生一次拷贝构造函数,并且后续的修改不会贴回到stus的字典里。

    End.

  • 编译clang搞清楚的一些概念(gcc, llvm, clang, libc++, libstdc++)

    clang++/llvm

    因为开发机的gcc版本太低了,就重新编译了一个clang,大概了解了一下这些底层工具的概念。

    clang++和clang都是llvm的一部分,而llvm是一个庞大的、模块化编译器的项目,clang++只是基于llvm的编译器前端。

    所以对于一个C++编译过程,从.cpp生成.o,在llvm的这个项目下的流程是:

    1. clang++对源代码进行词法分析,转化成抽象的AST树;
    2. clang++再将AST转化成中间表示(IR)
    3. 生成IR之后,llvm编译框架对这个中间进行优化;
    4. 接着llvm的后端再将编译后的IR转化成目标机器代码,并生成目标文件(.o)

    所以我们想编译一个完整的clang++,是在llvm项目,因为clang++依赖llvm作为后端,而clang++只是llvm的一种前端。

    libc++

    这个是clang实现的一版C++标准,不同于gnu gcc的实现。
    再编译了之后,我发现还是无法编译C++20的内容,提示找不到<coroutine>,那很自然是库的问题,我们可以在llvm的官网看到项目包含的子项目:
    https://llvm.org/
    除了clang,还有libc++, libc++ABI和libc。

    我重新编译了之后还是有问题,最终是在加了参数-stdlib=libc++之后才能正常编译,并且编译成功之后还是没办法正常的运行,我手动把/usr/local/lib/x86_64-unknown-linux-gnu/目录下的libc++.so.1用软连接放到/usr/lib下才全部搞定。

    标准库的实现

    这里我才知道,原来编译器和标准库是拆开的,编译器是clang,标准库可以用gnu gcc的,也可以用clang自己的libc++,这里列一下C++的标准实现:

    • libstdc++: GNU Compiler Collection (GCC) 提供的;
    • libc++: clang++提供的;
    • Microsoft C++ Standard Library (MSVC STL): 微软vs使用的
    • Dinkumware C++: 常用语嵌入式系统以及商业编译器中。

    参考

    https://developer.huawei.com/consumer/cn/forum/topic/41600287
    https://libcxx.llvm.org/
    https://llvm.org/
    https://llvm.org/docs/CMake.html

    End。

  • [Walo系列]3. 序列化和rpc的选型与思考

    rpc库

    决心推进这个计划之后就开始预研各种序列化和rpc库,看了很多对比,也做了一些测试,在这里统一记录一下。

    仔细看来序列化做的事情就是把数据打包,rpc要考虑的核心事情是如何把函数的形参和返回类型同步给双端。

    我看rpc库好像也只有两种形式,一个是通过proto定义好,例如grpc;另外一个是直接直接在调用的时候限定,属于一种硬编码?类似于rpclib

    浅浅地看了三个rpc库:

    • rpclib
    • grpc
    • capnproto

    通过各自的手段定义rpc的消息协议,然后可以快速简单地开始写一个网络通信程序。但是基本上是把连接管理的活都干完了,这不是我所需要的。

    因为我是需要同时TCP/UDP,并且基于kcp或者之类的形式做字节流,所以我除了实现数据传输,还需要一些连接控制的消息,所以rpc库不是我需要的。

    但是看了一下rpclib和grpc底层的代码,rpc的核心业无非就是两件事情,序列化和反射。

    如何快速将rpc调用变成字节流,通知到远端之后,远端如何快速解码出参数和rpc过程,并且反射找出本地的函数。除了这个核心的机制之外的功能,都是不同的库提供的特性。

    所以我要专注于序列化和反射这两块,看看能不能找到合适的库或者自己做,目的的核心第一是学习,其次才是实用。

    序列化

    序列化做的事情就是将数据按照某种格式序列化成二进制流,然后将二进制流恢复到内存。

    不同的库致力于解决不同大小场景的问题,所有的库都是各有优劣。首先两种序列化库是要写proto的:

    写proto的库

    • protobuf
    • capnproto

    我以为,这样的库主要解决的问题有几个:

    1. 跨语言的通信一致性
      因为精确地定义了协议,服务接入者按照协议接入就能使用,他们能生产对应语言的结构。这个场景主要是在互联网微服务的架构下,不同的微服务可以用不同的技术栈,对外通过统一的proto提供快速开发的手段。
    2. 快速开发
      这些库都是直接server.listen()就能联网通信,开发效率嘎嘎快。
    3. 简单地兼容性维护
      不用proto能做到兼容性维护,但是得在协议层或者入口处新增很多if的代码。而proto的协议帮你处理好了这一切,还提供很多默认协议之类的功能,是极其简单的。

    capnproto

    优点是无需解码,可以直接从内存里拿。但是仔细看了一下文档,它拿的方式并不是定义一个class或者struct,而是直接对流数据进行读写。不过它读写的效率并不低。
    我们通常读一个结构的内存是这样:

    struct C{
       int a;
       int b;
    };
    void foo()
    {
        C c;
        c.a = 23;
        bar(c.a);
    }

    但是capnp proto的读写方式是这样:

    // capnp proto
    struct EntityMessage
    {
        msgid @0: UInt32;
        entityid @1: UInt32;
        msgdata @2: Text;
    }
    
    void foo(void)
    {
        capnp::MallocMessageBuilder message;
        walo::EntityMessage::Builder entityMessage = message.initRoot<walo::EntityMessage>();
        entityMessage.setEntityid(21);
        entityMessage.setEntityid(47);
    
        const auto msgBuilder = capnp::Text::Builder(const_cast<char*>("test"));
        entityMessage.setMsgdata(msgBuilder);
        capnp::writePackedMessageToFd(1, message);
    }

    如上面的代码,并不能直接new一个proto的struct,而是需要用它提供的Builder去构造一个对象。

    然后要怎么发送呢?或者说怎么获得一个“正常”的接口呢?
    不知道…官方文档里没写,网上找到是要这样:

    void sendMessage( const char* data, const std::size_t size ); 
    
    void writeAddressBook()
    {
        ::capnp::MallocMessageBuilder message;
    
        auto addressBook = message.initRoot<AddressBook>();
        auto people = addressBook.initPeople(1);
    
        auto alice = people[0];
        alice.setId(123);
        alice.setName("Alice");
        alice.setEmail("alice@example.com");
    
        auto alicePhones = alice.initPhones(1);
        alicePhones[0].setNumber("555-1212");
        alicePhones[0].setType(Person::PhoneNumber::Type::MOBILE);
        alice.getEmployment().setSchool("MIT");
    
        // get char array and send
    
        const auto m = capnp::messageToFlatArray( message );
        const auto c = m.asChars();
        std::cout << c.size() << '\n';
    
        sendMessage( c.begin(), c.size() ); // pass as char array
    }

    库有两个命名空间kj::和capnp::,虽然差不多理清楚了,基础数据相关都是kj::提供,序列化相关都是capnp::提供,但还是挺……没必要的,这俩库都是老哥自己搞的。
    而且作者竟然提供了直接输出到fd的接口,但没提供输出到标准库的stream或者字符串的输出…作者老哥确实也在文章里提到了stl比较重,就自己撸了一套…

    protobuf

    不过多介绍了,甚至算行业标准。

    无需proto的库

    这种库太灵活了,它的优势就是无需proto,可以动态处理,快速迭代。

    对于游戏内的协议序列化,一定是选择这种协议,那这种协议就多了,有一点看不过来,索性打算做一个完整地测试。

    对比

    目标库

    需要proto

    • protobuf
    • capnproto

    无需proto

    • zpp_bits: C++20的高性能序列化库
    • msgpack: 非常高效的全语言序列化库
    • bitsery: C++11的序列化库,自称很适合游戏
    • Cista++: 基于C++17的struct序列化&反射库
    • yas: 有一个测评说它很快,但是很多年没维护了
    • boost::Serialization: 可以序列化stl容器对象,同时也能序列化指针对象
    • cereal: 高效的结构序列化库,比boost序列化要低一点,不支持指针
    • Flatbuffers: 跨平台的序列化库,内存利用率高,并且在不解析的情况下读数据

    对比方案

    其实这些库拥有不完全相同的场景,例如boost::Serialization还会将指针所指向的对象做序列化,以实现指针结构的序列化。
    包括有几个是专门给C++的struct结构直接使用的,还有一些是跨语言的序列化库。
    虽然如此,但是我的目的很明确,我想给我的Walo选择底层的序列化库,我的诉求就是速度快且内存大小小,而我的Walo是服务于游戏的底层。

    就这个实际的场景,我的目标有两种:

    • 协议控制包
    • 游戏数据包
      协议控制代表连接控制相关的内容,例如握手,权限校验等;游戏数据包代表游戏具体的属性同步以及rpc等内容。
      协议控制包
      协议控制包会让有proto的库一起参与测试,同时我会倾向于用固定结构的包,即使最后场景式非proto的协议更好,我也会确固定这部分的内容。
      因为这个协议是需要量好设计,且易读易改好累计的。这个场景更多是小体积,结构固定的包,核心诉求还是体积小且快,具体选型等测试结果。

    游戏数据包
    这是游戏的主要数据协议,细节我还没完全设计好,例如要不要在最底层的协议去设计actor模型,要不要在消息层面支持多个channel。
    当然这种类型最主要的还是数据和动态序列化本身,因为涉及到上层的脚本,甚至下层还会有zip压缩,这里的速度快可能也会被整体拖慢,所以我以为,包体大小会更重要一点。

    下一篇这个系列就做测试。

    End.