সরাসরি পর্বে যান
পর্ব ৪০ · ৫৯-এর মধ্যে · মডিউল ৬

thread আর synchronization — একসাথে অনেক কাজ, নিরাপদে

Threads and Synchronization — Race Conditions, Locks, volatile, Atomics and Deadlock

Taught in Bangla, this lesson introduces Java concurrency: processes versus threads, creating threads with Runnable, start versus run, join and thread states, race conditions and lost updates, synchronized blocks, the Java Memory Model with happens-before and volatile, atomic classes and ReentrantLock, deadlock prevention, thread-safe collections and immutability.

এই পর্বে যা শিখবেন

  • Process আর thread-এর পার্থক্য, concurrency বনাম parallelism, আর thread কেন দরকার
  • Thread তৈরি, start() বনাম run(), join(), sleep() আর thread-এর ছয়টি অবস্থা
  • Race condition, lost update আর check-then-act ভুল — আর synchronized দিয়ে সমাধান
  • Java Memory Model, happens-before, volatile, atomic class আর ReentrantLock
  • Deadlock ও তার প্রতিরোধ, thread-safe collection, immutability, daemon thread আর interrupt()

১ · thread কী — এক দোকানে কয়েকজন ক্যাশিয়ার

এ পর্যন্ত আমাদের প্রতিটি প্রোগ্রাম চলেছে একটিমাত্র পথে: main শুরু হয়, একটার পর একটা statement চলে, শেষ হয়। কিন্তু বাস্তবের সফটওয়্যার — যেমন শত শত গ্রাহকের অনুরোধ একসাথে সামলানো একটি web server — একসাথে অনেক কিছু করে। এর পেছনে আছে thread — একই প্রোগ্রামের ভেতরে একটি স্বাধীন কাজের পথ (path of execution). একটি প্রোগ্রামে অনেকগুলো thread থাকতে পারে, আর তারা একই সময়ে এগোতে পারে।

একটি বড় সুপারশপ ভাবুন, যেখানে চারজন ক্যাশিয়ার চারটি কাউন্টারে বসেন। প্রত্যেকের নিজের লাইন, নিজের হাতের হিসাবের খাতা — কিন্তু টাকা রাখার ক্যাশবাক্স একটাই, সবার মাঝখানে। চারজন একসাথে কাজ করায় লাইন দ্রুত শেষ হয়। কিন্তু দুজন যদি একই মুহূর্তে ক্যাশবাক্স খুলে গুনে, নিজের খাতায় নতুন যোগফল লিখে, তারপর বাক্সের গায়ে সেই সংখ্যা বসান — একজনের লেখা আরেকজনের লেখা মুছে দেবে। দোকান = প্রোগ্রাম (process), প্রতিটি ক্যাশিয়ার = একটি thread, হাতের খাতা = thread-এর নিজের stack, আর ক্যাশবাক্স = সবার ভাগাভাগি করা heap-এর object. এই পর্বের পুরো গল্প এই ছবিতেই: একসাথে কাজের সুবিধা, আর একই জিনিসে একসাথে হাত দেওয়ার বিপদ।

Process বনাম thread

অপারেটিং সিস্টেমে প্রতিটি চলমান প্রোগ্রাম একটি process — নিজের আলাদা মেমরি নিয়ে। একটি process আরেকটির মেমরিতে সরাসরি হাত দিতে পারে না। java Main চালালে একটি JVM process তৈরি হয়, আর তার ভেতরে শুরুতে একটি thread — main thread — আপনার main method চালায় (পেছনে garbage collector-এর মতো JVM-এর নিজের কিছু thread-ও থাকে)। পর্ব ০৫-এ দেখেছেন, JVM-এর মেমরির দুটি বড় অংশ heap আর stack. এখানে মূল কথা:

ভাগাভাগি — heap

new দিয়ে তৈরি সব object, static field — একই process-এর সব thread দেখতে আর বদলাতে পারে। যোগাযোগ সহজ, বিপদও এখানেই।

সবার জন্য এক

নিজস্ব — stack

প্রতিটি thread-এর নিজের call stack: local variable, method parameter, কোন method কোথায় আছে। একটি thread-এর local variable আরেকটি দেখতে পায় না।

প্রত্যেকের আলাদা

আলাদা — process

দুটি process-এর মেমরি আলাদা; কথা বলতে লাগে ফাইল, নেটওয়ার্ক বা pipe. বেশি নিরাপদ, কিন্তু তৈরি আর যোগাযোগ দুটোই ভারী।

সবচেয়ে বিচ্ছিন্ন

তাই নিয়মটা মনে রাখুন: local variable সবসময় নিরাপদ, কারণ সেটা একটি thread-এরই। বিপদ শুধু heap-এর এমন object-এ, যা একাধিক thread ভাগ করে আর অন্তত একজন বদলায়। JVM-এর মেমরি বিন্যাস নিয়ে আরও গভীরে যাবেন পর্ব ৪৪-এ।

Concurrency বনাম parallelism

দুটি শব্দ প্রায়ই গুলিয়ে যায়। Concurrency মানে অনেকগুলো কাজ একই সময়সীমার মধ্যে এগোচ্ছে — হয়তো পালা করে। একজন চা-দোকানি একাই চা বানাচ্ছেন, টাকা নিচ্ছেন, বিস্কুট দিচ্ছেন — এক মুহূর্তে একটি কাজ, কিন্তু সব কাজই এগোচ্ছে। Parallelism মানে আক্ষরিক অর্থে একই মুহূর্তে একাধিক কাজ চলছে — তিনজন দোকানি তিনটি কাজ একসাথে করছেন। এক core-এর CPU-তেও concurrency সম্ভব: অপারেটিং সিস্টেম খুব দ্রুত thread বদলায় — একে বলে context switch — ফলে মনে হয় সব একসাথে চলছে। একাধিক core থাকলে thread-গুলো সত্যিই parallel চলে।

Thread দুটি কারণে দরকার: (ক) অপেক্ষার সময় আটকে না থাকা — নেটওয়ার্ক বা ডিস্কের উত্তরের অপেক্ষায় পুরো অ্যাপ জমে যাবে না; আর (খ) একাধিক core কাজে লাগানো — ভারী হিসাব ভাগ করে দ্রুত শেষ করা। দাম হলো জটিলতা: thread-এর চলার ক্রম অপারেটিং সিস্টেম ঠিক করে, আপনি নন। প্রতিবার চালালে ক্রম আলাদা হতে পারে।

২ · thread তৈরি — Runnable, start(), join() আর জীবনচক্র

Java-তে একটি thread মানে java.lang.Thread class-এর একটি object. কী কাজ করবে, সেটা দেওয়া হয় একটি Runnable দিয়ে — একটি functional interface, যার একটিমাত্র method void run(). তাই পর্ব ৩৪-এর lambda দিয়েই কাজটা লেখা যায়:

Runnable job = () -> System.out.println("রিপোর্ট তৈরি হচ্ছে...");
Thread t = new Thread(job, "report-worker");   // নাম দেওয়া ঐচ্ছিক, debug-এ কাজে লাগে
t.start();      // নতুন thread তৈরি হয়ে job.run() চালায়
t.join();       // main অপেক্ষা করে, যতক্ষণ না t শেষ হয়

পুরোনো বইয়ে দেখবেন class MyThread extends Thread লিখে run() override করা। এটা চলে, কিন্তু আধুনিক কোডে Runnable (বা lambda) আলাদা রাখাই ভালো: কাজ আর কাজ-চালানোর যন্ত্র আলাদা থাকে, আর পরে একই কাজ পর্ব ৪১-এর thread pool-এ পাঠানো যায়।

সবচেয়ে বিখ্যাত ফাঁদ — run() বনাম start()

t.start() অপারেটিং সিস্টেমের কাছে একটি নতুন thread চায়, আর সেই নতুন thread-এ run() চালায়; start() নিজে সাথে সাথে ফেরত আসে। কিন্তু t.run() লিখলে? সেটা একটি সাধারণ method call — কোনো নতুন thread হয় না, কাজটা চলে যে thread ডেকেছে সেখানেই, আর শেষ না হওয়া পর্যন্ত পরের লাইনে যাওয়া যায় না। কোড compile হয়, চলে, ফলও ঠিক আসে — শুধু “একসাথে” কিছুই হয় না। অংশ ৪-এর প্রোগ্রামে নিজের চোখে দেখবেন: run() ডাকলে thread-এর নাম আসে main.

আরও দুটি নিয়ম: একটি Thread object-এ start() মাত্র একবার ডাকা যায়। দ্বিতীয়বার ডাকলে (Wandbox-এ যাচাই করা):

Exception in thread "main" java.lang.IllegalThreadStateException
	at java.base/java.lang.Thread.start(Thread.java:1525)
	at Main.main(Main.java:6)

আর Thread.sleep(ms) বর্তমান thread-কে কিছুক্ষণ ঘুম পাড়ায়। এটি একটি checked exception — InterruptedException — ছুড়তে পারে, তাই না সামলালে compile হয় না:

Main.java:5: error: unreported exception InterruptedException; must be caught or declared to be thrown
        Thread.sleep(100);
                    ^

এই exception-এর মানে কী, অংশ ৫-এ interrupt()-এর সাথে দেখবেন। ছোট উদাহরণে আমরা main-এ throws InterruptedException লিখে দিই।

join() — শেষ হওয়ার অপেক্ষা

t.join() ডাকা thread-কে থামিয়ে রাখে, যতক্ষণ না t শেষ হয়। এটা শুধু অপেক্ষা নয় — একটি নিশ্চয়তাও: t যা যা লিখেছে, join() ফেরত আসার পর ডাকা thread সব দেখতে পাবে (অংশ ৫-এর happens-before). তাই এই পর্বের সব চালানো যায় এমন প্রোগ্রাম একই কৌশলে লেখা: কাজ ভাগ করো, সব thread-এ join(), তারপর ফল ছাপো। এতে আউটপুট প্রতিবার এক হয়।

একটি thread-এর ছয় অবস্থা

t.getState() একটি Thread.State enum ফেরত দেয়। ছয়টি মান — ধাপে ধাপে দেখুন একটি thread কখন কোনটিতে থাকে:

  1. NEWnew Thread(job)তৈরি, চালু নয়
  2. RUNNABLEstart()চলছে বা চলার জন্য প্রস্তুত
  3. BLOCKEDsynchronizedতালার অপেক্ষা
  4. WAITING / TIMED_WAITINGjoin() · sleep()অন্যের বা সময়ের অপেক্ষা
  5. TERMINATEDrun() শেষআর চালু করা যায় না
