পাঠ ৫৪ · ৫৮-এর মধ্যে · মডিউল ১২

রানটাইম এনভায়রনমেন্ট ও গার্বেজ কালেকশন

Runtime environments & garbage collection
৯ মিনিট পড়া উচ্চ · Advanced Python কোডসহ সম্পূর্ণ বাংলায়

এই পাঠে যা শিখবেন

  • রানটাইম এনভায়রনমেন্টের ধারণা — আগের মডিউলগুলোর সংশ্লেষণ হিসেবে
  • ম্যানুয়াল মেমরি ম্যানেজমেন্টের দুই ক্লাসিক বাগ — মেমরি লিক ও ড্যাংলিং পয়েন্টার
  • গার্বেজ কালেকশনের ধারণা ও রেফারেন্স কাউন্টিং কৌশল
  • একটি বাস্তব রেফারেন্স সাইকেল তৈরি করে বিশুদ্ধ রেফারেন্স-কাউন্টিং-এর সীমাবদ্ধতা কোড দিয়ে প্রমাণ করা

১ · রানটাইম এনভায়রনমেন্ট — এখন পর্যন্ত শেখা সবকিছুর সংশ্লেষণ

রানটাইম এনভায়রনমেন্টRuntime Environmentএকটি কম্পাইল/ইন্টারপ্রেট হওয়া প্রোগ্রাম চলার সময় যে সাপোর্ট ইনফ্রাস্ট্রাকচারের উপর নির্ভর করে — শুধু জেনারেট হওয়া রও ইনস্ট্রাকশন নয়, তার সাথে সংশ্লিষ্ট মেমরি ব্যবস্থাপনাও। — এতে আছে কল স্ট্যাক (M11/L48-এ বিস্তারিত — প্রতিটি সাবপ্রোগ্রাম কলের অ্যাক্টিভেশন রেকর্ড), হিপ (M8/L39-এর হিপ-ডায়নামিক স্টোরেজ ক্যাটাগরি), এবং (এই পাঠের মূল নতুন বিষয়) স্বয়ংক্রিয় মেমরি ম্যানেজমেন্ট।

২ · ম্যানুয়াল মেমরি ম্যানেজমেন্ট — দুই ক্লাসিক বাগ

C-এর malloc/free-এর মতো ভাষায় প্রোগ্রামার নিজেই স্পষ্টভাবে হিপ মেমরি চেয়ে নেয় ও মুক্ত করে দেয় — এবং এটি সততার সাথে বলতে হয় খুবই ত্রুটিপ্রবণ। দুটো ক্লাসিক বাগ:

  • মেমরি লিক (Memory Leak): এমন হিপ মেমরি যা আর reachable/ব্যবহারযোগ্য নয়, কিন্তু কখনো স্পষ্টভাবে free করা হয়নি — অনির্দিষ্টকালের জন্য মেমরি অপচয় হতে থাকে।
  • ড্যাংলিং পয়েন্টার (Dangling Pointer) / use-after-free: যে মেমরি ইতিমধ্যে free হয়ে গেছে, তার একটি পয়েন্টার/রেফারেন্স এখনো থেকে যায় এবং সেটি পরে ব্যবহার করা হয় — M10/L47-এর পয়েন্টার-ডিরেফারেন্স ঝুঁকির সরাসরি বিস্তার, একটি গুরুতর ও সাধারণ বাস্তব বাগ শ্রেণি।

৩ · গার্বেজ কালেকশন ও রেফারেন্স কাউন্টিং

গার্বেজ কালেকশনGarbage Collection (GC)রানটাইম স্বয়ংক্রিয়ভাবে যে হিপ মেমরি আর কোনো সক্রিয় রেফারেন্স থেকে reachable নয়, তা চিহ্নিত করে রিক্লেইম করার প্রক্রিয়া — Python, Java, JavaScript, Go-সহ বেশিরভাগ আধুনিক ভাষার পদ্ধতি। একটি অবজেক্ট "reachable" যদি এটি বর্তমানে সক্রিয় কোনো স্কোপ/অ্যাক্টিভেশন রেকর্ডের (M11/L48) কোনো ভ্যারিয়েবল (M8/L37-এর বাইন্ডিং) থেকে সরাসরি বা পরোক্ষভাবে অন্য কোনো reachable অবজেক্টের মাধ্যমে পৌঁছানো যায়।

