Nim 内存模型

简介

这是一个小教程,解释 Nim 如何在内存中存储数据。解释了 Nim 程序员应该知道的要点,让你深入了解 Nim 组织字符串和 seq 等数据结构的方式。

注:本文在小屏幕上显示不理想,部分表格可能无法展示作者意图。

出于最实践的目的,Nim 将负责程序的所有内存管理,您不必提供详细细节。只要你使用语言的安全部分,你就很少需要处理内存地址,或者进行显式的内存分配。然而,当您希望 Nim 代码与外部 C 代码或 C 库互操作时,这种情况会发生变化——在这种情况下,您可能需要知道 Nim 对象存储在内存中的位置和方式,以便将其传递给 C ,或者您需要知道如何访问 C 分配的数据,使其可由 Nim 访问。

本文档的第一部分对于具有 C 或 C++ 背景的读者来说很熟悉,因为它的很多部分并非 Nim 语言独有。相比之下,对于来自动态语言(如Python或Javascript)的程序员来说,有些东西可能是新的,因为在这些语言中,内存处理更加抽象。

注意:本文档的大部分(如果不是全部)适用于 C 和 C++ 代码生成器(Nim后端),因为 Javascript 后端不使用原始内存,而是依赖于 Javascript 对象。

计算机内存基础知识

本节简要而抽象地介绍了计算机内存,以及从 CPU 和计算机程序的角度来看它是什么样子的(警告:前面要做大量的简化!)。

单字节大小

计算机的主存储器(RAM)由许多存储单元组成,每个存储单元都有一个唯一的地址。根据CPU 架构,每个存储器位置的大小(字大小"word size")通常在一个字节(8位)到八个字节(64位)之间变化,而 CPU 通常也能够访问较大的字作为较小的块。有些体系结构可以从任意地址读取和写入内存,而其他体系结构只能在多个字大小的地址访问内存。

CPU 使用专用指令访问存储器,根据字大小,指令让 CPU 从内存地址读写数据。例如,它可以将 32 位数值 0x12345 存入地址 0x100000 。执行此操作的底层汇编指令可能如下所示:

mov [0x100000], 0x12345

这是上述指令完成后地址 0x100000 上的内存的样子,每一列表示一个字节:

            00   01   02   03   04
          +----+----+----+----+----
0x100000  | 00 | 01 | 23 | 45 | ..
          +----+----+----+----+----

字节顺序

更为复杂的是,一个字中字节的实际顺序因 CPU 类型而异——一些 CPU 将最高有效字节放在第一位,而另一些 CPU 将最低有效字节放第一位。这称为CPU的大小端,用 endianess 标识。

  • 现在大多数 CPU( Intel 兼容、x86、amd64、大多数 ARM 系列)都是小端的。整数 0x1234 首先存储 最低 有效字节:

      00   01
    +----+----+
    | 34 | 12 |
    +----+----+
  • 其他一些 CPU 如 Freescale 或 OpenRISC 是大端的。整数 0x1234 首先存储 最高 有效字节。大多数网络协议在将数据发送到网络时以大端顺序串行化数据;这就是为什么大端也称为 network endian

      00   01
    +----+----+
    | 12 | 34 |
    +----+----+

最重要的是:如果您想编写可移植的代码,在将二进制数据写入磁盘或通过网络写入时,不要对机器的端序做任何假设,应该将数据显式转换为正确的端序。

组织内存的两种方式:栈和堆

传统上,C 程序使用两种常用的方法来组织计算机内存中的对象: stackheap 。这两种方法都有不同的目的和特点。Nim 代码被编译成C或C++代码,因此 Nim 自然共享这些语言的内存模型。

栈(Stack)

stack 译为堆栈,为防止歧义,称为栈。栈是内存的一个区域,数据总是从一端添加和删除,即 “后进先出”(LIFO)。

栈的原理

这就好比是餐厅厨房里的一堆盘子:新盘子从洗碗机中取出,放在上面;当需要盘子时,它们也从顶部取出。盘子永远不会插在中间或底部,盘子也永远不会从堆叠的中间或底部取出。

