Topic 34 สถานะที่ใช้ร่วมกันคือสถานะที่ผิดพลาด

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

ในขณะเดียวกัน ที่อีกฝั่งหนึ่งของร้าน ลูกค้าอีกคนก็ถามพนักงานเสิร์ฟคำถามเดียวกัน เธอมองดูเช่นกัน ยืนยันว่ายังมีพายเหลืออยู่ และลูกค้าคนนั้นก็สั่งพายชิ้นนั้นเช่นกัน

ลูกค้าคนใดคนหนึ่งจะต้องผิดหวังอย่างแน่นอนครับ

ลองเปลี่ยนตู้โชว์เป็นบัญชีธนาคารร่วม และเปลี่ยนพนักงานเสิร์ฟให้กลายเป็นเครื่อง point-of-sale แทน คุณกับคู่ของคุณต่างตัดสินใจซื้อโทรศัพท์เครื่องใหม่พร้อมกัน แต่ในบัญชีมีเงินพอสำหรับซื้อได้แค่เครื่องเดียว ใครสักคน—จะเป็นธนาคาร ร้านค้า หรือตัวคุณเอง—จะต้องไม่พอใจอย่างมากครับ

Tip 57 สถานะที่ใช้ร่วมกันคือสถานะที่ผิดพลาด

ปัญหาคือสถานะที่ใช้ร่วมกัน (shared state) นี่แหละครับ พนักงานเสิร์ฟแต่ละคนในร้านมองเข้าไปในตู้โชว์โดยไม่สนใจอีกฝ่าย เครื่อง point-of-sale แต่ละเครื่องก็ตรวจดูยอดเงินในบัญชีโดยไม่สนใจเครื่องอื่นเช่นกัน

Nonatomic Updates (การอัปเดตที่ไม่เป็นอะตอมิก)

ลองมาดูตัวอย่างร้านอาหารของเราในรูปแบบโค้ดกันครับ:

A figure shows a class diagram.

แผนภาพคลาส UML แสดงคลาสชื่อ display case ที่มีแอตทริบิวต์ "pie_count: 1" คลาสนี้เชื่อมโยงกับ waiter 1 และ waiter 2 โดย waiter 1 แสดงเงื่อนไข if display_case.pie_count > 0 promise_pie_to_customer() display_case.take_pie() give_pie_to_customer() end ส่วน waiter 2 แสดงเงื่อนไข if display_case.pie_count > 0 promise_pie_to_customer() display_case.take_pie() give_pie_to_customer() end เช่นกัน

พนักงานเสิร์ฟทั้งสองคนทำงานพร้อมกันแบบ concurrent (และในชีวิตจริงคือแบบ parallel ด้วย) ลองมาดูโค้ดของพวกเขากันครับ:

​ ​ if ​ display_case.pie_count > 0
​ promise_pie_to_customer()
​ display_case.take_pie()
​ give_pie_to_customer()
​ ​ end ​

Waiter 1 อ่านค่า pie count ปัจจุบัน และพบว่ามีอยู่หนึ่งชิ้น เขาจึงสัญญากับลูกค้าว่าจะได้พาย แต่ในจังหวะนั้นเอง waiter 2 ก็เริ่มทำงาน เธอเห็น pie count เป็นหนึ่งเช่นกัน และให้สัญญาแบบเดียวกันกับลูกค้าของเธอ สุดท้ายมีคนหนึ่งคว้าพายชิ้นสุดท้ายไปได้ ส่วนพนักงานอีกคนก็ต้องเข้าสู่สภาวะข้อผิดพลาดบางอย่าง (ซึ่งคงต้องขอโทษลูกค้ากันยกใหญ่)

ปัญหาตรงนี้ไม่ใช่เรื่องที่ two processes เขียนลงหน่วยความจำเดียวกันได้ครับ แต่ปัญหาคือไม่มี process ไหนรับประกันได้ว่ามุมมองของตัวเองที่มีต่อหน่วยความจำนั้นยังคง consistent อยู่ พูดง่าย ๆ คือเมื่อพนักงานเสิร์ฟรัน display_case.pie_count() พวกเขาจะคัดลอกค่าจากตู้โชว์มาเก็บไว้ในหน่วยความจำของตัวเอง ถ้าค่าที่อยู่ในตู้โชว์เปลี่ยนไป หน่วยความจำของพวกเขา (ที่ใช้ในการตัดสินใจ) ก็จะกลายเป็นข้อมูลที่ล้าสมัยไปทันที

ทั้งหมดนี้เกิดขึ้นเพราะการอ่านค่าแล้วอัปเดต pie count ไม่ใช่ atomic operation นั่นเองครับ ค่าที่อยู่เบื้องหลังสามารถเปลี่ยนแปลงได้ระหว่างทาง

แล้วเราจะทำให้มันเป็น atomic ได้อย่างไรครับ

