بخش بحرانی (Critical Section) و همگام سازی (synchronization) چیست؟

java-multithreading-synchronization

سلام در دو بخش قبلی آموزش Multithreading در جاوا یاد گرفتید که چگونه یک نخ را ایجاد و اجرا کنید، همچنین با بعضی از متدهای کلاس Thread و چرخه ی حیات نخ آشنا شدید؛ در این بخش به بررسی یک حالت بسیار مهم در برنامه نویسی چند نخی در جاوا می پردازیم که در صورت عدم رعایت آن، برنامه نوشته شده با مشکلات جدی روبرو می شود.

همان طور که می دانید هر برنامه ای که کامپایل می شود، مقداری حافظه را به خود اختصاص می دهد که مهم ترین آن ها Stack و Heap است.

به زبان ساده Stack محل ذخیره سازی متغییر هایی است که به صورت محلی تعریف شده اند و Heap بخشی است که اشیای ایجاد شده در برنامه، در آن قرار می گیرند

در زمان ایجاد یک نخ یک Stack جدید برای آن ایجاد می شود، در واقع هر نخ Stack جداگانه و مخصوص به خود را دارد و تمامی متغییر های محلی آن نخ نیز در Stack آن نگه داری می شوند.

وقتی دو نخ به طور همزمان یک متد را فرواخوانی کنند، در واقع در هر نخ، حافظه ی جداگانه ای برای متغییرهای محلی آن متد ایجاد می شود. از طرفی دیگر تمامی نخ های ایجاد شده در یک برنامه دارای Heap مشترک هستند، با توجه به توضیحات، هر شی می تواند توسط چندین نخ به طور همزمان قابل دسترسی باشد پس این روند می تواند باعث بروز مشکلاتی در اجرای برنامه شود. فرض کنید نخی در حال خواندن شی از حافظه است در حالی که نخی دیگر در حال تغییر بر روی آن شی است در اینجا عمل خواندن می تواند ناقص و اشتباه انجام شود.

چند اصطلاح مهم

منبع مشترک(Shared resource)

به بخش یا شی یا متغییری که به صورت همزمان در چند نخ، قابل دسترسی است.

شرایط مسابقه (Race condition)

شرایطی که در آن چند نخ، به صورت همزمان به یک منبع مشترک دست پیدا کنند و حداقل یکی از نخ ها، منبع مشترک را تغییر دهد. به عبارتی هر تعداد نخ می توانند به صورت همزمان به یک منبع وارد شوند ولی اگر یکی از آن ها بخواهد تغییراتی روی منبع اعمال کند، نخ های دیگر که در حال خواندن آن هستند با مشکل مواجه می شوند.

بخش بحرانی (Critical Section)

بخشی از برنامه ی هر نخ، که در آن نخ وارد شرایط مسابقه می شود یا به بیانی ساده تر برنامه نویس نباید اجازه دهد دو نخ (یا بیشتر) به صورت همزمان وارد بخش بحرانی خود شوند.

قبل از این که به توضیح synchronized و حل مشکل Critical Section بپردازیم، اجازه دهید تا مطالب بالا را در قالب یک مثال بیشتر توضیح دهیم.

کدهای زیر را در Netbeans اجرا کنید و خروجی آن را مشاهده کنید.

public class Main {
    public static void main(String[] args) throws InterruptedException {
        PrintValue p = new PrintValue();
        Thread t1 = new Thread(p, "t1");
        Thread t2 = new Thread(p, "t2");
        Thread t3 = new Thread(p, "t3");
        Thread t4 = new Thread(p, "t4");
        t1.start();
        t2.start();
        t3.start();
        t4.start();
    }
}
class PrintValue implements Runnable {
    public static int value = 0;
    @Override
    public  void run() {
        for (int i = 0; i < 2; i++) {
            value++;
            System.out.println("Thread name: " + Thread.currentThread().getName() + "\t" + "value = " + value);
            try {
                Thread.sleep(5);
            } catch (InterruptedException ex) {
            }
        }
    }
}

توجه کنید که value از نوع static تعریف شده است و مقدار آن برای تمامی اشیای این کلاس ثابت است.

چهار عدد Thread تعریف شده که هر کدام وظیفه دارند دستورات داخل run را اجرا کنند، پس انتظار دارید که هر Thread دو واحد به value اضافه کند و در نهایت مقدار نهایی آن 8 شود ولی اینطور نیست و خروجی آن مطابق زیر است.

