रेल एक बड़ा ढांचा है जिसमें विशिष्ट परिस्थितियों के लिए बहुत सारे उपयोगी उपकरण अंतर्निहित हैं। इस श्रृंखला में, हम रेल के बड़े कोडबेस में छिपे कुछ कम ज्ञात टूल पर एक नज़र डाल रहे हैं।
श्रृंखला के इस लेख में, हम ActiveRecord के update_counters
पर एक नज़र डालने जा रहे हैं तरीका। इस प्रक्रिया में, हम बहुप्रचारित कार्यक्रमों में "दौड़ की स्थिति" के सामान्य जाल को देखेंगे और यह विधि उन्हें कैसे रोक सकती है।
थ्रेड्स
प्रोग्रामिंग करते समय, हमारे पास समानांतर में कोड चलाने के कई तरीके हैं, जिसमें प्रक्रियाएं, थ्रेड्स, और, हाल ही में (रूबी में), फाइबर और रिएक्टर शामिल हैं। इस लेख में, हम केवल थ्रेड्स के बारे में चिंता करने जा रहे हैं, क्योंकि यह सबसे सामान्य रूप है जिसका सामना रेल डेवलपर्स करेंगे। उदाहरण के लिए, प्यूमा एक मल्टीथ्रेडेड सर्वर है, और साइडकीक एक मल्टीथ्रेडेड बैकग्राउंड जॉब प्रोसेसर है।
हम यहां धागे और धागे की सुरक्षा में गहरी डुबकी नहीं लगाएंगे। जानने वाली मुख्य बात यह है कि जब एक ही डेटा पर दो थ्रेड काम कर रहे हों, तो डेटा आसानी से सिंक से बाहर हो सकता है। इसे "दौड़ की स्थिति" के रूप में जाना जाता है।
रेस कंडीशन
एक दौड़ की स्थिति तब होती है जब दो (या अधिक) थ्रेड एक ही समय में एक ही डेटा पर काम कर रहे होते हैं, जिसका अर्थ है कि एक थ्रेड बासी डेटा का उपयोग करके समाप्त हो सकता है। इसे "दौड़ की स्थिति" कहा जाता है क्योंकि यह ऐसा है जैसे दो धागे एक-दूसरे को दौड़ रहे हैं, और डेटा की अंतिम स्थिति भिन्न हो सकती है जिसके आधार पर "दौड़ जीती" धागा। शायद सबसे खराब, दौड़ की स्थिति को पुन:पेश करना बहुत मुश्किल होता है क्योंकि वे आम तौर पर केवल तभी होते हैं जब धागे एक विशेष क्रम में और कोड में एक विशेष बिंदु पर "मोड़ लेते हैं"।
एक उदाहरण
दौड़ की स्थिति दिखाने के लिए इस्तेमाल किया जाने वाला एक सामान्य परिदृश्य बैंक बैलेंस अपडेट कर रहा है। हम एक बुनियादी रेल एप्लिकेशन के भीतर एक साधारण परीक्षण वर्ग बनाएंगे ताकि हम देख सकें कि क्या होता है:
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
. को खोजता है (एक स्टॉक-मानक रेल मॉडल जिसमें 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
be रहें . ये 100.0
और 300.0
प्रविष्टियाँ, तब, इस बात का प्रमाण हैं कि हमारे पास एक दौड़ की स्थिति है।
एनोटेटेड उदाहरण
आइए यहां समस्या कोड पर ज़ूम इन करें और देखें कि क्या हो रहा है। हम परिवर्तनों को balance
. में अलग कर देंगे और भी स्पष्टता के लिए।
threads << Thread.new do
# Thread could be switching here
balance = account.reload.balance
# or here...
balance += 100
# or here...
account.update!(balance: balance)
# or here...
balance = account.reload.balance
# or here...
balance -= 100
# or here...
account.update!(balance: balance)
# or here...
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
रूबी का म्यूटेक्स वह करता है जो हमें चाहिए, लेकिन जैसा कि आप शायद कल्पना कर सकते हैं, रेल अनुप्रयोगों में किसी विशेष डेटाबेस पंक्ति को लॉक करने की आवश्यकता होती है, और ActiveRecord ने हमें इस परिदृश्य के लिए कवर किया है।
विकल्प 2:ActiveRecord Locks
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
# equivalent to:
value = value + 10
# Or even more verbose:
temp_value = value + 10
value = temp_value
यदि थ्रेड अचानक value + 10
. काम करने के बीच "सो जाता है" है और परिणाम को value
पर वापस लिख रहा है , तो यह दौड़ की स्थिति की संभावना को खोलता है। हालाँकि, आइए कल्पना करें कि रूबी ने इस ऑपरेशन के दौरान धागों को सोने नहीं दिया। यदि हम निश्चित रूप से कह सकते हैं कि इस ऑपरेशन के दौरान एक धागा कभी नहीं सोएगा (उदाहरण के लिए, कंप्यूटर कभी भी एक अलग थ्रेड पर निष्पादन को स्विच नहीं करेगा), तो इसे "परमाणु" ऑपरेशन माना जा सकता है।
कुछ भाषाओं में इस तरह की थ्रेड-सुरक्षा (उदाहरण के लिए, परमाणु इंटेगर और परमाणु फ्लोट) के लिए आदिम मूल्यों के परमाणु संस्करण होते हैं। इसका मतलब यह नहीं है कि रेल डेवलपर्स के रूप में हमारे पास कुछ "परमाणु" संचालन उपलब्ध नहीं हैं। एक बार उदाहरण है 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 कॉल में डेटाबेस में होता है। इसका मतलब है कि हमारा रूबी थ्रेड ऑपरेशन को बाधित नहीं कर सकता है; भले ही वह सो जाए, इससे कोई फर्क नहीं पड़ेगा क्योंकि डेटाबेस वास्तविक गणना कर रहा है।
वास्तविक एसक्यूएल का उत्पादन इस तरह से होता है (कम से कम मेरी मशीन पर पोस्टग्रेज के लिए):
Account Update All (1.7ms) UPDATE "accounts" SET "balance" = COALESCE("balance", 0) + $1 WHERE "accounts"."id" = $2 [["balance", "100.0"], ["id", 1]]
यह तरीका भी बहुत बेहतर प्रदर्शन करता है, जो आश्चर्यजनक नहीं है, क्योंकि गणना पूरी तरह से डेटाबेस में होती है; हमें कभी भी reload
करने की आवश्यकता नहीं है नवीनतम मूल्य प्राप्त करने का रिकॉर्ड। हालाँकि, यह गति एक कीमत पर आती है। क्योंकि हम इसे कच्चे SQL में कर रहे हैं, हम रेल मॉडल को दरकिनार कर रहे हैं, जिसका अर्थ है कि कोई भी सत्यापन या कॉलबैक निष्पादित नहीं किया जाएगा (अर्थात्, अन्य बातों के अलावा, updated_at
में कोई बदलाव नहीं किया जाएगा। टाइमस्टैम्प)।
निष्कर्ष
रेस की स्थिति बहुत अच्छी तरह से हाइजेनबग पोस्टर चाइल्ड हो सकती है। उन्हें अंदर जाने देना आसान है, अक्सर प्रजनन करना असंभव है, और भविष्यवाणी करना मुश्किल है। रूबी और रेल, कम से कम, इन मुद्दों को खोजने के बाद हमें कुछ उपयोगी उपकरण दें।
सामान्य रूबी कोड के लिए, mutex
एक अच्छा विकल्प है और संभवत:"थ्रेड सेफ्टी" शब्द सुनते समय अधिकांश डेवलपर्स सबसे पहले सोचते हैं।
रेल के साथ, अधिक संभावना नहीं है, डेटा ActiveRecord से आ रहे हैं। इन मामलों में, lock!
(या with_lock
) उपयोग करने के लिए सीधा है और म्यूटेक्स की तुलना में अधिक थ्रूपुट की अनुमति देता है, क्योंकि यह केवल डेटाबेस में प्रासंगिक पंक्तियों को लॉक करता है।
मैं यहां ईमानदार रहूंगा, मुझे यकीन नहीं है कि मैं update_counters
तक पहुंच पाऊंगा वास्तविक दुनिया में बहुत कुछ। यह असामान्य है कि अन्य डेवलपर्स इससे परिचित नहीं हो सकते हैं कि यह कैसे व्यवहार करता है, और यह कोड के इरादे को विशेष रूप से स्पष्ट नहीं करता है। यदि थ्रेड-सुरक्षा चिंताओं का सामना करना पड़ता है, तो ActiveRecord के ताले (या तो lock!
या with_lock
) दोनों अधिक सामान्य हैं और अधिक स्पष्ट रूप से कोडर के इरादे को संप्रेषित करते हैं।
हालांकि, यदि आपके पास बहुत सारे सरल 'जोड़ें या घटाएं' कार्य हैं, और आपको कच्ची पेडल-टू-द-मेटल गति की आवश्यकता है, update_counters
आपकी पिछली जेब में एक उपयोगी उपकरण हो सकता है।