並列実行 ElixirのTaskとFlow/GenStage、bashとGNU parallel

Qiita初投稿のCayenneRyoです。
なんか間違ったこともしちゃうかもしれませんが、モヒカン族からの襲撃を歓迎します。

さて、先日[fukuoka.ex#8(福岡Elixir会)：春のElixir入学式](https://fukuokaex.connpass.com/event/85038/)に参加しました。
その中の[zackyさん](https://connpass.com/user/zacky1972/)の[プレゼン](https://zeam-vm.github.io/zeam-fukuoka.ex-20180420/#/5)を聴いて、なんか似たようなことがあったな、と思ったことを書いてみます。

今時、各言語で、並列実行の方法が準備されてるけど、うまく使わないと実行時間が早くならないよ、と言う話。
例えば、単に5秒スリープするだけの処理を10本実行する場合を考える。
普通に考えれば、ほぼ何もしない処理なので、同時並列に実行されて、5秒ちょっとで終わるのではないか、とかんがえるところだが…

# Elixirの並列実行: TaskとFlow/GenStage
Elixir歴二週間未満なので、zackyさんのプレゼンから丸パク。

### Elixir: Task
Elixirに標準で入っている`Task`を使った並列実行。
`:timer.tc`を使うと、マイクロ秒で実行時間を計測できる。

```Elixir
iex(1)> :timer.tc(fn ->
...(1)> 1..10
...(1)> |> Enum.map(fn i ->
...(1)>      Task.async(fn -> Process.sleep(5000); i end)
...(1)>    end)
...(1)> |> Enum.map(&Task.await &1)
...(1)> end)
{5011513, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]}
iex(2)>
```
すばらしい! 予想通り5秒ちょっとで実行できた!
※なぜか手元の環境では、たまにエラーになることがあるが、理由は不明。その内調べる…

### Elixir: Flow/GenStage
何も考えなくてそれなりに巧く並列実行してくれる[`Flow`](https://github.com/elixir-lang/flow)。
`mix`でプロジェクトを作って、`mix.exs`の中の`defp`に`Flow`を加えて…
とゴニョって、`iex -S mix`を実行。

```elixir
iex(1)> :timer.tc(fn ->
...(1)> 1..10
...(1)> |> Flow.from_enumerable
...(1)> |> Flow.map(fn i -> Process.sleep(5000); i end)
...(1)> |> Flow.partition
...(1)> |> Enum.to_list
...(1)> end)
{50052699, [3, 4, 2, 6, 8, 10, 1, 5, 7, 9]}
iex(2)> 
```
およ?! これって50秒かかっているのでは?
前述のzackyさんのプレゼンによれば、`Flow`は処理を適当な数の塊にしてElixirのプロセスに渡すし、並列度も調節してくれるのだが、今回の様に中身がスリープのみと言うようなケースでは、それが仇になってしまうのだとか。

それを避けるには、`Flow.from_enumerable`に適切なオプションを指定すればいいらしい。

```elixir
iex(2)> :timer.tc(fn ->
...(2)> 1..10
...(2)> |> Flow.from_enumerable(max_demand: 1, stages: 10)
...(2)> |> Flow.map(fn i -> Process.sleep(5000); i end)
...(2)> |> Flow.partition
...(2)> |> Enum.to_list
...(2)> end)
{5003564, [2, 1, 6, 5, 8, 9, 3, 4, 7, 10]}
iex(3)>
```
素晴らしい! 5秒台!!
この例では、同時に実行する`:stages`を10、バッチサイズ`:max_demand`を1にして、すべての処理が同時並列で実行されるよう調整している。

と、ここまでがzackyさんのプレゼンのパクリ。

# Bashの並列実行
で、同じことをシェルスクリプト・bashでやるとどうなるか?

## Bash: 通常のバックグラウンド実行とwait
ご存知の通り、シェルスクリプトは、コマンドの末尾に「`&`」を付けるとバックグラウンドで実行してくれる。
バックグラウンドジョブの終了を待つには、`wait`コマンドを使う。

```shell-session
$ time (for NUM in $(seq 1 10); do (sleep 5; echo $NUM)& done; wait $(jobs -p))
1
2
4
5
3
7
8
6
9
10

real    0m5.071s
user    0m0.043s
sys     0m0.114s
$ 
```
ゐぇい! 5秒台。
ちなみに、Elixirに影響されて、配管しようとしてはいけない。

```shell-session
\$ time (seq 1 10 | while read NUM; do (sleep 5; echo \$NUM)& done; wait $(jobs -p))
```
とやっても上手く行かない(実装が多い)。
これは、`jobs -p`が空を返すから。
パイプ「`|`」の後は、サブシェルで実行される。
サブシェルの中で起動されたバックグラウンドジョブは、親ジョブからは見えない。

## Bash: GNU Parallelで並列実行
[GNU Parallel](https://www.gnu.org/software/parallel/)は、並列実行を巧いことやってくれるツール。
しかし、使い方をよく考えないと、Elixirの`Flow`と同じようなことが起こる。

```shell-session
$ time (seq 1 10 | parallel "sleep 5; echo {}")
Academic tradition requires you to cite works you base your article on.
When using programs that use GNU Parallel to process data for publication
please cite:

  O. Tange (2011): GNU Parallel - The Command-Line Power Tool,
  ;login: The USENIX Magazine, February 2011:42-47.

This helps funding further development; AND IT WON'T COST YOU A CENT.
If you pay 10000 EUR you should feel free to use GNU Parallel without citing.

To silence the citation notice: run 'parallel --bibtex'.

1
2
3
4
5
6
7
8
9
10

real    0m25.623s
user    0m0.361s
sys     0m0.551s
$
```
実際に実行される様子を見てみると判るが、同時には二つづつしか実行されない。
これは、GNU Parallelがコア数などを見て調整した結果。

これを避けるには、`--jobs N`オプションを使う。

```shell-session
$ time (seq 1 10 | parallel --jobs 10 "sleep 5; echo {}")
Academic tradition requires you to cite works you base your article on.
When using programs that use GNU Parallel to process data for publication
please cite:

  O. Tange (2011): GNU Parallel - The Command-Line Power Tool,
  ;login: The USENIX Magazine, February 2011:42-47.

This helps funding further development; AND IT WON'T COST YOU A CENT.
If you pay 10000 EUR you should feel free to use GNU Parallel without citing.

To silence the citation notice: run 'parallel --bibtex'.

1
2
3
4
5
6
7
8
9
10

real    0m5.746s
user    0m0.354s
sys     0m0.661s
$
```
よし、5秒台。

# 考察
今回の例は、単に5秒間スリープすると言う、極端にCPU資源を消費しない特殊な処理だった。
なので、こんな検証に意味はない…わけでもない。
たとえば、ウェブAPIの応答待ちが処理時間の大半を占めるというようなものだと、似たような状況になる。

Elixirの`Flow`やGNU Parallelは、並列処理の実装に便利なツールだが、上の様な処理の場合には、期待通りには動かないことを知っておくべきだろう。

次は、PowerShellの場合についても検証してみたい。