ধাপ ১NEW
Thread t = new Thread(job);
t.getState()   → NEW
object আছে, কিন্তু অপারেটিং সিস্টেমে কোনো thread নেই
ধাপ ২RUNNABLE
t.start();
t.getState()   → RUNNABLE
CPU-তে চলছে, অথবা core পাওয়ার লাইনে আছে —
Java এই দুটি আলাদা করে না
ধাপ ৩BLOCKED
synchronized (account) {   // অন্য thread তালা ধরে আছে
    ...
}
→ BLOCKED  তালা খালি হলে আবার RUNNABLE
ধাপ ৪WAITING · TIMED_WAITING
other.join();          → WAITING        (সময়সীমা নেই)
Thread.sleep(500);     → TIMED_WAITING  (সময়সীমা আছে)
lock.wait();           → WAITING
ঘটনা ঘটলে বা সময় ফুরালে আবার RUNNABLE
ধাপ ৫TERMINATED
run() ফেরত এসেছে (বা exception-এ শেষ)
t.getState()   → TERMINATED
t.start()      → IllegalThreadStateException
  1. NEW. new Thread(...) শুধু একটি Java object বানায়। অপারেটিং সিস্টেমের thread তখনো তৈরি হয়নি, কোনো কাজও শুরু হয়নি।
  2. RUNNABLE. start()-এর পর thread চলার যোগ্য। সে হয়তো এই মুহূর্তে কোনো core-এ চলছে, হয়তো লাইনে দাঁড়িয়ে — কোনটা, সেটা অপারেটিং সিস্টেমের scheduler ঠিক করে, তাই Java দুটোকে একই নাম দেয়।
  3. BLOCKED. শুধু একটি কারণে: সে একটি synchronized block বা method-এ ঢুকতে চায়, কিন্তু তালা অন্য thread-এর হাতে। তালা খালি হলেই আবার RUNNABLE.
  4. WAITING আর TIMED_WAITING. নিজে থেকে অপেক্ষা: join(), wait() (সময়সীমা ছাড়া) দিলে WAITING; sleep(500), join(1000) দিলে TIMED_WAITING. একটি সূক্ষ্মতা: ReentrantLock-এর জন্য অপেক্ষারত thread দেখায় WAITING, BLOCKED নয় — BLOCKED শুধু synchronized-এর তালার জন্য।
  5. TERMINATED. run() শেষ — স্বাভাবিকভাবে বা কোনো exception-এ। Thread আর জাগানো যায় না; আবার start() ডাকলে IllegalThreadStateException. নতুন কাজের জন্য নতুন Thread object লাগে।

Daemon thread — পটভূমির সাহায্যকারী

JVM তখনই বন্ধ হয়, যখন সব সাধারণ thread শেষ। কিন্তু প্রতি মিনিটে cache পরিষ্কার করার মতো পটভূমির কাজের জন্য প্রোগ্রাম আটকে থাকা অর্থহীন। t.setDaemon(true) (অবশ্যই start()-এর আগে) দিলে সেটি একটি daemon thread: বাকি সব সাধারণ thread শেষ হলে JVM তার জন্য অপেক্ষা না করেই বেরিয়ে যায়। সতর্কতা: daemon thread মাঝপথে থেমে যেতে পারে, তাই ফাইলে লেখার মতো যে কাজ অর্ধেক থামলে ক্ষতি, সেটা daemon-এ দেবেন না।

৩ · race condition — যখন ফল নির্ভর করে কে আগে পৌঁছাল

এবার বিপদটা দেখি। একটি সাধারণ counter, যা দুটি thread এক লাখ বার করে বাড়ায়। যোগফল হওয়া উচিত দুই লাখ:

class Counter {
    private int count = 0;
    void increment() { count++; }          // synchronized নেই!
    int get() { return count; }
}
// main-এ:
Counter c = new Counter();
Runnable task = () -> { for (int i = 0; i < 100_000; i++) c.increment(); };
Thread a = new Thread(task), b = new Thread(task);
a.start(); b.start();
a.join(); b.join();
System.out.println("count = " + c.get() + " (প্রত্যাশিত 200000)");

এই কোডটি ইচ্ছা করেই চালানোর বোতাম ছাড়া রাখা হয়েছে, কারণ এর আউটপুট নির্দিষ্ট নয়। Wandbox-এ চারবার চালিয়ে পাওয়া একটি সম্ভাব্য আউটপুট (প্রতিবার আলাদা):

count = 187599 (প্রত্যাশিত 200000)     ← প্রথমবার
count = 200000 (প্রত্যাশিত 200000)     ← পরের তিনবার

প্রথমবার বারো হাজারের বেশি যোগ হারিয়ে গেছে। আর পরের তিনবার ফল ঠিক! এটাই race-এর সবচেয়ে বিপজ্জনক দিক — বেশিরভাগ সময় ঠিক চলে, টেস্ট পাস করে, তারপর একদিন production-এ, বেশি load-এ, ভুল টাকার হিসাব। যখন একটি প্রোগ্রামের ফল thread-গুলোর চলার সময়ক্রমের ওপর নির্ভর করে, তাকে বলে race condition.

count++ একটি কাজ নয়, তিনটি

পর্ব ০৭-এ count++-কে একটি increment operator হিসেবে দেখেছেন। দেখতে একটি কাজ, কিন্তু CPU-র কাছে তিনটি আলাদা ধাপ: মেমরি থেকে মান পড়া, এক যোগ করা, ফল মেমরিতে লেখা। এই ধাঁচকে বলে read-modify-write. দুই ধাপের মাঝখানে অপারেটিং সিস্টেম thread বদলে দিতে পারে — আর তখনই:

1// count++ আসলে তিনটি কাজ:2tmp = count;      // ১. পড়া (read)3tmp = tmp + 1;    // ২. বদলানো (modify)4count = tmp;      // ৩. লেখা (write)
এই মুহূর্তে
heap-এ count
Thread A-র tmp
Thread B-র tmp
  1. শুরু। heap-এ count-এর মান 5. দুটি thread-ই এখন count++ চালাবে, তাই শেষে 7 হওয়ার কথা।
  2. A পড়ে। Thread A মান পড়ে নিজের কাছে (CPU register বা stack-এ) রাখে: 5. ঠিক এই মুহূর্তে অপারেটিং সিস্টেম A-কে থামিয়ে B-কে চালায় — এটা যেকোনো ধাপের মাঝখানে হতে পারে।
  3. B-ও পড়ে। A এখনো কিছু লেখেনি, তাই B-ও পায় 5. দুজনের হাতে এখন একই পুরোনো মান — সুপারশপের দুই ক্যাশিয়ার একই সময়ে ক্যাশবাক্স গুনলেন।
  4. A হিসাব করে। 5 + 1 = 6, কিন্তু এটা A-র নিজের কাছে; heap এখনো 5.
  5. B-ও হিসাব করে। B-ও পায় 6 — কারণ সেও 5 থেকে শুরু করেছে।
  6. A লেখে। heap-এ count = 6. এ পর্যন্ত ঠিক আছে।
  7. B লেখে — হারানো আপডেট। B-ও লেখে 6, A-র কাজ মুছে দিয়ে। দুটি increment, কিন্তু মান বাড়ল একবার। একে বলে lost update. এক লাখ বারের লুপে এমন ঘটনা হাজার বার ঘটতে পারে — তাই 187599-এর মতো ফল।

এখানে একটি প্রশ্ন আসে: count-কে lambda-র ভেতরে সরাসরি local variable হিসেবে বাড়ালে কী হয়? Java সেটা হতেই দেয় না:

Main.java:4: error: local variables referenced from a lambda expression must be final or effectively final
        Runnable r = () -> count++;
                           ^

পর্ব ৩৪-এর effectively final নিয়মের একটি বড় কারণ ঠিক এটাই — local variable stack-এ থাকে, আর অন্য thread-এ চলা lambda-কে তা বদলাতে দিলে race তৈরি হতো। তাই ভাগাভাগি করা অবস্থা রাখতে হয় একটি object-এর field-এ (যেমন Counter), আর সেখানেই সুরক্ষা লাগে।

Check-then-act — শেষ সিটের লড়াই

Race শুধু counter-এ নয়। আরেকটি প্রচলিত রূপ: আগে শর্ত দেখা, তারপর সেই শর্তের ওপর ভরসা করে কাজ। একটি বাস-টিকিট অ্যাপে ঈদের আগে শেষ সিটটি বাকি:

class Bus {
    private int seatsLeft = 1;
    boolean book(String user) {
        if (seatsLeft > 0) {        // check: সিট আছে?
            seatsLeft--;            // act: সিট নাও
            return true;
        }
        return false;
    }
}

রহিম আর করিম একই মুহূর্তে “বুক করুন” চাপলেন। দুই thread-ই seatsLeft > 0 দেখে true পায় — কেউ তখনো কমায়নি। তারপর দুজনই কমায়: seatsLeft হয় −1, আর দুজনের কাছেই নিশ্চিতকরণ SMS যায়। একে বলে check-then-act ভুল: যাচাই আর কাজের মাঝখানে পৃথিবী বদলে যেতে পারে। একই ভুল ব্যাংকে: একটি যৌথ হিসাবে ১০,০০০ টাকা, স্বামী আর স্ত্রী দুটি ATM থেকে একসাথে ৮,০০০ করে তুলতে চাইলেন — if (balance >= amount) balance -= amount; দুজনের জন্যই সত্য হতে পারে। সমাধান একটাই: যাচাই আর কাজ — দুটোকে একসাথে একটি অবিভাজ্য (atomic) একক বানানো, যাতে মাঝখানে কেউ ঢুকতে না পারে। কোডের যে অংশে এক সময়ে একজনের বেশি ঢোকা চলবে না, তাকে বলে critical section.

৪ · synchronized — এক সময়ে একজন

Java-র প্রতিটি object-এর সাথে জন্ম থেকেই একটি অদৃশ্য তালা থাকে — একে বলে intrinsic lock বা monitor. synchronized keyword সেই তালা ব্যবহার করে: একটি thread তালা নিয়ে ভেতরে ঢোকে, কাজ শেষে বেরোনোর সময় ছেড়ে দেয়; এর মধ্যে একই তালা চাওয়া অন্য সব thread দরজায় অপেক্ষা করে (BLOCKED). এই নিশ্চয়তার নাম mutual exclusion — পারস্পরিক বর্জন। দুটি রূপ:

class Account {
    private long balance;
    private final Object lock = new Object();

    synchronized void deposit(long amt) {      // ১. পুরো method — তালা: this
        balance += amt;
    }
    boolean withdraw(long amt) {
        synchronized (lock) {                  // ২. শুধু একটি block — তালা: lock
            if (balance < amt) return false;   // check আর act একসাথে সুরক্ষিত
            balance -= amt;
            return true;
        }
    }
}

