প্রতিটি থ্রেড যা দেখে: স্ট্যাক, হিপ, এবং শেয়ার্ড স্টেট

কনকারেন্সি বাগ প্রায় কখনোই সিনট্যাক্স না বোঝার কারণে হয় না। এগুলো আসে মেমোরির একটি ভুল মানসিক মডেল থেকে: কোনো কিছুকে থ্রেডের ব্যক্তিগত ভেবে নেওয়া যখন সেটি আসলে শেয়ার্ড, অথবা একটি আপডেট অন্য থ্রেডের কাছে তাৎক্ষণিকভাবে দৃশ্যমান হবে ধরে নেওয়া যখন বাস্তবে তা হয় না। এই মডেলটি ঠিক করা যেকোনো API মুখস্থ করার চেয়ে বেশি মূল্যবান।

প্রতিটি থ্রেডের ব্যক্তিগত: স্ট্যাক

প্রতিটি থ্রেড তার নিজস্ব একটি কল স্ট্যাক পায়, যা থ্রেড শুরু হওয়ার সময় তৈরি হয়। স্ট্যাক বর্তমানে চলমান প্রতিটি মেথড কলের জন্য একটি করে ফ্রেম ধারণ করে, এবং প্রতিটি ফ্রেম সেই মেথডের লোকাল ভেরিয়েবল, প্যারামিটার এবং রিটার্ন অ্যাড্রেস ধরে রাখে। যেহেতু স্ট্যাক ঠিক একটি থ্রেডের মালিকানাধীন, লোকাল ভেরিয়েবলগুলো স্বাভাবিকভাবেই থ্রেড-সেইফ: অন্য কোনো থ্রেড সেগুলো দেখতে বা স্পর্শ করতে পারে না।

public class StackIsPrivate {
    static void countTo(int n) {
        int local = 0; // এই থ্রেডের স্ট্যাকে থাকে, অন্য থ্রেডের কাছে অদৃশ্য
        for (int i = 0; i < n; i++) {
            local++;
        }
        System.out.println(Thread.currentThread().getName() + " শেষ করলো " + local);
    }

    public static void main(String[] args) {
        Runnable task = () -> countTo(1_000_000);
        Thread t1 = new Thread(task, "t1");
        Thread t2 = new Thread(task, "t2");
        t1.start();
        t2.start();
    }
}

এখানে প্রতিটি থ্রেডের local ভেরিয়েবলের নিজস্ব স্বাধীন কপি রয়েছে। একই সময়ে যতগুলো থ্রেডই countTo চালাক না কেন, এটিতে কোনো রেস কন্ডিশন তৈরি হয় না। প্রতিটি থ্রেড শুধুমাত্র নিজের স্ট্যাকের ডেটা নিয়েই কাজ করে।

সকল থ্রেডের জন্য ভাগ করা: হিপ

new দিয়ে তৈরি অবজেক্ট, ইনস্ট্যান্স ফিল্ড এবং স্ট্যাটিক ফিল্ড সবই হিপে থাকে, আর হিপ হলো প্রসেসের প্রতিটি থ্রেডের কাছে দৃশ্যমান একটি শেয়ার্ড জায়গা। যদি দুটি থ্রেড একই অবজেক্টের রেফারেন্স ধরে রাখে, তারা একই মেমোরির দিকে তাকিয়ে আছে।

public class HeapIsShared {
    static int counter = 0; // হিপে একটি শেয়ার্ড স্ট্যাটিক ফিল্ড

    public static void main(String[] args) throws InterruptedException {
        Runnable increment = () -> {
            for (int i = 0; i < 100_000; i++) {
                counter++; // শেয়ার্ড স্টেটে read-modify-write, অ্যাটমিক নয়
            }
        };

        Thread t1 = new Thread(increment);
        Thread t2 = new Thread(increment);
        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println("প্রত্যাশিত 200000, পেলাম " + counter);
    }
}

এটি চালালে চূড়ান্ত মান প্রায় কখনোই ঠিক 200000 হয় না। counter++ আসলে তিনটি ধাপ: মান পড়া, এক যোগ করা, আবার লেখা। যখন দুটি থ্রেড এই ধাপগুলোকে এলোমেলোভাবে একত্রিত করে ফেলে, আপডেট হারিয়ে যায়। এটি JVM-এর কোনো বাগ নয়; সমন্বয় ছাড়া মিউটেবল হিপ স্টেট শেয়ার করার এটি সরাসরি, অনুমানযোগ্য পরিণতি।

টুকরোগুলো একসাথে সাজানো

একটি চলমান মাল্টিথ্রেডেড প্রোগ্রাম কল্পনা করার একটি কার্যকর উপায়: মাঝখানে একটি হিপ যেখানে প্রতিটি অবজেক্ট, প্রতিটি স্ট্যাটিক ফিল্ড এবং প্রতিটি ইনস্ট্যান্স ফিল্ড থাকে; তার চারপাশে কয়েকটি স্বাধীন স্ট্যাক, প্রতি থ্রেডে একটি, প্রতিটি শুধুমাত্র সেই থ্রেডের লোকাল ভেরিয়েবল এবং কল ফ্রেম ধারণ করে। হিপের অবজেক্টগুলো অন্য হিপ অবজেক্টের রেফারেন্স ধরে রাখতে পারে, কিন্তু কখনোই অন্য থ্রেডের স্ট্যাকের দিকে রেফারেন্স ধরে না — একটি মেথডের লোকাল ভেরিয়েবলগুলো মেথডটি রিটার্ন করার মুহূর্তেই অদৃশ্য হয়ে যায়, তাই থ্রেডের বাইরের কিছুই সেগুলোকে রেফারেন্স করতে পারে না। এই চিত্রটি এটাও ব্যাখ্যা করে কেন লোকাল ভেরিয়েবল পাস করা নিরাপদ, কিন্তু শেয়ার্ড অবজেক্টের রেফারেন্স পাস করলে সতর্কতা প্রয়োজন: রেফারেন্সটি নিজে লোকাল হলেও, এটি যেই হিপ অবজেক্টের দিকে নির্দেশ করে তা সব থ্রেড থেকেই দৃশ্যমান।

Share