| Topic 26 | วิธีรักษาสมดุลของทรัพยากร |
|---|
การจุดเทียนสักเล่ม ก็เท่ากับสร้างเงาขึ้นมาด้วยเช่นกัน...
Ursula K. Le Guin , A Wizard of Earthsea
เราทุกคนต้องจัดการกับทรัพยากรอยู่เสมอเวลาเขียนโค้ด ไม่ว่าจะเป็น memory, transaction, thread, การเชื่อมต่อเครือข่าย, ไฟล์, timer—สารพัดสิ่งที่มีอยู่อย่างจำกัด ส่วนใหญ่แล้วการใช้งานทรัพยากรจะเป็นไปตามแพตเทิร์นที่คาดเดาได้ คือคุณ allocate ทรัพยากรขึ้นมา ใช้งานมัน แล้วก็ deallocate มันทิ้งไปครับ
แต่นักพัฒนาจำนวนมากกลับไม่มีแผนที่แน่นอนในการจัดการ allocation และ deallocation ของทรัพยากร ดังนั้นเราขอเสนอ tip ง่ายๆ ข้อหนึ่งครับ:
| Tip 40 | ทำสิ่งที่เริ่มต้นไว้ให้เสร็จ |
|---|
Tip นี้นำไปใช้ได้ง่ายในเกือบทุกสถานการณ์ครับ มันหมายความง่ายๆ ว่า ฟังก์ชันหรืออ็อบเจกต์ที่ allocate ทรัพยากรขึ้นมา ก็ควรเป็นผู้รับผิดชอบ deallocate มันด้วยเช่นกัน ลองมาดูกันว่ามันนำไปใช้ยังไง ด้วยตัวอย่างโค้ดแย่ๆ ชิ้นหนึ่ง—ซึ่งเป็นส่วนหนึ่งของโปรแกรม Ruby ที่เปิดไฟล์ อ่านข้อมูลลูกค้าจากไฟล์นั้น อัปเดตฟิลด์หนึ่ง แล้วเขียนผลลัพธ์กลับลงไป เราตัด error handling ออกเพื่อให้ตัวอย่างดูง่ายขึ้นครับ:
| | def read_customer |
|---|---|
| | @customer_file = File.open(@name + ".rec" , "r+" ) |
| | @balance = BigDecimal(@customer_file.gets) |
| | end |
| | |
| | def write_customer |
| | @customer_file.rewind |
| | @customer_file.puts @balance.to_s |
| | @customer_file.close |
| | end |
| | |
| | def update_customer(transaction_amount) |
| | read_customer |
| | @balance = @balance.add(transaction_amount,2) |
| | write_customer |
| | end |
เมื่อมองเผินๆ รูทีน update_customer ดูสมเหตุสมผลดีครับ มันดูเหมือนจะทำตาม logic ที่เราต้องการ—อ่าน record อัปเดตยอดคงเหลือ แล้วเขียน record กลับออกไป แต่ความเรียบร้อยนี้กลับซ่อนปัญหาใหญ่เอาไว้ รูทีน read_customer และ write_customer ถูก coupled กันแน่นเกินไป [33] —เพราะทั้งสองใช้ instance variable ร่วมกันคือ customer_file . โดย read_customer จะเปิดไฟล์แล้วเก็บ reference ของไฟล์ไว้ใน customer_file จากนั้น write_customer จึงใช้ reference ที่เก็บไว้นั้นปิดไฟล์เมื่อทำงานเสร็จ ตัวแปรที่ใช้ร่วมกันนี้ไม่ได้ปรากฏอยู่ในรูทีน update_customer เลยด้วยซ้ำ
ทำไมมันถึงแย่? ลองนึกถึงโปรแกรมเมอร์ผู้โชคร้ายที่ต้องมาดูแลโค้ดนี้ต่อ แล้วถูกแจ้งว่า spec เปลี่ยนไป—ยอดคงเหลือควรถูกอัปเดตก็ต่อเมื่อค่าใหม่ไม่ติดลบเท่านั้น เขาเลยเข้าไปแก้ซอร์สโค้ดที่ update_customer :
| | def update_customer(transaction_amount) |
|---|---|
| | read_customer |
| | if (transaction_amount >= 0.00) |
| | @balance = @balance.add(transaction_amount,2) |
| | write_customer |
| | end |
| | end |
ตอน test ทุกอย่างดูเหมือนจะโอเคดีครับ แต่พอโค้ดนี้ขึ้น production มันกลับล่มหลังจากรันไปได้ไม่กี่ชั่วโมง พร้อม error too many open files ปรากฏว่า write_customer ไม่ถูกเรียกในบางสถานการณ์ และเมื่อเป็นแบบนั้น ไฟล์ก็ไม่ถูกปิดครับ
ทางแก้ที่ แย่ มากสำหรับปัญหานี้ก็คือ การจัดการ special case นี้เอาไว้ใน update_customer เลย:
| | def update_customer(transaction_amount) |
|---|---|
| | read_customer |
| | if (transaction_amount >= 0.00) |
| | @balance += BigDecimal(transaction_amount, 2) |
| | write_customer |
| | else |
| | @customer_file.close # Bad idea! |
| | end |
| | end |
วิธีนี้แก้ปัญหาได้จริง—ไฟล์จะถูกปิดไม่ว่ายอดคงเหลือใหม่จะเป็นเท่าไหร่ก็ตาม—แต่การแก้แบบนี้กลับทำให้รูทีนถึง สาม ตัวต้อง coupled กันผ่านตัวแปรร่วม customer_file และการติดตามว่าไฟล์เปิดอยู่หรือไม่จะเริ่มยุ่งเหยิงขึ้นเรื่อยๆ เรากำลังตกหลุมพรางอยู่ และถ้ายังเดินหน้าต่อไปในทิศทางนี้ สถานการณ์จะยิ่งแย่ลงอย่างรวดเร็ว นี่ไม่ใช่ความสมดุลเลยครับ!
Tip ทำสิ่งที่เริ่มต้นไว้ให้เสร็จ บอกเราว่า ตามหลักการแล้ว รูทีนที่ allocate ทรัพยากรขึ้นมา ก็ควรเป็นผู้ปล่อยมันคืนด้วยเช่นกัน เรานำหลักการนี้มาใช้ได้โดยการ refactor โค้ดเล็กน้อยครับ:
| | def read_customer(file) |
|---|---|
| | @balance=BigDecimal(file.gets) |
| | end |
| | |
| | def write_customer(file) |
| | file.rewind |
| | file.puts @balance.to_s |
| | end |
| | |
| | def update_customer(transaction_amount) |
| | file=File.open(@name + ".rec" , "r+" ) # >-- |
| | read_customer(file) # | |
| | @balance = @balance.add(transaction_amount,2) # | |
| | file.close # <-- |
| | end |
แทนที่จะถือ reference ของไฟล์ไว้ เราเปลี่ยนโค้ดให้ส่ง reference นั้นผ่านเป็นพารามิเตอร์แทน [34] ตอนนี้ความรับผิดชอบทั้งหมดของไฟล์อยู่ในรูทีน update_customer เพียงตัวเดียว มันเปิดไฟล์ขึ้นมา และ (ทำสิ่งที่เริ่มต้นไว้ให้เสร็จ) ปิดไฟล์ก่อนจะ return กลับไป รูทีนนี้รักษาสมดุลของการใช้ไฟล์ไว้ได้ คือการเปิดและปิดอยู่ในที่เดียวกัน และเห็นได้ชัดว่าทุกครั้งที่มีการเปิดจะมีการปิดคู่กันเสมอ การ refactor นี้ยังช่วยกำจัดตัวแปรร่วมที่ดูไม่เรียบร้อยออกไปด้วยครับ
ยังมีการปรับปรุงเล็กๆ แต่สำคัญอีกอย่างที่เราทำได้ครับ ในภาษาโปรแกรมสมัยใหม่หลายภาษา คุณสามารถกำหนด scope อายุของทรัพยากรให้อยู่ภายใน block หนึ่งๆ ได้ ใน Ruby มี variation ของ open ของไฟล์ที่ส่ง reference ของไฟล์ที่เปิดอยู่เข้าไปใน block ดังตัวอย่างที่แสดงระหว่าง do กับ end นี้ครับ:
| | def update_customer(transaction_amount) |
|---|---|
| | File.open(@name + ".rec" , "r+" ) do |file| # >-- |
| | read_customer(file) # | |
| | @balance = @balance.add(transaction_amount,2) # | |
| | write_customer(file) # | |
| | end # <-- |
| | end |
ในกรณีนี้ เมื่อจบ block ตัวแปร file จะหลุดออกจาก scope และไฟล์ภายนอกก็จะถูกปิดโดยอัตโนมัติ จบแค่นั้น ไม่ต้องคอยจำว่าต้องปิดไฟล์และคืนทรัพยากรเอง เพราะมันการันตีว่าจะเกิดขึ้นให้คุณเองครับ
เมื่อไม่แน่ใจ การลด scope ลงมักจะคุ้มค่าเสมอครับ
| Tip 41 | จำกัดขอบเขตให้แคบที่สุด |
|---|
Balancing Over Time (การรักษาสมดุลไปตามกาลเวลา)
ในหัวข้อนี้เราเน้นพูดถึงทรัพยากรชั่วคราวที่ process ที่กำลังรันอยู่ใช้งานเป็นหลัก แต่คุณอาจอยากลองพิจารณาดูว่ามีความยุ่งเหยิงอื่นๆ อีกไหมที่คุณอาจทิ้งไว้เบื้องหลังครับ
ยกตัวอย่างเช่น ไฟล์ log ของคุณถูกจัดการยังไง? คุณกำลังสร้างข้อมูลและใช้พื้นที่จัดเก็บไปเรื่อยๆ มีระบบสำหรับ rotate log และล้างมันทิ้งหรือเปล่า? แล้วไฟล์ debug ที่ไม่เป็นทางการที่คุณทิ้งไว้ล่ะ? ถ้าคุณเพิ่ม log record ลงในฐานข้อมูล มีกระบวนการที่คล้ายกันสำหรับ expire มันหรือไม่? สำหรับทุกสิ่งที่คุณสร้างขึ้นและใช้ทรัพยากรที่มีจำกัด ลองพิจารณาดูว่าจะรักษาสมดุลของมันยังไงครับ
แล้วคุณยังทิ้งอะไรไว้เบื้องหลังอีกบ้าง?
Nest Allocations (การจัดสรรทรัพยากรแบบซ้อนกัน)
แพตเทิร์นพื้นฐานสำหรับการ allocate ทรัพยากรสามารถขยายไปใช้กับรูทีนที่ต้องการทรัพยากรมากกว่าหนึ่งอย่างพร้อมกันได้ครับ มีข้อเสนอแนะเพิ่มเติมอีกแค่สองข้อ:
-
Deallocate ทรัพยากรในลำดับย้อนกลับกับตอนที่ allocate มันขึ้นมา วิธีนี้จะช่วยไม่ให้เกิดทรัพยากรกำพร้า ในกรณีที่ทรัพยากรหนึ่งมี reference ไปยังอีกทรัพยากรหนึ่ง
-
เมื่อ allocate ทรัพยากรชุดเดียวกันในหลายจุดของโค้ด ให้ allocate มันตามลำดับเดียวกันเสมอ วิธีนี้จะช่วยลดโอกาสเกิด deadlock (ถ้า process A ยึด resource1 ไว้แล้วกำลังจะยึด resource2 ในขณะที่ process B ยึด resource2 ไว้แล้วและกำลังพยายามขอ resource1 ทั้งสอง process จะรอกันไปตลอดกาล)
ไม่ว่าเราจะใช้ทรัพยากรแบบไหน—transaction, การเชื่อมต่อเครือข่าย, memory, ไฟล์, thread, window—แพตเทิร์นพื้นฐานก็ยังใช้ได้เสมอ คือใครก็ตามที่ allocate ทรัพยากรขึ้นมา ก็ควรรับผิดชอบ deallocate มันด้วย อย่างไรก็ตาม ในบางภาษาเราสามารถต่อยอดแนวคิดนี้ไปได้อีกครับ
Objects and Exceptions (อ็อบเจกต์กับ Exception)
ความสมดุลระหว่าง allocation กับ deallocation ทำให้นึกถึง constructor และ destructor ของคลาสแบบ object-oriented คลาสหนึ่งแทนทรัพยากรหนึ่งชนิด constructor จะให้อ็อบเจกต์เฉพาะของทรัพยากรชนิดนั้นแก่คุณ ส่วน destructor จะเอามันออกจาก scope ของคุณไป
ถ้าคุณเขียนโปรแกรมด้วยภาษาแบบ object-oriented คุณอาจพบว่าการห่อหุ้มทรัพยากรไว้ในคลาสนั้นมีประโยชน์ ทุกครั้งที่ต้องการทรัพยากรชนิดใดชนิดหนึ่ง คุณก็ instantiate อ็อบเจกต์ของคลาสนั้นขึ้นมา เมื่ออ็อบเจกต์หลุดออกจาก scope หรือถูก garbage collector เก็บกวาดไป destructor ของอ็อบเจกต์นั้นก็จะ deallocate ทรัพยากรที่ห่อหุ้มไว้ครับ
แนวทางนี้มีประโยชน์เป็นพิเศษเมื่อคุณทำงานกับภาษาที่ exception อาจเข้ามารบกวนการ deallocate ทรัพยากรได้
Balancing and Exceptions (การรักษาสมดุลกับ Exception)
ภาษาที่รองรับ exception อาจทำให้การ deallocate ทรัพยากรยุ่งยากขึ้น ถ้ามีการ throw exception เกิดขึ้น คุณจะรับประกันได้ยังไงว่าทุกอย่างที่ allocate ไว้ก่อนหน้า exception นั้นถูกเก็บกวาดเรียบร้อย คำตอบขึ้นอยู่กับว่าภาษานั้นรองรับอะไรบ้าง โดยทั่วไปแล้วคุณมีอยู่สองทางเลือกครับ:
- ใช้ scope ของตัวแปร (เช่น stack variable ใน C++ หรือ Rust)
- ใช้ clause finally ใน block try … catch
ด้วยกฎ scope ปกติในภาษาอย่าง C++ หรือ Rust หน่วยความจำของตัวแปรจะถูกเก็บกวาดคืนเมื่อตัวแปรหลุดออกจาก scope ไม่ว่าจะผ่านการ return, การออกจาก block หรือ exception แต่คุณก็ยังสามารถ hook เข้าไปที่ destructor ของตัวแปรเพื่อล้าง external resource ใดๆ ได้เช่นกัน ในตัวอย่างนี้ ตัวแปร Rust ชื่อ accounts จะปิดไฟล์ที่เกี่ยวข้องให้เองโดยอัตโนมัติเมื่อมันหลุดออกจาก scope ครับ:
| | { |
|---|---|
| | let mut accounts = File::open( "mydata.txt" )?; // >-- |
| | // use 'accounts' // | |
| | ... // | |
| | } // <-- |
| | // 'accounts' is now out of scope, and the file is |
| | // automatically closed |
อีกทางเลือกหนึ่ง ถ้าภาษานั้นรองรับ ก็คือ clause finally ครับ clause finally จะรับประกันว่าโค้ดที่ระบุไว้จะถูกรันเสมอ ไม่ว่าจะมี exception เกิดขึ้นใน block try … catch หรือไม่ก็ตาม:
| | try |
|---|---|
| | // some dodgy stuff |
| | catch |
| | // exception was raised |
| | finally |
| | // clean up in either case |
แต่ก็มีข้อควรระวังอยู่ครับ
An Exception Antipattern (แพตเทิร์นที่ไม่ควรทำกับ Exception)
เรามักเห็นคนเขียนโค้ดแบบนี้กันบ่อยๆ ครับ:
| | begin |
|---|---|
| | thing = allocate_resource() |
| | process(thing) |
| | finally |
| | deallocate(thing) |
| | end |
คุณเห็นไหมว่ามันผิดตรงไหน?
จะเกิดอะไรขึ้นถ้าการ allocate ทรัพยากรล้มเหลวและ raise exception ขึ้นมา? clause finally จะจับ exception นั้นไว้ แล้วพยายาม deallocate thing ที่ไม่เคยถูก allocate เลยด้วยซ้ำ
แพตเทิร์นที่ถูกต้องสำหรับจัดการการ deallocate ทรัพยากรในสภาพแวดล้อมที่มี exception คือ
| | thing = allocate_resource() |
|---|---|
| | begin |
| | process(thing) |
| | finally |
| | deallocate(thing) |
| | end |
When You Can't Balance Resources (เมื่อคุณไม่สามารถรักษาสมดุลของทรัพยากรได้)
บางครั้งแพตเทิร์นพื้นฐานของการ allocate ทรัพยากรก็ใช้ไม่ได้ผล ซึ่งมักพบในโปรแกรมที่ใช้ dynamic data structure รูทีนหนึ่งจะ allocate พื้นที่หน่วยความจำขึ้นมา แล้วเชื่อมมันเข้ากับโครงสร้างที่ใหญ่กว่า ซึ่งอาจอยู่ตรงนั้นไปอีกนาน
เคล็ดลับตรงนี้คือการกำหนด semantic invariant สำหรับการ allocate หน่วยความจำ คุณต้องตัดสินใจว่าใครเป็นผู้รับผิดชอบข้อมูลใน aggregate data structure นั้น จะเกิดอะไรขึ้นเมื่อคุณ deallocate โครงสร้างระดับบนสุด? คุณมีตัวเลือกหลักอยู่สามแบบครับ:
-
โครงสร้างระดับบนสุดรับผิดชอบปล่อย substructure ทั้งหมดที่มันบรรจุอยู่ด้วย จากนั้นโครงสร้างเหล่านั้นก็จะลบข้อมูลที่มันบรรจุอยู่แบบ recursive ต่อไปเรื่อยๆ
-
โครงสร้างระดับบนสุดถูก deallocate ไปเฉยๆ ส่วนโครงสร้างใดๆ ที่มันชี้ไปถึง (ที่ไม่ได้ถูกอ้างอิงจากที่อื่น) ก็จะกลายเป็นทรัพยากรกำพร้า
-
โครงสร้างระดับบนสุดจะปฏิเสธการ deallocate ตัวเอง ถ้ายังมี substructure บรรจุอยู่
ตัวเลือกตรงนี้ขึ้นอยู่กับสถานการณ์ของแต่ละ data structure ครับ แต่คุณต้องระบุมันให้ชัดเจนสำหรับแต่ละโครงสร้าง และ implement การตัดสินใจนั้นให้สม่ำเสมอ การ implement ตัวเลือกเหล่านี้ในภาษาแบบ procedural อย่าง C อาจเป็นปัญหาได้ เพราะตัว data structure เองไม่ได้ active ในตัวมันเอง สิ่งที่เราชอบทำในสถานการณ์แบบนี้คือเขียนโมดูลแยกสำหรับแต่ละโครงสร้างหลัก ที่ให้บริการ allocation และ deallocation มาตรฐานสำหรับโครงสร้างนั้น (โมดูลนี้ยังสามารถให้บริการอื่นๆ เช่น debug printing, serialization, deserialization และ traversal hook ได้ด้วย)
Checking the Balance (การตรวจสอบความสมดุล)
เพราะ Pragmatic Programmer ไม่เชื่อใจใครทั้งนั้น รวมถึงตัวเราเองด้วย เราจึงรู้สึกว่าการเขียนโค้ดที่คอยตรวจสอบจริงๆ ว่าทรัพยากรถูกปล่อยอย่างเหมาะสมแล้วหรือไม่ เป็นความคิดที่ดีเสมอครับ สำหรับแอปพลิเคชันส่วนใหญ่ วิธีนี้มักหมายถึงการสร้าง wrapper สำหรับทรัพยากรแต่ละชนิด แล้วใช้ wrapper เหล่านี้คอยติดตาม allocation และ deallocation ทั้งหมด ในบางจุดของโค้ด ตรรกะของโปรแกรมจะกำหนดว่าทรัพยากรควรอยู่ในสถานะใดสถานะหนึ่ง—ให้ใช้ wrapper ตรวจสอบตรงนี้ ยกตัวอย่างเช่น โปรแกรมที่รันระยะยาวคอยให้บริการ request มักจะมีจุดเดียวที่ด้านบนของ main processing loop ที่มันรอ request ถัดไปเข้ามา นี่คือจุดที่ดีสำหรับตรวจสอบว่าการใช้ทรัพยากรไม่ได้เพิ่มขึ้นนับตั้งแต่การรันรอบก่อนหน้าของ loop
ในระดับที่ต่ำลงมาแต่ก็มีประโยชน์ไม่แพ้กัน คุณสามารถลงทุนกับเครื่องมือที่ (นอกเหนือจากอย่างอื่น) คอยตรวจสอบ memory leak ในโปรแกรมที่กำลังรันอยู่ของคุณได้ครับ
Related Sections Include (หัวข้อที่เกี่ยวข้อง)
- Topic 24, Dead Programs Tell No Lies (โปรแกรมที่ตายแล้วไม่โกหก)
- Topic 30, Transforming Programming (การเปลี่ยนโฉมการเขียนโปรแกรม)
- Topic 33, Breaking Temporal Coupling (การทำลาย Temporal Coupling)
Challenges (แบบฝึกหัด)
- แม้จะไม่มีวิธีที่การันตีได้ร้อยเปอร์เซ็นต์ว่าคุณจะปล่อยทรัพยากรได้ครบทุกครั้ง แต่เทคนิคการออกแบบบางอย่าง เมื่อนำไปใช้อย่างสม่ำเสมอ ก็จะช่วยได้ครับ ในเนื้อหาที่ผ่านมาเราพูดถึงการกำหนด semantic invariant สำหรับ data structure หลักๆ ว่าช่วยกำหนดทิศทางการตัดสินใจ deallocate หน่วยความจำได้ยังไง ลองพิจารณาดูว่า Topic 23, Design by Contract (การออกแบบด้วยสัญญา) จะช่วยขัดเกลาแนวคิดนี้ให้ดีขึ้นได้อย่างไร
แบบฝึกหัดที่ 17 ( คำตอบที่เป็นไปได้ )
นักพัฒนา C และ C++ บางคนมักตั้งใจกำหนดค่า pointer ให้เป็น NULL หลังจาก deallocate หน่วยความจำที่มันอ้างอิงอยู่ ทำไมสิ่งนี้ถึงเป็นความคิดที่ดี?
แบบฝึกหัดที่ 18 ( คำตอบที่เป็นไปได้ )
นักพัฒนา Java บางคนมักตั้งใจกำหนดค่าตัวแปรอ็อบเจกต์ให้เป็น NULL หลังจากใช้งานอ็อบเจกต์นั้นเสร็จแล้ว ทำไมสิ่งนี้ถึงเป็นความคิดที่ดี?