【进程检查方法】

  1. Shell命令:i() 列出所有进程;i(0,36,0) 查看特定进程详细信息
  2. 程序方式:erlang:processes() 获取所有进程;erlang:process_info/1,2 获取进程信息
  3. Observer工具:图形界面查看运行系统的进程树和详细信息
  4. 究极诊断工具,BREAK menu,在 Erlang shell 中按一次 Ctrl+c(不要按两次,按两次会退出),输入 p 后回车,会冻结整个节点 - 在 BREAK 状态期间,所有进程都暂停,用处:当 shell 完全无响应时,用于紧急查看进程信息

进程的堆大小有很多是233字节的,因为这是进程启动时的默认大小

3.1.3 用观察者检查进程(感觉并不是很重点,知道即可)

观察运行时系统的一个拓展的图形接口

需要erlang编译一个WxWidgets支持,the observer and wx applications必须是在节点内可用的,

可以远程连接到生产环境的节点,这样就可以不用在生产环境的节点装这玩意了

3.2 进程只是内存

进程基本上只有四个内存块,堆、栈、消息信箱、还有进程控制块Process Control Block (the PCB)

栈:通过存储返回地址来追踪程序的运行,也用来记录函数参数,局部变量

堆:保存比参数、返回地址、局部变量更大的数据结构,比如列表、元组

消息信箱:存储其他进程发过来的消息

PCB:用来记录进程的状态

栈、堆、消息信箱都是动态分配内存的

PCB:是静态分配内存的,里面有很多字段是用来控制进程

introspection calls:内置的检查/查看功能,如:erlang:process_info/2

可以用erlang:process_display/2去看进程的栈信息,erlang:process_display(self(), backtrace).

还可以用debug版本和ETP来检查进程的堆,$ERL_TOP/bin/cerl -debug -rgdb

$ERL_TOP:一个环境变量,指向 Erlang 源码的顶层目录

cerl:"Erlang shell for debugging"

特点:

  • 启动调试版本的 Erlang
  • 支持 GDB 调试
  • 加载 ETP 宏工具

用上面的cerl进shell之后,创建一个进程,然后Ctrl+C进入debugger:就可以看到一些OS的线程,这个线程是可能运行着很多的erlang进程,这些OS线程对应的是Erlang的调度器

这个GDB调出的线程打印里面有一些对应erlang进程的数据,有命令etp-stackdump和etp-heapdump可以检查进程的栈和堆,实际试一下就知道了。

3.3 PCB(好像是最重要的一环)

In this section and the rest of the chapter we will go through the most important fields.

怎么看PCB?

方法1:简单方式 - 用 Erlang 函数(日常使用)

erlang:process_info(Pid). 里面的 heap_size、stack_size、stack_size 这些字段都是PCB的字段

方法2:深层方式 - 用 GDB 调试器(开发/诊断)

日常开发 → 用 process_info/2(简单,看得到想看的信息)

紧急调试 → 用 GDB + ETP(复杂,但看到所有 PCB 原始内容)

文本中说的"打印 PCB"就是指用 GDB 在 C 代码层面直接打印进程结构体,通常开发者不需要这样做。

只需打印进程指针指向的值,我们就可以看到PCB中的大多数字段:

print ((Process ) 0x7fff9ed6e030)

这个((Process ) 0x7fff9ed6e030)就是上面GDB里面会有一个字段 Pointer

这里打印出来其中又有一个id字段,这个字段是这个就是进程的id,例如:'id = 3470333576787',这是十进制的pid,如果转化为二进制则是'0b110010100000000000000000000000011001010011',最后的四位0011是一个标记,代表的是该数据的数据类型,0011则是pid,

我们可以用一个方法去直接检测十进制id是什么类型:show:tag_to_type(3470333576787).

介绍一下PCB中的堆栈

四个关键字段

字段含义类型
heap堆的起始地址指针
hend堆的结束地址指针
htop下一个空闲位置(堆顶)指针
stop下一个空闲位置(栈顶)指针

堆大小计算(关键!)

堆的大小单位是字,字在不同位数的操作系统下占用的字节数是不一样的

64位:1word = 8字节, 32位:1word = 4字节

heap = 堆的起始地址
hend = 堆的结束地址

htop = heap top (堆顶)
stop = stack top (栈顶)

heap_sz:堆整体大小

64位系统:hend - heap = heap_sz * 8
32位系统:hend - heap = heap_sz * 4

min_heap_size(最小堆)

  • 进程堆的最小值(默认 233 words)
  • 启动时堆大小 = min_heap_size
  • GC 不会把堆缩小于此值
  • 即使空闲,也保留 233 words 的内存

传统的堆栈设计,开辟两块内存空间:

  • 堆指针:heap_start, heap_end(2个)
  • 栈指针:stack_start, stack_end(2个)

