Goose: सिस्टम प्रोग्रामिंग को Heap की ज़रूरत क्यों नहीं
Goose एक memory-safe सिस्टम भाषा है जिसमें न heap है, न GC, न lifetime annotations। इसका stack-only डिज़ाइन Rust और regions के बारे में क्या बताता है।

यहाँ एक दावा है जो कम से कम दो भाषा-समुदायों को नागवार गुज़रेगा: borrow checker एक भटकाव था। यह गलती नहीं थी। Rust के ownership model ने असली समस्याएँ हल कीं और पूरे उद्योग को आगे बढ़ाया, लेकिन यह एक पुराने, सरल विचार को छोड़कर आगे निकल जाने जैसा था, जिसे हम अब फिर से खोज रहे हैं। वह विचार है regions: बड़ी stack-जैसी arenas में memory आवंटित होना और scope खत्म होने पर पूरे हिस्से का एक साथ मुक्त होना। इस हफ़्ते चर्चा में आई नई systems भाषा Goose इस विचार पर अब तक का मेरा देखा सबसे आक्रामक आधुनिक दाँव है। और इसका डिज़ाइन एक असहज सवाल खड़ा करता है: C++ और Rust में हम जो कुछ झेलते हैं, उसमें से कितना वाकई ज़रूरी है और कितना सिर्फ़ heap की देन है?
Goose में कोई heap allocator नहीं है। कोई garbage collector नहीं, कोई reference counting नहीं, कोई destructor नहीं, कोई lifetime annotation नहीं, और कोई unsafe नहीं। हर dynamic value कंपाइलर द्वारा स्थिर रूप से तय किए गए कुछ data stacks में से किसी एक पर सीधे inline रहती है। बढ़ना मतलब pointer को आगे खिसकाना है। एक करोड़ elements वाले nested structure को मुक्त करना stack के top पर एक ही store है। लेखक के बेंचमार्क के अनुसार Goose idiomatic C++ से (3.3x) और safe Rust से (1.12x) तेज़ चलता है, और कम memory में। John Carmack की सार्वजनिक प्रतिक्रिया ने वही हिस्सा पकड़ा जो मुझे सबसे दिलचस्प लगा: relative references, जो किसी data structure को उसका अपना file format बना देते हैं। चाहे Goose कभी production software में आए या नहीं, यह इस बात का गंभीर thought experiment है कि जब आप heap को हटा दें और उसके लिए माफ़ी न माँगें, तो systems भाषा कैसी दिखती है।
Memory model ही पूरी भाषा है
ज़्यादातर भाषा-फ़ीचर समझौता योग्य होते हैं। Syntax, module system, यहाँ तक कि error handling भी: आप सोच सकते हैं कि दो समझदार भाषाएँ उल्टे चुनाव कर रही हैं। Memory model ऐसी नहीं होती। एक चुन लीजिए और उसके बाद का सब कुछ तय हो जाता है: आपके data structures, concurrency की कहानी, FFI, और error messages। Goose यह बात समझती है और पूरी तरह उसी दिशा में झुकती है।
नियम कहना आसान है: एक प्रोग्राम के पास native call stack, static data, और N data stacks होते हैं, जहाँ N कंपाइलर तय करता है। हर stack पर एक समय में अधिकतम एक resizable value जीवित रहती है, और वह हमेशा top पर होती है। यह एक invariant अविश्वसनीय रूप से बहुत कुछ दे देता है। चूँकि बढ़ने वाली value कभी नहीं हिलती (उसके ऊपर कुछ नहीं होता जिसे स्थानांतरित करना पड़े), बढ़ते array के भीतर के references हमेशा वैध रहते हैं। C++ में std::vector<T> जब reallocate होता है तो उसके हर pointer और iterator अमान्य हो जाते हैं, और इसीलिए पीढ़ियों से C++ programmers push_back के बीच में reference रखने की गलती में फँसते आए हैं। Safe Rust इसे ऐसा बनाकर टालता है कि यह लिखा ही न जा सके; इसकी जगह आपको u32 indices इस्तेमाल करने पड़ते हैं, और उसकी कीमत bounds checks और ergonomics में चुकानी पड़ती है। Goose बस... आपको reference रखने देता है। Type और lifetime नियम compile time पर जाँचे जाते हैं, कंपाइलर हर reference को "rooted" किस चीज़ में है यह अनुमान लगाता है और उन्हें अस्वीकार करता है जो अपने owners से ज़्यादा जी सकते हैं, लेकिन इसके लिए कोई annotation नहीं, कोई aliasing नियम नहीं, कोई exclusivity नहीं।
struct Item { id: i32, weight: f32 }
var items: Item[>..] = [];
let first .= items.push(Item { 1, 0.5 });
for i: i32 in 2..1000001 {
items.push(Item { i, 0.0 });
}
first.weight = 99.5; // still valid, a million pushes later
अगर आपने Rust के borrow checker के साथ कुछ समय बिताया है, तो यह किसी जादू जैसा लगेगा। Lifetimes कहाँ चली गईं? ईमानदार जवाब है: एक restriction में। Rust का ownership system मनमाने sharing patterns के लिए safety सिद्ध कर सकता है: graphs, cycles, scopes के बीच साझा mutable structures। Goose पैटर्न के एक बहुत छोटे सेट के लिए safety सिद्ध करता है और बाकी को सीधे मना कर देता है। यह expressiveness बनाम simplicity वाला व्यापार डिज़ाइन की दुनिया का सबसे पुराना व्यापार है, और इसी धागे को मैं यहाँ खींचता रहूँगा। अगर आपने हमारा लेख Rust के borrow checker में soundness का असली मतलब क्या है पढ़ा है, तो Goose उसी सिक्के का दूसरा पहलू है: soundness किसी ज़्यादा शक्तिशाली analysis से नहीं, बल्कि एक छोटी भाषा से हासिल होती है।
सब कुछ inline: data layout एक प्रथम श्रेणी का फ़ीचर
दूसरा बड़ा विचार यह है कि कोई एक array type नहीं है। Goose के पास array प्रकारों का एक परिवार है, जो केवल इस बात में अलग हैं कि size के साथ क्या होता है: compile time पर तय, निर्माण के बाद जमे हुए, inline capacity के भीतर बढ़ने वाले, केवल बढ़ने वाले, और बढ़ने-घटने वाले। ये सब [metadata][elements...] के रूप में होते हैं: लगातार, पैक्ड, और कभी element block का pointer नहीं। Strings बस u8 arrays हैं, तो एक struct में declaration के क्रम में inline परिवर्तनशील-आकार वाले हिस्से हो सकते हैं। एक record जिसमें परिवर्तनशील-लंबाई वाली string field और परिवर्तनशील-लंबाई वाली sub-records की सूची हो, वह बिना किसी indirection के bytes की एक ही धारा है।
दावा किए गए आँकड़े आकर्षक हैं: Goose में एक order record 29 bytes का है और उसमें शून्य allocations हैं, जबकि C++ में std::string + std::vector<Item> के साथ यह 160 bytes और एक allocation है, और Rust में 153 bytes और चार allocations। अब एक निष्पक्ष सुधार चर्चा में लगभग तुरंत सामने आया: Goose के अपने tutorial का "strings का vector" वाला उदाहरण small-string optimization को नज़रअंदाज़ करता है, जिसे हर मुख्यधारा STL implementation करता है। C++ में छोटी strings allocate नहीं होतीं। इससे तर्क खत्म नहीं होता। SSO लगभग 15–22 bytes से छोटी strings की मदद करता है और परिवर्तनशील-आकार वाले nested data की सामान्य स्थिति में कुछ नहीं करता। फिर भी यह याद दिलाता है कि किसी भाषा के homepage पर बेंचमार्क तालिकाएँ मार्केटिंग होती हैं, भले ही भाषा अच्छी हो। नुकसान भी उन्हीं में दर्ज हैं, और इसका श्रेय लेखक को जाता है, जो ज़्यादातर प्रोजेक्ट नहीं करते।
इसकी असली कीमत है, और Goose इसे छिपाता नहीं: परिवर्तनशील-आकार elements का array क्रमिक होता है। आप उसे iterate कर सकते हैं, पर index नहीं कर सकते। Element k कहाँ है, यह इस पर निर्भर है कि bytes कहाँ पड़ते हैं। Random access चाहिए तो आप references का एक दूसरा array index के रूप में रखते हैं। यह वही तरह का समझौता है जो data-oriented design करने वाले लोग दशकों से हाथ से करते आए हैं (columnar formats और flatbuffers-जैसे layouts असल में इसी तरह काम करते हैं), लेकिन यहाँ यह default है, और इससे बाहर निकलने में अतिरिक्त मेहनत लगती है। यह फ़ीचर है या straitjacket, यह पूरी तरह आपके access patterns पर निर्भर करता है।
Relative references: आपका data structure ही उसका file format है
Goose का मेरा पसंदीदा विचार relative reference है, और Carmack ने भी इसी पर सबसे ज़्यादा ध्यान दिया। सामान्य reference एक machine address है: आठ bytes, absolute, और process के बाहर बेमतलब। Relative reference वही link 1, 2 या 4 bytes के offset के रूप में रखता है, जो या तो field से मापा जाता है या किसी नामित pool के base से। Self-relative links से बने structures position independent होते हैं। memory में वे जहाँ भी हों, उनका अर्थ एक ही रहता है। यानी data structure को सहेजना एक write() है, और उसे लोड करना एक read() और एक verification pass।
वह आख़िरी वाक्यांश जितना लगता है उससे ज़्यादा मायने रखता है। इस तरकीब का सीधा संस्करण, यानी pointers से भरी फ़ाइल को memory-map करना, एक क्लासिक security disaster है। Goose का संस्करण validate करता है: from_bytes bytes के value बनने से पहले framing, हर tag, हर length और हर link जाँचता है, इसलिए दूषित या दुर्भावनापूर्ण फ़ाइल आपको wild pointer नहीं, false देती है। आपको mmap किए गए structures की गति मिलती है, साथ में parser जैसी safety का कुछ हिस्सा।
Carmack का तर्क बिल्कुल सही था: "file और network data का 'parsing' अक्सर ज़रूरत से 100 गुना धीमा होता है।" जिसने भी deserialization hot path को profile किया है, वह इसे जानता है। अब तक उद्योग का जवाब schema-आधारित zero-copy formats रहे हैं, जैसे Cap'n Proto और FlatBuffers। ये काम करते हैं, पर आपके प्रोजेक्ट में एक schema compiler और एक runtime library जोड़ देते हैं। Goose का जवाब यह है कि memory layout और wire layout निर्माण से ही एक चीज़ हैं, क्योंकि इस भाषा में absolute addresses के pointers कभी थे ही नहीं। Binary search tree का उदाहरण लगभग शरारती है: 12-byte nodes, links समेत, insert एक push और एक store है, और पूरा tree अपने bytes लिख कर persist हो जाता है। Lisp से जुड़े लोग दार्शनिक चाल पहचान लेंगे: वह भेद मिटाना जिसे सब मौलिक मानते थे। यही वह बात है जिसकी वजह से Lisp आज भी क्यों मायने रखता है, साठ साल बाद भी एक design reference point के रूप में।
हम पहले भी यहाँ आ चुके हैं: regions, arenas और ML Kit
Goose पर इतिहास जानने वालों की प्रतिक्रिया टिप्पणियों में ठीक समय पर आ गई: "मुझे याद है ML वाले लोग इसी रास्ते पर गए थे। मेरे ख़्याल से वे इसे regions कहते थे।" सही है। Region-based memory management 1990 के दशक के ML Kit काम तक जाता है। Tofte और Talpin के region inference ने ML compiler को garbage collection की जगह स्थिर रूप से तय stack-जैसे regions देने दिए, जिनमें पूरे regions बिना किसी per-object bookkeeping के आवंटित और मुक्त होते थे। और ईमानदार ऐतिहासिक फ़ैसला यह है कि इसे एक तरह का dead end घोषित किया गया: region inference काम करता था, पर असली programs में पैथोलॉजिकल memory growth दिखी, यानी regions में मरा हुआ data बहुत लंबे समय तक ज़िंदा रहता था। शोध समुदाय ज़्यादातर आगे बढ़ गया। Garbage collectors ने अपना ताज बरकरार रखा।
पर यह विचार कभी मरा नहीं; उसने बस काम बदल लिया। Arena allocation game engines, compilers और high-performance servers में मानक तरीका है। Apache के memory pools, web servers में per-request arenas, game loops में हर frame पर scratch buffer को reset करने का पैटर्न, ये सब दूसरे नाम वाले regions हैं, जिन्हें हाथ से संभाला जाता है क्योंकि भाषा इसमें मदद नहीं करती। ML Kit से Goose अलग कैसे है, यह सटीक रूप से कहना उपयोगी है: किसी general-purpose functional भाषा के लिए regions का अनुमान लगाने (कठिन और अप्रत्याशित) के बजाय यह stack model को सीधे उजागर करता है और प्रोग्रामर के इरादे को type का हिस्सा बना देता है। T[>..] कहता है: "यह बढ़ता है, references वैध रहते हैं।" कंपाइलर का काम एक घोषित अनुशासन की जाँच करना है, उसे खोजना नहीं। यह कहीं आसान समस्या है, और यही वजह है कि Rust वहाँ सफल हुआ जहाँ पहले की automatic-lifetime योजनाएँ अटक गई थीं: प्रोग्रामर ऐसे contract को झेल लेते हैं जिसे वे देख सकते हैं।
यहाँ production भाषाएँ शोध को कैसे आत्मसात करती हैं, इसका एक बड़ा सबक है। Region inference को GC की पूरी स्वचालित जगह लेने का प्रयास: असफल। Regions को एक मैनुअल systems पैटर्न के रूप में: सर्वव्यापी। Borrow checking: बड़ी सफलता, जो खुद region विचारों और linear types से भारी उधार लेती है। Erlang-शैली के share-nothing processes: सही साबित हुए, और Goose के threads ठीक वैसे ही हैं: अलग प्रोग्रामों के रूप में compile किए गए workers, जिनकी अपनी memory है, और values typed queues के ज़रिए memcpy की तरह आगे जाते हैं, जहाँ data races को दर्शाया ही नहीं जा सकता। कोई भी पहली बार में पूरी तस्वीर सही नहीं बनाता। विचार दशकों तक घूमते हैं, फिर ऐसे रूप में उतरते हैं जो काम करता है।
जहाँ यह मॉडल टूटता है
मैंने कहा था कि मैं एक विरोधी दावे से शुरू करूँगा और फिर बताऊँगा कि वह कहाँ टूटता है। यह रहा वह स्वीकार। अगर आपके प्रोग्राम के sharing patterns सच में मनमाने हैं, तो borrow checker भटकाव नहीं है, और ऐसे प्रोग्रामों का एक विशाल वर्ग ठीक इसी आकार का होता है। Goose की चर्चा में सबसे तीखा सवाल सबसे सरल भी था: एक hash table बनाएँ जो keys को strings से जोड़ती है, जहाँ प्रोग्राम किसी भी समय random strings डालता और हटाता है? Deletion ही असली मुसीबत है। केवल बढ़ने वाला stack बीच के किसी छेद को वापस नहीं ले सकता। सुझाए गए उपाय (हर size class के लिए एक stack, समय-समय पर compaction, या freelist वाला pool जहाँ "मुक्त करने" का मतलब बस इतना है कि slot फिर इस्तेमाल हो सकता है) सब काम करते हैं, और pool वाला डिज़ाइन सचमुच चतुर है (पुराना slot memory को भ्रष्ट करने के बजाय सही type का एक अलग मान पढ़ता है)। पर हर उपाय में प्रोग्रामर उस data structure के लिए memory manager का एक टुकड़ा हाथ से दोबारा लिख रहा होता है।
इसी तरह memory उपयोग का सवाल है। GC न होने पर बढ़ता stack, scope खत्म होने तक, वह सब रखता है जो उसने कभी बढ़कर पकड़ा था। अनियमित जीवनकाल वाले लंबे समय तक चलने वाले programs (थ्रेड में उठा JSON-document सवाल, caches, session state) या तो scope-अनुशासित पैटर्न में फिट होते हैं या नहीं होते, और जब नहीं होते, तो कोई भी सुंदरता आपको नहीं बचाती। ML Kit की विफलता का तरीका (ज़िंदा regions में मरा हुआ data अटकना) Goose में गायब नहीं होता; वह दिखाई देने लगता है और मैनुअल हो जाता है, जो शायद बेहतर है, पर रहता तो है ही। एक टिप्पणीकार ने सही रुख़ रखा: Goose एक सीमित उद्देश्य के लिए बनी भाषा है, और यह ठीक है। Data-oriented workloads, compilers, game entity systems, और पाइपलाइन-आकार के काम, यानी पढ़ो, बदलो, emit करो, reset करो, इनमें यह खूबसूरती से फिट होती है। लंबे समय तक जीने वाला mutable object graph नहीं।
मुख्य आँकड़ों पर एक और चेतावनी। अपने ही बेंचमार्क suites एक ऐसी विधा हैं जिसकी विफलता के रास्ते ज्ञात हैं: आप अपने सबसे अच्छे Goose की तुलना किसी के "idiomatic" C++ से कर रहे होते हैं, और idioms ही वह जगह हैं जहाँ झगड़े शुरू होते हैं। SSO वाली चूक दिखाती है कि तुलना वाला कोड प्रतिकूल रूप से tune नहीं किया गया था। Safe Rust पर 1.12x का आँकड़ा 3.3x से ज़्यादा दिलचस्प है, ठीक इसलिए कि वह छोटा है: यह बताता है कि memory model layout और allocation हटाने में जीतता है, किसी जादू में नहीं, और arenas व indices वाला tune किया हुआ Rust संस्करण उस अंतर का बड़ा हिस्सा पाट देता। यही बात अपने आप में मुद्दा है: आप arena-आधारित Rust लिख सकते हैं, पर भाषा उसे अटपटा बनाती है, और अटपटापन ही तय करता है कि असल में कौन सा कोड लिखा जाता है।

