多线程应用程序中的最小延迟对象池技术
- 在应用程序中,我们大约有30种类型的对象被重复创建。
- 其中一些寿命长(小时),有些寿命短(毫秒)。
- 对象可以在一个线程中创建,而在另一个线程中销毁。
从最小的创建/销毁等待时间,低锁争用和合理的内存使用率的意义上,有人是否有什么线索可以成为好池技术?
追加1.
1.1. 一种类型的对象池/内存分配通常与另一种类型不相关(有关异常,请参见1.3)。
1.2. 一次只对一种类型(类)执行内存分配,通常一次对几个对象执行。
1.3. 如果一个类型使用指针聚合另一个类型(由于某种原因),则这些类型将在一个连续的内存中一起分配。
追加2.
2.1. 众所周知,使用具有每种类型的访问序列化的集合比使用new / delete更糟糕。
2.2. 应用程序在不同的平台/编译器上使用,不能使用特定于编译器/平台的技巧。
追加3.
显而易见,最快(具有最低延迟)的实现应将对象池组织为星形工厂网络。中心工厂是其他线程特定工厂的全局工厂。常规对象提供/回收在线程特定的工厂中更有效,而中央工厂可用于线程之间的对象平衡。
3.1. 组织中央工厂和特定线程工厂之间的通信的最有效方法是什么?
解决方案
如果我们尚未看过tcmalloc,则可能需要看看。以其概念为基础实施可能是一个好的开始。关键点:
- 确定一组尺寸等级。 (每个分配将通过使用相等或者更大规模的分配中的一个条目来实现。)
- 每页使用一个尺寸类别。 (页面中的所有实例大小均相同。)
- 使用每线程空闲列表来避免对每个alloc / dealloc进行原子操作
- 当每个线程的自由列表太大时,将一些实例移回中央自由列表。尝试从同一页面移回分配。
- 当每个线程的空闲列表为空时,请从中央空闲列表中获取一些。尝试采取连续的条目。
- 重要说明:我们可能知道这一点,但请确保设计将最大程度地减少错误共享。
tcmalloc无法做的其他事情:
- 尝试通过使用更细粒度的分配池来启用引用的局部性。例如,如果将同时访问几千个对象,那么最好是将它们紧密地放在内存中。 (以最大程度地减少缓存丢失和TLB错误。)如果从它们自己的线程缓存中分配这些实例,则它们应具有相当好的局部性。
- 如果我们事先知道哪些实例将长期存在,哪些实例将不会长期存在,则可以从单独的线程缓存中分配它们。如果我们不知道,请定期使用线程缓存复制旧实例进行分配,并更新对新实例的旧引用。
为了最大程度地减少构造/销毁延迟,我们需要手头完全构造的对象,因此我们将消除新建/删除/删除/删除时间。这些"免费"对象可以保留在列表中,因此我们只需在最后弹出/推入元素即可。
我们可以一一锁定对象池(每种类型一个)。它比系统范围的锁定更有效率,但是没有副对象锁定的开销。
我假设我们已经进行了所有创建工作,并且已经验证了create / destroy确实引起了问题,然后我们已经进行了概要分析并测量了代码。否则,这是我们首先应该做的。
如果仍然要进行对象池化,则第一步,应确保对象是无状态的,因为这是重用对象的前提。同样,我们应确保对象的成员和对象本身在使用其他线程(而不是创建该线程的线程)使用时没有问题。 (COM STA对象/窗口句柄等)
如果使用Windows和COM,则使用系统提供的池的一种方法是编写"自由线程"对象并启用对象池,这将使COM +运行时(以前称为MTS)为我们执行此操作。如果使用Java之类的其他平台,则可以使用定义对象应实现的接口的应用程序服务器,而COM +服务器可以为我们做缓冲。
或者我们也可以滚动自己的代码。但是我们应该尝试查找是否有此模式,如果可以,请使用该模式,而不是下面的内容
如果需要滚动自己的代码,请创建一个可动态增长的集合,该集合可跟踪已创建的对象。最好为集合使用向量,因为我们将只添加到集合中,并且遍历它以查找自由对象将很容易。 (假设我们不删除池中的对象)。根据删除策略更改集合类型(如果使用的是C ++,则指向对象的指针/引用的向量,以便在同一位置删除并重新创建对象)
每个对象都应使用一个标志进行跟踪,该标志可以以易失的方式读取,并使用互锁功能进行更改以将其标记为已使用/未使用。
如果使用了所有对象,则需要创建一个新对象并将其添加到集合中。在添加之前,我们可以获取一个锁(关键部分),将新对象标记为已使用并退出该锁。
如果将上述集合作为一个类来实现,则可以进行测量并继续进行操作,我们可以轻松地为不同的对象类型创建不同的集合,从而减少执行不同工作的线程的锁争用。
最后,我们可以实现一个重载的类工厂接口,该接口可以创建各种池对象,并知道哪个集合保存哪个类
然后,我们可以从那里对该设计进行优化。
希望能有所帮助。
如果我们对池的首选大小有所怀疑,则可以使用数组(使用最快的解决方案)使用堆栈结构来创建固定大小的池。然后,我们需要实现对象生命周期的四个阶段:硬初始化(和内存分配),软初始化,软清理和硬清理(和内存释放)。现在使用伪代码:
Object* ObjectPool::AcquireObject() { Object* object = 0; lock( _stackLock ); if( _stackIndex ) object = _stack[ --_stackIndex ]; unlock( _stackLock ); if( !object ) object = HardInit(); SoftInit( object ); } void ObjectPool::ReleaseObject(Object* object) { SoftCleanup( object ); lock( _stackLock ); if( _stackIndex < _maxSize ) { object = _stack[ _stackIndex++ ]; unlock( _stackLock ); } else { unlock( _stack ); HardCleanup( object ); } }
HardInit / HardCleanup方法执行完整的对象初始化和销毁,并且仅在池为空或者释放的对象因其已满而无法容纳池时才执行它们。 SoftIniti执行对象的软初始化,它仅初始化对象自发布以来可以更改的那些方面。 SoftCleanup方法释放对象使用的资源,这些资源应尽快释放,或者那些在其所有者驻留在池中时可能变为无效的资源。如我们所见,锁定是最小的,只有两行代码(或者只有很少的指令)。
这四种方法可以在单独的(模板)类中实现,因此我们可以针对每种对象类型或者用途实现微调的操作。我们也可以考虑使用智能指针在不再需要对象时自动将其返回到其池中。
我们是否尝试过the积分配器?与许多系统上的默认分配器相比,它提供了更好的性能。
为什么有多个线程销毁它们未创建的对象?这是处理对象生存期的简单方法,但是成本会因使用情况而有很大差异。
无论如何,如果我们尚未开始实现此功能,则至少可以将创建/销毁功能放在接口后面,以便以后在了解有关系统功能的更多信息时可以对其进行测试/更改/优化。确实有。