调度器是用来让erlang系统决定哪些代码要跑和什么时候跑的,
10.1 并发、并行、抢占式多任务(Preemptive Multitasking)
SMP:Symmetric Multi-Processing(对称多处理)
在Erlang/ERTS中的含义
SMP是Erlang虚拟机的一个特性,使其能够:
- 在多核处理器上真正地并行执行
- 使用多个OS线程,每个线程运行一个独立的调度器
- 充分利用多核CPU的计算能力
并发 vs 并行
- 并发(Concurrency):多个进程在逻辑上同时执行,从外部观察者看起来是同时进行。在单核系统中通过抢占式多任务处理实现——一个进程运行一段时间后被中断,另一个进程运行
- 并行(Parallelism):多个进程在物理上真正同时执行,仅在多核或分布式系统中可实现
10.2 ERTS中的抢占式多任务 与C代码的协作
抢占式多任务是通过C级别的协作多任务实现的
规约计数(Reduction Counting):Erlang语言、编译器和虚拟机三者共同协作,以确保Erlang进程的执行在有限的时间内让出CPU,允许下一个进程运行。这是一种用于测量和限制允许执行时间的技术。
10.3 Reductions
BEAM中的调度是一种协作式调度之上的抢占式调度。 一个进程只能在执行的特定点被中断,例如在receive或函数调用处。从这个意义上讲,调度是协作式的——进程必须执行允许被暂停的代码。Erlang代码的特性使得进程几乎不可能在不做函数调用的情况下运行很长时间。还有一些内置函数(BIF)仍然可能在不让出的情况下花费太长时间。另外,如果在实现不当的本地实现函数(NIF)中调用C代码,你可能会阻塞一个调度器很长时间。我们将何编在第16章讨论如写表现良好的NIF。
协作式调度:进程/线程主动让出CPU执行权,而不是被操作系统或虚拟机强制中断。
协作式调度之上的抢占式调度:erlang牛的地方,
对内是一种协作式的调度,有自己的规矩决定进程什么时候需要让出cpu去执行其他进程的任务;
对外看起来是抢占式调度,开发人员不需要自己手动去yield(),不需要自己去决定进程什么时候需要阻塞去让出cpu
由于唯一的循环构造是递归和列表推导式,不做函数调用就不可能无限循环。每个函数调用都被计为一个reduction;当进程的reduction限制达到时,它就被暂停。
规约(Reductions)术语来源于Erlang的Prolog血统。 在Prolog中,每个执行步骤都是一个目标化简(goal-reduction),其中每个步骤将逻辑问题化简为其构成部分,然后尝试求解每个部分。
BEAM的聪明设计: Erlang的语言特性(强制递归、频繁函数调用)天然地为规约计数提供了暂停点,使得系统既能实现看似抢占式的调度,又避免了真正抢占式调度的复杂性和性能开销。
10.3.1 你能得到多少reductions
这是定义在erl_vm.h文件里面的有一个CONTEXT_REDS字段,大约是4000左右。用完了reductions或者receive阻塞等待消息中,进程就会暂停,一个新的进程将被调度。
如果虚拟机已执行由INPUT_REDUCTIONS定义的规约数量(目前为2*CONTEXT_REDS,也在erl_vm.h中定义),或者没有就绪进程可运行,调度器将进行系统级活动。 即基本上检查IO。
reduction的双重作用:
- 进程公平性:每个进程都获得固定的4000规约
- 系统响应性:每8000规约检查一次IO,保证系统能及时响应外部事件
这确保了Erlang系统既能实现进程间的公平调度,又不会因为CPU密集型计算而忽视IO事件。
10.3.2 Reduction到底是是个啥?
目前来说reduction没有一个完全的定义,但是至少每个函数调用算一个reduction。
如果函数调用涉及到BIFs或者NIFs的话就有一些复杂了,首先用C写的函数不能中途yield,C函数无法自动暂停和恢复,所以很难支持中途让出,如果非要可重入(一个函数可以在自己还没执行完的时候被重新调用),需要保存一些执行状态和函数重新回到运行状态的时候要恢复状态,很麻烦。
核心问题: C函数如果要支持中途让出,编程复杂度很高,所以通常的做法是:函数要么完整执行不让出,要么分解成多个小函数在调用点让出。
比如说:在R16之前的Erlang版本中,BIF binary_to_term/1和term_to_binary/1是不让出的,只算作一个reduction。 这意味着对大型term调用这些函数的进程可能会使其他进程饥饿。而且这在SMP系统下也可能发生,因为进程在调度器之间是平衡的。
这就是为什么Erlang在C中编写NIF时,官方建议如果任务太长,要么:
- ✓ 分解成多个较小的NIF调用
- ✓ 或使用可让出的NIF API(保存执行状态)
当进程运行时,虚拟机在(寄存器映射的)变量FCALLS中保持要执行的剩余规约数。 reds字段记录进程已执行的总reduction数,直到被暂停。通过监控这个数字,你可以看到哪些进程做最多的工作。
用erlang:process_info/2 传入reductions作为第二个参数可以查看进程的总reductions(reds字段)你也可以在observer的process标签中看到这个数字,或在Erlang shell中使用i/0命令看到。
每次进程启动时,字段fcalls被设置为CONTEXT_REDS的值,对于进程执行的每个函数调用,fcalls减1。 当进程被暂停时,reds字段增加执行的规约数。在某些类似C的代码中,类似:p→reds += (CONTEXT_REDS - p→fcalls)。
通常一个进程会做完它分配的所有规约,此时fcalls为0,但如果进程在receive中暂停等待消息,它将有一些规约剩余。
当一个进程用完所有规约时,它将让出让另一个进程运行,它将从进程状态running转到状态runnable,如果它在receive中让出它将进入状态waiting(等待消息)。 在下一节中我们将看到进程可以处于的所有不同状态。
10.4 进程状态
PCB里面有几个进程的状态:free、runnable、waiting、running、exiting、garbing和suspended
进程的常规状态是runnable、waiting和running
free:当进程退出时,它被标记为free——你永远不应该能看到处于此状态的进程,这是一个短暂的状态,进程就系统的其余部分而言不再存在,但仍有一些清理工作要做(释放内存和其他资源)
running:进程当前在其中一个调度器中执行代码,进入receive并且消息队列中没有匹配的消息时,进程将变为waiting状态,直到消息到达或发生超时。如果进程用完所有reductions,它将变为runnable并等待调度器再次拾取它。
waiting:进程接收消息或超时将变为runnable
garbing:当进程需要进行垃圾回收时,它将进入garbing状态直到GC完成。 进行GC时,它在gcstatus字段中保存旧状态,完成后使用gcstatus将状态设置回旧状态。
suspended(有啥用呢?):仅用于调试目的。 你可以在另一个进程上调用erlang:suspend_process/2来强制它进入suspended状态。每次进程在另一个进程上调用suspend_process时,suspend count就会增加。这记录在rcount字段中。暂停进程调用erlang:resume_process/1将减少suspend count。进程在suspend状态中不会离开,除非suspend count达到零。
rstatus(恢复状态)字段用于跟踪进程暂停前的状态。 如果它是running或runnable,它将以runnable开始,如果是waiting,它将返回等待队列。如果一个suspended的waiting进程接收到超时,rstatus被设置为runnable,所以它将以runnable状态恢复。
10.5 进程队列
维护进程队列是调度器主要干的活,调度器必须处理两种进程状态:runnable和waiting。等待接收消息的进程处于waiting状态。当waiting进程接收到消息时,发送操作触发接收进程移动到runnable状态。如果receive语句有超时子句,调度器必须在超时触发时触发状态转换到runnable。
10.5.1 就绪队列(The Ready Queue)
处于runnable状态的进程被放置在调度器处理的FIFO(先进先出)队列中,称为就绪队列。 该队列由first指针和last指针实现,以及每个参与进程的PCB中的next指针。当新进程添加到队列时,跟随last指针,进程以O(1)操作添加到队列末尾。当新进程被调度时,它只是从队列的头部(first指针)弹出。
SMP系统中的队列:在具有多个调度器线程的SMP系统中,每个调度器有一个队列。
优先级队列:Erlang进程有优先级,每个调度器实际上有三个队列:一个用于最高优先级任务的队列,一个用于高优先级任务的队列,以及一个包含普通和低优先级任务的队列。
调度规则:
- ✓ 如果max队列中有进程,调度器选择这些进程执行
- ✓ 如果max队列为空但high队列有进程,调度器选择high进程
- ✓ 只有当max和high队列都为空时,调度器才从normal和low队列中选择第一个进程
优先级与调度计数:
- 普通(Normal)进程:schedule count = 1(立即执行)
- 低优先级(Low)进程:schedule count = 8(执行前要排队7次)
当从队列前面弹出进程时,其schedule count减1。如果计数达到零,进程被调度;否则它被插入到队列末尾。
10.5.2 等待、超时和时间轮(Waiting, Timeouts and the Timing Wheel)
等待状态:进程尝试receive,但是邮箱是空的或没有匹配消息,进程将让出yield并进入waiting状态。
当消息被传递到收件箱时,发送进程将检查接收者是否在waiting状态下休眠,如果是,它将唤醒进程,将其状态更改为runnable,并将其放在适当就绪队列的末尾。
超时机制:如果receive语句有超时子句,将为进程创建一个计时器,该计时器将在指定的超时时间后触发。 运行时系统对超时的唯一保证是它不会在设定时间之前触发,在进程被调度并获得执行机会之前可能需要一些时间。
时间轮(Timing Wheel):计时器由VM中的时间轮处理。 即一个时间槽数组,不断循环。在Erlang 18之前,时间轮是全局资源,如果有许多进程将计时器插入轮中,写锁可能会有一些竞争。如果使用许多计时器,请确保使用Erlang的较新版本。
Erlang18之后的时间轮:
时间轮配置:Erlang/OTP源代码 (beam/erl_time.c)、Erlang 18 Release Notes (关于timer改进)
- 默认大小(TIW_SIZE):65536个槽位(小内存系统为8192槽位)
- 当前时间:通过索引(tiw_pos)指示
- 插入公式:timer插入到 (tiw_pos+T)%TIW_SIZE 槽位
相同槽位的多个计时器: 如果多个计时器被插入同一槽位,它们通过prev和next字段链接在一起形成链表。count字段设为T/TIW_SIZE。
计时器结构体
typedef struct erl_timer {
struct erl_timer next; / 时间轮槽位或链中的下一项 */
struct erl_timer prev; / 时间轮槽位或链中的前一项 */
Uint slot; / 时间轮中的槽位 /
Uint count; / 剩余循环次数 /
int active; / 1=激活, 0=已取消 /
void (timeout)(void); / 超时时调用 /
void (cancel)(void); / 取消时调用(可能为NULL) /
void arg; / timeout/cancel函数的参数 */
} ErlTimer;
时间轮运转逻辑:
OTP高精度定时器(HLT)文档
10.6 端口(Ports)
端口:对Erlang虚拟机外部通信点的抽象。
在Erlang中,与套接字、管道和文件I/O的所有通信都通过端口进行。
端口与进程一样,是在与创建进程相同的调度器上创建的。像进程一样,端口也使用归约(reduction)来决定何时让出,它们也可以运行4000个归约。但由于端口不运行Erlang代码,没有Erlang函数调用来计算为归约,而是每个端口任务都计算为一定数量的归约。目前,每个任务使用略多于200个归约,还有相对于传输数据大小的1/1000的归约数。
端口的任务:如读取、写入,打开文件等(按上面的说法一个操作大概200个reductions或者相对于传输数据大小的1/1000的reduction)
为了执行端口任务,执行线程(threads)必须对端口进行加锁。
端口大概运行几个reduction让出端口:我猜应该是根据调度器上面运行的进程来的
- 为什么需要让出?:
- 公平性:防止一个端口独占调度器,影响其他端口和进程的执行
- 响应性:确保系统能及时响应其他任务
- 并发执行:多个端口和进程能轮流获得执行机会
10.7. 调度器循环
概念上,调度器像是erlang虚拟机里面程序运行的驱动,实际上,从C代码的结构上来看,是模拟器(beam_emu.c中的process_main)用来驱动执行的,它将调度器当作一个subroutine子程序,用来找到下一个需要运行的进程。
不过,我们将假装它是另一种方式,因为这样可以形成调度器循环的一个好的概念模型。也就是说,我们将其视为:调度器选择一个进程来执行,然后将执行权交给模拟器。
调度器循环就像这样:
- 更新reduction计数器 - 每个进程被分配一定数量的归约(执行的小单位)。当归约计数超过限制时,该进程被抢占,另一个进程被调度。
- 检查定时器 - 定时器(如receive...after)通过定时轮进行检查。如果超时已过期,相关进程被移动到就绪队列。
- 必要时检查平衡 - 系统偶尔检查某些调度器线程是否超负荷。如果是,则进行负载平衡决策。
- 必要时迁移进程和端口 - 进程和端口可以在调度器线程之间移动,以在多核环境中重新平衡CPU负载。
- 执行辅助调度器工作 - 这包括处理脏NIF、进程退出、跟踪钩子和其他延迟的运行时维护任务。
- 必要时检查IO并更新时间 - 轮询文件描述符或端口的I/O就绪情况,可能会更新系统时间或墙钟。
- 在需要时选择端口任务执行 - 在调度进程之前,先执行端口任务,如TCP读写或调用驱动程序回调。
- 选择一个进程执行 - 从运行队列中选择一个可运行的进程(考虑优先级和公平性),并将其交给模拟器执行。
10.9 负载均衡
目标:使用尽可能少的调度器,同时避免任何CPU过载。这样做是为了通过进程共享同一CPU来获得更好的内存局部性,从而提高性能。
在哪执行:负载均衡是在调度器线程之间进行的,而不一定是在CPU或核心之间
BEAM假设每个核心上运行一个调度器。如果改变调度器与核心的绑定方式,或操作系统分配不当,负载均衡可能反而会降低性能
主要有两个技术:Task Stealing和Migration
10.9.1 任务窃取 (Task Stealing)
工作流程:
- 当调度器的运行队列为空时,它会尝试从其他调度器窃取任务
- 先对自己加锁,防止其他调度器来窃取
- 首先检查是否有不活跃的调度器有可窃取的任务
- 如果没有,则查看活跃调度器(从较高ID的开始),寻找可窃取的任务
- 每次只看一个调度器,尝试窃取该调度器的最高优先级任务
特点:
- 速度快,调度器每次迭代都可以执行
- 将任务向较低编号的调度器移动
- 确保进程分散在所有活跃调度器上
10.9.2 迁移 (Migration)
目标:将负载压缩到尽可能少的调度器,同时确保没有调度器过载
实现机制:
- 通过erl_process.c中的check_balance函数完成
- 每经过2000*CONTEXT_REDS次reductions,调度器会根据所有调度器的工作负载计算一个迁移计划
迁移路径的三种值:
- cleared - 清空状态
- migrate to scheduler # - 迁移到指定调度器
- immigrate from scheduler # - 从指定调度器接收进程
运作流程:
- 进程准备就绪时(如收到消息或触发超时),通常被调度回最后运行的调度器(S1)
- 如果S1的迁移路径被清空,进程就保留在S1
- 如果S1的路径设置为"迁移到S2",且两个调度器运行队列不平衡,进程会被转移到S2
平衡计算方法:
- 比较每个调度器的最大运行队列长度
- 计算平均最大队列长度(AMQL)
- 队列长度大于平均值的调度器标记为迁移源
- 队列长度小于平均值的调度器标记为迁移目标
特殊情况:
- 调度器离线时,设置撤空标志确保没有新进程被调度到离线调度器
- 检测到某个优先级没有进展时(如高优先级进程独占CPU),也会设置撤空标志,强制迁移
总结
Erlang的负载均衡是一个两层策略系统:任务窃取用于快速应对不平衡,迁移用于长期优化资源分配。这种设计确保了系统既能高效利用多核,又不会过度优化而造成反效果。
补充:
📝 BEAM 调度底层进阶:JIT 与脏调度 (OTP 24+)#### 1. JIT 时代的 Reduction 检查* 实现机制:JIT 编译器在每个 Native 函数的开头插入汇编指令检查 fcalls 寄存器。
- 高效跳转:通过"Out-of-line code"处理进程挂起。只有当 `fcalls
- Dirty I/O:处理阻塞挂起型 NIF(如同步 IO)。特点:无 CPU 亲和性,允许大量线程并存。
- 触发阈值:官方建议如果一个 NIF 执行时间超过 1毫秒,必须移交给脏调度器,否则会造成调度器阻塞(Scheduler Collapse)。#### 3. NIF 让出机制 (Trapping)* enif_consume_timeslice:在 C 代码内部手动扣除进度,告知 VM 消耗了多少百分比的 Reductions。
- enif_schedule_nif:现代化的 NIF 分片执行方案。VM 会自动处理上下文切换,无需开发者手动在 C 里维护复杂的重入状态机。