Fiber 클래스

Fiber 클래스 (Fiber)

Fiber는 Ruby에서 가벼운 협력형(cooperative) 동시성을 구현하는 원시 요소(primitive)예요. 기본적으로 스레드와 비슷하게 일시 중지(pause)했다가 다시 재개(resume)할 수 있는 코드 블록을 만드는 수단이죠. 가장 큰 차이는 절대 선점(preempt)되지 않고, 스케줄링을 VM이 아니라 프로그래머가 직접 해야 한다는 점이에요.

다른 스택리스(stackless) 경량 동시성 모델과 달리, 각 파이버는 스택을 가지고 있어요. 그래서 파이버 블록 안의 깊게 중첩된 함수 호출 중에도 파이버를 일시 중지할 수 있죠. 파이버 스택 크기 설정은 ruby(1) man 페이지를 참고하세요.

파이버를 만들 때는 자동으로 실행되지 않아요. Fiber#resume으로 명시적으로 실행해야 하죠. 파이버 안에서 실행되는 코드는 Fiber.yield를 호출해 제어권을 넘길 수 있어요. 그러면 원래 Fiber#resume을 호출한 쪽으로 제어권이 돌아가죠. 일시 중지(pending)되거나 종료될 때 파이버는 마지막으로 실행된 표현식의 값을 돌려줘요.

fiber = Fiber.new do
  Fiber.yield 1
  2
end
puts fiber.resume
puts fiber.resume
puts fiber.resume

이 코드는 이렇게 출력해요.

1
2
FiberError: dead fiber called

Fiber#resume은 임의의 개수 인자를 받아요. 첫 resume 호출이면 그 인자들이 블록 인자로 전달되고, 그 외에는 Fiber.yield 호출의 반환값이 돼요.

fiber = Fiber.new do |first|
  second = Fiber.yield first + 2
end
puts fiber.resume 10
puts fiber.resume 1_000_000
puts fiber.resume "The fiber will be dead before I can cause trouble"

출력:

12
1000000
FiberError: dead fiber called

논블로킹 파이버 (Non-blocking Fibers)

논블로킹 파이버 개념은 Ruby 3.0에서 도입됐어요. 논블로킹 파이버는 sleep이나 다른 프로세스/I/O 대기처럼 보통 파이버를 블로킹할 연산에 도달하면, 다른 파이버에 제어권을 넘기고 스케줄러가 블로킹을 처리해서, 진행할 수 있을 때 이 파이버를 깨워(resume) 줘요.

파이버가 논블로킹으로 동작하려면 Fiber.new에서 blocking: false(기본값)로 만들어야 하고, Fiber.set_schedulerFiber.scheduler를 설정해야 해요. 현재 스레드에 Fiber.scheduler가 설정돼 있지 않으면 블로킹 파이버와 논블로킹 파이버의 동작은 동일해요.

Ruby는 스케줄러 클래스를 제공하지 않아요. 사용자가 직접 구현해서 Fiber::Scheduler에 맞춰야 하죠. 또한 Fiber.schedule 메서드도 있는데, 주어진 블록을 논블로킹 방식으로 즉시 실행할 것으로 기대돼요. 실제 구현은 스케줄러의 몫이에요.

출처: Ruby 4.0 API

본문

::[] (Public Class Method)

Fiber[key] → value — key로 식별되는 파이버 스토리지 변수의 값을 돌려줘요. key는 심볼이어야 해요. 값은 Fiber#[]= 또는 Fiber#storage로 설정돼요. Fiber::[]=도 참고하세요.

::[]= (Public Class Method)

Fiber[key] = valuekey로 식별되는 파이버 스토리지 변수에 value를 할당해요. 변수가 없으면 생성해요. keySymbol이어야 하고, 그렇지 않으면 TypeError가 발생해요. valuenil이면 해당 키를 스토리지에서 삭제해요. Fiber::[] 참고.

::blocking (Public Class Method)

blocking { |fiber| ... } → result — 블록이 실행되는 동안 파이버가 블로킹이 되도록 강제해요. 블록의 결과를 돌려줘요. 이미 블로킹 상태면 실질적으로 아무것도 안 해요.

::blocking? (Public Class Method)

blocking? → false 또는 1 — 현재 파이버가 논블로킹이면 false를 돌려줘요. 파이버는 Fiber.newblocking: false를 넘기거나 Fiber.schedule로 만들었을 때 논블로킹이에요. 현재 파이버가 블로킹이면 1을 돌려줘요. (미래에는 더 큰 정수가 반환될 수 있어요.) false를 돌려줘도 파이버가 실제로 다르게 동작하는 건 현재 스레드에 Fiber.scheduler가 설정돼 있을 때뿐이라는 점을 기억하세요.

::current (Public Class Method)

current → fiber — 현재 파이버를 돌려줘요. 파이버 문맥 안에서 실행 중이 아니면 루트 파이버를 돌려줘요.

::current_scheduler (Public Class Method)

current_scheduler → obj 또는 nil — 현재 파이버가 논블로킹일 때만, Fiber.set_scheduler로 현재 스레드에 마지막으로 설정된 Fiber 스케줄러를 돌려줘요.

::new (Public Class Method)

new(blocking: false, storage: true) { |*args| ... } → fiber — 새 Fiber를 만들어요. 처음에는 실행되지 않고 resume으로 재개할 수 있어요. 첫 resume 호출의 인자는 블록으로 전달돼요.

f = Fiber.new do |initial|
   current = initial
   loop do
     puts "current: #{current.inspect}"
     current = Fiber.yield
   end
end
f.resume(100)     # prints: current: 100
f.resume(1, 2, 3) # prints: current: [1, 2, 3]
f.resume          # prints: current: nil
# ... and so on ...

Fiber.newblocking: false를 넘기고 현재 스레드에 Fiber.scheduler가 정의돼 있으면 파이버는 논블로킹이 돼요.

storage를 지정하지 않으면 기본은 현재 파이버의 스토리지 사본을 상속하는 것이고, 이는 storage: true를 지정한 것과 같아요.

Fiber[:x] = 1
Fiber.new do
  Fiber[:x] # => 1
  Fiber[:x] = 2
end.resume
Fiber[:x] # => 1

storagenil이면 내부 스토리지를 지연 초기화해 빈 해시로 시작해요.

Fiber[:x] = "Hello World"
Fiber.new(storage: nil) do
  Fiber[:x] # nil
end

그 외에는 주어진 storage가 새 파이버의 스토리지로 쓰이며, Hash 인스턴스여야 해요. storage: true를 명시적으로 쓰는 건 현재 실험적이고 미래에 바뀔 수 있어요.

::schedule (Public Class Method)

schedule { |*args| ... } → fiber — 주어진 코드 블록을 별도의 논블로킹 파이버에서 즉시 실행할 것으로 기대되는 메서드예요.

puts "Go to sleep!"
Fiber.set_scheduler(MyScheduler.new)
Fiber.schedule do
  puts "Going to sleep"
  sleep(1)
  puts "I slept well"
end
puts "Wakey-wakey, sleepyhead"

MyScheduler가 제대로 구현돼 있다면 이 프로그램은 이렇게 출력돼요.

Go to sleep!
Going to sleep
Wakey-wakey, sleepyhead
...1 sec pause here...
I slept well

파이버 안의 첫 블로킹 연산(sleep(1))에서 제어권이 바깥 코드(메인 파이버)로 넘어가고, 그 실행이 끝나면 스케줄러가 블로킹된 모든 파이버를 제대로 재개해 주는 거예요. 위 동작은 "기대되는" 동작이고, 실제로는 현재 스케줄러의 Fiber::Scheduler#fiber 구현에 달려 있어요. Ruby는 이 메서드가 특정 방식으로 동작하도록 강제하지 않아요. 스케줄러가 설정돼 있지 않으면 RuntimeError (No scheduler is available!)를 발생시켜요.

::scheduler (Public Class Method)

scheduler → obj 또는 nil — Fiber.set_scheduler로 현재 스레드에 마지막으로 설정된 Fiber 스케줄러를 돌려줘요. 설정된 게 없으면(기본) nil을 돌려주고, 논블로킹 파이버의 동작은 블로킹과 같아져요.

::set_scheduler (Public Class Method)

set_scheduler(scheduler) → scheduler — 현재 스레드의 Fiber 스케줄러를 설정해요. 설정되면 논블로킹 파이버들은 잠재적 블로킹 연산에서 그 스케줄러의 훅 메서드를 호출하고, 현재 스레드는 마무리(finalization) 시 스케줄러의 close 메서드를 호출해서 스케줄러가 완료되지 않은 모든 파이버를 제대로 관리하게 해요. schedulerFiber::Scheduler에 해당하는 어떤 클래스 객체든 될 수 있고 구현은 사용자 몫이에요.

