রেল হল একটি বড় ফ্রেমওয়ার্ক যার মধ্যে নির্দিষ্ট পরিস্থিতির জন্য বিল্ট-ইন অনেক সহজ টুল রয়েছে। এই সিরিজে, আমরা রেলের বৃহৎ কোডবেসে লুকিয়ে থাকা কিছু স্বল্প পরিচিত টুলের দিকে নজর দিচ্ছি।
সিরিজের এই নিবন্ধে, আমরা ActiveRecord-এর update_counters-এর দিকে নজর দিতে যাচ্ছি পদ্ধতি প্রক্রিয়ায়, আমরা মাল্টিথ্রেডেড প্রোগ্রামে "রেসের অবস্থার" সাধারণ ফাঁদ এবং কীভাবে এই পদ্ধতিটি তাদের প্রতিরোধ করতে পারে তা দেখব৷
থ্রেড
প্রোগ্রামিং করার সময়, আমাদের কাছে প্রসেস, থ্রেড এবং সম্প্রতি (রুবিতে), ফাইবার এবং রিঅ্যাক্টর সহ সমান্তরালভাবে কোড চালানোর বিভিন্ন উপায় রয়েছে। এই নিবন্ধে, আমরা শুধুমাত্র থ্রেড সম্পর্কে চিন্তা করতে যাচ্ছি, কারণ এটি সবচেয়ে সাধারণ ফর্ম যা রেল ডেভেলপারদের সম্মুখীন হবে। উদাহরণস্বরূপ, Puma হল একটি মাল্টিথ্রেড সার্ভার, এবং Sidekiq হল একটি মাল্টিথ্রেডেড ব্যাকগ্রাউন্ড জব প্রসেসর৷
আমরা এখানে থ্রেড এবং থ্রেড নিরাপত্তা একটি গভীর ডুব না. জানার প্রধান বিষয় হল যখন দুটি থ্রেড একই ডেটাতে কাজ করে, তখন ডেটা সহজেই সিঙ্ক থেকে বেরিয়ে যেতে পারে। এটিই "জাতির অবস্থা" হিসাবে পরিচিত।
জাতির অবস্থা
একটি রেস অবস্থা ঘটে যখন দুটি (বা তার বেশি) থ্রেড একই সময়ে একই ডেটাতে কাজ করে, যার অর্থ একটি থ্রেড বাসি ডেটা ব্যবহার করে শেষ হতে পারে। এটিকে "রেসের অবস্থা" বলা হয় কারণ এটি এমন যে দুটি থ্রেড একে অপরের সাথে দৌড়াচ্ছে এবং কোন থ্রেড "দৌড় জিতেছে" তার উপর নির্ভর করে ডেটার চূড়ান্ত অবস্থা ভিন্ন হতে পারে। সম্ভবত সবচেয়ে খারাপ, জাতিগত অবস্থার পুনরুত্পাদন করা খুব কঠিন কারণ তারা সাধারণত তখনই ঘটে যখন থ্রেডগুলি একটি নির্দিষ্ট ক্রমে এবং কোডের একটি নির্দিষ্ট বিন্দুতে "বাঁক নেয়"৷
একটি উদাহরণ
রেসের অবস্থা দেখানোর জন্য ব্যবহৃত একটি সাধারণ দৃশ্য হল একটি ব্যাঙ্ক ব্যালেন্স আপডেট করা। আমরা একটি বেসিক রেল অ্যাপ্লিকেশনের মধ্যে একটি সাধারণ পরীক্ষা ক্লাস তৈরি করব যাতে আমরা দেখতে পারি কি হয়:
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 থাকুন . এইগুলি 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:Mutex
সবচেয়ে সহজ বিকল্প হল একটি "পারস্পরিক বর্জন লক", যা সাধারণত মিউটেক্স নামে পরিচিত। আপনি একটি মিউটেক্সকে শুধুমাত্র একটি চাবি সহ একটি লক হিসাবে ভাবতে পারেন। যদি একটি থ্রেড কী ধরে থাকে তবে এটি মিউটেক্সে যা কিছু আছে তা চালাতে পারে। অন্যান্য সমস্ত থ্রেডকে অপেক্ষা করতে হবে যতক্ষণ না তারা চাবিটি ধরে রাখতে পারে।
আমাদের উদাহরণ কোডে একটি মিউটেক্স প্রয়োগ করা এভাবে করা যেতে পারে:
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
Ruby's Mutex আমাদের যা প্রয়োজন তা করে, কিন্তু আপনি সম্ভবত কল্পনা করতে পারেন, এটি একটি নির্দিষ্ট ডাটাবেস সারি লক করার জন্য রেল অ্যাপ্লিকেশনগুলিতে মোটামুটি সাধারণ, এবং 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
যেখানে একটি Mutex একটি নির্দিষ্ট থ্রেডের জন্য কোডের বিভাগটিকে "লক" করে, 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-এ লিখছে , তারপর এটি একটি জাতি অবস্থার সম্ভাবনা খোলে। যাইহোক, আসুন কল্পনা করা যাক যে রুবি এই অপারেশনের সময় থ্রেডগুলিকে ঘুমাতে দেয়নি। যদি আমরা নিশ্চিতভাবে বলতে পারি যে এই অপারেশনের সময় একটি থ্রেড কখনই ঘুমাবে না (যেমন, কম্পিউটার কখনই এক্সিকিউশনকে অন্য থ্রেডে স্যুইচ করবে না), তাহলে এটি একটি "পারমাণবিক" অপারেশন হিসেবে বিবেচিত হতে পারে।
ঠিক এই ধরনের থ্রেড-নিরাপত্তার জন্য কিছু ভাষায় আদিম মানের পারমাণবিক সংস্করণ রয়েছে (যেমন, AtomicInteger এবং AtomicFloat)। এর মানে এই নয় যে রেল ডেভেলপার হিসেবে আমাদের কাছে কয়েকটি "পারমাণবিক" অপারেশন উপলব্ধ নেই। একবার উদাহরণ হল 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 করতে হবে না সর্বশেষ মান পেতে রেকর্ড. এই গতি একটি মূল্য আসে, যদিও. যেহেতু আমরা কাঁচা এসকিউএল-এ এটি করছি, আমরা রেল মডেলটিকে বাইপাস করছি, যার অর্থ কোনো বৈধতা বা কলব্যাক কার্যকর করা হবে না (অর্থাৎ, অন্যান্য জিনিসগুলির মধ্যে, updated_at-এ কোনো পরিবর্তন নেই টাইমস্ট্যাম্প)।
উপসংহার
রেসের অবস্থা খুব ভাল হতে পারে হাইজেনবাগ পোস্টার শিশু। এগুলি প্রবেশ করানো সহজ, পুনরুত্পাদন করা প্রায়শই অসম্ভব এবং পূর্বাভাস দেওয়া কঠিন। রুবি এবং রেল, অন্তত, এই সমস্যাগুলি খুঁজে পাওয়ার পরে আমাদের কিছু সহায়ক টুল দেয়।
সাধারণ রুবি কোডের জন্য, Mutex এটি একটি ভাল বিকল্প এবং সম্ভবত "থ্রেড নিরাপত্তা" শব্দটি শোনার সময় বেশিরভাগ বিকাশকারীরা প্রথম জিনিসটি মনে করেন৷
রেলের সাথে, সম্ভবত না হওয়ার চেয়ে বেশি, ডেটা ActiveRecord থেকে আসছে। এই ক্ষেত্রে, lock! (বা with_lock ) ব্যবহার করা সহজ এবং একটি মিউটেক্সের চেয়ে বেশি থ্রুপুট অনুমোদন করে, কারণ এটি শুধুমাত্র ডাটাবেসের প্রাসঙ্গিক সারিগুলিকে লক করে।
আমি এখানে সৎ থাকব, আমি নিশ্চিত নই যে আমি update_counters-এ পৌঁছতে পারব বাস্তব জগতে অনেক। এটি যথেষ্ট অস্বাভাবিক যে অন্যান্য বিকাশকারীরা এটি কীভাবে আচরণ করে তার সাথে পরিচিত নাও হতে পারে এবং এটি কোডের উদ্দেশ্যকে বিশেষভাবে পরিষ্কার করে না। থ্রেড-নিরাপত্তা সংক্রান্ত উদ্বেগের সম্মুখীন হলে, ActiveRecord-এর লক (হয় lock! অথবা with_lock ) উভয়ই আরও সাধারণ এবং আরও স্পষ্টভাবে কোডারের উদ্দেশ্য সম্পর্কে যোগাযোগ করে।
যাইহোক, আপনার যদি অনেক সহজ 'যোগ বা বিয়োগ' কাজ ব্যাক আপ করা থাকে এবং আপনার কাঁচা প্যাডেল-টু-দ্য-মেটাল গতির প্রয়োজন হয়, update_counters আপনার পিছনের পকেটে একটি দরকারী টুল হতে পারে।