Semaphores and Other Forms of Mutual Exclusion (Semaphore และรูปแบบอื่น ๆ ของ Mutual Exclusion)

semaphore ก็คือ สิ่งของ ที่มีเจ้าของได้เพียงคนเดียวในแต่ละครั้งเท่านั้นครับ คุณสามารถสร้าง semaphore ขึ้นมาแล้วใช้มันควบคุมการเข้าถึง resource อื่น ๆ ได้ ในตัวอย่างของเรา เราสามารถสร้าง semaphore ขึ้นมาเพื่อควบคุมการเข้าถึงตู้โชว์พาย และตกลงกันเป็นข้อตกลงว่าใครก็ตามที่ต้องการอัปเดตเนื้อหาในตู้โชว์พาย จะทำได้ก็ต่อเมื่อถือ semaphore นั้นอยู่เท่านั้น

สมมติว่าร้านอาหารตัดสินใจแก้ปัญหาเรื่องพายด้วย physical semaphore โดยวางตุ๊กตา Leprechaun พลาสติกไว้บนตู้โชว์พาย ก่อนที่พนักงานเสิร์ฟคนไหนจะขายพายได้ พวกเขาต้องถือ Leprechaun ตัวนี้อยู่ในมือก่อน เมื่อออร์เดอร์เสร็จสมบูรณ์แล้ว (คือนำพายไปเสิร์ฟที่โต๊ะแล้ว) พวกเขาก็นำ Leprechaun กลับไปวางไว้ที่เดิมเพื่อเฝ้าสมบัติแห่งพาย พร้อมสำหรับออร์เดอร์ถัดไป

ลองมาดูกันในรูปแบบโค้ดครับ แต่เดิมนั้น operation สำหรับยึด semaphore จะเรียกว่า P ส่วน operation สำหรับปล่อยมันจะเรียกว่า V . [47] ปัจจุบันเราใช้คำเช่น lock/unlock , claim/release และอื่น ๆ

​ case_semaphore.lock()
​
​ ​ if ​ display_case.pie_count > 0
​ promise_pie_to_customer()
​ display_case.take_pie()
​ give_pie_to_customer()
​ ​ end ​
​
​ case_semaphore.unlock()

โค้ดนี้สมมติว่ามีการสร้าง semaphore ไว้แล้วและเก็บไว้ในตัวแปร case_semaphore

สมมติว่าพนักงานเสิร์ฟทั้งสองคนรันโค้ดนี้พร้อมกัน ทั้งคู่จะพยายาม lock semaphore แต่จะมีเพียงคนเดียวที่ทำสำเร็จ คนที่ได้ semaphore ไปจะทำงานต่อไปตามปกติ ส่วนคนที่ไม่ได้ semaphore จะถูกพักไว้จนกว่า semaphore จะว่าง (พนักงานเสิร์ฟก็ต้องรอ…) เมื่อพนักงานเสิร์ฟคนแรกทำออร์เดอร์เสร็จแล้ว unlock semaphore พนักงานคนที่สองก็จะทำงานต่อ ซึ่งตอนนี้จะเห็นว่าไม่มีพายในตู้แล้ว และต้องขอโทษลูกค้าครับ

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

Make the Resource Transactional (ทำให้ Resource เป็น Transactional)

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

​ slice = display_case.get_pie_if_available()
​ ​ if ​ slice
​ give_pie_to_customer()
​ ​ end ​

เพื่อให้ทำงานได้ เราต้องเขียน method ที่รันเป็นส่วนหนึ่งของตัวตู้โชว์เอง:

​ ​ def ​ get_pie_if_available() ​ #### ​
​ ​ if ​ @slices.size > 0 ​ # ​
​ update_sales_data(​ :pie ​) ​ # ​
​ ​ return ​ @slices.shift ​ # ​
​ ​ else ​ ​ # incorrect code! ​
​ ​ false ​ ​ # ​
​ ​ end ​ ​ # ​
​ ​ end ​ ​ #### ​

โค้ดนี้แสดงให้เห็นความเข้าใจผิดที่พบได้บ่อยครับ เราย้ายการเข้าถึง resource ไปไว้ที่จุดศูนย์กลางแล้วก็จริง แต่ method ของเราก็ยังถูกเรียกจากหลาย ๆ thread พร้อมกันได้อยู่ดี ดังนั้นเรายังต้องป้องกันมันด้วย semaphore อยู่เหมือนเดิม:

​ ​ def ​ get_pie_if_available()
​ @case_semaphore.lock()
​
​ ​ if ​ @slices.size > 0
​ update_sales_data(​ :pie ​)
​ ​ return ​ @slices.shift
​ ​ else ​
​ ​ false ​
​ ​ end ​
​
​ @case_semaphore.unlock()
​ ​ end ​

