分类: 随笔

  • [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.

  • 从网速的角度对网络协议(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。

  • [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.

  • [Walo系列]2.企划和环境搭建

    企划

    还是打算启动这个企划,核心的目标就像前一篇说的,我想做一个游戏的网络rpc的库。相比传统的rpc框架,核心在于这个是给游戏用的。

    思考

    游戏用的rpc和互联网的主要差别在哪呢?
    这几天我一直在思考这个问题,互联网的请求更多的是我发起、等待然后收到回调。同时互联网有大量的基础设施,在rpc的通信中有大量的扩散的请求。例如一个订单可能涉及到向多个微服务发起服务,最终汇聚成一个请求返回到客户端。

    互联网的rpc更注重跨语言,快速实现需求,还要考虑多服务的均衡、容灾等等…

    而游戏的网络框架其实分两个部分,拿平安京举例,大厅的部分跟互联网的rpc没有太多差别的;但是战斗跟大部分互联网的需求是不同的,战斗更注重的是快速地相应,对游戏战场的控制方式。

    所以我做这个还是有意义的,例如互联网的rpc框架默认就是基于TCP的,附带大量微服务特性的。

    期望的特性

    1. 基于C++20:目的是学习;
    2. 支持UDP、TCP和WebSocket等协议:目的适配更广泛的游戏场景;
    3. 提供网卡、网络到连接层面的封装:目的是对网络有更精确的掌控力;
    4. 提供基于Entity和属性的同步:简化游戏开发;
    5. 提供局域网探测的功能:同样是为了提供简化的联网能力;

    环境和依赖

    稍微研究了一下,最后还是决定使用CMake + Conan做依赖管理。
    尽量用公版以及conan中拥有的库做开发,减少我的开发成本,以及未来可能的管理成本。

    倒腾了一个晚上,终于把conan2和cmake的融合做好了。
    由CMake构建的时候引入conan2的conan_provider.cmake,这个cmake提供自动从云端的conan find_package,解决依赖问题。
    工程只需要包含CMake以及工程源码,CMake就能自动从公版拉相应的库下来。

    以及工程命名:Walo,想着朗朗上口,又要含义简单,取自网络的中文谐音。

    End。

  • [Walo系列]1.开发自己的游戏通讯库对grpc祛魅了

    本意是想深入研究和学习一下grpc的设计,来给自己的游戏网络通信引擎提供多一份参考。但是细致的看了一圈文档意识到grpc的实际应用场景不是给游戏,而是像文档里描述的给微服务和多语言部署的大型业务。

    grpc作为定义好comn中间结构体就能够注册到服务发现集群,其他的业务可以直接将comn结构下载下来,快速地请求到新业务的一套跨越多语言的协议+底层框架。

    这是它的核心,或者说它解决的问题。

    就忽然想起来之前华仔说面试的是时候是要问对方解决问题的能力,遇到了什么问题,提出了什么方案。

    最近也研究godot和unreal的网络框架实现原理,顺便也看了一下如何给这些引擎做共享。

    就看见godot的贡献原则:

    file

    file

    file

    要从问题出发寻找结论,而不是我觉得这个可以,然后它就可以。要解决一个具体的问题,解决常见的问题。

    不想当厨子的裁缝绝不是好司机,我今年想自己写一个网络通信引擎,一方面是学习和练习,另外也想对工作有一些反哺。最近就也在思考,如果真的开始动手写我的库,这个库需要哪些特性?如何跟其他的引擎结合?甚至如何让其他人用我的这个库?

    特性:

    1. 基于C++20,目的是学习和练习C++20;
    2. 支持UDP、TCP和WebSocket等协议;
    3. 提供网卡、网络到连接层面的封装;
    4. 提供基于Entity和属性同步和管理和rpc;
    5. 提供局域网探测的功能;

    如何跟引擎和语言结合:

    1. python: nanobind
    2. lua: 未来可以支持
    3. UE:不好融合;
    4. Unity: 增加插件
    5. Godot:

    如何让其他人用:

    1. 开源
    2. 好用

    End。

  • [2024年10月31日]

    今天生命状态异常的好!很难描述是怎么造成的,在公司忙了一些事情,吃完饭之后就开始玩游戏,玩了几把就回家…
    路上听着睡眠那本书,哦,那句话我印象很深:然而值得注意的是,无论机会有多大,大脑都无法恢复它失去的所有睡眠。人类永远不能把我们之前失去的睡眠补回来。
    可能是这句话打醒了我,让我今天生命状态异常的好!
    回家就先吃药,收拾东西,久违的拖地,处理各种垃圾…

    今天精神上的状态真的还挺舒服的,可能是被睡眠的警告惊醒,也可能是之前生病的后遗症。
    总之,希望明天能保持这个状态,每天10点开始集中注意力有点太迟了,因为很快就到中午困倦的时间了,要再早起一点。

  • Hazel视频笔记 —— 预编译头文件

    视频内容

    刚开始我以为up就是想把所有的系统级头文件合并在一个.h里然后引用,还在想这有什么意义,好看一点?

    看到改CMake直接使用了一个pchheader才知道,这里有东西的!!

    预编译的头文件

    PCH文件是一个预编译头文件(pre-compiled header),它的后缀是PCH,所以也叫PCH文件。编译器会将头文件的内容事先编译成二进制的中间文件,在整个编译过程中,只编译一次,并且有缓存,除非有变化,否则不会重新编译(每个引入的.h和.cpp)文件,从而大大提高编译速度。

    每个源文件只能使用一个预编译的标头(.pch)文件,但是可以在一个项目中使用多个.pch文件。

    几乎所有C/C++编译器都支持预编译头文件的,例如gcc, clang, msvc等…但是不同的编译器对它的支持力度和处理方式有很大差异,并不是非常通用。

    msvc的处理

    /Yc是创建pch文件,必须要通过编译stdafx.cpp才能生成stdafx.pch,使用的时候需要用/Yu来使用,最后链接的时候还需要把stdafx.obj和test.obj都链接上才行,这也是和gcc, clang最大的不同。

    其他

    clang的支持很简单,可以直接通过-c创建,使用的时候通过-include-pch直接使用。

    gcc不支持-include-pch,所以需要从-I的头文件路径中搜索。

    标头单元、模块和预编译头文件

    如微软的文档里所描述的,include是最慢的,其次是预编译头,再快一点的是标头单元,而最快的是模块。

    import std或者import std.compat的速度比#include <vector>要快,不过目前好像只有msvc支持完整,clang到17还是部分支持。

    那说回来,什么是标头单元?
    标头单元是头文件的二进制表示形式。 标头单元以 .ifc 扩展名结尾。相同的格式也用于命名模块。
    使用标头单元比include编译要快的主要原因就是标头单元类似于预编译头文件一样,是提前被编译过的,而且标头单元是不受外部宏定义影响的,因为已经被编译过了。

    不过标准库的标头文件,现在看支持好像还是一般,以mvsc为主。

    参考

    https://tboox.org/cn/2017/07/31/precompiled-header/
    https://learn.microsoft.com/zh-cn/cpp/build/compare-inclusion-methods?view=msvc-170

    End。

  • 读书笔记《现代C++语言核心特性解析》 | 前五章

    介绍

    是一本介绍C++11到C++20特性的书籍。
    越来越多的项目迁移到了更新的标准上,毕竟带来了新的好用的特性,而且性能依然非常的高,需要学习一下。

    不会大而全的记录,主要会是让我有所醒悟的内容。

    基础类型

    整型

    新增了long long表示至少64位的整数,对应的还有LL和ULL的后缀。

    long long x = 65536LL;
    unsigned long long x = 65536ULL;

    整型上限:

    std::numeric_limits<long long>::min()
    std::numeric_limits<long long>::max()
    std::numeric_limits<unsigned long long>::max()

    字符和字符串

    字符集和编码方法

    字符集和编码方法是有区别的,字符集就是所有字符的集合,应该是包含所有的文字对应的图像,而编码方式就是用数字和字符集建立对应关系的方法。
    Unicode有三种编码方式,UTF-8, UTF-16, UTF-32,都是Unicode字符集。而UTF-X是具体的编码方式,UTF-32是最简单的,用4个字节直接存储一个字符,但是很浪费。UTF-16稍微好一点,UTF-8特别差,不好计算长度不好查找字符。

    标准新增类型

    新增了char16_t和char32_t,分别用来对应Unicode字符集的UTF-16和UTF-32两种编码方法。

    • utf8的前缀u8
    • utf16的前缀u
    • utf32的前缀U
    • wchat_t的前缀是L(早期定义的类型)
    char utf8c = u8'a';       // C++17标准
    char16_t utf16c = u'好';
    char32_t utf32c = U'好';

    在C++11标准中u8只能作为字符串字面量的前缀,而无法作为字符的前缀。这个问题直到C++17标准才得以解决,所以上述代码需要C++17的环境来执行编译。

    whcar_t是早期定义的类型,没有严格规定大小,导致Windows是16位,Linux是32位,这种标准不好修改,所以引入新的标准解决这个问题。

    新增的函数,记忆主要是分三个类型:

    • mbr: utf-8,即char
    • c16: utf-16
    • c32: utf-32
      size_t mbrtoc16(char16_t * pc16,const char * s,size_t n,mbstate_t * ps);
      std::mbrtoc32
      std::c16rtomb
      std::c32rtomb

      auto

      主要的用法有两个:

    • 声明变量时2,自动推断类型
    • 声明函数时返回值的占位符(模板推导使用)

    还有几个要点:

    • 从左到右推导
    • 不会收窄类型
    • 总是使用更强的类型
    • 无法声明非静态成员变量
    • 按值推导会忽略cv限定符、引用属性
    • 使用auto和万能引用声明变量,左值会被推导为引用
    • 如果目标对象是数组或者函数,auto会推导为对应的指针类型

    T &&是万能引用声明,会触发引用折叠。

    decltype

    这个初步了解的话,比较清楚地是:

    int i = 1;
    decltype(i) j = 2;      // 推到为int

    但是它的复杂之处就在于它的推导,auto是将cv去掉了,而且不主动用万能引用引发引用折叠是不会有太多引用的问题要考虑的。

    但是decltype的推倒过程是要考虑引用的,它整体的推导规则:
    decltype(e) 其中e的类型是T,推导规则有五条:

    1. 如果e是未加括号的符号表达式或者未加括号的类成员访问,则推出的类型是T;
    2. 如果e是一个函数调用或者仿函数调用,那么推导出返回值的类型;
    3. 如果e是一个类型为T的左值,则decltype(e)是T&
    4. 如果e是一个类型为T的将亡值,则decltype(e)是T&&
    5. 除了以上情况,推导结果为T

    但是实际实验,发现在创造的时候由不符合这个情况,可能是因为编译不过就自动退化了。

    其他资料

    字符编码那些事

    End.