خروجی برنامه نویسی Multithreading
خروجی

خب در این مثال بخش اضافه کردن به مقدار value و چاپ کردن عدد را می توان Critical Section در نظر گرفت و value منبع مشترک بحساب می آید. توجه داشته باشید که چهار Thread همزمان به این بلوک وارد می شوند و همزمان به مقدار value دو واحد اضافه می کنند، مشاهده می کنید که هر چهار نخ قصد تغییر یک مقدار را دارند (race condition) پس تداخل پیش می آید، فرض کنید دو نخ با هم در حال افزایش مقدار value هستند و دو نخ دیگر به مقدار قبلی value دو واحد اضافه کرده اند و در حال چاپ آن هستند، پس در این صورت یک مقدار دو بار چاپ می شود (یا بیشتر) و یا یک مقدار اصلا چاپ نمی شود که ایده آل ما نبوده و اشتباه است؛ این مثال یکی از کوچکترین مشکلات ورود همزمان به Critical Section بوده و در برنامه های بزرگ تر می تواند باعث مشکلات بسیار پیچیده تر شود، حال با توضیح و بررسی synchronized به حل این مشکل می پردازیم.

همگام سازی یا synchronization چیست؟

جاوا برای جلوگیری از ورود چند Thread به بخش بحرانی& امکاناتی در نظر گرفته است که به وسیله ی آن می توان ورود Thread ها را به این بخش کنترل کرد، به طوری که وقتی یک Thread در حال اجرای بخش بحرانی خود است بقیه ی Thread ها منتظر می مانند تا کار آن Thread جاری تمام شود. شخص برنامه نویس باید به خوبی از این امکانات استفاده کند تا از خطا های احتمالی جلو گیری کند.

توجه کنید Thread 1 هنگام ورود به بخش بحرانی، قفل (Lock) آن بخش را در اختیار خود می گیرد، پس وقتی Thread 2 بخواهد به آن بخش وارد شود قفلی وجود ندارد که در اختیار بگیرد؛ به عبارت دیگر فقط یک قفل وجود دارد که Thread 1 در اختیار قرار گرفته پس Thread 2 باید منتظر بماند تا قفل توسط Thread 1 آزاد شود.

تمامی قفل گذاری ها و تعیین نوع آن توسط برنامه نویس مشخص می شود، در مثال قبلی بخش اضافه کردن مقدار value یک Critical Section بوده و باید برای آن قفلی ایجاد شود تا در هر لحظه فقط یکی از Thread ها بتواند به آن وارد شده، قفل آن را بگیرد، عملیات لازم را انجام دهد و سپس قفلی را که گرفته آزاد کند و از آن خارج شود تا Thread بعدی به آن وارد شود.

برای مشخص کردن Critical Section از واژه ی کلیدی synchronized استفاده می کنیم در زبان جاوا هر گاه یک متد synchronized بر روی یک شی فراخوانی شود جاوا سعی میکند که قفل مربوط به همان شی را بگیرد و با توجه به این که هر شی قفل مخصوص به خود را دارد، اگر یک Thread قفل شی مورد نظر (مثلا شی X) را گرفت، بقیه ی Thread ها نمی توانند آن قفل (همان شی X) را در اختیار بگیرند مگر این که قفل دوباره آزاد شود.

در زمانی که یک متد از نوع synchronized بر روی شی در حال اجرا است، هیچ متد synchronized دیگری نمی تواند بر روی آن شی اجرا شود دلیل آن هم مشخص است (چون شی قفل شده).

مثال بالا را به این صورت تغییر دهید و خروجی را مشاهده کنید.

    public static void main(String[] args) throws InterruptedException {
        PrintValue p = new PrintValue();
        Thread t1 = new Thread(p, "t1");
        Thread t2 = new Thread(p, "t2");
        Thread t3 = new Thread(p, "t3");
        Thread t4 = new Thread(p, "t4");
        t1.start();
        t2.start();
        t3.start();
        t4.start();
    }
}
class PrintValue implements Runnable {

    public static int value = 0;