แม้แต่โค้ดนี้ก็อาจจะยังไม่ถูกต้องครับ ถ้า update_sales_data raise exception ขึ้นมา semaphore ก็จะไม่มีวันถูก unlock และการเข้าถึงตู้โชว์พายในอนาคตทั้งหมดก็จะค้างอยู่แบบนั้นตลอดไป เราจึงต้อง handle กรณีนี้ด้วย:

​ ​ def ​ get_pie_if_available()
​ @case_semaphore.lock()
​
​ try {
​ ​ if ​ @slices.size > 0
​ update_sales_data(​ :pie ​)
​ ​ return ​ @slices.shift
​ ​ else ​
​ ​ false ​
​ ​ end ​
​ }
​ ​ ensure ​ {
​ @case_semaphore.unlock()
​ }
​ ​ end ​

เพราะนี่เป็นความผิดพลาดที่พบได้บ่อยมาก หลายภาษาจึงมี library ที่จัดการเรื่องนี้ให้คุณโดยอัตโนมัติ:

​ ​ def ​ get_pie_if_available()
​ @case_semaphore.protect() {
​ ​ if ​ @slices.size > 0
​ update_sales_data(​ :pie ​)
​ ​ return ​ @slices.shift
​ ​ else ​
​ ​ false ​
​ ​ end ​
​ }
​ ​ end ​

Multiple Resource Transactions (Transaction ที่มีหลาย Resource)

ร้านอาหารของเราเพิ่งติดตั้งตู้แช่ไอศกรีมมาใหม่ ถ้าลูกค้าสั่งพาย à la mode พนักงานเสิร์ฟจะต้องตรวจสอบว่าทั้งพาย และ ไอศกรีมมีพร้อมให้บริการหรือไม่ครับ

เราอาจเปลี่ยนโค้ดของพนักงานเสิร์ฟให้เป็นประมาณนี้:

​ slice = display_case.get_pie_if_available()
​ scoop = freezer.get_ice_cream_if_available()
​
​ ​ if ​ slice && scoop
​ give_order_to_customer()
​ ​ end ​

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

เราอาจแก้ปัญหานี้ได้โดยเพิ่ม method ให้ตู้โชว์ที่ช่วยให้เราคืนพายกลับไปได้ และเราจะต้องเพิ่ม exception handling เพื่อให้แน่ใจว่าเราจะไม่ถือ resource ไว้ถ้ามีอะไรผิดพลาด:

​ slice = display_case.get_pie_if_available()
​
​ ​ if ​ slice
​ try {
​ scoop = freezer.get_ice_cream_if_available()
​ ​ if ​ scoop
​ try {
​ give_order_to_customer()
​ }
​ ​ rescue ​ {
​ freezer.give_back(scoop)
​ }
​ ​ end ​
​ }
​ ​ rescue ​ {
​ display_case.give_back(slice)
​ }
​ ​ end ​

อีกครั้งที่วิธีนี้ยังไม่เหมาะสมนัก โค้ดตอนนี้ดูรกมากครับ การจะทำความเข้าใจว่ามันทำอะไรกันแน่นั้นยาก เพราะ business logic ถูกฝังอยู่ใต้งาน housekeeping ทั้งหมด

ก่อนหน้านี้เราแก้ปัญหาด้วยการย้ายโค้ดจัดการ resource เข้าไปไว้ในตัว resource เอง แต่คราวนี้เรามี resource สองตัว แล้วเราควรใส่โค้ดไว้ในตู้โชว์พายหรือตู้แช่ไอศกรีมดีล่ะครับ

เราคิดว่าคำตอบคือ "ไม่ใช่" ทั้งสองตัวเลือกครับ แนวทางแบบ pragmatic คือการมองว่า "apple pie à la mode" เป็น resource ของตัวมันเอง เราจะย้ายโค้ดนี้ไปไว้ใน module ใหม่ แล้วให้ client แค่บอกว่า "ขอ apple pie พร้อมไอศกรีม" จากนั้นมันก็จะสำเร็จหรือล้มเหลวไปเลย

แน่นอนว่าในโลกจริง เมนูที่ประกอบด้วยหลาย ๆ อย่างแบบนี้คงมีอยู่เยอะ และคุณคงไม่อยากเขียน module ใหม่สำหรับแต่ละเมนู แต่คุณน่าจะอยากได้ menu item บางรูปแบบที่เก็บ reference ไปยังส่วนประกอบต่าง ๆ ของมัน แล้วมี method ทั่วไปอย่าง get_menu_item ที่จัดการ resource dance กับแต่ละส่วนประกอบแทน

Non-Transactional Updates (การอัปเดตแบบ Non-Transactional)

