Erlang虚拟机源码阅读笔录(三)虚拟机的进程调度

时间:2024-03-16 16:52:42

3. Erlang虚拟机的调度

在这一部分中我们来重点分析Erlang虚拟机的调度策略。

由第一分部的介绍可以得知,在ERTS_SMP模式中,erl_start()函数在创建好第一个进程后最后调用的两个函数分别为erts_start_schedulers()erts_sys_main_thread()。在单核模式下,erl_start()函数在调用set_main_stack_size()进行一些栈区设置后调用了process_main()函数,然后进入了单核模式下的进程切换调度。这里我们重点讨论在ERTS_SMP模式下Erlang虚拟机的调度策略。

进入erts_start_schedulers()函数的定义(该函数的定义在otp_src_R15B02/erts/emulator/beam/erl_process.c文件中)erts_start__schedulers()函数通过前面early_init()函数获取的cpu数目来为每个cpu创建一个调度线程,然后创建一个附属线程。调度线程的入口函数是sched_thread_func(),这个函数的定义也在erl_process.c文件中,该函数设置调度器初始化时使用的一系列回调函数,然后进行初始化并等待其他调度线程完成初始化,然后每个调度线程最终也会调用process_main()。而process_main()函数在整个erlang虚拟机的运行过程中会被调用两次,第一次调用是进行emulator的初始化工作,首先被init_emulator()调用,init_emulator()又被erl_init()调用,erl_init的调用关系已经在第一部分中被说明,在该次调用中process_main()在进行一些指令集所需的寄存器参数定义后跳转到init_emulator程序段中,如图3.1



Erlang虚拟机源码阅读笔录(三)虚拟机的进程调度
 
 

 

3.1

       在这部分代码中,process_main()首先初始化了emulator调用的error_handler和一些其他的BIF的入口函数,然后初始化了所有BIF的函数导出表,当这些工作都完成后,process_main()函数的第一次调用结束并return;如图3.2所示:



Erlang虚拟机源码阅读笔录(三)虚拟机的进程调度
 
 
3.2

process_main()函数的第二次调用是被sched_thread_func()调用,在这次调用中process_main()函数进行真正的调度工作,这部分的代码十分的长,大概有4000多行的代码量,因此这个函数是调度的核心,在这次调用中process_main()将进入一个死循环而永远不会返回,因此在真正的调度工作中,process_main()函数没有返回值。

process_main()函数最先调用的是schedule()函数,schedule()通过传入的进程信息c_p和进程已经执行了的reds来调度选择下一个要执行的进程(有可能还是原来的那个c_p,也可能会发生切换)

schedule()函数的定义和process_main()位于同一个文件,该函数首先通过传递的进程信息获取到对应的调度器(如果c_p = NULL,说明是进行第一次调度,schedule()通过调用erts_get_scheduler_data()从异步队列里获取一个调度器,并且获取与调度器绑定的运行队列,如果c_p != NULL,说明不是第一次调度,那么直接从进程中获取与之绑定的调度器和运行队列)。获取了调度器后,schedule()将计算每次reds的增量以及整个队列的reds增量,然后通过profile_runnable_proc()函数来分析当前的执行进程是继续执行还是需要被调度。

profile_runnable_proc()分析好进程的执行情况后会设置c_pstatus,接着进入一个swith(p->status)的程序段中。如果当前进程执行状态经过分析变成退出状态,将调用handle_pending_exit()进行进程的善后处理并退出。除此之外即为reds用完但是进程尚未退出的情况,调度器会将当前进程调用handle_pending_suspend()函数进行挂起。

接下来调度器需要选择一个新的进程用于执行,首先,schedule()函数会先调用check_balance(rq)检查该调度器绑定的runqueue是否是平衡状态,如果进程数的低于或者高于了迁移的阀值,就调用immigrate()函数进行迁移,Erlang的进程共有四个优先级,如下所示:

/* process priorities */

#define PRIORITY_MAX          0

#define PRIORITY_HIGH         1

#define PRIORITY_NORMAL       2

#define PRIORITY_LOW          3

#define ERTS_NO_PROC_PRIO_LEVELS      4

其中PRIORITY_MAXPRIORITY_HIGH各有一个优先级队列,PRIORITY_NORMALPRIORITY_LOW共用一个优先级队列,一般情况下,PRIORITY_LOW优先级进程只有在调度特定个数的PRIORITY_NORMAL后才会被调度,这种机制保证了PRIORITY_NORMAL优先级高于PRIORITY_LOW优先级被执行,但在某些情况下会引起优先级反转。