রেফারেন্স কাউন্টিংReference Countingপ্রতিটি হিপ অবজেক্ট নিজের একটি কাউন্ট রাখে — কতগুলো রেফারেন্স এই মুহূর্তে তাকে নির্দেশ করছে; নতুন রেফারেন্স তৈরি হলে কাউন্ট বাড়ে, রেফারেন্স মুছে গেলে/স্কোপ থেকে বেরিয়ে গেলে কাউন্ট কমে; কাউন্ট শূন্যে পৌঁছালেই অবজেক্ট সাথে সাথে রিক্লেইম হয়। Python-এর প্রকৃত প্রাইমারি GC কৌশল এটাই (M13/L55-এ আরও বিস্তারিত)।

৪ · সীমাবদ্ধতা — রেফারেন্স সাইকেল

বিশুদ্ধ রেফারেন্স কাউন্টিং-এর একটি গুরুত্বপূর্ণ সীমাবদ্ধতা আছে — পারস্পরিকভাবে একে অপরকে রেফারেন্স করা অবজেক্টের সাইকেল (অবজেক্ট A অবজেক্ট B-কে রেফারেন্স করে, B আবার A-কে রেফারেন্স করে, কিন্তু কেউই কোনো সক্রিয় স্কোপ থেকে reachable নয়) কখনোই তাদের রেফারেন্স কাউন্ট শূন্যে পৌঁছাতে দেবে না — যদিও তারা প্রকৃতপক্ষে reachable নয়, তবুও genuine garbage। বাস্তব রেফারেন্স-কাউন্টিং সিস্টেম (Python-সহ) তাই একটি পৃথক সাইকেল-ডিটেক্টিং মেকানিজম প্রয়োজন করে এই বিশেষ কেসটি হ্যান্ডেল করার জন্য।

অবজেক্ট তৈরি (ref_count = 0) নতুন রেফারেন্স -> add_ref() (count++) রেফারেন্স বাদ -> remove_ref() (count--) count == 0 ? হ্যাঁ -> সাথে সাথে রিক্লেইম (কালেক্টেড)
সাইকেলের ক্ষেত্রে এই চক্র কখনো count == 0-তে পৌঁছায় না — নিচের কোড সেলে এই ব্যতিক্রমটাই সরাসরি প্রমাণ করা হয়েছে।

৫ · বাস্তবায়ন — স্বাভাবিক কালেকশন বনাম রেফারেন্স সাইকেল

নিচের কোড সেলে দুটো কেস দেখানো হয়েছে — প্রথমে একটি সাধারণ, চক্রবিহীন অবজেক্ট সঠিকভাবে কালেক্ট হচ্ছে; তারপর দুটো অবজেক্ট A ও B একে অপরকে রেফারেন্স করছে (একটি সাইকেল), বাইরের একমাত্র root রেফারেন্স রিলিজ হওয়ার পরেও উভয়ের ref_count শূন্যের উপরে আটকে থাকছে — যদিও তারা কোনো root থেকে আর reachable নয়।

Python
class RefCountedObject:
    """প্রতিটি অবজেক্ট নিজের ref_count রাখে -- কতগুলো রেফারেন্স এই মুহূর্তে তাকে নির্দেশ করছে।"""
    def __init__(self, name):
        self.name = name
        self.ref_count = 0
        self.points_to = []   # এই অবজেক্ট অন্য কাকে রেফারেন্স করছে (সাইকেল দেখানোর জন্য)
        self.collected = False

    def add_ref(self):
        self.ref_count += 1

    def remove_ref(self):
        self.ref_count -= 1
        if self.ref_count <= 0 and not self.collected:
            self.collected = True
            print(f"  [কালেক্টেড] {self.name}: ref_count শূন্যে পৌঁছেছে, মেমরি রিক্লেইম হলো")


def is_reachable_from_roots(obj, roots):
    """একটি অবজেক্ট এখনো কোনো root (সক্রিয় স্কোপের ভ্যারিয়েবল) থেকে reachable কিনা যাচাই করে।"""
    visited = set()
    def dfs(o):
        if id(o) in visited:
            return False
        visited.add(id(o))
        if o is obj:
            return True
        return any(dfs(p) for p in o.points_to)
    return any(dfs(r) for r in roots)


print("== কেস ১: স্বাভাবিক, চক্রবিহীন রেফারেন্স কাউন্টিং ==")
x = RefCountedObject("X")
x.add_ref()
print("X-এর ref_count (১টি root রেফারেন্সের পর):", x.ref_count)
x.remove_ref()
print("X-এর ref_count (root রেফারেন্স রিলিজের পর):", x.ref_count, " collected:", x.collected)

print("\n== কেস ২: ইচ্ছাকৃত রেফারেন্স সাইকেল (A <-> B), কোনো external root ছাড়া ==")
a = RefCountedObject("A")
b = RefCountedObject("B")

