资源内存管理
发布日期:2021-10-01 11:36:07 浏览次数:2 分类:技术文章

本文共 11148 字,大约阅读时间需要 37 分钟。

网络游戏的 client 开发中,很重要的一块就是资源管理。游戏引擎的好坏在此高下立现。这方面我做过许多研究和一些尝试。近年写的 blog 中,已有两篇关于这个话题的:、。

最近在重构引擎,再次考虑这一个模块的设计时,又有了一些不算新的想法。今天写了一天程序,一半时间在考虑接口的设计,头文件改了又改。最终决定把想到的东西在这里写出来,算是对自己思考过程的一个梳理。

如今的 PC 网络游戏早就今非昔比。为了满足玩家感官上的需要,以及机器性能的提升,整个游戏 client 的数据资源已经开始论 G 计算。在 64 位操作系统普及之前,Windows 下那 2G 用户可用地址空间开始有点相形见绌了。换句话说,无论你多么不重视资源的内存管理,原来用过的最苯最有效的方案:只加载不释放资源的做法,开始不太适用了。设计引擎的程序员必须考虑适时的把一些已读入内存的数据清出内存,并在需要的时候再读回来。当然,如何有效的管理内存也是我这些年做程序员一贯的课题,今天的局面只是逼更多的人跟我一起来研究解决方案 :D

到了 3d 技术普及的今天,3d 游戏的资源面临另一个问题:资源的交叉引用。例如:一张贴图可能被好几个模型引用,但是我们只需要在内存中保留一个拷贝。这种引用关系有可能错综复杂,在场景的构建上尤其突出。一旦我们需要实现无缝连接的超大场景,每个地图区域上物体之间的都会发现直接或间接的关联关系。房子的旁边的树,树的旁边是房子,人物在场景中行动,不停的有新的资源被请求使用,也会有一些资源可以暂时清出内存。

一开始我希望用 gc 技术来简化这个问题的解决方案,经过一段时间后,目前的思路有所改变。我希望可以在内存里保留所有资源的数据头(即部分数据),而在不需要完整数据的时候,将资源的一部分数据卸载。资源数据在游戏开发期就把关联数据构建好,这样所有的资源数据的头信息会一点点加载进内存,完善整个游戏所有资源之间的关系网。在管理这些数据的时候,即不需要引用记数又不需要 gc 链。以目前的系统结构,内存消耗也是可控的。

在软件运行中,会有大量的内存分配请求不再被回收(除了进程结束),我们可以专门写一个特别的内存分配器做这件事情。实现的算法非常简单:递增堆指针,并在堆不够的时候向系统申请新的内存页,足够了。

下面来看一下资源的动态加载方案:

显然,多线程读取资源是首选,我们也不排斥单线程方案。另外有些资源是动态计算出来,或是并不从硬盘加载。所以我们需要同时支持多个加载器。加载器这个东西必须被抽象出来方便后期对引擎的二次开发。资源管理的框架不需要干涉加载的细节,也就是说,它不能有任何线程相关的代码,但需要为异步加载提供接口上的方便。

引擎使用者应该了解的细节越少越好。他只需要通知引擎准备加载什么资源。这通常用字符串来定位,或者通过关联信息。当然,有时候也需要一些额外的请求,比如在内存拮据的时候通知资源管理模块回收适当的内存。

对于加载器开发者,我们需要暴露更多的内部信息,但不是无条件的全部公开。加载器不需要了解管理器的内存数据结构,甚至不必看到资源数据节点上的数据结构。它只需要提供函数供回调加载数据就够了。不过因为我们需要支持异步加载的可能,所以在回调函数的接口设计上,需要传递一个类似状态机的东西,可以分步加载数据。

定下这些需求,下面可以着手设计了。

我希望资源数据被放在硬盘上的假想的数据库里,通常这个数据库就用本地文件系统模拟。或是在发布时打包成一个数据包。我可以用分段的字符串的形式定位数据库中的具体资源;但不需要每个资源都必须有字符串路径,匿名的资源只以唯一数字 id 的形式放在数据库内,这个数字 id 可以存在于别的资源的关联信息中。例如大部分贴图文件就不需要有名字,它们只会被模型引用,而不会由引擎直接加载。