由于历史原因,计算机栈通常是自上而下的:新数据被添加到栈底部或从栈底移除,但这不会改变出入栈的机制。

+--------------+ <-- 栈顶
|              |
|   已使用      |
|              |
|              |
+--------------+ <-- 栈指针
|              |
|              | | 新的数据 v 添加到底部
:    未用       :

栈的管理非常简单:程序只需要跟踪一个指向当前栈底部的地址 —— 这通常称为 stack pointer 。当数据被添加到栈中时,它会被复制到位,栈指针也会减少。当数据从栈中删除时,它将被复制出来,栈指针将再次增加。

实际中的栈

在 Nim、 C 和大多数其他编译语言中,栈用于两个不同的目的:

  • 首先,它被用作存储临时局部变量的地方。这些变量只存在于函数中,只要该函数处于活动状态(即未返回)。

  • 编译器还使用栈进行不同类型的记录:每次调用函数时,call 指令后的下一条指令的地址都会被放在栈上,这就是 return address 。当函数返回时,它在栈上找到该地址,并跳转到该地址。

上述两种机制的数据组合构成了一个栈帧 stack frame :这是栈的一部分,其中包含当前活动函数的返回地址及其所有本地变量。

在程序执行期间,如果您的程序嵌套了两个函数,栈将是这样的:

+----------------+ <-- 栈顶
| 返回地址        |
| 内部变量        | <-- 栈帧 #1
| 内部变量        |
| ...            |
+----------------+
| 返回地址        |
| 内部变量        | <-- 栈帧 #2
| ...           |
+----------------+ <-- 栈指针
|     未用      |
:                :

将栈用于数据和返回地址是一个非常巧妙的技巧,并且给程序带来了个好功能:可以给数据提供自动的内存分配和清理。

栈也可以很好地与线程一起工作:每个线程都有自己的栈,存储自己的局部变量并保存自己的栈帧。

现在,您知道 Nim 在遇到运行时错误或异常时,生成 stacktrace 的栈跟踪,从何处获取信息:它将找到栈上最内部活动函数的地址,并打印其名称。然后,它在栈上进一步查找下一级活动函数,一直找到顶部。

堆(Heap)

在栈旁边,堆是计算机中存储数据的另一个位置,虽然栈通常用于保存本地变量,但堆可以用于更动态的存储。

堆的原理

堆是一个有点像仓库的内存区域。内存区域称为堆区 arena

:              : ^堆可以在顶部增长
|              | |
|              |
|   未分配!   |<---堆区域
|              |
|              |
+--------------+

当程序想要存储数据时,它将首先计算它需要多少存储空间。然后,它将转到仓库管理员(内存分配器)并请求存储数据的位置。管理员有一个分类账本,它可以跟踪仓库中的所有分配情况,并且可以找到一个足够大的空闲位置来存放数据。然后,它将在分类账中输入该地址和大小的区域,并将地址返回给程序。程序现在就可以在内存中任意存储和检索该区域的数据。

:              :
|    未分配     |
|              |
+--------------+
|  已分配       | <--- 分配的地址
+--------------+

可以重复上述过程,在堆上分配其他大小不同的块:

:              :
|    未分配     |
+--------------+
|              |
| 已分配 #3     |
|              |
+--------------+
| 已分配 #2     |
+--------------+
| 已分配 #1     |
+--------------+

当数据块不再使用时,程序将告诉内存分配器块的地址。分配器在分类账中查找地址,并删除条目。此块就可以释放,供将来使用。这是释放块 #2 时的上图:

:              :
|    未分配     |
+--------------+
|              |
| 已分配 #3     |
|              |
+--------------+
| 未分配        | <-- 堆里有个洞!
+--------------+
| 已分配 #1     |
+--------------+

如您所看到的,释放块 #2 会在堆中留下一个洞,这可能会导致未来的问题。有下一个分配请求时:

  • 如果下一个分配比洞小,分配器可以重用洞中的空闲空间;如果新的请求较小,在新的区块之后就会留下一个较小的新洞

  • 如果下一个分配比洞大,分配器必须在某处找到一个更大的空闲点。洞就会继续存在。

有效重复使用洞的唯一方法是,下一次分配的大小与洞完全相同。

大量使用具有很多不同大小对象的堆,可能会导致一种称为 fragmentation 的现象。这意味着分配器不能有效地使用 100% 的内存来满足分配请求,浪费了部分可用内存。

实际中的堆

在 Nim 中,所有数据都存储在栈中,除非您明确请求它进入堆: new() 过程通常用于在堆上,为新对象分配内存:

type Thing = object
  a: int

var t = new Thing

上面的代码片段将在堆上分配内存,以存储类型为 Thing 的对象。新分配的内存块的地址 addressnew 返回,为 ref Thing 类型。 ref 是一种特殊的指针,通常由 Nim 为您管理。有关这一点的更多信息,请参阅 [跟踪引用和垃圾收集器] 一节。

Nim 内存组织

只要你坚持使用语言的 安全 safe 部分,Nim 就会为你管理内存的分配。它将确保您的数据存储在适当的位置,并在您不需要时释放。但是,如果需要, Nim 也可以让您自己完全控制,允许您选择存储数据的方式和位置。

Nim 提供了一些方便的功能,允许您检查数据在内存中的组织方式。这些将在以下各节的示例中使用,以检查 Nim 存储数据的方式和位置:

addr(x)

此过程返回变量 x 的地址。对于变量类型 T ,其地址将具有类型 ptr T

unsafeAddr(x)

这个过程基本上与 addr(x) 相同,假设 Nim 认为获取对象地址不安全,也可以使用它,稍后将详细介绍。

sizeof(x)

返回变量 x 的字节大小。

typeof(x)

返回变量 x 类型的字符串表示。

在类型 T 对象上使用 addr(x)unsafeAddr(x) ,返回类型为 ptr T。 Nim 不知道默认如何打印,因此使用 repr() 格式化类型:

var a: int
echo a.addr.repr
# ptr 0x56274ece0c60 --> 0

使用指针

基本上,指针是一种特殊类型的变量,它持有一个内存地址——它指向内存中的其他东西。如上所述, Nim 中有两种类型的指针:

  • ptr T 用于 未跟踪的引用 ,也称为 指针

  • ref T 用于 跟踪的引用 ,用于 Nim 管理的内存

    `ptr T` 指针类型被视为 _不安全的_ 。指针指向手动分配的对象或内存中其他位置的对象,作为程序员,您的任务就是确保指针始终指向有效数据。

当您想要访问指针指向内存中的数据(即具有该数字索引的地址的内容)时,需要对指针进行 取引用(或简而言之,deref)地址的数据。。

在 Nim 中,可以使用空数组下标 [] 来实现这一点,类似于在C中使用 * 前缀运算符。下面的代码片段显示了如何为 int 创建别名并更改其值。

var a = 20       (1)
var p = a.addr   (2)
p[] = 30 (3)
echo a  # --> 30
  1. 这里声明一个变量 a ,初始化为 20 。

  2. p 是类型为 ptr int 的指针,指向 int a 的地址。

  3. [] 运算符用于取指针 p 的引用。由于 pptr int 类型的指针,指向 a 的内存地址,因此取引用的变量 p[] 也是 int 类型的。变量 ap[] 现在指的是相同的内存位置,因此为 p[] 赋值也会更改 a 值。

对于对象或元组的访问,Nim 将自动执行取引用: . 运算符与普通对象一样使用访问引用的元素。

栈里的局部变量

局部变量(也称为 自动 变量)是 Nim 存储变量和数据的默认方法。

Nim 为栈上的变量保留空间,只要它在作用域内,它就会一直保留在那里。实际上,这意味着只要声明变量的函数不返回,变量就会存在。函数一返回栈就 展开 ,变量就消失了。

下面是一些存储在栈上的变量示例:

type Thing = object
  a, b: int

var a: int
var b = 14
var c: Thing
var d = Thing(a: 5, b: 18)

