フレームワークに頼って実装していると、そのフレームワークが内部でどの様な仕組みで並列または並行処理しているのかが理解できず、ただ使っているだけの状態になり得ます。

フレームワークの設計者からすると、プログラマがそれらを気にしなくても利用できるというのがプロジェクトのゴールでもあるので、それはそれで正しいのですが「並列処理」や「並行処理」を理解したいというモチベーションでは逆にそれが邪魔をしてしまうかもしれません。

並行処理や並列処理を学ぶのであれば、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 の宣伝ではありません)

2023/12/05投稿
Loading...
匿名で mattn さんにメッセージを送ろう
0 / 20000

利用規約プライバシーポリシーに同意の上ご利用ください

Loading...