前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >UE4的TripleBuffer

UE4的TripleBuffer

作者头像
quabqi
发布2021-11-04 10:53:58
8450
发布2021-11-04 10:53:58
举报
文章被收录于专栏:Dissecting UnrealDissecting Unreal

UE4中有一个特殊的容器TripleBuffer,三缓冲,顾名思义,这个容器内确实是有三个Buffer。这个三缓冲,和引擎渲染时候用到的双缓冲三缓冲虽然原理差不多,但并不是同一个东西,而是更广泛意义上的一个容器,是给开发者的做多线程同步来使用的。我们可以看看渲染时候使用单缓冲,使用双缓冲和使用三缓冲是怎么做的。

1 使用单缓冲

屏幕会读取FrontBuffer来绘制,那么我们往FrontBuffer画什么,屏幕就会立即去显示什么,比如我们要画一个三角形,一个圆,一个正方形,程序画这些的代码是按顺序提交的,每画一个图形,要等屏幕先画完,还回FrontBuffer才能给FrontBuffer上提交下一个图形,所以屏幕上就会看到程序绘制的过程,FrontBuffer刷了三次,可能看到的画面就会有三角形,圆,正方形依次刷出来的感觉,并不是一张静态的图像。我们可以看到,因为只有一个Buffer,屏幕在访问FrontBuffer期间,程序是需要等待的。

2 使用双缓冲

程序会在每一帧把一堆要画的图形画到一个BackBuffer上,这个BackBuffer并不立即绘制,会在每帧的末尾做一次SwapBuffer操作,把BackBuffer和FrontBuffer交换,而FrontBuffer是屏幕正在绘制的内容,从而让屏幕绘制程序这一帧已经画好的Buffer,这样做,我们看到的画一个三角形,一个圆,一个正方形就不会有一个一个刷出来的情况,因为只画了一次FrontBuffer就把这3个图形同时刷到了FrontBuffer上。在屏幕绘制的同时,因为上一次的FrontBuffer交换回了BackBuffer,所以程序可以接着准备下一帧的数据,不用等待屏幕还回FrontBuffer。这样减少了大量空等的时间,所以速度会比不使用缓冲要更快一些,但代价是我们多使用了一倍的Buffer空间。

使用双缓冲,虽然相比单缓冲需要等待的情况少了很多,但程序就完全不需要等待了吗?我们可以看到在每一帧的末尾,都要做一次SwapBuffer操作,如果程序写BackBuffer比较慢,屏幕先画完了,这时因为屏幕没有下一帧的数据可画,就会开始空等。而如果程序写BackBuffer比较快,屏幕还没有画完,程序在SwapBuffer时就还拿不到FrontBuffer来交换,这时就会阻塞等待。我们也经常能从stat中看到RHI卡在SwapBuffer上,大部分都是这个原因。

3 使用三缓冲

既然两个缓冲还会出现等待的情况,那如果再加一个缓冲BackBuffer2,就可以让程序在画完这一帧的情况下不等待,直接在BackBuffer2上画下一帧,这时屏幕正在画的是FrontBuffer,程序已经准备好的是BackBuffer1,正在画的是BackBuffer2,屏幕绘制完成时交换已经画好的这个BackBuffer1就可以了,这时FrontBuffer就交换到了BackBuffer1上,程序如果画完了BackBuffer2就继续画BackBuffer1,让屏幕去读取BackBuffer2内容。如果程序的提交速度是远快于屏幕绘制速度时,就完全不会出现等待,因为程序总是在往BackBuffer1和BackBuffer2的其中一块Buffer上提交,而屏幕总是取另一块绘制,不会出现等待。和双缓冲一样,代价就是要申请三倍的内存。

UE4的TTripleBuffer

前面说了这么多,只是为了讲解三缓冲本身的原理,UE4提供了TTripleBuffer容器,就是按照这样的原理,可以让两个线程之间可以高性能同步数据。可以看到下面注释,这个容器是lock-free的,支持两个线程用3个Buffer交换数据,解决的就是生产者-消费者问题。额外提到了一点,为了避免交换指针,他用了一个flags来记录Buffer,这一点我觉得是UE4这个容器最有特色的一点,也是将性能优化到极致的一个体现,这个下面会具体解释。

先看构造函数和析构函数