资源的加载通常分两个阶段,数据头加载和全部数据加载。数据头很小,可以常驻内存。通过数据头可以得到数据的尺寸大小信息。这个信息可以帮助管理器的决策。另外,关于匿名资源的加载还会多一个阶段,从 id 映射到数据本身。(对于具名资源,文件头加载完毕后,对应 id 就自然获得)

我设计了一个树结构来保存具名资源,每个具名资源都可以通过类似 URL 的字符串形式得到,并在第一次请求时立刻把文件头加载进内存保存在这个树结构里。这棵数的每个枝干都有一个字符串名字。btw, 为了提高效率,不要使用 string 作 key 的字典结构。因为所有可能出现的字符串是非常有限的,我们可以设计一个字符串池,内存以 hash 表形式组织。这个字符串池也只只增加而不释放的。这样,每个做 key 的 string 都会有一个全局唯一的指针来表示了。这样做可以提高许多树检索的效率,并减少内存消耗。

另外,再用一个 hash 表来保存所有资源 id 的映射。

每个资源都会有一个小结构常驻内存,所以以上两个结构中都可以直接用指针对资源结构做引用。甚至不需要引用计数。这些资源结构我用链表串将起来,每次加载请求,如果内存中不存在,就创建新的节点,并插入链表头部。

加载请求是不会立刻要求加载完整资源数据的。在主逻辑线程运行的间隙,我们可以适量的从这个链表的逐个节点上取得可能的关联资源并做新的加载请求。这部分通常并不需要多线程工作,因为数据头的加载通常非常的快,并且可控,不会影响玩家的操作感。

当程序真正需要用这些资源的数据时,可以通过资源管理模块向加载器发起请求,要求立即加载数据。这时候除了满足加载请求完,还需要把资源本身调整到链表尾部,表示其刚被使用,不要立刻回收。

当内存拮据的时候,我们就可以从链表头部开始一点点回收内存了。

这里,加载器可以做成多线程的工作方式,并且 lock-free 。不过要做到真的 lock-free ,有一些先决条件:首先,我们的内存分配器不设定锁,那么数据加载线程就不得自行分配内存。这一点比较容易实现。因为一旦得到资源的数据头,就能准确计算出整个资源消耗的内存量,可以一次分配出来。而资源数据加载的时候,只在这块已申请内存块中做切割,不会有任何副作用了。

数据加载线程序可以不断的去完成队列中的数据加载请求,一旦加载完毕,就在资源结构中做好标记。而主线程收到数据使用请求时,检查到标记后直接把完整的数据指针复制过来即可。如果没有完成,则自行申请另一块内存,阻塞做数据加载。这样是为了避免冲突,当然也有可能浪费,两条线程恰巧加载同一份资源。由于单个资源体积都不大,这点时间浪费是可以接受的,而空间在数据回收阶段可以完全收回。

ps. 真正用到多线程的项目,我只写过一个。那就是大话西游里的地图动态加载模块。01 年的时候,为了实现这个模块,整整调试了一个月才稳定下来。那是第一次写多线程程序,真是往事不堪回首。做梦都会遇到程序 crash 。后来大话西游II 以及梦幻西游都重写了 client ,就是这个模块无人敢动,一直保留到现在。也算是经受了千万用户的考验了。最后的效果虽然令人满意,玩家可以享受到无缝的场景跳转体验,并且 client 只用了 16M 内存就做到了这一点(完整的地图资源如今已经上 G 了);但是我自己知道,那段代码是极其缺少美感。

至此之后,我不轻易用多线程的设计。我想,只有真正免锁的多线程程序才会显得漂亮吧。

TRACKBACK

链入链接::

»from Atry

多线程程序是很容易出错的。使用加锁的代码很容易碰上并发的冲突,比如说把一个资源读了一半的时候被别的线程改了之类的。唯一可以做到不出错的办法就是不加锁,用消息解决,要完全做到不用锁,其充分必要条件就是所有线程之间零共享数据。

COMMENTS

很支持云风的把纹理分为描述部分和数据部分,描述部分用来做说明资源的名称及加载方法及路径,已经纹理的其他信息比如mipmap,2D/3D/1D纹理等等;在此资源不需要使用或者内存紧张的时候可以释放数据部分。

如果没有引用计数,那么一个纹理被多个材质引用的时候如何去判断其是否能够被释放?

