Skip to main content

[این مقاله در سطح «متوسط» و نیازمند آشنایی خواننده با مفهوم «پردازش موازی» و زبان برنامه‌سازی «++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 برای درک چگونگی ساختار و عملکرد آن است. این ساختارها، علی‌رغم سادگی، نقشی بسیار اساسی در محاسبات موازی ایفا می‌کنند. برای چگونگی پردازش صف وظایف، الگوریتم‌های بسیاری توسعه داده شده‌اند که مطالعه‌ی آنها توسط خواننده می‌تواند در درک بهتر این شیوه‌ی پردازش بسیار مفید واقع شود.