- Go में सिर्फ goroutine और channels से कुछ concurrency patterns स्वाभाविक नहीं लगते; coroutines ऐसा प्रस्ताव हैं जो parallelism के बिना execution flow को स्पष्ट रूप से आगे सौंपकर program को बेहतर ढंग से संरचित करने में मदद करते हैं
- Coroutines
resume और yield के जरिए execution control का आदान-प्रदान करते हैं, और एक समय में केवल एक ही चलता है, इसलिए shared data races से बचते हुए switch points synchronization points बन जाते हैं
- Python generator और CLU iterator coroutines से मिलते-जुलते हैं, लेकिन
yield की जगह सीमित होती है; इसलिए Lua-style nested tree traversal को सीधे port करने पर कुछ values गायब हो जाती हैं
- Go का
coro.New channels और goroutine से भी व्यक्त किया जा सकता है, और coro.Pull push iterator को ऐसे pull iterator में बदलता है जो हर call पर एक value निकालता है
- Channel-based implementation में हर switch लगभग 190ns का था; runtime-level direct switch इसे घटाकर लगभग 20ns प्रति switch और
coro.Pull के लिए लगभग 40ns प्रति value तक ला सकता है, ताकि real-world bottleneck से बचा जा सके
Coroutines का execution model
- Coroutines function calls जैसे दिखते हैं, लेकिन अलग-अलग stacks पर चलते हैं और साथ-साथ execute नहीं होते
- अगर
F, G को start करता है, तो G तुरंत execute नहीं होता; F के explicit resume करने पर ही चलता है
G execution के दौरान कभी भी yield के जरिए execution control वापस F को दे सकता है
- जब
G return करता है, तो वह clean up हो जाता है, और F को signal मिलता है कि उसे अब G को resume नहीं करना चाहिए
- इस model में एक समय में केवल एक coroutine चलता है, और caller किसी दूसरे stack पर wait करता है
- Execution switch program के केवल खास points पर होता है, इसलिए कई flows coordinated तरीके से बारी-बारी से चलते हैं
Lua example से coroutines को समझना
- Lua 5 example अलग-अलग structure वाले दो binary trees की तुलना करता है कि क्या उनमें values की same sequence है
t1 और t2 में 1, 2, 3, 4, 5 शामिल हैं
t3 में 1, 2, 3, 4, 6 शामिल हैं
visit(t) tree को inorder traverse करता है और हर value को coroutine.yield(t.value) से emit करता है
- Comparison function दो
visit coroutines बनाता है और बारी-बारी से coroutine.resume करके अगली value पढ़ता है
- अगर दोनों coroutines के खत्म होने की स्थिति या values अलग हों, तो
false
- अगर दोनों खत्म हो जाएं, तो
true
- ज्यादा idiomatic Lua code
coroutine.wrap से coroutine object को hide करने वाला next function पाता है
- Coroutine खत्म होने पर
next function nil return करता है
- पूरा code Gist में है
Python generator और CLU iterator की सीमाएं
- Python generator Lua coroutine जैसा दिखता है, लेकिन वही model नहीं है
- Lua example को सीधे Python में बदलने पर
visit(t['left']) असल traversal execute नहीं करता, सिर्फ generator object बनाकर छोड़ देता है
- अगर function body में
yield है, तो def visit normal function नहीं बल्कि generator define करता है
- सरल translation example tree से सिर्फ 4 print करता है और 1, 2, 3, 5 खो देता है
- सही Python code nested generator को सीधे iterate करके फिर से
yield करता है
- Python 3.3 का
yield from इस pattern को आसान बनाता है
- Python generator object सिर्फ एक
visit call की state रखता है
- Local variable values और currently executing line generator object में store होती हैं
- Resume होने पर वह state call stack पर चढ़ती है, और
yield पर फिर generator object में वापस निकल जाती है
yield केवल top-level call frame में ही possible है
- CLU ने इस abstraction को iterator कहा और statically
iter और proc को अलग किया
- Type information की वजह से compiler iterator को normal function की तरह गलत call करने वाले usage को diagnose कर सकता था
- Barbara Liskov आदि का 1977 का paper “Abstraction Mechanisms in CLU” बताता है कि iterator coroutine का limited form है, इसलिए इसे सिर्फ program stack से implement किया जाता है
Coroutines, threads और generator में फर्क
- तीनों concepts किसी न किसी तरह की concurrency देते हैं, लेकिन उनकी शक्ति और cost अलग होती है
-
Coroutines
- Parallelism के बिना concurrency देते हैं
- अगर एक coroutine चल रहा है, तो जिस coroutine ने उसे resume किया था या जिसने उसे yield किया था, वह नहीं चलता
- Switch points explicit होते हैं, इसलिए data sharing में races नहीं बनतीं
coroutine.resume या next call जैसे switches synchronization points बनकर happens-before edge बनाते हैं
- Operating system के बिना explicit scheduling होती है, इसलिए switch लगभग 10ns या उससे कम तक संभव है
-
Threads
- Coroutines से ज्यादा powerful होते हैं, और अतिरिक्त शक्ति parallelism है
- इसकी कीमत scheduling overhead, ज्यादा महंगा context switch, और किसी न किसी तरह के preemption की जरूरत है
- सामान्य thread switch कुछ microseconds के स्तर का होता है
-
Go goroutine
- इस classification में ये सस्ते threads के करीब हैं
- Go runtime scheduling का कुछ हिस्सा संभालता है, इसलिए switch कुछ सौ ns के करीब होता है
- Threads की तरह parallelism और preemption देते हैं
- Java का नया lightweight thread भी मूल रूप से goroutine जैसा ही है
-
Generator
Go में coroutines की जरूरत वाले मामले
- Go की मौजूदा concurrency libraries coroutine pattern को सीधे provide नहीं करतीं
- Goroutine अक्सर काफी समान होती है, लेकिन parallelism और preemption की वजह से coroutine से अलग results दे सकती है
- Rob Pike की 2011 की presentation “Lexical Scanning in Go”
text/template package के शुरुआती lexer और parser design पर चर्चा करती है
- Lexer और parser अलग goroutine में चलते हैं और channel से जुड़े होते हैं
- यह coroutine pair की अधूरी नकल करने वाला structure था
- Parser हालिया token process कर रहा होता था, तब lexer अगला token पहले से देख लेता था
- Generator उस lexer के लिए पर्याप्त नहीं था जिसे कई functions से values
yield करनी थीं
- Goroutine के parallelism ने races बनाए, और आखिर में design बदलकर lexer state को object में store करने वाला हो गया
- अगर सही coroutines होतीं, तो races से बचा जा सकता था और वह goroutine से ज्यादा efficient होता
- Future use case के तौर पर generic collection traversal है
- Go में functions के लिए range support पर चर्चा हो चुकी है
- यह collection और abstraction authors को CLU-style iterator functions provide करने के लिए प्रेरित कर सकता है
- Go में अभी भी function values का उपयोग करके push iterator implement किया जा सकता है
- उदाहरण:
func (t *Tree[V]) All(yield func(v V))
- अभी इसे
t.All(func(v V) { fmt.Println(v) }) की तरह call किया जा सकता है
- भविष्य में
for v := range t.All जैसा form संभव हो सकता है
- Single
for loop में fit न होने वाला traversal समस्या है
- Binary tree comparison की तरह ऐसे cases हो सकते हैं जहां दो traversals को आपस में interleave करना पड़े
- Coroutines
(*Tree).All जैसे push iterator को ऐसे pull iterator में बदल सकते हैं जो हर call पर एक value return करता है
शुद्ध Go में लिखा गया coro.New
- अगर Go में coroutines जोड़े जाएँ, तो यह भाषा बदले बिना संभव होना चाहिए, और सामान्य Go code के रूप में समझा व implement किया जा सकना चाहिए
- एक सरल
coro.New को channels और goroutine से व्यक्त किया जाता है
cin input value पास करता है
cout output value लौटाता है
resume cin में value भेजता है और cout से result का इंतज़ार करता है
- नई goroutine शुरुआत में
<-cin पर blocked रहती है, इसलिए parallel execution का मौका नहीं होता
yield जोड़ने पर f चलते समय value बाहर भेजता है, और caller अगले resume में फिर value डाल सकता है
yield(out) cout में value भेजता है और cin से अगले input का इंतज़ार करता है
- यह भी send-receive pair है, इसलिए parallelism नहीं है
- यह communication pattern goroutine को coroutine जैसा behave करने तक सीमित करता है
- असल में यह goroutine ही है, लेकिन
resume और yield switch operations की भूमिका निभाते हैं
string parser example
- “Storing Data in Control Flow” की problem यह है कि
func parseQuoted(read func() byte) bool को अलग control flow में चलाया जाए, और bytes को Write method से एक-एक करके supply किया जाए
coro.New इस्तेमाल करने पर इसे पिछले लेख के temporary channel-based implementation की तुलना में higher level पर लिखा जा सकता है
Init coparse function define करता है
read NeedMoreInput को yield करता है और फिर caller द्वारा भेजा गया byte लौटाता है
parseQuoted(read) का boolean result BadInput या Success में बदल जाता है
p.resume(0) parseQuoted के पहले read तक execution आगे बढ़ाता है
Write(c byte) p.resume(c) को call करने वाला एक thin wrapper बन जाता है
- पूरा code Go Playground पर है
prime sieve example
- Doug McIlroy का concurrent prime sieve एक pipeline है जिसमें हर prime
p के लिए एक coroutine होता है
- हर filter अपने left neighbor से number लेता है, और अगर वह
p से divisible नहीं है तो उसे right neighbor को pass कर देता है
- सबसे बाएँ छोर का counter 2, 3, 4, ... supply करता है
- सबसे दाएँ छोर की output coroutine primes पढ़कर print करती है और नई filter coroutine बनाती है
counter एक function है जो values yield करने वाले loop को coro.New में wrap करता है
more bool यह बताता है कि generation जारी रखनी है या नहीं
yield(i) value बाहर भेजता है और आगे जारी रखना है या नहीं, यह receive करता है
filter(p, next) left coroutine के next(true) से value लेता है, और केवल तब yield(n) करता है जब n%p != 0 हो
main current pipeline output को next में रखता है
- prime
p पढ़ता है
p print करता है
p के multiples हटाने वाला नया filter pipeline के right side में जोड़ता है
- coroutines के बीच call relationship execution के दौरान बदल सकता है
- counter का पहला
yield main के पास जाता है, लेकिन बाद के yield 2-filter के पास जाते हैं
- हर
p-filter का पहला output अगले prime के रूप में main के पास जाता है, और बाद के output अगले filter के पास जाते हैं
- पूरा code Go Playground पर है
goroutine और coroutine का संबंध
- यहाँ बनाया गया control flow सख्ती से कहें तो goroutine है
- यह वे सभी काम कर सकता है जो सामान्य goroutine कर सकती है, जैसे mutex, channel, system call का wait आदि
coro.New ऐसी goroutine बनाता है जो yield और resume के अंदर coroutine switch operations का इस्तेमाल कर सकती है
go statement नया concurrent और parallel control flow बनाता है, लेकिन coro.New नया concurrent लेकिन non-parallel control flow बनाता है
- अगर 10
go statements चलाएँ, तो main सहित 11 goroutines एक साथ execute हो सकती हैं
- अगर
coro.New को 10 बार call करें, तो control flows 11 होंगे, लेकिन program का parallelism वही रहेगा और एक समय में केवल एक ही execute होगा
- कौन-सी goroutine “non-parallel” coroutine की भूमिका निभाती है, यह execution के दौरान बदल सकता है
- यह वैसा ही है जैसे execution के दौरान यह बदल सकता है कि कौन-सी goroutine channel send/receive कर रही है
अधिक मजबूत resume
- शुरुआती
coro.New में function खत्म होने के बाद resume call करने पर deadlock होता है
- इसे ठीक करने के लिए
resume result के साथ bool return करता है
true का मतलब है कि result yield से आया है
- function return होने पर
resume return value और false return करता है
- coroutine खत्म हो जाने के बाद
resume call करने पर zero value और false return करता है
running variable track करता है कि f execute हो रहा है या नहीं
resume और coroutine बारी-बारी से execute होते हैं, इसलिए running share करना race नहीं है
- example
"hello" true, "world" true, "done" false, "" false print करता है
coro.Pull से iterator conversion
coro.Pull push iterator को pull iterator में बदलता है
- input push iterator का form इस प्रकार है
push func(yield func(V) bool)
yield की boolean return value बताती है कि जारी रखना है या नहीं
- target pull iterator का form इस प्रकार है
pull func() (V, bool)
- channel receive या map lookup की तरह यह value और iteration खत्म हुआ या नहीं, return करता है
- जल्दी रोकने के लिए
Pull सिर्फ pull ही नहीं, stop भी return करता है
- implementation के लिए
coro.New से push iterator चलाने वाला छोटा wrapper बनाना पर्याप्त है
pull resume(true) call करता है
stop resume(false) call करता है
- tree की
All method बदली जाती है ताकि वह yield के bool result का इस्तेमाल करे
- left traversal, current value yield, और right traversal को
&& से जोड़कर early stop propagate किया जाता है
- tree comparison function दो
coro.Pull बनाता है और values को एक-एक करके compare करता है
- early exit होने पर coroutines रोकने के लिए
defer stop1() और defer stop2() इस्तेमाल करता है
- अगर value या end state अलग हो तो
false
- दोनों खत्म हो जाएँ तो
true
- पूरा code Go Playground पर है
panic का propagation और cancellation
- Coroutine में हुआ panic उस caller को वापस भेजा जा सकता है जिसने सबसे हाल में उस coroutine को
resume किया था
- सामान्य goroutine में यह जानना मुश्किल होता है कि किस goroutine को सूचित करना चाहिए, और क्या वह goroutine receive करने के लिए तैयार है
- Coroutine में caller
resume पर block होकर इंतज़ार कर रहा होता है, इसलिए panic भेजने का target स्पष्ट होता है
- Implementation
cout के ज़रिए value या panic वाला message भेजता है
- नए coroutine का
defer panic को catch करता है
- इंतज़ार कर रहा
resume उसी panic value के साथ फिर से panic करता है
- Example में coroutine
"hello" yield करने के बाद "world" से panic करता है
- panic main goroutine तक propagate होता है, और stack में ऐसा दिखता है जैसे यह
resume call में हुआ हो
- पूरा code Go Playground पर है
- Caller के जल्दी समाप्त होने पर coroutine को बताने के लिए
cancel function जोड़ा गया है
cancel resume जैसा ही है, लेकिन yield को value return कराने के बजाय panic कराता है
- Cancellation panic
ErrCanceled को satisfy करने वाला एक unique error wrapper इस्तेमाल करता है
cancel से trigger हुआ panic फिर से propagate नहीं किया जाता, लेकिन cancellation के दौरान coroutine कोई दूसरा panic करे तो वह propagate होता है
- अगर
resume अभी तक call नहीं हुआ है, तो cancel f को बिल्कुल execute नहीं होने देता
- Iterator को रोकने के लिए panic की तुलना में explicit
bool ज़्यादा स्पष्ट है, इसलिए Pull bool-based stopping को बनाए रखता है
Prime sieve पर फिर से नज़र: cleanup और error propagation
- नए API में
counter और filter दोनों resume function और cancel function साथ में return करते हैं
primes(n int) counter बनाता है और defer cancel() register करता है
- हर prime को read और print करता है
- हर बार नया filter जोड़ते समय उस filter का
cancel भी defer से register करता है
- Function जब
n primes हासिल करके return करता है, तो deferred cancel calls बनाए गए coroutines को clean up कर देते हैं
- अगर कोई coroutine panic करता है, तो वह इंतज़ार कर रहे coroutine तक propagate होता है
- अगर वह coroutine है जिसे
primes ने सीधे next से resume किया है, तो panic primes में वापस आता है
- अगर वह coroutine है जिसे किसी filter ने
next से resume किया है, तो panic filter chain के साथ ऊपर चढ़ते हुए primes के p := next(true) तक आता है
- इसके बाद
primes के deferred cancel बाकी coroutines को clean up करते हैं
- पूरा code Go Playground पर है
Final API shape
New एक नया suspended coroutine बनाता है और function f को execute करने के लिए तैयार करता है
- नया coroutine एक goroutine है, लेकिन अपने-आप execute नहीं होता
- यह केवल तब execute होता है जब कोई दूसरा goroutine
resume या cancel call करके इंतज़ार कर रहा हो
resume(in) calling goroutine को रोकता है और नए coroutine पर switch करता है
- पहला call
f(in, yield) शुरू करता है
- जब तक
f yield(out) call नहीं करता या out return नहीं करता, resume blocked रहता है
yield call होने पर resume out, true return करता है
f return करने पर resume out, false return करता है
- अगला
resume(in) blocked yield से in return कराता है
cancel f की execution रोकता है और coroutine को terminate करता है
- अगर
resume एक बार भी call नहीं हुआ है, तो f execute नहीं होता
- वरना blocked
yield ErrCanceled को satisfy करने वाले error से panic करता है
- अगर
f ऐसा panic करता है जिसे recover नहीं किया गया, तो वह panic resume या cancel में इंतज़ार कर रहे goroutine में move होकर उसी value से फिर panic करता है
- हालांकि,
cancel अपने trigger किए हुए cancellation panic को फिर से panic नहीं करता
- अगर
f return या panic कर देता है, तो coroutine अब मौजूद नहीं रहता
- इसके बाद
resume calls zero value और false return करते हैं
- इसके बाद
cancel calls बस return कर जाते हैं
resume, cancel, yield को दूसरे goroutine में pass करके इस्तेमाल किया जा सकता है
- इसके परिणामस्वरूप कौन-सा goroutine “coroutine” है, यह dynamically बदल सकता है
New नया goroutine बनाता है, लेकिन यह invariant रखता है कि हमेशा एक goroutine resume, cancel, yield या initial wait state में blocked रहता है
- यह invariant तब तक बना रहता है जब तक
f return नहीं करता
- परिणामस्वरूप
coro.New नई concurrency बनाता है, लेकिन नई parallelism नहीं बनाता
- Final signature इस प्रकार है
func New[In, Out any](f func(in In, yield func(Out) In) Out) (resume func(In) (Out, bool), cancel func())
Efficiency
- Pure Go implementation से coroutines define किए जा सकने चाहिए, लेकिन वास्तविक use के लिए optimized runtime implementation की ज़रूरत है
- 2019 MacBook Pro पर channel-based
coro.New में value round-trip के लिए प्रति switch लगभग 190ns लगते हैं
coro.Pull में प्रति value लगभग 380ns लगते हैं
coro.Pull iterator का standard usage pattern नहीं है
- Standard तरीका iterator को सीधे call करना है, और इस case में coroutine overhead नहीं होता
coro.Pull तब चाहिए जब values को एक single for loop में नहीं, बल्कि धीरे-धीरे process करना हो
- पहला optimization प्रयास यह था कि compiler send-receive pair को mark करे और runtime को उन्हें एक operation में merge करने का hint छोड़े
- Channel runtime scheduler को bypass करके सीधे दूसरे coroutine पर jump कर सकता है
- प्रति switch लगभग 118ns, और प्रति pulled value लगभग 236ns लगते हैं
- यह original channel implementation से 38% तेज़ है
- दूसरा implementation channels को पूरी तरह avoid करता है और runtime में सीधे coroutine switching जोड़ता है
- Coroutine switch तीन atomic compare-and-swap तक घट जाता है
- एक coroutine data structure के लिए, एक block हो रहे coroutine के scheduler status के लिए, और एक resume हो रहे coroutine के scheduler status के लिए इस्तेमाल होता है
- प्रति switch लगभग 20ns, और प्रति pulled value लगभग 40ns लगते हैं
- यह original channel implementation से लगभग 10 गुना तेज़ है
- प्रति value 40ns को इतना छोटा absolute cost माना जाता है कि जहाँ
coro.Pull की ज़रूरत हो, वहाँ यह bottleneck नहीं बनेगा
1 टिप्पणियां
Hacker News टिप्पणियाँ
लगता है यहाँ बहुत से लोग मूल बात को मिस कर रहे हैं। यह सही है कि coroutine library,
gokeyword की तुलना में concurrency को संभालने का अधिक खराब और झंझटभरा तरीका है।इस जटिलता को लाने वाला असली उपयोग मामला function iterators है, यानी
func() (T, bool)type के functions परrangeका इस्तेमाल कर पाना। इस पर Go community में लंबे समय से चर्चा होती रही है, और ज़्यादातर Go programmers के लिए इसका अर्थ सहज रूप से स्पष्ट होगा।यह लेख उसके बाद वाले सवाल को देखता है, यानी अगर function iterators भाषा में जोड़े जाते हैं, तो
forloop में इस्तेमाल होने वाले iterators कैसे लिखे जाएँ। यह इस बात से शुरू होता है कि push iterators कई बार लिखने में आसान होते हैं, और फिर इसे push-pull adapter तक बढ़ाता है, जो adapter coroutines के ऊपर बनाया गया है।अगर यह सब शामिल किया जाता है, तो iteration के अलावा दूसरे कामों के लिए coroutines का इस्तेमाल शायद वैसी ही खराब प्रथा बन जाएगा जैसे जहाँ mutex काफ़ी हो वहाँ channels/goroutines का इस्तेमाल करना
जब दो tasks तार्किक रूप से synchronous सहयोग में हों, जैसे iterator के मामले में, तो सब कुछ एक ही CPU पर चलाना कहीं अधिक efficient होता है। kernel को कुछ भी reschedule करने या CPU core को सुलाने-जगाने की ज़रूरत नहीं पड़ती, और data भी CPU cache में ठीक से बना रहता है, जिससे cache latency और hit rate बेहतर होते हैं।
goroutines में भी यह संयोग से हो सकता है, लेकिन इसकी गारंटी नहीं होती, और कम-से-कम Go runtime के goroutine scheduler से गुजरने की लागत तो होती ही है। वह तेज़ है, लेकिन उसी goroutine के भीतर दूसरे code context को चलाने जितना तेज़ नहीं।
coroutines में यह पता होता है कि task A सीधे task B में switch होगा, इसलिए scheduling behavior अधिक predictable होता है। लेख के आख़िरी हिस्से में Russ दिखाते हैं कि optimized runtime coroutine implementation, goroutines से नकल किए गए रूप की तुलना में 10 गुना तेज़ है।
Google के पास अंदरूनी तौर पर ऐसा kernel patch है जो इस तरह की cooperative multithreading लागू करता है, और अंदर इसे fibers कहा जाता है। यह बेहतर latency और predictable scheduling के लिए मौजूद है। लगभग 10 साल पहले LPC में Paul Turner का एक talk भी था जिसमें इस motivation को समझाया गया था: https://www.youtube.com/watch?v=KXuZi9aeGTw
for { next := getNext(); ... }लिखें तो उसमें समस्या क्या है, यह समझ नहीं आता।for next := range getNext { ... }की क्या बढ़त है, यह जानना चाहता हूँrangeयाswitchइस्तेमाल करके, और उसमें values push करने वाली एक goroutine चलाकर काम क्यों नहीं चल सकता। अब भी यह बात समझ में नहीं आती कि coroutines क्यों चाहिएइस बार यह सावधानी से सोचा गया अच्छा 80/20 समाधान नहीं, बल्कि कुछ ऐसा लगता है जैसे, “इसे ठीक से करना है तो coroutines चाहिए होंगे, तो बस जोड़ दो।”
generics जोड़ते समय सच में बहुत लंबे और गहरे स्तर पर सोचा गया था, और एक बेहतरीन संतुलित, अभिनव समझौता सामने आया था।
यहाँ भी उम्मीद थी कि “कुछ खास परिस्थितियों में control देने के लिए goroutine में एक feature जोड़ना” जैसी कोई दिशा होगी। यह “इस समस्या पर Rust की तरह पूरी तरह उतरकर बस जोड़ देते हैं” से बेहतर लगता
मैंने कई सालों तक पेशेवर रूप से Go का इस्तेमाल किया है, लेकिन मैं नहीं चाहता कि यह Python के Twisted / Tornado / दूसरे frameworks जैसा बन जाए।
gokeyword काफ़ी दर्दनाक function coloring problem को अच्छी तरह रोकता है।high-performance context में कभी-कभी CPU core के हिसाब से data sharding जैसी चीज़ें चाहिए होती हैं, लेकिन यह प्रस्ताव उस तरह की ज़रूरत को पूरा नहीं करता
async/awaitजैसा कुछ लाने की बात नहीं है।coroutines एक दूसरे क्षेत्र को भरेंगी, जो Python के generators के ज़्यादा क़रीब है। जहाँ composable component pipelines जोड़ने होते हैं, वहाँ ये अक्सर memory usage और code complexity को काफ़ी कम कर सकती हैं। design पूरी तरह synchronous लगता है
Contextका इस्तेमाल करना पड़े, तो वही तो शब्दशः function coloring हैबेशक, दुनिया में कहीं कोई बहुत होशियार developer इससे कुछ बेवकूफ़ी भरा बनाएगा, और उसके बहुत लोकप्रिय हो जाने की संभावना लगभग तय है। मेरा अनुमान है goroutines और coroutines को एक ही abstraction में समेटने का।
आमतौर पर goroutine-संबंधित code, channels की वजह से बिखरा हुआ लगता है, और समझ नहीं आता कि उसे और “साफ़” कैसे बनाया जाए। अगर maintainable साबित हुए patterns के बारे में कोई संकेत हों, तो सच में आभारी रहूँगा
मल्टीटास्किंग सिस्टम ने हमें process दिए
लेकिन वे बहुत भारी थे
इसलिए thread आए, यानी ऐसे process जो address space, file table और कुछ दूसरी चीज़ें share करते हैं। Scheduler के लिए process की तुलना में thread के बीच switch करना आसान होता है, और threads के बीच data share करने के लिए serialization की ज़रूरत नहीं पड़ती
लेकिन वह भी बहुत भारी था
इसलिए user-space thread आए। ये ऐसे logical execution threads हैं जिन्हें runtime पूरी तरह user space में चलाता है। Runtime standard library की सभी input/output functions में scheduling hooks डालता है, या Unix signals जैसे system API से logical threads को preempt करता है। इसमें system-level context switch की ज़रूरत नहीं होती और इन्हें बहुत छोटा बनाया जा सकता है
लेकिन वह भी बहुत भारी था
इसलिए coroutine आए। ये programmer को ऐसे logical “threads” परिभाषित करने देते हैं जो आपस में cooperatively interact करते हैं। इसमें scheduler के मौजूद होने की धारणा नहीं होती। Programmer खुद event loop लिखता है, या library के event loop को “असल” logical thread से call करता है
आगे क्या आएगा, यह जानने की उत्सुकता है। [Communicating Sequential Processes][1] के नज़रिए से देखें तो cooperative coroutine शायद वह सबसे निचला स्तर है जहाँ तक चीज़ें जा सकती हैं
[1]: https://www.cs.cmu.edu/~crary/819-f09/Hoare78.pdf
इसके लिए concurrency को support करने वाली semantics चाहिए। उदाहरण के लिए iteration डिफ़ॉल्ट रूप से unordered होनी चाहिए, और whole-program flow analysis पर भी निर्भरता होगी, लेकिन सिद्धांततः कोई रुकावट नज़र नहीं आती
क्या Microsoft ने कुछ साल पहले ऐसी किसी language पर research नहीं की थी? Ada community के किसी व्यक्ति ने ParaSail भी बनाया था। सोचता हूँ इन projects का क्या हुआ, क्या अब कोई इन्हें इस्तेमाल नहीं करता
मैंने हमेशा सोचा था कि green threads की असली बात यह है कि Python के
yieldजैसे keyword इस्तेमाल किए बिना भी अच्छी cooperative scheduling मिल जाती हैGo का
call sites और कुछ खास जगहों पर resume points insert करनावाला design decision मुझे बहुत अच्छा समझौता लगायह धीरे-धीरे hardware के और क़रीब का control expose कर रहा है। एक बिंदु के बाद तो पता नहीं यह बस Zig को फिर से बनाने जैसा हो जाता है या नहीं। अगला कदम optional garbage collector है क्या?
yieldऔरresumekeyword नहीं, बल्कि सामान्य variables हैं। वे सिर्फ शिक्षण उद्देश्य से ऐसे नाम दिए गए सामान्य closure references हैंदिलचस्प हिस्सा cancel callback बनाना और उसका उपयोग करना है। मैंने Go बहुत ज़्यादा नहीं किया है, इसलिए समझ नहीं पा रहा कि यह dropped iterator state को जल्दी reclaim करने के लिए performance optimization है, या इसलिए ज़रूरी है क्योंकि जब किसी channel पर wait कर रही goroutine के पास उस channel का एकमात्र reference होता है, तब Go उस goroutine को garbage collect नहीं करता
Lua में ऐसी चीज़ की ज़रूरत नहीं होती। coroutine/thread भी दूसरी objects की तरह garbage collect हो जाते हैं, इसलिए जैसे ही सारे references गायब हो जाते हैं, आख़िरी operation entry function का return न होकर
yieldभी हो, तब भी उसे reclaim कर लिया जाता हैGOGC=offसेट करके garbage collection बंद की जा सकती हैGOGC के बारे में विस्तार से: https://dave.cheney.net/tag/gogc
मुझे लगता है coroutine के लिए language support होना library से बेहतर रहेगा
मैं
x := co func(){ var z int; for { z++; yield z } }जैसी किसी चीज़ की कल्पना करता हूँ, या उसी तरह का कोई रूपसिर्फ pure Go में भी यह संभव है, यह काफ़ी अच्छा है, और language spec को जटिल बनाने के बजाय optimized runtime के साथ standard library package के रूप में देने का आकर्षण भी समझ आता है। आख़िर pure Go में संभव हो तो दूसरे implementations भी जल्दी bootstrap किए जा सकते हैं
Go को रोज़
$workमें इस्तेमाल करने वाले व्यक्ति के रूप में, मुझे दोनों में से कोई भी विकल्प स्वीकार है, लेकिन language में built-in होना ज़्यादा पसंद है। Go की concurrency primitives हमेशा इसकी ताकत रही हैं, इसलिए उसी दिशा को आगे बढ़ाया जा सकता हैअगर यह library-based solution है, तो या तो coroutine को मारना पड़ेगा या पूरे program को। अगर आप चाहते हैं कि stack पारदर्शी रूप से बढ़े, तो यह सिर्फ generated code ही कर सकता है। क्योंकि उसे stack usage मॉनिटर करनी होगी और ज़रूरत पड़ने पर बढ़ाना होगा। मेरी समझ से goroutine में ऐसा तंत्र है
शायद library solution में भी stack के अंत में guard page रखी जा सकती है। वहाँ पहुँचने पर error handler stack expansion की कोशिश कर सकता है। लेकिन अगर किसी ने stack variables के pointers पकड़ रखे हों, तो शायद यह काम नहीं करेगा
yieldparameter जोड़कर इसे मौजूदा type system के साथ बेहतर तरह से interact कराया जाएअगर कोई function values yield करना चाहता है, तो उसके पास तथाकथित
yieldparameter होना चाहिए, और उसे सिर्फ उन्हीं functions के अंदर yield करने की अनुमति होनी चाहिए जिनका वही yield signature हो, या जिनका कोई signature न होउदाहरण शायद
x := func(:z int) { for { z++; :- z } }जैसा बन सकता है।:yield signature जोड़ता है, और:-value yield करता हैजो function सिर्फ value
Xyield करता है, उसमें सिर्फ: Xया: name Xहोना चाहिए। अगर resume होने पर typeYकी value भी लेनी हो, तो signature:[Y] Xया:[Y] name Xहो जाएगाAcceptance को weak होना चाहिए। जहाँ
Yके साथ resume होकरXyield करने वाले function की अपेक्षा हो, वहाँ ऐसा function भी स्वीकार होना चाहिए जो resume के बिना सिर्फXyield करता होअगर
copackage की विशेष functionsresumeऔरNewfunctionality दें, तो Go style बना रह सकता है।rangesyntax को-:से resume value पास करने के लिए बढ़ाया जा सकता है, और अगर value न दी जाए तो default zero value के साथ resume किया जा सकता हैमुझे यह खास पसंद नहीं आया। उदाहरणों को देखें तो यह भाषा को पढ़ना और उसके साथ चलना काफ़ी मुश्किल बनाता हुआ लगता है। हाँ, यह मेरी समझ और पूर्वाग्रह की वजह से भी हो सकता है
और ऊपर से, ऐसा भी नहीं लगता कि यह वह कुछ संभव बनाता है जो मौजूदा blocking channel या state से नहीं किया जा सकता
core code path में allocation से बचने के लिए मैंने इस लेख में दिखाए गए जैसे iterators इस्तेमाल किए हैं। इस तरीके से ऐसा code काफ़ी कम अटपटा लगेगा। ख़ासकर आने वाले
rangeiterator language change के साथ तो और भीchannel, जब वह blocking operation को wrap नहीं कर रहा होता, तब बिना वजह धीमा होता है
टिप्पणियाँ पढ़कर कड़वाहट-सी महसूस हुई
बहुत से लोग coroutine और green thread को लगभग एक ही चीज़ मानते हैं, जबकि दोनों के अपने फायदे और नुकसान हैं
यह दुखद है कि Go community में iterators का न होना स्वीकार्य माना जा सकता है। ऐसा लगता है जैसे simplicity के नाम पर भाषा को ज़रा भी जटिल बना सकने वाली सुविधाओं को जानबूझकर ठुकराया जाता है। फिर भी कम-से-कम generics पर रुख़ तो बदला गया
फिर से लग रहा है कि Go शायद मेरी भाषा नहीं है
अब मैं इसे थोड़ा इस्तेमाल करता हूँ, लेकिन सिर्फ इसलिए कि call site पर syntactic convenience थोड़ी बेहतर है। definition site पर यह उम्मीद के मुताबिक अब भी बदसूरत है, हालाँकि Go syntax फिर भी दूसरी भाषाओं की तुलना में काफ़ी बेहतर है
आख़िर में, मुझे लगता है “generics नहीं हैं” वाली शिकायतें शांत कराने के लिए simplicity काफ़ी खो दी गई। यह अच्छा सौदा नहीं था
क्या पोस्ट में प्रस्तावित
coropackage ज्यों-का-त्यों जोड़ दिया जाएगा? हो सकता है, लेकिन शायद बिल्कुल ऐसे नहीं। क्या कुछ मिलता-जुलता जोड़ा जाएगा? अगर दाँव लगाना हो तो मैं हाँ कहूँगाकितना समय लगेगा? कम-से-कम 1 साल, यानी शायद अगस्त 2024 की Go 1.23 release तक। थोड़ा ज़्यादा भी लग सकता है। इससे बहुत कम समय लगना मुश्किल लगता है
generics को community ने इसलिए स्वीकार किया क्योंकि यह मौजूदा code के साथ पूरी तरह backward compatible है, और अगर ज़रूरत न हो तो इसे सुरक्षित रूप से नज़रअंदाज़ किया जा सकता है
जैसा कि अपेक्षित था, Go का अधिकांश code अब भी यही करता है। अलग-अलग तरह के “collections” को छोड़ दें तो generics के व्यावहारिक उपयोग ढूँढना इतना आसान नहीं है। शुरू से ही ज़्यादातर code एक से ज़्यादा type देखता ही नहीं, और दो से आगे जाना तो पहले से ही काफ़ी दुर्लभ है
उल्टा, generics जोड़े जाने से बड़े community को यह दिखा कि लोग जिन सुविधाओं को “ज़रूरी” बताकर माँगते हैं, वे एक व्यापक रूप से स्वीकार की गई बेहतरीन भाषा में वास्तव में कितनी कम आवश्यक होती हैं
अब जाकर CLU जैसी programming language पर ध्यान देना अच्छी बात लगती है
दूसरी ओर, .NET और C++ coroutines, और Symbian C++ तथा Active Oberon के Active Object के अनुभव से मुझे भरोसा नहीं है कि इसे सच में Go में जोड़ना चाहिए
जैसा कि .NET team ने इस साल BUILD में भी माना, अगर वे समय पीछे ले जा सकते तो बेहतर होता कि runtime इसे Go-style तरीके से संभालता। क्योंकि बहुत से developers आज भी async/await को समझने में कठिनाई महसूस करते हैं
पता नहीं इसकी सच में ज़रूरत है भी या नहीं। Go के ज़्यादातर use case goroutine से ही संभल जाते हैं, और
yield/resumesemantics के लिए 2 blocking channels ही काफ़ी हैंयह जटिलता के लिए जटिलता जोड़ने जैसा लगता है, और यह भी साफ़ नहीं कि इससे Go में कोई नई क्षमता वास्तव में जुड़ती है
तुलना के लिए, हाल की एक प्रस्तुति में Elixir(BEAM VM) के 10 लाख threads चलाए गए और सबको
"Hello!"संदेश भेजा गया, फिर हर thread ने 0 से 2 सेकंड के बीच किसी random समय तक इंतज़ार करके"Process received message !"वापस भेजा—ऐसा demo किया गयासाथ ही Erlang observer भी खोला गया ताकि CPU और memory consumption, और garbage collection के बाद सिस्टम कितनी जल्दी सामान्य होता है, यह देखा जा सके
यहाँ सबसे बड़ा bottleneck terminal की साथ चल पाने की क्षमता है, लेकिन observer असल स्थिति को काफ़ी ठीक तरह से दिखाता हुआ लगता है
https://www.youtube.com/watch?v=yxyYKnashR0
इस्तेमाल किया गया code: https://gist.github.com/pmarreck/4cc8f2f55a561ebce2012085a3a...
ऐसी क्षमता 1980 के दशक से Erlang में, और इसलिए Elixir में भी, built-in रही है। actor model या Erlang के “legendary” implementation के बारे में बहुत लोगों ने सुना होगा, लेकिन monitoring tools साथ चलाकर इसे वास्तव में कितनों ने देखा है, कहना मुश्किल है
अच्छा होता अगर Go भी इस तरह का language-level support देता, लेकिन BEAM VM की thread implementation creation और runtime consumption—दोनों में ही बेहद resource-efficient है, और immutable values की वजह से concurrency भी आसान हो जाती है, इसलिए लगता है इसकी बराबरी करना आसान नहीं होगा