چند نخی (Multi-Threading) در ++C — به زبان ساده

۱۴۷۲ بازدید
آخرین به‌روزرسانی: ۲۶ شهریور ۱۴۰۲
زمان مطالعه: ۶ دقیقه
چند نخی (Multi-Threading) در ++C — به زبان ساده

یادگیری ساختارهای جدید چند نخی در ++C بسیار آسان است. اگر با C یا ++C آشنا باشید، با استفاده از این مقاله می‌توانید برنامه‌های چند نخی (multithread) بنویسید.

در این نوشته از نسخه 14 ++C به عنوان مرجع استفاده شده است، اما همه این مفاهیم در نسخه 17 نیز پشتیبانی می‌شوند. در این نوشته تنها ساختارهای رایج پوشش یافته‌اند. پس از خواندن این نوشته شما می‌توانید برنامه‌های چند نخی خودتان را بنویسید.

ایجاد نخ (Thread)

نخ را می‌توان به چند روش ایجاد کرد:

  1. استفاده از اشاره‌گر تابع
  2. استفاده از functor
  3. استفاده از تابع لامبدا (lambda)

این روش‌ها بسیار شبیه هم هستند و تنها چند اختلاف جزئی با هم دارند.

استفاده از اشاره‌گر تابع (Function Pointer)

تابع زیر را در نظر بگیرید که یک ارجاع بردار به نام v، یک ارجاع به نتیجه به نام acm و دو اندیس در بردار V می‌گیرد. این تابع همه عناصر بین beginIndex و endIndex را جمع می‌زند.

void accumulator_function2(const std::vector<int> &v, unsigned long long &acm, 
                            unsigned int beginIndex, unsigned int endIndex)
{
    acm = 0;
    for (unsigned int i = beginIndex; i < endIndex; ++i)
    {
        acm += v[i];
    }
}

تابعی که به محاسبه مجموع همه عناصر بین beginIndex و endIndex در بردار V می‌پردازد.

اینک فرض کنید که می‌خواهیم بردار را به دو بخش افراز کرده و مجموع کلی هر بخش را در دو نخ جداگانه به نام‌های t1 و t2 محاسبه کنیم:

//Pointer to function
    {
        unsigned long long acm1 = 0;
        unsigned long long acm2 = 0;
        std::thread t1(accumulator_function2, std::ref(v), 
                        std::ref(acm1), 0, v.size() / 2);
        std::thread t2(accumulator_function2, std::ref(v), 
                        std::ref(acm2), v.size() / 2, v.size());
        t1.join();
        t2.join();

        std::cout << "acm1: " << acm1 << endl;
        std::cout << "acm2: " << acm2 << endl;
        std::cout << "acm1 + acm2: " << acm1 + acm2 << endl;
}

توضیح کد فوق

  1. std::thread یک نخ جدید ایجاد می‌کند. پارامتر اول نام اشاره‌گر تابع accumulator_function2 است. از این رو هر نخ این تابع را اجرا می‌کند.
  2. بقیه پارامترها که به سازنده std::thread ارسال می‌شوند پارامترهایی هستند که باید به accumulator_function2 ارسال کنیم.
  3. مهم: همه پارامترهای ارسالی به accumulator_function2 به وسیله مقدار ارسال می‌شوند؛ مگر این که آن‌ها را درون std::ref قرار دهیم. به همین دلیل است که v، acm1 و acm2 را درون std::ref قرار داده‌ایم.
  4. نخ‌های ایجاد شده از سوی std::thread مقادیر بازگشتی ندارند. اگر بخواهید چیزی را بازگردانید باید آن را در یکی از پارامترهای ارسالی با ارجاع یعنی acm ذخیره کنید.
  5. هر نخ به محض ایجاد آغاز می‌شود.
    1. ما از تابع ()join برای انتظار جهت پایان یافتن نخ استفاده می‌کنیم.

استفاده از Functor ها

می‌توان کارهای بالا را دقیقاً با Functor ها نیز انجام داد. در ادامه کدی را می‌بینید که از یک Functor استفاده می‌کند:

class CAccumulatorFunctor3
{
  public:
    void operator()(const std::vector<int> &v, 
                    unsigned int beginIndex, unsigned int endIndex)
    {
        _acm = 0;
        for (unsigned int i = beginIndex; i < endIndex; ++i)
        {
            _acm += v[i];
        }
    }
    unsigned long long _acm;
};

و کدی که نخ را ایجاد می‌کند به صورت زیر است:

//Creating Thread using Functor
    {

        CAccumulatorFunctor3 accumulator1 = CAccumulatorFunctor3();
        CAccumulatorFunctor3 accumulator2 = CAccumulatorFunctor3();
        std::thread t1(std::ref(accumulator1), 
            std::ref(v), 0, v.size() / 2);
        std::thread t2(std::ref(accumulator2), 
            std::ref(v), v.size() / 2, v.size());
        t1.join();
        t2.join();

        std::cout << "acm1: " << accumulator1._acm << endl;
        std::cout << "acm2: " << accumulator2._acm << endl;
        std::cout << "accumulator1._acm + accumulator2._acm : " << 
            accumulator1._acm + accumulator2._acm << endl;
}

