[این مقاله در سطح «متوسط» و نیازمند آشنایی خواننده با مفهوم «پردازش موازی» و زبان برنامهسازی «++C» است.]
در پیادهسازی سرویسدهندهها موارد بسیاری وجود دارد که در آن نیازمند پردازش درخواستهای کوچک اما متعدد هستیم. برای درک بهتر مسأله یک شعبهی بانک یا یک باجهی فروش بلیت را در نظر بگیرید. در مثال شعبهی بانک، تعداد مراجعان در طول یک روز غالباً بسیار زیاد است. پاسخ به درخواست هر مراجع ممکن است (به طور مثال) از ۵ تا ۵۰ دقیقه طول بکشد. آنچه برای شما به عنوان یک مراجع مهم است، سرعت شعبهی بانک در پاسخ به درخواست شماست و احتمالاً دوست ندارید مدتها در صف طولانی مراجعان بانک حضور داشته باشید. اگر از منظر رئیس شعبه به آن نگاه کنید، با دو مسأله روبرو هستید. از سویی، مراجعان از کندی سرعت شما در پاسخگویی گلایه خواهند کرد. از سوی دیگر، شما با توجه به منابع انسانی و مالی که در دسترس دارید نمیتوانید بیش از توانتان به باجههای پاسخگویی اضافه کنید.
سرویسدهندهها با مسأله مشابهی روبرو هستند، با این تفاوت که مراجعان همان درخواستهای دریافتشده و منابع در دسترس همان پردازندهها و حافظه هستند. برای سرویسدهی بیشتر و بهتر، شیوهی اختصاص منابع به درخواستها از اهمیت بسیاری برخوردار است. یکی از راهکارهای اختصاص منابع، استفاده از thread برای پردازش درخواستهاست. اما thread بهخودی خود علاوه بر اختصاص منبع، مصرفکنندهی آن نیز هست. به زبان سادهتر، استفاده از thread، علیرغم اینکه میتواند به سرعت پردازش کمک کند، دارای سربار حافظه و پردازش است. این سربار بهویژه وقتی که زمان پردازش هر درخواست کم باشد، خود را نشان میدهد، زیرا زمان صرفشده برای ایجاد و مدیریت هر thread نسبت به زمان پردازش درخواست قابل توجه میشود. بر همین اساس توصیه میشود که از thread برای پردازشهایی استفاده شود که زمان قابل توجهی را به خود اختصاص میدهد و نه کارهای کوچک.
با توجه به مطالب گفته شده، مسائل پیش رو را مرور میکنیم: نخست، ما با درخواستهای پرتعدادی روبرو هستیم که قریب به اتفاق آنها زمان کمی برای پردازش نیاز دارند، اما قرار گرفتن آنها در صف پردازش زمان قابل توجهی را به خود اختصاص میدهد. دوم، با توجه به منابع محدود در دسترس، ما نیاز داریم که این منابع را به شیوهای مناسب به درخواستها اختصاص دهیم تا تمام درخواستها در کوتاهترین زمان ممکن پردازش شوند. سوم، ممکن است نیاز داشته باشیم که درخواستها را بر اساس درجهبندی و اولویت آنها پردازش کنیم، به طوری که درخواستهای حیاتی و فوری زمان کمتری را نسبت به درخواستهای زمانبر و کم اهمیت در صف پردازش صرف کنند. این مقاله برای دستیابی به این اهداف راهکاری را پیادهسازی میکند که به thread pool موسوم است.
Thread Pool چیست؟
به زبان ساده، thread pool جایی است که تعداد مشخصی thread قرار گرفتهاند تا تعدادی وظیفه (task) را که غالباً در یک صف قرار دارند، انجام دهند. پاسخ انجام این وظایف نیز ممکن است در صف دیگری قرار بگیرد. به طور معمول، تعداد وظایف بسیار بیشتر از تعداد thread هاست. یک thread بلافاصله پس از آنکه وظیفهی جاری خود را انجام داد، وظیفهی دیگری را از صف خارج میکند و آن را انجام میدهد. تعداد این thread ها ممکن است ثابت یا با توجه به تعداد وظایف موجود در صف، متغیر باشد. به طور مثال، یک سرویسدهندهی وب با افزایش درخواستها به صفحات وب به تعداد thread ها افزوده و با کاهش درخواستها، تعداد thread ها را کاهش میدهد. هزینهی داشتن یک thread pool بزرگتر، افزایش منابع مصرفشده است. بنابراین، تعیین دقیق بزرگی thread pool با توجه به حجم درخواستها و منابع موجود میتواند علاوه بر افزایش کارایی سرویسدهنده، از هدر رفتن منابع نیز جلوگیری کند.
تعاریف
برای پیادهسازی thread pool نیاز به تعریف دقیقی از وظایف داریم. برای این منظور فرض میکنیم که انجام هر وظیفه متناظر با فراخوانی یک تابع با پارامترهای آن باشد. علاوه بر این، هر وظیفه دارای یک خصیصه با نام اولویت خواهد بود که ترتیب انجام گرفتن آن را معین میکند. مطابق با این گفتهها، ساختار task
را به صورت زیر تعریف میکنیم.
// Task priorities #define LOW 0 #define NORM 1 #define HIGH 2 // Type definition for task function typedef void (*task_func_t)(void *); typedef struct task { task_func_t func; // Function to be called for performing task void *task_params; // Parameters to be passed to task function int priority; // Task priority } task_t;
برنامهنویس باید قادر باشد که یک thread pool ساخته و task های مورد نیازش را به آن بیفزاید. در سوی دیگر، thread pool این task ها را با توجه به نوبت و اولویتشان انجام میدهد. برای سادگی فرض میکنیم که task ها دارای خروجی نباشند، لذا thread pool نیازی نخواهد داشت که پاسخ task را بازگرداند. تعریف کلاس ThreadPool
بر اساس این گفتهها چیزی مشابه کد زیر خواهد بود.
// Number of threads to be used. // This might be increased, but often set to // the number of processors available. #define NUM_THREADS 2 class TaskQueue { // TaskQueue implementation is considered here // to be thread-safe. public: void enqueue(task_t *task); // Adds a new task to the queue. task_t *dequeue(); // Removes earliest task. bool empty(); // Checks whether queue is empty. }; typedef void (*thread_entry_t)(void *); class Thread { public: // Instantiates a new thread by calling the entry function // given its arguments and starts it immediately. Thread(thread_entry_t entry, void *arg); }; class ThreadPool { public: ThreadPool(); void schedule(task_t *task); // Schedules a new task to be // processed later. private: std::vector<Thread*> _threads; // Holds processing threads. std::map<int, TaskQueue*> _queues; // Holds queues for various task // priorities. friend void threadEntry(void *arg); };
در ادامه به پیادهسازی این تعاریف خواهیم پرداخت. لیکن برای جلوگیری از اطالهی کلام، پیادهسازی کلاسهای TaskQueue
و Thread
به خواننده واگذار شده است.
پیادهسازی
کلاس ThreadPool
دارای یک متد سازنده و یک متد schedule
ساده است. سادگی بیش از اندازهی این کلاس ممکن است گمراه کننده باشد. ممکن است بپرسید: پس پردازش صف وظایف کجاست؟ برای پاسخ به این سؤال به صورت گام به گام پیادهسازی کلاس را دنبال میکنیم.
برای رعایت نوبت و دستهبندی وظایف بر اساس اولیویتها، کلاس ThreadPool
دارای یک صف به ازای هر سطح اولویت است که در عضو queues_
مشاهده میشود. متد schedule
وظیفهی افزودن task
های جدید را به صف پردازش مرتبط با آن بر عهده دارد. پیادهسازی این متد بسیار ساده در ادامه آمده است.
void ThreadPool::schedule(task_t *task) { _queues[task->priority]->enqueue(task); }
دقت داشته باشید که فرض شده است کلاس TaskQueue
به صورت thread-safe پیادهسازی شده باشد، پس در پیادهسازی متد schedule
دیگر نیازی به استفاده از دسترسی انحصاری نداریم. وظیفهی ساختن صفهای وظایف و thread های پردازش بر عهدهی متد سازندهی کلاس است که پیادهسازی آن در زیر دیده میشود.
ThreadPool::ThreadPool() { // Create a task queue for each priority. _queues[LOW] = new TaskQueue(); _queues[NORM] = new TaskQueue(); _queues[HIGH] = new TaskQueue(); // Create processing threads. for (int i = 0; i < NUM_THREADS; ++i) { _threads.push_back(new Thread(threadEntry, this)); } }
اگر به خط علامتگذاری شده دقت کنید، میبینید که تابع threadEntry
به عنوان تابع اجرایی thread پردازش و اشارهگر this
به عنوان پارامتر ورودی آن وارد شده است. این تابع وظیفهی اصلی پردازش task
ها را بر عهده دارد. پیادهسازی این تابع به صورت زیر است.
void threadEntry(void *arg) { ThreadPool *_this = (ThreadPool *)arg; while (true) { task_t *task = NULL; if (! _this->_queues[HIGH]->empty()) { task = _queues[HIGH]->dequeue(); } else if (! _this->queues[NORM]->empty()) { task = _queues[NORM]->dequeue(); } else if (! _this->queues[LOW]->empty()) { task = _queues[LOW]->dequeue(); } if (task != NULL) { task->func(task->task_params); } else { sleep(20); // This helps OS for task switch and // prevents idle threads from consuming // cpu time. } } }
این شاید سادهترین پیادهسازی ممکن برای پردازش صف وظایف باشد. همانگونه که میبینید، وظایفی با اولویت بالاتر در پردازش نیز در اولویت قرار میگیرند. البته این شیوهی پردازش دارای ایراداتی نیز هست، به طور مثال، ازدحام پردازشهایی با اولویت بالا موجب متوقف شدن طولانیمدت وظایفی با اولویت پایین میشوند. به هر حال، این یک پیادهسازی مثالی است و خواننده میتواند به پیادهسازیهای بهتر فکر کند.
آنچه در اینجا ارائه شد، پیادهسازی مثالی از یک thread pool برای درک چگونگی ساختار و عملکرد آن است. این ساختارها، علیرغم سادگی، نقشی بسیار اساسی در محاسبات موازی ایفا میکنند. برای چگونگی پردازش صف وظایف، الگوریتمهای بسیاری توسعه داده شدهاند که مطالعهی آنها توسط خواننده میتواند در درک بهتر این شیوهی پردازش بسیار مفید واقع شود.