可以外部提供3个Buffer传进来,也可以内部new出来3个BufferType类型的Buffer,其中BufferType是模板的参数,需要业务用的时候自己指定,如果是内部new出来的Buffer,OwnsMemory是true,外部传进来的是false,在析构函数时如果OwnsMemory为true会主动delete掉。

下面是TripleBuffer的成员变量

这个Flags,在初始化的时候,会设为Initial,下面这个枚举可以看到每一位具体定义,这里其实是用了7位表示,其中0-1位表示ReadBuffer的Index,2-3表示WriteBuffer的Index,4-5是TempBuffer的Index,而6表示当前TempBuffer是否为Dirty,当有新写入新的数据后就会把这里Dirty位置为true,如果是Dirty就表示有新的数据可以读,当读取后会把Dirty设位false。因为有3个Buffer,这个Buffer在构造的时候是个3个元素的数组,所以我们可以用下标来表示Read,Write,Temp分别用的是第几个数组。

你可能会问,为什么不直接用3个指针,非要这样绕一层来表示呢?这里就是UE4这个容器比较有特色的地方,我们知道一个指针是8字节,3个指针就是24字节,再加上一个dirty标记,如果我们在交换Buffer的时候直接交换指针,怎么能保证修改这24字节+标记位在任意机器上都是原子操作呢?显然这是一个很困难的事情,加锁肯定可以做到交换期间线程安全,但如果改成标记位的方式,只需要7位,连1字节都不到,显然可以不加锁,通过原子操作API就完成交换,即使机器环境再苛刻,1字节数据的原子操作显然是能做到的。这样UE4通过索引间接访问的方式,就实现了TripleBuffer的lock-free

刚才看到初始化的时候会把Flags设为Initial标记,这个标记可以看到值是0x06,注释也写了每一位的含义0dttwwrr。那么06按照这个格式来表示,就是指Temp Index为0,Write Index为1,而Read Index为2,因此,初始化状态指向的Buffer如下图所示。

当需要写入数据时,我们可以通过调用GetWriteBuffers取得Buffer的引用,可以看到下面代码就是通过取flag的2-3位拿到索引值,这个索引值就是Buffer的下标,在初始化状态,这个值就是1,所以拿到的是Buffer1的引用

也可以通过Write函数写入,这里会把外部传入的数据拷到Buffer1内。

当写入数据完成时,需要主动调用SwapWriteBuffers,可以看到这里调用了InterlockedCompareExchange,这个函数就是操作系统提供的比较交换的原子操作,可以简单的认为这个函数就是做了原子赋值操作,可以自己去搜这个函数,有很多详细讲解。

因此在做完这一步,Flags内容就被设为了SwapWriteWithTempFlags的返回值。SwapWriteWithTempFlags如下所示:

可以看到,内部把Temp位的内容左移两位,把Write位的内容右移了两位,而Read位的内容不变,同时设上Dirty位。这时,Flags就变成了下面这样,这时Temp指向的是Buffer1。其中Buffer 1因为刚才写入了一些数据原因,我标记为了紫色。其他两个Buffer这时还是空的。

当需要读数据时,我们可以调用Read函数

可以看到,Read函数目前访问的是Buffer 2,取到的是Buffer2的引用,所以并不能读到有用的数据。我们需要有种办法可以知道有Buffer已经写入了数据可以读,因为在写入的时候,同时设了Dirty位,所以可以通过判断Dirty是否标记来确定。如下图所示

检测之后如果为true,我们肯定是希望把已经写好的Buffer交换到ReadIndex上,而如果没有写好的Buffer就说明没有新内容可以读,就什么都不做。可以看SwapReadBuffers就是这样实现的,在Dirty为false时什么都不做直接返回,为true时,交换Read和TempBuffer

SwapReadWithFlags里将Read位内容左移4位,将Temp位右移4位,Write位保持不变,同时把Dirty位设为false。

这时TripleBuffer状态就变成了下面这样

当我们调用Read时,就可以读到刚才写入的内容。到现在,我们就了解了要怎样去使用TripleBuffer,写入时要先Write完成后再SwapBuffer,在读取时要先SwapBuffer再读取。

为了方便操作,容器还额外提供了两个函数SwapAndRead和WriteAndSwap,将上面这两个操作进行了封装,通过一次调用就可以完成,也可以看到读的时候是先Swap再Read,写的时候是先写再Swap。

