网络同步技术方案——帧同步与状态同步
本文系统梳理了帧同步与状态同步的核心思想、执行流程、优缺点对比及典型应用场景等,以及帧同步中保证多端确定性的常见问题与解决方案;并总结了网络延迟优化手段。由于本人没有实际的商业项目经验,所以只是基于各种资料以及个人的玩具Demo所总结的。 概括总结【什么是帧同步?什么是状态同步?】 帧同步让所有的客户端在相同的逻辑帧内执行相同的确定性操作来保证网络同步,其核心思想是相同的输入+相同的处理=相同的结果。 基本流程是,玩家一操作,客户端立即将该操作指令发送给服务端,服务端按照固定的时间频率(即逻辑帧)转发来自所有玩家的操作指令,在客户端收到操作指令后,以相同的逻辑执行并渲染,从而实现多客户端的网络同步。实际的实现细节可能有所不同,比如客户端收集的数据发给谁——存在P2P架构和CS架构;服务端是否需要收集齐所有客户端的数据才允许转发——严格锁步帧同步和乐观帧同步….. 其最核心问题是——如何保证多客户端的确定性。也就是说,怎么保证在多端接收到了相同的输入后,能够产生相同的结果 状态同步是一种由服务器作为唯一权威状态来源,客户端仅负责采集输入、渲染显示的同步方式。 最终的游戏结...
从源码到机器码:C#编译与运行时底层原理
本文将带你沿着从源码到机器码的完整路径,拆解C#程序集的结构,揭开元数据的神秘面纱,深入CLR的运行时对象体系,并追踪JIT编译器如何将IL一步步转化为可执行的机器码 流程概述 我们自己写的C#代码以及引用的基础类库会被C#编译器编译为IL中间代码,同时根据C#代码生成元数据MetaData,再加上程序头(CLR Header和PE Header),由四者以及其他内容组成一个程序集。 【IL和MetaData都是什么?】其中,中间代码IL可以理解为方法体的中间代码表示,由指令+到元数据索引(Token)组成,用于描述程序的执行逻辑;元数据MetaData本质是一套结构化的表,用于描述类型、方法、字段等信息 【IL和MetaData是如何被使用的?】运行时,JIT编译器 按需将IL中间代码翻译为可执行的机器码,在遇到MetaData Token时,会通过加载器ClassLoader根据元数据构建出类型系统的运行时对象,基于这些运行时对象中的信息,生成机器码并做缓存 下面,详细展开 具体展开元数据【什么是MetaData元数据?】 在C#代码层面,其中的每一个变量、常量、方法、类...
Fantasy中的async/await+FTask机制
为了更好地理解async/await+Task与同步上下文机制,我们来看看实际的游戏网络框架Fantasy中是如何基于这套机制来“魔改”的,本文是基于早期版本的Fantasy展开分析的(https://github.com/qq362946/Fantasy)。 Fantasy 没有沿用原生的 Task,而是另起炉灶,设计了一套轻量级的异步模型。接下来,我们将看到 Fantasy 如何通过自定义的 AsyncFTaskMethodBuilder和 FTask,绕开 SynchronizationContext的隐式捕获,将线程调度的主动权牢牢握在自己手中。 Fantasy中的线程同步模型及运行机制核心机制:网络线程负责IO+解包,逻辑线程(Scene)负责处理业务,二者直接通过SynchronizationContext.Post来做线程切换 Fantasy中每个Scene可以理解为一个单线程的逻辑单元,其拥有自己的ThreadSynchronizationContext同步上下文对象(SynchronizationContext的子类),专门负责处理业务逻辑 Fanta...
从IL看穿async/await:状态机、同步上下文与Task
本文将从 IL(中间语言)的视角出发,剖析 async/await的底层实现。我们会一步步拆解状态机的生成、MoveNext的执行逻辑,以及 AsyncTaskMethodBuilder如何协调线程上下文与回调注册。当理解了这些底层原理后,async/await将不再是一个黑盒——你将清晰地知道它在何时挂起、如何在正确的线程上恢复、以及它究竟是如何做到“用同步的方式写异步”的。 从IL理解async/await先看一下单独的async方法底层会有什么变化 反编译: 反编译:对于一个async函数来说,会生成了一个状态机类。 状态机类中的关键字段: 状态字段:标识当前的执行状态,初始为-1代表方法开始执行,后续为0 1 2..代表在不同的await的暂停阶段 -2代表执行完毕(成功或异常) 异步任务构建器AsyncTaskMethodBuilder <>t__builder:负责启动状态机、同步线程上下文、注册线程回调事件、返回Task… async函数中局部变量的缓存:会把async方法中的所有局部变量都提升为状态机字段,确保之后a...
从栈到信号:操作系统如何操作、切换和修改执行流
引言在操作系统中有很多的概念:线程、进程、调度、中断、信号、系统调用……教材分别给它们下出定义:线程是CPU调度的单位,进程是资源分配的基本单位…..但当这些机制同时出现时,一个问题很容易产生:我只知道它们本身是什么,但是它们之间到底是什么关系?为什么要有这些概念? 再具体到实现层面,可能出现下面的问题:为什么线程切换需要保存寄存器和栈?信号处理函数是怎么“插入”到用户执行程序中的?为什么系统调用需要切换到内核栈?为什么调度的核心操作是保存和恢复上下文? 实际上,这些问题都在围绕着同一个事情:暂停一个正在执行的程序,然后在某个时刻恢复它。什么意思?无论是线程切换调度还是中断处理,操作系统都必须完成一个基本的操作:在某个时刻“冻结”当前程序的执行状态,并在未来恢复它。 这就引入了另一个更基础的问题:**“程序的执行状态”到底是由什么状态构成的?**换句话说,如果我们想暂停一个程序,然后在未来继续执行,究竟要保存哪些信息? 从计算机的体系结构来看,这些状态主要包括三部分: 程序计数器(PC):当前/下一步执行哪条指令 寄存器:当前计算的中间结果 调用栈:记录函数的调用...
从Callback到委托:C# 委托底层原理
本篇文章主要讲解委托的底层原理,但在此之前,我们必须先理清委托究竟是什么——其不过是 callback 在 C# 中的具象化体现。因此,在真正进入委托的底层实现之前,我们需要先回到 callback 本身:它是如何工作的、为什么需要它、以及它在异步与解耦中的角色。理解了这些,再看委托的内部结构、多播机制和调用流程,就会变得顺理成章。 理解callback【callback是什么?】callback是一种思想,其核心原理是:你定义一个函数,将其作为参数交给某个框架、库,对方将其保存起来,等到某个条件满足时,比如资源加载完成、状态发生变化时,再执行该函数。 【为什么要有callback?】而之所以要有callback,其根本动机是为了实现控制权的反转,callback函数就像是一个钩子函数一样,你将你自己的逻辑“挂到”一个更大的系统中,将逻辑的调用权从你手里,转换到了框架手里,由框架在它自己的某个生命周期节点调用你的函数。从callback这个名字中也能感受到这层意思:不是我主动call你,而是你在合适的时机call back我 这样做的意义是可以让模块间解耦,这也是callbac...
【内存分配与管理】Unity GC
在上一篇文章中,我们深入剖析了Unity托管内存的分配机制,从Boehm GC的ok_freelist与GC_hblkfreelist两级结构,到小对象与大对象的差异化分配策略,再到hblk与hblkhdr分离设计的精妙之处。我们看到了Unity如何在C++引擎层搭建起一套高效的内存分配基础设施,为C#层的对象创建提供支撑。 然而,分配只是内存管理的一半,另一半,也是我们更为关切的痛点——垃圾回收。当托管堆上的对象不再被引用时,它们占据的内存需要被回收以供后续分配使用。Unity采用的Boehm GC是一个基于Mark-Sweep的保守式GC,它的工作原理与传统GC有着显著的不同:它无法精确区分指针与非指针数据,无法对内存进行整理压缩,也无法像.NET的GC那样实现分代回收。这些特性决定了Unity GC的性能特征和局限性。 本文将从GC的触发条件入手,深入剖析Mark阶段与Reclaim阶段的完整流程,包括根节点扫描、地址到header的映射机制、标记位的管理与回收策略。通过理解这些底层原理,我们将能更好地把握Unity内存管理的本质,为后续的优化实践打下坚实基础。 托管内存管...
【内存分配与管理】Unity 增量式GC
在上一篇文章中,我们深入剖析了 Unity 传统 Boehm GC 的完整工作机制——从触发条件、STW(Stop-The-World)线程暂停,到 Mark 阶段的递归可达性分析,再到 Sweep 阶段的分级回收策略。我们看到了保守式 GC 在设计与实现上的诸多权衡:无法整理内存、无法精确区分指针与数据、依赖全局扫描来完成回收。这些特性使得传统 GC 在一次回收中往往带来不可忽视的卡顿,尤其是在托管堆较大、对象存活率较高的游戏场景下。 然而,游戏对帧率稳定性的要求极为苛刻。一次 50ms 甚至 100ms 的主线程停顿,足以让玩家明显感知到“掉帧”或“卡一下”。为了解决这个问题,Unity 引入了增量式 GC(Incremental GC)。它并没有改变 Boehm GC 的基本算法,而是通过一种精巧的工程手段:将原本一次性完成的 Mark 阶段拆分成多个短时间的增量步骤,穿插在多帧中执行,从而把一次长时间的 STW 卡顿,转化为多次几乎不可感知的微停顿。 增量式 GC 的核心挑战在于:如何在 GC 与用户线程并发运行的前提下,仍然保证标记结果的正确性? 答案就是本文要重点讲...
【内存分配与管理】Unity内存分配
本文作为Unity内存管理系列的开篇,将聚焦于Unity的内存分配原理,深入剖析Boehm GC在托管内存分配中的具体实现,包括小对象与大对象的分配策略、空闲链表与内存池的设计、以及这些机制背后的性能考量。后续文章将进一步讲解Unity GC的回收机制与优化实践。 整体认识Unity具有两套内存——托管内存Managed Memory和原生内存Native Memory。 Managed Memory即C#层的堆内存,其中存储了C#代码分配的对象,即class实例对象、string、装箱等,由C#运行时(Mono/iL2CPP)管理 Native Memory即Unity底层的C++层的堆内存,其中存储了Unity引擎内部的资源,比如纹理,模型,音频以及GameObject本体等,需要我们手动进行资源的卸载以及引擎内部的生命周期管理 ┌─────────────────────────────────────────────────────────────┐ │ Operating System ...
【内存分配与管理】移动语义——参数传递
在探讨了移动语义的机制之后,我们面临一个更实际的问题:如何在实际的函数设计中充分利用这些机制? 移动语义、右值引用、完美转发为我们提供了强大的工具,但这些工具的最佳使用场景是什么?不同的参数传递方式又有怎样的性能特性和设计考量? 三种传参方案现代C++提供了多种参数传递策略:左值引用重载、右值引用重载、万能引用完美转发、按值传递移动。每种策略都有其适用场景和权衡点。理解这些选择背后的性能模型和设计哲学,是编写高效、健壮C++代码的关键。 对于函数传参的参数设计,常见的有以下几种形式: class Widget { // 途径一: public: // 针对左值和右值重载 void addName(const std::string& newName) { names.push_back(newName); } void addName(std::string&& newName) { names.push_back(std::move(newName)); } ....