LoginSignup
9
6

More than 5 years have passed since last update.

はじめてな Elixir(26) プロセスの優先順位を調べてみる

Last updated at Posted at 2019-04-30

平成最後はElixirのプロセスの優先制御を調べてみることにします。

プロセスの優先順位とは

一般に、複数のプロセスがあって並行に動作している場合に、プロセスが CPU の割当を待っている状態になっているにもかかわらず、すべてのプロセスには CPU を割り当てられない状況が発生しえます。どのプロセスに CPU を割り当てるのかを決めるのにはいくつものアルゴリズムがあり、その中に優先順位・優先制御の概念があります。複数のプロセスが異なる優先順位を持っている場合に、優先順位の高い方に CPU を割り当てる制御が優先制御です。詳しくは OS の教科書を御覧ください。(この節 2019.05.02 に追加)

Elixir の(つまり Erlang の)プロセスには通常の OS 同様に優先順位の概念があります。公式ドキュメント Elixir Process flag を見ると flag(:priority, priority_level()) :: priority_level() という記述があって、プロセスの優先順位を変えられるような記述があります。
ただここを見てもほとんど何も書いてなくて Erlang のドキュメントを読む必要があります。公式ドキュメント Erlang process_flag-2 に飛んで、そこから呼んでいくと process_flag(Flag :: priority, Level) という記述が出てきます。ここを読むと全貌がわかります。

優先順位の種類

Erlang の優先制御はシンプルです。Erlang のプロセスには以下の4レベルの優先順位を定めることが出来ます。

  • max: 最も優先順位が高いレベル。他のどの優先順位のプロセスに対しても CPU が優先的に割り当てられます。システムが使うのでユーザはこれを指定してはいけません。
  • high: この優先順位のプロセスは :normal や :low のプロセスより CPU が優先して割当たります
  • normal: これはデフォルトの優先順位です。この優先順位のプロセスは :low のプロセスより CPU が優先して割当たります。
  • low: 最も低い優先順位です。

同じレベルの優先順位のプロセスは平等に CPU が割当たります。また max は high, normal, low に対して絶対的に優先度が高い、つまり max のプロセスが CPU を必要としているときには他のレベルの優先度のプロセスには CPU が割当たりません。high も normal, low に対して絶対的に高い優先順位です。

なお繰り返しますが、max はシステム(言語処理系)が使うので ユーザは指定しないよう にドキュメントには注意書きがあります。注意書きがあるぐらいで、指定できてしまうので要注意です。

これに対して、normal と low は相対的な優先順位の関係です。normal のプロセスが CPU を必要としている場合でも、ときどきは low のプロセスにも CPU が割当たります。

なお Elixir でプロセスの優先順位を変えるには Process.flag(:priority, prio) 関数を用います。第2引数 prio には優先順位を示す atom の :high :normal :low のどれかを指定します。返ってくる値は、指定する直前の優先レベルです。プロセスはデフォルトでは優先順位 :normal レベルで動いているので、最初にこの関数で優先順位を指定すると :normal が返ってきます。

実行して試してみる

プロセスをいくつか生成して優先順位を与えてどういう風に振る舞うかを試してみました。使った環境は MacBook Air です。

  • CPU: 1.7GHz Intel Core i7
  • メモリ: 8GB 1600MHz DDR3
  • OS: 10.13.6
$ iex
Erlang/OTP 21 [erts-10.3.4] [source] [64-bit] [smp:4:4] [ds:4:4:10] [async-threads:1] [hipe] [dtrace]

Interactive Elixir (1.8.1) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> 

コアは4個のようです。

簡単な話なのでプログラムは Agent, Task, 生 spawn とか検討したのですが、結局なれてる?GenServer使って書いてます。

