| Topic 35 | Actors and Processes (แอคเตอร์และโพรเซส) |
|---|
หากปราศจากนักเขียน เรื่องราวก็คงไม่ถูกเขียนขึ้น
หากปราศจากนักแสดง เรื่องราวก็คงไม่มีชีวิต
Angie-Marie Delsante
Actor และ Process นำเสนอวิธีที่น่าสนใจในการทำ concurrency โดยไม่ต้องแบกภาระของการ synchronize การเข้าถึงหน่วยความจำที่ใช้ร่วมกันครับ
ก่อนที่เราจะลงลึกในรายละเอียด เราต้องนิยามความหมายของคำเหล่านี้ก่อนครับ ฟังดูอาจจะเป็นวิชาการไปสักหน่อย แต่ไม่ต้องกังวล เดี๋ยวเราจะค่อย ๆ ไล่ดูไปด้วยกันครับ
-
actor คือ virtual processor อิสระที่มี local state (และเป็น state ส่วนตัว) ของตัวเอง actor แต่ละตัวมี mailbox ของตัวเอง เมื่อมีข้อความปรากฏใน mailbox และ actor นั้นว่างอยู่ มันจะเริ่มทำงานและประมวลผลข้อความนั้น เมื่อประมวลผลเสร็จ มันจะประมวลผลข้อความถัดไปใน mailbox หรือถ้า mailbox ว่างเปล่า มันก็จะกลับไปพักครับ
เมื่อกำลังประมวลผลข้อความ actor สามารถสร้าง actor อื่นได้ ส่งข้อความไปยัง actor อื่นที่มันรู้จัก และสร้าง state ใหม่ที่จะกลายเป็น state ปัจจุบันเมื่อมีการประมวลผลข้อความถัดไปครับ
-
process โดยทั่วไปแล้วเป็น virtual processor ที่มีจุดประสงค์ทั่วไปมากกว่า มักถูก implement โดยระบบปฏิบัติการเพื่อรองรับ concurrency process สามารถถูกจำกัด (โดยข้อตกลง) ให้ทำงานเหมือน actor ได้ และนั่นคือประเภทของ process ที่เรากำลังพูดถึงในที่นี้ครับ
Actors Can Only Be Concurrent (Actor ทำงานแบบ Concurrent เท่านั้น)
มีบางสิ่งที่คุณจะ ไม่มีทาง พบในคำนิยามของ actor:
-
ไม่มี สิ่งใดสิ่งหนึ่ง ที่เป็นตัวควบคุมทั้งหมด ไม่มีอะไรคอยกำหนดว่าอะไรจะเกิดขึ้นต่อไป หรือคอยควบคุมการส่งต่อข้อมูลจาก raw data ไปจนถึงผลลัพธ์สุดท้ายครับ
-
state เพียงหนึ่งเดียว ที่มีอยู่ในระบบคือสิ่งที่ถูกเก็บอยู่ในข้อความและใน local state ของ actor แต่ละตัวเท่านั้น ข้อความจะไม่สามารถถูกตรวจสอบได้ นอกจากถูกอ่านโดยผู้รับ และ local state ก็ไม่สามารถเข้าถึงได้จากภายนอก actor ครับ
-
ข้อความทั้งหมดเป็นแบบทางเดียว—ไม่มีแนวคิดเรื่องการตอบกลับ ถ้าคุณต้องการให้ actor ส่ง response กลับมา คุณต้องแนบที่อยู่ mailbox ของคุณเองไปในข้อความที่ส่งไป แล้วมันจะ (ในที่สุด) ส่ง response กลับมาเป็นอีกข้อความหนึ่งไปยัง mailbox นั้นครับ
-
actor จะประมวลผลข้อความแต่ละข้อความจนเสร็จสมบูรณ์ และประมวลผลทีละข้อความเท่านั้นครับ
ผลก็คือ actor จะทำงานแบบ concurrent, asynchronous และไม่แชร์อะไรกันเลย ถ้าคุณมี physical processor มากพอ คุณก็สามารถรัน actor แต่ละตัวบนแต่ละ processor ได้ แต่ถ้าคุณมี processor เดียว ก็จะมี runtime บางตัวคอยจัดการสลับ context ระหว่างกันให้ ไม่ว่าจะแบบไหน โค้ดที่รันอยู่ใน actor ก็เหมือนกันครับ
| Tip 59 | ใช้ Actor เพื่อทำ Concurrency โดยไม่ต้องแชร์ State |
|---|
A Simple Actor (ตัวอย่าง Actor แบบง่าย)
มาลอง implement ร้านอาหารของเราโดยใช้ actor กันครับ ในกรณีนี้เราจะมี actor สามตัว (customer, waiter และ pie case)
message flow โดยรวมจะมีลักษณะดังนี้ครับ:
-
เรา (ในฐานะสิ่งภายนอกที่มีอำนาจเหนือทุกอย่างเหมือนพระเจ้า) บอก customer ว่าพวกเขาหิว
-
จากนั้น customer จะขอพายจาก waiter
-
waiter จะขอให้ pie case เอาพายไปให้ customer
-
ถ้า pie case มีพายเหลืออยู่ มันจะส่งพายไปให้ customer และแจ้ง waiter ให้เพิ่มรายการนั้นลงในบิลด้วย
-
ถ้าไม่มีพายเหลือแล้ว pie case จะแจ้ง waiter และ waiter ก็จะขอโทษ customer
เราเลือก implement โค้ดด้วย JavaScript โดยใช้ library ที่ชื่อ Nact ครับ [48] เราเพิ่ม wrapper เล็ก ๆ เข้าไปเพื่อให้เขียน actor เป็น object ง่าย ๆ ได้ โดยที่ key คือประเภทของข้อความที่ actor จะรับ และ value คือ function ที่จะรันเมื่อได้รับข้อความประเภทนั้น (ระบบ actor ส่วนใหญ่ก็มีโครงสร้างคล้าย ๆ กันแบบนี้ แต่รายละเอียดจะขึ้นอยู่กับภาษาที่ใช้ครับ)
เริ่มจาก customer กันก่อน customer สามารถรับข้อความได้สามแบบ:
- คุณหิว (ส่งมาจาก external context)
- มีพายวางอยู่บนโต๊ะ (ส่งมาจาก pie case)
- ขอโทษด้วย พายหมดแล้ว (ส่งมาจาก waiter)
โค้ดมีดังนี้ครับ:
| | const customerActor = { |
|---|---|
| | 'hungry for pie' : (msg, ctx, state) => { |
| | return dispatch(state.waiter, |
| | { type: "order" , customer: ctx.self, wants: 'pie' }) |
| | }, |
| | |
| | 'put on table' : (msg, ctx, _state) => |
| |
console.log( ${ctx.self.name} sees " ${msg.food} " appear on the table ), |
| | |
| | 'no pie left' : (_msg, ctx, _state) => |
| |
console.log( ${ctx.self.name} sulks… ) |
| | } |
ส่วนที่น่าสนใจคือตอนที่เราได้รับข้อความ ‘‘hungry for pie'” ซึ่งตอนนั้นเราจะส่งข้อความไปหา waiter ครับ (เดี๋ยวเราจะดูกันว่า customer รู้จัก waiter actor ได้อย่างไรครับ)
นี่คือโค้ดของ waiter ครับ:
| | const waiterActor = { |
|---|---|
| | "order" : (msg, ctx, state) => { |
| | if (msg.wants == "pie" ) { |
| | dispatch(state.pieCase, |
| | { type: "get slice" , customer: msg.customer, waiter: ctx.self }) |
| | } |
| | else { |
| |
console.dir( Don't know how to order ${msg.wants} ); |
| | } |
| | }, |
| | |
| | "add to order" : (msg, ctx) => |
| |
console.log( Waiter adds ${msg.food} to ${msg.customer.name} 's order ), |
| | |
| | "error" : (msg, ctx) => { |
| | dispatch(msg.customer, { type: 'no pie left' , msg: msg.msg }); |
| |
console.log( \nThe waiter apologizes to ${msg.customer.name} : ${msg.msg} ) |
| | } |
| | |
| | }; |
เมื่อได้รับข้อความ 'order' จาก customer มันจะตรวจสอบว่าคำสั่งนั้นคือพายหรือเปล่า ถ้าใช่ มันจะส่งคำขอไปยัง pie case พร้อมกับส่ง reference ของตัวมันเองและของ customer ไปด้วยครับ
pie case มี state เป็นของตัวเอง คือ array ของพายทุกชิ้นที่มันถืออยู่ (เดี๋ยวเราจะดูกันว่ามันถูกตั้งค่าไว้อย่างไร) เมื่อได้รับข้อความ 'get slice' จาก waiter มันจะเช็กว่ายังมีพายเหลืออยู่ไหม ถ้ามี มันจะส่งพายชิ้นนั้นไปให้ customer บอกให้ waiter อัปเดตออเดอร์ และสุดท้ายคืนค่า state ที่อัปเดตแล้ว ซึ่งมีพายเหลือน้อยลงหนึ่งชิ้น โค้ดมีดังนี้ครับ:
| | const pieCaseActor = { |
|---|---|
| | 'get slice' : (msg, context, state) => { |
| | if (state.slices.length == 0) { |
| | dispatch(msg.waiter, |
| | { type: 'error' , msg: "no pie left" , customer: msg.customer }) |
| | return state |
| | } |
| | else { |
| | var slice = state.slices.shift() + " pie slice" ; |
| | dispatch(msg.customer, |
| | { type: 'put on table' , food: slice }); |
| | dispatch(msg.waiter, |
| | { type: 'add to order' , food: slice, customer: msg.customer }); |
| | return state; |
| | } |
| | } |
| | } |
แม้ว่าโดยทั่วไปแล้ว actor มักจะถูกสร้างขึ้นแบบ dynamic โดย actor อื่น แต่ในตัวอย่างนี้เราจะทำให้เรียบง่ายและ start actor ของเราด้วยตัวเองครับ นอกจากนี้เราจะส่ง initial state ให้แต่ละตัวด้วย:
- pie case จะได้รับรายการพายเริ่มต้นที่มันถืออยู่
- เราจะให้ reference ของ pie case กับ waiter
- เราจะให้ reference ของ waiter กับ customer ทุกคน
| | const actorSystem = start(); |
|---|---|
| | |
| | let pieCase = start_actor( |
| | actorSystem, |
| | 'pie-case' , |
| | pieCaseActor, |
| | { slices: [ "apple" , "peach" , "cherry" ] }); |
| | |
| | let waiter = start_actor( |
| | actorSystem, |
| | 'waiter' , |
| | waiterActor, |
| | { pieCase: pieCase }); |
| | let c1 = start_actor(actorSystem, 'customer1' , |
|---|---|
| | customerActor, { waiter: waiter }); |
| | let c2 = start_actor(actorSystem, 'customer2' , |
| | customerActor, { waiter: waiter }); |
และสุดท้ายเราก็เริ่มการทำงานครับ ลูกค้าของเราค่อนข้างโลภ Customer 1 ขอพายสามชิ้น ส่วน Customer 2 ขอสองชิ้น:
| | dispatch(c1, { type: 'hungry for pie' , waiter: waiter }); |
|---|---|
| | dispatch(c2, { type: 'hungry for pie' , waiter: waiter }); |
| | dispatch(c1, { type: 'hungry for pie' , waiter: waiter }); |
| | dispatch(c2, { type: 'hungry for pie' , waiter: waiter }); |
| | dispatch(c1, { type: 'hungry for pie' , waiter: waiter }); |
| | sleep(500) |
| | .then(() => { |
| | stop(actorSystem); |
| | }) |
เมื่อเรารันโค้ดนี้ เราจะเห็น actor สื่อสารกันครับ [49] ลำดับที่คุณเห็นอาจแตกต่างกันไปได้:
| | $ node index.js |
|---|---|
| | customer1 sees "apple pie slice" appear on the table |
| | customer2 sees "peach pie slice" appear on the table |
| | Waiter adds apple pie slice to customer1's order |
| | Waiter adds peach pie slice to customer2's order |
| | customer1 sees "cherry pie slice" appear on the table |
| | Waiter adds cherry pie slice to customer1's order |
| | |
| | The waiter apologizes to customer1: no pie left |
| | customer1 sulks… |
| | |
| | The waiter apologizes to customer2: no pie left |
| | customer2 sulks… |
No Explicit Concurrency (ไม่มี Concurrency ที่ต้องเขียนเอง)
ใน actor model เราไม่จำเป็นต้องเขียนโค้ดเพื่อจัดการ concurrency เลย เพราะไม่มี shared state และก็ไม่ต้องเขียน logic แบบ end-to-end ที่คอยสั่ง “ทำแบบนี้ แล้วทำแบบนั้น” เพราะ actor แต่ละตัวจะจัดการเรื่องนี้ด้วยตัวเองตามข้อความที่ได้รับครับ
และก็ไม่มีการพูดถึง architecture เบื้องหลังเลยด้วยซ้ำ ชุดของ component เหล่านี้ทำงานได้ดีเท่า ๆ กัน ไม่ว่าจะบน processor เดียว หลาย core หรือหลายเครื่องที่เชื่อมต่อกันผ่านเครือข่ายครับ
Erlang Sets the Stage (Erlang ผู้ปูทาง)
ภาษา Erlang และ runtime ของมันเป็นตัวอย่างที่ดีของการ implement actor (แม้ว่าคนที่คิดค้น Erlang จะไม่เคยอ่าน paper ต้นฉบับเรื่อง Actor เลยก็ตาม) Erlang เรียก actor ว่า process แต่มันไม่ใช่ process ของระบบปฏิบัติการทั่วไป ในทางกลับกัน เช่นเดียวกับ actor ที่เราคุยกันมา process ของ Erlang นั้นเบามาก (คุณสามารถรันมันได้เป็นล้าน ๆ ตัวบนเครื่องเดียว) และมันสื่อสารกันด้วยการส่งข้อความ แต่ละตัวถูกแยกออกจากกันโดยสมบูรณ์ จึงไม่มีการแชร์ state ระหว่างกันเลยครับ
นอกจากนี้ runtime ของ Erlang ยัง implement ระบบ supervision ที่คอยจัดการ lifetime ของ process ต่าง ๆ และสามารถ restart process หรือกลุ่มของ process ได้เมื่อเกิดความล้มเหลว Erlang ยังมี hot-code loading ด้วย นั่นคือคุณสามารถเปลี่ยนโค้ดในระบบที่กำลังรันอยู่ได้โดยไม่ต้องหยุดระบบนั้นเลย และระบบของ Erlang ก็รันโค้ดที่เชื่อถือได้มากที่สุดในโลกบางส่วน มักถูกอ้างถึงว่ามี availability ระดับ nine nines ครับ
แต่ Erlang (และภาษาลูกอย่าง Elixir) ก็ไม่ใช่ของเดียวในโลกนะครับ ยังมีการ implement actor สำหรับภาษาส่วนใหญ่อยู่ด้วย ลองพิจารณาใช้มันสำหรับงาน concurrent ของคุณดูครับ
Related Sections Include (หัวข้อที่เกี่ยวข้อง)
- Topic 28, Decoupling
- Topic 30, Transforming Programming
- Topic 36, Blackboards
Challenges (แบบฝึกหัด)
-
ตอนนี้คุณมีโค้ดที่ใช้ mutual exclusion เพื่อป้องกัน shared data อยู่หรือเปล่าครับ ลองเขียน prototype ของโค้ดเดียวกัน โดยใช้ actor ดูสิครับ
-
โค้ด actor สำหรับร้านอาหารนี้รองรับแค่การสั่งพายเป็นชิ้นเท่านั้น ลองขยายมันให้ลูกค้าสั่งพาย à la mode ได้ด้วย โดยให้มี agent แยกกันจัดการพายและไอศกรีมแต่ละสกู๊ป จัดการให้ระบบรองรับสถานการณ์ที่ของอย่างใดอย่างหนึ่งหมดด้วยครับ