การสร้างระบบปฏิบัติการตั้งแต่เริ่มต้นฟังดูเหมือนเป็นงานของเหล่า kernel hacker ที่เขียนด้วยภาษา C แต่คุณสามารถสร้างแบบจำลองอย่างง่ายด้วย Python ได้ภายในบ่ายวันเดียว และคุณจะพบอย่างรวดเร็วว่าตรรกะของการจัดการโปรเซส (process management) นั้นเข้มงวดไม่แพ้กันแม้จะใช้ภาษาระดับสูงก็ตาม ผมเรียนรู้เรื่องนี้ด้วยบทเรียนที่แสนสาหัส ผมนั่งลงเพื่อเขียนโปรแกรมจำลอง OS ขนาดจิ๋ว เป้าหมายนั้นเรียบง่าย: สร้างโปรเซสขึ้นมาไม่กี่ตัว จัดตารางเวลา (schedule) ให้พวกมัน และทำเครื่องหมายว่าเสร็จสิ้นเมื่อทำงานเสร็จ โค้ดนั้นสั้น ตรรกะดูเหมือนจะไร้ช่องโหว่ แต่พอผมรันมัน กลับไม่มีโปรเซสไหนจบการทำงานเลย
Why Build a Mini OS in Python?
ระบบปฏิบัติการจริงๆ ต้องจัดการทั้ง memory paging, file systems, hardware interrupts และ device drivers แต่แบบจำลองจะตัดสิ่งเหล่านั้นออกไปทั้งหมด เพื่อให้คุณโฟกัสที่แนวคิดหลัก นั่นคือ: สถานะ (state) คุณกำหนดโปรเซสขึ้นมาหนึ่งตัว มันจะมี PID, burst time และสถานะวงจรชีวิต (lifecycle status) เช่น Ready, Running หรือ Finished ลูปของ scheduler จะเลือกตัวเลือกถัดไป เลื่อนสถานะของมัน จำลองช่วงเวลา (time slice) และเปลี่ยนสถานะเป็นเสร็จสิ้น (done)
Python เป็นเครื่องมือที่ยอดเยี่ยมสำหรับการทดลองประเภทนี้ เพราะมันช่วยให้คุณไม่ต้องกังวลเรื่อง pointer arithmetic และ memory alignment รายการของ dictionary จะกลายเป็น process table ของคุณ และลูป while จะกลายเป็น kernel scheduler คุณสามารถทำ round-robin scheduling หรือ priority queues ได้โดยใช้เพียงเครื่องมือจาก standard library เท่านั้น มันดูเข้าถึงง่าย ซึ่งนั่นแหละคือสาเหตุที่ทำให้บั๊กที่ตามมานั้นน่าหงุดหงิดใจเหลือเกิน
The Setup
แบบจำลองของผมใช้ list ที่ชื่อว่า process_table โดยแต่ละรายการเป็น dictionary ที่มีรูปแบบดังนี้:
{
"pid": 1,
"burst_time": 3,
"status": "ready"
}
ตัว scheduler ทำงานด้วยลูป while ง่ายๆ มันจะสแกนตารางเพื่อหาโปรเซสแรกที่มีสถานะไม่ใช่ "finished" เมื่อเจอแล้ว มันจะเรียกฟังก์ชันช่วยที่ชื่อ execute_tick(p) เพื่อรันโปรเซสนั้นหนึ่งรอบการจำลอง ภายใน execute_tick ผมตั้งค่าสถานะโปรเซสเป็น "running", ลดค่า burst time ลง และตรวจสอบว่างานที่เหลือเป็นศูนย์หรือไม่ ถ้าเป็นศูนย์ ผมก็จะอัปเดตสถานะเป็น "finished" ลูปชั้นนอกควรจะสิ้นสุดลงเมื่อทุกโปรเซสเข้าสู่สถานะ finished
ในทางทฤษฎี ลำดับขั้นตอนนั้นดูสะอาดตา: หาโปรเซสที่พร้อม -> รันมัน -> ตรวจสอบการเสร็จสิ้น -> ทำซ้ำจนกว่าจะเสร็จ ผมถึงกับเพิ่มคำสั่ง print เพื่อดูการทำงานของ scheduler ผมเห็นโปรเซสถูกเลือก ลูปยังคงหมุนวนไปเรื่อยๆ แต่โปรเซสเหล่านั้นกลับเหมือนติดอยู่ในปัจจุบันกาลชั่วนิรันดร์ รันไปเรื่อยๆ แต่ไม่ยอมก้าวต่อไปเสียที
The Symptom
นี่คือความล้มเหลวประเภทที่แย่ที่สุด นั่นคือความล้มเหลวแบบเงียบเชียบ ไม่มี stack trace พ่นออกมาใน terminal ไม่มี IndexError หรือ KeyError มาเป็นเบาะแสให้ตามรอย ตัว interpreter ทำงานได้อย่างปกติไม่มีปัญหา แต่โปรแกรมแค่ไม่ทำงานตามที่ควรจะเป็น โปรเซสเริ่มทำงาน แต่พวกมันไม่เคยเสร็จสิ้น ผมใช้เวลาหลายชั่วโมงในการไล่ลำดับขั้นตอนใหม่
เงื่อนไขของลูปผิดหรือเปล่า? หรือผมอาจจะคำนวณ burst time ผิดไปหนึ่ง (off-by-one error)? ตารางโปรเซสถูกสร้างซ้ำหรือถูกคัดลอกแทนที่จะอัปเดตที่ตัวเดิมหรือเปล่า? เงื่อนไขการสิ้นสุดตรวจสอบคีย์ผิดตัวไหม? ผมเพิ่มคำสั่ง print มากขึ้น ผมตรวจสอบทุกนิพจน์บูลีน (boolean expression) ผมตั้งคำถามกับทุกอย่าง ยกเว้นบรรทัดเดียวที่สำคัญจริงๆ
The Culprit
แล้วผมก็เห็นมัน ภายใน execute_tick ผมเขียนไว้ว่า:
p["status"] == "running"
เครื่องหมายเท่ากับสองตัว มันคือการเปรียบเทียบ ไม่ใช่การกำหนดค่า (assignment) การแก้ไขนั้นห่างไปเพียงแค่ตัวอักษรเดียวเท่านั้น:
p["status"] = "running"
ใน Python p["status"] == "running" เป็นนิพจน์ที่ถูกต้องสมบูรณ์ มันจะประเมินค่าเป็น True หรือ False จากนั้น interpreter ก็จะทิ้งผลลัพธ์นั้นไปเพราะผมไม่ได้นำไปกำหนดค่าให้สิ่งใดเลย บรรทัดนั้นจึงไม่ได้ทำอะไรที่มีประโยชน์เลย ข้อมูลใน dictionary ไม่ถูกแตะต้อง และยังคงสถานะเดิมที่มันเคยเป็นอยู่ ทำให้โปรเซสไม่สามารถดำเนินต่อไปตามวงจรชีวิตของมันได้
ผมเปลี่ยนมันเป็นเครื่องหมายเท่ากับตัวเดียว ผมรันสคริปต์อีกครั้ง แบบจำลองเริ่มทำงานได้อย่างมีชีวิตชีวา โปรเซสเปลี่ยนสถานะผ่าน ready, running และ finished ตามที่วางแผนไว้เป๊ะ การกดปุ่มเพิ่มเพียงครั้งเดียวทำให้ผมเสียเวลาไปหลายชั่วโมง
Why These Bugs Hide
เหตุผลที่เรื่องนี้มันน่าเจ็บใจนักก็เพราะว่า Python จะไม่แจ้งเตือนนิพจน์ (expression statement) ว่าเป็นข้อผิดพลาด เว้นแต่ว่ามันจะเป็นไวยากรณ์ (syntax) ที่ผิดพลาดอย่างชัดเจน บั๊กนี้คือความผิดพลาดทางความหมาย (semantic typo) โปรแกรมทำการเปรียบเทียบสถานะ สร้างค่าบูลีนขึ้นมา แล้วก็โยนมันทิ้งไป และเนื่องจากการเปรียบเทียบนั้นสามารถคืนค่าเป็น False ได้ โปรเซสจึงติดอยู่ในสถานะเดิม และลูปชั้นนอกก็ไม่มีเหตุผลที่จะต้องหยุดทำงาน
คุณยิ่งซ้ำเติมเรื่องนี้ด้วย confirmation bias เพราะคุณรู้ว่าคุณพิมพ์การกำหนดค่าลงไปเพราะคุณตั้งใจจะทำแบบนั้น เมื่อคุณอ่านโค้ดเป็นครั้งที่ห้า สมองของคุณจะทำการแก้ไขสัญลักษณ์นั้นให้โดยอัตโนมัติ นี่คือเหตุผลว่าทำไมเทคนิค rubber ducking ถึงได้ผล เพราะมันบังคับให้คุณต้องอธิบายแต่ละบรรทัดอย่างช้าๆ จนช่องว่างระหว่างสิ่งที่เขียนไว้กับสิ่งที่คุณตั้งใจจะสื่อนั้นปรากฏให้เห็นชัดเจนขึ้น
บั๊กเล็กๆ แบบนี้หาได้ยากกว่าการที่โปรแกรมค้างหรือพังอย่างรุนแรงเสียอีก เพราะ segfault หรือ syntax error จะแจ้งเตือนให้ทราบทันที แต่ silent no-op จะเพียงแค่ทำให้สถานะ (state) ผิดเพี้ยนไป และปล่อยให้โปรแกรมทำงานต่อไปอย่างติดขัด ความล้มเหลวจะไปปรากฏในขั้นตอนถัดไป (downstream) และสัญชาตญาณของคุณมักจะทำให้คุณไปไล่แก้ที่อาการมากกว่าที่จะแก้ที่ต้นเหตุ
การป้องกันที่ดีกว่า
คุณไม่สามารถเชื่อสายตาตัวเองเพียงอย่างเดียวได้ หลังจากเหตุการณ์นี้ ผมได้เปลี่ยนนิสัยบางอย่างที่จะช่วยให้ตรวจพบข้อผิดพลาดได้เร็วขึ้น
อย่างแรก หากคุณกำลังจัดการสถานะ (state) ใน dictionary ให้ลองพิจารณาใช้ dataclass หรือ enum.Enum สำหรับสถานะของโปรเซส (process states) โดยกำหนดสถานะต่างๆ เป็นค่าคงที่ (constants) หรือสมาชิกของ enum:
from enum import Enum
class ProcessState(Enum):
READY = "ready"
RUNNING = "running"
FINISHED = "finished"
เมื่อมีการระบุประเภทข้อมูล (explicit types) อย่างชัดเจน เครื่องมืออย่าง mypy จะสามารถแจ้งเตือนการเปรียบเทียบที่น่าสงสัยในระหว่างการทำ static analysis ได้ การเปรียบเทียบที่เกิดขึ้นโดยไม่ตั้งใจในจุดที่ควรจะเป็นการกำหนดค่า (assignment) จะสังเกตเห็นได้ง่ายขึ้นมากเมื่อประเภทข้อมูลไม่ตรงกับที่คาดหวังไว้
อย่างที่สอง ให้เขียน unit tests สำหรับการเปลี่ยนสถานะ (state transitions) ก่อนที่คุณจะเขียน logic ของ scheduler การทดสอบง่ายๆ ที่สร้างโปรเซสขึ้นมาเพื่อทำงานเพียงหนึ่ง tick, รัน scheduler และตรวจสอบ (assert) ว่าสถานะสุดท้ายคือ FINISHED จะทำให้การทดสอบล้มเหลวทันที ซึ่งความล้มเหลวนั้นจะช่วยจำกัดขอบเขตการค้นหาให้แคบลงมาอยู่ที่ logic การอัปเดตสถานะ แทนที่จะปล่อยให้ผมต้องไล่หาไปทั่วทั้ง loop