BEAM的设计,堆栈公用一个内存空间:

  • 内存块指针:heap, hend(2个)
  • 顶部指针:htop, stop(2个)

内存增长:栈是向低的内存地址增长的,堆是向高的内存地址增长的,这样的话相当于

低内存地址---------高内存地址

栈的stop是从右向左移动,堆的htop是从左向右移动的,直到这两个指针相遇时,相当于内存满了,触发CG

为什么堆和栈共用一块内存?(BEAM 在堆栈设计这块的优势):

✓ 灵活分配:堆大了,栈自动小;栈大了,堆自动小
✓ 高效利用:不会有一个区满、另一个空闲的浪费情况

✓ 内存利用率高 - 堆和栈灵活共享一块内存

✓ 避免预测错误 - 不用提前分配堆栈大小

✓ 减少碎片化 - 一块连续内存,没有中间空隙

✓ 简化 GC 逻辑 - 只需管理一块内存,而不是两块

3.4 垃圾回收器(简要提一下,GC具体的在第12章节)

用的是分代复制式GC:每个Erlang进程有独立的垃圾回收器,采用复制和分代回收策略

分代策略(两层优化)

阶段处理范围频率
次要回收新数据(栈顶部)频繁
主要回收老堆的所有数据偶尔

初始只有新堆,成熟后才创建老堆

通过high_water标记区分新老数据

分代的机制:

  • 次要回收:堆满了 → 先尝试次要GC(只清理新数据区)
  • 主要回收:次要GC清不出足够空间 → 才触发主要GC(清理整个堆)

简单说就是:堆满是总的触发条件,但具体是次要还是主要,取决于 max_gen_gcs 计数器和清理效果。

详细的触发逻辑确实应该在第12章讲。

3.5 消息邮箱和消息传递

进程之间的通信是通过将发送方进程私有堆里面的数据拷贝到接收方进程的邮箱里面

3.5.1 并行发送消息

早期erlang单核时期是直接进程A写数据到进程B的堆里面,这样是不安全的

后面多核时期,不同的进程在不同的cpu核上运行,这样就存在了并发问题,需要上锁

如果抢不到锁的情况下如何保证并发呢?引入了一个m-buf机制

m-buf:进程内一块独立于堆栈以外的内存区域,这块区域可以安全的写入数据,不需要去获得接收方进程的锁

当发送方的数据写入到m-buf里面的时候,数据会通过消息信箱连接到接收方进程的消息队列中,然后通过GC将数据全部拷贝到进程的堆上

GC优化:mailbox是有两个列表组成的,一个是已读消息列表,一个是新消息列表,GC只处理已读消息

为什么新消息不用GC处理?

  • 新消息还在邮箱里 → 肯定会存活(不会立即被GC回收)
  • GC可以跳过它们 → 避免不必要的复制
  • 只有当消息被取出使用时,后续GC才会处理它

简单理解:m-buf像一个"中转站",让多个进程安全地并行发送消息,同时减轻GC的工作量。

3.6 无锁的消息传递

erlang19出了一个新设置,message_queue_data,可以设置成on_heap或者off_heap.

on_heap:发送方进程尝试直接获取接收方进程的锁,如果获取成功就直接写入接收方的堆里,但是这只能在接收方进程暂停的时候或者没有其他进程发送消息给接收方进程获取到锁的时候才可以。如果发送方拿不到锁的话,那么就会分配一个heap fragment(堆缓存片段,其实就是m-bufs),将数据直接拷贝过去。

off_heap:永远不会去获得锁,直接写入到上面的堆碎片空间里面。这可以减少锁竞争,但是分配heap fragment比直接写入进程已经分配好内存的堆里面代价更高(性能开销),而且还会导致内存占用变多。还有可能明明堆里面有空间,但是新的消息还是不断写入到heap fragment里面。

在on_heap分配方案中,所有消息(无论是直接分配在堆上还是在heap fragments中的消息)都将被GC复制。如果消息队列很大且许多消息未被处理因此仍然活跃,它们将被提升到老堆,进程堆的大小将增加,导致更高的内存使用。

所有消息在被复制到接收进程时都被添加到一个链表(邮箱)中。如果消息被复制到接收进程的堆上,该消息被链接到内部消息队列(或已看过的消息)中,并由GC检查。在off_heap分配方案中,新消息被放置在"外部"消息入队列中,并被GC忽略。

3.6.1消息的内存空间

到目前为止内存区域已经新增至五个了:堆栈区、老堆、PCB、MBox、m-bufs

3.6.2 进程消息推送

发送方进程会采用这些步骤:

  1. 计算msg的大小
  2. 在接收方进程分配消息的空间(跟前面说的一样,可能在堆上或者不在)
  3. 从发送方进程的堆上拷贝Msg过去
  4. 分配装填一个ErlMessage结构来包装消息
  5. Link in the ErlMessage either in the ErlMsgQueue or in the ErlMsgInQueue.(看不懂)

