কম্পিউটার টিউটোরিয়াল

ActiveRecord-এর update_counters দিয়ে রেইলসে রেস কন্ডিশন প্রতিরোধ

রেইলস একটি বড় ফ্রেমওয়ার্ক যার ভেতরে নির্দিষ্ট পরিস্থিতির জন্য অনেক হ্যান্ডি টুল বিল্ট-ইন রয়েছে। এই সিরিজে, আমরা রেইলসের বিশাল কোডবেসে লুকানো কিছু কম-পরিচিত টুল দেখব।

এই আর্টিকেলে, আমরা ActiveRecord-এর update_counters মেথড দেখব। এই প্রক্রিয়ায়, আমরা মাল্টিথ্রেডেড প্রোগ্রামে "রেস কন্ডিশন" এর সাধারণ জাল এবং কীভাবে এই মেথড সেটি প্রতিরোধ করতে পারে সেটি দেখব।

থ্রেড

প্রোগ্রামিংয়ে, আমাদের কোড প্যারালлеলে চালানোর অনেক উপায় আছে, যেমন প্রসেস, থ্রেড, এবং সবচেয়ে gầnতই (রুবিতে) ফাইবার এবং রিয়েক্টর। এই আর্টিকেলে, আমরা শুধু থ্রেড নিয়ে চিন্তা করব, কারণ এটি রেইলস ডেভেলপারদের مواجهা সবচেয়ে সাধারণ ফর্ম। উদাহরণস্বরূপ, Puma একটি মাল্টিথ্রেডেড সার্ভার, এবং Sidekiq একটি মাল্টিথ্রেডেড ব্যাকগ্রাউন্ড জব প্রসেসর।

আমরা এখানে থ্রেড এবং থ্রেড সেফটি নিয়ে গভীরে যাব না। মূল কথা হলো যখন দুটি থ্রেড একই ডেটা ওপর অপারেট করে, ডেটা সহজে আউট-অফ-সিঙ্ক হয়ে যেতে পারে। এটিকে ही "রেস কন্ডিশন" বলা হয়।

রেস কন্ডিশন

রেস কন্ডিশন ঘটে যখন দুই (বা অধিক) থ্রেড একই সময় একই ডেটা ওপর অপারেট করে, অর্থাৎ একটি থ্রেড পুরনো (stale) ডেটা ব্যবহার করতে পারে। এটাকে "রেস কন্ডিশন" বলা হয় কারণ এটি যেন দুটি থ্রেড একে অপরके সাথে দৌড়াচ্ছে, এবং ডেটার ফাইনাল স্টেট ভিন্ন হতে পারে কোন থ্রেড "রেস জিতল" তার উপর নির্ভর করে। সবচেয়ে খারাপ ব্যাপার হচ্ছে, রেস কন্ডিশন খুব কঠিন রিপ্রডিউস করা, কারণ সেটি সাধারণত শুধু তখনই ঘটে যখন থ্রেডগুলো একটি নির্দিষ্ট অর্ডারে এবং কোডের একটি নির্দিষ্ট পয়েন্টে "টার্ন নেয়"।

একটি উদাহরণ

রেস কন্ডিশন দেখানোর জন্য একটি সাধারণ সিনেরিও হলো ব্যাংক ব্যালেন্স আপডেট করা। আমরা একটি সিম্পল টেস্ট ক্লাস তৈরি করব একটি বেসিক রেইলস অ্যাপ্লিকেশনের ভেতরে যাতে আমরা দেখতে পাই কী ঘটে:

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 অ্যাট্রিবিউট রয়েছে)। আমরা ব্যালেন্স শূন্য রিসেট করি যাতে টেস্ট পুনরাবৃত্তি করা সহজ হয়।

ইনার লুপই جایی জিনিসগুলো আরো আকর্ষণীয়। আমরা চারটি থ্রেড তৈরি করছি যা একাউন্টের বর্তমান ব্যালেন্স নেবে, সেটিতে ১০০ যোগ করবে (যেমন $১০০ ডিপোজিট), এবং তারপর অবিলম্বে ১০০ বিয়োগ করবে (যেমন $১০০ উইথড্রয়াল)। আমরা দুবার 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 {} ব্লকের কোড ১০ বার রান করবে, প্রতিটি রানের রেজাল্ট একটি অ্যারেতে রাখবে। শেষে .map(&:to_f) শুধু নম্বরগুলো হিউম্যান-রিডেবল করতে, কারণ BigDecimal মানগুলো সাধারণত এক্সপোনেনশিয়াল নোটেশন যেমন 0.1e3-এ প্রিন্ট হয়।

মনে রাখুন, আমাদের কোড বর্তমান ব্যালেন্স নেয়, ১০০ যোগ করে, এবং তারপর অবিলম্বে ১০০ বিয়োগ করে, তাই ফাইনাল রেজাল্ট সবসময় 0.0 হওয়া উচিত। এই 100.0 এবং 300.0 এন্ট্রিগুলো প্রুফ যে আমাদের রেস কন্ডিশন আছে।

এনোটেটেড উদাহরণ

চলুন সমস্যায় কোডে জুম ইন করি এবং দেখি কী হচ্ছে। আরও ক্লিয়ারিটি için 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 