หลายคนให้ความสนใจกับ shared memory ในฐานะต้นตอของปัญหา concurrency แต่จริง ๆ แล้วปัญหานี้เกิดขึ้นได้ ทุกที่ ที่โค้ดแอปพลิเคชันของคุณแชร์ mutable resource กัน ไม่ว่าจะเป็นไฟล์ ฐานข้อมูล บริการภายนอก และอื่น ๆ เมื่อไรก็ตามที่โค้ดของคุณสองชุดขึ้นไปสามารถเข้าถึง resource เดียวกันพร้อมกันได้ คุณก็กำลังเผชิญกับปัญหาที่อาจเกิดขึ้นได้แล้วครับ

บางครั้ง resource ก็ไม่ได้ชัดเจนขนาดนั้นครับ ระหว่างที่เขียนหนังสือฉบับนี้ เราได้อัปเดต toolchain ให้ทำงานแบบ parallel มากขึ้นโดยใช้ thread ซึ่งทำให้ build ล้มเหลว แต่ล้มเหลวในแบบที่แปลกประหลาดและเกิดขึ้นแบบสุ่ม สิ่งที่ error ทั้งหมดมีเหมือนกันคือหาไฟล์หรือไดเรกทอรีไม่เจอ ทั้ง ๆ ที่มันอยู่ตรงตำแหน่งที่ถูกต้องจริง ๆ

เราตามไปเจอว่าต้นเหตุคือโค้ดบางจุดที่เปลี่ยน current directory ไว้ชั่วคราว ในเวอร์ชันที่ไม่ parallel การที่โค้ดเปลี่ยน directory กลับคืนก็เพียงพอแล้ว แต่ในเวอร์ชัน parallel thread หนึ่งอาจเปลี่ยน directory แล้วขณะที่ยังอยู่ใน directory นั้น thread อีกตัวก็เริ่มทำงานขึ้นมา thread นั้นคาดหวังว่าตัวเองจะยังอยู่ใน directory เดิม แต่เพราะ current directory ถูกแชร์กันระหว่าง thread จึงไม่เป็นอย่างที่คิดครับ

ธรรมชาติของปัญหานี้นำไปสู่ tip อีกข้อหนึ่งครับ:

Tip 58 ความล้มเหลวแบบสุ่มมักเป็นปัญหาเรื่อง Concurrency

Other Kinds of Exclusive Access (การเข้าถึงแบบ Exclusive รูปแบบอื่น ๆ)

ภาษาส่วนใหญ่มี library รองรับการเข้าถึง shared resource แบบ exclusive อยู่แล้วครับ อาจเรียกว่า mutex (มาจาก mut ual ex clusion) monitor หรือ semaphore ซึ่งทั้งหมดนี้ถูก implement เป็น library

อย่างไรก็ตาม บางภาษาก็มีการรองรับ concurrency ฝังอยู่ในตัวภาษาเองเลย เช่น Rust ที่บังคับใช้แนวคิดเรื่อง data ownership โดยมีเพียงตัวแปรหรือ parameter เดียวเท่านั้นที่ถือ reference ไปยัง mutable data ชิ้นใดชิ้นหนึ่งได้ในแต่ละครั้ง

คุณอาจแย้งได้ว่าภาษา functional ซึ่งมักทำให้ข้อมูลทั้งหมดเป็น immutable ช่วยให้ concurrency ง่ายขึ้น แต่ถึงอย่างนั้นภาษาเหล่านี้ก็ยังเจอความท้าทายแบบเดียวกันอยู่ดี เพราะสุดท้ายแล้วก็ต้องก้าวเข้าสู่โลกจริงที่เป็น mutable อยู่ดีครับ

Doctor, It Hurts… (คุณหมอครับ มันเจ็บตอนที่ผม…)

ถ้าคุณจะจำอะไรจากหัวข้อนี้ได้แค่อย่างเดียว ขอให้จำเรื่องนี้ไว้ครับ: concurrency ในสภาพแวดล้อมที่มี shared resource นั้นยากมาก และการจัดการมันเองก็เต็มไปด้วยความท้าทาย

นี่จึงเป็นเหตุผลที่เราขอแนะนำมุกจบของเรื่องตลกเก่า ๆ เรื่องนี้ครับ:

คุณหมอครับ มันเจ็บทุกครั้งที่ผมทำแบบนี้

อ้าว… งั้นก็อย่าทำแบบนั้นสิ

อีกสองสามหัวข้อถัดไปจะนำเสนอวิธีอื่น ๆ ที่ช่วยให้คุณได้ประโยชน์จาก concurrency โดยไม่ต้องเจ็บปวดครับ

Related Sections Include (หัวข้อที่เกี่ยวข้อง)

  • Topic 10, ​ Orthogonality (ความเป็นอิสระต่อกัน) ​
  • Topic 28, ​ Decoupling (การลดการเชื่อมโยง) ​
  • Topic 38, ​ Programming by Coincidence (การเขียนโปรแกรมด้วยความบังเอิญ) ​