توضیح کد فوق

در کد فوق همه چیز شبیه به وضعیت استفاده از اشاره‌گرهای تابع است به جز دو مورد زیر:

  1. پارامتر نخست یک شیء Functor است.
  2. به جای ارسال کردن یک ارجاع به Functor جهت ذخیره‌سازی نتیجه، می‌توانیم مقدار بازگشتی آن را به صورت یک متغیر عضو درون functor یعنی acm_ ذخیره کنیم.

استفاده از تابع‌های لامبدا

روش سوم برای تعریف کردن نخ استفاده از تابع لامبدا است که در ادامه مشاهده می‌کنید:

{
        unsigned long long acm1 = 0;
        unsigned long long acm2 = 0;
        std::thread t1([&acm1, &v] {
            for (unsigned int i = 0; i < v.size() / 2; ++i)
            {
                acm1 += v[i];
            }
        });
        std::thread t2([&acm2, &v] {
            for (unsigned int i = v.size() / 2; i < v.size(); ++i)
            {
                acm2 += v[i];
            }
        });
        t1.join();
        t2.join();

        std::cout << "acm1: " << acm1 << endl;
        std::cout << "acm2: " << acm2 << endl;
        std::cout << "acm1 + acm2: " << acm1 + acm2 << endl;
}

در این مورد نیز همه چیز شبیه به اشاره‌گرهای تابع است مگر این مورد: به عنوان روش جایگزین برای ارسال پارامتر، می‌توانیم ارجاع‌ها به تابع‌های لامبدا را با استفاده از گیرنده لامبدا ارسال کنیم.

خصوصیات Task ،Future و Promises در نخ‌ها

به جای std::thread می‌توان از tasks نیز استفاده کرد.

Tasks ساختاری کاملاً شبیه به نخ دارد؛ اما تفاوت اصلی در این نکته نهفته است که در این روش یک مقدار بازگشت می‌یابد. بنابراین می‌توان آن‌ها را به صورت یک روش انتزاعی‌تر برای تعریف کردن نخ‌ها و استفاده از آن‌ها در مواردی که نخ باید مقداری بازگرداند، در نظر گرفت.

در ادامه همان مثال را که این بار با استفاده از tasks نوشته شده می‌بینید:

#include <future>
//Tasks, Future, and Promises
    {
        auto f1 = [](std::vector<int> &v, 
            unsigned int left, unsigned int right) {
            unsigned long long acm = 0;
            for (unsigned int i = left; i < right; ++i)
            {
                acm += v[i];
            }

            return acm;
        };

        auto t1 = std::async(f1, std::ref(v), 
            0, v.size() / 2);
        auto t2 = std::async(f1, std::ref(v), 
            v.size() / 2, v.size());

        //You can do other things here!
        unsigned long long acm1 = t1.get();
        unsigned long long acm2 = t2.get();

        std::cout << "acm1: " << acm1 << endl;
        std::cout << "acm2: " << acm2 << endl;
        std::cout << "acm1 + acm2: " << acm1 + acm2 << endl;
}

توضیح کد فوق

  1. Tasks با استفاده از std::async تعریف و ایجاد می‌شوند؛ در حالی که نخ‌ها با استفاده از std::thread ایجاد می‌شدند.
  2. مقدار بازگشتی از std::async به صورت std::future نامیده می‌شود. البته نباید از نام آن بترسید، چون تنها به این معنی است که متغیرهای t1 و t2 متغیرهایی هستند که مقادیرشان در آینده انتساب خواهد یافت. ما مقدارهای آن‌ها را با فراخوانی ()t1.get و ()t2.get به دست می‌آوریم.
  3. اگر مقادیر آتی آماده نباشند، به محض فراخوانی ()get، نخ اصلی تا زمانی که مقدارهای آتی موجود نشوند، مسدود می‌شود (مشابه join).
  4. دقت کنید که تابعی که به std::async ارسال می‌شود یک مقدار بازمی‌گرداند. این مقدار از طریق یک نوع به نام std::promise ارسال می‌شود. در این مورد نیز نباید از نام آن بترسید، چون در اغلب موارد لازم نیست در مورد جزییات std::promise چیزی بدانید یا متغیری از نوع std::promise تعریف کنید. کتابخانه ++C همه کارها را در پس‌زمینه خود انجام می‌دهد.
  5. هر task به طور پیش‌فرض به محض ایجاد شدن آغاز می‌شود. البته روشی برای تغییر دادن این وضعیت وجود دارد که توضیح آن خارج از حیطه این مقاله است.

خلاصه نکاتی از ایجاد نخ‌ها

ایجاد نخ به همین سادگی است که توضیح دادیم. شما می‌توانید با استفاده از std::thread و یکی از روش‌های زیر نخ ایجاد کنید:

  1. استفاده از اشاره‌گرهای تابع
  2. استفاده از functor ها
  3. استفاده از تابع‌های لامبدا