跟踪引用和垃圾收集

在前面的部分中,我们看到 addr() 返回的 Nim 中的指针类型为 ptr T,但我们看到 new 返回的是 ref T

虽然 ptrref都是指向数据的指针,但两者之间有一个重要区别:

  • ptr T 只是一个指针,一个保存着指向数据的地址变量。作为程序员,您有责任确保在使用该指针时该指针引用的是有效内存。

  • ref T 是一个跟踪引用:这也是一个指向其他对象的地址,但 Nim 会为您跟踪它指向的数据,并确保在不需要时将其释放。

获取 ref T 指针的唯一方法是使用 new() 过程分配内存。Nim 将为您保留内存,并开始跟踪代码中引用数据的位置。当 Nim 运行时发现数据不再被引用时,知道丢弃它是安全的时,会自动释放它。这称为 垃圾收集 ,简称 GC

Nim 如何在内存中存储数据

本节将进行一些实验,看看 Nim 如何在内存中存储各种数据类型。

基本类型

基本标量 类型是 "单个" 值,如 intboolfloat 。标量通常保存在栈中,除非它们是容器类型(如对象)的一部分。

看看 Nim 是如何为基本类型管理内存的。下面的代码片段首先创建了一个类型为int 的变量 a ,并打印该变量及其大小。然后,它将创建类型为 ptr int 的第二个变量 b,称为 指针,保存变量 a地址

var a = 9
echo a.repr
echo sizeof(a)

var b = a.addr
echo b.repr
echo sizeof(b)

在我的计算机上回得到下面的输出

9  (1)
8  (2)
ptr 0x300000 --> 9 (3)
8  (4)
  1. 这里并不奇怪:这是变量 a 的值

  2. 这是变量的大小,以字节为单位。8 字节等于 64 位,这恰好是我机器上 Nim 中 int 类型的默认大小。到现在为止,一直都还不错。

  3. 此行显示变量 b , 表示 b 保存变量 a 的地址,该变量恰好位于地址 0x300000 。在 Nim 中,地址称为参考 ref 或指针 pointer

  4. b 本身也是一个变量,它不是 ptr int 类型。在我的机器上,内存地址的大小也为64位,相当于8字节。

以上内容可由下图表示:

           +---------------------------------------+
0x??????:  | 00 | 00 | 00 | 00 | 30 | 00 | 00 | 00 | b: ptr int =
           +---------------------------------------+    0x300000
                               |
                               |
                               v
           +---------------------------------------+
0x300000:  | 00 | 00 | 00 | 00 | 00 | 00 | 00 | 09 | a: int = 9
           +---------------------------------------+

复合类型:对象 object

让我们在栈上放置一个更复杂的对象,看看会发生什么:

type Thing = object (1)
  a: uint32
  b: uint8
  c: uint16

var t: Thing  (2)

echo "size t.a ", t.a.sizeof
echo "size t.b ", t.b.sizeof
echo "size t.c ", t.c.sizeof
echo "size t   ", t.sizeof  (3)

echo "addr t.a ", t.a.addr.repr
echo "addr t.b ", t.b.addr.repr
echo "addr t.c ", t.c.addr.repr
echo "addr t   ", t.addr.repr  (4)
  1. 对象类型 Thing 的定义,它包含几种大小的整数

  2. 创建 Thing 类型的变量 t

  3. 打印 t 及其所有字段的大小,

  4. 打印 t 及其所有字段的地址。

在 Nim 中,对象是将变量分组到一个容器中的一种方式,确保它们在内存中以与 C 相同的方式相邻放置。

在我机器上的输出:

size t.a 4  (1)
size t.b 1
size t.c 2
size t   8  (2)
addr t   ptr 0x300000 --> [a = 0, b = 0, c = 0]  (3)
addr t.a ptr 0x300000 --> 0  (4)
addr t.b ptr 0x300004 --> 0
addr t.c ptr 0x300006 --> 0  (5)

