Computer >> บทช่วยสอนคอมพิวเตอร์ >  >> การเขียนโปรแกรม >> Ruby

เชี่ยวชาญ Fibers และ Enumerators ใน Ruby: Advanced Flow Control Techniques

Ruby 有各種執行 iteration 的方式 — loops, blocks 和 enumerators 大多數 Ruby programmers 至少熟悉 loops 和 blocks 但 Enumerator 和 Fiber 經常停留在黑暗中 在這個 Ruby Magic 版本 guest author Julik 發光於 Enumerable 和 Fiber 解釋 flow controlling enumerables 和 turning blocks inside out

Suspending Blocks and Chained Iteration

我們在之前的 Ruby Magic 版本討論過 Enumerator 在那裡我們描述了如何從你自己的 #each 方法返回 Enumerator 和它可以用於什麼 Enumerator 和 Fiber 甚至更廣泛的用例是它們可以 "suspend a block" mid-flight 不僅僅是給 #each 的塊或整個 #each 調用 而是任何塊!

這是一個非常強大的構造 可用於實現 shims 為那些通過使用 blocks 作為橋樑來調用期望 sequential calls 而不是接受塊的 callers 的方法 例如 想像我們想打開 database handle 並讀取我們檢索到的每個 item:

db.with_each_row_of_result(sql_stmt) do |row|
  yield row
end

Block API 很棒 因為當塊終止時 它可能為我們執行各種 cleanup 然而 某些 consumers 可能想以這種方式與 database 工作:

@cursor = cursor
# later:
row = @cursor.next_row
send_row_to_event_stream(row)

實際上 這意味著我們想 "suspend" 塊的執行 "just for now" 並在塊內稍後繼續 因此 caller 接管 flow control 而不是在 callee (執行塊的方法) 手中

Chaining Iterators

這個 pattern 最常見的用途之一是將多個 iterators chain 在一起 當我們這樣做時 我們習慣用於 iteration 的方法 (像 #each) 返回一個 Enumerator object 我們可以用它來 "grab" 塊通過 yield 語句發送給我們的值:

range = 1..8
each_enum = range.each # => <Enumerator...>

Enumerators 然後可以被 chained 這允許我們執行像 "任何 iteration 但帶有 index" 這樣的操作 在這個例子中 我們在 range 上調用 #map 獲取一個 Enumerable object 然後 chain #with_index 以 index 迭代 range:

(1..3).map.with_index {|element_n, index| [element_n, index] }
#=> [[1, 0], [2, 1], [3, 2]]

如果你的系統使用 events 這可能非常有用 Ruby 提供一個 built-in method 用於 wrapping 任何方法與 Enumerator generator 這允許我們精確完成這一點 想像我們想從 with_each_row_of_result "pull" rows 一個接一個 而不是方法 yielding 它們給我們

@cursor = db.to_enum(:with_each_row_of_result, sql_stmt)
schedule_for_later do
  begin
    row = @cursor.next
    send_row_to_event_stream(row)
  rescue StopIteration
    # the block has ended and the cursor is empty, the cleanup has taken place
  end
end

如果我們自己實現這很可能會這樣:

cursor = Enumerator.new do |yielder|
  db.with_each_row_of_result(sql_stmt) do |row|
    yielder.yield row
  end
end

Turning Blocks Inside Out

Rails 允許我們將 response body 分配為也可以是 Enumerator 它將在作為 response body 分配的 Enumerator 上調用 next 並期望返回值是一個字符串 — 將被寫入 Rack response 例如 我們可以返回 Range 的 #each 方法調用作為 Rails response body:

class MyController < ApplicationController
  def index
    response.body = ('a'..'z').each
  end
end

這就是我所謂的 turning a block inside out. 本質上 它是一個 control flow helper 允許我們在塊中 "freeze time" (或循環 在 Ruby 中也是一個塊) mid-flight

然而 Enumerators 有一個限制性質 使它們稍微不那麼有用 想像我們想做像這樣的事:

File.open('output.tmp', 'wb') do |f|
  # Yield file for writing, continuously
  loop { yield(f) }
end

讓我們用 enumerator wrap 它 並寫入它

writer_enum = File.to_enum(:open, 'output.tmp', 'wb')
file = en.next
file << data
file << more_data

一切運作良好 然而 有一個 hitch — 我們如何告訴 enumerator 我們寫完了 以便它可以 "finish" 塊 關閉文件並退出? 這將執行一些重要步驟 — 例如 resource cleanup (文件將被關閉) 以及確保所有 buffered writes 被 flushed 到磁盤 我們確實有 File 對象的訪問權 並可以自己關閉它 但我們希望 enumerator 為我們管理關閉 我們必須讓 enumerator 繼續超過塊

另一個障礙是有時我們想傳遞參數給 suspended 塊內發生的事情 想像我們有一個接受塊的方法具有以下語義:

write_file_through_encryptor(file_name) do |writable|
  writable << "Some data"
  writable << "Some more data"
  writable << "Even more data"
end

但在我們的 calling code 我們想像這樣使用:

writable = write_file_through_encryptor(file_name)
writable << "Some data"
# ...later on
writable << "Some more data"
writable.finish

理想情況下 我們會將方法調用包裝在某種結構中 允許我們以下技巧:

write_file_through_encryptor(file_name) do |writable|
  loop do
    yield_and_wait_for_next_call(writable)
    # Then we somehow break out of this loop to let the block complete
  end
end

如果我們 wrap 我們的 writes 像這樣呢?

deferred_writable = write_file_through_encryptor(file_name)
deferred_writable.next("Some data")
deferred_writable.next("Some more data")
deferred_writable.next("Even more data")
deferred_writable.next(:terminate)

在這種情況下 我們將使用 :terminate 作為 magic value 告訴我們的方法它可以完成塊並返回 這 是 Enumerator 不會真正幫助我們的地方 因為我們不能傳遞任何參數給 Enumerator#next 如果我們能 我們將能夠做:

deferred_writable = write_file_through_encryptor(file_name)
deferred_writable.next("Some data")
...
deferred_writable.next(:terminate)

Enter Ruby's Fibers

這正是 Fibers 允許的 Fibers 允許你 accept arguments on each reentry 所以我們可以這樣實現我們的 wrapper:

deferred_writable = Fiber.new do |data_to_write_or_termination|
  write_file_through_encryptor(filename) do |f|
    # Here we enter the block context of the fiber, reentry will be to the start of this block
    loop do
      # When we call Fiber.yield our fiber will be suspended—we won't reach the
      # "data_to_write_or_termination =" assignment before our fiber gets resumed
      data_to_write_or_termination = Fiber.yield
    end
  end
end

這是它如何工作: 當你第一次在 deferred_writable 上調用 .resume 它進入 fiber 並一路到第一個 Fiber.yield 語句或最外層 Fiber 塊的末尾 以先到者為準 當你調用 Fiber.yield 它把控制權交還給你 記住 Enumerator? 塊將被 suspended 下次你調用 .resume 時 resume 的參數成為新的 data_to_write

deferred_writes = Fiber.new do |data_to_write|
  loop do
    $stderr.puts "Received #{data_to_write} to work with"
    data_to_write = Fiber.yield
  end
end
# => #<Fiber:0x007f9f531783e8>
deferred_writes.resume("Hello")
#=> Received Hello to work with
deferred_writes.resume("Goodbye")
#=> Received Goodbye to work with

所以 在 Fiber 內部 代碼流程在第一次調用 Fiber#resume 時 started 在第一次調用 Fiber.yield 時 suspended 然後在後續調用 Fiber#resume 時 continued Fiber.yield 的返回值是 resume 的參數 代碼從上次調用 Fiber.yield 的點繼續運行

這是 Fibers 有點 quirk 即 fiber 的初始參數將作為塊參數傳遞給你 而不是通過 Fiber.yield 的返回值

考慮到這一點 我們知道通過傳遞特殊參數給 resume 我們可以在 Fiber 內部決定是否應該停止 讓我們試試:

deferred_writes = Fiber.new do |data_to_write|
  loop do
    $stderr.puts "Received #{data_to_write} to work with"
    break if data_to_write == :terminate # Break out of the loop, or...
    write_to_output(data_to_write) # ...write to the output
    data_to_write = Fiber.yield # suspend ourselves and wait for the next `resume`
  end
  # We end up here if we break out of the loop above. There is no Fiber.yield
  # statement anywhere, so the Fiber will terminate and become "dead".
end

deferred_writes.resume("Hello")
#=> Received Hello to work with
deferred_writes.resume("Goodbye")
#=> Received Goodbye to work with
deferred_writes.resume(:terminate)
deferred_writes.resume("Some more data after close")
# FiberError: dead fiber called

有許多情況這些設施非常有用 由於 Fiber 包含一個可以手動恢復的 suspended 代碼塊 Fibers 可用於實現 event reactors 和處理單線程內的 concurrent operations 它們很輕量 所以你可以通過將單個 client 分配給單個 Fiber 並在必要時在這些 Fiber objects 之間切換來使用 Fibers 實現 server

client_fiber = Fiber.new do |socket|
  loop do
    received_from_client = socket.read_nonblock(10)
    sent_to_client = socket.write_nonblock("OK")
    Fiber.yield # Return control back to the caller and wait for it to call 'resume' on us
  end
end

client_fibers << client_fiber
# and then in your main webserver loop
client_fibers.each do |client_fiber|
  client_fiber.resume # Receive data from the client if any, and send it an OK
end

Ruby 有一個額外的 standard library 叫 fiber 允許你 explicitly transfer control 從一個 fiber 到另一個 這對這些用途可能是 bonus facility

Controlling Data Emission Rates

Fibers 和 enumerators 的另一個偉大用途 出現當你想能夠控制 Ruby 塊發射數據的速率 例如 在 zip_tricks 我們支持以下塊用作使用 library 的主要方式:

ZipTricks::Streamer.open(output_io) do |z|
  z.write_deflated_file("big.csv") do |destination|
    columns.each do |col|
      destination << column
    end
  end
end

因此 我們允許創建 ZIP archive 的代碼部分 "push" control 並且無法控制它輸出多少數據和多頻繁 如果我們想以 5 MB chunks 寫入 ZIP — 這將是 AWS S3 object storage 的限制 — 我們將必須創建一個自定義 output_io 對象 它以某種方式 "refuse" 接受 << 方法調用 當 segment 需要被 split off 進入 S3 multipart part 我們可以 然而 invert control 並使它 "pull" 我們仍將使用相同的塊寫入我們的大 CSV 文件 但我們將基於它提供的輸出 resume 和 halt 它 因此我們使以下用法成為可能:

output_enum = ZipTricks::Streamer.output_enum do |z|
  z.write_deflated_file("big.csv") do |destination|
    columns.each do |col|
      destination << column
    end
  end
end

# At this point nothing has been generated or written yet
enum = output_enum.each # Create an Enumerator
bin_str = enum.next # Let the block generate some binary data and then suspend it
output.write(bin_str) # Our block is suspended and waiting for the next invocation of `next`

這允許我們控制 ZIP file generator 發射數據的速率

Enumerator 和 Fiber 因此是一個 control flow mechanism 用於將 "push" blocks 轉換為接受方法調用的 "pull" objects

Fibers 和 Enumerators 只有一個陷阱 — 如果你在塊中有像 ensure 這樣的東西 或一些塊完成後需要做的事 現在取決於 caller 調用你足夠多次 在某種程度上 它類似於你在 JavaScript 中使用 Promises 時的約束

Conclusion

這結束了我們對 Ruby 中 flow-controlled enumerables 的探索 一路上 Julik 發光於 Enumerable 和 Fiber 類的相似性和差異 並深入例子 caller 決定 data flow 我們也學到了 Fiber 的額外魔法允許在每次塊 reentry 時傳遞參數 祝你 flow-controlling 愉快!

為了獲得穩定的魔法劑量 訂閱 Ruby Magic 我們將每月直接發送到你的收件箱