हमने हाल ही में अपने रस्ट प्रोजेक्ट्स में से एक , एक axum
सेवा को देखा, जब मेमोरी उपयोग की बात आती है तो कुछ अजीब व्यवहार प्रदर्शित होता है। एक अजीब-सी दिखने वाली मेमोरी प्रोफाइल वह आखिरी चीज है जिसकी मैं रस्ट प्रोग्राम से अपेक्षा करता हूं, लेकिन यहां हम हैं।
सेवा कुछ समय के लिए "फ्लैट" मेमोरी के साथ चलेगी, फिर अचानक एक नए स्तर पर पहुंच जाएगी। यह पैटर्न घंटों तक दोहराया जाएगा, कभी-कभी लोड के तहत, लेकिन हमेशा नहीं। चिंताजनक बात यह थी कि एक बार जब हमने तेज वृद्धि देखी, तो स्मृति का वापस नीचे गिरना दुर्लभ था। यह ऐसा था मानो स्मृति खो गई हो, या अन्यथा कभी-कभार "लीक" हो गई हो।
सामान्य परिस्थितियों में, यह "सीढ़ी-कदम" प्रोफ़ाइल बस अजीब लग रही थी, लेकिन एक बिंदु पर मेमोरी उपयोग असंगत रूप से बढ़ गया। असीमित मेमोरी वृद्धि के कारण सेवाओं को बाहर निकलने के लिए मजबूर होना पड़ सकता है। जब सेवाएँ अचानक बंद हो जाती हैं, तो इससे उपलब्धता कम हो सकती है... जो व्यवसाय के लिए बुरा है। मैं गहराई से जानना चाहता था कि क्या हो रहा है।
आम तौर पर जब मैं किसी प्रोग्राम में अप्रत्याशित मेमोरी वृद्धि के बारे में सोचता हूं, तो मैं लीक के बारे में सोचता हूं। फिर भी, यह अलग लग रहा था. रिसाव के साथ, आप विकास का अधिक स्थिर, नियमित पैटर्न देखते हैं।
अक्सर यह ऊपर और दाहिनी ओर झुकी हुई एक रेखा की तरह दिखता है। तो, यदि हमारी सेवा लीक नहीं हो रही थी, तो वह क्या कर रही थी?
यदि मैं उन स्थितियों की पहचान कर सकता हूं जिनके कारण मेमोरी उपयोग में उछाल आया है, तो शायद मैं जो कुछ भी हो रहा था उसे कम कर सकता हूं।
मेरे पास दो ज्वलंत प्रश्न थे:
ऐतिहासिक मेट्रिक्स को देखते हुए, मैं लंबी सपाट अवधियों के बीच तेज वृद्धि के समान पैटर्न देख सकता था, लेकिन पहले कभी भी हमने इस तरह की वृद्धि नहीं देखी थी। यह जानने के लिए कि क्या विकास स्वयं नया था ("सीढ़ी-कदम" पैटर्न हमारे लिए सामान्य होने के बावजूद), मुझे इस व्यवहार को पुन: उत्पन्न करने के लिए एक विश्वसनीय तरीके की आवश्यकता होगी।
अगर मैं "कदम" को खुद को दिखाने के लिए मजबूर कर सकता हूं, तो मेरे पास स्मृति वृद्धि को रोकने के लिए कदम उठाते समय व्यवहार में बदलाव को सत्यापित करने का एक तरीका होगा। मैं हमारे गिट इतिहास को भी पीछे ले जा सकूंगा और एक ऐसे समय की तलाश कर सकूंगा जब सेवा ने असीमित वृद्धि प्रदर्शित नहीं की थी।
अपने लोड परीक्षण चलाते समय मैंने जिन आयामों का उपयोग किया वे थे:
सेवा में भेजे गए POST निकायों का आकार.
अनुरोध दर (यानी, प्रति सेकंड अनुरोध)।
समवर्ती ग्राहक कनेक्शन की संख्या.
मेरे लिए जादुई संयोजन था: बड़े अनुरोध निकाय और उच्च समवर्तीता ।
स्थानीय सिस्टम पर लोड परीक्षण चलाते समय, क्लाइंट और सर्वर दोनों को चलाने के लिए उपलब्ध प्रोसेसर की सीमित संख्या सहित सभी प्रकार के सीमित कारक होते हैं। फिर भी, मैं अपनी स्थानीय मशीन पर मेमोरी में "स्टेयर-स्टेप" को सही परिस्थितियों में देख पा रहा था, यहां तक कि कम समग्र अनुरोध दर पर भी।
एक निश्चित आकार के पेलोड का उपयोग करना और बैचों में अनुरोध भेजना, उनके बीच थोड़े समय के आराम के साथ, मैं एक समय में एक कदम, बार-बार सेवा की मेमोरी को बढ़ाने में सक्षम था।
मुझे यह दिलचस्प लगा कि समय के साथ मेरी याददाश्त तो बढ़ सकती है, लेकिन आख़िरकार मैं घटते प्रतिफल के बिंदु पर पहुँच जाऊँगा। अंततः, वृद्धि की कुछ सीमा (अभी भी अपेक्षा से कहीं अधिक) होगी। थोड़ा और खेलने पर, मैंने पाया कि अलग-अलग पेलोड आकारों के साथ अनुरोध भेजकर मैं और भी ऊंची सीमा तक पहुंच सकता हूं।
एक बार जब मैंने अपने इनपुट की पहचान कर ली, तो मैं हमारे गिट इतिहास के माध्यम से पीछे की ओर काम करने में सक्षम हो गया, अंततः मुझे पता चला कि हमारा उत्पादन डर हमारी ओर से हाल के परिवर्तनों का परिणाम होने की संभावना नहीं है।
इस "सीढ़ी-कदम" को ट्रिगर करने के लिए कार्यभार का विवरण स्वयं एप्लिकेशन के लिए विशिष्ट है, हालांकि मैं एक खिलौना परियोजना के साथ एक समान ग्राफ बनाने में सक्षम था।
#[derive(serde::Deserialize, Clone)] struct Widget { payload: serde_json::Value, } #[derive(serde::Serialize)] struct WidgetCreateResponse { id: String, size: usize, } async fn create_widget(Json(widget): Json<Widget>) -> Response { ( StatusCode::CREATED, Json(process_widget(widget.clone()).await), ) .into_response() } async fn process_widget(widget: Widget) -> WidgetCreateResponse { let widget_id = uuid::Uuid::new_v4(); let bytes = serde_json::to_vec(&widget.payload).unwrap_or_default(); // An arbitrary sleep to pad the handler latency as a stand-in for a more // complex code path. // Tweak the duration by setting the `SLEEP_MS` env var. tokio::time::sleep(std::time::Duration::from_millis( std::env::var("SLEEP_MS") .as_deref() .unwrap_or("150") .parse() .expect("invalid SLEEP_MS"), )) .await; WidgetCreateResponse { id: widget_id.to_string(), size: bytes.len(), } }
यह पता चला कि आपको वहां पहुंचने के लिए ज्यादा कुछ नहीं चाहिए था। मैं JSON बॉडी प्राप्त करने वाले एकल हैंडलर के साथ एक axum
ऐप से एक समान तेज (लेकिन इस मामले में बहुत छोटा) वृद्धि देखने में कामयाब रहा।
हालाँकि मेरे खिलौना प्रोजेक्ट में स्मृति वृद्धि कहीं भी उतनी नाटकीय नहीं थी जितनी हमने उत्पादन सेवा में देखी थी, यह मेरी जांच के अगले चरण के दौरान तुलना और अंतर करने में मेरी मदद करने के लिए पर्याप्त थी। जब मैंने विभिन्न कार्यभार के साथ प्रयोग किया तो इससे मुझे छोटे कोडबेस का सख्त पुनरावृत्ति लूप प्राप्त करने में भी मदद मिली। मैंने अपने लोड परीक्षण कैसे चलाए, इसके विवरण के लिए README देखें।
मैंने कुछ समय वेब पर बग रिपोर्ट या चर्चाएँ खोजने में बिताया जो समान व्यवहार का वर्णन कर सकती हैं। एक शब्द जो बार-बार सामने आया वह था हीप फ्रैग्मेंटेशन और इस विषय पर थोड़ा और पढ़ने के बाद, ऐसा लगा कि यह जो मैं देख रहा था उसमें फिट हो सकता है।
एक निश्चित उम्र के लोगों को "प्रयुक्त" और "मुक्त" क्षेत्रों को समेकित करने के लिए हार्ड डिस्क पर डीओएस या विंडोज़ पर डीफ़्रैग उपयोगिता को ब्लॉकों को घुमाते हुए देखने का अनुभव हो सकता है।
इस पुराने पीसी हार्ड ड्राइव के मामले में, अलग-अलग आकार की फ़ाइलों को डिस्क पर लिखा गया था और बाद में अन्य उपयोग किए गए क्षेत्रों के बीच उपलब्ध स्थान का "छेद" छोड़कर स्थानांतरित या हटा दिया गया था। जैसे ही डिस्क भरने लगती है, आप एक नई फ़ाइल बनाने का प्रयास कर सकते हैं जो उन छोटे क्षेत्रों में से किसी एक में बिल्कुल फिट नहीं बैठती है। ढेर विखंडन परिदृश्य में, इससे आवंटन विफलता हो जाएगी, हालांकि डिस्क विखंडन की विफलता मोड थोड़ा कम कठोर होगा। डिस्क पर, फ़ाइल को छोटे टुकड़ों में विभाजित करने की आवश्यकता होगी जो पहुंच को बहुत कम कुशल बनाता है ( सुधार के लिए wongarsu
धन्यवाद)। डिस्क ड्राइव का समाधान उन खुले ब्लॉकों को निरंतर स्थानों के साथ फिर से व्यवस्थित करने के लिए ड्राइव को "डीफ़्रैग" (डी-फ़्रैगमेंट) करना है।
कुछ ऐसा ही तब हो सकता है जब एलोकेटर (आपके प्रोग्राम में मेमोरी आवंटन को प्रबंधित करने के लिए जिम्मेदार चीज़) समय के साथ अलग-अलग आकार के मान जोड़ता और हटाता है। अंतराल जो बहुत छोटे हैं और पूरे ढेर में बिखरे हुए हैं, एक नए मूल्य को समायोजित करने के लिए मेमोरी के नए "ताज़ा" ब्लॉक आवंटित किए जा सकते हैं जो अन्यथा फिट नहीं होंगे। हालाँकि दुर्भाग्य से मेमोरी प्रबंधन कैसे काम करता है, इसके कारण "डीफ़्रैग" संभव नहीं है।
विखंडन का विशिष्ट कारण कई चीजें हो सकती हैं: serde
के साथ JSON पार्सिंग, axum
में फ्रेमवर्क-स्तर पर कुछ, tokio
में कुछ गहरा, या यहां तक कि दिए गए सिस्टम के लिए विशिष्ट आवंटनकर्ता कार्यान्वयन का एक विचित्र रूप भी। यहां तक कि मूल कारण (यदि ऐसी कोई बात है) को जाने बिना भी व्यवहार हमारे वातावरण में देखा जा सकता है और कुछ हद तक नंगे-हड्डियों वाले ऐप में पुनरुत्पादित किया जा सकता है। (अपडेट: अधिक जांच की आवश्यकता है, लेकिन हमें पूरा यकीन है कि यह JSON पार्सिंग है, HackerN ews पर हमारी टिप्पणी देखें)
यदि प्रक्रिया स्मृति के साथ यही हो रहा था, तो इसके बारे में क्या किया जा सकता है? ऐसा लगता है कि विखंडन से बचने के लिए कार्यभार को बदलना कठिन होगा। ऐसा भी लगता है कि विखंडन की घटनाएं कैसे घटित हो रही हैं, इसके लिए कोड में मूल कारण ढूंढने के लिए मेरे प्रोजेक्ट में सभी निर्भरताओं को दूर करना मुश्किल होगा। तो क्या कर सकते हैं?
Jemalloc
jemalloc
"विखंडन से बचने और स्केलेबल समवर्ती समर्थन पर जोर देने" के लक्ष्य के रूप में वर्णित किया गया है। समवर्तीता वास्तव में मेरे कार्यक्रम के लिए समस्या का एक हिस्सा थी, और विखंडन से बचना खेल का नाम है। jemalloc
ऐसा लगता है जैसे यह वही हो सकता है जिसकी मुझे आवश्यकता है।
चूँकि jemalloc
एक एलोकेटर है जो सबसे पहले विखंडन से बचने के लिए अपने रास्ते से हट जाता है, उम्मीद थी कि हमारी सेवा मेमोरी को धीरे-धीरे बढ़ाए बिना लंबे समय तक चलने में सक्षम हो सकती है।
मेरे प्रोग्राम में इनपुट, या एप्लिकेशन निर्भरता के ढेर को बदलना इतना मामूली नहीं है। हालाँकि, आवंटनकर्ता की अदला-बदली करना मामूली बात है।
https://github.com/tikv/jemallocator रीडमी में उदाहरणों के बाद, इसे टेस्ट ड्राइव के लिए लेने के लिए बहुत कम काम की आवश्यकता थी।
अपने खिलौना प्रोजेक्ट के लिए, मैंने वैकल्पिक रूप से jemalloc
के लिए डिफ़ॉल्ट एलोकेटर को स्वैप करने और अपने लोड परीक्षणों को फिर से चलाने के लिए एक कार्गो सुविधा जोड़ी।
मेरे सिम्युलेटेड लोड के दौरान रेजिडेंट मेमोरी को रिकॉर्ड करने से दो अलग-अलग मेमोरी प्रोफाइल दिखाई देते हैं।
jemalloc
के बिना, हम परिचित सीढ़ी-चरण प्रोफ़ाइल देखते हैं। jemalloc
के साथ, हम देखते हैं कि परीक्षण चलने पर मेमोरी बार-बार बढ़ती और घटती रहती है। इससे भी महत्वपूर्ण बात यह है कि लोड और निष्क्रिय समय के दौरान jemalloc
के साथ मेमोरी के उपयोग के बीच काफी अंतर होता है, हम "अपनी जमीन नहीं खोते" जैसा कि हमने पहले किया था क्योंकि मेमोरी हमेशा बेसलाइन पर वापस आती है।
यदि आपको रस्ट सेवा पर "सीढ़ी-कदम" प्रोफ़ाइल दिखाई देती है, तो परीक्षण ड्राइव के लिए jemalloc
लेने पर विचार करें। यदि आपके पास कार्यभार है जो ढेर विखंडन को बढ़ावा देता है, jemalloc
समग्र रूप से बेहतर परिणाम दे सकता है।
अलग से, खिलौना प्रोजेक्ट रेपो में https://github.com/fcsonline/dill लोड परीक्षण टूल के साथ उपयोग के लिए एक benchmark.yml
शामिल है। यह देखने के लिए कि एलोकेटर में परिवर्तन मेमोरी प्रोफ़ाइल को कैसे प्रभावित करता है, समवर्तीता, बॉडी आकार (और सेवा में मनमाने ढंग से हैंडलर नींद की अवधि) आदि को बदलने का प्रयास करें।
जहां तक वास्तविक दुनिया के प्रभाव की बात है, जब हमने jemalloc
पर स्विच शुरू किया तो आप प्रोफ़ाइल में बदलाव को स्पष्ट रूप से देख सकते हैं।
जहां सेवा अक्सर लोड की परवाह किए बिना सपाट रेखाएं और बड़े चरण दिखाती थी, अब हम एक अधिक टेढ़ी-मेढ़ी रेखा देखते हैं जो सक्रिय कार्यभार का अधिक बारीकी से अनुसरण करती है। सेवा को अनावश्यक मेमोरी वृद्धि से बचने में मदद करने के अलावा, इस बदलाव ने हमें इस बात की बेहतर जानकारी दी कि हमारी सेवा लोड पर कैसे प्रतिक्रिया करती है, इसलिए कुल मिलाकर, यह एक सकारात्मक परिणाम था।
यदि आप रस्ट का उपयोग करके एक मजबूत और स्केलेबल सेवा बनाने में रुचि रखते हैं, तो हम भर्ती कर रहे हैं! अधिक जानकारी के लिए हमारा करियर पेज देखें।
इस तरह की अधिक सामग्री के लिए, स्विक्स वेबहुक सेवा के नवीनतम अपडेट के लिए हमें ट्विटर , जीथब या आरएसएस पर फ़ॉलो करना सुनिश्चित करें, या हमारे समुदाय स्लैक पर चर्चा में शामिल हों।