prio.ex
defmodule Prio do
  @loop 10000000 #無駄にプロセスを走らせるための定数。お使いの CPU の性能に合わせて適当に決めてください。
  @behaviour GenServer

  def start_link(pname) do
    GenServer.start_link(__MODULE__, nil, name: pname)
  end

  def set_prio(pname, prio) do
    GenServer.call(pname, {:set_prio, prio})
  end

  def run_n_times(pname, n) do
    1..n |> Enum.map(fn i -> GenServer.cast(pname, {:run, pname, i}) end)
    pname
  end

  @impl GenServer
  def init(_void) do
    {:ok, []}
  end

  @impl GenServer
  def handle_call({:set_prio, prio}, _from, state) do
    old_prio = Process.flag(:priority, prio)
    {:reply, old_prio, state}
  end

  @impl GenServer
  def handle_cast({:run, pname, j}, _from) do
    dummy_loop(@loop)
    IO.puts("#{pname}: #{j}")
    {:noreply, []}
  end

  def dummy_loop(n) do dummy_loop(n, n) end
  def dummy_loop(0, n) do {:ok, n} end
  def dummy_loop(i, n) do dummy_loop(i-1, n) end

  def dispatch(pname, prio, n) do
    start_link(pname)
    set_prio(pname, prio)
    run_n_times(pname, n)
  end

  def dispatch_plist(l) do
     Enum.map(l, fn [h|[p|[n|_t]]] -> dispatch(h, p, n) end)
  end
end

dispatch(pname, prio, n) 関数で、プロセス名、優先順位、どれぐらい走らせるか、を指定します。例えば dispatch(Afo, :low, 10) と書くと、プロセス名: Afo でプロセスを作って、優先順位を :low にして 1億回ループを回します。ループはここでは1千万回を1単位としてて、1千万回回るたびに IO.puts で回ったことを知らせます。実行するとこんな感じです。

iex(1)> Prio.dispatch(Afo, :low, 10)
Afo
Elixir.Afo: 1
Elixir.Afo: 2
Elixir.Afo: 3
Elixir.Afo: 4
Elixir.Afo: 5
Elixir.Afo: 6
Elixir.Afo: 7
Elixir.Afo: 8
Elixir.Afo: 9
Elixir.Afo: 10

プロセス名は atom である必要があります。大文字で始まる名前だと勝手に Elixir. が補完されるのでこんな風に Elixir.Afo: となります。勝手に名前を補完されたくないならこんな風に使えます。

iex(2)> Prio.dispatch(:afo, :low, 10)
:afo
afo: 1  
afo: 2  
afo: 3  
afo: 4  
afo: 5  
afo: 6  
afo: 7  
afo: 8  
afo: 9  
afo: 10 

複数のプロセスを動かしてみる

試しに2つ走らせてみます。プログラムでは dispatch_plist/1 関数を使って食わせています。

prio.ex
  def test0() do
    dispatch_plist([
      [Alpha, :normal, 5],
      [Bravo, :normal, 5]
    ])
  end

これの結果が以下です。

iex(3)> Prio.test0                   
[Alpha, Bravo]
Elixir.Bravo: 1
Elixir.Alpha: 1
Elixir.Bravo: 2
Elixir.Alpha: 2
Elixir.Bravo: 3
Elixir.Alpha: 3
Elixir.Bravo: 4
Elixir.Alpha: 4
Elixir.Bravo: 5
Elixir.Alpha: 5

2つのプロセスが並行に走ってますね。ただこれ、後で書くように「並行プログラミング」を観察するには適してません。優先順位の指定とは関係なく並行に走ってしまっています。わけについてはもう少し先をどうぞ。

次に優先順位が異なる2つのプロセスを走らせてみます。

  def test1() do
    dispatch_plist([
      [Alpha, :normal, 5],
      [Bravo, :high, 5]
    ])
  end

これの結果が以下です。あれえ、なんだか平等に走ってるふうですね。

iex(1)> Prio.test1
[Alpha, Bravo]
Elixir.Alpha: 1
Elixir.Bravo: 1
Elixir.Alpha: 2
Elixir.Bravo: 2
Elixir.Alpha: 3
Elixir.Bravo: 3
Elixir.Alpha: 4
Elixir.Bravo: 4
Elixir.Alpha: 5
Elixir.Bravo: 5

これ、おそらくCPUの別々のコアに割り当てられてます。並行に走らせてるつもりですが、ありがたいことに並列にも走ってます。これでは優先順位制御の様子がわからないので、コア数を減らしましょう。

シングルコアで試す

動作するコア数を制御するには :erlang.system_flag を使います。コア数を減らすというのは Erlang のスケジューラを減らすことで実現できます。コマンドラインで以下のように打つとスケジューラの数が(すなわち使えるコア数が)1 になります。