每个runqueue和一个scheduler进行绑定,每个runqueue又包含了三个优先级队列:一个PRIORITY_MAX队列,一个PRIORITY_HIGH队列,一个PRIORITY_NORMALPRIORITY_LOW共用的混合队列,在进行任务迁移的时候,immigrate()中有个for结构,分别对每种优先级队列进行任务迁移。关于时间片调度算法和Erlang任务迁移算法,这里不做详细说明,请参见论文:Characterizing the Scalability of Erlang VM on Many-core Processors。平衡运行队列中的进程数量后检查运行队列的状态,如果运行队列状态变为ERTS_QUNQ_FLG_SUSPENDED,将调用suspend_scheduler()函数将当前的调度器挂起。如果调度器未被挂起,接着检查runqueue是否有任务,如果没有任务,将调用empty_runq()清除runqueue的状态标志,然后调用try_steal_tast()函数拉取任务,如果拉取成功则调用goto continue_check_activites_to_run跳转到上一个检查点,重新检查runqueue的运行状态,然后按状态调度runqueue。如果没有拉取成功,则调用scheduler_wait()等待系统IO任务,scheduler_wait()函数将调用erl_sys_schedule()阻塞在系统IO上,其实erl_sys_schedule()unix系列平台下的定义就是一个poll模型的封装,定义如下:

void

erl_sys_schedule(int runnable)

{

#ifdef ERTS_SMP

    ERTS_CHK_IO(!runnable);

#else

    ERTS_CHK_IO(runnable ? 0 : !check_children());

#endif

    ERTS_SMP_LC_ASSERT(!erts_thr_progress_is_blocking());

    (void) check_children();

}

 

如果runqueuelen != 0,那么scheduler先调度runqueue中的PRIORITY_MAX优先队列(通常为系统任务),然后调度PRIORITY_HIGH优先级队列(通常为port任务),然后调度一般任务。当选择好一个任务进行调度后,schedule()函数返回选择的新进程的指针给process_main()

这时,process_main()函数通过schedule()计算得到了下一个需要执行的process的指针,接下来需要将进程的中断现场恢复到当前的上下文环境中,首先将进程的寄存器参数恢复到每个寄存器中,然后调用SET_I(c_p->i)这个宏定义,来设置register变量II代表的是下一条即将执行的Erlang虚拟机指令threaded-code。然后用next指针指向I寄存器中保存的threaded-code地址,经过一系列的跟踪调用后调用Goto(next)这个宏来执行下一条threaded-code

threaded-code指令段的定义在process_main()中,是以宏OpCase(OpCode)开始的程序段,经过调度的进程后调用Goto(next)将跳转到具体的threaded-code段中继续执行。这里以send原语来举例说明,当Erlang进程调用Pid ! MsgObj原语的时候,将跳转到lb_send这一threaded-code段中,在这个内建函数中首先计算进程的reds消耗(c_p->fcalls = FCALLS - 1),然后将寄存器r0的值赋给了reg[0]register变量r0此时保存的值为要发送到的进程idx(1)寄存器保存的是要发送的消息体的地址,然后将调用erl_send()。消息在进程间发送的形式和在不同node上的发送形式不同,进程间,是通过Erlang自定义的消息队列,每个进程都有一个公有的消息队列和私有消息队列,消息队列位于进程的堆空间中。当erl_send()执行结束后,lb_send接着执行PreFetch(0 next)这个宏结构,这个宏结构将I指向的地址做+1操作后赋予next指针,然后对进程的消息队列进行检查,调用erts_gc_after_bif_call对发送的消息数据进行内存回收,然后调用goto find_func_info跳转到该程序段中,在该程序段中对执行现场进行错误检查,并将handle_error()结果返回给register变量I,然后跳转到post_error_handling,在这段程序中,如果I == 0,说明执行成功,没有错误产生,将调用goto do_schedule进行跳转,至此一次完整的调度执行流程就完成了,程序将进入下一次调度。如果I != 0那说明指令执行失败,产生了错误,将调用erts_garbage_collect()对执行过程中产生的堆数据进行回收,然后调用Goto(*I)跳转到具体的错误处理函数中。

 

总结:在Erlang虚拟机的指令集(threaded-code)中提供了sendreceive原语,所以erlang语言屏蔽了网络编程开发和进程通信等很多琐碎的问题,更利于高效率的开发出分布式结构的程序。sendreceive原语的提供,为进程间消息通信机制也提供了基础支持。

Erlang虚拟机有自己的指令集系统,更方便的设计出适合分布式程序的调度算法和调度粒度。

Erlang虚拟机对系统IO的响应采用了事件驱动模式,这是一种异步模式,而不是同步模式,而Erlang语言中所涉及到的同步语法都是使用异步模式模拟的(即在用户态模式下由虚拟机进行阻塞操作,而不是由操作系统内核来进行同步调用的阻塞操作),虽然逻辑编写会变得更复杂,但这种模式对繁重的IO处理有很大的性能提升,所以Erlang不会出现某个调度线程由于调度内核函数而阻塞在内核态中不能及时切换到用户态。

Erlang虚拟机的每一条虚拟机指令(threaded-code)的执行都会进行错误检查,使erlang程序的容错性更高,也为Erlang的容错性提供了最底层的支持,同时Erlang的进程设计等方面也会考虑容错机制。