Swift内存布局:大小,步幅,对齐

Swift结构实例的内存布局的基础知识。

Swift类型在内存中处理时要考虑三个属性:大小,步幅和对齐。

尺寸

让我们从两个简单的结构开始:

struct Year { 
let year: Int
}
 struct YearWithMonth { 
let year: Int
let month: Int
}

我的直觉告诉我, YearWithMonth的实例比YearWithMonth的实例更大(它占用更多的内存空间)。 但是我们是这里的科学家。 我们如何用硬数字验证直觉?

内存布局

我们可以使用MemoryLayout类型来检查一些有关类型在内存中的外观的属性。

要从结构的类型中查找结构的size ,请使用size属性以及通用参数:

 let size = MemoryLayout.size 

如果您有该类型的实例,请使用size(ofValue:)静态函数:

 let instance = Year(year: 1984) 
let size = MemoryLayout.size(ofValue: instance)

在这两种情况下,大小均报告为8个字节。

毫不奇怪,我们的结构YearWithMonth的大小为16个字节。

回到尺寸

结构的大小似乎很直观-计算每个属性的大小之和。 对于这样的结构:

 struct Puppy { 
let age: Int
let isTrained: Bool
}

大小应与其属性的大小相匹配:

 MemoryLayout.size + MemoryLayout.size 
// returns 9, from 8 + 1
 MemoryLayout.size 
// returns 9

似乎可以工作! [ 旁白:是吗? 😈]

大步走

当您在单个缓冲区(例如数组)中处理多个实例时,类型的跨度变得很重要。

如果我们有一组连续的幼犬,每个幼犬大小为9个字节,那么在内存中会是什么样?

事实并非如此。 ❌

步幅确定两个元素之间的距离,该距离将大于或等于大小。

 MemoryLayout.size 
// returns 9
 MemoryLayout.stride 
// returns 16

因此,布局实际上如下所示:

也就是说,如果您有一个指向第一个元素的字节指针,并且想移至第二个元素,则跨度就是您需要使指针前进的字节距离数。

为什么大小和步幅会有所不同? 这使我们达到了内存布局的最终​​魔数。

对准

想象一下,计算机一次获取了8位或一个字节的内存。 询问字节1或字节7所需的时间相同。

然后,您升级到16位计算机,该计算机可以16位访问数据。 您仍然有旧软件想要按字节访问数据,但可以想象这里可能存在的魔术:如果该软件要求字节0和字节1,则计算机现在可以对字0进行单个内存访问并拆分16位结果。

在这种理想情况下,字节级内存访问速度是原来的两倍! 🎉

现在说一个流氓程序,将这样的16位值放入:

然后,您要求计算机在字节位置3处输入16位字。问题是该值未对齐 。 要读取它,计算机需要在位置1读取单词,将其切成两半,在位置2读取单词,将其切成两半,然后将两半粘贴在一起。 那是两个单独的16位内存读取,以访问单个16位值-速度应该是它的两倍! 😭

在某些系统上,未对齐的访问比慢要糟糕-完全不允许这样做,它会使程序崩溃。

简单的Swift类型

在Swift中,简单类型(如IntDouble与其大小具有相同的对齐值。 一个32位(4字节)整数的大小为4个字节,需要对齐到4个字节。

 MemoryLayout.size // returns 4 
MemoryLayout.alignment // returns 4 MemoryLayout.stride // returns 4

跨度也是4,这意味着连续缓冲区中的值相隔4个字节。 无需填充。

复合类型

现在回到我们的Puppy结构,它具有IntBool属性。 再次考虑在缓冲区中值彼此相对的情况:

由于Bool值的alignment=1 ,因此很高兴。 但是第二个整数未对齐。 它是一个alignment=8的64位(8字节)值,并且其字节位置不是 8的倍数。

记住这种类型的跨度为16,这意味着缓冲区实际上看起来像这样:

我们保留了struct内部所有值的对齐要求:第二个整数位于字节16处,是8的倍数。

这就是为什么该结构的步幅可以大于其大小的原因:添加足够的填充来满足对齐要求。

计算对齐

因此,在此过程的最后, Puppy结构类型的对齐方式是什么?

 MemoryLayout.alignment // returns 8 

结构类型的对齐方式是其所有属性中的最大对齐方式。 在IntBoolInt的对齐值较大,为8,因此该结构使用它。

然后跨度变为将大小四舍五入到对齐的下一个倍数。 在我们的情况下:

  • 大小是9
  • 9不是8的倍数
  • 9之后的8的下一个倍数是16
  • 因此,步幅为16

最后的并发症

考虑我们原始的Puppy并将其与AlternatePuppy对比:

 struct Puppy { 
let age: Int
let isTrained: Bool
} // Int, Bool
 struct AlternatePuppy { 
let isTrained: Bool
let age: Int
} // Bool, Int

AlternatePuppy结构仍然具有8的对齐方式和16的步幅,但是:

 MemoryLayout.size 
// returns 16

什么?! 我们所做的就是更改属性的顺序。 为什么现在大小不同? 它仍然应该是9,不是吗? 一个Bool后面跟一个Int ,就像这样:

也许您在这里看到了问题:8字节整数不再对齐! 这实际上在内存中是这样的:

结构本身必须对齐, 并且结构内部的属性必须保持对齐。 填充在元素之间移动,并且整个结构的大小会扩展。

在这种情况下,步幅仍为16,因此从PuppyAlternatePuppy的有效更改是填充的位置。 这些结构呢?

 struct CertifiedPuppy1 { 
let age: Int
let isTrained: Bool
let isCertified: Bool
} // Int, Bool, Bool
 struct CertifiedPuppy2 { 
let isTrained: Bool
let age: Int
let isCertified: Bool
} // Bool, Int, Bool

这两个结构的大小,步幅和对齐度是多少? 🤔(剧透)

结束括号

最后,假设您有一个UnsafeRawPointer (在C中为void * )。 您知道它指向的事物的类型。 尺寸,步幅和对齐方式在哪里出现?

  • 大小是从指针读取以到达所有数据的字节数。
  • 跨步是要前进到缓冲区中下一项的字节数。
  • 对齐是每个实例必须位于的“均匀可除数”数字。 如果要分配内存以将数据复制到其中,则需要指定正确的对齐方式(例如, allocate(byteCount: 100, alignment: 4) )。

对于我们大多数人而言,大多数时候,我们可能会处理诸如数组和集合之类的高级集合,而无需考虑底层的内存布局。

在其他情况下,您可以在平台上使用较低级别的API或与C代码互操作。 如果您有一个Swift结构数组,并且需要C代码来读取它(反之亦然),则需要担心分配具有正确对齐方式的缓冲区,确保结构内部的填充对齐,并确保正确的步幅值,以便您可以正确解释数据。

正如我们所看到的,即使计算大小也不像看起来那样简单-每个大小和每个属性的对齐之间存在一些相互作用,这些相互作用决定了结构的整体大小。 因此,了解所有这三个方面意味着您将成为内存大师。

}