a.add_ref()                          # একটি root ভ্যারিয়েবল সাময়িকভাবে A-কে রেফারেন্স করছে
a.points_to.append(b); b.add_ref()   # A -> B রেফারেন্স
b.points_to.append(a); a.add_ref()   # B -> A রেফারেন্স (সাইকেল সম্পূর্ণ)

print("সাইকেল তৈরির পর: A.ref_count =", a.ref_count, " B.ref_count =", b.ref_count)

a.remove_ref()                       # একমাত্র root রেফারেন্সটি স্কোপ থেকে বেরিয়ে গেল
print("root রিলিজের পর:  A.ref_count =", a.ref_count, " B.ref_count =", b.ref_count)
print("A collected?", a.collected, " B collected?", b.collected)

roots = []   # আর কোনো root A বা B-কে নির্দেশ করছে না
print("A এখনো কোনো root থেকে reachable?", is_reachable_from_roots(a, roots))
print("B এখনো কোনো root থেকে reachable?", is_reachable_from_roots(b, roots))

print("\nউপসংহার: A ও B উভয়েরই ref_count > 0, অথচ কোনো root থেকেই তারা reachable নয়")
print("-- এটাই বিশুদ্ধ রেফারেন্স কাউন্টিং-এর সাইকেল-সীমাবদ্ধতা, কনক্রিটভাবে প্রমাণিত।")

    
লক্ষ্য করুন — কেস ১-এ x.remove_ref() কল হওয়া মাত্রই "[কালেক্টেড]" বার্তা দেখা যায়, যেহেতু ref_count সাথে সাথে শূন্যে নেমে যায়। কিন্তু কেস ২-তে, বাইরের একমাত্র root রেফারেন্স রিলিজ হওয়ার পরেও — অর্থাৎ প্রোগ্রামের কোনো সক্রিয় ভ্যারিয়েবল আর A বা B-কে নির্দেশ করছে না — উভয়ের ref_count এখনো ১ (একে অপরের কাছ থেকে পাওয়া রেফারেন্স), এবং কোনো "[কালেক্টেড]" বার্তা দেখা যায় না। is_reachable_from_roots স্পষ্টভাবে দেখায় উভয়ে False — genuine garbage, কিন্তু চিরকাল স্মৃতিতে আটকে থাকবে যদি একটি পৃথক সাইকেল-ডিটেক্টর না থাকে।
মূল কথা · Key takeaway

গার্বেজ কালেকশন প্রোগ্রামারকে ম্যানুয়াল মেমরি ম্যানেজমেন্টের ঝুঁকি (লিক, ড্যাংলিং পয়েন্টার) থেকে মুক্তি দেয়, কিন্তু রেফারেন্স কাউন্টিং একা যথেষ্ট নয় — সাইকেল একটি বাস্তব, ফাঁকফোকর যা শুধুমাত্র একটি সরাসরি reachability-ভিত্তিক (গ্রাফ ট্রাভার্সাল) মেকানিজম সমাধান করতে পারে। M13/L55-এ দেখা যাবে Python বাস্তবে এই সমস্যা কীভাবে সমাধান করে।

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিজে কিছুক্ষণ ভাবুন — তারপর "→ উত্তর" চাপুন।

প্র ০১ উপরের কোড সেলে যদি b.points_to.append(a); a.add_ref() লাইনটি বাদ দেওয়া হতো (শুধু A -> B, B -> A নয়), তাহলে a.remove_ref()-এর পর কী হতো?

তাহলে আর প্রকৃত সাইকেল থাকত না — শুধু A -> B একমুখী রেফারেন্স। a.remove_ref() কল হলে A-এর ref_count ০-এ নেমে যেত এবং A কালেক্টেড হতো; A কালেক্ট হওয়ার সময় বাস্তব রেফারেন্স-কাউন্টিং সিস্টেমে A-এর ধরে রাখা রেফারেন্সগুলোও (এখানে B) রিলিজ করা হয়, ফলে B-এর ref_count-ও ০-এ নেমে B-ও কালেক্ট হতো — একটি স্বাভাবিক চেইন-রিক্লেমেশন, কোনো সাইকেল-সমস্যা ছাড়াই।

প্র ০২ রেফারেন্স কাউন্টিং-এর একটি সুবিধা হলো এটি "সাথে সাথে" (immediately) মেমরি রিক্লেইম করে। GC-এর অন্যান্য কৌশল (যেমন mark-and-sweep, শুধু নাম-উল্লেখ) কি এই সুবিধা দেয়?