通过上面这个流程,可以看到读和写永远是在访问不同的Buffer,还有一个TempBuffer帮忙中转,这就保证了两个线程可以高效的不加锁的操作自己的Buffer而不用担心线程安全问题。那既然TripleBuffer这么好用,为什么UE4内部用的这么少呢?可以全局搜索,除了一个Test代码,基本没有地方在用。我们在做网络收发包时都用TripleBuffer不就好了,为什么还要用Queue或RingBuffer?我们知道TQueue内部每次进队列时都会new Node会有大量内存碎片,而RingBuffer容量固定,当数据量特别大的时候会溢出,而TripleBuffer能保证无锁,也没有其他这两个容器的副作用,为什么最后都没有选择TripleBuffer呢?其实TripleBuffer本身是完全可以用在处理网络消息的,只是在一些特殊的地方要注意,只要能接受这些特殊的地方,就完全可用,下面就通过一个例子来具体说明:

然后Async开两个线程,分别读写,每次读写都sleep 0.1秒,这样能让两个线程保持相同的速度。

运行,可以在log看到有些数据读了多次,有些没读到,比如下面读了两次6,但5没有读到。

为什么会这样呢?其实可以想到,如果读取过慢,连续写入两次,还没有来及读,那么在写入第三次时,第一次的Buffer就会被第三次的内容覆盖掉,这时就会丢失一个包。而如果写入过慢,来不及写,那么就会读取同样的内容。为了证明这点,我们可以稍微修改一下代码,将写线程的sleep调慢一些来模拟写入过慢的问题。

可以看到这样的log,证明了上面说的写过慢问题

如果把读线程的sleep改慢一些,来模拟读过慢的问题。

可以看到下面这样的log,很多写入值没有读,证明了读过慢的问题:

那有没有办法解决这样的问题呢?先看写过慢读多次同样数据的问题。

其实我们看前面TripleBuffer源码知道dirty表示有没有新数据可以读,我们自己代码在读的地方我们没有管是否为dirty就直接读,那么必然会读到多次同样的数据,所以可以改为判断dirty,只要不是读过慢的情况下影就能解决多次读的问题。

再看结果,和预期一样,很完美

但如果写入过快,读取过慢会导致Buffer被擦掉丢包的问题还是没有解决,其实假如业务逻辑使用UDP允许丢包,每次只需要最新的包,用这个方案就已经完美了,但就是想解决数据被擦掉问题,要怎么办呢?

我们可以这样解决,如果我们TripleBuffer里面不是单元素,而是数组,当还没有来及读的时候就不要Swap,而是在原来的Buffer后面继续写入,这样就能保证数据不会被丢弃。来具体看代码:

把TripleBuffer的元素改为Array

读取线程,把数据一次全读出来,读取完成后Reset清理TArray,防止Write时Add到已经读过的数据后面。

写入的地方加上判断,是否已经读了,如果还没有读Temp,那么可以继续追加数据,先不交换。验证结果,无论读写快慢,最终可以看到完全符合了预期。

总得来说,TripleBuffer完全满足了需要,在实现网络线程时,内存能保证基本固定且可以预分配好(TArray的Reset不会回收内存),不会像TQueue那样每次都会new Node导致大量内存碎片,也不会像RingBuffer那样溢出。但这样做真的就完美了吗?再仔细观察上面的log,第一次读只读到了Seq=1,但是已经写入了5个元素,为什么会出现这样的现象?其实仔细想想,我们在第一次写入的时候,因为还没有开始读,所以写入了一个元素就马上执行了Swap,而第2,3,4,5时都没有Swap(因为再Swap就会覆盖掉),就会导致写入的5个元素,只有第一个可以被立即读,而后面的必须要等待读完,读线程通知Dirty结束,写线程才会Swap,这里就会造成数据稍微有一些不及时,也就是这样的表现。但TQueue就完全不会有这样的问题,所以这就解释清楚了为什么UE4内部在要求高性能的场景,即使有大量内存碎片也要使用TQueue(其实每次都new问题不严重,因为UE4有实现自己的内存池,重载了new和delete,不会频繁向系统分配内存的)。

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 1 使用单缓冲
  • 2 使用双缓冲
  • 3 使用三缓冲
  • UE4的TTripleBuffer
相关产品与服务
容器服务
腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档