थ्रेड्स की बजाय async/await चुनने के कारण
(notgull.net)- Rust का
async/awaitथ्रेड्स का सिर्फ़ एक साधारण विकल्प नहीं है, बल्कि I/O-केंद्रित concurrency code को composable state machines के रूप में व्यक्त करने वाला एक programming model है - web server की तरह कई connections को एक साथ संभालने वाले code में सिर्फ़ linear execution से जल्दी सीमाएँ सामने आती हैं, जबकि थ्रेड्स
thread::spawnके ज़रिए client handling को अलग कर concurrent processing संभव बनाते हैं async/awaitawaitबिंदु पर execution छोड़ देता है, और executor दूसरे कामों को आगे चलाता है, जिससे एक ही runtime के भीतर बहुत-से tasks interleave होकर चल सकते हैं- 3-second timeout जैसी ज़रूरत
asyncमेंraceऔरTimerके संयोजन से जोड़ी जा सकती है, लेकिन synchronous thread code मेंTcpStream-विशेष wrapper और read/write timeout settings की ज़रूरत पड़ती है, इसलिए इसकी generality कम हो जाती है - केवल performance overhead के आधार पर
asyncको समझाने पर CPU bound कामों में counterexample मिलते हैं; Rustasyncकी असली ताकत semantic expressiveness और ecosystem composability में है
Rust concurrency समस्या की शुरुआत
- Rust का सामान्य code मूल रूप से linear execution structure पर चलता है
foo(),bar(),baz()की तरह एक काम पूरा होने के बाद अगला काम चलता है
- लेकिन web server की तरह कई काम एक साथ संभालने पड़ें तो linear structure जल्दी अपनी सीमा पर पहुँच जाता है
TcpListener::accept()से client लेने औरhandle_client()चलाने वाली संरचना में पहले client को process करते समय दूसरे client को इंतज़ार करना पड़ता है- अगर
handle_client()को कुछ milliseconds लगते हों और एक साथ clients 2 हों, तो थोड़ी प्रतीक्षा पैदा होती है - अगर एक साथ clients 20 लाख हों, तो queue के आख़िर में मौजूद user को कई मिनट इंतज़ार करना पड़ सकता है
थ्रेड्स इसे कैसे हल करते हैं
- operating system thread registers के मान और program stack को memory में सहेजकर किसी दूसरी routine को चला सकता है, और बाद में मूल routine को फिर से resume कर सकता है
- web server code
thread::spawn(move || handle_client(client))जैसे रूप में client handling को अलग thread पर भेज देता है- main thread नए connections को लगातार
accept()करता रहता है - अगर client-handling thread block हो जाए, तो OS main thread पर लौटकर अगला connection ले सकता है
- दो clients कुछ microseconds की देरी के बाद parallel चल सकते हैं
- main thread नए connections को लगातार
- अगर production-grade web server के पास दर्जनों CPU cores हों, तो OS threads को सिर्फ़ साथ-साथ चलते हुए दिखाता ही नहीं, बल्कि वास्तव में कई threads को एक साथ चला भी सकता है
async/await कैसे काम करता है
- user-space concurrency के लिए event-based programming, actor, coroutine जैसे कई models हैं, और Rust ने
async/awaitको चुना है - सरल रूप में देखें तो program को स्वतंत्र रूप से चल सकने वाली state machines के समूह के रूप में compile किया जाता है
async fnपारंपरिक function नहीं, बल्कि state machine लौटाने वाला function हैawaitकिसी दूसरी state machine को मौजूदा state machine के एक चरण के रूप में शामिल करता है- जब अंदर का function नए connection का इंतज़ार करने जैसी स्थिति में execution छोड़ता है, तो पूरी state machine ऊपर के executor को control लौटा देती है
smol::Executorजैसा executor मौजूदा state machine की जगहspawnसे बनी दूसरी state machine चलाता हैasync move { handle_client(client).await }blockmainसे स्वतंत्र एक नई state machine हैmainके yield करते ही clients के कामों में से एक चलता है, और उसके फिर से yield करते ही अगला task चलने लगता है
- इस संरचना से लाखों clients को एक साथ संभाला जा सकता है, लेकिन executor, task, state machine जैसी अतिरिक्त अवधारणाओं के कारण complexity भी बढ़ती है
timeout उदाहरण में दिखने वाली composability
- Rust की एक बड़ी ताकत composability है
Iteratorमें कई combinators जोड़े जा सकते हैं, और फिर परिणाम को दोबाराIteratorलेने वाले function में दिया जा सकता हैmpsc::channel()केrecv.try_iter().filter(...).map(...)की तरह values को filter और transform करके list में जोड़ा जा सकता है
async/awaitयही composability I/O bound functions पर भी लागू करने देता है- अगर
handle_client()एक asynchronous function है जोread_to_end,do_something_with_data,write_allकोawaitकरता है, तो 3-second timeout दो Futures को जोड़कर लागू किया जा सकता है - यह तरीका सिर्फ़
TcpStreamतक सीमित नहीं है- जो भी
impl AsyncRead + AsyncWriteimplement करता हो, उस पर यही pattern लागू किया जा सकता है - सामान्य stream के ऊपर GZIP stream, Unix socket, file जैसे targets भी बदले जा सकते हैं
- जो भी
synchronous thread code में वही timeout लागू करने की सीमाएँ
- blocking code में सामान्यतः
readयाwritesystem call को बीच में रोकना मुश्किल होता है, और file descriptor बंद करने जैसी विधि Rust में उपयोगी नहीं होती TcpStreamset_read_timeoutऔरset_write_timeoutदेता है- read और write के लिए अलग-अलग timeout सेट किए जा सकते हैं
- लेकिन अगर client हर 2.9 seconds पर 1 byte भेजे, तो साधारण timeout बार-बार reset हो सकता है
- इससे बचने के लिए
TcpStreamको wrap करने वालाDeadlineStreamजैसा type बनाना पड़ता है, जो हर बार पूरे deadline तक बचा समय निकालकर read/write timeout में सेट करे - यह तरीका काम कर सकता है, लेकिन इसकी सीमाएँ बड़ी हैं
- यह
TcpStreamसे बँधा हुआ है - Rust में
set_read_timeoutऔरset_write_timeoutके उपयोग को abstract करने वाला कोई trait नहीं है - generic writer पर इसे लागू करने के लिए काफ़ी अतिरिक्त काम चाहिए
- timeout setting के लिए अतिरिक्त system calls लगते हैं
- वास्तविक web server logic में इसका उपयोग और भी असुविधाजनक हो सकता है
- यह
Rust async ecosystem के उदाहरण
- HTTP ecosystem ने client सहित
async/awaitको मुख्य runtime mechanism के रूप में इसलिए अपनाया, क्योंकि functions की composability बहुत उपयोगी है- HTTP calls बनाने वाले functions को अलग-अलग hooks और use cases में फिट किया जा सकता है
towerasync/awaitcomposability का एक प्रमुख उदाहरण है- अगर service को
asyncfunction के रूप में implement किया जाए, तो timeout, rate limiting, load balancing, hedging, और backpressure handling जोड़ी जा सकती है - कौन-सा runtime उपयोग हो रहा है, या service अंदर क्या कर रही है, इससे स्वतंत्र होकर
towerलगाकर robustness बढ़ाई जा सकती है
- अगर service को
macroquadRust का एक छोटा game engine है, जो main function मेंasync/awaitका उपयोग करके engine चलाता है- Rust में किसी काम का इंतज़ार करने के लिए linear function को रोकना हो, तो उसे व्यक्त करने के लिए
async/awaitउपयुक्त है - एक ही thread पर game server network connection और GUI framework को साथ-साथ poll करने जैसी संरचना संभव है
- Rust में किसी काम का इंतज़ार करने के लिए linear function को रोकना हो, तो उसे व्यक्त करने के लिए
केवल performance के आधार पर async को समझाने की सीमा
- Rust Async Book तुलना करता है कि OS threads programming model बदले बिना concurrency व्यक्त करना आसान बनाते हैं, लेकिन threads के बीच synchronization कठिन हो सकता है, performance overhead बड़ा होता है, और thread pool के साथ भी बड़े I/O bound workloads को पर्याप्त रूप से support करना कठिन हो सकता है
asynccommunity में जब पूछा जाता है कि OS threads की बजायasyncक्यों, तो अक्सर जवाब होता है: “overhead कम है, बाकी सब वही है”- web server लिखने वालों ने
async/awaitअपनाया ताकि C10k problem हल किया जा सके, लेकिन हर user के लिएasync/awaitचुनने का कारण performance होना ज़रूरी नहीं है - performance advantage स्थिति के अनुसार गायब भी हो सकता है
- CPU bound कामों में thread-आधारित workflow, बराबर के
asyncworkflow से तेज़ हो सकता है - Rust
asyncके अस्थायी performance लाभ को अक्सर ज़रूरत से ज़्यादा उभारा गया है, जबकि semantic लाभ को कम आँका गया है
- CPU bound कामों में thread-आधारित workflow, बराबर के
async/awaitकोई niche use case का tool नहीं, बल्कि ऐसा शक्तिशाली programming model है जो उन patterns को संभाल सकता है जिन्हें synchronous Rust में दर्जनों threads और channels के बिना संक्षेप में व्यक्त करना मुश्किल होता है
sync Rust जैसा बनाने से बेहतर है उसके अंतर को स्वीकार करना
- Rust project roadmap में यह दिशा है कि
async Rustलिखना, कभी-कभीasyncऔरawaitkeywords इस्तेमाल करने के अलावा, synchronous code लिखने जितना आसान होना चाहिए - लेकिन
async Rustको “sync Rust जैसा ही” बनाने की framing मूल रूप से कठिन है, ऐसा भी एक दृष्टिकोण है- चाहे 99% तक समान बना दिया जाए, औसत user को अंतर फिर भी महसूस होगा
- Rust का
async/awaitecosystem synchronous Rust जैसा बनने की कोशिश करने के बजाय, composability और expressiveness जैसी अपनी ताकतों को और स्पष्ट रूप से सामने लाना चाहिए - अगर concurrency की ज़रूरत होने पर
async/awaitको default choice बनाना है, तो इस model को technical performance कारणों से ज़्यादा semantic कारणों से समझाना होगा
1 टिप्पणियां
Hacker News की राय
सिंगल-थ्रेड async/await एक सरल और अच्छी तरह जाना-पहचाना मॉडल है, और JavaScript इसी तरीके पर चलता है
थ्रेड्स कई CPU को समस्या हल करने में लगाने देते हैं, और Rust लॉक मैनेजमेंट में मदद करता है। अलग-अलग प्राथमिकता वाले थ्रेड भी रखे जा सकते हैं, इसलिए compute-bound कामों के लिए इसकी ज़रूरत पड़ सकती है
इसके उलट मल्टी-थ्रेड async/await गड़बड़ हो जाता है। जब compute-bound सेक्शन गंभीर रूप से शामिल हो जाएं, तो वे दूसरे कामों के साथ साझा किए गए थ्रेड को असल में ब्लॉक कर देते हैं, इसलिए मॉडल आसानी से टूट सकता है
Rust का compute-bound मल्टी-थ्रेड उतना अच्छा काम नहीं करता जितनी उम्मीद होती है। store allocator में कई थ्रेड एक ही लॉक पर चोट करते हुए futex congestion collapse पैदा कर सकते हैं, और खासकर buffer विस्तार के दौरान पूरे allocator को लॉक किए रखकर recopy होने पर यह बहुत महंगा हो जाता है। Wine की Microsoft लाइब्रेरी emulation .DLL के अंदर मौजूद library allocator इस समस्या के प्रति संवेदनशील था, जिससे CPU time spinlock में ही चला गया और performance दो अंकों के गुणक से गिर गई, जबकि Microsoft implementation में ऐसा नहीं हुआ
साथ ही standard
Mutexऔरcrossbeam-channelचैनल में unfair mutex starvation हो सकता है। अगर कई थ्रेड किसी resource को lock करके काम करें और unlock करने का चक्र दोहराएं, तो एक थ्रेड लगातार जीत सकता है और बाकी पीछे छूट सकते हैं। fair mutex चाहिए तोparking-lotहै, लेकिन इसमें standard mutex की thread panic के समय वाली poisoning safety नहीं हैअगर यह I/O-bound नहीं है, तो मामला कहीं ज्यादा जटिल हो जाता है
https://users.rust-lang.org/t/mutex-starvation/89080
अगर I/O throughput ही फिर से सीमा है, तो दस लाख coroutines का क्या मतलब है, ऐसा लगता है। अगर आकार 10 का DB connection pool तुरंत exhaust हो जाए, तो coroutines आपको नहीं बचाएंगे; वे सिर्फ debugging और workarounds को ज्यादा कठिन और reasoning को मुश्किल बनाते हैं
CPU-bound समस्या शायद व्यवस्थित interrupt/resume तक पहुंचती है, और अगर चल रहे n execution threads के लिए fair और efficient queue-based context switching संभव हो, तो उदाहरण के लिए n active contexts वाला CPU होने पर, क्या समस्या resource allocation की समस्या बन सकती है—यह सोचने लायक है
Go, जिसे एक जिम्मेदारी से डिजाइन की गई भाषा माना जाता है, भी शुरुआत में cooperative रास्ते पर गया था, लेकिन आखिरकार उसे preemptive में बदलना ही पड़ा। इसका मतलब यह नहीं कि cooperative multitasking बेकार है, लेकिन उस पर warning label होना चाहिए, और उससे आगे कुछ खास तरह के code को static रूप से चलने से रोकना बेहतर हो सकता है
संबंधित लेख के तौर पर “What color is your function” जोड़ रहा हूं
https://journal.stuffwithstuff.com/2015/02/01/what-color-is-...
async/await का multi-threaded executor starvation को पर्याप्त रूप से संभाल सकता है, और .NET implementation blocking calls और async को मिलाने वाले बहुत खराब code को भी झेल लेता है
https://news.ycombinator.com/item?id=39530435
https://news.ycombinator.com/item?id=39786142
https://news.ycombinator.com/item?id=39721626
https://journal.stuffwithstuff.com/2015/02/01/what-color-is-...
अगर software को high scalability की जरूरत नहीं है, तो async के tradeoffs शायद उस लायक न हों
async/await और threads की बहस का मूल सवाल यह नहीं है कि कौन-सा पक्ष ज़्यादा जटिल है, बल्कि यह है कि यह ecosystem को दो हिस्सों में बाँट देता है और एक पक्ष second-class citizen बन जाता है, इसलिए प्रोजेक्ट में गलत चुनाव करने पर friction पैदा होता है
इन्हें मिलाकर इस्तेमाल किया जा सकता है, लेकिन ज़रूरत पड़ने पर यह hacky और inefficient होता है। अभी Rust ecosystem में, अगर I/O शामिल है तो लगभग सब कुछ async/await ecosystem से बंधा हुआ है, और Rust में किए जाने वाले लगभग हर काम में, बहुत कम exceptions छोड़कर, I/O शामिल होता है; इसलिए application का बाकी हिस्सा async चाहता हो या नहीं, non-async libraries को आम तौर पर ignore करना पड़ता है
अगर Rust ने async/await से ज़्यादा composable abstraction इस्तेमाल किया होता और वह composability बाकी चीज़ों को भी async/await बनाने की मांग न करती, तो लगता है कि ज़्यादातर शिकायतें गायब हो जातीं
इससे भी बुरी बात यह है कि ecosystem सिर्फ दो हिस्सों में नहीं बंटता, बल्कि async code के अंदर भी आप आम तौर पर executor, अक्सर Tokio, से बहुत मजबूती से बंध जाते हैं। function color problem को बढ़ाकर देखें तो blue (non-I/O), green (blocking I/O), red (async I/O) के बजाय असल में blue, green, red (Tokio), purple (async-std), orange (smol) जैसा हो जाता है
मुझे इस समस्या का सबसे अच्छा समाधान sans-I/O pattern लगता है। सारे blue code को अलग कर दें और I/O व समय के लिए inversion of control इस्तेमाल करें, तो core protocol logic को I/O से अनजान बनाया जा सकता है और उसे कई I/O forms में wrap करना आसान हो जाता है
0: https://hugotunius.se/2024/03/08/on-async-rust.html
async application में blocking code इस्तेमाल करना उतना smooth नहीं है जितनी उम्मीद होती है, लेकिन मुश्किल भी नहीं है।
foo()के बजायtokio::spawn_blocking(foo).awaitइस्तेमाल करें; यह नया code अलग thread में चलाता है और उस thread के खत्म होने पर complete होने वाला future लौटाता हैI/O के लिए non-async विकल्प मौजूद हैं, लेकिन async विकल्प इस्तेमाल करने पर असल में
Main()तक सब कुछ async होने के लिए मजबूर हो जाता है। synchronous method से async method को सुरक्षित रूप से call करने के तरीके भी हैं, लेकिन वे debugging को बेहद कठिन बना देते हैंलेख में कई हिस्से छूट गए हैं
async/await एक thread context में चलता है, इसलिए locking या synchronization की ज़रूरत नहीं होती, लेकिन CPU cores का उपयोग करने के लिए जब कई threads पर async/await चलाते हैं, तो locking और synchronization फिर से ज़रूरी हो जाते हैं। यह complexity external code में छिपाई जा सकती है। उदाहरण के लिए, एक single DB connection तक access synchronize करने के बजाय हर async task के लिए एक DB connection खोलना आसान है, लेकिन SQLite या PostgreSQL में इसका performance पर असर पड़ सकता है
async/await में error propagation स्पष्ट नहीं है। खासकर जब async tasks को group करना हो, और Happy Eyeballs इसका typical example है
अगर बात network I/O की है तो backpressure को भी साथ में address करना चाहिए। CPython की async/await implementation network backpressure की कमी के लिए बदनाम है, और उससे समस्याएँ होती हैं
Gang of Four की “Design Patterns” ज़्यादातर C++ की खामियों से बचने की recipe book थी, लेकिन लोगों ने उन patterns को उन languages में भी लागू किया जिनमें वे खामियाँ नहीं थीं
Rust JavaScript नहीं है और कई threads अच्छी तरह चला सकता है। इसलिए async/await इस्तेमाल करना अनिवार्य नहीं था, और systems language की तरह यह दूसरे solutions आज़मा सकता था
लेकिन JavaScript programmers को Rust की ओर धकेलना हो तो async/await ज़रूरी था। without.boats ने लिखा था कि “Rust के survive करने का दारोमदार इस feature पर है, इस धारणा के sincere passion के साथ async/await को push किया गया”
https://without.boats/blog/why-async-rust/
async/await तकनीकी रूप से Rust में fit बैठता है या नहीं, यह important नहीं था; बात ऐसी दिखती है कि JavaScript programmers async/await से familiar थे, इसलिए Rust में भी async/await होना चाहिए था
कोई code block exclusive access पर निर्भर था और उसमें
awaitनहीं था, इसलिए वह guarantee बनी हुई थी; लेकिन बीच मेंawaitजोड़ दें तो code टूट जाता है। threading कम से कम यह code में explicit कराती है कि किस चीज़ पर exclusive access चाहिएasync का मतलब यह भी है कि आप अपनी thread scheduling खुद manage करना चाहते हैं। I/O बहुत हो और CPU-bound code छोटा हो तो ठीक है, लेकिन CPU-bound code हो, या कभी-कभी भी हो, तो आप scheduler खेलने लगते हैं
कई services बस ABEND होकर crash हो गईं, और Java background से आए मेरे लिए ऐसी कमी हैरान करने वाली थी। team को इसे ठीक करने का तरीका समझाना भी मुश्किल था
error propagation की वजह से, अगर choice हो तो मैं async/await इस्तेमाल नहीं करूँगा। solo project हो तो maybe, लेकिन दूसरों के साथ काम करना हो, libraries इस्तेमाल करनी हों और सबको समान समझ पर लाना हो, तो बिल्कुल नहीं
language-level structured concurrency को अभी गंभीरता से इस्तेमाल नहीं किया है, लेकिन Java के Loom Project से उम्मीद है। देखने में लगता है कि यह इस बहस को meaningless बना देगा
लेख में कुछ समस्याएँ हैं
उदाहरण सिर्फ एक वेब सर्वर का है, और threads वाला समाधान गलत तरीके से हल किया गया है। साथ ही सवाल ऐसा मानता है मानो लोग async/await के बजाय OS threads चाहते हों
प्रोग्रामर जो चाहते हैं वह conceptual और semantic threads हैं। यानी sequential logic लिखना और
asyncजैसी अजीब annotation न लगाना। अगर async/await इतना अच्छा है, तो सभी functions को implicitly async बना दें औरawaitकी जगह सामान्य function call इस्तेमाल करें—क्यों नहीं? तब असल में आप threads की तरह ही programming कर रहे होंगेOS threads महंगे होते हैं क्योंकि उनमें statically allocated stack होता है; हमें चाहिए सस्ते threads, जिन्हें एक ही CPU पर लाखों की संख्या में चलाया जा सके। बस भद्दे
async/awaitशब्दों के बिना।waitevent या दूसरे thread के पूरा होने का इंतजार करने वाली classical blocking wait के लिए रह सकता है, लेकिन function calls में यह नहीं चाहिएवेब सर्वर वाले उदाहरण पर लौटें तो,
driver.race(timeout).awaitसे timeout लागू करने पर,racetimeout error बताने के बाद client socket का क्या होता है? क्या वह खुला और connected रहकर leak नहीं होता?thread वाले version में timeout को भी
threaded_race(client_thread, timeout).waitकी तरह async/await जैसा ही बनाया जा सकता है।threaded_racetimer के साथ thread के parallel timeout को track करता है, और समय होने पर Java style मेंclient_thread.interrupt()call करता है।Thread.interrupt()thread blocked न हो तो सिर्फ flag set करता है, और अगर I/O call में blocked हो तोInterruptedExceptionthrow करता है। यह checked exception है, इसलिए compilerclient.read_to_end(&mut data)को try/catch में wrap करने याhandle_clientमें exception declare करने को मजबूर करता है, जिससे programmer client socket बंद करना नहीं भूलताrace()के अंदर की valuesDropहो जाती हैं औरdriverखुद बचा रहता हैअगर type को ज्यों-का-त्यों इस्तेमाल करें तो Rust शिकायत करेगा कि
Resulthandle नहीं किया गया, और अगर future के अंदर नया socket local रूप से बनाया गया था तो वह clean up हो जाएगाRust future की अच्छी बात यह है कि आप उसके आसपास का सारा behavior define कर सकते हैं। उस model के उलट जिसमें सभी functions blocking होते हैं, Rust यह specify कर सकता है कि execution को work queue के next task तक कब टालना है, और explicitly stored state (
Futurestruct) रखकर task को arbitrary रूप से तेज़ी से poll करता है। इसलिए thread की तरह yield करने के लिएsleep()करने की जरूरत नहीं होती, यह तेज़ और reasoning में आसान होता हैJava का
Thread.interruptभी अंततः sleep loop के करीब ही है, और ज़्यादातर applications के लिए ठीक हो सकता है। लेकिन Rust एक systems language है, इसलिए embedded systems में ऐसा तरीका नहीं रखा जा सकता, और kernel या low-latency applications के लिए भी यह अच्छा नहीं हैज़्यादातर मामलों में यह बहुत मायने नहीं रखता कि यह OS blocking system call है या non-blocking system call, लेकिन मैं जिस program को पढ़ रहा हूँ उसका control flow समझना चाहता हूँ—कहाँ wait हो रहा है और उसे parallel में कैसे चलाया जा सकता है
बल्कि blocking functions के साथ काम करते समय मैं
blocking/blockkeyword pair चाहता। Blocking calls चुपचाप पूरी चीज़ को धीमा बना सकती हैं, और मैंने UI thread में blocking system call आ जाने से बहुत सारी painfully slow apps देखी हैंsync और async को unify करने की सभी कोशिशें fail हुई हैं। एक thread के अंदर चलने वाले code, threads के बीच चलने वाले code, और यहाँ तक कि computers के बीच चलने वाले code में बड़े semantic differences होते हैं। उन्हें abstract करने की कोशिश अंततः कम पड़ती है, इसलिए बेहतर है कि शुरुआत से ही ठीक से सीखा जाए
poll()औरselect()से code लिखने का तरीका भी है, लेकिन वह फिर एक अलग तरीका हैasync/await की इज्जत बचाने की लगभग marketing जैसी campaign देखना दिलचस्प है
मेरे अनुभव में यह सिर्फ technical mistake नहीं थी, बल्कि community को भी इसकी बड़ी कीमत चुकानी पड़ी। सच में useful language features पर focus करने के बजाय Rust की effort इस confusion में भटक गई
फिर भी language से अभी भी बहुत उम्मीदें हैं, और मेरे हिसाब से मौजूदा विकल्पों में यह सबसे अच्छी है। बस चिंता है कि यह लड़ाई हमेशा चलती रहेगी
P.S.:
AsyncWrite/AsyncReadexample अच्छा दिखता है, लेकिन असल में अगर *nix तक सीमित रहें तो threads और file descriptors से भी वही काम किया जा सकता हैऐसी generalization के लिए आधार कम है और यह किसी खास workload की तरफ झुकी हुई लगती है
async में मेरी बिल्कुल दिलचस्पी नहीं है और मैं Rust के बारे में पढ़ना चाहता हूँ, तो यह काफी खराब लगता है
poll()से तेज़ हैं, तो जानना चाहूँगा कि कौन सा use case है। मैंने पूरी जिंदगी में ऐसा मामला कभी नहीं देखागलती इसे उन ज़्यादातर use cases तक push करने में है जहाँ ऐसी जरूरत नहीं है
कई प्रमुख Rust projects async पर निर्भर करते हैं, सिर्फ thread-based alternatives की तुलना में बड़े performance improvements के कारण नहीं, बल्कि design characteristics के कारण भी। प्रमुख I/O-bound workloads में ये फायदे आसानी से दिखते हैं। वास्तविक समस्याएँ हल करने वाले smart लोगों ने major crates में async को व्यापक रूप से अपनाया है—यह मेरे हिसाब से मजबूत संकेत है कि async सच में useful language feature है
लड़ाई मुख्यतः Hacker News और reddit पर होती है, जहाँ जिन लोगों को async की जरूरत नहीं है वे इस बात पर नाराज़ होते हैं कि जिन I/O crates का वे इस्तेमाल करते हैं वे अब async चाहते हैं। यह स्थिति मजेदार नहीं है, यह समझता हूँ, और async में वास्तविक समस्याएँ हैं तथा कुछ अभी भी हल हो रही हैं। यह perfect नहीं है। लेकिन forums में दिखने वाला async विभाजन वास्तविक projects जितना व्यापक या dramatic नहीं लगता
एक बड़ी छूटी हुई चीज़ cancellation है
future को cancel करना बहुत आसान है। इसके उलट thread cancellation whack-a-mole जैसा गंदा मामला है, और जबरन thread रोकना भरोसेमंद नहीं है क्योंकि locks locked ही रह जाने का जोखिम होता है
Rust के async model में हर future पर बाहर से timeout लगाया जा सकता है। हर leaf I/O function को timeout option support करने की ज़रूरत नहीं होती, न ही उस timeout को पूरे call stack में आगे pass करने की ज़रूरत होती है
in-progress state management के लिए Rust की best practice
Dropguard के साथ इस्तेमाल करें, तो बड़े और जटिल काम भी आसानी से और भरोसेमंद तरीके से cancel किए जा सकते हैंमसलन
spawn_blockingका इस्तेमाल करने वाली किसी चीज़ को cancel (drop) करने पर भी वह background में चलती रहती है, और user को पता नहीं चल सकता। thread pool से implement किए गए async file system operations भी cancel करने पर चलते रहते हैंऐसी चीज़ें “मुझे पूरा यकीन है कि कोई भी वह file नहीं लिख रहा, फिर service file in use बताकर fail क्यों हो रही है?” जैसे समझना मुश्किल bugs तक ले जा सकती हैं
समस्या काफी हद तक समरूपी है। system calls मुश्किल हैं, लेकिन चाहे thread में करें या async future में, वही system call करने पर बिल्कुल वही cancellation problem पैदा होती है
सभी threads की पहुँच में रहने वाली किसी flag जैसी state रख दें, और work loop में वह flag check करवाएँ। अगर false हो तो return करें और thread को join कर दें, बस
अगर कोई काम अब उपयोगी नहीं रहा, तो यह जानकारी आखिरकार उस काम की ओर से call किए गए function को दिख जाए, इतना काफी है। RPC शुरू करने जैसे किसी बहुत महंगे काम से ठीक पहले न हो, तो मैं अलग से check नहीं करता
मेरे हिसाब से बेहतर सवाल यह है: “fiber क्यों नहीं, async/await क्यों?”
मुझे पता है कि Rust में 1.0 से पहले green threads थे और उन्हें जानबूझकर हटाया गया था, लेकिन fiber-आधारित concurrency implementation के कई तरीके हैं—जैसे भाषा में भारी runtime embed किए बिना भी इसे करने के तरीके
अगर मैंने लेख को सही समझा है, तो वह मुख्य रूप से इस बात की तारीफ करता दिखता है कि future को कभी भी drop किया जा सकता है। threads में साफ कारणों से ऐसा कुछ नहीं किया जा सकता, और तकनीकी रूप से संभव हो भी तो यह बेहद unsafe होगा। लेकिन इस क्षमता की बहुत बड़ी कीमत है।
io-uringजैसे completion-आधारित executor के साथ stack-आधारित arrays का इस्तेमाल नहीं किया जा सकता, या sub-tasks को किसी दूसरे executor thread पर नहीं चलाया जा सकता; साथ ही सूक्ष्म pitfalls और reliability issues भी पैदा होते हैं, जो synchronous Rust लिखते समय बहुत अप्रिय surprise बन जाते हैंhttps://smallcultfollowing.com/babysteps/blog/2022/06/13/asy...
task cancellation मूल रूप से cooperative cancellation ही होना चाहिए; non-cooperative cancellation ऊपर से सुविधाजनक लगता है, लेकिन अंदर गहरी समस्याओं वाला गलत feature जैसा है
साथ ही async/await की composability की तारीफ करना भी अजीब है। proper effect system के बिना मौजूदा Rust में, इसकी infectious nature की वजह से यह composability से काफी दूर है। उदाहरण के लिए standard library के
mapmethod के साथ async closure इस्तेमाल करके देखें, या standardio::Read/Writetraits इस्तेमाल करके देखेंजैसे stack को spaghetti stack के रूप में implement करना है, process-level memory mapping library की जरूरत रखनी है, या fixed-size stack तक सीमित रखना है
इन तीनों तरीकों में अलग ABI वाली language code के साथ interact करते समय समस्याएँ आती हैं। उदाहरण के लिए, अगर किसी fiber से C code call किया गया और वह C code किसी दूसरे fiber को resume करने की कोशिश करे, तो मामला काफी जटिल हो सकता है
async/await के फायदों में से एक खुद
awaitkeyword है। explicit wait points की वजह से concurrency program के interactions के बारे में सच में reason किया जा सकता हैyield करने वाला fiber concurrency की दुनिया के
gotoजैसा है। कोई method call करते समय आपको पता नहीं होता कि side effect के रूप में execution रुक जाएगा या नहीं, या resume होने पर दुनिया की state बदल चुकी होगी या नहीं। बाहरी दुनिया से संपर्क करते समय defensive तरीके से लिखना पड़ता है, इसलिए fibers उन tasks के लिए ज्यादा उपयुक्त हैं जो isolated रूप से चलते हैं और completion के जरिए communicate करते हैंgreen threads, fibers और coroutines यहाँ एक ही समस्या share करते हैं। user-space cooperative concurrency, concurrency के कठिन हिस्से को हल करने से ज्यादा मेज पर रखे कागज इधर-उधर करने जैसा है। Rust async/await ज्यादा explicit है, इसलिए वह उन side effects को नहीं छिपाता जिन्हें दूसरे mechanisms छिपा देते हैं
fiber-based code में चलते हुए thread को दिमाग में track करना पड़ता है, इसलिए उसे follow करना मुश्किल लगता है। कम से कम मेरे लिए, पूरा होने वाली value को track करना कहीं आसान है
C++ committee के लिए Gor Nishanov की review http://www.open-std.org/JTC1/SC22/WG21/docs/papers/2018/p136... देखें। इसे https://devblogs.microsoft.com/oldnewthing/20191011-00/?p=10... में भी link किया गया है। summary भी साफ है: DO NOT USE FIBERS!
https://m.youtube.com/watch?v=qKQcUDEo-ZI
मुझे लगता है यह काफी अच्छे से दिखाता है कि async/await infectious और awkward है, और कम से कम Ruby implementation में fiber कहीं बेहतर paradigm है
कोई optimization algorithm root-finding problem को call कर सकता है, और वह ODE integrator को call कर सकता है, इस तरह nesting हो सकती है, और किसी भी stage में बहुत लंबा समय लग सकता है। cancellation token हर जगह pass करना पड़ेगा, लेकिन numerical computation frameworks आम तौर पर इसे support नहीं करते
हर algorithm पर iteration count limit लगाई जा सकती है और लगानी भी चाहिए, लेकिन nested algorithms में बस इतना guarantee होता है कि इस साल के भीतर रुक जाएगा; 5 सेकंड में रुकने की guarantee देना मुश्किल है
ऐसी problems में मैं guarantee दे सकता हूँ कि मेरा काम सिर्फ大量 mathematical computation, allocation और उससे होने वाले page faults, कोई I/O नहीं, और उन log strings को standard library Queue object में लिखना है जिन्हें main thread handle करेगा और जिसे cancel नहीं किया जाएगा। जो दूसरी functionality चाहिए, उसे भी Queue के जरिए main thread को वापस भेजा जा सकता है
21वीं सदी में यह समस्या बिना cancellation token को हर जगह जबरन pass कराए, और बिना token check किए लंबे समय तक चलने वाले code को defensive ढंग से लिखने पर मजबूर किए, हल हो जानी चाहिए—ऐसा लगता है
async/await पर एक और discussion में लोग async/await को समझ नहीं पाते, यह कल्पना नहीं कर पाते कि single thread पर concurrency mechanism की जरूरत क्यों होगी, और मान लेते हैं कि किसी को इसकी जरूरत नहीं है
UI programming, GPU से communication, runtime-to-runtime communication इसके अच्छे उदाहरण हैं, और भी होंगे
threads—चाहे green threads हों या नहीं—इन cases के लिए fit नहीं हैं, लेकिन async/await fit है
अगर वही binary बनाते हुए language users के लिए कम झंझट वाली दूसरी language feature implementation संभव हो, तो उस पर भी बात की जा सकती है
Rust async/await का एक मुख्य फायदा यह है कि यह threads या dynamic memory न होने की स्थिति में भी काम कर सकता है
माइक्रोकंट्रोलर पर interrupt किसी buffer में आने वाले I2C डेटा को पढ़ने तक इंतज़ार करने वाला संक्षिप्त code लिखने के लिए भी यह काफी उपयोगी हो सकता है। यह एक higher-level abstraction है, जो नीचे के runtime के साथ बहुत अधिक interaction उजागर किए बिना concurrency इस्तेमाल करने देता है
जिन भी बड़े software पर मैंने काम किया है, उन सभी ने किसी न किसी रूप में ऐसी चीज़ implement की थी। आधुनिक C++ coroutine concept न होने वाले code में भी Apple Grand Central Dispatch, Intel Threading Building Blocks वगैरह इस्तेमाल किए गए थे। वरना business logic I/O पर बहुत inefficient तरीके से block हो जाता है, या threads की संख्या इतनी ज़्यादा हो जाती है कि development और debugging नरक बन जाते हैं, या code नीचे के runtime implementation details से भर जाता है—या फिर इन तीनों का मिश्रण होता है
अगर आप language में मौजूद abstraction या library के existing abstraction का इस्तेमाल नहीं करते, तो आखिरकार आपको खुद बनाना पड़ता है, और यह कठिन होता है तथा व्यापक रूप से इस्तेमाल होने वाली चीज़ों की तुलना में कुल मिलाकर inferior होने की संभावना ज़्यादा होती है। पहले मैंने C++ के लिए खुद ऐसा बनाया भी था: https://github.com/goto-opensource/asyncly
लगता है लेखक दो चीज़ों को मिला रहे हैं
एक है user-space threads/green threads, और दूसरी है structured concurrency
पहला async/await का फायदा है, लेकिन इसका exclusive फायदा नहीं है। Go या Java Loom जैसे उदाहरण हैं, जहाँ function color problem के बिना यह संभव है
दूसरा OS threads और green threads, दोनों से implement किया जा सकता है। Java का Structured Concurrency JEP देखें
https://openjdk.org/jeps/462