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

UE4的队列TQueue

作者头像
quabqi
发布2021-11-04 10:54:44
2.4K0
发布2021-11-04 10:54:44
举报
文章被收录于专栏:Dissecting UnrealDissecting Unreal

TQueue是UE4提供的队列容器,完全满足队列的先进先出性质,这里主要用于多线程同步数据。如果比较了解多线程编程的话,那你肯定知道多线程中最常用的一个容器就是消息队列,解决的就是生产者-消费者问题。

如果自己简单的去写一个消息队列,我想大部分人肯定就直接在进队列和出队列的地方加锁,避免两个线程同时访问,但是假如消息通信非常频繁,大量的加锁就会造成当前线程有大量时间在阻塞等待,这个容器的性能就会非常差,一个消息队列的性能好坏会直接影响到了两个或多个线程的性能。

如果经常写C++的话,肯定会想到STL中的队列std::queue,std的队列底层其实是用std::deque这个容器来实现的,内部存储是通过链表+数组的形式,把不同的块链在一起,我们知道std::vector和UE4的TArray一样都是单向扩容的,而std::deque通过链表+数组这种方式,就可以支持的前后双向扩容。其实std::queue这样的容器底层内存管理方式在UE4中也有一个容器TChunkedArray和他非常像,但UE4并不是把他当作队列来用的,如果看过Unity的ECS实现你就会觉得这个结构很眼熟,跟Architype的底板容器一模一样,UE4也是用他来做和ECS管理组件内存差不多的事情,ECS的多线程处理多个组件之间的关系有多复杂就不多说了。显而易见,用链表+数组这种复杂的数据结构来实现队列,要保证线程安全是非常困难的,强行加锁的话性能会变得很差。游戏引擎肯定要优先保证性能,所以这就是为什么UE4没有选择std::deque或TChunkedArray类似数据结构来实现队列的原因。

那UE4的队列是怎样做的?从最上面注释可以看到,TQueue选择了简单的单链表这样的结构,从链表的结构上就能很好的解决这个问题。UE4还通过巧妙的实现保证了无锁(lock-free),用于两个线程的单生产者-单消费者(只有一个线程的情况最后会说)或多个线程的多生产者-单消费者的这两种模式,虽然没有覆盖到多生产者-多消费者这种模式,但已经能满足绝大部分的开发需求了。引擎内部有很多地方都在使用这个容器或类似的思想,下面就来说说这个容器的具体实现

TQueue的成员变量

先看成员变量,两个指针Head和Tail,其中Head加了volatile关键字,同时用宏告诉编译器必须16字节对齐,之前分享的TripleBuffer也提到了成员变量Flag是这样声明的,上次写的时候没有说具体的原因,下面就专门解释下UE4为什么要这样写

quabqi:UE4的TripleBuffer

TTripleBuffer的成员变量

先看Align(16),16字节对齐这个很好理解,C++的字节对齐上限std::max_align_t就是16。因为TQueue不知道外面代码会用在哪里,成员变量依赖于外面的内存空间,不声明16字节,外面就有可能把内存对到一个诡异的位置上。比如用一个uint8数组来装TQueue,先用掉一个字节,从第二字节开始装TQueue,我们知道指针在64位机器是8字节,那么这个Head就有7字节在前面,一个字节在后面,那么Head指针肯定不是16的倍数,在做一些事情时,如果不接受这样的指针就很可能报错,即使不报错也不那么效率。

再看volatile,在C++中volatile关键字,是为了告诉编译器,这个变量会经常修改,让编译器不要生成带优化的汇编代码,而是生成每次访问都是从内存读取和写入的汇编代码。因为编译器在优化时不会考虑一段作用域内,不考虑多线程之间,如果发现这个值在一个作用域内的代码中从来没改过,或者改过之后再也没有使用过,就很可能把这个变量存成一个常量,赋值后就再也不改了。这样就会导致即使另一个线程改了这个值,但在这个线程就完全感知不到。加了volatile,两个线程在同时访问这块内存时,比如原始值是1,第一个线程写了2,编译器不去优化了,就会生成把2写入内存的汇编指令,第二个线程在读取的时候生成的是读取内存的汇编指令,这样就能感知到这个值为2。那么可以看到这样就保证了这个变量基本是线程安全的。