همچنین می‌توانید با استفاده از std::async یک task ایجاد و مقادیر بازگشتی را در std::future دریافت کنید. task ها نیز از اشاره‌گرهای تابع، functor یا تابع لامبدا بهره می‌گیرند.

حافظه مشترک و منابع مشترک

به طور خلاصه هنگام استفاده از نخ‌ها برای خواندن/نوشتن در حافظه و منابع مشترک، مانند فایل‌ها باید کاملاً مراقب بود تا از شرایط رقابت بر سر منابع (race conditions) جلوگیری کرد.

نسخه 14 ++C چند قید برای همگام‌سازی نخ‌ها قرار داده است که موجب جلوگیری از بروز شرایط رقابت می‌شود.

استفاده از Mutex, lock, () و ()unlock (توصیه نمی‌شود)

در متد زیر شیوه ایجاد یک بخش ضروری توضیح داده می‌شود به طوری که هر نخ به طور انحصاری به std::cout دسترسی دارد:

std::mutex g_display_mutex;
thread_function()
{

    g_display_mutex.lock();
    std::thread::id this_id = std::this_thread::get_id();
    std::cout << "My thread id is: " << this_id  << endl;
    g_display_mutex.unlock();
}

توضیح کد فوق

  1. یک mutex به نام std::mutex ایجاد می‌شود.
  2. یک بخش ضروری (یعنی تضمین شده است که هر بار تنها روی یک نخ اجرا شود) با استفاده از ()lock ایجاد می‌شود.
  3. بخش ضروری در زمان فراخوانی ()unlock خاتمه می‌یابد.
  4. هر نخ در ()lock منتظر می‌ماند و تنها در صورتی وارد بخش ضروری می‌شود که نخ دیگری در آن بخش نباشد.

با این که روش فوق کار می‌کند؛ اما توصیه نمی‌شود زیرا:

  1. در برابر بروز استثنا (exception) امن نیست یعنی اگر کد پیش از قفل شدن استثنایی ایجاد کند، unlock() اجرا نخواهد شد و ما mutex را که ممکن است منجر به قفل شدن هرگز اجرا نمی‌کنیم.
  2. ما باید همیشه مراقب باشیم که فراخوانی ()unlock را فراموش نکنیم.

استفاده از std::lock_gaurd (توصیه شده)

گرچه نام std::lock_gaurd ممکن است ترسناک به نظر بیاید؛ اما این تنها یک روش انتزاعی برای ایجاد بخش‌های ضروری است. در ادامه کد بخش ضروری که با استفاده از lock_gaurd نوشته شده را مشاهده می‌کنید:

std::mutex g_display_mutex;
thread_function()
{
    std::lock_guard<std::mutex> guard(g_display_mutex);
    std::thread::id this_id = std::this_thread::get_id();
    std::cout << "From thread " << this_id  << endl;
}

توضیحات کد فوق

  1. کدی که پس از ایجاد std::lock_gaurd آمده است به طور خودکار قفل شده است. نیازی به فراخوانی صریح تابع‌های ()lock و ()unlock نیست.
  2. بخش ضروری به طور خودکار زمانی که std::lock_gaurd از حیطه آن خارج شود پایان می‌یابد. این امر موجب می‌شود که در برابر بروز شرایط استثنا امن باشد و همچنین نیازی به یادآوری فراخوانی ()unlock نیست.
  3. lock_gaurd همچنان به متغیری از نوع std::mutex در سازنده خود نیاز دارد.

چه تعداد نخ باید ایجاد کنیم؟

شما می‌توانید هر تعداد نخ که دوست دارید ایجاد کنید؛ اما اگر تعداد نخ‌های فعال از تعداد هسته‌های CPU بیشتر شود؛ بی‌فایده خواهند بود.

جهت دریافت بیشینه تعداد هسته‌های CPU سیستم می‌توانید ()std::thread::hardware_cuncurrency را فراخوانی کنید:

{
    unsigned int c = std::thread::hardware_concurrency();
    std::cout << " number of cores: " << c << endl;;
}

سخن پایانی

ما در این نوشته اغلب مباحث مورد نیاز در مورد استفاده از نخ در ++C را شرح دادیم. البته چند مورد جزئی و.جود دارند که رواج کمتری دارند و بنابراین در این مقاله مورد اشاره قرار نگرفتند؛ اما شما می‌توانید خودتان آن‌ها را مطالعه کنید:

  1. std::move
  2. جزییات std::promise
  3. std::packaged_task
  4. متغیرهای شرطی

اگر علاقه‌مند به یادگیری بیشتر در این زمینه هستید، آموزش‌های زیر نیز به شما پیشنهاد می‌شوند:

==

بر اساس رای ۷ نفر
آیا این مطلب برای شما مفید بود؟
اگر بازخوردی درباره این مطلب دارید یا پرسشی دارید که بدون پاسخ مانده است، آن را از طریق بخش نظرات مطرح کنید.
منابع:
hackernoon
نظر شما چیست؟

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *