回到未来 - 解码





5.00/5 (22投票s)
在 C# 中开发时不注意资源消耗可能导致系统过载。本文描述了一个内存和 CPU 时间大量浪费的案例以及如何避免这种情况。
引言
当编程模块处理存储在 RAM 中的大量数据时,数据存储结构会影响 RAM 消耗和性能。使用更原始的数据类型、结构而不是类、原生数据而不是结构,可以节省计算机资源。这种方法打破了面向对象编程(OOP),并回归到应用“旧”编程方法。然而,在某些情况下,这种优化能够解决许多问题。对该问题进行简单研究已证明可以将内存消耗减少多达三倍。
本文涵盖以下问题:
- 软件架构对内存消耗和性能的影响
- 32 位和 64 位模式下运行的差异
- 指针和数组索引之间的差异
- 类/结构内数据对齐的影响
- CPU 缓存对性能的影响
- 支持高级编程语言中 OOP 的成本评估
- 认识到即使使用高级语言也需要考虑平台的底层特性
背景
我们最初将此方法应用于为门户网站 www.GoMap.Az 开发新的最佳路径查找器。新创建的算法比以前的算法消耗更多的 RAM,结果,应用程序安装在测试服务器上后开始卡顿。在这种情况下,升级硬件需要几天时间,而通过软件优化 RAM 消耗可以更快地解决问题。在本文中,我们分享了我们的经验,并用简单的语言描述了已实施的行动以及获得的收益。
对于处理地理数据的信息系统而言,组织大量数据结构的存储和访问是一个基本问题。这类问题在开发其他类型的现代信息系统时也倾向于发生。
让我们以道路——图的边为例,回顾数据存储和访问。简化形式下,道路可以用类 Road
表示,道路的容器可以用类 RoadsContainer
表示。此外,还有一个表示图节点的类 Node
。关于 Node
,我们只需要知道它是一个类。我们假设数据结构不包含方法且超出继承关系,换句话说,它们仅用于存储和操作数据。
在此,我们讨论的是使用 C# 语言的方法,尽管它最初是在 C++ 上应用的。更具体地说,问题及其解决方案属于系统编程领域。然而,该研究也表明了 OOP 支持的成本可能有多高。C# 尽管不是系统编程语言,但可能是展示这些隐藏成本的最佳方式。
// Main data structure – class Road
public class Road
{
public float Length;
public byte Lines ;
// Class Node is described anywhere
// Road refers to two Node objects here
public Node NodeFrom;
public Node NodeTo;
// Other members
}
// Container of roads
public class RoadsContainer
{
// Other members
// Returns roads located in specific region
public Road[] getRoads(float X1, float Y1, float X2, float Y2)
{
// Implementation
}
// Other members
}
内存和性能
在评估性能和内存消耗时,应考虑平台架构的特性,包括:
- 数据对齐。对齐是为了 CPU 正确快速地访问内存单元。因此,根据 CPU 类型,类/结构在内存中的位置可以从 32 或 64 的倍数的地址开始。在类/结构内部,字段也可以按 32、16 或 8 字节边界对齐(例如,类
Road
中的字段 Lines 在内存中可能占用 4 字节而不是 1 字节)。这样,未使用的内存空间会增加内存消耗; - CPU 缓存。众所周知,CPU 缓存的基本目的是提供更快地访问频繁使用的内存单元。缓存的大小非常小,因为它是最昂贵的内存。在处理类/结构时,由于数据对齐而出现的未使用的内存空间也会通过处理器缓存并堵塞它,而没有任何有用的信息。这降低了缓存的有效性。
- 指针大小。在 32 位系统上,指向内存中对象的指针通常也是 32 位的,这限制了处理 4GB 以上 RAM 的能力。64 位系统可以使用 64 位指针寻址更多的内存。对象总是有一个指向它们的指针(否则就是内存泄漏,或者对象被列为由垃圾收集器移除)。在我们的例子中,类
Road
的字段NodeFrom
和NodeTo
在 64 位系统中各占用 8 字节,在 32 位系统中各占用 4 字节。
通常,编译器会尝试生成最有效的代码,但只有软件架构解决方案才能实现最高效率。
对象数组
数据可以存储在各种容器中——列表、哈希表等。存储在数组中可能是最简单和最流行的方式,因此我们决定考虑这种方法。其他容器可以以类似的方式进行研究。
在 C# 中,对象数组实际上存储对对象的引用,而每个对象在堆中占用自己独立的地址空间。这使得操作对象集合变得容易,因为您处理的是指针而不是完整的对象。因此,在我们的示例中,类 RoadsContainer
的函数 getRoads
方便地传输一组特定的类 Road
对象——即通过引用而不是复制对象的内部。之所以会发生这种行为,是因为 C# 中的对象是引用数据类型。
将对象存储为数组的缺点主要是对象指针的额外存储成本以及堆中每个对象的对齐。在 64 位平台上,每个指针占用 8 字节内存,并且每个对象都按 8 的倍数的地址对齐。
结构体数组
设计用于存储道路和节点的类可以转换为结构体(如我们的示例中所示,OOP 部分没有任何限制)。可以使用整数索引代替指向对象的指针。生成的代码将是:
public struct Road
{
public float Length;
byte Lines ;
Int32 NodeFrom;
Int32 NodeTo;
// Other members
}
public class RoadsContainer
{
// Other members
// Roads are in an array now, not in the heap
Road[] Roads;
// Returns roads located in specific region
public Int32[] getRoads(float X1, float Y1, float X2, float Y2)
{
// Implementation
}
// Returns road by index
public Road getRoad(Int32 Index)
{
return Roads[Index];
}
// Other members
}
// Container of nodes is similar by structure
// to the container of roads
public class NodesContainer
{
// Other members
Node []Nodes;
// Returns node by index
public Node getNode (Int32 Index)
{
return Nodes[Index];
}
// Other members
}
结果是什么?下面我们将详细讨论。
道路在此处以结构体(C# struct
)而非对象的形式存储。类 RoadsContainer
中的 Road
数组用于存储。同一类的 getRoad
函数用于访问单个结构体。32 位整数索引充当特定道路数据结构的指针。节点和存储类 NodesContainer
也类似地进行了转换。
使用 32 位索引代替 64 位指针可以减少内存消耗并简化操作。在 Road
结构中使用索引通过字段 NodeFrom
和 NodeTo
引用节点将减少结构 8 字节的内存消耗(如果对齐等于 32、16 或 8 位)。
为存储道路分配内存通过一次调用(通过调用操作符“new
”)完成。道路结构在创建数组的同时被创建。在存储对象引用的情况下,每个对象必须单独创建。创建单独的对象不仅需要时间,而且还会消耗一定量的开销内存用于对齐、在堆中注册对象以及在垃圾回收系统中注册对象。
使用结构体而不是对象的缺点是,严格来说,无法使用指向结构体的指针(结构体是值类型,而类是引用类型)。这一事实导致在操作对象集合时受到限制。因此,类 RoadsContainer
的函数 getRoads
现在返回数组中结构体的索引。同时,函数 getRoad
提供结构体。但是,此函数将复制整个返回的结构体,这将导致内存流量和 CPU 时间增加。
基本类型数组
结构数组可以转换为该结构的各个字段的数组。换句话说,结构可以被解封装和废除。例如,在解封装并废除结构 Road
之后,我们将得到以下代码:
public class RoadsContainer
{
// Other members
// Fields of structure Road
float[] Lengths;
byte[] Lines;
Int32[] NodesFrom;
Int32[] NodesTo;
// Other members
// Returns roads located in specific region
public Int32[] getRoads(float X1, float Y1, float X2, float Y2)
{
// Implementation
}
// Returns length of road by the index
public float getRoadLength(Int32 Index)
{
return Lengths[Index];
}
// Returns number of lines of road by the index
public byte getRoadLines(Int32 Index)
{
return Lines[Index];
}
// Returns starting node of road by the index
public Int32 getRoadNodeFrom(Int32 Index)
{
return NodesFrom[Index];
}
// Returns ending node of road by the index
public Int32 getRoadNodeTo(Int32 Index)
{
return NodesTo[Index];
}
// Other members
}
结果是什么?下面我们详细讨论。
不是将整个结构存储在一个数组中,而是将结构的各个字段存储在不同的数组中。字段的访问也通过索引单独进行。
由于结构体内部字段对齐造成的内存浪费被消除,因为基本类型的数据彼此紧密存储。内存不是通过一个大的块一次性存储所有结构体,而是通过几个块分别存储字段数组。在一定程度上,这种划分是有益的,因为对于系统来说,提供几个相当小的连续内存部分通常比一个大的连续段更容易。
每次访问每个字段都需要使用索引,而访问整个结构只需使用一次索引。在实践中,此功能既可以看作缺点,也可以看作优点。如果字段位于单独的数组中,则仅处理部分字段,例如仅处理 Lengths
、NodesFrom
和 NodesTo
这三个字段,将优化 CPU 缓存的使用。缓存的所有优势的利用取决于数据访问算法,但在任何情况下,优势都是显而易见的。
垃圾回收和内存管理
给定的问题与内存管理有关。毕竟,对象在内存中的位置会影响访问时间。如今,有大量组织内存管理的方式,包括自动垃圾回收系统。这些自动化系统不仅监控内存清理,还对内存进行碎片整理(像文件系统一样)。
内存管理系统主要处理指向堆中对象的指针。在结构/字段数组的情况下,内存管理将无法处理数组元素,所有与它们的创建和销毁相关的工作将由程序员承担。因此,在某种意义上,使用结构/字段数组会禁用它们的垃圾收集器。此限制根据应用程序的不同可以被视为优点或缺点。
Measures
为了评估解封装的优点,进行了一项小测试。测试的源代码位于此处。为了测试,创建、读取和写入了用于存储 1000 万条道路的数组。测试在 32 位和 64 位模式下运行。需要提到的是,在处理大量数据时,32 位模式很容易导致内存溢出。尽管如今服务器和桌面系统很少使用 32 位模式,但对于移动系统而言,32 位模式目前至关重要。因此,两种模式的指标评估都被使用了。
内存
内存消耗,32 位模式
内存消耗,64 位模式
如您所见,最浪费存储的是对象数组。同时,在 64 位系统上,存储成本急剧上升。结构体或字段数组形式的存储在 32 位和 64 位模式下通常同样昂贵。以字段数组形式存储在内存量方面有一些好处,但这种增益并不关键。这种增益包括结构体内数据对齐的成本。
访问时间
注释
* - 与时间测量精度相比,此数字太小。
当在数组中存储对象时,数据结构的访问时间显示出最大的时间消耗。同时,如果字段位于单独的数组中,则对字段的访问速度更快。这种加速是更有效利用 CPU 缓存的结果。值得一提的是,在测试中,访问是通过连续读写数组元素来实现的,在这种情况下,缓存的使用接近最佳。
结论
- 在处理大量数据时避免使用 OOP,可以在 64 位系统上使 RAM 使用效率提高 3 倍,在 32 位系统上提高 2 倍。这个问题是由于硬件架构的特殊性而产生的,因此在一定程度上影响所有编程语言。
- C# 中通过数组索引的访问时间可以大大少于通过指向对象的指针的访问时间。
- 编程技术水平的提高是资源密集型的。低级(系统)和使用原始数据(在类/结构解封装后获得)使用最少的资源,但需要更多的源代码行和更多的程序员精力。
- 使用原始数据类型是对代码的优化。因此,这种架构不应作为初始设计,而应作为在需要时减少资源消耗的措施。
- 在 C++ 中,许多被考虑的问题可能以透明的方式解决,但 C# 隐藏了其底层实现。此外,在研究 C# 时,通常不考虑平台的影响。
- 在 C# 中,应尽可能考虑使用结构体而非类。