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

เส้นใยและการแจงนับในทับทิม - พลิกบล็อกด้านในออก

Ruby มีหลายวิธีในการวนซ้ำ—ลูป บล็อก และแจงนับ โปรแกรมเมอร์ Ruby ส่วนใหญ่คุ้นเคยกับลูปและบล็อกเป็นอย่างน้อย แต่ Enumerator และ Fiber มักจะอยู่ในความมืด ใน Ruby Magic ฉบับนี้ นักเขียนรับเชิญ Julik ได้ให้ความกระจ่างเกี่ยวกับ Enumerable และ Fiber เพื่ออธิบายการควบคุมการไหลที่นับได้และการเปลี่ยนบล็อกด้านในออก

การระงับบล็อกและการวนซ้ำที่ถูกล่ามโซ่

เราได้พูดถึง Enumerator ใน Ruby Magic รุ่นก่อนหน้า ซึ่งเราได้อธิบายวิธีส่งคืน Enumerator จาก #each ของคุณเอง วิธีการและสิ่งที่จะใช้ได้ กรณีการใช้งานที่กว้างขึ้นสำหรับ Enumerator และ Fiber คือพวกเขาสามารถ "ระงับ" ระหว่างเที่ยวบิน ไม่ใช่แค่บล็อกที่มอบให้ #each หรือโทรทั้งหมดไปที่ #each แต่บล็อกใด ๆ !

นี่เป็นโครงสร้างที่ทรงพลังมาก ซึ่งสามารถใช้เพื่อปรับใช้ shims สำหรับวิธีการที่ทำงานโดยใช้บล็อกเป็นสะพานเชื่อมไปยังผู้โทรที่คาดหวังการเรียกตามลำดับแทนที่จะบล็อก ตัวอย่างเช่น สมมติว่าเราต้องการเปิดฐานข้อมูลและอ่านแต่ละรายการที่เราดึงมา:

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

API ของบล็อกนั้นยอดเยี่ยม เนื่องจากมันสามารถล้างข้อมูลให้เราได้ทุกประเภทเมื่อการบล็อกสิ้นสุดลง อย่างไรก็ตาม ผู้บริโภคบางรายอาจต้องการทำงานกับฐานข้อมูลในลักษณะนี้:

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

ในทางปฏิบัติ หมายความว่าเราต้องการ "ระงับ" การดำเนินการของบล็อก "เพียงชั่วคราว" และดำเนินการต่อไปภายในบล็อก ดังนั้น ผู้เรียกเข้าควบคุมโฟลว์คอนโทรลแทนที่จะอยู่ในมือของผู้เรียก (เมธอดที่ดำเนินการบล็อก)

Chaining Iterators