来看看输出:

  1. 首先是对象字段的大小 a 被声明为 4 字节大的 uint32b是 1字节的 uint8 `,`c 是 2 字节大的 uint16 。检查一下。

  2. 这里有一点令人惊讶:打印对象 t 的大小,它有8个字节大。但这并不能简单相加,因为对象的内容只有 4+1+2=7 字节!下面将详细介绍。

  3. 让我们获取对象 t 的地址:在我的机器上,它被放置在栈的地址 0x300000 上。

  4. 这里我们可以看到字段 t.a 与对象本身在内存中的位置完全相同: 0x300000t.b 的地址是 0x300004 ,它在 t.a 之后4个字节。这是有意义的,因为 t.a 有4个字节大。

  5. t.c 的地址是 0x300006 ,它是 t.b 之后的 2(!) 字节,但 t.b 只有一个字节大啊?

因此,让我们来描绘一下我们从上面学到的东西:

              00   01   02   03   04   05   06   07
            +-------------------+----+----+---------+
 0x300000:  | a                 | b  | ?? | c       |
            +-------------------+----+----+---------+
            ^                   ^         ^
            |                   |         |
         t 和 t.a 地址          t.b addr  t.c addr

这就是我们的 Thing 对象在内存中的样子。那么标记为 ?? 的洞是怎么回事,为什么总大小不是7而是8字节?

这是由编译器做 对齐 的事情引起的,它使CPU更容易访问内存中的数据。通过确保对象在内存中以其大小的倍数(或体系结构单个字大小的倍数,单个字即8,16,32,64bit)对齐,CPU可以更有效地访问内存。这通常会导致更快的代码,代价是浪费一些内存。