对于游戏的资源读取,是非常符合生产者和消费者的模型的,并且生产者和消费者都是唯一的(多个生产者没有意义,磁盘io是独占的)。锁无非是消费者——主线程每个循环主动的要求把读好的资源拿来锁那么一次,应该不需要考虑什么效率吧,而在我的设计模型中,可能和匿名的 Anonymous 相似,消费者主线程不操作io完全依赖生产者,其一是为了简单,其二我觉得2个线程都操作io,效率还不如一个,当然这完全是感觉,没有验证过。

看了一遍评论,对于Atry提供的方案比较认同,这也是我既将实施的一个方案,可能会作一些简化。

Cloud:我想请教一下,从文章来看,资源加载线程不可能是在主线程请求的时候才去读资源,因为主线程请求不到就会阻塞去加载资源,那么资源加载线程的预读是在什么时机发生的呢?

看了一遍评论,对于Atry提供的方案比较认同,这也是我既将实施的一个方案,可能会作一些简化。

Cloud:我想请教一下,从文章来看,资源加载线程不可能是在主线程请求的时候才去读资源,因为主线程请求不到就会阻塞去加载资源,那么资源加载线程的预读是在什么时机发生的呢?

跟文件系统挺类似的,不知道用到的小资源/文件多不多,如果多可以参考一下NTFS么,把小文件/资源的内容直接存储在文件头中,即NTFS中的$MFT文件存储有小于4K的文件的所有内容。

异步通讯也一样需要LOCK前缀保证其原子操作,只不过是在异步通讯实现模块中使用,例如win的消息通信

嘿嘿,西游地图预读那块,确实是复杂得很啊,不过懂了之后也就明白了。

02年我做过一个和这个类似的设计,也是在占用内存极大的情况下,设计换入换出。把一段时间没有使用的数据换出内存。当遇到需要使用的数据不在内存中的时候,再把它装载上来。不过都是同步的,没敢设计成异步操作。

不用锁的多线程,全部用异步通讯,这是最好的策略,它和低耦合的要求是一致的

也许Intel需要借鉴哈佛架构的思想,用硬件实现一些锁来提升多CPU环境下的锁性能。

Intel曾经在 IXP 2400/2800 系列的网络处理器中,采用了硬件的锁,效率很高。
可惜后来,网络处理器部门被卖掉了。

追本溯源,lock并不是因为有系统资源要在两线程间共享访问,而是因为对该系统资源(如内存区域)的操作不具原子性造成的。这是常引起误解的地方。

游戏中同时有多个消费者在现在几乎是不可能存在的。因为资源消费的最大头是在显卡,只有一块显卡的情况下,不会有多个线程同时把资源送向渲染管道。

即使有多个消费者,引用同样也是不必要的。因为只需要索取引用的时候资源可以即时载入就够了。我相信这样的设计对其它的应用也可以适应,比如你说的"海量视频数据" 。是淘汰算法决定了清除内存里的垃圾,让引用计数大于 0 的资源有不被淘汰的特权意义不大。

我认为当计算机的核越来越多以后,锁操作造成的性能降低会越来越明显。因为任意一个核上发起 lock 信号到总线,都会对其它核的工作流造成影响,这不是操作系统补丁可以解决的。只是现在我们才双核还看不出来。

加锁的程序段当然是非常轻量的,比如从链表中获取一个元素,向hash表中加个一个元素,复制一小段内存,这些好象都不会导致什么明显的阻塞吧。难道谁还会在加锁的程序段里做io操作么?另外,多线程的情况下部分线程临时等待也是没有问题的,因为cpu并未闲置,从总体速度来说并无降低,只是说稍微降低了平滑度。

cloud : 我指的资源管理池只负责已完全加载的内存资源,资源头不在其中。实现的就是一个自适应的资源read cache,在我的项目实际工作中还实现了write back cache。看来我误解了你的实现,按照我现在的理解,资源的消费者/游戏引擎实际大部分是单线程的,所以不存在多个消费线程共用的问题,也就不需要引用的模式。这样看来实现的还是一个简单的预读而已。

锁之所以慢,并不是因为加锁本身慢,而是因为加锁的位置可能产生瓶颈,多个线程都堵在那里,这样慢。

引用计数这个东西是首先可以拿掉的。因为资源数量是一定的,内存中保留的只是数据头,所以永远不释放也不会有问题。

其次不需要用队列来同步需要读取的数据。其实开一块共享内存一边生产请求,一边消费请求就足够了。