Production भाषाएँ Goose से वास्तव में क्या चुराएँगी
अगले कुछ वर्षों में लगभग कोई production Goose नहीं लिखेगा। एक व्यक्ति द्वारा बनाई गई भाषाएँ, जो एक single C backend से compile होती हैं, इतनी जल्दी वहाँ नहीं पहुँचतीं, और लेखक खुद अपनी स्थिति के बारे में यही कहता है। यह दिलचस्प सवाल नहीं है। दिलचस्प सवाल यह है कि कौन से विचार उन भाषाओं में पहुँचते हैं जिन्हें हम पहले से इस्तेमाल करते हैं, क्योंकि यह हमेशा ऐसे ही होता है। मेरी भविष्यवाणियाँ, भरोसे के क्रम में:
- Verified loading के साथ position-independent structures। Safety pass वाला zero-copy serialization इतना अच्छा है कि वह niche बना नहीं रह सकता। जो काम FlatBuffers अटपटे ढंग से करता है, उसके भाषा-एकीकृत संस्करण आएँगे, खासकर जब data-भारी सेवाएँ parse की लागत से टकराती रहेंगी।
- Arrays एक type नहीं, बल्कि एक परिवार। "Size के साथ क्या होता है?" "Stack या heap?" से बेहतर संगठनात्मक सवाल है। Fixed, frozen, capacity-bounded, केवल-बढ़ने-वाले: ये भेद हर codebase में अनौपचारिक परंपराओं के रूप में पहले से मौजूद हैं। इन्हें types बनाना सस्ता है और असली bugs पकड़ता है।
- Compile-time function values। Goose function values को compile-time entities मानता है, इसलिए
xs.filter() { it > 0 }वैसा ही loop बन जाता है जैसा वह दिखता है। C++ templates और Rust monomorphization यह पहले से करते हैं; फ़र्क यह है कि Goose इसे एकमात्र तरीका बनाता है, और runtime function-pointer वाले खतरे को पूरी तरह हटा देता है। - स्थिर रूप से जाँचे गए multi-frame returns।
return err from loadएक फ़ंक्शन से N frames ऊपर तक लौटता है, जिसे compile time पर जाँचा जाता है, बिना unwinder के, बिनाResultकी पिरामिड के, और हर call पर?के बिना। Effect systems इसी विचार के चारों ओर दूसरी दिशा से घूम रहे हैं। इनमें से कोई एक इस दशक में किसी मुख्यधारा भाषा में उतरेगा।
Share-nothing threading model का ज़िक्र भी ज़रूरी है, ज़्यादातर इसलिए क्योंकि यह सबसे कम नया है और सबसे ज़्यादा सही साबित हुआ है: यह Erlang के processes हैं, Dart के isolates हैं, actor model है, बिना उसकी विचारधारा के। यह बार-बार नए सिरे से खोजा जाता है, और इससे पता चलता है कि साझा mutable state पर उद्योग की सहमति सिद्धांत में तय है पर व्यवहार में अनदेखी की जाती है। Goose कम से कम इस अनदेखी को असंभव बना देता है।
असली तर्क: restriction से सरलता
पीछे हटकर देखें तो Goose का डिज़ाइन एक ही चाल का दोहराव है: उन प्रोग्रामों का समूह सिकोड़ना जिन्हें दर्शाया जा सकता है, ताकि safety सिद्ध करना सस्ता हो जाए। Heap नहीं, तो dangling heap pointers नहीं। केवल-बढ़ने-वाले stacks, तो references अमान्य नहीं हो सकते। अलग-worker threads, तो races संभव नहीं। Copy-free निर्माण, तो अनजाने में बने temporaries नहीं। हर restriction बग की एक पूरी श्रेणी और भाषा की मशीनरी की एक पूरी श्रेणी हटा देती है, जैसे borrow checker के aliasing नियम, destructor, और unwinder, जो उन बगों को संभालने के लिए ही थे।
यह Rob Pike के "less is exponentially more" तर्क का बिना दस्ताने वाला रूप है, और यह उसी तनाव पर पहुँचता है जिस पर Pike की Go पहुँची थी: restrictions तब तक आज़ादी जैसी लगती हैं जब तक आपका प्रोग्राम ही वह न हो जिसे restriction बाहर कर दे, और तब वे दीवार जैसी लगती हैं। Go ने एक दशक तक generics को बाहर रखा और फिर भी टिकी रही; उसने कई सही प्रोग्रामों को भी बाहर रखा। Goose उससे कहीं ज़्यादा बाहर रखता है। फिर भी ध्यान देने का कारण यह है कि Pike के सरलता वाले पुराने नियम ठीक इसलिए अच्छी तरह उम्रदराज़ हुए हैं, क्योंकि complexity का बजट असली है: भाषा जो भी guarantee अपने आप देती है, हर प्रोग्रामर को उसे अपने दिमाग में नहीं रखना पड़ता।
तो सवाल: क्या borrow checker एक भटकाव था? General-purpose systems programming के लिए, नहीं। मनमाने sharing वाले मामलों के लिए Rust की expressiveness सचमुच ज़रूरी है, और उसकी सफलता ने उद्योग को ownership के बारे में बात करने की एक शब्दावली दी। पर systems code के उस बड़े, शांत बहुमत के बारे में क्या जो पाइपलाइन-आकार, arena-आकार, या request-आकार का है? Goose इस बात का प्रमाण है कि शायद हम memory safety, गति और छोटे binaries एक अंश के ताम-झाम के साथ पा सकते थे, अगर हम और प्रोग्रामों को "नहीं" कहने के लिए राज़ी होते। Language design यह चुनने की कला है कि किन प्रोग्रामों को मना करना है। Goose उन प्रोग्रामों को मना करता है जिनकी हम आदी हैं, और नतीजा पिछले कुछ वर्षों में मेरी पढ़ी सबसे दिलचस्प systems language की रूपरेखा है। मुझे उम्मीद नहीं कि मैं इसे कभी ship करूँगा। पर मुझे उम्मीद है कि मैं इससे चुराता रहूँगा।
Goose का दाँव यह है कि एक भाषा Rust से ज़्यादा प्रोग्रामों को मना कर सकती है और फिर भी उपयोगी रह सकती है। बेंचमार्क बताते हैं कि इसे गंभीरता से लेना चाहिए, नुकसानों समेत।


