Rails विशिष्ट स्थितियों के लिए अंतर्निहित कई उपयोगी टूल्स के साथ एक बड़ा फ्रेमवर्क है। इस श्रृंखला में, हम Rails के विशाल कोडबेस में छिपे कुछ कम-ज्ञात टूल्स पर एक नज़र डाल रहे हैं।
इस लेख में, हम ActiveRecord के update_counters मेथड पर एक नज़र डालेंगे। इस प्रक्रिया में, हम मल्टीथ्रेडेड प्रोग्राम्स में "रेस कंडीशन्स" के आम जाल को देखेंगे और यह मेथड उन्हें कैसे रोक सकता है।
थ्रेड्स
प्रोग्रामिंग करते समय, हमारे पास कोड को समानांतर में चलाने के कई तरीके हैं, जिनमें प्रोसेस, थ्रेड्स, और हाल ही में (रूबी में) फाइबर्स और रिएक्टर्स शामिल हैं। इस लेख में, हम केवल थ्रेड्स की चिंता करेंगे, क्योंकि यह सबसे आम रूप है जिसका Rails डेवलपर्स सामना करेंगे। उदाहरण के लिए, Puma एक मल्टीथ्रेडेड सर्वर है, और Sidekiq एक मल्टीथ्रेडेड बैकग्राउंड जॉब प्रोसेसर है।
हम यहां थ्रेड्स और थ्रेड सेफ्टी में गहराई से नहीं जाएंगे। मुख्य बात यह जानना है कि जब दो थ्रेड्स एक ही डेटा पर काम कर रहे होते हैं, तो डेटा आसानी से आउट ऑफ सिंक हो सकता है। इसे "रेस कंडीशन" के रूप में जाना जाता है।
रेस कंडीशन्स
रेस कंडीशन तब होती है जब दो (या अधिक) थ्रेड्स एक ही समय में एक ही डेटा पर काम कर रहे होते हैं, जिसका अर्थ है कि एक थ्रेड स्टेल डेटा का उपयोग कर सकता है। इसे "रेस कंडीशन" कहा जाता है क्योंकि यह ऐसा है जैसे दो थ्रेड्स एक-दूसरे के साथ दौड़ रहे हों, और डेटा की अंतिम स्थिति इस बात पर निर्भर हो सकती है कि कौन सा थ्रेड "दौड़ जीता"। शायद सबसे बुरी बात यह है कि रेस कंडीशन्स को पुन: उत्पन्न करना बहुत मुश्किल होता है क्योंकि वे आमतौर पर केवल तभी होती हैं जब थ्रेड्स एक विशेष क्रम में और कोड में एक विशेष बिंदु पर "बारी लेते हैं"।
एक उदाहरण
रेस कंडीशन दिखाने के लिए उपयोग किया जाने वाला एक सामान्य परिदृश्य बैंक बैलेंस को अपडेट करना है। हम एक बुनियादी Rails एप्लिकेशन के भीतर एक साधारण टेस्ट क्लास बनाएंगे ताकि हम देख सकें कि क्या होता है:
class UnsafeTransaction def self.run account = Account.find(1) account.update!(balance: 0) threads = [] 4.times do threads << Thread.new do balance = account.reload.balance account.update!(balance: balance + 100) balance = account.reload.balance account.update!(balance: balance - 100) end end threads.map(&:join) account.reload.balance end end हमारा UnsafeTransaction काफी सरल है; हमारे पास बस एक मेथड है जो एक Account को खोजता है (एक मानक Rails मॉडल जिसमें BigDecimal balance एट्रिब्यूट होता है)। हम बैलेंस को शून्य पर रीसेट करते हैं ताकि टेस्ट को फिर से चलाना सरल हो।
आंतरिक लूप वह जगह है जहां चीजें थोड़ी अधिक दिलचस्प हो जाती हैं। हम चार थ्रेड्स बना रहे हैं जो अकाउंट का वर्तमान बैलेंस लेंगे, इसमें 100 जोड़ेंगे (जैसे $100 जमा), और फिर तुरंत 100 घटा देंगे (जैसे $100 निकासी)। हम दोनों बार reload का उपयोग कर रहे हैं ताकि यह अतिरिक्त सुनिश्चित हो सके कि हमारे पास अप-टू-डेट बैलेंस है।
शेष लाइनें केवल सफाई हैं। Thread.join का अर्थ है कि हम आगे बढ़ने से पहले सभी थ्रेड्स के समाप्त होने की प्रतीक्षा करेंगे, और फिर मेथड के अंत में अंतिम बैलेंस लौटाएंगे।
यदि हम इसे एकल थ्रेड के साथ चलाते हैं (लूप को 1.times do में बदलकर), तो हम इसे खुशी से दस लाख बार चला सकते हैं और सुनिश्चित हो सकते हैं कि अंतिम अकाउंट बैलेंस हमेशा शून्य होगा। इसे दो (या अधिक) थ्रेड्स में बदलें, हालांकि, और चीजें कम निश्चित हो जाती हैं।
कंसोल में एक बार टेस्ट चलाने पर शायद सही उत्तर मिलेगा:
UnsafeTransaction.run => 0.0 हालांकि, क्या होगा यदि हम इसे बार-बार चलाएं। मान लीजिए हम इसे दस बार चलाते हैं:
(1..10).map { UnsafeTransaction.run }.map(&:to_f) => [0.0, 300.0, 300.0, 100.0, 100.0, 100.0, 300.0, 300.0, 100.0, 300.0] यदि यहां सिंटैक्स परिचित नहीं है, तो (1..10).map {} ब्लॉक में कोड को 10 बार चलाएगा, प्रत्येक रन के परिणामों को एक ऐरे में रखेगा। अंत में .map(&:to_f) केवल संख्याओं को अधिक मानव-पठनीय बनाने के लिए है, क्योंकि BigDecimal मान सामान्यतः एक्सपोनेंशियल नोटेशन में प्रिंट होंगे जैसे 0.1e3।
याद रखें, हमारा कोड वर्तमान बैलेंस लेता है, 100 जोड़ता है, और फिर तुरंत 100 घटाता है, इसलिए अंतिम परिणाम हमेशा 0.0 होना चाहिए। ये 100.0 और 300.0 एंट्रीज़, तब, इस बात का प्रमाण हैं कि हमारे पास एक रेस कंडीशन है।
एक एनोटेटेड उदाहरण
आइए यहां समस्या कोड पर ज़ूम इन करें और देखें कि क्या हो रहा है। हम स्पष्टता के लिए balance में परिवर्तनों को अलग कर देंगे।
threads << Thread.new do # थ्रेड यहां स्विच हो सकता है balance = account.reload.balance # या यहां... balance += 100 # या यहां... account.update!(balance: balance) # या यहां... balance = account.reload.balance # या यहां... balance -= 100 # या यहां... account.update!(balance: balance) # या यहां... end जैसा कि हम टिप्पणियों में देखते हैं, थ्रेड्स इस कोड के लगभग किसी भी बिंदु पर स्वैप हो सकते हैं। यदि थ्रेड 1 बैलेंस पढ़ता है, फिर कंप्यूटर थ्रेड 2 को निष्पादित करना शुरू करता है, तो यह काफी संभव है कि update! कॉल करने तक डेटा आउट ऑफ डेट हो जाए। दूसरे शब्दों में, थ्रेड 1, थ्रेड 2, और डेटाबेस, सभी में डेटा है, लेकिन वे एक-दूसरे के साथ आउट ऑफ सिंक हो रहे हैं।
यहां उद्देश्यपूर्ण रूप से तुच्छ है ताकि इसे समझना आसान हो। वास्तविक दुनिया में, हालांकि, रेस कंडीशन्स का निदान करना कठिन हो सकता है, विशेष रूप से क्योंकि उन्हें आमतौर पर विश्वसनीय रूप से पुन: उत्पन्न नहीं किया जा सकता।
समाधान
रेस कंडीशन्स को रोकने के लिए कुछ विकल्प हैं, लेकिन उनमें से लगभग सभी एक ही विचार के इर्द-गिर्द घूमते हैं: यह सुनिश्चित करना कि किसी भी समय केवल एक इकाई डेटा बदल रही है।
विकल्प 1: म्यूटेक्स
सबसे सरल विकल्प "म्यूचुअल एक्सक्लूज़न लॉक" है, जिसे आमतौर पर म्यूटेक्स के नाम से जाना जाता है। आप म्यूटेक्स को केवल एक चाबी वाले लॉक के रूप में सोच सकते हैं। यदि एक थ्रेड चाबी रखता है, तो वह म्यूटेक्स में जो कुछ भी है उसे चला सकता है। अन्य सभी थ्रेड्स को तब तक इंतजार करना होगा जब तक वे चाबी नहीं रख लेते।
हमारे उदाहरण कोड पर म्यूटेक्स लागू करना इस तरह किया जा सकता है:
class MutexTransaction def self.run account = Account.find(1) account.update!(balance: 0) mutex = Mutex.new threads = [] 4.times do threads << Thread.new do mutex.lock balance = account.reload.balance account.update!(balance: balance + 100) mutex.unlock mutex.lock balance = account.reload.balance account.update!(balance: balance - 100) mutex.unlock end end threads.map(&:join) account.reload.balance end end यहां, हर बार जब हम account को पढ़ते और लिखते हैं, तो हम पहले mutex.lock कॉल करते हैं, और एक बार जब हम काम पूरा कर लेते हैं, तो हम अन्य थ्रेड्स को मौका देने के लिए mutex.unlock कॉल करते हैं। हम ब्लॉक की शुरुआत में सिर्फ mutex.lock और अंत में mutex.unlock कॉल कर सकते थे; हालांकि, इसका मतलब होगा कि थ्रेड्स अब साथ-साथ नहीं चल रहे हैं, जो थ्रेड्स का उपयोग करने के कारण को कुछ हद तक नकार देता है। प्रदर्शन के लिए, mutex के अंदर कोड को यथासंभव छोटा रखना सबसे अच्छा है, क्योंकि यह थ्रेड्स को यथासंभव अधिक कोड को समानांतर में निष्पादित करने की अनुमति देता है।
हमने यहां स्पष्टता के लिए .lock और .unlock का उपयोग किया है, लेकिन रूबी का Mutex क्लास एक अच्छा synchronize मेथड प्रदान करता है जो एक ब्लॉक लेता है और इसे हमारे लिए संभालता है, इसलिए हम निम्नलिखित कर सकते थे:
mutex.synchronize do balance = ... ... end रूबी का म्यूटेक्स वह करता है जिसकी हमें आवश्यकता है, लेकिन जैसा कि आप शायद कल्पना कर सकते हैं, Rails एप्लिकेशन्स में किसी विशेष डेटाबेस रो को लॉक करना काफी आम है, और ActiveRecord ने इस परिदृश्य के लिए हमें कवर किया है।
विकल्प 2: ActiveRecord लॉक्स
ActiveRecord कुछ अलग लॉकिंग तंत्र प्रदान करता है, और हम उन सभी में यहां गहराई से नहीं जाएंगे। हमारे उद्देश्यों के लिए, हम उस रो को लॉक करने के लिए बस lock! का उपयोग कर सकते हैं जिसे हम अपडेट करना चाहते हैं:
class LockedTransaction def self.run account = Account.find(1) account.update!(balance: 0) threads = [] 4.times do threads << Thread.new do Account.transaction do account = account.reload account.lock! account.update!(balance: account.balance + 100) end Account.transaction do account = account.reload account.lock! account.update!(balance: account.balance - 100) end end end threads.map(&:join) account.reload.balance end end जहां एक म्यूटेक्स एक विशेष थ्रेड के लिए कोड के खंड को "लॉक" करता है, lock! विशेष डेटाबेस रो को लॉक करता है। इसका मतलब है कि वही कोड कई अकाउंट्स पर समानांतर में निष्पादित हो सकता है (उदाहरण के लिए, बैकग्राउंड जॉब्स के एक समूह में)। केवल उन्हीं थ्रेड्स को इंतजार करना होगा जिन्हें उसी रिकॉर्ड तक पहुंचने की आवश्यकता है। ActiveRecord एक आसान #with_lock मेथड भी प्रदान करता है जो आपको ट्रांजैक्शन और लॉक को एक बार में करने देता है, इसलिए ऊपर के अपडेट्स को थोड़ा अधिक संक्षिप्त रूप से इस प्रकार लिखा जा सकता है:
account = account.reload account.with_lock do account.update!(account.balance + 100) end ... समाधान 3: एटॉमिक मेथड्स
एक 'एटॉमिक' मेथड (या फंक्शन) को निष्पादन के बीच में रोका नहीं जा सकता। उदाहरण के लिए, रूबी में आम += ऑपरेशन नहीं एटॉमिक है, भले ही यह एकल ऑपरेशन जैसा दिखता हो:
value += 10 # समतुल्य: value = value + 10 # या अधिक वर्बोज़: temp_value = value + 10 value = temp_value यदि थ्रेड अचानक value + 10 क्या है यह निकालने और परिणाम को value पर वापस लिखने के बीच "सो जाता है", तो यह रेस कंडीशन की संभावना खोलता है। हालांकि, कल्पना करें कि रूबी ने इस ऑपरेशन के दौरान थ्रेड्स को सोने की अनुमति नहीं दी। यदि हम निश्चितता के साथ कह सकते हैं कि एक थ्रेड इस ऑपरेशन के दौरान कभी नहीं सोएगा (उदाहरण के लिए, कंप्यूटर कभी भी निष्पादन को एक अलग थ्रेड पर स्विच नहीं करेगा), तो इसे "एटॉमिक" ऑपरेशन माना जा सकता है।
कुछ भाषाओं में इस तरह की थ्रेड-सेफ्टी के लिए प्रिमिटिव मानों के एटॉमिक संस्करण होते हैं (उदाहरण के लिए, AtomicInteger और AtomicFloat)। इसका मतलब यह नहीं है कि Rails डेवलपर्स के रूप में हमारे पास कुछ "एटॉमिक" ऑपरेशन उपलब्ध नहीं हैं। एक उदाहरण ActiveRecord का update_counters मेथड है।
हालांकि यह मुख्य रूप से काउंटर कैश को अप-टू-डेट रखने के लिए है, लेकिन हमें अपने एप्लिकेशन्स में इसका उपयोग करने से कोई नहीं रोक रहा है। काउंटर कैश के बारे में अधिक जानकारी के लिए, आप कैशिंग पर मेरा पहले का लेख देख सकते हैं।
मेथड का उपयोग करना अविश्वसनीय रूप से सरल है:
class CounterTransaction def self.run account = Account.find(1) account.update!(balance: 0) threads = [] 4.times do threads << Thread.new do Account.update_counters(account.id, balance: 100) Account.update_counters(account.id, balance: -100) end end threads.map(&:join) account.reload.balance end end कोई म्यूटेक्स नहीं, कोई लॉक नहीं, बस रूबी की दो लाइनें; update_counters पहला आर्गुमेंट के रूप में रिकॉर्ड आईडी लेता है, और फिर हम इसे बताते हैं कि किस कॉलम को बदलना है (balance:) और कितना बदलना है (100 या -100)। यह काम करने का कारण यह है कि रीड-अपडेट-राइट साइकिल अब डेटाबेस में एक ही SQL कॉल में होती है। इसका मतलब है कि हमारा रूबी थ्रेड ऑपरेशन को बाधित नहीं कर सकता; भले ही यह सो जाए, इससे कोई फर्क नहीं पड़ता क्योंकि डेटाबेस वास्तविक गणना कर रहा है।
उत्पन्न वास्तविक SQL कम से कम मेरे मशीन पर पोस्टग्रेस के लिए इस तरह दिखता है:
Account Update All (1.7ms) UPDATE "accounts" SET "balance" = COALESCE("balance", 0) + $1 WHERE "accounts"."id" = $2 [["balance", "100.0"], ["id", 1]] यह तरीका बहुत बेहतर प्रदर्शन करता है, जो आश्चर्यजनक नहीं है, क्योंकि गणना पूरी तरह से डेटाबेस में होती है; हमें नवीनतम मान प्राप्त करने के लिए रिकॉर्ड को कभी reload नहीं करना पड़ता। यह गति एक कीमत पर आती है, हालांकि। चूंकि हम यह रॉ SQL में कर रहे हैं, हम Rails मॉडल को बायपास कर रहे हैं, जिसका अर्थ है कि कोई भी वैलिडेशन्स या कॉलबैक्स निष्पादित नहीं होंगे (जिसका अर्थ है, अन्य बातों के अलावा, updated_at टाइमस्टैम्प में कोई परिवर्तन नहीं)।
निष्कर्ष
रेस कंडीशन्स बहुत अच्छी तरह से हाइजेनबग पोस्टर चाइल्ड हो सकती हैं। उन्हें अंदर आने देना आसान है, अक्सर पुन: उत्पन्न करना असंभव है, और पूर्वानुमान लगाना मुश्किल है। रूबी और Rails, कम से कम, हमें कुछ उपयोगी टूल्स देते हैं ताकि एक बार मिल जाने पर हम इन मुद्दों को कुचल सकें।
सामान्य रूबी कोड के लिए, Mutex एक अच्छा विकल्प है और शायद पहली चीज है जिसके बारे में अधिकांश डेवलपर्स "थ्रेड सेफ्टी" शब्द सुनकर सोचते हैं।
Rails के साथ, अधिक संभावना है कि डेटा ActiveRecord से आ रहा है। इन मामलों में, lock! (या with_lock) उपयोग करना सीधा है और म्यूटेक्स की तुलना में अधिक थ्रूपुट की अनुमति देता है, क्योंकि यह डेटाबेस में केवल प्रासंगिक पंक्तियों को लॉक करता है।
मैं यहां ईमानदार रहूंगा, मुझे यकीन नहीं है कि मैं वास्तविक दुनिया में update_counters के लिए बहुत पहुंचूंगा। यह इतना असामान्य है कि अन्य डेवलपर्स इस बात से परिचित नहीं हो सकते हैं कि यह कैसे व्यवहार करता है, और यह कोड के इरादे को विशेष रूप से स्पष्ट नहीं करता है। यदि थ्रेड-सेफ्टी चिंताओं का सामना करना पड़ता है, तो ActiveRecord के लॉक्स (या तो lock! या with_lock) दोनों अधिक सामान्य हैं और कोडर के इरादे को अधिक स्पष्ट रूप से संप्रेषित करते हैं।
हालांकि, यदि आपके पास बहुत सारे सरल 'जोड़ या घटाव' जॉब्स बैकअप हैं, और आपको कच्ची गति की आवश्यकता है, तो update_counters आपकी पिछली जेब में एक उपयोगी टूल हो सकता है।