থ্রেড পুল বাড়ালেই সমাধান হয় না

Phase 1-এ তুমি দেখেছ প্রতিটি প্ল্যাটফর্ম থ্রেড (platform thread) সরাসরি একটি OS থ্রেডের সাথে ১:১ ম্যাপ হয় — এবং প্রতিটির জন্য মেগাবাইট-স্কেল স্ট্যাক মেমরি বরাদ্দ থাকে, কার্নেল-লেভেল শিডিউলিং এন্ট্রি থাকে, আর কনটেক্সট সুইচের একটা বাস্তব খরচ থাকে। যতক্ষণ থ্রেডের সংখ্যা কয়েকশো'র মধ্যে থাকে, ততক্ষণ এই খরচ উপেক্ষণীয়। কিন্তু ধরো তোমার সার্ভিস প্রতি সেকেন্ডে দশ হাজার রিকোয়েস্ট হ্যান্ডল করছে, আর প্রতিটি রিকোয়েস্ট গড়ে ৫০ মিলিসেকেন্ড ধরে একটি ডাউনস্ট্রিম ডাটাবেস বা HTTP কলের জন্য ব্লক হয়ে থাকে। থ্রেড-পার-রিকোয়েস্ট মডেলে এর মানে যেকোনো মুহূর্তে প্রায় ৫০০ থ্রেড ব্লকড অবস্থায় বসে থাকবে — শুধু অপেক্ষা করার জন্য।

এখন প্রশ্ন আসে: থ্রেড পুল কি ৫০,০০০ বা ৫,০০,০০০ থ্রেডে স্কেল করতে পারবে? বাস্তবে না। প্রতিটি প্ল্যাটফর্ম থ্রেডের ডিফল্ট স্ট্যাক সাইজ প্রায় ৫১২ KB থেকে ১ MB, তাই এক লাখ থ্রেড মানে কয়েক গিগাবাইট শুধু স্ট্যাকের জন্য। OS শিডিউলার হাজার হাজার থ্রেডের মধ্যে কনটেক্সট সুইচ করতে করতেই CPU সময়ের একটা বড় অংশ খরচ করে ফেলে (Phase 1-এর "Context Switching" আর্টিকেলে যা তুমি দেখেছ)। তাই বাস্তবে প্রোডাকশন সার্ভিসগুলো থ্রেড পুলের সাইজ কয়েকশো'তে সীমাবদ্ধ রাখে — আর যখন সব থ্রেড ব্যস্ত থাকে, নতুন রিকোয়েস্ট সারিতে অপেক্ষা করে, ল্যাটেন্সি বাড়ে, থ্রুপুট কমে।

সমস্যাটা থ্রেড না, মডেল

লক্ষ্য করো — CPU কিন্তু ব্যস্ত নেই। থ্রেডগুলো I/O-এর জন্য অপেক্ষা করছে, CPU সাইকেল খরচ করছে না। আসল সমস্যা হলো: আমরা একটি দামি, OS-ম্যানেজড রিসোর্সকে (থ্রেড) এমন একটা কাজে ব্যবহার করছি যেটার আসলে দরকার শুধু "কোথায় আমি থামলাম" মনে রাখা — অর্থাৎ ব্লকিং কলের পরে ফিরে আসার একটা জায়গা। এই জন্যই Node.js-এর মতো ইভেন্ট-লুপ ভিত্তিক মডেল বা রিঅ্যাক্টিভ প্রোগ্রামিং (Project Reactor, RxJava) জনপ্রিয় হয়ে উঠেছিল — কিন্তু সেগুলোর দাম হলো কোডের প্রতিটি ধাপ .then(), .map(), .flatMap()-এ ভেঙে ফেলা, স্ট্যাক ট্রেস হারিয়ে ফেলা, ডিবাগিং কঠিন করে তোলা।

ভার্চুয়াল থ্রেড: একই সরল কোড, ভিন্ন রানটাইম মডেল

Java 21-এ (JEP 444) ভার্চুয়াল থ্রেড স্ট্যান্ডার্ড হয়েছে। এটা একটা নতুন প্রোগ্রামিং মডেল নয় — বরং Thread-এর একটা নতুন implementation, যেটা JVM ম্যানেজ করে, OS নয়। হাজার হাজার ভার্চুয়াল থ্রেড অল্প কয়েকটা ক্যারিয়ার থ্রেডের (carrier thread, যেগুলো আসল প্ল্যাটফর্ম থ্রেড) উপর মাল্টিপ্লেক্স হয়ে চলে — অনেকটা কিভাবে একটা OS কয়েকটা CPU কোরের উপর হাজার হাজার প্রসেস চালায় তার মতো।

// আগে: প্রতিটি রিকোয়েস্টের জন্য একটি ব্যয়বহুল প্ল্যাটফর্ম থ্রেড
ExecutorService pool = Executors.newFixedThreadPool(200);

// এখন: প্রতি টাস্কের জন্য একটি নতুন, সস্তা ভার্চুয়াল থ্রেড
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

for (int i = 0; i < 100_000; i++) {
    executor.submit(() -> {
        String result = callDownstreamService(); // ব্লকিং কল, কিন্তু সমস্যা নেই
        process(result);
    });
}

এই লুপে এক লাখ টাস্ক সাবমিট করলে এক লাখ প্ল্যাটফর্ম থ্রেড তৈরি হয় না — বরং এক লাখ হালকা-পাতলা ভার্চুয়াল থ্রেড তৈরি হয়, যেগুলো দরকার হলেই একটা ক্যারিয়ার থ্রেডে "মাউন্ট" হয়, ব্লক হলে "আনমাউন্ট" হয়ে যায়, আর ক্যারিয়ার থ্রেডটা অন্য কোনো ভার্চুয়াল থ্রেডের কাজ চালাতে মুক্ত হয়ে যায়। কোডটা দেখতে একদম আগের ব্লকিং কোডের মতোই — try/catch, লুপ, স্ট্যাক ট্রেস, ডিবাগার — সবকিছু আগের মতোই কাজ করে।

কেন এটা গুরুত্বপূর্ণ

ভার্চুয়াল থ্রেড CPU-বাউন্ড কাজকে দ্রুত করে না — কোরের সংখ্যা তো বাড়ছে না। এর আসল সুবিধা হলো I/O-বাউন্ড, উচ্চ-concurrency অ্যাপ্লিকেশনে, যেখানে থ্রেড বেশিরভাগ সময় অপেক্ষা করে কাটায়। এমন অ্যাপ্লিকেশনে ভার্চুয়াল থ্রেড ব্যবহার করলে থ্রেড-পুল সাইজিং নিয়ে জটিল হিসাব-নিকাশ, কানেকশন পুল টিউনিং, রিঅ্যাক্টিভ স্ট্রিম চেইন — এসবের প্রয়োজনীয়তা অনেকটাই কমে যায়। Phase 3 আর Phase 4-এ তুমি যা শিখেছ — ExecutorService, CompletableFuture — সেই ভিত্তির উপর দাঁড়িয়েই এই ফেজে আমরা দেখব কিভাবে ভার্চুয়াল থ্রেড তৈরি করতে হয়, কোথায় এগুলো ভেঙে পড়ে (pinning), আর কিভাবে স্ট্রাকচার্ড কনকারেন্সি দিয়ে এই মডেলটাকে আরও নিরাপদ করা যায়।

Share