(বাস্তব কোডে একটি class-এর সব জায়গায় একই তালা ব্যবহার করুন — এখানে দুটি রূপ পাশাপাশি দেখানোর জন্য আলাদা।) synchronized instance method-এর তালা হলো this; static synchronized method-এর তালা হলো class object (Account.class). Block রূপে আপনি নিজে বলে দেন কোন object-এর তালা — আর block ছোট রাখা যায়, শুধু যেখানে ভাগাভাগি করা ডেটা ছোঁয়া হয়। Intrinsic lock reentrant: একই thread তালা ধরে থাকা অবস্থায় আবার সেই তালার আরেকটি synchronized method ডাকলে নিজেই নিজেকে আটকায় না।

1synchronized (lock) {        // তালা নাও (না পেলে অপেক্ষা)2    balance += amount;3}                            // তালা ছাড়ো
এই মুহূর্তে
তালার মালিক
Thread A (জমা 500)
Thread B (জমা 500)
balance
  1. শুরু। ব্যালান্স 1000. দুটি thread একসাথে 500 করে জমা দিতে আসছে; তালা খালি।
  2. A তালা পায়। A আগে পৌঁছায়, তালা নিয়ে ভেতরে ঢোকে। এখন থেকে তালার মালিক A.
  3. B অপেক্ষা করে। B একই lock object-এর তালা চায়, কিন্তু সেটা A-র হাতে। B-র অবস্থা হয় BLOCKED — সে CPU নষ্ট না করে ঘুমিয়ে থাকে। মনে রাখুন: তালা শুধু তখনই কাজ করে, যখন দুজন একই object-এর তালা চায়।
  4. A নির্বিঘ্নে কাজ করে। পড়া-বদলানো-লেখা তিন ধাপ এখন একটি অবিভাজ্য এককের মতো — মাঝখানে B ঢুকতে পারছে না। ব্যালান্স 1500.
  5. A তালা ছাড়ে। Block থেকে বেরোনোর সময় তালা নিজে থেকেই ছেড়ে যায় — exception হলেও। JVM অপেক্ষারত B-কে জাগায়।
  6. B ভেতরে। B তালা পায় আর পড়ে 1500 — A-র লেখা মানটি। এটা দ্বিতীয় নিশ্চয়তা: তালা ছাড়ার আগে যা লেখা হয়েছে, পরে একই তালা যে নেবে সে তা দেখবে। ব্যালান্স 2000.
  7. ফল। 2000 — সঠিক। দাম: দুই thread এখানে একসাথে চলেনি, পালা করে চলেছে। তাই critical section যত ছোট, প্রোগ্রাম তত দ্রুত।

এবার চালিয়ে দেখুন — প্রথম অংশে run() বনাম start()-এর ফাঁদ আর thread-এর অবস্থা, দ্বিতীয় অংশে দুই thread একসাথে এক লাখ করে জমা দিচ্ছে, কিন্তু synchronized থাকায় কিছু হারায় না:

class Account {
    private long balance;
    synchronized void deposit(long amount) { balance += amount; }   // এক সময়ে একজন
    synchronized long balance() { return balance; }
}
class Main {
    public static void main(String[] args) throws InterruptedException {
        Runnable hello = () -> System.out.println("চলছে: " + Thread.currentThread().getName());
        Thread t = new Thread(hello, "worker-1");
        System.out.println("start-এর আগে: " + t.getState());
        t.run();                      // ফাঁদ: নতুন thread নয়, main-এই চলে
        t.start();                    // ঠিক: নতুন thread তৈরি হয়ে hello চালায়
        t.join();                     // worker-1 শেষ না হওয়া পর্যন্ত main অপেক্ষা করে
        System.out.println("join-এর পর: " + t.getState());
        Account acc = new Account();
        Runnable task = () -> { for (int i = 0; i < 100_000; i++) acc.deposit(1); };
        Thread a = new Thread(task), b = new Thread(task);
        a.start(); b.start();         // দুই thread একসাথে জমা দেয়
        a.join(); b.join();
        System.out.println("synchronized balance = " + acc.balance());
    }
}

আউটপুট প্রতিবার একই পাঁচ লাইন: start-এর আগে: NEW, চলছে: main, চলছে: worker-1, join-এর পর: TERMINATED আর synchronized balance = 200000. দ্বিতীয় লাইনটিই ফাঁদের প্রমাণ — t.run() ডাকায় কাজটা চলেছে main thread-এ, কোনো নতুন thread হয়নি। t.start()-এর পর একই কাজ চলেছে worker-1 নামের নতুন thread-এ। আমরা প্রতিটি thread-কে join() করে তবেই পরের লাইনে যাচ্ছি, আর কোনো thread-ই অন্যের সাথে একই সময়ে ছাপে না — তাই ক্রম নির্দিষ্ট। শেষ লাইন: দুই thread একসাথে চললেও synchronized-এর কারণে দুই লাখ জমার একটিও হারায়নি। deposit থেকে synchronized মুছে দিয়ে কয়েকবার চালান — ফল কখনো কখনো দুই লাখের কম আসবে, যেমন অংশ ৩-এ দেখেছেন। লক্ষ করুন, balance() পড়ার method-ও synchronized — পড়ার সময়ও তালা লাগে, নইলে পুরোনো মান দেখার ঝুঁকি থাকে (পরের অংশ)। (অনলাইন runner ফাইলটিকে অন্য নামে compile করে, তাই Main এখানে public নয়।)

ভুল object-এ তালা — সবচেয়ে চুপচাপ bug

তালা কাজ করে শুধু যখন সব thread একই object-এর তালা চায়। এই তিনটি ভুল প্রায়ই দেখা যায়:

  • প্রতিবার নতুন তালা: synchronized (new Object()) { balance += amt; } — প্রতিটি thread নিজের নতুন তালা পায়, কেউ কারো জন্য অপেক্ষা করে না। কোনো সুরক্ষাই নেই।
  • বদলে যাওয়া field-এ তালা: synchronized (count) যেখানে count একটি Integer — count++ প্রতিবার নতুন Integer object বসায়, তালাও বদলে যায়। তালার field সবসময় private final রাখুন।
  • দুই জায়গায় দুই তালা: deposit লেখে synchronized (this)-এ, আর withdraw লেখে synchronized (lock)-এ — একই balance দুটি আলাদা তালায় “সুরক্ষিত”, অর্থাৎ অরক্ষিত। নিয়ম: একটি ডেটার জন্য একটিই তালা।

৫ · দৃশ্যমানতা — Java Memory Model, happens-before আর volatile

Race-এর বাইরেও একটি অদ্ভুত সমস্যা আছে, যা নতুনদের অবাক করে: এক thread একটি মান লিখল, অন্য thread হয়তো সেটা কখনো দেখবেই না। কারণ আধুনিক হার্ডওয়্যার। প্রতিটি CPU core-এর নিজের দ্রুত cache আছে; একটি core নতুন মান হয়তো কিছুক্ষণ নিজের cache-এ রাখে। আর JIT compiler ও CPU গতি বাড়াতে নির্দেশের ক্রম বদলাতে পারে — একটি thread-এর ভেতরে ফল একই থাকলে। একটি thread-এর দিক থেকে সব ঠিক থাকে; অন্য thread থেকে দেখলে ক্রম আর মান অন্যরকম লাগতে পারে।

কোন লেখা কোন thread কখন দেখবে, তার নিয়মকানুন লেখা আছে Java Memory Model (JMM)-এ, Java Language Specification-এর অধ্যায় ১৭-এ। এর কেন্দ্রীয় ধারণা happens-before: যদি কাজ X, কাজ Y-এর আগে “happens-before” সম্পর্কে থাকে, তাহলে X-এর সব লেখা Y দেখতে পাবে। এমন সম্পর্ক না থাকলে কোনো নিশ্চয়তা নেই। প্রধান নিয়মগুলো:

যদি……তাহলে নিশ্চিত
একটি thread একটি তালা ছাড়ে (synchronized থেকে বেরোয়)পরে একই তালা যে নেবে, সে আগের সব লেখা দেখবে
একটি volatile field-এ লেখা হয়পরে সেই field যে পড়বে, সে লেখার আগের সব কিছু দেখবে
t.start() ডাকা হয়start()-এর আগে যা লেখা হয়েছে, নতুন thread সব দেখবে
thread t শেষ হয়, আর কেউ t.join() থেকে ফেরেt-এর সব লেখা সে দেখবে
একই thread-এর ভেতরে কোডের ক্রমআগের লাইনের লেখা পরের লাইন দেখে (program order)

তাই visibility (দৃশ্যমানতা) আর atomicity (অবিভাজ্যতা) দুটি আলাদা সমস্যা। synchronized দুটোই সমাধান করে। কিন্তু কখনো শুধু দৃশ্যমানতা দরকার — যেমন একটি stop flag.

volatile — একটি flag সবাইকে দেখানো

একটি ডাউনলোড চলছে আলাদা thread-এ; ব্যবহারকারী “বাতিল” চাপলে UI thread একটি flag বদলায়:

class Downloader implements Runnable {
    private volatile boolean running = true;   // volatile ছাড়া লুপ হয়তো কখনো থামবে না
    public void run() {
        while (running) {
            downloadNextChunk();
        }
        System.out.println("ডাউনলোড থামল");
    }
    void stop() { running = false; }           // অন্য thread (যেমন UI) ডাকে
}

volatile না থাকলে JMM অনুযায়ী worker thread-এর running-এর নতুন মান দেখার কোনো নিশ্চয়তা নেই। বাস্তবে JIT লক্ষ করতে পারে যে লুপের ভেতরে কেউ running বদলায় না, আর পড়াটা লুপের বাইরে একবারে সরিয়ে নিতে পারে — if (running) while (true) { … } — ফলে লুপ চিরকাল চলে। এটা সব মেশিনে, সব সময় ঘটে না; ঠিক সেজন্যই বিপজ্জনক। volatile বলে দেয়: এই field-এর প্রতিটি পড়া মূল মেমরির সর্বশেষ মান দেখবে, আর লেখার আগের সব লেখাও পাঠকের কাছে দৃশ্যমান হবে।

volatile তালা নয়। volatile int count; count++; এখনো race — পড়া-বদলানো-লেখা তিন ধাপ, মাঝখানে অন্য thread ঢুকতে পারে। volatile ঠিক তখন, যখন একজন লেখে আর অন্যরা শুধু পড়ে, আর নতুন মান পুরোনো মানের ওপর নির্ভর করে না: flag, “সর্বশেষ কনফিগ”-এর reference, শেষ আপডেটের সময়। Counter-এর জন্য পরের অংশের atomic class.

interrupt() — ভদ্রভাবে থামতে বলা

নিজের flag-এর বদলে Java-র নিজস্ব একটি থামার সংকেত আছে: worker.interrupt(). এটা thread-কে জোর করে থামায় না — শুধু তার গায়ে একটি “থামো” flag বসায়। Thread নিজে Thread.currentThread().isInterrupted() দেখে সিদ্ধান্ত নেয়। আর thread যদি sleep, wait বা join-এ ঘুমিয়ে থাকে, তাকে সাথে সাথে জাগিয়ে InterruptedException ছোড়া হয় — volatile flag এটা পারে না, ঘুমন্ত thread flag দেখে না:

Thread worker = new Thread(() -> {
    while (!Thread.currentThread().isInterrupted()) {
        try {
            syncNextFile();
            Thread.sleep(5_000);              // ৫ সেকেন্ড পরপর
        } catch (InterruptedException e) {
            return;                           // থামার অনুরোধ — গুছিয়ে বেরিয়ে যাও
        }
    }
});
worker.start();
// ... পরে, অ্যাপ বন্ধের সময়:
worker.interrupt();

Interrupt হলো সহযোগিতামূলক বাতিল: thread নিজে ঠিক করে কোথায় থামা নিরাপদ। পুরোনো Thread.stop() একটি thread-কে যেকোনো জায়গায় মেরে ফেলত — তালা ছেড়ে, ডেটা অর্ধেক লেখা অবস্থায়; সেজন্য বহু বছর আগে deprecated, আর Java 20 থেকে সেটি আর কাজই করে না — ডাকলে শুধু UnsupportedOperationException আসে। InterruptedException চুপ করে গিলবেন না; নিজে থামতে না পারলে Thread.currentThread().interrupt() দিয়ে flag আবার বসিয়ে দিন।

৬ · তালা ছাড়া নিরাপদ — atomic class, আর ReentrantLock

AtomicInteger — ডাউনলোডের অগ্রগতি গোনা

একটি অ্যাপ চারটি thread দিয়ে একটি ফাইলের ১০০টি টুকরো নামাচ্ছে, আর স্ক্রিনে দেখাচ্ছে “৪৭/১০০ সম্পন্ন”। প্রতিটি thread টুকরো শেষ করে counter বাড়ায়। শুধু একটি সংখ্যা বাড়ানোর জন্য পুরো synchronized লাগে না — java.util.concurrent.atomic package-এর AtomicInteger যথেষ্ট:

AtomicInteger done = new AtomicInteger();
// প্রতিটি worker thread, একটি টুকরো শেষে:
int now = done.incrementAndGet();         // ++ অবিভাজ্যভাবে, নতুন মান ফেরত
System.out.println(now + "/100 সম্পন্ন");  // (বাস্তবে UI-তে দেখানো হয়)

incrementAndGet(), addAndGet(n), getAndIncrement() — সবই পড়া-বদলানো-লেখাকে একটি অবিভাজ্য কাজে পরিণত করে। এর নিচে কোনো তালা নেই; আছে CPU-র একটি বিশেষ নির্দেশ — compare-and-swap (CAS): “মেমরিতে মান যদি এখনো 5 থাকে, তবেই 6 বসাও; নইলে জানাও।” ব্যর্থ হলে thread নতুন মান পড়ে আবার চেষ্টা করে। অংশ ৩-এর stepper-এ B যখন 6 লিখতে যেত, CAS দেখত মান আর 5 নেই (A ইতিমধ্যে 6 করেছে), B-র চেষ্টা ব্যর্থ হতো, B আবার পড়ত 6 আর লিখত 7. কেউ ঘুমায় না, কেউ BLOCKED হয় না। Atomic variable-এর লেখা-পড়া volatile-এর মতো দৃশ্যমানতাও দেয়।

খুব বেশি thread একটি counter নিয়ে কাড়াকাড়ি করলে CAS বারবার ব্যর্থ হয়। তখন Java 8 থেকে আসা LongAdder ভালো: সে ভেতরে কয়েকটি আলাদা ঘর রাখে, প্রতিটি thread সাধারণত নিজের ঘরে যোগ করে, আর sum() ডাকলে সব যোগ করে দেয়। ওয়েবসাইটের hit counter বা metrics-এর জন্য আদর্শ; তবে প্রতি মুহূর্তে সঠিক মান পড়তে হলে AtomicInteger/AtomicLong.

ReentrantLock — যখন synchronized যথেষ্ট নয়

java.util.concurrent.locks-এর ReentrantLock একই কাজ করে — mutual exclusion আর দৃশ্যমানতা — কিন্তু আরও নিয়ন্ত্রণ দেয়। সবচেয়ে কাজের বৈশিষ্ট্য: অপেক্ষার একটি সীমা দেওয়া।

private final ReentrantLock lock = new ReentrantLock();

boolean bookSeat(String user) throws InterruptedException {
    if (!lock.tryLock(2, TimeUnit.SECONDS)) {   // ২ সেকেন্ডে না পেলে হাল ছাড়ো
        return false;                           // "সার্ভার ব্যস্ত, আবার চেষ্টা করুন"
    }
    try {
        if (seatsLeft == 0) return false;
        seatsLeft--;
        return true;
    } finally {
        lock.unlock();                          // সবসময় finally-তে!
    }
}

synchronized থেকে বেরোলে তালা নিজে থেকে ছাড়ে; ReentrantLock ছাড়ে না — unlock() ভুললে বা exception-এ বাদ পড়লে তালা চিরকাল আটকে থাকে। তাই lock()/tryLock()-এর পরেই try, আর unlock() সবসময় finally-তে। বাড়তি সুবিধা — tryLock, lockInterruptibly(), fair ক্রম, একাধিক Condition — না লাগলে synchronized-ই সহজ আর কম ভুলপ্রবণ।

৭ · deadlock — দুজনই অপেক্ষায়, চিরকাল

তালা race সারায়, কিন্তু নতুন একটি বিপদ আনে। দুটি হিসাবের মধ্যে টাকা পাঠানো — দুই হিসাবই একসাথে আটকাতে হবে, যাতে মাঝপথে কেউ অর্ধেক অবস্থা না দেখে:

void transfer(Account from, Account to, long amt) {
    synchronized (from) {
        synchronized (to) {
            from.withdraw(amt);
            to.deposit(amt);
        }
    }
}
// Thread A: transfer(x, y, 100)      Thread B: transfer(y, x, 50)

রহিম করিমকে টাকা পাঠাচ্ছেন, আর ঠিক একই মুহূর্তে করিম রহিমকে। দেখুন কী ঘটে:

1synchronized (from) {          // প্রথম তালা2    synchronized (to) {        // দ্বিতীয় তালা3        from.withdraw(amt); to.deposit(amt);4// সমাধান: সবসময় ছোট id-র হিসাব আগে
তালার অবস্থা
x-এর তালা (id 1)
y-এর তালা (id 2)
Thread A
Thread B
  1. দুটি উল্টো লেনদেন। A ডাকে transfer(x, y), B ডাকে transfer(y, x). কোডটি একই, কিন্তু argument-এর ক্রম উল্টো — তাই তালা নেওয়ার ক্রমও উল্টো হবে।
  2. A প্রথম তালা নেয়। A-র from হলো x — x-এর তালা A-র হাতে।
  3. B-ও প্রথম তালা নেয়। B-র from হলো y — y-এর তালা B-র হাতে। এ পর্যন্ত কেউ কাউকে আটকায়নি।
  4. A দ্বিতীয় তালা চায়। A-র to হলো y, যা B ধরে আছে। A BLOCKED — x ছাড়ে না, কারণ synchronized block থেকে সে এখনো বেরোয়নি।
  5. B দ্বিতীয় তালা চায় — চক্র পূর্ণ। B-র to হলো x, যা A ধরে আছে। দুজনই এমন কিছুর অপেক্ষায়, যা অন্যজন ছাড়বে শুধু নিজে এগোতে পারলে। এই হলো deadlock: প্রোগ্রাম কোনো error দেয় না, CPU-ও খরচ করে না — শুধু চিরকাল চুপচাপ জমে থাকে। দুজনের টাকা আটকে গেল।
  6. সমাধান: একটি নির্দিষ্ট ক্রম। নিয়ম বানান — সবসময় ছোট id-র হিসাবের তালা আগে। এখন দুই thread-ই আগে x (id 1) চায়। A পেল; B অপেক্ষা করছে, কিন্তু B কিছুই ধরে নেই। চক্র তৈরিই হতে পারে না।
  7. সবাই এগোয়। A y-ও নেয়, টাকা পাঠায়, দুটো তালা ছাড়ে। তারপর B x আর y নিয়ে নিজের কাজ করে। একে বলে lock ordering — deadlock প্রতিরোধের সবচেয়ে সহজ আর কার্যকর কৌশল।

কোডে সমাধান:

void transfer(Account from, Account to, long amt) {
    Account first  = from.id() < to.id() ? from : to;   // সবসময় ছোট id আগে
    Account second = from.id() < to.id() ? to : from;
    synchronized (first) {
        synchronized (second) {
            from.withdraw(amt);
            to.deposit(amt);
        }
    }
}

আরেকটি কৌশল: অংশ ৬-এর tryLock(সময়সীমা) — দ্বিতীয় তালা নির্দিষ্ট সময়ে না পেলে প্রথমটিও ছেড়ে দিন, একটু অপেক্ষা করে আবার চেষ্টা করুন। আর deadlock হয়ে গেলে খোঁজার উপায়: JDK-র jstack <pid> কমান্ড সব thread-এর অবস্থা ছাপে, আর deadlock পেলে নিচে স্পষ্ট লেখে “Found one Java-level deadlock”, কোন thread কোন তালা ধরে কোনটার অপেক্ষায় — সবসহ। আর তালা ধরে থাকা অবস্থায় অজানা কোড (callback, নেটওয়ার্ক কল) ডাকবেন না — সেটা ভেতরে কোন তালা চাইবে, আপনি জানেন না।

wait/notify — পুরোনো যন্ত্র, আধুনিক বিকল্প

কখনো একটি thread-কে অন্যের সংকেতের অপেক্ষা করতে হয়: “সারিতে কাজ এলে জাগাও”। Java 1.0 থেকে প্রতিটি object-এ এর জন্য wait() আর notify() আছে। wait() তালা ছেড়ে ঘুমায়, notifyAll() অপেক্ষারতদের জাগায়। নিয়মগুলো কঠিন: এদের ডাকতে হয় সেই object-এর তালা ধরে থাকা অবস্থায়, নইলে —

Exception in thread "main" java.lang.IllegalMonitorStateException: current thread is not owner
	at java.base/java.lang.Object.wait0(Native Method)
	at java.base/java.lang.Object.wait(Object.java:366)
	at java.base/java.lang.Object.wait(Object.java:339)
	at Main.main(Main.java:4)

আর শর্ত যাচাই করতে হয় if দিয়ে নয়, while দিয়ে — কারণ thread কোনো কারণ ছাড়াই জেগে উঠতে পারে (spurious wakeup), বা জাগার পর অন্য কেউ আগে জিনিসটা নিয়ে যেতে পারে:

synchronized (queue) {
    while (queue.isEmpty()) queue.wait();     // পুরোনো ধাঁচ: if নয়, while
    order = queue.remove();
}
// আধুনিক বিকল্প — একই কাজ, এক লাইনে, ভুলের সুযোগ নেই:
order = blockingQueue.take();

আজকের কোডে wait/notify নিজে লেখার দরকার প্রায় পড়ে না। পর্ব ৩২-এ দেখা BlockingQueue producer-consumer-এর পুরো ব্যবস্থা তৈরি দেয়, আর “তিনটি কাজ শেষ হলে এগোও” ধরনের অপেক্ষার জন্য আছে CountDownLatch — এগুলো আর executor পর্ব ৪১-এ। wait/notify চিনে রাখুন পুরোনো কোড পড়তে আর ইন্টারভিউর জন্য।

৮ · thread-safe collection আর immutability

পর্ব ৩০–৩১-এর ArrayList আর HashMap thread-safe নয়। একাধিক thread একসাথে put বা add করলে উপাদান হারাতে পারে, আকার ভুল হতে পারে, এমনকি ভেতরের কাঠামো নষ্ট হয়ে exception আসতে পারে। নিজে প্রতিটি কাজে তালা না বসিয়ে তৈরি সমাধান ব্যবহার করুন:

প্রয়োজনব্যবহার করুনকীভাবে কাজ করে, কী মনে রাখবেন
একাধিক thread পড়ে-লেখে এমন MapConcurrentHashMapভেতরে ভাগ ভাগ করে সুরক্ষা — একসাথে অনেকে পড়তে আর আলাদা key-তে লিখতে পারে। merge, compute, putIfAbsent অবিভাজ্য। null key বা value চলে না।
পুরোনো কোডে দ্রুত সুরক্ষাCollections.synchronizedList(list)প্রতিটি method-এ একটি তালা — একটি synchronized wrapper. কিন্তু ঘোরানোর (iterate) সময় নিজে synchronized (list) { … } দিতে হয়, আর “না থাকলে যোগ করো”-র মতো দুই-ধাপের কাজ এখনো check-then-act.
অনেক পড়া, কদাচিৎ লেখা (listener-এর তালিকা)CopyOnWriteArrayListপ্রতিটি লেখায় পুরো array-র নতুন কপি; পড়া আর ঘোরানো তালা ছাড়া, কখনো ConcurrentModificationException নয়। ঘন ঘন লেখায় খুব ধীর।
producer-consumer সারিBlockingQueue (ArrayBlockingQueue, LinkedBlockingQueue)put জায়গার জন্য, take উপাদানের জন্য অপেক্ষা করে — পর্ব ৩২ ও ৪১
পুরোনো classVector, Hashtableপ্রতিটি method synchronized, Java 1.0-র যুগের। নতুন কোডে ব্যবহার করবেন না — ওপরের বিকল্পগুলো ভালো।

এবার সব মিলিয়ে একটি শব্দ-গণনা: একটি বইয়ের চারটি পাতা চারটি thread-এ ভাগ করে দেওয়া হলো; প্রতিটি thread নিজের পাতার শব্দ একটি ভাগাভাগি করা ConcurrentHashMap-এ গোনে, আর একটি AtomicInteger গোনে কয়টি পাতা শেষ:

import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
class Main {
    public static void main(String[] args) throws InterruptedException {
        List<String> pages = List.of("আমি ভাত খাই", "আমি চা খাই", "সে চা খায়", "আমি বই পড়ি");
        Map<String, Integer> freq = new ConcurrentHashMap<>();
        AtomicInteger done = new AtomicInteger();          // কয়টি পাতা শেষ
        List<Thread> workers = new ArrayList<>();
        for (String page : pages) {
            Thread t = new Thread(() -> {
                for (String w : page.split(" ")) freq.merge(w, 1, Integer::sum);
                done.incrementAndGet();
            });
            workers.add(t);
            t.start();
        }
        for (Thread t : workers) t.join();                 // সবার শেষ হওয়ার অপেক্ষা
        System.out.println("শেষ হয়েছে: " + done.get() + "/" + pages.size() + " পাতা");
        System.out.println("শব্দ গণনা: " + new TreeMap<>(freq));
    }
}

আউটপুট প্রতিবার একই দুই লাইন: শেষ হয়েছে: 4/4 পাতা আর শব্দ গণনা: {আমি=3, খাই=2, খায়=1, চা=2, পড়ি=1, বই=1, ভাত=1, সে=1}. চারটি thread কোন ক্রমে চলবে, কেউ জানে না — কিন্তু ফল ক্রমের ওপর নির্ভর করে না, কারণ (ক) পর্ব ৩১-এর merge ConcurrentHashMap-এ অবিভাজ্য: “আমি” শব্দটি তিনটি thread একসাথে যোগ করলেও কোনো গণনা হারায় না; (খ) done.incrementAndGet() অবিভাজ্য; (গ) সব thread-এ join()-এর পরেই ছাপা হচ্ছে, তাই সব লেখা দৃশ্যমান। ConcurrentHashMap-এর ছাপার ক্রম HashMap-এর মতোই অনিশ্চিত, তাই ছাপার আগে TreeMap-এ কপি করে সাজানো হয়েছে। এবার new ConcurrentHashMap<>()-এর জায়গায় new HashMap<>() লিখে ভাবুন: এই ছোট ডেটায় হয়তো ঠিকই আসবে — কিন্তু সেটা ভাগ্য, নিশ্চয়তা নয়।

thread-safe collection মানে পুরো কাজ thread-safe নয়। if (!map.containsKey(k)) map.put(k, 1); else map.put(k, map.get(k) + 1); — প্রতিটি method আলাদাভাবে নিরাপদ, কিন্তু তিনটি মিলে check-then-act, ConcurrentHashMap-এও race. একটি অবিভাজ্য method ব্যবহার করুন: merge, compute, putIfAbsent.

সবচেয়ে সহজ thread safety — কিছু বদলাতেই না দেওয়া

Race-এর তিনটি শর্ত: ডেটা ভাগাভাগি করা, ডেটা বদলানো যায়, আর সমন্বয় নেই। যেকোনো একটি সরালেই সমস্যা শেষ। তালা তৃতীয়টি সারায়; কিন্তু দ্বিতীয়টি সরানো আরও সহজ — immutable object কখনো বদলায় না, তাই একে যত thread খুশি একসাথে পড়ুক, কোনো তালা লাগে না। পর্ব ০৮-এর String, পর্ব ২৫-এর record (যদি তার field-গুলো নিজেও immutable হয়), List.of, LocalDate — সবই এমন। পর্ব ১৮-এ দেখা final field-এর একটি বাড়তি নিশ্চয়তা JMM দেয়: constructor শেষ হওয়ার পর যে thread-ই object-টি পায়, সে final field-গুলোর সঠিক মান দেখবে।

নকশার কৌশল: অবস্থা বদলাতে হলে পুরোনো object বদলাবেন না, নতুন একটি বানান, আর একটি volatile reference (বা AtomicReference) দিয়ে নতুনটির দিকে ঘুরিয়ে দিন। যেমন একটি অ্যাপের কনফিগ: private volatile Config current; — reload হলে current = new Config(...). পাঠক thread-রা হয় পুরো পুরোনোটা দেখে, নয় পুরো নতুনটা, কখনো অর্ধেক-বদলানো নয়। আর পর্ব ১৮-এ যে global state নিয়ে সতর্ক করা হয়েছিল, thread-এর জগতে সেটাই বিপদের প্রধান উৎস — বদলানো যায় এমন static field মানে সব thread-এর ভাগাভাগি করা ক্যাশবাক্স।

C/C++/Python জানলে

C-তে thread আসে library থেকে — POSIX-এর pthreads বা C11-এর <threads.h>; C++11 থেকে std::thread, std::mutex, std::atomic. Java-র পার্থক্য: প্রতিটি object-এ তালা জন্মগত, আর synchronized block থেকে বেরোলে তালা নিজে ছাড়ে — C++-এর std::lock_guard-এর মতো। সাবধান: C/C++-এর volatile thread-এর মধ্যে দৃশ্যমানতার নিশ্চয়তা দেয় না (সেখানে std::atomic লাগে), কিন্তু Java-র volatile দেয়। Python-এর প্রচলিত CPython-এ GIL-এর কারণে Python কোডের thread সাধারণত একসাথে parallel চলে না, তবু count += 1-এ race হয় — ধারণাগুলো একই। তুলনার জন্য দেখুন C++-এর মাল্টিথ্রেডিং আর Python-এর thread, process আর GIL.

পরীক্ষা ও ইন্টারভিউতে
  • Process আর thread-এর পার্থক্য? — process-এর নিজস্ব মেমরি; একই process-এর thread-রা heap ভাগ করে, কিন্তু প্রত্যেকের stack আলাদা।
  • start() আর run()-এর পার্থক্য? — start() নতুন thread তৈরি করে তাতে run() চালায়; run() সরাসরি ডাকলে সাধারণ method call, বর্তমান thread-এই চলে।
  • synchronized আর volatile-এর পার্থক্য? — synchronized দেয় mutual exclusion আর দৃশ্যমানতা; volatile দেয় শুধু দৃশ্যমানতা ও ক্রম, count++-কে অবিভাজ্য করে না।
  • Deadlock কী, আর কীভাবে এড়াবেন? — দুই বা বেশি thread একে অপরের ধরে রাখা তালার জন্য চিরকাল অপেক্ষা; সব জায়গায় একই ক্রমে তালা নেওয়া, বা সময়সীমাসহ tryLock.
  • HashMap, Hashtable আর ConcurrentHashMap? — প্রথমটি thread-safe নয়; দ্বিতীয়টি পুরো object-এ এক তালা, পুরোনো; তৃতীয়টি thread-safe আর অনেক বেশি একসাথে-চলা সহ্য করে।

৯ · AI-এর যুগে — যে bug টেস্টে ধরা পড়ে না

Concurrency-র bug AI-এর জন্য সবচেয়ে কঠিন জায়গাগুলোর একটি — আর আপনার জন্যও, কারণ AI-এর কোড চালিয়ে দেখলে প্রায় সবসময় ঠিক ফল আসে। AI-এর লেখা Java concurrency কোডে বারবার যা দেখা যায়:

AI যা করেকেন সমস্যাআপনি যা করবেন
thread.run() লেখেকোনো নতুন thread নেই; কোড চলে, কিন্তু একসাথে কিছু হয় নাstart(); সন্দেহ হলে thread-এর নাম ছেপে দেখুন
synchronized (new Object()), বা this আর অন্য field মিশিয়ে তালাতালা ভাগ হয় না, সুরক্ষা নেইপ্রতিটি ডেটার জন্য একটি private final তালা
thread-এর মধ্যে সাধারণ HashMap/ArrayList ভাগ করেআপডেট হারায়, কাঠামো নষ্ট হতে পারেConcurrentHashMap, BlockingQueue বা thread-প্রতি local ফল
stop flag-এ volatile ভোলেworker হয়তো কখনো থামে নাvolatile boolean বা interrupt()
counter-কে volatile int বানিয়ে “thread-safe” বলেcount++ তবুও raceAtomicInteger/LongAdder
দুই তালা nested, বিভিন্ন method-এ উল্টো ক্রমেdeadlock — মাঝে মাঝে, load বাড়লেএকটি নির্দিষ্ট তালা-ক্রম, বা tryLock সময়সীমাসহ
catch (InterruptedException e) {} খালি রাখেবাতিলের সংকেত হারিয়ে যায়থামুন, বা Thread.currentThread().interrupt()
“ঠিক আছে কি না দেখতে” একবার চালিয়ে আউটপুট দেখায়race একবারের চালানোয় প্রমাণ হয় নাযুক্তি দিয়ে যাচাই: কোন ডেটা ভাগ হচ্ছে, কে কোন তালায় সুরক্ষিত

Thread-সংক্রান্ত কোড লেখাতে বা রিভিউ করাতে এই কাঠামো ব্যবহার করুন — এটা AI-কে প্রতিটি ভাগাভাগি করা ডেটার হিসাব দিতে বাধ্য করে:

আমি Java 21 শিখছি; thread, synchronized, volatile আর AtomicInteger জানি। কাজ: [যেমন — চারটি thread দিয়ে লগ ফাইলের লাইন ভাগ করে প্রতিটি error-কোড কতবার এসেছে গোনা] শর্ত: ১. কোডের আগে একটি তালিকা দাও: কোন কোন object একাধিক thread ভাগ করছে, আর প্রতিটিকে কীভাবে সুরক্ষিত রাখছ (তালা, atomic, immutable, নাকি thread-প্রতি আলাদা)। ২. নতুন thread চালু করতে শুধু start() ব্যবহার করবে; সব thread-এ join() করে তারপর ফল ছাপবে। ৩. thread-এর মধ্যে ভাগ করা Map/List হলে ConcurrentHashMap বা অন্য thread-safe collection; গণনায় merge বা AtomicInteger. ৪. একাধিক তালা লাগলে কোন ক্রমে নিচ্ছ, স্পষ্ট লিখবে, আর সব জায়গায় সেই একই ক্রম রাখবে। ৫. stop flag থাকলে volatile, আর InterruptedException কখনো খালি catch-এ গিলবে না। ৬. আউটপুট প্রতিবার একই হতে হবে — thread-এর নাম বা ভেতর থেকে মিশ্র লাইন ছাপবে না। ৭. শেষে বলো, এই কোডে কোথায় race বা deadlock হতে পারত, আর কোন লাইন সেটা ঠেকাচ্ছে।

AI-এর thread কোড হাতে পেলে — যাচাইয়ের চেকলিস্ট

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

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। এগুলোই আপনাকে কোড-লেখক থেকে সিস্টেম-চিন্তক বানাবে।

প্র ০১১৯৯৬ সালে Java 1.0-তেই thread আর synchronized ভাষার ভেতরে ছিল — তখনকার জনপ্রিয় ভাষাগুলোর জন্য যা ছিল বিরল। এটা কেন বড় সিদ্ধান্ত ছিল, আর এর দাম কী?

তখনকার অবস্থা। ১৯৯০-এর দশকের মাঝামাঝি C আর C++-এর ভাষা-মানে (standard) thread বলে কিছুই ছিল না। Thread আসত অপারেটিং সিস্টেমের library থেকে — Unix-এ POSIX threads (pthreads, ১৯৯৫ সালে মানসম্মত), Windows-এ আলাদা API. ফলে একই thread-কোড এক সিস্টেম থেকে আরেকটিতে নিতে গেলে নতুন করে লিখতে হতো, আর compiler জানতই না যে কোড একাধিক thread-এ চলবে — তাই সে নিশ্চিন্তে এমন অপ্টিমাইজেশন করত যা thread-এর জগতে ভুল। C আর C++ ভাষার ভেতরে thread আর memory model পেল অনেক পরে, ২০১১ সালে (C11 আর C++11).

Java-র সিদ্ধান্ত। Java তৈরি হয়েছিল নেটওয়ার্কের জন্য, আর তখনকার উদাহরণ ছিল browser-এর applet — যেখানে ছবি নামানো, অ্যানিমেশন আর ব্যবহারকারীর ক্লিক একসাথে চলতে হয়। তাই Java শুরু থেকেই Thread class, synchronized keyword আর প্রতিটি object-এ wait/notify দিল। ধারণাটি নেওয়া হয়েছিল ১৯৭০-এর দশকের monitor থেকে — Per Brinch Hansen আর C. A. R. (Tony) Hoare-এর কাজ: ডেটা আর তার তালা একসাথে বাঁধা। “এক কোড, সব জায়গায়” প্রতিশ্রুতির মধ্যে thread-ও পড়ল — Windows, Solaris, Linux-এ একই কোড।

দাম। প্রতিটি object-কে তালা বানানো সুবিধাজনক, কিন্তু সমালোচনাও হয়েছে। Brinch Hansen নিজে ১৯৯৯ সালে “Java’s Insecure Parallelism” নামে একটি প্রবন্ধে যুক্তি দেন যে Java monitor-এর ধারণাকে দুর্বল করেছে: synchronized ঐচ্ছিক, তাই একই ডেটা কোথাও তালা দিয়ে আর কোথাও তালা ছাড়া ছোঁয়া যায়, compiler কিছুই ধরতে পারে না। wait/notify নিচু স্তরের আর ভুলপ্রবণ (অংশ ৭)। আর সবচেয়ে বড় দাম, প্রথম memory model-টিই ভুল ছিল — প্র ০২-এ।

পরিণতি। তবু সিদ্ধান্তটি ইতিহাস বদলেছে। server-side Java প্রতিটি request আলাদা thread-এ চালিয়ে ইন্টারনেটের বড় অংশ সামলেছে, আর Java 5-এর java.util.concurrent (Doug Lea-র নেতৃত্বে) অন্য ভাষাগুলোর জন্য আদর্শ হয়ে উঠেছে। Java 21-এর virtual thread (পর্ব ৪২) সেই একই Thread API-কে নতুন যুগে নিয়ে গেছে।

মূল উপলব্ধি: Java concurrency-কে library থেকে ভাষার অংশ বানিয়ে portable করেছে — বিনিময়ে কিছু তাড়াহুড়োর নকশা এসেছে, যা পরের দশকগুলোতে উন্নত library আর নতুন memory model দিয়ে সারাতে হয়েছে।

প্র ০২Java-র প্রথম Memory Model ভুল ছিল, আর Java 5-এ (JSR 133) সেটা নতুন করে লেখা হয়। কী ভুল ছিল, আর “double-checked locking” কেন ভেঙে পড়েছিল?

প্রথম নিয়ম। ১৯৯৬ সালের Java Language Specification-এর অধ্যায় ১৭ একটি memory model দিয়েছিল — কোন লেখা কোন thread কখন দেখবে। ১৯৯০-এর দশকের শেষে University of Maryland-এর গবেষক Bill Pugh দেখান যে সেই নিয়মগুলো একই সাথে খুব কঠোর আর খুব দুর্বল। কঠোর, কারণ এটি compiler-এর অনেক সাধারণ অপ্টিমাইজেশন নিষিদ্ধ করত, যা বাস্তবের JVM-গুলো এমনিতেই করত। দুর্বল, কারণ এটি এমন নিশ্চয়তা দিত না যা প্রোগ্রামাররা ধরে নিতেন — যেমন, একটি immutable String-কেও অন্য thread থেকে বদলে যেতে দেখা সম্ভব ছিল, তাত্ত্বিকভাবে। প্রায় ২০০০ সালে Pugh-এর একটি লেখার শিরোনামই ছিল “The Java Memory Model is Fatally Flawed”.

Double-checked locking. সবচেয়ে বিখ্যাত শিকার একটি জনপ্রিয় কৌশল: singleton lazily তৈরি করতে প্রতিবার তালা না নিয়ে আগে দেখা if (instance == null), তারপর তালা নিয়ে আবার দেখা, তারপর instance = new Helper(). সমস্যা: compiler বা CPU আগে reference-টি instance-এ বসিয়ে পরে constructor-এর লেখাগুলো করতে পারে। তখন অন্য thread প্রথম if-এ (তালা ছাড়া) একটি non-null reference দেখে অর্ধেক-তৈরি object ব্যবহার করে ফেলে। Pugh, Joshua Bloch, Doug Lea-সহ অনেক বিশেষজ্ঞ মিলে “The ‘Double-Checked Locking is Broken’ Declaration” প্রকাশ করেন — কৌশলটি তখনকার Java-তে নিরাপদে লেখাই যেত না।

JSR 133. Pugh-এর নেতৃত্বে, Jeremy Manson, Sarita Adve-সহ একদল গবেষক কয়েক বছর ধরে নতুন model লেখেন, যা ২০০৪ সালে Java 5-এ আসে। এর কেন্দ্রে happens-before (অংশ ৫)। volatile-এর অর্থ শক্তিশালী করা হয় — এখন volatile লেখা তার আগের সব লেখাকেও দৃশ্যমান করে — ফলে instance-কে volatile করলে double-checked locking ঠিকমতো কাজ করে। final field-কে বিশেষ নিশ্চয়তা দেওয়া হয়, যাতে immutable object সত্যিই নিরাপদ হয়। আজ সবচেয়ে সহজ lazy singleton হলো একটি static nested holder class বা একটি enum — JVM-এর class loading নিজেই thread-safe.

মূল উপলব্ধি: “এক thread যা লিখল, অন্যটি তা দেখবে” — এই সহজ ধারণাটিও প্রমাণসহ সংজ্ঞায়িত করতে বিশ্বের সেরা বিশেষজ্ঞদের কয়েক বছর লেগেছে; তাই নিজের চতুর তালা-ছাড়া কৌশলের চেয়ে প্রমাণিত হাতিয়ার (synchronized, volatile, java.util.concurrent) বিশ্বাসযোগ্য।

প্র ০৩Mutual exclusion আর deadlock-এর ধারণা ১৯৬০-এর দশকে Edsger Dijkstra-র কাজ থেকে এসেছে। “dining philosophers” সমস্যা কী শেখায়?

প্রথম সমাধান। ১৯৬৫ সালে ডাচ কম্পিউটার বিজ্ঞানী Edsger W. Dijkstra Communications of the ACM-এ একটি এক পাতার প্রবন্ধ প্রকাশ করেন — “Solution of a Problem in Concurrent Programming Control”. প্রশ্নটি ছিল: N-টি প্রক্রিয়া, প্রত্যেকের একটি critical section, কোনো বিশেষ হার্ডওয়্যার ছাড়া শুধু সাধারণ মেমরি পড়া-লেখা দিয়ে কীভাবে নিশ্চিত করা যায় যে এক সময়ে একজনই ভেতরে ঢুকবে, আর কেউ অকারণে চিরকাল আটকে থাকবে না? দুজনের জন্য সমাধান আগে দিয়েছিলেন তাঁর সহকর্মী গণিতবিদ Th. J. Dekker; Dijkstra সেটিকে N জনে বিস্তৃত করেন। প্রায় একই সময়ে তিনি semaphore-এর ধারণা দেন — আজকের তালা ও Semaphore class-এর পূর্বপুরুষ।

Dining philosophers. একই সময়ে Dijkstra ছাত্রদের জন্য একটি সমস্যা দেন — কয়েকটি কম্পিউটার কয়েকটি টেপ-ড্রাইভ নিয়ে প্রতিযোগিতা করছে। পরে Tony Hoare সেটিকে আজকের পরিচিত গল্পে রূপ দেন: পাঁচজন দার্শনিক একটি গোল টেবিলে, প্রত্যেকের দুই পাশে একটি করে কাঁটাচামচ, খেতে লাগে দুটোই। প্রত্যেকে যদি আগে বাম কাঁটা তোলেন, তারপর ডানটির অপেক্ষা করেন — সবাই একটি করে ধরে চিরকাল অপেক্ষায়। এটা ঠিক অংশ ৭-এর হিসাব-স্থানান্তরের deadlock, শুধু দুজনের বদলে পাঁচজন।

কী শেখায়। ১৯৭১ সালে Coffman ও তাঁর সহকর্মীরা deadlock-এর চারটি প্রয়োজনীয় শর্ত লেখেন: (১) mutual exclusion — সম্পদ একজনই ধরতে পারে; (২) hold and wait — একটি ধরে আরেকটির অপেক্ষা; (৩) no preemption — জোর করে কেড়ে নেওয়া যায় না; (৪) circular wait — অপেক্ষার একটি চক্র। চারটির যেকোনো একটি ভাঙলেই deadlock অসম্ভব। দার্শনিকদের ক্লাসিক সমাধান — কাঁটাগুলোর নম্বর দাও, সবাই আগে ছোট নম্বরেরটি তুলবে — চতুর্থ শর্ত ভাঙে; এটাই lock ordering. tryLock-এ সময় শেষ হলে প্রথম তালা ছেড়ে দেওয়া দ্বিতীয় শর্ত ভাঙে। আর সমস্যাটি আরেকটি বিপদও দেখায়: সমাধানে যদি একজন দার্শনিক বারবার সুযোগ না পান, সেটা deadlock নয়, কিন্তু starvation — তিনি না খেয়ে থাকেন।

মূল উপলব্ধি: আজকের Java-র synchronized, ReentrantLock আর lock ordering নতুন আবিষ্কার নয় — ষাট বছর আগের গাণিতিক যুক্তির প্রয়োগ; deadlock-এর চার শর্ত মনে রাখলে প্রতিটি নকশায় জিজ্ঞেস করতে পারবেন, কোন শর্তটি আমি ভাঙছি?

প্র ০৪আপনার কম্পিউটারে ৮টি core. কাজটা ৮টি thread-এ ভাগ করলে কি ৮ গুণ দ্রুত হবে? Amdahl’s law কী বলে?

Amdahl-এর যুক্তি। ১৯৬৭ সালে IBM-এর কম্পিউটার স্থপতি Gene Amdahl একটি সম্মেলনে একটি সহজ কিন্তু কঠিন সত্য তুলে ধরেন। যেকোনো কাজের একটি অংশ ভাগ করা যায় না — শুরুতে ডেটা পড়া, শেষে ফল মেলানো, একটি ভাগাভাগি করা তালার ভেতরের কাজ। ধরুন কাজের ভাগযোগ্য অংশ p, আর core n-টি। তাহলে সর্বোচ্চ গতিবৃদ্ধি = ১ / ((১ − p) + p/n). যদি কাজের ৯০% ভাগ করা যায় (p = 0.9), তাহলে ৮টি core-এ গতিবৃদ্ধি প্রায় ৪.৭ গুণ, ৮ নয়। আর core অসীম হলেও সর্বোচ্চ ১০ গুণ — কারণ ভাগ-না-হওয়া ১০% একাই সময় নেয়। এই সীমাকে বলে Amdahl’s law.

Java-তে এর অর্থ। প্রতিটি synchronized block একটি ভাগ-না-হওয়া অংশ: তার ভেতরে এক সময়ে একটিই thread. অংশ ৪-এর চালানো প্রোগ্রামে দুই thread এক লাখ করে জমা দিয়েছে, কিন্তু প্রতিটি জমা তালার ভেতরে — অর্থাৎ কাজটা কার্যত পালা করে চলেছে, আর তালা হাতবদলের খরচ মিলিয়ে হয়তো এক thread-এর চেয়েও ধীর। এর সাথে যোগ হয় আরও খরচ: thread তৈরি আর context switch; একই তালা নিয়ে কাড়াকাড়ি (contention), যেখানে thread-রা বেশিরভাগ সময় অপেক্ষায়; আর হার্ডওয়্যারের “false sharing” — দুটি thread আলাদা ভ্যারিয়েবল লিখলেও সেগুলো একই cache line-এ থাকলে core-গুলো বারবার cache বাতিল করে। এজন্যই LongAdder ভেতরে আলাদা ঘর রাখে।

বিপরীত দৃষ্টিভঙ্গি। ১৯৮৮ সালে John Gustafson যুক্তি দেন যে core বাড়লে আমরা সাধারণত বড় কাজ করি — বেশি ডেটা, বেশি গ্রাহক — তখন ভাগযোগ্য অংশ বাড়ে, লাভও বাড়ে। স্বাধীন request-এর web server ঠিক এমন।

ব্যবহারিক শিক্ষা। (১) আগে মাপুন — profiler দিয়ে দেখুন সময় কোথায় যাচ্ছে। (২) ভাগাভাগি করা অবস্থা কমান: প্রতিটি thread নিজের local ফল বানাক, শেষে একবার মেলান। (৩) critical section ছোট রাখুন। (৪) CPU-ভারী কাজে core-এর চেয়ে বেশি thread সাধারণত লাভ দেয় না; অপেক্ষা-ভারী কাজে (নেটওয়ার্ক) অনেক বেশি thread লাভজনক — সেটাই পর্ব ৪২-এর virtual thread-এর মূল যুক্তি।

মূল উপলব্ধি: গতি নির্ধারণ করে কাজের ভাগ-না-হওয়া অংশ, core-এর সংখ্যা নয়; তাই concurrency-র নকশায় আসল প্রশ্ন “কয়টি thread?” নয়, “thread-রা কতটা কম ভাগাভাগি আর অপেক্ষা করে?”

প্র ০৫race condition কি শুধু ভুল সংখ্যা ছাপে? ১৯৮০-এর দশকের Therac-25 দুর্ঘটনা থেকে কী শেখার আছে?

যন্ত্রটি। Therac-25 ছিল ক্যানসারের চিকিৎসায় ব্যবহৃত একটি রেডিয়েশন থেরাপি যন্ত্র, Atomic Energy of Canada Limited (AECL)-এর তৈরি। ১৯৮৫ থেকে ১৯৮৭ সালের মধ্যে যুক্তরাষ্ট্র আর কানাডার কয়েকটি হাসপাতালে ছয়টি জানা দুর্ঘটনায় রোগীরা নির্ধারিত মাত্রার বহু গুণ বেশি রেডিয়েশন পান; কয়েকজন মারা যান, অন্যরা গুরুতর আহত হন। পরে Nancy Leveson আর Clark Turner বিস্তারিত তদন্ত প্রকাশ করেন (IEEE Computer, ১৯৯৩), যা আজ software নিরাপত্তার পাঠ্যসূচিতে ক্লাসিক।

Race-টি। যন্ত্রটির software একসাথে কয়েকটি কাজ সামলাত — অপারেটরের কীবোর্ড-ইনপুট নেওয়া আর যন্ত্রের যান্ত্রিক অংশ (beam-এর ধরন, ধাতব পাত) প্রস্তুত করা। এই কাজগুলো ভাগাভাগি করা ভ্যারিয়েবল দিয়ে যোগাযোগ করত, সঠিক সমন্বয় ছাড়া। একজন অভিজ্ঞ, দ্রুত অপারেটর ভুল করে একটি মোড লিখে কয়েক সেকেন্ডের মধ্যে সেটা সংশোধন করলে, স্ক্রিনে সংশোধিত তথ্য দেখাত, কিন্তু যন্ত্রের প্রস্তুতির কাজটি সেই বদল “দেখেনি” — এই পর্বের ভাষায় অনেকটা একটি visibility আর check-then-act সমস্যা। ফল: উচ্চ-শক্তির beam, অথচ সেটা নিয়ন্ত্রণের অংশ সঠিক জায়গায় নেই। আরেকটি দুর্ঘটনায় একটি এক-byte-এর counter উপচে শূন্যে ফিরে যাওয়ায় একটি নিরাপত্তা-যাচাই বাদ পড়ে যেত। ভুলগুলো ঘটত শুধু নির্দিষ্ট সময়ক্রমে — তাই পরীক্ষায় ধরা পড়েনি, আর প্রথমদিকে নির্মাতা অভিযোগ পুনরুৎপাদন করতে পারেননি।

গভীর কারণ। আগের মডেলগুলোতে হার্ডওয়্যারের নিরাপত্তা-তালা (interlock) ছিল, যা software-এর এমন ভুল থাকলেও বিপদ ঠেকাত। Therac-25-এ সেগুলো সরিয়ে পুরো ভরসা software-এর ওপর রাখা হয়। অর্থাৎ একটি concurrency bug একা নয়, পুরো ব্যবস্থার নকশার দুর্বলতার সাথে মিলে দুর্ঘটনা ঘটায়। পরেও এমন ঘটনা ঘটেছে — যেমন ২০০৩ সালের উত্তর আমেরিকার বিশাল বিদ্যুৎ বিপর্যয়ের তদন্তে একটি alarm software-এর race condition-কে অন্যতম কারণ হিসেবে চিহ্নিত করা হয়, যার ফলে নিয়ন্ত্রণকক্ষ সময়মতো সতর্কবার্তা পায়নি।

আমাদের জন্য শিক্ষা। (১) “টেস্টে ঠিক চলেছে” concurrency কোডের নিরাপত্তার প্রমাণ নয়; যুক্তি দিয়ে দেখাতে হয় প্রতিটি ভাগাভাগি করা ডেটা কীভাবে সুরক্ষিত। (২) সরল নকশা — immutable ডেটা, কম ভাগাভাগি, প্রমাণিত library — জটিল চতুরতার চেয়ে নিরাপদ। (৩) গুরুত্বপূর্ণ ব্যবস্থায় software-এর বাইরে আরেক স্তরের সুরক্ষা রাখুন।

মূল উপলব্ধি: Race condition সময়ক্রমের ওপর নির্ভর করে বলে বিরল, পুনরুৎপাদন-অযোগ্য আর পরীক্ষায় অদৃশ্য — আর ঠিক সেজন্যই বাস্তব জগতে তার পরিণতি একটি ভুল সংখ্যার চেয়ে অনেক ভয়াবহ হতে পারে।

অনুশীলন

বেশিরভাগ কাজে নতুন কোড লিখতে হবে না — কোন ডেটা ভাগ হচ্ছে আর কে কখন কী দেখছে, তা নিয়ে ভাবাই আসল কাজ।

  1. আউটপুট অনুমান করুন: Thread t = new Thread(() -> System.out.println(Thread.currentThread().getName()), "T1"); t.run(); t.start(); t.join(); t.run(); — কী কী ছাপা হবে, কোন ক্রমে? শেষের t.run()-এ কোনো exception হবে কি?

    তিনটি লাইন: main, T1, main. প্রথম run() সাধারণ method call, তাই main thread-এ চলে। start() নতুন thread “T1” তৈরি করে কাজটা চালায়, আর join() তার শেষ হওয়ার অপেক্ষা করে — তাই ক্রম নির্দিষ্ট। শেষের run()-এ কোনো exception নেই: thread TERMINATED হলেও run() একটি সাধারণ method, আবার main-এ চলে। শুধু দ্বিতীয়বার start() ডাকলে IllegalThreadStateException আসত।

  2. Bug খুঁজুন ও সারান: একটি কনসার্টের অ্যাপে class Event { private final Set<String> booked = ConcurrentHashMap.newKeySet(); private int left = 100; boolean book(String u) { if (left > 0 && !booked.contains(u)) { booked.add(u); left--; return true; } return false; } } — Set-টি thread-safe, তবু টিকিট ১০০-র বেশি বিক্রি হয় আর একজন দুটি পায়। কেন? ঠিক রূপ কী?

    দুটি check-then-act: left > 0 দেখা আর left---এর মাঝখানে, আর contains আর add-এর মাঝখানে অন্য thread ঢুকতে পারে। তার ওপর left-- নিজেই read-modify-write. Set thread-safe হওয়ায় প্রতিটি আলাদা method নিরাপদ, কিন্তু কয়েকটি method মিলে তৈরি পুরো সিদ্ধান্তটি নয়। সরল সমাধান: synchronized boolean book(String u) { … } — এখন যাচাই আর কাজ একসাথে, এক সময়ে একজন (তখন Set-টি সাধারণ HashSet হলেও চলবে, কারণ সব ব্যবহার তালার ভেতরে)। শুধু Set-এর জন্য বিকল্প: booked.add(u) নিজেই বলে দেয় নতুন কি না — if (!booked.add(u)) return false; — কিন্তু left-এর সাথে মিলিয়ে সিদ্ধান্তের জন্য তালাই সবচেয়ে পরিষ্কার।

  3. Deadlock বিশ্লেষণ: একটি লাইব্রেরি-সিস্টেমে lend(book, member) আগে book-এর তালা নেয়, তারপর member-এর; আর returnAll(member) আগে member-এর তালা নেয়, তারপর তার প্রতিটি book-এর। Deadlock কীভাবে হতে পারে? দুটি সমাধান বলুন।

    Thread A lend(b1, m1)-এ b1-এর তালা ধরল; একই সময়ে Thread B returnAll(m1)-এ m1-এর তালা ধরল আর এখন b1-এর তালা চায়। A এখন m1 চায় — চক্র পূর্ণ, deadlock. সমাধান ১ — lock ordering: সারা সিস্টেমে একটি নিয়ম, যেমন “সবসময় আগে member, তারপর book”; lend-কে বদলে আগে member-এর তালা নিতে হবে। সমাধান ২ — ReentrantLock.tryLock(সময়সীমা): দ্বিতীয় তালা নির্দিষ্ট সময়ে না পেলে প্রথমটি ছেড়ে, একটু অপেক্ষা করে আবার চেষ্টা। আরেকটি নকশাগত বিকল্প: তালার সংখ্যা কমানো — যেমন পুরো লেনদেন একটি তালায় বা ডেটাবেস transaction-এ।

  4. সঠিক হাতিয়ার বাছুন: প্রতিটির জন্য বলুন কী ব্যবহার করবেন আর কেন। (ক) একটি ওয়েবসাইটের মোট পেজ-ভিউ, শত শত thread থেকে বাড়ে, মিনিটে একবার পড়া হয়। (খ) অ্যাপের সেটিংস — হাজারবার পড়া হয়, দিনে একবার বদলায়। (গ) একটি background sync thread-কে অ্যাপ বন্ধের সময় থামানো, যে বেশিরভাগ সময় sleep-এ থাকে। (ঘ) কয়েকটি thread মিলে প্রতিটি জেলার অর্ডার গোনা।

    (ক) LongAdder — অনেক thread-এর কাড়াকাড়িতে AtomicLong-এর চেয়ে ভালো, আর মিনিটে একবার sum() পড়াই যথেষ্ট। (খ) একটি immutable Settings object (যেমন record), আর private volatile Settings current; বদলালে নতুন object বানিয়ে reference বদলান — পাঠকদের কোনো তালা লাগে না। (গ) interrupt() — thread sleep-এ থাকলে volatile flag সে দেখবে শুধু ঘুম ভাঙার পর, কিন্তু interrupt তাকে সাথে সাথে জাগিয়ে InterruptedException দেয়। (ঘ) ConcurrentHashMap<String, Integer> আর merge(district, 1, Integer::sum); অথবা আরও ভালো, প্রতিটি thread নিজের local Map বানাক, শেষে join-এর পর মেলান — ভাগাভাগিই নেই।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমJava-তে একটি thread তৈরি থেকে main-এর join() ফেরত আসা পর্যন্ত ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. 🆕 new Thread(task) — object তৈরি, অবস্থা NEW
  2. ▶️ start() — নতুন thread তৈরি, অবস্থা RUNNABLE
  3. ⚙️ নতুন thread-এ task.run() চলে (মাঝে তালার জন্য BLOCKED হতে পারে)
  4. 🏁 run() শেষ — অবস্থা TERMINATED
  5. ✅ main-এর join() ফেরত আসে; thread-এর সব লেখা এখন main দেখতে পায়

Java-তে Thread t = new Thread(job); তারপর t.start()-এর বদলে t.run() লিখলে কী হয়?

  • compile error হয়
  • কাজটা ডাকা thread-এই চলে, নতুন thread হয় না
  • নতুন thread-এ চলে, start()-এর মতোই
  • IllegalThreadStateException ছোড়ে

দুটি Java thread একটি ভাগাভাগি করা int field-এ synchronized ছাড়া এক লাখ করে count++ করে, তারপর দুটিতে join() করে ফল ছাপা হয়। ফল কী?

  • সবসময় 200000
  • সবসময় 100000
  • 200000 বা তার কম — প্রতিবার আলাদা হতে পারে
  • ConcurrentModificationException

Java-তে একটি field-কে volatile ঘোষণা করলে কোনটি নিশ্চিত হয়?

  • এক thread-এর লেখা অন্য thread দেখবে, কিন্তু count++ অবিভাজ্য হয় না
  • count++ অবিভাজ্য হয়ে যায়
  • এক সময়ে একটিই thread field-টি ছুঁতে পারে
  • field-টি আর বদলানো যায় না

একটি Java thread একটি synchronized block-এ ঢুকতে চায়, কিন্তু সেই object-এর তালা অন্য thread-এর হাতে। এই মুহূর্তে প্রথম thread-এর Thread.State কী?

  • WAITING
  • TIMED_WAITING
  • RUNNABLE
  • BLOCKED

Thread A আগে হিসাব x তারপর y-এর তালা নেয়, আর Thread B আগে y তারপর x. Java-তে deadlock এড়ানোর সবচেয়ে সরল ও নির্ভরযোগ্য উপায় কোনটি?

  • দুই হিসাবের field volatile করা
  • সব জায়গায় একটি নির্দিষ্ট ক্রমে তালা নেওয়া, যেমন ছোট id আগে
  • দুই তালার মাঝে Thread.sleep() দেওয়া
  • আরও বেশি thread চালানো

Java-তে একটি object-এর তালা না ধরে সরাসরি obj.wait() ডাকলে কী হয়?

  • thread নিঃশব্দে চিরকাল অপেক্ষা করে
  • compile error
  • IllegalMonitorStateException ছোড়ে
  • InterruptedException ছোড়ে

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. Thread = একই process-এ একটি স্বাধীন কাজের পথ: heap ভাগাভাগি, stack নিজস্ব; চালু করুন start() দিয়ে (run() নয়), ফলের জন্য join(); ছয় অবস্থা NEW → RUNNABLE → (BLOCKED / WAITING / TIMED_WAITING) → TERMINATED.
  2. Race condition: ভাগাভাগি করা, বদলানো যায় এমন ডেটা + সমন্বয় নেই = ফল সময়ক্রমের ওপর নির্ভর। count++ তিন ধাপ (lost update); check-then-act (শেষ সিট, যৌথ হিসাব)। টেস্টে প্রায়ই ধরা পড়ে না।
  3. synchronized: প্রতিটি object-এর intrinsic lock; mutual exclusion আর দৃশ্যমানতা দুটোই। একই ডেটার জন্য সবসময় একই private final তালা; critical section ছোট।
  4. volatile, atomic, lock: volatile শুধু দৃশ্যমানতা (stop flag); AtomicInteger/LongAdder তালা ছাড়া অবিভাজ্য গণনা (CAS); ReentrantLock-এ tryLock সময়সীমা, unlock() সবসময় finally-তে; থামাতে interrupt().
  5. Deadlock আর নিরাপদ নকশা: একাধিক তালা সবসময় একই ক্রমে; wait/notify-এর বদলে BlockingQueue; ConcurrentHashMap.merge; আর সবচেয়ে সহজ — immutable ডেটা আর thread-প্রতি local ফল।

পরের পর্বে: Executor আর CompletableFuture. এই পর্বে প্রতিটি কাজের জন্য হাতে new Thread বানিয়েছেন আর হাতে join করেছেন — বাস্তবে হাজার কাজের জন্য হাজার thread বানানো ব্যয়বহুল। এবার শিখবেন thread pool কীভাবে thread পুনর্ব্যবহার করে, Future দিয়ে কাজের ফল ফেরত পাওয়া, CountDownLatch, আর CompletableFuture দিয়ে একটার পর একটা অ্যাসিনক্রোনাস কাজ জোড়া।

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

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান