যেখানে কাজ চলবে তা নিয়ন্ত্রণ করা: এক্সিকিউটর এবং অ্যাসিঙ্ক Variant
প্রতিটি মেথডের একটি নীরব যমজ আছে
CompletableFuture API-টি ভালোভাবে লক্ষ করলে দেখবেন প্রতিটি কলব্যাক মেথড তিনটি রূপে আসে: thenApply, thenApplyAsync(fn), এবং thenApplyAsync(fn, executor)। একই প্যাটার্ন thenAccept, thenRun, thenCompose এবং বাকিগুলোর জন্যও প্রযোজ্য। এতদিন পর্যন্ত এই পর্বে কেবল সাধারণ রূপটি ব্যবহার করা হয়েছে, যা নিঃশব্দে সেই থ্রেডে চলে যে থ্রেড পূর্ববর্তী ধাপটি সম্পন্ন করেছিল — কখনও কলিং থ্রেড, কখনও কমন-পুলের কোনো ওয়ার্কার। Async variant গুলো exists করে যাতে আপনি সেই পছন্দের উপর স্পষ্ট নিয়ন্ত্রণ পেতে পারেন।
ডিফল্ট: কমন পুল
যখন আপনি কোনো executor ছাড়া supplyAsync(supplier) কল করেন, কাজটি ForkJoinPool.commonPool()-এ চলে। এটি একটি JVM-ব্যাপী পুল, যা প্রসেসের প্রতিটি CompletableFuture (এবং প্যারালাল স্ট্রিম) দ্বারা শেয়ার করা হয়। এর সাইজ ডিফল্টভাবে available processor-এর সংখ্যা minus এক। ছোট, CPU-নির্ভর কাজের জন্য এটি ভালো ডিফল্ট, কিন্তু কোনো কলব্যাক ব্লক করলেই এটি দায় হয়ে দাঁড়ায় — যেমন JDBC কানেকশনের জন্য অপেক্ষা, সিনক্রোনাস HTTP কল, বা Thread.sleep। একটি ব্লক হওয়া কমন-পুল থ্রেড কেবল আপনার পাইপলাইনের জন্যই idle নয়; এটি JVM-এর অন্য প্রতিটি অংশের কাছেও unavailable, যারা একই সময়ে CompletableFuture বা প্যারালাল স্ট্রিম ব্যবহার করছে।
// ঝুঁকিপূর্ণ: একটি ধীর JDBC কল কমন-পুলের একটি থ্রেড দখল করে রাখে
CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() -> jdbcLookupUser(id));
উপরের কোডে supplyAsync কোনো executor ছাড়া ব্যবহৃত হয়েছে, তাই jdbcLookupUser(id) কলটি কমন পুলে চলবে। JDBC কলটি ব্লকিং প্রকৃতির হওয়ায় এটি কমন পুলের একটি থ্রেড দীর্ঘ সময় ধরে আটকে রাখবে, যা অন্য কাজের জন্য ক্ষতিকর।
নিজস্ব Executor সরবরাহ করা
দুই-আর্গুমেন্টের *Async overload গুলো একটি Executor গ্রহণ করে, যার মাধ্যমে আপনি কাজকে এমন একটি পুলে পাঠাতে পারেন যা আপনি নিজে নিয়ন্ত্রণ করেন এবং কাজের ধরন অনুযায়ী সঠিক সাইজ নির্ধারণ করেন — যেমনটা আপনি আগের পর্বে ExecutorService দিয়ে দেখেছেন।
ExecutorService dbPool = Executors.newFixedThreadPool(20);
CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() -> jdbcLookupUser(id), dbPool);
এখানে ২০টি থ্রেডের একটি আলাদা পুল তৈরি করে supplyAsync-এ পাঠানো হয়েছে। এর ফলে JDBC কলটি কমন পুলের বদলে dbPool-এ চলবে এবং কমন পুল অন্য কাজের জন্য মুক্ত থাকবে।
বাস্তব অ্যাপ্লিকেশনে একটি যুক্তিসঙ্গত প্যাটার্ন হলো ভিন্ন ধরনের ব্লকিং কাজের জন্য আলাদা আলাদা পুল রাখা — একটি ডেটাবেস কলের জন্য, আরেকটি আউটগোয়িং HTTP-এর জন্য — এবং প্রতিটি supplyAsync বা thenApplyAsync কলে উপযুক্ত পুলটি পাঠানো। এতে কমন পুল ছোট, নন-ব্লকিং ট্রান্সফরমেশনের জন্য মুক্ত থাকে।
সাধারণ then* মেথড আসলে কোথায় চলে
সাধারণ (নন-Async) then* মেথডগুলোর নিজস্ব কোনো থ্রেড নীতি নেই — এরা সেই থ্রেডে চলে যে থ্রেড পূর্ববর্তী ধাপটি সম্পন্ন করে। যদি পূর্ববর্তী ধাপটি already complete থাকে যখন আপনি কলব্যাক যুক্ত করেন, তাহলে এটি immediately সেই থ্রেডে চলে যে থ্রেড thenApply কল করেছে। যদি এখনও complete না হয়, তাহলে এটি পরে সেই থ্রেডে চলে যে থ্রেড পূর্ববর্তী ধাপটি শেষ করবে, যা প্রায়শই (কিন্তু নিশ্চিত নয়) সেই ধাপের ব্যবহৃত executor-এর কোনো থ্রেড হয়।
CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() -> jdbcLookupUser(id), dbPool);
// লুকআপ শেষ হলে dbPool-এর একটি থ্রেডে চলে, অথবা already done থাকলে immediately চলে
CompletableFuture<String> nameFuture = userFuture.thenApply(User::getName);
এই উদাহরণে thenApply-এর কলব্যাকটি dbPool-এর কোনো থ্রেডে চলবে যদি userFuture তখনও সম্পন্ন না হয়ে থাকে। যদি userFuture ইতোমধ্যে সম্পন্ন হয়ে যায়, তাহলে thenApply কল করার সময়ই কলব্যাকটি current থ্রেডে চলে যাবে।
ছোট, নন-ব্লকিং ট্রান্সফরমেশনের জন্য এটি ঠিক আছে, যেমন User::getName।