游戏里的需求有一个特殊之处:资源被引用的时候也需要被清出内存。这是因为,逻辑数据往往非常巨大,但是在当前帧的图象很可能不用渲染。数据就不需要加载。单纯靠引用计数来工作往往有两种结果,一种是引用加减变得非常频繁(提交到渲染管道前加引用,渲染完后立刻减引用),另一种是不能有效的释放资源。

不存在谁来释放资源的问题,也没有管理的问题。数据头本身是只加载不释放的,动态变化的只是数据本身。

批评谈不上,只是和我历来的做法和思路有所出入,提出来探讨一下而已。

象你描述的那读abcde这个思路在读入上当然是不需要任何lock的,问题主要是出在释放和引用上,如果资源读线程只负责读,而资源本身又是由申请/使用者自行负责管理的话,我看不到什么大的改进。

我自己的做法,是采用了 资源池+后台管理线程+后台资源预读线程 完成的。

每个资源都有自己的引用计数。
资源池是一个hash表,以资源guid为key保存全部在线资源的指针,后台管理线程定期按照某算法评价各个在线资源的生存优先度,并淘汰超过预留内存限制的refcount为0的优先度最低的资源。
所有需要在后台加载的资源列表加入后台预读线程的工作队列尾。
当主线程(或其他工作线程)需要某资源,则先在资源池中查找
* 假如找到,则返回一个引用并为其添加refcount,在使用完成后只减少计数,不释放内存。
* 假如没找到,则自行读取并加入资源池(资源池在加入过程中需要检查是否重复加入,如果存在重复,则释放新加入的资源,并返回旧资源的引用及增加记数)

后台预读线程从异步工作队列中提取请求,同样先在资源池中查找。

* 假如存在,则增加资源优先度。
* 假如不存在,则自行读入资源后加入资源池并赋予初始优先度(同样做重复性检查,只是这个情况不需要再添加引用记数)。

采用合适和优先度评价方法(综合使用次数,最后使用时间,占用内存大小及预读发生次数) ,可以比较好的自适应地降低内存消耗和磁盘IO。

而以上做法的瓶颈就是资源池的临时锁定和资源查找速度。锁定我是用临界量足够快(一秒一百万次只占1%cpu),对于多核多U操作系统本身有补丁不用我操心,查找算法我的hash表足够大,hashvalue生成也有针对性,一般也就3,4步就找到。也不存在问题。

以上是我的做法,虽然是应用在海量视频数据的高速缓存服务中的,但是对游戏资源的管理也应该有效。无非是规模区别,本质相同。

哦,我当时用的是更粗放的办法来管理那个缓存,第一次请求的时候开始加载,长时间不用就删。好像也够用了。

后者是用资源依赖关系表决定的。

资源保存在外存里就有依赖关系表了。比如一个打包的模型,它天然就依赖贴图,骨骼等等别的数据。

又比如一张地图,依赖上面种的树,盖的房子。

这种依赖关系在运行时会被精简,不是所有依赖的东西都真的需要用到。

再说一下那个加载器吧。一个异步的加载器,是否使用了多线程,是否开了别的线程,对于主线程是透明的。主线程只知道资源加载完了有一个回调就行了,而这个回调是在主线程发生的,主线程不必知道资源加载线程的存在。

资源的缓存的逻辑也放到主线程比较方便。我最早试过把缓存的逻辑放到资源加载线程,这样主线程想要知道一个资源是否已经加载,就必须对资源加载线程有一个send操作来获取资源,实际上是行不通的。

不好意思,我之前看你文章看得不仔细。

如果程序需要 ABCDEF... 等资源,那么我们开两个线程读取它们。一个读 ABCDEF... ,一个在另外的内存空间读 A' B' C' D' E' F' ... (这个次序随着程序的运行,两者并不会一直保持相同)

前者只在需要的时候触发读操作,后者则持续的进行。所以,一般情况下后者总是先于前者把资源读进内存。

前者每次企图读特定资源的时候,比如需要 C ,只需要先检查一下 C' 是否存在就够了。

---------------------

请问后者读取哪些资源的算法是如何确定的呢?

其实提供的接口很简单的。一共两个步骤。

第一个步骤是初始化的时候,用一个字符串来获取一个资源信息的结构。

