フレームワークに頼って実装していると、そのフレームワークが内部でどの様な仕組みで並列または並行処理しているのかが理解できず、ただ使っているだけの状態になり得ます。
フレームワークの設計者からすると、プログラマがそれらを気にしなくても利用できるというのがプロジェクトのゴールでもあるので、それはそれで正しいのですが「並列処理」や「並行処理」を理解したいというモチベーションでは逆にそれが邪魔をしてしまうかもしれません。
並行処理や並列処理を学ぶのであれば、API サーバ等といった物ではなく、コード片で学び始めるのが良いと思います。
例えば Ruby で Thread と Fiber を使って簡単な処理を実装してみるのが理解を早めると思います。
※ Go でも並行処理を学ぶことはできますが、Go の goroutine はスレッドでありスレッドではない、という特性があるため、並列処理や並行処理がどういった物かを学び始めるには難しいかもしれません。
まずは Fiber を使ったコードです。
def print_chars(s)
Fiber.new do
s.each_char do |c|
Fiber.yield c
end
end
end
fiber = print_chars('あいう')
puts fiber.resume
puts fiber.resume
puts fiber.resume
この Fiber を使ったコードを実行すると「あ」「い」「う」が3つ表示されます。これのどこが並行処理なんだと思うかもしれませんが、並行処理は「実行状態を複数持つこと」なのです。
一方 Thread を使ったコードを示します。
require 'thread'
def print_chars(a)
q = Queue.new
th = Thread.start do
a.chars.each do |e|
q.push e
end
end
th.run
q
end
v = print_chars('あいう')
puts v.pop
puts v.pop
puts v.pop
同じく「あ」「い」「う」が表示されます。一見、同じ動作になる実装に見えますが、並行と並列には大きな違いがあります。これらの puts での表示処理を print_chars の中に移動してみましょう。
def print_chars(s)
Fiber.new do
s.each_char do |c|
puts c
Fiber.yield
end
end
end
fiber = print_chars('あいう')
fiber.resume
fiber.resume
fiber.resume
require 'thread'
def print_chars(a)
th = Thread.start do
a.chars.each do |e|
puts e
end
end
th
end
th = print_chars('あいう')
th.run
th.join
Fiber は、確かに実行環境が2つありますがそれを動かすためには resume を呼び出さなければなりません。一方で Thread は run を実行した後で勝手に動き出します。
例えば Fiber の方を、2つ同時に呼び出したい場合、それぞれの resume を呼び出さなければなりません。
fiber1 = print_chars('あいう')
fiber2 = print_chars('かきく')
fiber1.resume
fiber2.resume
fiber1.resume
fiber2.resume
fiber1.resume
fiber2.resume
Thread は実行してしまえば勝手に並列に実行されます。
require 'thread'
def print_chars(a)
th = Thread.start do
a.chars.each do |e|
puts e
sleep 0.1
end
end
th
end
th1 = print_chars('あいう')
th2 = print_chars('かきく')
th1.run
th2.run
th1.join
th2.join
プログラムのパフォーマンスを大きく左右するのは I/O です。ですので Fiber の方は、fiber1 の I/O に伴って fiber2 の実行がブロックされる事になります。一方で Thread は片方のスレッドが I/O で待ちになっても、もう片方のスレッドが実行されます。
結果として、Ruby の Thread は I/O が絡む実装でパフォーマンスが良くなります。
ただし Ruby の Thread は、GIL (Global Interpreter Lock) があるため、安全にスレッド内で変数を触る事ができますが、完全に排他制御されるわけではありません。
class Counter
attr_accessor :count, :tmp
def initialize
@count = 0
@tmp = 0
end
def increment
@count += 1
end
end
c = Counter.new
t1 = Thread.start { 1000000.times { c.increment; c.tmp += 1 if c.count.even?; } }
t2 = Thread.start { 1000000.times { c.increment; c.tmp += 1 if c.count.even?; } }
t1.join
t2.join
p c.count # 常に 2000000
p c.tmp # 100000 でない事もある
これを実行すると最終的な値が 1 だったり 997 だったり 1000 だったりします。
※ これを回避するには Mutex を使わなければなりません。
また Ruby の GIL は I/O が発生するとそのタイミングで競合が起きえます。
当然ながら排他処理を入れると、プログラムが遅くなってしまいます。
処理を同時に実行したいというニーズに「並列」を使うか「並行」を使うかは、要件次第という事が分かると思います。新しい Ruby では Ractor という機能も追加されているため Thread よりも気軽に並列処理を扱う事ができると思います。
r1 = Ractor.new 'あいう'.chars do |arr|
puts arr.each(&:to_s)
end
r2 = Ractor.new 'かきく'.chars do |arr|
puts arr.each(&:to_s)
end
r1.take
r2.take
※ これを実行すると混ざって表示される様になります。(タイミングによります)
ぜひこういったコード片で仕組みを学ばれるのが良いと思います。
ちなみに、Fiber の例を実行されたのであれば「じゃぁ、Fiber の resume をもっと賢く呼んでくれたらいいのに、例えば fiber1 が I/O でブロックしている間に fiber2 の resume を呼び出し続けてくれたらいいのに」と感じたかもしれません。これを上手く扱える様にしているプログラミング言語も存在します。その1つが Go です。(Go の宣伝ではありません)