iex(2)> :erlang.system_flag(:schedulers_online, 1)
4

この4という返り値は、これまでのスケジューラの数です。

このままだと先程のプロセスが動いたままなので、一旦 iex を抜けてやり直します。

iex(1)> :erlang.system_flag(:schedulers_online, 1)
4
iex(2)> Prio.test1
Elixir.Bravo: 1
Elixir.Bravo: 2
Elixir.Bravo: 3
Elixir.Bravo: 4
Elixir.Bravo: 5
[Alpha, Bravo]
Elixir.Alpha: 1
Elixir.Alpha: 2
Elixir.Alpha: 3
Elixir.Alpha: 4
Elixir.Alpha: 5

というふうに優先順位が高い Bravo プロセスが先に実行されているのがわかります。ちなみに dispatch_plist/1 関数は返り値として実行したプロセスのリストを返しますが、これを実行するプロセス自体が優先順位 :normal で動いています。なので、より優先順位の高い :high で動いている Bravo の実行後にプロセスリストが出力されてます。

さて再度、同じ優先順位のプロセスを複数実行してみます。こんどはシングルコアです。

iex(1)> :erlang.system_flag(:schedulers_online, 1)
4
iex(2)> Prio.test0
[Alpha, Bravo]
Elixir.Alpha: 1
Elixir.Bravo: 1
Elixir.Alpha: 2
Elixir.Bravo: 2
Elixir.Alpha: 3
Elixir.Bravo: 3
Elixir.Alpha: 4
Elixir.Bravo: 4
Elixir.Alpha: 5
Elixir.Bravo: 5

はい、シングルコアでも平等に計算資源が割当たって実行が進んでいるのがわかります。これは「並行」に実行されていますが「並列」には実行されてません。

絶対優先順位と相対優先順位を試す

Erlang のドキュメントによると、:high が :normal や :low に対して絶対的に優先順位が高いのに対して、:normal と :low は相対的な優先順位とあります。ならばこの3種類のプロセスを同時に走らせるとどうなるでしょう。

  def test8() do
    dispatch_plist([
      [Al1, :low, 1],
      [Bl1, :low, 1],
      [Cn1, :normal, 10], [Cn2, :normal, 10], [Cn3, :normal, 10], [Cn4, :normal, 10], [Cn5, :normal, 10],
      [Dn1, :normal, 10], [Dn2, :normal, 10], [Dn3, :normal, 10], [Dn4, :normal, 10], [Dn5, :normal, 10],
      [Eh1, :high, 5],
      [Fh1, :high, 5]
    ])
  end

意図的に優先順位 :low のプロセスを少なくかつループ回数を減らしてあります。これを走らせるとこうなりました。

iex(1)> :erlang.system_flag(:schedulers_online, 1)
4
iex(2)> Prio.test8
Elixir.Eh1: 1 # 優先順位 :high のプロセスが2つ走る
Elixir.Eh1: 2
Elixir.Fh1: 1
Elixir.Eh1: 3
Elixir.Fh1: 2
Elixir.Eh1: 4
Elixir.Fh1: 3
Elixir.Eh1: 5
Elixir.Fh1: 4
Elixir.Fh1: 5 # 優先順位 :high のプロセスが走り終わる
[Al1, Bl1, Cn1, Cn2, Cn3, Cn4, Cn5, Dn1, Dn2, Dn3, Dn4, Dn5, Eh1, Fh1] #dispatch_plist の返り値
Elixir.Cn1: 1 # ここから優先順位 :normal のプロセスたちが走る。実は :low のプロセスたちも走り出している。
Elixir.Cn2: 1
Elixir.Cn3: 1
Elixir.Cn4: 1
Elixir.Cn5: 1
Elixir.Dn1: 1
Elixir.Dn2: 1
Elixir.Dn3: 1
Elixir.Dn4: 1
Elixir.Dn5: 1
Elixir.Cn1: 2
############# 長いので間を省略
Elixir.Dn5: 6
Elixir.Cn1: 7
Elixir.Cn2: 7
Elixir.Cn3: 7
Elixir.Cn4: 7
Elixir.Cn5: 7
Elixir.Dn1: 7
Elixir.Dn2: 7
Elixir.Dn3: 7
Elixir.Dn4: 7
Elixir.Dn5: 7
Elixir.Al1: 1 # 優先順位 :low のプロセスが終了する
Elixir.Bl1: 1 # 優先順位 :low のプロセスが終了する
Elixir.Cn1: 8
Elixir.Cn2: 8
Elixir.Cn3: 8
Elixir.Cn4: 8
Elixir.Cn5: 8
Elixir.Dn1: 8
Elixir.Dn2: 8
Elixir.Dn3: 8
Elixir.Dn4: 8
Elixir.Dn5: 8
Elixir.Cn1: 9
Elixir.Cn2: 9
Elixir.Cn3: 9
Elixir.Cn4: 9
Elixir.Cn5: 9
Elixir.Dn1: 9
Elixir.Dn2: 9
Elixir.Dn3: 9
Elixir.Dn4: 9
Elixir.Dn5: 9
Elixir.Cn1: 10
Elixir.Cn2: 10
Elixir.Cn3: 10
Elixir.Cn4: 10
Elixir.Cn5: 10
Elixir.Dn1: 10
Elixir.Dn2: 10
Elixir.Dn3: 10
Elixir.Dn4: 10
Elixir.Dn5: 10 # すべてのプロセスが実行を終了する

優先順位 :high のプロセス Eh1, Fh1 までが他のプロセスに優先して先に実行されてます。これは :normal や :low に対して絶対的なので他のプロセスの出力が出てきません。次に優先順位 :normal と :low のプロセスが合わせて12個走ります。相対的な優先順位なので :normal のプロセスが走っていても :low のプロセス Al1, Bl1 にも CPU がたまには割当たりますので、Cn1〜DN5 までがたくさん走っている間に Al1 と Bl1 の実行が終了します。どうやらドキュメントの記述のとおりになっているようです。

注意

これだけ見てると「おお!」となりますがいくつか注意が必要です。

OSのプロセス上での優先順位は変わらない

ここでのプロセスの優先順位はあくまで Erlang VM (BEAM) 上でのプロセスの話です。この BEAM 自体は今回は MacOS 上のプロセスとして動いています。ですので同一 BEAM 内での優先制御になってます。ここで優先順位が高いプロセスを実行しても、MacOS の他のプロセスに対しての優先順位が上がるわけではありません。これ、MacOS 以外でも他の OS でも同じことです。 Elixir (Erlang) でプロセスの制御をして OS 上でも優先制御がされるようになるためには OS ごと考えないとなりません。それにはしかるべき優先制御を持っている Real Time OS を使い、かつ Erlang VM もその OS の機能を使うように組み上げる必要があります。

特に、何か時間の制約があるような IO 動作をさせたくて、それを扱う Elixir プロセスを他の OS プロセスに対しても優先させたいというような用途では、OS 自体が絶対優先順位を持つ優先制御をできないとなりません。GRiSPAtomVM、そして我らが fukuoka.ex@zacky1972 さんの ZEAM という動きもあるので、これらを楽しみにしてます。

実行例と同じように動かない場合がある

関数 dispatch_plist/1 は起動したプロセスのリストを出力します。これは普通に iex から起動すると優先順位 :normal で走るので、実行している :high のプロセスの出力には優先しないはずです。が、何度も実行しているとときどき :high のプロセスが終わる前に dispatch_plist/1 の値が出力されることがあります。これは :high のプロセスが存在しても、実行中に他の優先順位の低いプロセスに実行権限を渡す場合があるからです。
今回でいうと IO.puts がそうです。プロセスが IO.puts する場合には入出力待ちで CPU を使わないタイミングがやってきます。優先順位 :high のプロセスが IO.puts しようとして CPU が暇になると、スケジューラは(他に :high のプロセスが CPU 割当を待っていなければ)より低い優先順位のプロセスに実行権を渡します。そのときに :normal や :low のプロセスが走ることができます。

参考文献

公式ドキュメント Elixir Process flag
公式ドキュメント Erlang process_flag-2
Hastega / micro Elixir / ZEAM の実装戦略〜 Erlang VM からの円滑な移行を見据えて
Github AtomVM
AtomVM: how to run Elixir code on a 3 $ microcontroller
ElixirでIoT#3.1:ESP32やSTM32でElixirが動く!AtomVMという選択肢
GRiSP
Kickstarter GRiSP2

9
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
6