การใช้รูปแบบนี้โดยทั่วไปอย่างหนึ่งคือการโยงตัววนซ้ำหลายตัวเข้าด้วยกัน เมื่อเราทำเช่นนั้น วิธีการที่เราใช้ในการทำซ้ำ (เช่น #each ) ส่งคืนวัตถุ Enumerator แทน ซึ่งเราสามารถใช้เพื่อ "คว้า" ค่าที่บล็อกส่งให้เราโดยใช้ yield คำสั่ง:

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

ตัวแจงนับสามารถ ถูกล่ามโซ่ ซึ่งทำให้เราสามารถดำเนินการต่างๆ ได้ เช่น "การวนซ้ำใดๆ แต่ด้วยดัชนี" ในตัวอย่างนี้ เรากำลังเรียก #map ในช่วงที่จะได้รับ Enumerable วัตถุ. จากนั้นเราก็โยง #with_index เพื่อวนซ้ำในช่วงที่มีดัชนี:

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

สิ่งนี้มีประโยชน์มาก โดยเฉพาะอย่างยิ่งถ้าระบบของคุณใช้เหตุการณ์ Ruby มีวิธีการในตัวสำหรับการห่อวิธีการใดๆ ด้วยตัวสร้าง Enumerator ซึ่งช่วยให้เราบรรลุสิ่งนี้ได้อย่างแน่นอน ลองนึกภาพว่าเราต้องการ "ดึง" ทีละแถวจาก with_each_row_of_result ของเรา , แทนที่จะเป็นวิธีการที่ยอมจำนนต่อเรา

@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

พลิกบล็อกกลับด้าน

Rails ช่วยให้เราสามารถกำหนดเนื้อหาการตอบสนองให้เป็นผู้แจงนับได้ มันจะเรียก next บน Enumerator เรากำหนดเป็นเนื้อหาการตอบสนอง และคาดว่าค่าที่ส่งคืนจะเป็นสตริง ซึ่งจะถูกเขียนลงในการตอบสนองของ Rack ตัวอย่างเช่น เราสามารถโทรกลับที่ #each เมธอดของ Range เป็นเนื้อหาการตอบสนองของ Rails:

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

นี่คือสิ่งที่เรียกว่า พลิกบล็อกด้านในออก โดยพื้นฐานแล้ว มันคือตัวช่วยควบคุมโฟลว์ที่ช่วยให้เรา "หยุดเวลา" ในบล็อก (หรือวนซ้ำ ซึ่งเป็นบล็อกใน Ruby ด้วย) ระหว่างการบิน

อย่างไรก็ตาม Enumerators มีคุณสมบัติจำกัดที่ทำให้มีประโยชน์น้อยลงเล็กน้อย ลองนึกภาพว่าเราต้องการทำอะไรแบบนี้:

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

มาห่อมันด้วยการแจงนับแล้วเขียนลงไป

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

ทุกอย่างทำงานได้ดี อย่างไรก็ตาม มีข้อขัดข้อง—เราจะบอกผู้แจงนับได้อย่างไรว่าเราเขียนเสร็จแล้ว เพื่อให้สามารถ "เสร็จสิ้น" บล็อก ปิดไฟล์และออกได้ การดำเนินการนี้จะดำเนินการตามขั้นตอนที่สำคัญหลายขั้นตอน เช่น การล้างทรัพยากร (ไฟล์จะถูกปิด) ตลอดจนการตรวจสอบให้แน่ใจว่าการเขียนบัฟเฟอร์ทั้งหมดถูกล้างไปยังดิสก์ เราสามารถเข้าถึง File คัดค้าน และเราสามารถปิดมันเองได้ แต่เราต้องการให้ผู้แจงนับจัดการการปิดสำหรับเรา เราต้องให้ตัวแจงนับดำเนินการผ่านบล็อก

อุปสรรคอีกประการหนึ่งคือบางครั้งเราต้องการส่งผ่านอาร์กิวเมนต์ของสิ่งที่เกิดขึ้นภายในบล็อกที่ถูกระงับ ลองนึกภาพว่าเรามีวิธีการบล็อกรับด้วยความหมายดังต่อไปนี้:

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

แต่ในรหัสการโทรของเรา เราต้องการใช้ดังนี้:

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

จะเป็นอย่างไรถ้าเราจะเขียนงานเขียนแบบนี้

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 เป็นค่าเวทย์มนตร์ที่จะบอกวิธีการของเราว่าสามารถจบบล็อกและส่งคืนได้ สิ่งนี้ เป็นที่ที่ Enumerator จะไม่ช่วยเราจริงๆ เพราะเราไม่สามารถส่งผ่านอาร์กิวเมนต์ใดๆ ไปยัง Enumerator#next . ถ้าเราทำได้ เราก็จะทำได้:

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

ป้อนเส้นใยของทับทิม

นี่คือสิ่งที่ไฟเบอร์อนุญาต Fiber ช่วยให้คุณ ยอมรับอาร์กิวเมนต์ในการย้อนกลับแต่ละครั้ง ดังนั้นเราจึงสามารถใช้ 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

นี่คือวิธีการทำงาน:เมื่อคุณเรียก .resume . ครั้งแรก บน deferred_writable . ของคุณ เข้าสู่เส้นใยและไปจนสุดที่ Fiber.yield คำสั่งหรือไปยังส่วนท้ายของบล็อกไฟเบอร์นอกสุดแล้วแต่ว่าจะถึงอย่างใดก่อน เมื่อคุณเรียก Fiber.yield ช่วยให้คุณกลับมาควบคุมได้ จำการแจงนับ? กำลังจะถูกบล็อก ระงับ และครั้งต่อไปที่คุณโทร .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 , ระงับการโทรครั้งแรกไปยัง Fiber.yield แล้ว ต่อ ในการโทรครั้งต่อไปที่ Fiber#resume โดยมีค่าส่งคืนเป็น Fiber.yield เป็นอาร์กิวเมนต์ของ resume . โค้ดทำงานต่อจากจุดที่ Fiber.yield ถูกเรียกครั้งสุดท้าย

นี่เป็นเรื่องเล็กน้อยของ Fibers ที่อาร์กิวเมนต์เริ่มต้นของไฟเบอร์จะถูกส่งผ่านถึงคุณในฐานะอาร์กิวเมนต์บล็อก ไม่ใช่ผ่านค่าส่งคืนของ 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 มีบล็อกโค้ดที่ถูกระงับซึ่งสามารถกลับมาทำงานต่อได้ด้วยตนเอง Fibres จึงสามารถใช้สำหรับการติดตั้งเครื่องปฏิกรณ์เหตุการณ์และสำหรับการจัดการกับการทำงานพร้อมกันภายในเธรดเดียว พวกมันมีน้ำหนักเบา ดังนั้นคุณจึงสามารถปรับใช้เซิร์ฟเวอร์โดยใช้ Fibers โดยกำหนดไคลเอนต์เดียวให้กับ Fiber เดียว และสลับไปมาระหว่างวัตถุ Fiber เหล่านี้ตามความจำเป็น

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 มีไลบรารีมาตรฐานเพิ่มเติมชื่อ fiber ซึ่งช่วยให้คุณถ่ายโอนการควบคุมจากไฟเบอร์หนึ่งไปยังอีกไฟเบอร์หนึ่งได้อย่างชัดเจน ซึ่งอาจเป็นสิ่งอำนวยความสะดวกพิเศษสำหรับการใช้งานเหล่านี้

การควบคุมอัตราการปล่อยข้อมูล

การใช้ไฟเบอร์และการแจงนับที่ยอดเยี่ยมอีกอย่างหนึ่งอาจเกิดขึ้นได้เมื่อคุณต้องการควบคุมอัตราที่บล็อก Ruby ปล่อยข้อมูล ตัวอย่างเช่น ใน zip_tricks เราสนับสนุนการใช้บล็อกต่อไปนี้เป็นวิธีหลักในการใช้ไลบรารี:

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 และไม่สามารถควบคุมจำนวนข้อมูลที่ส่งออกและความถี่ได้ หากเราต้องการเขียน ZIP เป็นส่วนๆ เช่น 5 MB ซึ่งจะเป็นข้อจำกัดในการจัดเก็บอ็อบเจ็กต์ AWS S3 เราจะต้องสร้าง output_io ที่กำหนดเอง วัตถุที่จะ "ปฏิเสธ" อย่างใดที่จะยอมรับ << เรียกเมธอดเมื่อเซ็กเมนต์ต้องแยกออกเป็นหลายส่วน S3 อย่างไรก็ตาม เราสามารถพลิกการควบคุมและทำให้ "ดึง" ได้ เราจะยังคงใช้บล็อกเดิมในการเขียนไฟล์ CSV ขนาดใหญ่ของเรา แต่เราจะกลับมาทำงานต่อและหยุดการทำงานตามผลลัพธ์ที่มีให้ ดังนั้นเราจึงทำให้การใช้งานต่อไปนี้เป็นไปได้:

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 ของเราจะส่งข้อมูลอัตราใด

ตัวแจงนับและไฟเบอร์จึงเป็นกลไกการควบคุมการไหล สำหรับเปลี่ยนบล็อก "พุช" ให้เป็นวัตถุ "ดึง" ที่ยอมรับการเรียกใช้เมธอด

มีข้อผิดพลาดเพียงอย่างเดียวกับ Fibers และ Enumerators หากคุณมีบางอย่างเช่น ensure ในบล็อกของคุณหรือสิ่งที่ต้องทำหลังจากบล็อกเสร็จสิ้น ตอนนี้ขึ้นอยู่กับผู้โทรที่จะโทรหาคุณหลายครั้ง ในทางหนึ่ง มันเปรียบได้กับข้อจำกัดที่คุณมีเมื่อใช้คำสัญญาใน JavaScript

บทสรุป

นี่เป็นการสรุปลักษณะของเราในการนับที่ควบคุมการไหลใน Ruby ระหว่างทาง Julik ได้ให้ความกระจ่างเกี่ยวกับความเหมือนและความแตกต่างระหว่าง Enumerable และ Fiber คลาสและเจาะลึกเข้าไปในตัวอย่างที่ผู้โทรกำหนดการไหลของข้อมูล นอกจากนี้เรายังได้เรียนรู้เกี่ยวกับ Fiber เวทย์มนตร์เพิ่มเติมเพื่อให้ส่งผ่านอาร์กิวเมนต์ในแต่ละบล็อกกลับเข้ามาใหม่ มีความสุขในการควบคุมการไหล!

หากต้องการรับความมหัศจรรย์อย่างต่อเนื่อง สมัครรับ Ruby Magic แล้วเราจะส่งฉบับรายเดือนตรงไปยังกล่องจดหมายของคุณ