第二个步骤是渲染的时候,每需要用到一个资源,就用那个结构调一个require()函数。这个有可能返回空也可能能够得到具体的资源,但不管怎么样,都一定是立即返回。

当主线程获得这个资源以后,在这一帧里面,资源是能保证有效的。因为资源缓存的管理,资源的释放是在两帧之间干的。

对主逻辑来说,它根本不用管理资源的生命周期,它想要什么就请求什么(虽然不一定能请求到),经常用的东西自然会在内存里等着它用。

我想,让用户看到不完全的画面总比让用户什么都看不到,连鼠标都卡住不能动要好吧。

to 楼下的,如果用到 cmpxchg ,在多线程环境下依旧需要加上 LOCK 前缀,保证其原子操作。如果用了这个就不是 lockfree 了。

to Ricepig, 如果目录结构扁平,应该是 trie 的相对效率更低才对。因为在这个应用里,hash-map 的 key 全部变成了 DWORD ,(查询操作前的 string to key 转换是一次性的,多次重复查询只需要转换一次)。hash-map 是典型的空间换时间的结构,我理解的 trie 更多的是空间上的节省。一般来说 hash-map 的检索一个 O(1) 时间,这里还省略了 strcmp 的操作。所以无论 trie 组织的多么高效,一次 strcmp 是躲不掉了。

to atry: 我的需求是,是否多线程加载对逻辑来说是透明的。逻辑线程面对的接口就是一个可以立刻返回数据的接口。比如都进入 3d 渲染环节,当索取一个贴图的资源时,绝对不允许返回为空的。如果用回调来实现,交给程序员的接口就过于复杂了。

另外,我这里资源节点树只用于避免资源依赖关系表中资源对象在内存中的重复。判断数据是否加载是是用不到它的。还有就是,这个方案并不依赖消息队列来做线程间通讯。

to 不愿留名的批评家:

lock 操作会随着多核的发展,负担越来越严重。目前连真双核(独立 L1 cache)的机器都还是初步阶段,所以问题不那么明显罢了。我们这里有个同事在 freebsd 社区负责编写线程库,前段时间在新购置的四核主机上做测试,就发现和双核机器已大为不同。很多我们以前认为好的策略都变得相对坏了。多核时代程序员面临的问题会比我们曾经预想的糟糕。

这篇 blog 提到的方案之所以可以避免锁的使用,简单说来是这样的。

如果程序需要 ABCDEF... 等资源,那么我们开两个线程读取它们。一个读 ABCDEF... ,一个在另外的内存空间读 A' B' C' D' E' F' ... (这个次序随着程序的运行,两者并不会一直保持相同)

前者只在需要的时候触发读操作,后者则持续的进行。所以,一般情况下后者总是先于前者把资源读进内存。

前者每次企图读特定资源的时候,比如需要 C ,只需要先检查一下 C' 是否存在就够了。

整个过程由于并不存在需要同步的数据,所以不需要锁。

至于 C 和 C' 可能同时读取发生碰撞,我们只需要简单的允许两者同时存在于内存即可。这并不造成多大的浪费,原因有三:在需要读取的资源数量很大的时候,碰撞很难发生;单个资源体积不大,重复读取的量很小;操作系统对 I/O 操作本身有 cache 。

另外,win32里的临界量操作也是原子级别的,在未遇到互斥的情况下进入也是无lock的,速度非常接近lockfree算法,测试了1百万次循环 Enter,leave对,时间是10.5ms, 而lockfree的核心cmpxchg 跑1百万次是6.2ms,和单跑Enter速度一样。再考虑到遇到lock后运算时间较长(超过了线程切换消耗)的情况,我想使用哪个更加可靠和易懂应该是不言而喻了。

你搞错了吧?

稍微search下lockfree,貌似都是通过傻重试机制完成的,这样在对共享数据存在互斥操作本身比较耗时的情况下会导致cpu消耗成倍的增加。换句话说,这个机制只适合互斥访问频度非常高,而每次访问又非常短暂的情况下有效率提升,否则还不如通常的lock。

数据加载线程序可以不断的去完成队列中的数据加载请求,一旦加载完毕,就在资源结构中做好标记。而主线程收到数据使用请求时,检查到标记后直接把完整的数据指针复制过来即可。如果没有完成,则自行申请另一块内存,阻塞做数据加载。这样是为了避免冲突,当然也有可能浪费,两条线程恰巧加载同一份资源。由于单个资源体积都不大,这点时间浪费是可以接受的,而空间在数据回收阶段可以完全收回。