为什么这里加了一个基本?因为还要考虑读取内存和写入内存这两个操作不一定能一次做完,因为这两个操作有可能是多条汇编指令,也可能单条指令不是原子指令,如果再能保证操作也是原子的,就能保证了线程安全。怎样保证操作是原子的,这里后面来说

构造和析构函数:

默认就会new一个空节点,作为头和尾,虽然浪费一个节点内存,但这样就不用判断空指针的情况。

这个TNode是个子结构体,也很简单,就是一个普通的链表,一个Item和一个NextNode,注意NextNode也加了volatile。这里没有加align(16)的原因是,TNode外部不能使用,唯一内存来源就是TQueue内部new,new操作来源于UE4的内存池FMemory(文末有截图),内存池已经保证了是对齐的,所以不用强行加align(16)。

进队列Enqueue:

代码里的TSAN系列宏可以先无视,是查高并发时数据竞争的BUG用的,有兴趣可以看这篇怎么用。ThreadSanitizer——跟data race说再见 - 知乎 (zhihu.com),里面写的很专业,但我目前也没怎么看懂。

前面也说了,因为要支持两种模式,单生产者-单消费者,多生产者-单消费者,进队列相当于是处理生产者,那么就可能出现1和多的情况,所以这里在进入队列的时候区分开,因为多生产者可能同时操作到这个Head变量,虽然加了volatile但不能保证原子性,所以这里使用InterlockedExchangePtr来强制保证原子性。考虑了下这里还是彻底说清楚吧,这个函数在Windows上其实调用的是下面这个函数:

查了MSDN,可以看到

注意下面的说明

在clang下用的是__sync_lock_test_and_set

所以看到,通过这个函数操作,就能保证多生产者即使同时操作这个变量,就是安全的。做的事情其实就是把Exchange的值给Dest,返回老的Dest,这是一次做完的。

所以这里就是一次性把Head指向NewNode,OldHead变为原来的Head,然后一次性把OldHead的NextNode指向了NewNode。相当于是通过两个原子操作。这里可以想象一下,假如有两个线程(多生产者)同时Enqueue,第二个线程就正好在第一个线程这两个原子操作中间Enqueue,那么可以看到第一个线程的Head被改成了新的,OldHead是局部变量不会变

对于单生产者来说,即使不是线程安全的,但是因为只有一个生产者,不会出现多线程同时修改Head这样的操作,所以也是没有问题的。我们看这里实际是三行代码,其中倒数第二行有个MemoryBarrier,这个函数的作用是,可以保证调用这个函数之后的所有代码,一定在调用这个函数之前的代码之后执行。这个听起来很怪,可能你会觉得正常代码不都是这样执行的吗?但实际上在Shipping编译时,C++编译器很可能为了性能会做优化,在作用域内保证逻辑正确的前提下让指令乱序执行,所以这里的目的就是告诉编译器不要这样搞,一定要按顺序做事情。这样就保证了OldHead->NextNode一定是在Head先指向NewNode后才会指向NewNode,假设这个步骤反过来先让OldHead->Next指向NewNode,那如果当Tail也指向的是OldHead,在还没来及让Head指向NewNode时,出队列操作就会把OldHead从链表上移除,这时再让OldHead->Next指向NewNode就变成了让一个没在链表上的节点的Next指向NewNode,这显然是有问题的。另外因为这里放弃了编译器的乱序优化,肯定在一定程度上会比不加这个内存屏障要慢,但也比加锁要快非常多。

比如有个空TQueue<int32>,调用Enqueue进队列,这个队列的变化过程就是这样的:

出队列Dequeue:

