शून्य से एक ऑपरेटिंग सिस्टम बनाना C में लिखने वाले कर्नल हैकर्स का काम लगता है। लेकिन आप एक दोपहर में Python में एक सरल सिमुलेशन तैयार कर सकते हैं, और आप जल्द ही पाएंगे कि प्रोसेस मैनेजमेंट (process management) का लॉजिक एक हाई-लेवल लैंग्वेज में भी उतना ही कठिन होता है। मैंने यह सबक बहुत मुश्किल से सीखा। मैं एक छोटा सा OS सिम्युलेटर लिखने बैठा। लक्ष्य मामूली था: कुछ प्रोसेस बनाना, उन्हें शेड्यूल करना, और काम पूरा होने पर उन्हें 'finished' मार्क करना। कोड छोटा था। लॉजिक अभेद्य लग रहा था। फिर मैंने इसे चलाया, और कुछ भी समाप्त (die) नहीं हुआ।
Python में मिनी OS क्यों बनाएं?
एक वास्तविक ऑपरेटिंग सिस्टम मेमोरी पेजिंग, फ़ाइल सिस्टम, हार्डवेयर इंटरप्ट और डिवाइस ड्राइवर को संभालता है। एक सिमुलेशन इन सब चीजों को हटा देता है और आपको मुख्य विचार पर ध्यान केंद्रित करने देता है: स्टेट (state)। आप एक प्रोसेस को परिभाषित करते हैं। इसमें एक PID, एक burst time और एक लाइफसाइकिल स्टेटस होता है। Ready. Running. Finished. एक शेड्यूलर लूप अगले उम्मीदवार को चुनता है, उसकी स्टेट को आगे बढ़ाता है, एक टाइम स्लाइस का सिमुलेशन करता है, और उसे 'done' में बदल देता है।
Python इस तरह के प्रयोग के लिए एक बेहतरीन माध्यम है क्योंकि यह आपको पॉइंटर अरिथमेटिक (pointer arithmetic) और मेमोरी अलाइनमेंट (memory alignment) को नज़रअंदाज़ करने की अनुमति देता है। डिक्शनरी की एक लिस्ट आपकी प्रोसेस टेबल बन जाती है। एक while लूप आपका कर्नल शेड्यूलर बन जाता है। आप स्टैंडर्ड लाइब्रेरी टूल्स के अलावा और कुछ भी उपयोग किए बिना राउंड-रॉबिन शेड्यूलिंग या प्रायोरिटी क्यू (priority queues) लागू कर सकते हैं। यह काफी सरल लगता है, और यही कारण है कि इसके बाद आने वाला बग इतना कष्टप्रद था।
सेटअप
मेरे सिमुलेशन में process_table नामक एक लिस्ट का उपयोग किया गया था। प्रत्येक एंट्री इस तरह की एक डिक्शनरी थी:
{
"pid": 1,
"burst_time": 3,
"status": "ready"
}
शेड्यूलर एक साधारण while लूप चलाता था। यह टेबल में उस पहले प्रोसेस को खोजता था जिसका स्टेटस "finished" नहीं था। जब उसे कोई प्रोसेस मिलता, तो वह एक सिम्युलेटेड साइकिल के लिए उस प्रोसेस को चलाने के लिए एक हेल्पर फंक्शन, execute_tick(p), को कॉल करता था। execute_tick के अंदर, मैंने प्रोसेस स्टेटस को "running" पर सेट किया, burst time को कम किया, और चेक किया कि क्या बचा हुआ काम शून्य हो गया है। यदि ऐसा होता, तो मैं स्टेटस को "finished" में अपडेट कर देता। बाहरी लूप को तब समाप्त होना था जब हर प्रोसेस 'finished' स्टेट में पहुँच जाए।
कागज़ पर, फ्लो (flow) एकदम साफ था। एक रेडी प्रोसेस ढूँढें। उसे चलाएं। पूरा होने की जाँच करें। पूरा होने तक दोहराएं। मैंने शेड्यूलर को अपना काम करते देखने के लिए प्रिंट स्टेटमेंट्स भी जोड़े थे। मैं देख सकता था कि प्रोसेस चुने जा रहे हैं। लूप चलता रहा। फिर भी ऐसा लग रहा था कि प्रोसेस एक अनंत वर्तमान काल (eternal present tense) में प्रवेश कर गए हैं, हमेशा चलते रहते हैं, कभी आगे नहीं बढ़ते।
लक्षण
यह सबसे खराब तरह की विफलता है: शांत विफलता (silent failure)। टर्मिनल में कोई स्टैक ट्रेस (stack trace) नहीं आया। किसी IndexError या KeyError ने मुझे आगे बढ़ने का कोई सुराग नहीं दिया। इंटरप्रेटर पूरी तरह से खुश था। प्रोग्राम ने बस वैसा व्यवहार नहीं किया जैसा अपेक्षित था। प्रोसेस शुरू हुए, लेकिन वे कभी समाप्त नहीं हुए। मैंने घंटों फ्लो को फिर से समझने में बिता दिए।
क्या लूप की कंडीशन गलत थी? शायद burst time कैलकुलेशन में मुझसे 'off-by-one' एरर हो गया था। क्या प्रोसेस टेबल को इन-प्लेस अपडेट करने के बजाय शैडो (shadow) या कॉपी किया जा रहा था? क्या मेरी टर्मिनेशन कंडीशन गलत की (key) को चेक कर रही थी? मैंने और अधिक प्रिंट्स जोड़े। मैंने हर बूलियन एक्सप्रेशन (boolean expression) का ऑडिट किया। मैंने उस एक लाइन को छोड़कर सब कुछ पर सवाल उठाए जो वास्तव में मायने रखती थी।
अपराधी
तभी मैंने उसे देखा। execute_tick के अंदर, मैंने लिखा था:
p["status"] == "running"
दो बराबर के निशान। एक तुलना (comparison), असाइनमेंट (assignment) नहीं। सुधार बस एक कैरेक्टर की दूरी पर था:
p["status"] = "running"
Python में, p["status"] == "running" एक पूरी तरह से वैध एक्सप्रेशन है। यह True या False के रूप में इवैल्यूएट होता है, और फिर इंटरप्रेटर परिणाम को छोड़ देता है क्योंकि मैंने इसे कभी किसी चीज़ को असाइन नहीं किया था। यह लाइन बिल्कुल भी उपयोगी काम नहीं करती है। डिक्शनरी एंट्री अपरिवर्तित रही, और जो भी स्टेटस पहले था उसे बनाए रखा, और प्रोसेस कभी भी अपनी लाइफसाइकिल में आगे नहीं बढ़ पाया।
मैंने इसे सिंगल इक्वल साइन में बदल दिया। मैंने स्क्रिप्ट को फिर से चलाया। सिमुलेशन में जान आ गई। प्रोसेस ठीक उसी तरह 'ready', 'running', और 'finished' के चक्र से गुजरे जैसा कि योजना बनाई गई थी। एक अतिरिक्त कीस्ट्रोक ने मुझे घंटों का नुकसान करा दिया था।
ये बग क्यों छिप जाते हैं
इसके इतना चुभने का कारण यह है कि Python किसी एक्सप्रेशन स्टेटमेंट को तब तक एरर के रूप में फ्लैग नहीं करता जब तक कि वह पूरी तरह से अमान्य सिंटैक्स (invalid syntax) न हो। बग एक सिमेंटिक टाइपो (semantic typo) था। प्रोग्राम ने स्टेटस की तुलना की, एक बूलियन बनाया, और उसे फेंक दिया। चूंकि तुलना स्वयं False लौटा सकती थी, इसलिए प्रोसेस अपनी पिछली स्थिति में ही फंसा रहा, और बाहरी लूप के पास टूटने (break) का कोई कारण नहीं था।
आप इसे confirmation bias के साथ और भी जटिल बना देते हैं। आप जानते हैं कि आपने एक assignment टाइप किया है क्योंकि आपका इरादा एक assignment करने का ही था। जब आप पांचवीं बार कोड पढ़ते हैं, तो आपका मस्तिष्क उस सिंबल को अपने आप सही कर लेता है। यही कारण है कि rubber ducking काम करती है। यह आपको प्रत्येक लाइन को इतनी धीरे बोलने के लिए मजबूर करती है कि जो लिखा गया है और जो आपका मतलब था, उनके बीच का अंतर स्पष्ट रूप से दिखाई देने लगता है।
इस तरह के छोटे बग्स को ढूंढना बड़े क्रैश की तुलना में अधिक कठिन होता है। एक segfault या syntax error तुरंत खुद को प्रकट कर देता है। एक शांत no-op बस स्टेट को दूषित कर देता है और प्रोग्राम को बिना किसी त्रुटि के आगे बढ़ने देता है। विफलता बाद के चरणों (downstream) में होती है, और आपकी प्रवृत्ति कारण के बजाय लक्षण को डिबग करने की होती है।
एक बेहतर बचाव
आप केवल अपनी आँखों पर भरोसा नहीं कर सकते। इस घटना के बाद, मैंने अपनी कुछ आदतों को बदल दिया जो इस गलती को पहले ही पकड़ लेतीं।
सबसे पहले, यदि आप एक dictionary में state बनाए रख रहे हैं, तो process states के लिए dataclass या enum.Enum का उपयोग करने पर विचार करें। अपने statuses को constants या enum members के रूप में परिभाषित करें:
from enum import Enum
class ProcessState(Enum):
READY = "ready"
RUNNING = "running"
FINISHED = "finished"
explicit types के साथ, mypy जैसे टूल्स static analysis के दौरान संदिग्ध तुलनाओं को चिह्नित कर सकते हैं। जहाँ एक assignment होना चाहिए था, वहाँ गलती से हुई तुलना को पहचानना बहुत आसान हो जाता है जब types अपेक्षाओं के अनुरूप नहीं होते हैं।
दूसरा, scheduler logic लिखने से पहले state transitions के लिए unit tests लिखें। एक साधारण टेस्ट जो एक 'tick' के काम के साथ एक प्रोसेस बनाता है, scheduler चलाता है, और यह सुनिश्चित (assert) करता है कि अंतिम state FINISHED है, वह तुरंत विफल हो जाता। उस विफलता ने मुझे पूरे लूप में भटकने देने के बजाय खोज को सीधे state update logic तक सीमित कर दिया होता।