<hr/>
这一点跟我所作的(http://www.ac.net.blog.163.com/blog/static/13649056200741255845585/)不太一样。我不会那么麻烦去做可以不做的事情,我让主线程绝不阻塞,如果没有那个资源就跳过。我这么做程序复杂度比你低,用户体验也比主线程自己阻塞加载要好。

我的实现,主线程的资源指针是资源加载线程把资源加载完以后的,主线程自己在回调函数里面修改的。而这个回调函数是发起一次请求的时候主线程交给资源加载线程,然后资源加载线程在加载完以后再post给主线程运行的。资源指针在post以后,所有权转移给主线程。

判断一个资源是否加载到内存里面,用hash表或者类似的技术查找一般来说不能满足网络游戏或者类似应用的效率要求。所以不得不让逻辑线程持有资源的标示信息,在这个结构里面他根据判断指针是否为空之类的办法来判断一个资源是否加载。这样效率才行得通。

关于lock-free programming和transactional memory, http://acmqueue.com/modules.php?name=Content&pa=showpage&pid=332有提到。

关于trie和hash,得看你的hash算法效率了。如果目录结构不太深的话,也就是trie扁平,应该效率和hash差不多。

我认为这里在 key-value 里用单词查找树来保存这些字符串没有多少好处。上面列出来的用唯一指针的方式做 key 效率要高很多。

trie 对相似的大量字符串的保存有优势。在字符串池本身的储存上可以考虑使用 trie 。我用的 hash-map 只是为了实现上的方便。

我觉得写一些免锁的多线程程序,在多核 CPU 下之所以依旧需要一些 cpu 指令的支持,比如 intel 提供的 LFENCE/SFENCE/MFENCE 指令,这皆是因为现在的 CPU 开始不保证读写次序以及多核 CPU 上开始存在多个 cache 的缘故。我们的算法本身并无过错。

而锁机制最终要调用 LOCK 指令,发送 LOCK# 信号到总线,这就低效的多了。

感谢提供资料,tharris 的主页正在看。英文上我不太理解前面提到的东西的区别,我暂时关心的是不用锁机制来解决问题。也就是最终代码是否需要产生 LOCK# 信号。个人认为通过良好的设计,是完全可行的。

至于 MFENCE 这样的指令的调用,以后完全可能被编译器支持,放入 volatile 的实现中。

无锁数据结构是以前的研究热点,但是后来被证明很多公开发表文章里的实现方法都是错误的,transactional memory是目前的研究热点,见:http://research.microsoft.com/~tharris/

免锁的多线程,除了没有资源共享之外,只能靠cpu指令了,这样的代码更加没有美感吧。

另外,在资源树中,既然字符串重复出现次数很多,为啥不用trie?

申请和释放应该放在同一线程内,这样就可以了。只把加载的过程放在线程池里去做。

免锁的数据结构及算法是当今多核时代程序员们的重要研究课题。google 一下 lock free 可以得到大量的信息。

另,多线程设计起来复杂度并不高,但是早期一定要设计好,因为后面的调试会很困难.分离好任务流,安排好汇合点,一切其实很明了.

没听说过lockfree的多线程设计,只要线程之间有数据关联,则必然有交叉点需要做保护.好的线程任务分配模式可以减少交叉点,但是不可能消灭交叉点.另外,临界量的进出消耗是可以忽略不计的(每秒百万次级别以上).

如果你这个实现无lock,那么必然在资源的释放上产生歧义,也许释放者正准备淘汰一个资源而申请者正好得到了它..

确实!多线程的复杂度和单线程不是一个数量级的~

我今天才知道,原来用消息队列有一个专有名词叫lock-free,又学到一个名词!

我前段时间也做过类似的工作。

不错哦

转载地址:https://blog.csdn.net/iteye_4639/article/details/82472055 如侵犯您的版权,请留言回复原文章的地址,我们会给您删除此文章,给您带来不便请您谅解!

上一篇:基于垃圾回收的资源管理
下一篇:MFC原创教程:3.0 Menu

发表评论

最新留言

路过,博主的博客真漂亮。。
[***.116.15.85]2024年03月25日 10时57分41秒