消息要么链接到这个队列,要么链接到那个队列

  1. 创建一个ErlMessage结构来包装消息
  2. 把这个ErlMessage插入到邮箱的两个队列之一中:

    • msg队列(on_heap):通过修改msg.last->next
    • msg_inq队列(off_heap):通过修改msg_inq.last->next
  3. 更新队列的末尾指针(last)指向新的ErlMessage

3.6.3 接收消息

Erlang支持选择性接收,这是一个重要的特性,其实就是receive里面可以匹配不同的消息

% 典型的选择性接收例子
receive
{msg1, Data} ->
handle_msg1(Data)
{msg2, Data} ->
handle_msg2(Data)
Other ->
% 不匹配的消息怎么办?
end

"后续章节会详细讲解m-bufs和GC如何处理邮箱,以及receive在BEAM中的具体实现"

3.6.4 调整消息传递

有了erlang19介绍的message_queue_data 标识,可以以一种新的方式用内存换取执行时间。

如果接收方进程过载了或者没有锁了,那最好还是用off_heap区快速的把消息放到m_buf里面

如果两个进程对锁没什么竞争,那就用on_heap直接把消息放到对方的堆内存上会更快、更低的内存占用,但是在如果接收消息太快了,进程不能及时处理那么多消息,那么消息直接复制在堆上就会开始触发cg,然后把消息放进老堆里面,而且由于未查看的消息是被认为是活跃的,所以堆会分配更多的内存

简单说:消息处理不过来 → 邮箱堆积 → GC无法清理 → 内存持续增长

如何选择最佳内存分配策略?

通过基准测试和测量决定:

第1步:系统全局测试(最简单)
命令行启动:
erl +hmqd off_heap # 测试所有进程使用off_heap

erl +hmqd on_heap # 测试所有进程使用on_heap(默认)

erl # 默认on_heap

对比观察:

  • 系统吞吐量
  • 内存使用
  • GC暂停时间
  • CPU使用率

第2步:找出性能瓶颈
识别:
哪些进程对系统性能影响最大?

  • 消息队列堆积的进程?
  • 频繁GC的进程?
  • 内存占用大的进程?

第3步:针对性优化(精细化)
只为瓶颈进程调整策略:

erlang:process_flag(message_queue_data, off_heap).

erlang:process_flag(message_queue_data, on_heap).

例子:
spawn(fun() ->
erlang:process_flag(message_queue_data, off_heap),
process_loop()
end)

策略对比参考

方面on_heapoff_heap
默认值
锁竞争可能高
GC压力可能大
内存用量较少可能多
适用场景消息处理快消息积压/高并发

总结:

  1. 先全局测试,再局部优化
  2. ERTS标志 +hmqd 控制系统默认
  3. 运行时可用 process_flag 针对单个进程调整
  4. 找到真正的瓶颈后才做优化

3.7 进程字典

进程字典是每个进程私有的键值对存储,存储在进程的堆上。

  1. 数据结构:哈希表
    桶数组:
    ┌──────┐
    │ NIL │ (空桶)
    │ {...}│ (单条目:{Key, Value})
    │[...] │ (多条目:链表)
    └──────┘
  2. 存储位置:进程堆
    所有数据都存在进程堆上

    不需要像消息传递那样复制

    零拷贝优势
  3. 访问方式
    put(key, value) % 存储
    get(key) % 读取
    erase(key) % 删除

优缺点对比

优势劣势
零拷贝GC负担大
数据存在堆上所有数据都是活跃的
快速访问大量数据会增加GC压力
实现简单大字典不如ETS表高效

内存成本

存储一对键值:
成本1:创建元组 {Key, Value}
成本2:如果桶有多个条目,创建list cons单元
成本3:可能触发GC
成本4:可能需要扩大桶数组
成本5:更新时,整个桶列表要重新分配

性能考虑

更新操作特别昂贵:
更新桶中某个键:
原来的桶列表:[{k1,v1}, {k2,v2}, {k3,v3}]
↓ 更新k2的值
新的桶列表:[{k1,v1}, {k2,新v2}, {k3,v3}] (整个列表重新分配)

原因:确保没有从旧堆指向新堆的指针(GC要求)

所以储存大数据的时候不要使用进程字典,要不就用ets,如果非要用的话,那就这样

% 为进程分配更大的初始堆
spawn_opt(
fun() -> ... end,
[{min_heap_size, 1000000}] % 预分配大堆
)

设计亮点:

  • 不需要单独管理动态增长的桶
  • 完全利用堆的分配机制
  • GC可以无缝遍历整个哈希表(像遍历普通数据结构一样)

总结:进程字典是便利性和性能的权衡 — 提供零拷贝的便利,但代价是GC要管理所有数据。适合存储少量进程本地状态,不适合大数据存储。