最近在做一些和 NIF 有關的事情,看到 OTP 團隊發布的 17 rc1 引入了一個新的特性“臟調度器”,為的是解決 NIF 運行時間過長耗死調度器的問題。本文首先簡單介紹臟調度器機制的用法,然后簡要分析虛擬機中的實現原理,最后討論了一下臟調度器的局限性。
臟調度器機制的用法
了解 NIF 的同學都知道,在 Erlang 虛擬機的層面,NIF 調用是不會被搶占的,在執行 NIF 的時候調度器線程的控制權完全被 NIF 調用接管,因此除非 NIF 調用的代碼主動交出控制權,否則調度器線程會一直執行 NIF 調用的代碼。這實際上變成了協程式的調度,因此運行時間過長的 NIF 會影響其所在的調度器上的所有其他進程的調度。
之前對於這種長時運行 NIF 的一種解決方法是可以使用官方提供的 enif_consume_timeslice 調用,這種方法還是要讓 NIF 代碼自己在恰當的地方調用這個 api,然后根據 enif_consume_timeslice 返回的結果判斷是否需要放棄控制權,因此實際上還是協程的模式。協程式調度和搶占式調度混合在一起本來就是壞味道,如果通過判斷發現已經用完時間片,程序員必須自己手工保存斷點以及下一次恢復斷點;而且這里還要自己估計時間片,把 timeslice 和虛擬機中本來就很模糊的規約(reduction)混在一起,味道也不好聞。
那么 R17 通過引入“臟調度器”從一定程度上解決了這個問題。臟調度器本質上和普通調度器是一樣的,也是運行在虛擬機中的調度器線程,但是這種調度器專門運行長時運行的 NIF,R17 允許將長時運行的 NIF 直接丟到臟調度器上去跑。通過調用 enif_schedule_dirty_nif 將需要長時運行的 NIF 函數丟到臟調度器上。長時運行的函數返回的時候要調用 enif_schedule_dirty_nif_finalizer 函數,表示從臟調度器返回到了普通調度器。
下面看一個簡單的例子,比如下面這個簡單霸道的 NIF:
1 static ERL_NIF_TERM 2 io_work(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) 3 { 4 int i; 5 int Number; 6 enif_get_int(env, argv[0], &Number); 7 for (i = 0; i < 5; ++i) { 8 sleep(1); 9 printf("nif process number %d\n", Number); 10 } 11 return enif_make_atom(env, "ok"); 12 }
io_work 函數顯然會運行很長時間(遠長於官方文檔建議的 1ms)。
利用 R17 新引入的臟調度器,這個 NIF 可以這么寫:
1 #include "erl_nif.h" 2 #include <unistd.h> 3 #include <stdio.h> 4 5 static int 6 load(ErlNifEnv* env, void** priv, ERL_NIF_TERM load_info) 7 { 8 return 0; 9 } 10 11 static ERL_NIF_TERM 12 dirty_io_work(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) 13 { 14 int i; 15 int Number; 16 enif_get_int(env, argv[0], &Number); 17 for (i = 0; i < 5; ++i) { 18 sleep(1); 19 printf("nif process number %d\n", Number); 20 } 21 return enif_schedule_dirty_nif_finalizer(env, 22 enif_make_atom(env, "ok"), 23 enif_dirty_nif_finalizer); 24 } 25 26 static ERL_NIF_TERM call_dirty_io_work( 27 ErlNifEnv* env, 28 int argc, 29 const ERL_NIF_TERM argv[]) 30 { 31 return enif_schedule_dirty_nif(env, 32 ERL_NIF_DIRTY_JOB_IO_BOUND, 33 dirty_io_work, argc, argv); 34 } 35 36 static ErlNifFunc io_nif_funcs[] = 37 { 38 {"call_dirty_io_work", 1, call_dirty_io_work} 39 }; 40 41 ERL_NIF_INIT(io_nif, io_nif_funcs, load, NULL, NULL, NULL)
這段代碼將長時運行的工作放在 dirty_io_work 函數中,Erlang 模塊調用 call_dirty_io_work 函數,這個函數轉而調用 enif_schedule_dirty_nif 函數,將 dirty_io_work 函數傳入,call_dirty_io_work 立即返回,dirty_io_work 函數進入臟調度器等待調度執行。dirty_io_work 函數在返回的時候調用 enif_schedule_dirty_nif_finalizer 將實際的結果返回給原調用者。
enif_schedule_dirty_nif() 函數還接受一個參數 type,表示要調度的 NIF 的類型:CPU 密集型或 IO 密集型。后面可以看出,根據不同的類型,NIF 會被不同類型的臟調度器調用。
下面簡單分析一下臟調度器機制的工作原理。
工作原理淺析
OTP 團隊實現的臟調度器機制實際上很簡單,臟調度器是普通調度器之外的調度器線程。從位於 erts/emulator/beam/erl_nif.c 的 enif_schedule_dirty_nif 函數開始:
這個函數設置當前進程的標志 ERTS_PSFLG_DIRTY_CPU_PROC 或 ERTS_PSFLG_DIRTY_IO_PROC,說明當前進程應該在某種臟調度器上執行。然后一次性將當前進程的規約配額全部清零,說明這個進程很快就要讓出調度器了。最后就是設置了一些和虛擬機代碼相關的狀態,改變虛擬機的執行狀態。注意最后將 proc->freason 設置為 TRAP,之后虛擬機會利用到這個標志,雖然最后 return 了一個 THE_NON_VALUE,但是放心,最后返回到原調用者的不會是這個值。
enif_schedule_dirty_nif 函數返回之后會返回上一級調用的函數,通常就是被調用的 NIF,例如上面例子中的 call_dirty_io_work,后者返回之后會將控制權返回給虛擬機,那么返回的位置必然是虛擬機(即一個調度器線程)中處理 call_nif 指令的位置:
NIF 返回到虛擬機中的時候返回到上圖中 1 的位置,然后在使用臟調度器的時候,2 中的 if 條件會滿足,因此這里設置了一些代碼相關的東西,最后跳轉到
正常 NIF 的情況下,會滿足 if 的第一個條件:設置返回結果,設置下一條指令並跳轉執行。但是對於使用臟調度器的情況,滿足的是 else if 的條件,這里執行的就不是下一條指令了,而是當前進程中設置的 c_p->i,指令,這個指令是之前在 enif_schedule_dirty_nif 函數中設置的,實際上就是表示執行要被臟調度的那個 NIF 函數的指令。因此執行到上圖中的 3472 行的 Dispatch() 之前的時候,當前進程的狀態是:
- 進程上下文已經准備好要執行 call_nif 調用另一個 NIF 了
- 當前的規約配額已經歸零
- 設置了 ERTS_PSFLG_DIRTY_CPU_PROC 或 ERTS_PSFLG_DIRTY_IO_PROC 標志,表示進程需要在臟調度器上執行
好了,接下來就是 Dispatch() 了,分發下一條要執行的指令。在 Dispatch() 的時候,發現當前線程的規約配額用完,所以准備調度下一個進程。調度下一個進程的時候得把當前進程調度出吧,這時流程就進入了龐雜的調度函數:位於 erts/emulator/beam/erl_process.c 的 schedule() 函數。schedule() 函數調用 schedule_out_process() 函數處理的是調度出一個進程需要的操作。schedule_out_process() 函數會通過 check_enqueue_in_prio_queue() 函數判斷是否需要轉移進程所在的隊列。check_enqueue_in_prio_queue() 函數中有一個判斷:
根據進程的ERTS_PSFLG_DIRTY_CPU_PROC 或 ERTS_PSFLG_DIRTY_IO_PROC 狀態打上新的標簽:ERTS_PSFLG_DIRTY_CPU_PROC_IN_Q 或 ERTS_PSFLG_DIRTY_IO_PROC_IN_Q,表示進程應該進入 CPU 密集型的臟調度器運行隊列或 IO 密集型的臟調度器運行隊列。回到 schedule_out_process() 函數:
在 1 處,根據進程的標簽判斷要進入的隊列,ERTS_DIRTY_CPU_RUNQ 和 ERTS_DIRTY_IO_RUNQ 宏分別表示 CPU 密集型任務的隊列和 IO 密集型任務的隊列。雖然虛擬機中可以有多個 CPU 密集型臟調度器和 IO 密集型臟調度器,但是兩類隊列分別只有一個,即一類臟調度器分享同一個隊列。由於臟調度器通常運行長時間的任務,因此訪問運行隊列的頻次要低得多,所以可以只共享一個隊列。為此,ErtsRunQueue_ 數據結構中還增加了一個字段 sleepers 用於表示睡了這個隊列的調度器列表。最后,在 2 處,當前進程被加入了相應的臟運行隊列,如果臟調度器在睡覺的話,此時會被喚醒。
下面兵分兩路,一路是“當前進程”原來所在的普通調度器,另一路是某一個被喚醒的臟調度器。
在普通調度器中,之前的“當前進程”被調度出了,那么這個調度器可以像以前一樣,挑選下一個正常的進程繼續執行。后面還有一個判斷:
如果發現有需要臟調度器的進程遺留在普通調度器的隊列中的時候,則忽略這個進程。以上兩個方面保證了普通調度器能夠正常運行,不會被長時運行的 NIF 耗死。
另一路就進入臟調度器了。臟調度器實際上復用的就是普通調度器的代碼,即位於 erts/emulator/beam/beam_emu.c 中的 process_main() 函數。這一路的操作和正常調度器的操作一樣,從運行隊列中取出要執行的進程。當然就是之前 enqueue 進去的那個“當前進程”了。記得我們之前提到的這個進程的狀態:要執行一條 call_nif 指令。那么臟調度器拿到這個進程之后,首先開始分發指令就是 call_nif 指令,之后執行這條指令,執行的時候正常調用之前指定的 NIF。不論這個 NIF 運行時間有多長有多復雜,都沒關系,因為在獨立的調度器,也就是操作系統進程中運行,操作系統可以保證其他普通調度器線程被調度執行,而其他普通調度器線程也能正常運行。
臟調度器運行完長 NIF 之后,通過 enif_schedule_dirty_nif_finalizer() 函數執行上述相反的過程,把“當前進程”變成普通進程,丟回普通調度器執行。
綜上可以看出,臟調度器機制簡單地說就是提供了一個場所讓運行時間很長的進程運行,在這個環境中,進程可以自由運行,不會被搶占。
根據 github 上的 commit 記錄:
Currently only NIFs are able to access dirty scheduler
functionality. Neither drivers nor BIFs currently support dirty
schedulers. This restriction will be addressed in the future.
目前只有 NIF 能訪問臟調度器,未來會允許 BIF 和驅動也能訪問臟調度器。因此可以想象未來也許會出現不會被搶占的普通 Erlang 進程。
局限性
這個簡單的實現目前有一定的局限性。首先,目前雖然區分了 CPU 密集型計算和 I/O 密集型計算的臟調度器,但是這兩個調度器運行的代碼完全是一樣的,只是用了不同的運行隊列而已,調度策略都是一樣的。但是這反映了未來的一個優化方向。
此外,從以上對臟調度器原理的淺析,我們可以發現臟調度器有一點過去 batch 操作系統的感覺,臟調度器必須完整處理完一個任務才會處理下一個任務。因此,如果當前所有臟調度器都被占用滿了,那么新的臟任務就不能及時得到調度。下面用一個例子來演示。
這個例子的 NIF 部分就是本文最開始部分的那個 C 語言代碼。略去 NIF 調用的樁,下面是主模塊的代碼:
1 -module(io_nif_test). 2 -export([start/0, heart/0]). 3 4 start() -> 5 io:format("Starting heartbeat.~n", []), 6 {ok, _} = timer:apply_interval(500, io_nif_test, heart, []), 7 timer:sleep(500), 8 io:format("Stress dirty io schedulers~n", []), 9 NormalCount = erlang:system_info(schedulers), 10 DirtyIOCount = erlang:system_info(dirty_io_schedulers), 11 io:format("There are ~w normal schedulers and ~w dirty io schedulers~n", 12 [NormalCount, DirtyIOCount]), 13 IOProcessCount = DirtyIOCount + 1, 14 io:format("Create ~w IO NIF processes~n", [IOProcessCount]), 15 lists:foreach( 16 fun(Number) -> spawn(fun() -> io_nif:call_dirty_io_work(Number) end) end, 17 lists:seq(1, IOProcessCount) 18 ), 19 receive 20 stop_me -> ok 21 end, 22 timer:sleep(1000). 23 24 heart() -> 25 io:format("Tick~n", []).
這段代碼的主進程每半秒鍾會心跳一下,我們可以通過心跳看出調度器是不是卡死了。然后根據 io 臟調度器的數目,創建多於一個這個數目的 NIF 進程,也就是說會有一個 NIF 進程得不到及時調度。下面將普通調度器和 io 臟調度器都設置為 1 的運行結果:
erl +S 1:1 +SDio 1 -noshell -s io_nif_test start -s init stop Starting heartbeat. Stress dirty io schedulers There are 1 normal schedulers and 1 dirty io schedulers Tick Create 2 IO NIF processes Tick Tick nif process number 2 Tick Tick nif process number 2 Tick Tick nif process number 2 Tick Tick nif process number 2 Tick Tick nif process number 2 Tick Tick nif process number 1 Tick Tick nif process number 1 Tick Tick nif process number 1 Tick Tick nif process number 1 Tick Tick nif process number 1 Tick Tick Tick Tick Tick Tick Tick Tick
果然,進程 2 全部執行完之后才輪到進程 1 執行。將 io 臟調度器設置為 2:
/Users/zhengsyao/programs/ErlangInstall/otp_17rc1/bin/erl +S 1:1 +SDio 2 -noshell -s io_nif_test start -s init stop Starting heartbeat. Stress dirty io schedulers There are 1 normal schedulers and 2 dirty io schedulers Tick Create 3 IO NIF processes Tick Tick nif process number 3 nif process number 2 Tick Tick nif process number 3 nif process number 2 Tick Tick nif process number 3 nif process number 2 Tick Tick nif process number 3 nif process number 2 Tick Tick nif process number 3 nif process number 2 Tick Tick nif process number 1 Tick Tick nif process number 1 Tick Tick nif process number 1 Tick Tick nif process number 1 Tick Tick nif process number 1 Tick Tick Tick
2 個 io 臟調度器,創建了 3 個 nif 進程。進程 3 和 2 立即都得到了調度,而進程 1 則在其中一個進程運行完之后得到了調度。