    @Override
    public synchronized void run() {
        for (int i = 0; i < 2; i++) {
            value++;
            System.out.println("Thread name: " + Thread.currentThread().getName() + "\t" + "value = " + value);
            try {
                Thread.sleep(5);
            } catch (InterruptedException ex) {
            }
        }
    }
}
خروجی برنامه جاوا با اعمال synchronization در برنامه نویسی Multithreading
خروجی برنامه با اعمال synchronization

در اینجا متد run بخش Critical Section است، ولی لزوما همیشه اینطور نیست این مثال به این صورت قابل تغییر است.

class PrintValue implements Runnable {
    public static int value = 0;
    public synchronized void increase(){
        for (int i = 0; i < 2; i++) {
            value++;
            System.out.println("Thread name: " + Thread.currentThread().getName() + "\t" + "value = " + value);
            try {
                Thread.sleep(5);
            } catch (InterruptedException ex) {
            }
        }
    } 
    @Override
    public  void run() {
        increase();
    }
}

چند تذکر مهم

1- واژه ی کلیدی synchronized باید قبل از نوع بازگشتی تابع استفاده شود.

2- امکان ایجاد بخش بحرانی با روش هایی به غیر از قفل this وجود دارد که به صورت زیر است:

        Set<String> object = new HashSet<>();
        synchronized(object){
            object.add();
        }

به این صورت بر روی شی از نوع Set قفل ایجاد کردیم.

3- اگر متد Static از نوع synchronized تعریف شود، یک Thread برای ورود به آن، باید قفل کل کلاس را به دست بگیرد. یعنی هیچ دو نخی نمی توانند همزمان وارد آن کلاس شوند (کل کلاس در اختیار Thread جاری است).

4- یک متد غیر استاتیک که synchronized تعریف شده می تواند به صورت همزمان توسط دو Thread مختلف اجرا شود، به شرطی که آن دو Thread توسط اشیای مختلفی از آن کلاس اجرا شوند.

به مثال زیر توجه کنید:

public class Main {
    public static void main(String[] args) throws InterruptedException {
        PrintValue obj1 = new PrintValue();
        PrintValue obj2 = new PrintValue();
        Thread t1 = new Thread(obj1, "Thread_1");
        Thread t2 = new Thread(obj2, "Thread_2");
        t1.start();
        t2.start();
    }
}
class PrintValue implements Runnable {
    
    public  int value = 0;

    public synchronized void increase(){
             for (int i = 0; i < 2; i++) {
            value++;
            System.out.println("Thread name: " + Thread.currentThread().getName() + "\t" + "value = " + value);
            try {
                Thread.sleep(5);
            } catch (InterruptedException ex) {
            }
        }
    } 
    @Override
    public  void run() {
        increase();
    }
}

در اینجا t1 بر روی obj1 و t2 بر روی obj2 عمل میکند که اگر خروجی آن را مشاهده کنید بدون مشکل اجرا می شود.

5- استفاده از بلوک synchronized به دو صورت زیر دارای کارایی یکسان است.

    public synchronized  MyFun(){
        دستورات.....
    }

یا

    public MyFun(){
        synchronized(this){
        دستورات.....
        }

این جلسه از آموزش هم به پایان رسید؛ بخش Thread دارای نکته های بسیار مهم و زیادی است که گفتن همه ی آن در در یک بخش از آموزش جز گم راه کردن خواننده نتیجه ی دیگری ندارد، در جلسه ی آینده با چند متد دیگر از کلاس Thread آشنا می شویم و مثالهایی کلی تر و جامع تر را برای فهم آسان تر مطالب گفته شده بررسی می کنیم.

موفق باشید.

تمام فصل‌های سری ترتیبی که روکسو برای مطالعه‌ی دروس سری آموزش برنامه نویسی MultiThreading در جاوا توصیه می‌کند:
نویسنده شوید
دیدگاه‌های شما (1 دیدگاه)

در این قسمت، به پرسش‌های تخصصی شما درباره‌ی محتوای مقاله پاسخ داده نمی‌شود. سوالات خود را اینجا بپرسید.

sina
22 آذر 1400
سلام واقعا عالی و کاربردی و با زبانی قابل درک و شیوا بیان شده ممنونم

در این قسمت، به پرسش‌های تخصصی شما درباره‌ی محتوای مقاله پاسخ داده نمی‌شود. سوالات خود را اینجا بپرسید.