না, সাধারণত নয় — mark-and-sweep-এর মতো ট্রেসিং-ভিত্তিক GC কৌশল সাধারণত পর্যায়ক্রমে (periodically) চলে, পুরো reachability গ্রাফ ট্রাভার্স করে অ্যাক্সেসযোগ্য অবজেক্ট চিহ্নিত করে, তারপর বাকি সব রিক্লেইম করে — একটি অবজেক্ট unreachable হওয়ার সাথে সাথেই নয়, বরং পরের GC পাস চলার সময় রিক্লেইম হয়। রেফারেন্স কাউন্টিং-এর "সাথে সাথে রিক্লেইম" সুবিধা তার প্রধান আকর্ষণ, যদিও সাইকেল-সমস্যা তার বড় দুর্বলতা — বাস্তব সিস্টেম (Python) তাই দুটো কৌশলই একসাথে ব্যবহার করে।

প্র ০৩ মেমরি লিক ও রেফারেন্স সাইকেল — দুটোই "মেমরি অপচয় হচ্ছে" এই অর্থে একরকম শোনায়। এই দুটোর মূল পার্থক্য কী?

মেমরি লিক ম্যানুয়াল মেমরি ম্যানেজমেন্টের একটি প্রোগ্রামার-এরর — প্রোগ্রামার স্পষ্টভাবে free কল করতে ভুলে যায়, একটি মানুষের ভুল যা GC-যুক্ত ভাষায় সাধারণত ঘটেই না। রেফারেন্স সাইকেল সম্পূর্ণ ভিন্ন — এটি GC-যুক্ত ভাষাতেই ঘটে, প্রোগ্রামার সবকিছু "সঠিকভাবে" করেছে (কোনো ম্যানুয়াল free ভুলে যাওয়ার প্রশ্নই নেই), তবুও পারস্পরিক রেফারেন্সের কাঠামোগত কারণে বিশুদ্ধ রেফারেন্স-কাউন্টিং অ্যালগরিদম নিজেই ব্যর্থ হয় — এটি একটি অ্যালগরিদমিক সীমাবদ্ধতা, মানুষের ভুল নয়।

অনুশীলন

  1. চিন্তা করুন: একটি তিন-অবজেক্টের সাইকেল কল্পনা করুন — A -> B -> C -> A (প্রতিটি পরেরটিকে রেফারেন্স করে, শেষেরটি আবার প্রথমটিকে)। বাইরের একমাত্র root রেফারেন্স (A-এর দিকে) রিলিজ হওয়ার পর প্রতিটি অবজেক্টের ref_count কত হবে বলে আপনার মনে হয়?

    প্রতিটির ref_count হবে ১ — A-এর কাউন্ট ১ (শুধু C থেকে আসা রেফারেন্স, root রিলিজ হয়ে যাওয়ার পর), B-এর কাউন্ট ১ (শুধু A থেকে), C-এর কাউন্ট ১ (শুধু B থেকে)। তিনটিই এখনো ০-এর উপরে, তিনটিই কোনো root থেকে reachable নয় — দুই-অবজেক্টের সাইকেলের মতোই সীমাবদ্ধতা, শুধু চক্রের দৈর্ঘ্য বড়। সাইকেলের আকার যাই হোক, বিশুদ্ধ রেফারেন্স কাউন্টিং একইভাবে ব্যর্থ হয়।

  2. পরীক্ষা করুন: উপরের কোড সেলে কেস ২-এর শেষে b.remove_ref(); a.remove_ref() যোগ করে Run চেপে দেখুন (এটি ম্যানুয়ালি সাইকেলের ভেতরের রেফারেন্সগুলোও রিলিজ করে দিচ্ছে) — এবার কি "[কালেক্টেড]" বার্তা দেখা যায়?

    হ্যাঁ — b.remove_ref() B-এর কাউন্ট ১ থেকে ০-এ নামাবে (B কালেক্টেড), এবং a.remove_ref() A-এর কাউন্টও ১ থেকে ০-এ নামাবে (A কালেক্টেড)। এটি দেখায় সমস্যাটি রেফারেন্স কাউন্টিং অ্যালগরিদমের নিজস্ব সীমাবদ্ধতা, বাস্তব প্রোগ্রামে যদি কেউ ম্যানুয়ালি প্রতিটি অভ্যন্তরীণ রেফারেন্সও ট্র্যাক করে রিলিজ করতে পারত তাহলে সমস্যা হতো না — কিন্তু বাস্তবে কোনো প্রোগ্রামার প্রতিটি সাইকেল হাতে ভেঙে দেওয়ার কথা মনে রাখতে পারে না, তাই একটি স্বয়ংক্রিয় সাইকেল-ডিটেক্টর প্রয়োজন।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

আগের পাঠ
কোড অপ্টিমাইজেশন টেকনিক