কমেন্টগুলোর মতো, থ্রেডগুলো প্রায় যেকোনো পয়েন্টে এই কোডের દরমিয়ান সوییচ করতে পারে। যদি থ্রেড ১ ব্যালেন্স পড়ে, তারপর কম্পিউটার থ্রেড ২ এক্সিকিউট করা শুরু করে, তো ডেটা আউট-অফ-ডেট হয়ে যাওয়ার সম্ভাবনা রয়েছে update! কল করার সময়। অন্য কথায়, থ্রেড ১, থ্রেড ২, এবং ডেটাবেস, সব जगह ডেটা আছে, কিন্তু সেগুলো একে অপরের সাথে আউট-অফ-সিঙ্ক হয়ে যাচ্ছে।

এখানে উদাহরণটি जानবুঝकर তुच্ছ রেখা হয়েছে যাতে সহজে ডিসেক্ট করা যায়। কিন্তু রিয়েল ওয়ার্ল্ডে, রেস কন্ডিশন ডায়াগনোস করা কঠিন হতে পারে, বিশেষত কারণ সেগুলো সাধারণত विश्वস্তভাবে রিপ্রডিউস করা যায় না।

সমাধান

রেস কন্ডিশন প্রতিরোধের কিছু অপশন আছে, কিন্তু প্রায় সবই একটি একক আইডিয়ারওয়ারি ঘুরে: নিশ্চিত করা যে কোনো একটি একক এন্টিটি এক وقت ডেটা চেঞ্জ করছে।

অপশন ১: মিউটেক্স

সबচেয়ে সহজ অপশন হলো "মিউচুয়াল এক্সক্লুশন লক", যাকে সাধারণত মিউটেক্স বলা হয়। আপনি মিউটেক্সকে একটি লক হিসেবে ভাবতে পারেন যার শুধু একটি চাবি। যদি একটি থ্রেড চাবিটি ধরে রাখে, সেটি মিউটেক্সের ভেতরে যা আছে সেটি রান করতে পারে। অন্যান্য সব থ্রেড অপেক্ষা করবে যতক্ষণ না সেগুলো চাবি পায়।

আমাদের উদাহরণ কোডে মিউটেক্স অ্যাপ্লাই করা যেতে পারে এভাবে:

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 

রুবির Mutex আমাদের প্রয়োজনীয় কাজটি করে, কিন্তু আপনি কল্পনা করতে পারেন, রেইলস অ্যাপ্লিকেশনে খুব সাধারণ बात একটি নির্দিষ্ট ডেটাবেস রো লক করা, এবং ActiveRecord এই সিনেরিওর জন্য আমাদের কভার দিয়ে রেখেছে।

অপশন ২: 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 ... 

সমাধান ৩: অ্যাটমিক মেথড

একটি 'অ্যাটমিক' মেথড (বা ফাংশন) এক্সিকিউশনের মাঝপথে বন্ধ করা যায় না। উদাহরণস্বরূপ, রুবিতে সাধারণ += অপারেশন অ্যাটমিক নয়, যদিও সেটি একটি একক অপারেশনের মতো লাগে:

value += 10 # সমতুল্য: value = value + 10 # অথবা আরও ভার্বোস: 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 কলের মাধ্যমে হয়। এর মানে আমাদের রুবি থ্রেড অপারেশনটি ইন্টERRUPT করতে পারে না; যদি সেটি স্লিপও করে, তাও কিছু ফার্ক পড়ে না কারণ ডেটাবেসই একচুয় কমপিউটেশন করছে।

প্রোডিউস হওয়া একচুয় 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) ব্যবহার করা সহজ এবং মিউটেক্সের চেয়ে বেশি থ্রুপুট দেয়, কারণ এটি শুধু ডেটাবেসের প্রাসঙ্গিক রোগুলো লক করে।

ইমানদারiego বলতে গেলে, আমি নিশ্চিত নই আমি রিয়েল ওয়ার্ল্ডে update_counters অনেক ব্যবহার করব। এটি যথেষ্ট অসামান্য যাতে অন্য ডেভেলপাররা এর বিহেভিয়ারamiliar নাও হতে পারে, এবং কোডের ইন্টেনশন خاص করে কমিউনিকেট করে না। যদি থ্রেড-সেফটি কনসার্ন ফেস করি, ActiveRecord-এর লক (lock! বা with_lock) দুটোই বেশি কমন এবং কোডারের ইন্টেনশন বেশি স্পষ্টভাবে কমিউনিকেট করে।

তবে, যদি আপনার খুব সিম্পল 'যোগ বা বিয়োগ' জব ব্যাকআপ থাকে, এবং আপনার র‍্যাঁ পেডাল-টু-দ-মেটাল স্পিড দরকার, update_counters আপনার পকেটে একটি উপকারী টুল হতে পারে।

  1. Windows 10-এ Presentation Settings ব্যবহার করে প্রেজেন্টেশনের সময় ল্যাপটপকে Sleep থেকে রোধ করুন

  2. Hotwire দিয়ে Rails অ্যাপস দ্রুত করুন: JavaScript ছাড়া সার্ভার-সাইড রেন্ডারিং

  3. রুবিতে ল্যাম্বডা মাস্টার করুন: বেসিক থেকে অ্যাডভান্সড টেকনিক

  4. Angular এবং Rails 5 ইন্টিগ্রেশন: একটি স্মুথ ফুল-স্ট্যাক পদ্ধতি