::yield (Public Class Method)

yield(args, ...) → obj — 파이버를 재개한 문맥에 제어권을 돌려주면서, 전달된 인자들을 함께 넘겨요. 다음에 resume이 호출되면 파이버는 이 지점부터 처리를 재개해요. 다음 resume에 전달된 인자들이 바로 이 Fiber.yield 표현식이 평가되는 값이 돼요.

#alive? (Public Instance Method)

alive? → true 또는 false — 파이버를 아직 resume(또는 transfer)할 수 있으면 true를 돌려줘요. 파이버 블록 실행을 마친 후에는 항상 false를 돌려줘요.

#backtrace / #backtrace_locations (Public Instance Method)

backtrace → array — 파이버의 현재 실행 스택을 돌려줘요. start, count, end로 백트레이스의 일부만 선택할 수 있어요.

def level3
  Fiber.yield
end
def level2
  level3
end
def level1
  level2
end
f = Fiber.new { level1 }
# It is empty before the fiber started
f.backtrace
#=> []
f.resume
f.backtrace
#=> ["test.rb:2:in `yield'", "test.rb:2:in `level3'", "test.rb:6:in `level2'", "test.rb:10:in `level1'", "test.rb:13:in `block in <main>'"]
p f.backtrace(1) # start from the item 1
p f.backtrace(2, 2) # start from item 2, take 2
p f.backtrace(1..3) # take items from 1 to 3

backtrace_locationsbacktrace와 같되 실행 스택의 각 줄을 Thread::Backtrace::Location으로 돌려줘요. 같은 인자를 받아요.

f = Fiber.new { Fiber.yield }
f.resume
loc = f.backtrace_locations.first
loc.label  #=> "yield"
loc.path   #=> "test.rb"
loc.lineno #=> 1

#blocking? (Public Instance Method)

blocking? → true 또는 false — 파이버가 블로킹이면 true, 아니면 false를 돌려줘요. 파이버는 Fiber.newblocking: false를 넘기거나 Fiber.schedule로 만들었을 때 논블로킹이에요. false를 돌려줘도 파이버가 다르게 동작하는 건 현재 스레드에 Fiber.scheduler가 설정돼 있을 때뿐이에요.

#kill (Public Instance Method)

kill → nil — 잡을 수 없는 예외를 발생시켜 파이버를 종료해요. 주어진 파이버만 종료하고 다른 파이버는 종료하지 않아요. 만약 그 파이버가 resume이나 transfer를 호출 중이었다면 다른 파이버에 nil을 돌려줘요. Fiber#kill은 다른 파이버가 Fiber.yield 상태일 때만 그 파이버를 방해해요. 현재 파이버에 호출하면 Fiber#kill 호출 지점에서 그 예외를 발생시켜요. 아직 시작되지 않은 파이버는 곧바로 종료 상태로 전이하고, 이미 종료된 파이버면 아무것도 안 해요. 다른 스레드에 속한 파이버에 호출하면 FiberError가 발생해요.

#raise (Public Instance Method)

raise(exception, message = exception.to_s, backtrace = nil, cause: $!) 또는 raise(message = nil, cause: $!) — 마지막 Fiber.yield가 호출된 지점에서 파이버 안에 예외를 발생시켜요.

f = Fiber.new {
  puts "Before the yield"
  Fiber.yield 1 # -- exception will be raised here
  puts "After the yield"
}
p f.resume
f.raise "Gotcha"

출력:

Before the first yield
1
t.rb:8:in 'Fiber.yield': Gotcha (RuntimeError)
  from t.rb:8:in 'block in <main>'

아직 시작되지 않았거나 이미 끝까지 실행된 파이버면 FiberError를 발생시켜요. 파이버가 yield 상태면 재개되고, transfer 상태면 transfer되고, resume 상태면 FiberError가 발생해요. 다른 Thread에 속한 Fiber에 호출하면 FiberError가 발생해요. 인자에 대한 자세한 내용은 Kernel#raise 참고.

#resume (Public Instance Method)

resume(args, ...) → obj — 마지막 Fiber.yield가 호출된 지점부터 파이버를 재개해요. 첫 resume 호출이면 파이버를 시작해요. resume에 전달된 인자는 Fiber.yield 표현식의 값이 되거나, 첫 resume이면 파이버 블록의 파라미터로 전달돼요. 반대로 resume이 호출되면, 그 값은 파이버 블록 안의 다음 Fiber.yield 문에 전달된 인자가 되거나, Fiber.yield 없이 끝까지 실행됐다면 블록의 값이 돼요.

#storage (Public Instance Method)

storage → hash (dup) — 파이버의 스토리지 해시의 사본을 돌려줘요. 이 메서드는 Fiber.current에서만 호출할 수 있어요.

#storage= (Public Instance Method)

storage = hash — 파이버의 스토리지 해시를 설정해요. 이 기능은 실험적이고 미래에 바뀔 수 있어요. Fiber.current에서만 호출할 수 있어요. 이 메서드를 쓰면 중요한 파이버 스토리지 상태를 실수로 지울 수 있으니 주의해야 해요. 대부분은 Fiber::[]=로 특정 키만 할당하는 게 좋아요. Fiber.new(storage: nil)로 빈 스토리지를 가진 파이버를 만들 수도 있어요.

while request = request_queue.pop
  # Reset the per-request state:
  Fiber.current.storage = nil
  handle_request(request)
end

#to_s / #inspect (Public Instance Method)

to_s — 파이버의 문자열 표현을 돌려줘요. inspectto_s의 별칭이에요.

#transfer (Public Instance Method)

transfer(args, ...) → obj — 다른 파이버로 제어권을 넘겨요. resume처럼 마지막 멈춘 지점부터 재개하거나, 아직 재개된 적 없으면 시작해요. 호출한 파이버는 Fiber.yield를 호출한 것처럼 일시 중지돼요. transfer를 받은 파이버는 이를 resume처럼 다루고, transfer로 전달된 인자도 resume에 전달된 것처럼 취급돼요.

파이버로/로부터 제어권을 넘기는 두 스타일(resume + Fiber::yield, 그리고 transfer)은 자유롭게 섞을 수 없어요.

  • 파이버 수명이 transfer로 시작했다면 절대 yield하거나 resume 방식으로 제어권을 받을 수 없고, 끝내거나 transfer로만 되돌아갈 수 있어요.
  • resume으로 시작했다면 yield하거나 다른 파이버에 transfer할 수 있지만, 다시 제어권을 받는 방식은 제어권을 넘긴 방식과 호환되어야 해요 — transfer했으면 transfer로만 되돌아받고, yield했으면 resume으로만 되돌아받아요.

이 규칙을 어기면 FiberError가 발생해요. 개별 파이버 설계 관점에서 yield/resume이 쓰기 쉬워요(파이버는 그냥 제어권을 넘기기만 하면 되고 누구에게 넘기는지 생각할 필요가 없어요). transfer는 복잡한 경우에 더 유연해서, 서로 의존하는 파이버들의 임의의 그래프를 만들 수 있게 해줘요.

manager = nil # For local var to be visible inside worker block
# This fiber would be started with transfer
# It can't yield, and can't be resumed
worker = Fiber.new { |work|
  puts "Worker: starts"
  puts "Worker: Performed #{work.inspect}, transferring back"
  # Fiber.yield     # this would raise FiberError: attempt to yield on a not resumed fiber
  # manager.resume  # this would raise FiberError: attempt to resume a resumed fiber (double resume)
  manager.transfer(work.capitalize)
}
# This fiber would be started with resume
# It can yield or transfer, and can be transferred
# back or resumed
manager = Fiber.new {
  puts "Manager: starts"
  puts "Manager: transferring 'something' to worker"
  result = worker.transfer('something')
  puts "Manager: worker returned #{result.inspect}"
  # worker.resume    # this would raise FiberError: attempt to resume a transferring fiber
  Fiber.yield        # this is OK, the fiber transferred from and to, now it can yield
  puts "Manager: finished"
}
puts "Starting the manager"
manager.resume
puts "Resuming the manager"
# manager.transfer  # this would raise FiberError: attempt to transfer to a yielding fiber
manager.resume

출력:

Starting the manager
Manager: starts
Manager: transferring 'something' to worker
Worker: starts
Worker: Performed "something", transferring back
Manager: worker returned "Something"
Resuming the manager
Manager: finished