(您可以指示 Nim 编译器不要进行对齐,而是使用 {.packed.} 编译指示将对象的字段紧挨着放在内存中,可参阅链接:https://nim-lang.github.io/Nim/manual.html#[尼姆语言手册]中详细信息)

字符串 string 和序列 seq

以上章节描述了 Nim 如何管理内存中相对简单的静态对象。本节将讨论作为 Nim 语言实现的更复杂部分,动态数据类型:stringseq

在 Nim 中, stringseq 数据类型密切相关。这些基本上都是一组相同类型的对象(字符串为字符,seq为任何其他类型)。这些类型的不同之处在于它们可以在内存中动态增长或收缩。

先讲讲 seqs

创建一个 seq 包含一些对象试验一下::

var a = @[ 30, 40, 50 ]

再打印出 a 的对象类型:

var a = @[ 30, 40, 50 ]
echo typeof(a)   # -> seq[int]

我们看到打印出了 seq[int], 正是我们期望的。

现在,我们看看在 Nim 中,seq 是如何存储数据的:

var a = @[ 0x30, 0x40, 0x50 ]
echo a.repr
echo a.len
echo a[0].addr.repr
echo a[1].addr.repr

我的机器输出为:

ptr 0x300000 --> 0x900000@[0x30, 0x40, 0x50]  (1)
3 (2)
ptr 0x900010 --> 0x30  (3)
ptr 0x900018 --> 0x40  (4)

这能推断出什么?

  1. 变量 a 本身被放置在栈上,恰好位于我的计算机上的地址 0x300000 。 A是指向堆上地址 0x900000 的某种指针!这就是真正的seq 存的地方。

  2. 这个 seq 包含 3 个元素,正如它应该包含的那样。

  3. a[0] 是 seq 的第一个元素。其值为 0x30 ,i 存储在地址0x900010,该地址正好在 seq 本身之后。

  4. seq 中的第二项是 a[1] ,位于地址 0x900018。这是非常合理的,因为 int 的大小是 8 字节,seq 中的所有 int 都紧挨着放在内存中。

让我们再画个图。我们知道 a 是栈上的一个指针,它指的是堆上大小为 16 字节的东西,后跟 seq 的元素:

             栈
           +---------------------------------------+
0x300000   | 00 | 00 | 00 | 00 | 90 | 00 | 00 | 00 | a: seq[int]
           +---------------------------------------+
                            |
             堆              v
           +---------------------------------------+
0x900000   | ?? | ?? | ?? | ?? | ?? | ?? | ?? | ?? |
           +---------------------------------------+
0x900008   | ?? | ?? | ?? | ?? | ?? | ?? | ?? | ?? |
           +---------------------------------------+
0x900010   | 00 | 00 | 00 | 00 | 00 | 00 | 00 | 30 | a[0] = 0x30
           +---------------------------------------+
0x900018   | 00 | 00 | 00 | 00 | 00 | 00 | 00 | 40 | a[1] = 0x40
           +---------------------------------------+
0x900020   | 00 | 00 | 00 | 00 | 00 | 00 | 00 | 50 | a[2] = 0x50
           +---------------------------------------+

这几乎解释了 seq 所有部分,除了块开头的 16 个未知字节之外:这个区域是 Nim 存储 seq 内部信息的地方。

此数据通常对用户隐藏,但您可以在 Nim 系统库中找到 seq 标头 的实现,如下所示:

type TGenericSeq = object
  len: int  (1)
  reserved: int (2)
  1. Nim 使用 len 字段来保存 seq 的当前长度,即 seq 中的元素数。

  2. reserved 字段用于跟踪 seq 中存储的实际大小,出于性能原因,Nim 可能会提前预留更大的空间,以避免在需要添加新项目时调整 seq 的大小。

让我们做一个小实验来检查 seq 标头中的内容(有不安全的代码!):

type TGenericSeq = object (1)
  len, reserved: int

var a = @[10, 20, 30]
var b = cast[ptr TGenericSeq](a) (2)
echo b.repr
  1. 原始的 TGenericSeq 对象未从系统库导出,因此此处定义了相同的对象

  2. 这里,变量 a 被强制转换为 TGenericSeq 类型。

当我们使用 echo b.repr 打印结果时,输出如下所示:

ptr 0x900000 --> [len = 3, reserved = 3]

我们的 seq 大小为 3,总共为 3 个元素预留了空间。下一节将解释在 seq 中添加更多字段时会发生什么。

增长序列 seq

下面的代码段以相同的 seq 开头,然后添加新元素。每次迭代都将打印 seq 标头:

type TGenericSeq = object
  len, reserved: int

var a = @[10, 20, 30]

for i in 0..4:
  echo cast[ptr TGenericSeq](a).repr
  a.add i

这是输出,你是否能发现有趣的位:

ptr 0x900000 --> [len = 3, reserved = 3] (1)
ptr 0x900070 --> [len = 4, reserved = 6] (2)
ptr 0x900070 --> [len = 5, reserved = 6] (3)
ptr 0x900070 --> [len = 6, reserved = 6]
ptr 0x9000d0 --> [len = 7, reserved = 12] (4)
  1. 这是原始的 3 元素 seq :它存储在堆中的地址 0x900000,长度为 3 个元素,并且还保留了 3 个元素的存储空间

  2. 添加了一个元素,发生了一些值得注意的事情:

  3. 当添加上面的第 4 个元素时, Nim 调整了 seq 存储的大小,以容纳 6 个元素——这允许再添加两个元素,而不必进行更大的分配。现在 seq 中有 6 个元素,总共保留了 6 个元素的大小。

  4. 在这里,同样的情况再次发生:区块不够大,无法容纳第 7 项,因此整个 seq 被移动到另一个地方,分配被放大以容纳 12 个元素。

结论

这篇文章只简单的介绍 Nim 如何处理内存,还有很多事情要讲。以下是一些我认为也值得的主题,但我还没来写:

  • 更详细地讨论了垃圾收集,以及 Nim 可用的 GC 策略。

  • 在没有垃圾收集器/内存不足的嵌入式系统的情况下使用 Nim。

  • 新的尼姆运行时!

  • 闭包、迭代器、异步(closures/iterator/async)中的内存使用情况:局部变量不在栈中的情况。

  • FFI:C 和 Nim 之间传递数据的讨论和示例。

这是一份还在修改的文件,非常感谢您的任何意见。来源在github上找到https://github.com/zevv/nim-memory

results matching ""

    No results matching ""