সবকিছু একত্রিত করা: ফিউচার, টাস্ক, এবং থ্রেড
যেমনটি আমরা চ্যাপ্টার ১৬-এ দেখেছি, থ্রেডগুলি কনকারেন্সির (concurrency) জন্য একটি পদ্ধতি সরবরাহ করে। আমরা এই অধ্যায়ে আরেকটি পদ্ধতি দেখেছি: ফিউচার এবং স্ট্রীমের সাথে অ্যাসিঙ্ক (async) ব্যবহার করা। আপনি যদি ভাবেন যে কোনটির উপর কোন পদ্ধতি বেছে নেবেন, উত্তর হলো: এটি নির্ভর করে! এবং অনেক ক্ষেত্রে, পছন্দটি থ্রেড অথবা অ্যাসিঙ্ক নয় বরং থ্রেড এবং অ্যাসিঙ্ক।
অনেক অপারেটিং সিস্টেম এখন কয়েক দশক ধরে থ্রেড-ভিত্তিক কনকারেন্সি মডেল সরবরাহ করেছে, এবং ফলস্বরূপ অনেক প্রোগ্রামিং ভাষা সেগুলি সমর্থন করে। যাইহোক, এই মডেলগুলি ট্রেড-অফ (trade-off) ছাড়া নয়। অনেক অপারেটিং সিস্টেমে, তারা প্রতিটি থ্রেডের জন্য বেশ কিছুটা মেমরি ব্যবহার করে, এবং সেগুলি শুরু এবং বন্ধ করার জন্য কিছু ওভারহেড নিয়ে আসে। থ্রেডগুলি কেবল তখনই একটি বিকল্প যখন আপনার অপারেটিং সিস্টেম এবং হার্ডওয়্যার সেগুলি সমর্থন করে। মূলধারার ডেস্কটপ এবং মোবাইল কম্পিউটারের মতো নয়, কিছু এমবেডেড সিস্টেমের কোনো ওএস (OS) নেই, তাই তাদের থ্রেডও নেই।
অ্যাসিঙ্ক মডেল একটি ভিন্ন—এবং শেষ পর্যন্ত পরিপূরক—ট্রেড-অফের সেট সরবরাহ করে। অ্যাসিঙ্ক মডেলে, কনকারেন্ট অপারেশনগুলির নিজস্ব থ্রেডের প্রয়োজন হয় না। পরিবর্তে, তারা টাস্কগুলিতে চলতে পারে, যেমনটি আমরা স্ট্রীম বিভাগে একটি সিঙ্ক্রোনাস ফাংশন থেকে কাজ শুরু করার জন্য trpl::spawn_task
ব্যবহার করেছি। একটি টাস্ক একটি থ্রেডের অনুরূপ, কিন্তু অপারেটিং সিস্টেম দ্বারা পরিচালিত হওয়ার পরিবর্তে, এটি লাইব্রেরি-স্তরের কোড দ্বারা পরিচালিত হয়: রানটাইম (runtime)।
পূর্ববর্তী বিভাগে, আমরা দেখেছি যে আমরা একটি অ্যাসিঙ্ক চ্যানেল ব্যবহার করে এবং একটি অ্যাসিঙ্ক টাস্ক তৈরি করে একটি স্ট্রীম তৈরি করতে পারি যা আমরা সিঙ্ক্রোনাস কোড থেকে কল করতে পারি। আমরা একটি থ্রেডের সাথে ঠিক একই কাজ করতে পারি। লিস্টিং ১৭-৪০-এ, আমরা trpl::spawn_task
এবং trpl::sleep
ব্যবহার করেছি। লিস্টিং ১৭-৪১-এ, আমরা get_intervals
ফাংশনে সেগুলিকে স্ট্যান্ডার্ড লাইব্রেরি থেকে thread::spawn
এবং thread::sleep
API দিয়ে প্রতিস্থাপন করি।
extern crate trpl; // required for mdbook test use std::{pin::pin, thread, time::Duration}; use trpl::{ReceiverStream, Stream, StreamExt}; fn main() { trpl::run(async { let messages = get_messages().timeout(Duration::from_millis(200)); let intervals = get_intervals() .map(|count| format!("Interval #{count}")) .throttle(Duration::from_millis(500)) .timeout(Duration::from_secs(10)); let merged = messages.merge(intervals).take(20); let mut stream = pin!(merged); while let Some(result) = stream.next().await { match result { Ok(item) => println!("{item}"), Err(reason) => eprintln!("Problem: {reason:?}"), } } }); } fn get_messages() -> impl Stream<Item = String> { let (tx, rx) = trpl::channel(); trpl::spawn_task(async move { let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"]; for (index, message) in messages.into_iter().enumerate() { let time_to_sleep = if index % 2 == 0 { 100 } else { 300 }; trpl::sleep(Duration::from_millis(time_to_sleep)).await; if let Err(send_error) = tx.send(format!("Message: '{message}'")) { eprintln!("Cannot send message '{message}': {send_error}"); break; } } }); ReceiverStream::new(rx) } fn get_intervals() -> impl Stream<Item = u32> { let (tx, rx) = trpl::channel(); // This is *not* `trpl::spawn` but `std::thread::spawn`! thread::spawn(move || { let mut count = 0; loop { // Likewise, this is *not* `trpl::sleep` but `std::thread::sleep`! thread::sleep(Duration::from_millis(1)); count += 1; if let Err(send_error) = tx.send(count) { eprintln!("Could not send interval {count}: {send_error}"); break; }; } }); ReceiverStream::new(rx) }
আপনি যদি এই কোডটি চালান, আউটপুটটি লিস্টিং ১৭-৪০-এর মতোই হবে। এবং লক্ষ্য করুন কলিং কোডের দৃষ্টিকোণ থেকে এখানে কত কম পরিবর্তন হয়েছে। আরও কী, যদিও আমাদের একটি ফাংশন রানটাইমে একটি অ্যাসিঙ্ক টাস্ক তৈরি করেছে এবং অন্যটি একটি ওএস থ্রেড তৈরি করেছে, ফলস্বরূপ স্ট্রীমগুলি পার্থক্য দ্বারা প্রভাবিত হয়নি।
তাদের মিল থাকা সত্ত্বেও, এই দুটি পদ্ধতি খুব ভিন্নভাবে আচরণ করে, যদিও আমরা এই খুব সাধারণ উদাহরণে এটি পরিমাপ করতে কিছুটা বেগ পেতে পারি। আমরা যেকোনো আধুনিক ব্যক্তিগত কম্পিউটারে লক্ষ লক্ষ অ্যাসিঙ্ক টাস্ক তৈরি করতে পারতাম। যদি আমরা থ্রেডের সাথে এটি করার চেষ্টা করতাম, তাহলে আমাদের আক্ষরিক অর্থেই মেমরি ফুরিয়ে যেত!
যাইহোক, এই API-গুলি এত অনুরূপ হওয়ার একটি কারণ আছে। থ্রেডগুলি সিঙ্ক্রোনাস অপারেশনগুলির সেটের জন্য একটি সীমানা হিসাবে কাজ করে; কনকারেন্সি থ্রেডগুলির মধ্যে সম্ভব। টাস্কগুলি অ্যাসিঙ্ক্রোনাস অপারেশনগুলির সেটের জন্য একটি সীমানা হিসাবে কাজ করে; কনকারেন্সি টাস্কগুলির মধ্যে এবং ভিতরে উভয়ই সম্ভব, কারণ একটি টাস্ক তার বডিতে ফিউচারগুলির মধ্যে স্যুইচ করতে পারে। অবশেষে, ফিউচারগুলি হলো রাস্টের কনকারেন্সির সবচেয়ে ক্ষুদ্রতম একক, এবং প্রতিটি ফিউচার অন্যান্য ফিউচারের একটি ট্রি (tree) প্রতিনিধিত্ব করতে পারে। রানটাইম—বিশেষত, এর এক্সিকিউটর (executor)—টাস্কগুলি পরিচালনা করে, এবং টাস্কগুলি ফিউচারগুলি পরিচালনা করে। সেই ক্ষেত্রে, টাস্কগুলি লাইটওয়েট, রানটাইম-পরিচালিত থ্রেডের মতো যা অপারেটিং সিস্টেম দ্বারা পরিচালিত হওয়ার পরিবর্তে একটি রানটাইম দ্বারা পরিচালিত হওয়ার কারণে অতিরিক্ত ক্ষমতা পায়।
এর মানে এই নয় যে অ্যাসিঙ্ক টাস্কগুলি সবসময় থ্রেডের চেয়ে ভালো (বা বিপরীত)। থ্রেডের সাথে কনকারেন্সি কিছু উপায়ে async
-এর সাথে কনকারেন্সির চেয়ে একটি সহজ প্রোগ্রামিং মডেল। এটি একটি শক্তি বা দুর্বলতা হতে পারে। থ্রেডগুলি কিছুটা "ফায়ার অ্যান্ড ফরগেট" (fire and forget); তাদের ফিউচারের কোনো নেটিভ সমতুল্য নেই, তাই তারা অপারেটিং সিস্টেম নিজে ছাড়া অন্য কোনো কিছু দ্বারা বাধাগ্রস্ত না হয়ে কেবল সম্পূর্ণ না হওয়া পর্যন্ত চলে। অর্থাৎ, তাদের ইন্ট্রা-টাস্ক কনকারেন্সি (intratask concurrency)-র জন্য কোনো বিল্ট-ইন সমর্থন নেই যেমনটি ফিউচারের আছে। রাস্টে থ্রেডগুলির ক্যান্সেলেশনের (cancellation) জন্য কোনো মেকানিজমও নেই—একটি বিষয় যা আমরা এই অধ্যায়ে স্পষ্টভাবে কভার করিনি তবে এটি অন্তর্নিহিত ছিল যে যখনই আমরা একটি ফিউচার শেষ করেছি, তার স্টেট সঠিকভাবে পরিষ্কার হয়ে গেছে।
এই সীমাবদ্ধতাগুলি থ্রেডগুলিকে ফিউচারের চেয়ে কম্পোজ করা কঠিন করে তোলে। উদাহরণস্বরূপ, এই অধ্যায়ের শুরুতে আমরা যে timeout
এবং throttle
মেথডগুলি তৈরি করেছি সেগুলির মতো হেল্পার তৈরি করতে থ্রেড ব্যবহার করা অনেক বেশি কঠিন। ফিউচারগুলি যে আরও সমৃদ্ধ ডেটা স্ট্রাকচার, তার মানে হলো সেগুলি আরও স্বাভাবিকভাবে একসাথে কম্পোজ করা যেতে পারে, যেমন আমরা দেখেছি।
টাস্কগুলি, তাহলে, আমাদের ফিউচারগুলির উপর অতিরিক্ত নিয়ন্ত্রণ দেয়, যা আমাদের কোথায় এবং কীভাবে সেগুলিকে গ্রুপ করতে হবে তা বেছে নেওয়ার সুযোগ দেয়। এবং দেখা যাচ্ছে যে থ্রেড এবং টাস্কগুলি প্রায়শই খুব ভালোভাবে একসাথে কাজ করে, কারণ টাস্কগুলি (অন্তত কিছু রানটাইমে) থ্রেডগুলির মধ্যে সরানো যেতে পারে। আসলে, পর্দার আড়ালে, আমরা যে রানটাইমটি ব্যবহার করে আসছি—spawn_blocking
এবং spawn_task
ফাংশন সহ—ডিফল্টরূপে মাল্টিথ্রেডেড! অনেক রানটাইম সিস্টেমের সামগ্রিক পারফরম্যান্স উন্নত করার জন্য থ্রেডগুলির মধ্যে স্বচ্ছভাবে টাস্কগুলি সরানোর জন্য ওয়ার্ক স্টিলিং (work stealing) নামে একটি পদ্ধতি ব্যবহার করে, যা থ্রেডগুলি বর্তমানে কীভাবে ব্যবহৃত হচ্ছে তার উপর ভিত্তি করে। সেই পদ্ধতির জন্য আসলে থ্রেড এবং টাস্ক, এবং তাই ফিউচার প্রয়োজন।
কোন পদ্ধতি কখন ব্যবহার করতে হবে তা নিয়ে ভাবার সময়, এই সাধারণ নিয়মগুলি বিবেচনা করুন:
- যদি কাজটি খুবই প্যারালাল করা যায় (very parallelizable), যেমন একগুচ্ছ ডেটা প্রসেস করা যেখানে প্রতিটি অংশ আলাদাভাবে প্রসেস করা যায়, তবে থ্রেডগুলি একটি ভালো পছন্দ।
- যদি কাজটি খুবই কনকারেন্ট (very concurrent) হয়, যেমন বিভিন্ন উৎস থেকে আসা বার্তাগুলি পরিচালনা করা যা বিভিন্ন ব্যবধানে বা বিভিন্ন হারে আসতে পারে, তবে অ্যাসিঙ্ক একটি ভালো পছন্দ।
এবং যদি আপনার প্যারালালিসম এবং কনকারেন্সি উভয়ই প্রয়োজন হয়, তবে আপনাকে থ্রেড এবং অ্যাসিঙ্কের মধ্যে বেছে নিতে হবে না। আপনি সেগুলিকে অবাধে একসাথে ব্যবহার করতে পারেন, প্রত্যেকটিকে তার সেরা কাজটি করতে দিয়ে। উদাহরণস্বরূপ, লিস্টিং ১৭-৪২ বাস্তব-বিশ্বের রাস্ট কোডে এই ধরনের মিশ্রণের একটি মোটামুটি সাধারণ উদাহরণ দেখায়।
extern crate trpl; // for mdbook test use std::{thread, time::Duration}; fn main() { let (tx, mut rx) = trpl::channel(); thread::spawn(move || { for i in 1..11 { tx.send(i).unwrap(); thread::sleep(Duration::from_secs(1)); } }); trpl::run(async { while let Some(message) = rx.recv().await { println!("{message}"); } }); }
আমরা একটি অ্যাসিঙ্ক চ্যানেল তৈরি করে শুরু করি, তারপরে একটি থ্রেড তৈরি করি যা চ্যানেলের প্রেরক (sender) দিকের মালিকানা নেয়। থ্রেডের মধ্যে, আমরা ১ থেকে ১০ পর্যন্ত সংখ্যা পাঠাই, প্রতিটির মধ্যে এক সেকেন্ড ঘুমিয়ে। অবশেষে, আমরা অধ্যায় জুড়ে যেমন করেছি ঠিক তেমনই trpl::run
-এ পাস করা একটি অ্যাসিঙ্ক ব্লক দিয়ে তৈরি একটি ফিউচার চালাই। সেই ফিউচারে, আমরা সেই বার্তাগুলি await করি, ঠিক যেমন আমরা দেখেছি অন্যান্য বার্তা-প্রেরণের উদাহরণগুলিতে।
অধ্যায়ের শুরুতে আমরা যে পরিস্থিতি দিয়ে শুরু করেছিলাম সেটিতে ফিরে যেতে, কল্পনা করুন একটি ডেডিকেটেড থ্রেড ব্যবহার করে একগুচ্ছ ভিডিও এনকোডিং টাস্ক চালানো হচ্ছে (কারণ ভিডিও এনকোডিং কম্পিউট-বাউন্ড) কিন্তু একটি অ্যাসিঙ্ক চ্যানেল দিয়ে UI-কে জানানো হচ্ছে যে সেই অপারেশনগুলি শেষ হয়েছে। বাস্তব-বিশ্বের ব্যবহারের ক্ষেত্রে এই ধরনের সংমিশ্রণের অগণিত উদাহরণ রয়েছে।
সারসংক্ষেপ
এই বইয়ে কনকারেন্সির বিষয়ে এটিই শেষ নয়। চ্যাপ্টার ২১-এর প্রকল্পটি এখানে আলোচনা করা সহজ উদাহরণগুলির চেয়ে আরও বাস্তবসম্মত পরিস্থিতিতে এই ধারণাগুলি প্রয়োগ করবে এবং থ্রেডিং বনাম টাস্কের সাথে সমস্যা-সমাধানের আরও সরাসরি তুলনা করবে।
আপনি এই পদ্ধতিগুলির মধ্যে যেটিই বেছে নিন না কেন, রাস্ট আপনাকে নিরাপদ, দ্রুত, কনকারেন্ট কোড লেখার জন্য প্রয়োজনীয় টুলস সরবরাহ করে—সেটি একটি উচ্চ-থ্রুপুট ওয়েব সার্ভারের জন্য হোক বা একটি এমবেডেড অপারেটিং সিস্টেমের জন্য।
এরপরে, আমরা আপনার রাস্ট প্রোগ্রামগুলি বড় হওয়ার সাথে সাথে সমস্যা মডেলিং এবং সমাধান কাঠামোবদ্ধ করার ইডিওম্যাটিক উপায়গুলি নিয়ে আলোচনা করব। এছাড়াও, আমরা আলোচনা করব কীভাবে রাস্টের ইডিওমগুলি অবজেক্ট-ওরিয়েন্টেড প্রোগ্রামিং থেকে আপনার পরিচিত হতে পারে এমন ইডিওমগুলির সাথে সম্পর্কিত।