Rails เป็นเฟรมเวิร์กขนาดใหญ่ที่มีเครื่องมือมากมายสำหรับสถานการณ์เฉพาะ ในซีรีส์นี้ เราจะมองหาเครื่องมือที่น้อยคนรู้จักซ่อนอยู่ในโค้ดเบสขนาดใหญ่ของ Rails
ในบทความนี้ เราจะมาดูที่เมธอด update_counters ของ ActiveRecord ในกระบวนการ เราจะสำรวจกับดักทั่วไปของ "race conditions" ในโปรแกรม multi-threaded และวิธีที่เมธอดนี้ช่วยป้องกันได้
Thread (เธรด)
ตอนเขียนโปรแกรม เรามีหลายวิธีในการรันโค้ดแบบขนาน รวมถึง processes, threads และล่าสุด (ใน Ruby) fibers และ reactors ในบทความนี้ เราจะกังวลแค่ threads เพราะเป็นรูปแบบที่พบบ่อยที่สุดสำหรับนักพัฒนา Rails เช่น Puma เป็น multi-threaded server และ Sidekiq เป็น multi-threaded background job processor
เราจะไม่ลึกซึ้งเรื่อง threads และ thread safety ที่นี่ สิ่งสำคัญคือเมื่อสอง threads ทำงานบนข้อมูลเดียวกัน ข้อมูลสามารถ out-of-sync ได้ง่าย นี่คือสิ่งที่เรียกว่า "race condition"
Race Conditions (ภาวะแข่งขัน)
Race condition เกิดขึ้นเมื่อสอง (หรือมากกว่า) threads ทำงานบนข้อมูลเดียวกันในเวลาเดียวกัน ทำให้ thread หนึ่งอาจใช้ข้อมูลเก่า (stale data) เรียกว่า "race condition" เพราะเหมือนสอง threads กำลังแข่งกัน และสถานะสุดท้ายของข้อมูลอาจแตกต่างกันไปขึ้นอยู่กับว่า thread ไหน "ชนะการแข่งขัน" สิ่งที่แย่ที่สุดคือ race conditions ยากมากที่จะ reproduce เพราะมักเกิดขึ้นก็ต่อเมื่อ threads "สลับกันทำงาน" ในลำดับและจุดเฉพาะในโค้ด
ตัวอย่าง
สถานการณ์ทั่วไปที่ใช้แสดง race condition คือการอัปเดตยอดเงินในบัญชีธนาคาร เราจะสร้างคลาสทดสอบง่ายๆ ภายในแอพพลิเคชัน Rails พื้นฐานเพื่อดูสิ่งที่เกิดขึ้น:
class UnsafeTransaction def self.run account = Account.find(1) account.update!(balance: 0) threads = [] 4.times do threads << Thread.new do balance = account.reload.balance account.update!(balance: balance + 100) balance = account.reload.balance account.update!(balance: balance - 100) end end threads.map(&:join) account.reload.balance end end UnsafeTransaction ของเราเรียบง่าย มีแค่เมธอดเดียวที่ค้นหา Account (โมเดล Rails มาตรฐานพร้อม attribute balance เป็น BigDecimal) เรารีเซ็ต balance เป็นศูนย์เพื่อให้รันทดสอบซ้ำง่ายขึ้น
ลูปภายในคือจุดน่าสนใจ เราสร้างสี่ threads ที่จะดึง balance ปัจจุบันของบัญชี เพิ่ม 100 (เช่น ฝากเงิน 100 ดอลลาร์) แล้วลบ 100 ทันที (เช่น ถอนเงิน 100 ดอลลาร์) เรายังใช้ reload ทั้งสองครั้งเพื่อให้ มั่นใจมาก ว่าเรามี balance ล่าสุด
บรรทัดที่เหลือคือการเก็บกวาด Thread.join หมายถึงเราจะรอให้ทุก threads จบก่อนจะดำเนินต่อ แล้วคืนค่า balance สุดท้ายในตอนท้ายของเมธอด
ถ้าเรารันด้วย single thread (เปลี่ยนลูปเป็น 1.times do) เราสามารถรันได้หนึ่งล้านครั้ง และมั่นใจได้ว่า final account balance จะเป็นศูนย์เสมอ แต่เปลี่ยนเป็นสอง (หรือมากกว่า) threads แล้ว สิ่งจะไม่แน่นอนอีกต่อไป
รันทดสอบครั้งเดียวในคอนโซล อาจให้คำตอบที่ถูกต้อง:
UnsafeTransaction.run => 0.0 แต่ถ้าเรารันซ้ำๆ หลายครั้ง สมมติรันสิบครั้ง:
(1..10).map { UnsafeTransaction.run }.map(&:to_f) => [0.0, 300.0, 300.0, 100.0, 100.0, 100.0, 300.0, 300.0, 100.0, 300.0] กรณี syntax ไม่คุ้นเคย (1..10).map {} จะรันโค้ดในบล็อก 10 ครั้ง ผลลัพธ์แต่ละครั้งจะถูกเก็บในอาเรย์ .map(&:to_f) ท้ายสุดเพื่อให้ตัวเลขอ่านง่าย เพราะค่า BigDecimal จะพิมพ์แบบ exponential notation เช่น 0.1e3
จำได้ไหม โค้ดของเราเอา balance ปัจจุบัน บวก 100 แล้วลบ 100 ทันที ดังนั้นผลลัพธ์สุดท้าย ควร เป็น 0.0 เสมอ ค่า 100.0 และ 300.0 ที่ปรากฏ แสดงว่ามี race condition
ตัวอย่างพร้อมคำอธิบาย
มาสูบซูมดูโค้ดที่มีปัญหาและดูว่ากำลังเกิดอะไรขึ้น เราจะแยกการเปลี่ยนแปลง balance เพื่อความชัดเจนมากขึ้น
threads << Thread.new do # Thread อาจสลับที่นี่ balance = account.reload.balance # หรือที่นี่... balance += 100 # หรือที่นี่... account.update!(balance: balance) # หรือที่นี่... balance = account.reload.balance # หรือที่นี่... balance -= 100 # หรือที่นี่... account.update!(balance: balance) # หรือที่นี่... end เห็นในคอมเมนต์แล้ว threads อาจสลับได้เกือบจุดไหนก็ได้ในโค้ดนี้ หาก Thread 1 อ่าน balance แล้วคอมพิวเตอร์เริ่มรัน Thread 2 จึงเป็นไปได้ว่าข้อมูลจะล้าสมัยเมื่อมันเรียก update! พูดอีกแบบ Thread 1, Thread 2 และ database ทั้งคู่มีข้อมูล แต่พวกมันกำลัง out-of-sync กัน
ตัวอย่างนี้ trivial בכוונה เพื่อให้แยกวิเคราะห์ง่าย ในโลกจริง race conditions อาจยากต่อการ diagnose มากขึ้น โดยเฉพาะเพราะมัก reproduce ไม่ได้อย่างน่าเชื่อถือ
วิธีแก้ไข
มีหลายตัวเลือกสำหรับป้องกัน race conditions แตเกือบทั้งหมดหมุนรอบแนวคิดเดียว: ให้แน่ใจว่ามี entity เดียวเท่านั้นที่กำลังเปลี่ยนข้อมูลในเวลาใดเวลาหนึ่ง
ตัวเลือก 1: Mutex
ตัวเลือกง่ายที่สุดคือ "mutual exclusion lock" ซึ่งรู้จักกันดีในชื่อ mutex คุณคิด mutex เป็นล็อกที่มีกุญแจเพียงอันเดียว หาก thread หนึ่งถือกุญแจ มันสามารถรันสิ่งที่อยู่ใน mutex ได้ ส่วน threads อื่นต้องรอจนกว่าจะได้ถือกุญแจ
การนำ mutex ไปใช้กับโค้ดตัวอย่างของเราอาจทำได้แบบนี้:
class MutexTransaction def self.run account = Account.find(1) account.update!(balance: 0) mutex = Mutex.new threads = [] 4.times do threads << Thread.new do mutex.lock balance = account.reload.balance account.update!(balance: balance + 100) mutex.unlock mutex.lock balance = account.reload.balance account.update!(balance: balance - 100) mutex.unlock end end threads.map(&:join) account.reload.balance end end ที่นี่ ทุกครั้งที่เราอ่านและเขียน account เราจะเรียก mutex.lock ก่อน แล้วเมื่อเสร็จเรียก mutex.unlock ให้ threads อื่นได้เทิร์น เราอาจแค่เรียก mutex.lock ตอนเริ่มบล็อกและ mutex.unlock ตอนท้าย แต่จะทำให้ threads ไม่ได้รัน concurrently อีกต่อไป ซึ่งทำลายวัตถุประสงค์ของการใช้ threads สำหรับ performance ควรเก็บโค้ดภายใน mutex ให้น้อยที่สุด เพื่อให้ threads รันขนานได้มากที่สุด
เราใช้ .lock และ .unlock เพื่อความชัดเจน แต Mutex ของ Ruby มีเมธอด synchronize ที่สะดวก รับบล็อกและจัดการให้อัตโนมัติ ดังนั้นเราสามารถเขียนได้แบบนี้:
mutex.synchronize do balance = ... ... end Ruby's Mutex ทำสิ่งที่เราต้องการ แต่ในแอพ Rails มักพบว่าต้อง lock row ฐานข้อมูลเฉพาะ ActiveRecord ครอบคลุมสถานการณ์นี้ไว้ให้แล้ว
ตัวเลือก 2: ActiveRecord Locks
ActiveRecord มีกลไกการ lock หลายแบบ เราจะไม่ลึกซึ้งทั้งหมด สำหรับจุดประสงค์ของเรา ใช้ lock! เพื่อ lock row ที่เราต้องการอัปเดต:
class LockedTransaction def self.run account = Account.find(1) account.update!(balance: 0) threads = [] 4.times do threads << Thread.new do Account.transaction do account = account.reload account.lock! account.update!(balance: account.balance + 100) end Account.transaction do account = account.reload account.lock! account.update!(balance: account.balance - 100) end end end threads.map(&:join) account.reload.balance end end Mutex "lock" ส่วนของโค้ดสำหรับ thread เฉพาะ ในขณะที่ lock! lock row ฐานข้อมูลเฉพาะ นั่นหมายความว่าโค้ดเดียวกันสามารถรันขนานได้บนหลายบัญชี (เช่นในหลาย background jobs) เฉพาะ threads ที่ต้องเข้าถึง record เดียวกันเท่านั้นที่ต้องรอ ActiveRecord ยังมี #with_lock ที่สะดวก ให้ทำ transaction และ lock ในครั้งเดียว ดังนั้นการอัปเดตข้างบนเขียนได้สั้นกว่า:
account = account.reload account.with_lock do account.update!(account.balance + 100) end ... วิธีแก้ 3: Atomic Methods
เมธอด 'atomic' (หรือฟังก์ชัน) คือเมธอดที่ไม่สามารถหยุดระหว่างการทำงานได้ ตัวอย่าง การดำเนินการ += ทั่วไปใน Ruby ไม่ใช่ atomic แม้จะดูเหมือน single operation:
value += 10 # equivalent to: value = value + 10 # Or even more verbose: temp_value = value + 10 value = temp_value ถ้า thread "นอน" ระหว่างคำนวณ value + 10 และเขียนผลลัพธ์กลับไปที่ value จะเปิดโอกาสให้ race condition เกิดขึ้น แต่ลองจินตนาการว่า Ruby ไม่อนุญาตให้ threads นอนระหว่างการดำเนินการนี้ หากเราสามารถรับรองได้ว่า thread จะไม่มีการนอน (คอมพิวเตอร์จะไม่สลับไปรัน thread อื่น) ระหว่างการดำเนินการ นี่จึงเป็น "atomic" operation
บางภาษามี atomic versions ของค่าพื้นฐานสำหรับ thread-safety นี้ (เช่น AtomicInteger และ AtomicFloat) แตไม่ได้หมายความว่าเราไม่มี "atomic" operations ให้ใช้งานเป็นนักพัฒนา Rails ActiveRecord's update_counters เป็นตัวอย่างหนึ่ง
แม้จะตั้งใจสำหรับอัปเดต counter caches แตไม่มีอะไรขัดขวางเราไม่ให้ใช้ในแอพของเรา สำหรับข้อมูลเพิ่มเติมเกี่ยวกับ counter caches สามารถอ่านบทความก่อนหน้าของผมเกี่ยวกับ caching ได้
การใช้เมธอดนี้เรียบง่ายมาก:
class CounterTransaction def self.run account = Account.find(1) account.update!(balance: 0) threads = [] 4.times do threads << Thread.new do Account.update_counters(account.id, balance: 100) Account.update_counters(account.id, balance: -100) end end threads.map(&:join) account.reload.balance end end ไม่ต้อง mutex, ไม่ต้อง locks แค่สองบรรทัดของ Ruby update_counters รับ record ID เป็น argument แรก แล้วเราบอกว่าจะเปลี่ยนคอลัมน์ไหน (balance:) และเปลี่ยนเท่าไหร่ (100 หรือ -100) เหตุผลที่ทำงานได้คือ read-update-write cycle เกิดขึ้นใน database ใน single SQL call นี่หมายความว่า Ruby thread ของเราไม่สามารถ interrupt การดำเนินการได้ แม้มันจะนอนก็ตาม เพราะ database เป็นฝ่ายคำนวณจริง
SQL จริงที่ผลิตออกมาเป็นแบบนี้ (อย่างน้อยสำหรับ postgres บนเครื่องผม):
Account Update All (1.7ms) UPDATE "accounts" SET "balance" = COALESCE("balance", 0) + $1 WHERE "accounts"."id" = $2 [["balance", "100.0"], ["id", 1]] วิธีนี้ perform ดีกว่ามาก ซึ่งไม่แปลก เพราะการคำนวณเกิดขึ้นทั้งหมดใน database เราไม่ต้อง reload record เพื่อหาค่าล่าสุด ความเร็วนี้มีราคา อย่างไรก็ตาม เนื่องจากเราทำใน raw SQL เรากำลัง bypass Rails model หมายถึง validations หรือ callbacks จะไม่ถูก execute (หมายถึง ระหว่างนั้น ไม่มีการเปลี่ยนแปลง timestamp updated_at)
สรุป
Race conditions อาจเป็น poster child ของ Heisenbug พวกมันง่ายที่จะเข้ามา บ่อยครั้ง reproduce ไม่ได้ และยากที่จะคาดการณ์ล่วงหน้า Ruby และ Rails ให้เครื่องมือบางอย่างช่วยกำจัดปัญหาเหล่านี้เมื่อเราพบมัน
สำหรับ Ruby code ทั่วไป Mutex เป็นตัวเลือกดี และอาจเป็นสิ่งแรกที่นักพัฒนาส่วนใหญ่คิดถึงเมื่อได้ยิน "thread safety"
กับ Rails มากกว่านี้ ข้อมูลมักมาจาก ActiveRecord ในกรณีเหล่านี้ lock! (หรือ with_lock) ใช้งานตรงไปตรงมา และอนุญาต throughput มากกว่า mutex เพราะ lock แค่ rows ที่เกี่ยวข้องใน database
จะพูดตรงๆ ผมไม่แน่ใจว่าจะใช้ update_counters บ่อยในโลกจริง มัน uncommon พอที่นักพัฒนาคนอื่นอาจไม่คุ้นเคยกับพฤติกรรมของมัน และมันไม่ทำให้ intention ของโค้ดชัดเจนมาก หากเผชิญ thread-safety concerns ActiveRecord's locks (ไม่ว่าจะ lock! หรือ with_lock) ทั้งคู่นิยมกว่า และสื่อสาร intention ของโค้ดเดอร์ชัดเจนกว่า
อย่างไรก็ตาม หากคุณมี simple 'add or subtract' jobs มากมาย backing up และต้องการความเร็วดิบๆ update_counters อาจเป็นเครื่องมือที่มีประโยชน์ในกระเป๋าสะพายของคุณ