因为出队列本身只有单消费者,不存在多个线程同时访问的情况,所以这里只要处理好和Enqueue的关系,保证操作是安全的就好。具体流程是让Tail指向自己的Next,并把旧数据用默认构造的对象覆盖掉,再删掉更老的无效节点,就完成任务了。可以看到这里的代码连MemoryBarrier都没有加。至于为什么不加,是因为Head只会往Next方向去移动,不会往回移动指向前一个结点,Tail永远不会超过Head,即使追上Head又因为Tail始终指向的无效节点,真正的数据是Tail->NextNode,这样就相当于根本不存在多线程访问Tail的情况,即使编译器做了乱序优化,也能保证操作结果的正确。整个出队列的流程,用图画出来就是下面这样的:

其他函数

除了基本的进出队列的这两个基本函数外,还有清空Empty,判空IsEmpty,查询队尾但不出队的Peek,出队但不取值的Pop函数,这些都和出队列一样都是在跟Tail打交道,所以也相当于是没有涉及到多线程访问,完全不需要任何措施来保障线程安全。可以自行了解对应功能,就不再细说了。

tradeoff:

整个队列在进出时没有加任何的锁,进入队列在多生产者模式下只有两个原子操作,单生产模式只有一个MemoryBarrier,而出队列和其他函数完全没有原子操作和MemoryBarrier,性能几乎跟单线程的队列性能一样,这也正体现出来TQueue这个容器为什么是Lock-Free的队列了,性能肯定特别好,但是免费的东西就没一点代价吗?

如果再细心一些可能会注意到,这里无论是进队列还是出队列,节点都是new出来的,用完都是delete掉的,进队列时外部的对象还要拷贝到new出来的节点上,这样当队列进出非常频繁时,就产生了大量的内存碎片,如果学过C++肯定都知道new和delete虽然较快,但还是比在栈上分配要慢很多,因为分配堆内存最终会走到操作系统的API来分配,这样不会有性能问题吗?

这里确实是这个容器的唯一缺点了,但是UE4本身在全局重载了new和delete,内存的分配和释放其实是来源于内存池,只有在内存池的内存不够用时UE4才会向系统要新的内存,这个频率是远远低于代码中调用new和delete的频率的。因为UE4的内存池默认用的是Binned2或3,为了防止分配内存的全局锁,内部小内存都是在TLS (Thread Local Storage)内存块上申请的,每个线程独占,这里的队列是一个线程new另一个线程delete,肯定delete后这块内存在短期内是还不回原来的那个线程的内存块的,虽然长期来看不会有问题,但短期大量使用一定会使内存峰值撑大。但为了高性能的同步,即使有这么一点点的副作用,我觉得还是可以承受的。

UE4全局重载了new和delete,实际调用的是FMemory上的Malloc和Free函数

可以在TQueue的注释上看到下面这句,说明UE4的开发同学确实意识到了这个问题,可以搞个节点池化,但可能还没来及做或没考虑好怎样实现最优吧,所以默默的写了个todo蒙混过关。虽然已经很多个版本一直都是这样了,但还是希望哪天能看到这里该怎么来实现。

可能有人还会想到,如果是在单线程下使用队列,也属于单生产者,单消费者的情况,这里因为有个MemoryBarrier性能肯定还是比没有的情况要差,而且同一个线程可以完全不需要MemoryBarrier,事实也确实如此。强行用其实也没有任何问题,但是我觉得既然都保证了单线程访问,为什么不直接去用TArray或TSparseArray其他性能更好的容器呢?

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

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

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

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

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 构造和析构函数:
  • 进队列Enqueue:
  • 出队列Dequeue:
  • 其他函数
  • tradeoff:
相关产品与服务
消息队列 CMQ 版
消息队列 CMQ 版(TDMQ for CMQ,简称 TDMQ CMQ 版)是一款分布式高可用的消息队列服务,它能够提供可靠的,基于消息的异步通信机制,能够将分布式部署的不同应用(或同一应用的不同组件)中的信息传递,存储在可靠有效的 CMQ 队列中,防止消息丢失。TDMQ CMQ 版支持多进程同时读写,收发互不干扰,无需各应用或组件始终处于运行状态。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档