تست کدهای چندنخی در جاوا — از صفر تا صد

۱۰۴ بازدید
آخرین به‌روزرسانی: ۰۵ شهریور ۱۴۰۲
زمان مطالعه: ۱۵ دقیقه
تست کدهای چندنخی در جاوا — از صفر تا صد

در این مقاله به بررسی برخی از مفاهیم مقدماتی تست برنامه‌های همروند یا هم‌زمان (Concurrent) می‌پردازیم. تمرکز اصلی ما روی هم‌زمانی مبتنی بر «نخ» (Thread) و مشکلاتی است که در زمان تست کردن تولید می‌کنند. همچنین با شیوه حل این مشکلات و یافتن روشی کارآمد برای تست کدهای چندنخی در جاوا آشنا خواهیم شد.

برنامه‌نویسی همروند

منظور از برنامه‌نویسی همروند یا هم‌زمان، آن نوع از برنامه‌نویسی است که در آن حجم بالایی از محاسبات به اجزای محاسبه‌ای کوچک‌تر و نسبت مستقل از هم تقسیم می‌شوند. منظور از این کار اجرای این محاسبات کوچک‌تر به صورت هم‌زمان و حتی در صورت امکان موازی است. با این که چندین روش برای نیل به این مقصود وجود دارند، اما در هر صورت هدف غایی همه آن‌ها اجرای سریع‌تر برنامه است.

نقش نخ‌ها در برنامه‌نویسی همروند

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

شاید بپرسید چه چیزی موجب می‌شود که تست کد‌های همروند دشوار باشد؟ برای درک پاسخ این سؤال باید با شیوه اجرای همروندی در برنامه‌ها آشنا باشیم. یکی از رایج‌ترین تکنیک‌های برنامه‌نویسی همروند، بهره‌گیری از نخ‌ها (threads) است.

نخ‌ها می‌توانند نیتیو باشند، یعنی از سوی سیستم عامل زمان‌بندی شوند و یا این که از نخ‌های موسوم به «نخ سبز» (Green Thread) استفاده کنیم که به صورت مستقیم از سوی یک محیط «زمان اجرا» (runtime) زمان‌بندی می‌شوند.

دشواری تست برنامه‌های همروند

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

عمده مشکلات مرتبط‌ با برنامه‌نویسی همروند از کاربرد نخ‌های نیتیو به همراه اشتراک حافظه ناشی می‌شود. تست کردن چنین برنامه‌هایی نیز به همین جهت دشوار است. چند نخ که به حافظه مشترکی دسترسی داشته باشند، نیازمند «انحصار متقابل» (Mutual Exclusion) هستند. ما به طور معمول از سازوکاری به نام قفل (Lock) برای پیاده‌سازی این موضوع استفاده می‌کنیم.

اما این موضوع می‌تواند منجر به بروز مشکلات دیگری مانند شرایط رقابت، قفل‌های زنده، بروز بن‌بست‌ها و انقراض نخ‌ها شود. به علاوه این مشکلات ماهیتی نامنظم دارند، چون نخ‌های نیتیو کاملاً غیرقطعی هستند.

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

آناتومی تداخل نخ‌ها

می‌دانیم که نخ‌های نیتیو از سوی سیستم عامل به صورتی غیر قابل پیش‌بینی زمان‌بندی می‌شوند. در حالتی که این نخ‌ها به داده‌های مشترک دسترسی یافته و آن‌ها را تغییر دهند، وضعیتی جالب به نام «تداخل نخ‌ها» (Thread Interleaving) پیش می‌آید. با این که برخی از این تداخل‌ها ممکن است کاملاً قبال قبول باشند، اما برخی دیگر ممکن است موجب ایجاد حالت‌های نامطلوب در داده‌های نهایی شوند.

برای تفهیم بهتر موضوع یک مثال را بررسی می‌کنیم. فرض کنید یک شمارنده سراسری داریم که هر نخ یک واحد آن را افزایش می‌دهد. در انتهای پردازش، می‌خواهیم حالت این شمارنده دقیقاً با تعداد نخ‌هایی که اجرا شده‌اند، برابر باشد:

1private int counter;
2public void increment() {
3    counter++;
4}

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

تست کدهای چندنخی در جاوا

با این که این رویه متداخل خاص به صورت کامل نتیجه قابل قبولی ارائه می‌کند،؛ اما ممکن است شرایط همواره چنین نباشد. برای مثال به تصویر زیر توجه کنید:

تست کدهای چندنخی در جاوا

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

چندین روش وجود دارد که می‌توان کدی نوشت که از این مشکلات جلوگیری کند، اما موضوع این نوشته بررسی این روش‌ها نیست. یکی از رایج‌ترین این روش‌ها «همگام‌سازی» (Synchronization) با استفاده از یک قفل است، این روش نیز مشکلاتی در رابطه با ایجاد «شرایط رقابت» (Race Conditions) ایجاد می‌کند.

تست کدهای چندنخی در جاوا

اکنون که با چالش‌های مقدماتی تست کدهای چندنخی در جاوا آشنا شدید، نوبت آن فرا رسیده که شیوه فائق آمدن بر این چالش‌ها را بررسی کنیم. به این منظور یک نمونه کاربردی ساده می‌سازیم تا آنجا که می‌توانیم بسیاری از مسائل مرتبط با همروندی را شبیه‌سازی کنیم.

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

1public class MyCounter {
2    private int count;
3    public void increment() {
4        int temp = count;
5        count = temp + 1;
6    }
7    // Getter for count
8}

این قطعه کد، به ظاهر بی‌ضرر است، اما با کمی توجه متوجه می‌شویم که «نخ-ایمن» (Thread-Safe) نیست. اگر بخواهیم یک برنامه همروند با این کلاس بنویسیم، احتمالاً مستعد نقایص مختلف خواهد بود. هدف از تست کردن در اینجا شناسایی این نقایص مختلف است.

تست کردن بخش‌های غیر همروند

به عنوان یک قاعده کلی همواره بهتر است کد را از طریق جداسازی آن از رفتار همروند، تست کنید. به این ترتیب می‌توانیم مطمئن باشیم، هیچ نقصی در کد نمانده است که به همروندی مربوط نباشد. طرز کار به صورت زیر است:

1@Test
2public void testCounter() {
3    MyCounter counter = new MyCounter();
4    for (int i = 0; i < 500; i++) {
5        counter.increment();
6    }
7    assertEquals(500, counter.getCount());
8}

با این که با این که این تست حاوی کد زیادی نیست، اما به ما این اطمینان را می‌دهد که دست کم در غیاب همروندی همه چیز به درستی کار می‌کند.

نخستین تلاش برای تست با همروندی

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

1@Test
2public void testCounterWithConcurrency() throws InterruptedException {
3    int numberOfThreads = 10;
4    ExecutorService service = Executors.newFixedThreadPool(10);
5    CountDownLatch latch = new CountDownLatch(numberOfThreads);
6    MyCounter counter = new MyCounter();
7    for (int i = 0; i < numberOfThreads; i++) {
8        service.execute(() -> {
9            counter.increment();
10            latch.countDown();
11        });
12    }
13    latch.await();
14    assertEquals(numberOfThreads, counter.getCount());
15}

این تست معقولی است، چون تلاش می‌کنیم روی داده‌های مشترک با چند نخ عملیاتی اجرا کنیم. از آنجا که تعداد نخ‌ها پایین و حدود 10 است، متوجه می‌شویم که تقریباً در همه موارد این تست پاس می‌شود. نکته جالب اینجا است که اگر تعداد نخ‌ها را افزایش دهیم و مثلاً به 100 برسانیم، خواهیم دید که تست کم‌کم در اغلب موارد شکست می‌خورد.

تلاش بهتر برای تست با همروندی

با این که تست قبل مشخص ساخت که کد ما «نخ-ایمن» نیست، اما این تست یک مشکل دارد. این تست قطعی نیست، زیرا نخ‌های متداخل به روشی غیرقطعی عمل می‌کنند. امکان تکیه بر این تست برای برنامه وجود ندارد.

ما به روشی برای کنترل تداخل نخ‌ها نیاز داریم تا بتوانیم مشکلات همروندی را به روشی قطعی با تعداد نخ‌های کمتر مشخص سازیم. به این منظور کد را کمی دستکاری می‌کنیم:

1public synchronized void increment() throws InterruptedException {
2    int temp = count;
3    wait(100);
4    count = temp + 1;
5}

در این کد متد را به صورت synchronized درآورده‌ایم و یک «انتظار» (Wait) بین دو مرحله درون متد تعریف کرده‌ایم. توجه داشته باشید که ما لزوماً کدی را که می‌خواهیم تست کنیم، ویرایش نکرده‌ایم. با این حال، از آنجا که روش‌های زیادی برای تأثیرگذاری روی زمان‌بندی نخ وجود ندارد، از این راهکار استفاده کرد‌ه‌ایم.

در بخش بعدی، با روش انجام این کار، بدون دستکاری کد آشنا خواهیم شد. اکنون کد را به روشی مشابه روش قبلی تست می‌کنیم:

1@Test
2public void testSummationWithConcurrency() throws InterruptedException {
3    int numberOfThreads = 2;
4    ExecutorService service = Executors.newFixedThreadPool(10);
5    CountDownLatch latch = new CountDownLatch(numberOfThreads);
6    MyCounter counter = new MyCounter();
7    for (int i = 0; i < numberOfThreads; i++) {
8        service.submit(() -> {
9            try {
10                counter.increment();
11            } catch (InterruptedException e) {
12                // Handle exception
13            }
14            latch.countDown();
15        });
16    }
17    latch.await();
18    assertEquals(numberOfThreads, counter.getCount());
19}

کد فوق را صرفاً با دو نخ اجرا می‌کنیم و این احتمال وجود دارد که بتوانیم نقصی که به دنبالش هستیم را به دست آوریم. در این کد تلاش کرده‌ایم تا به یک نخ متداخل خاص برسیم که می‌دانیم روی کار ما تأثیر می‌گذارد. با این که این روش به منظور نمایش طرز کار مناسب است، اما ممکن است برای مقاصد عملی مفید نباشد.

ابزارهای موجود برای تست کردن

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

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

دسته دوم ابزارها ما را قادر می‌سازند که یک تداخل نخ خاص را شبیه‌سازی کنیم و از این طریق با قطعیت بیشتری نقایص را شناسایی کنیم.

کتابخانه tempus-fugit

کتابخانه tempus-fugit در جاوا به ما کمک می‌کند که کد همروند را نوشته و با سهولت تست کنیم. ما صرفاً روی بخش تست این کتابخانه تمرکز می‌کنیم. پیش‌تر دیدیم که تولید استرس روی کد با چند نخ، احتمال یافتن نقایص مرتبط با همروندی را افزایش می‌دهد.

با این که می‌توانیم از ابزارهای مختلف برای تولید استرس استفاده کنیم، اما کتابخانه tempus-fugit روش‌های آسانی برای انجام این کار در اختیار ما قرار می‌دهد. در این بخش کد قبلی را که برای تولید استرس استفاده کردیم مورد بازبینی قرار می‌دهیم تا متوجه شویم که انجام همین کار با استفاده از کتابخانه tempus-fugit تا چه حد آسان است:

1public class MyCounterTests {
2    @Rule
3    public ConcurrentRule concurrently = new ConcurrentRule();
4    @Rule
5    public RepeatingRule rule = new RepeatingRule();
6    private static MyCounter counter = new MyCounter();
7	
8    @Test
9    @Concurrent(count = 10)
10    @Repeating(repetition = 10)
11    public void runsMultipleTimes() {
12        counter.increment();
13    }
14 
15    @AfterClass
16    public static void annotatedTestRunsMultipleTimes() throws InterruptedException {
17        assertEquals(counter.getCount(), 100);
18    }
19}

در این کد از دو قاعده ارائه شده از سوی کتابخانه tempus-fugit استفاده کرده‌ایم. این قواعد تست‌ها را تفسیر کرده و به ما کمک می‌کنند تا رفتارهای مطلوب مانند تکرار و همروندی را به کار بگیریم. بنابراین به صورت کارآمدی عملیات را زیر تست، ده بار تکرار می‌کنیم و هر باز از ده نخ متفاوت استفاده می‌کنیم.

زمانی که تکرار و همروندی را افزایش دهیم، احتمال تشخیص نقایص مربوط با همروندی نیز افزایش خواهد یافت.

فریمورک Thread Weaver

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

در این بخش خواهیم دید که Thread Weaver در این زمینه چه کمکی می‌تواند به ما بکند. Thread Weaver به ما امکان می‌دهد که اجرای دو نخ مجزا را به روش‌های کاملاً مختلفی در هم تداخل دهیم و در مورد روش انجام کار نیز هیچ نگرانی نداشته باشیم. همچنین به ما این امکان را می‌دهد که کنترل کاملاً دقیقی روی شیوه تداخل نخ‌ها داشته باشیم.

در ادامه با شیوه بهبود تست قبلی آشنا می‌شویم:

1public class MyCounterTests {
2    private MyCounter counter;
3 
4    @ThreadedBefore
5    public void before() {
6        counter = new MyCounter();
7    }
8    @ThreadedMain
9    public void mainThread() {
10        counter.increment();
11    }
12    @ThreadedSecondary
13    public void secondThread() {
14        counter.increment();
15    }
16    @ThreadedAfter
17    public void after() {
18        assertEquals(2, counter.getCount());
19    }
20 
21    @Test
22    public void testCounter() {
23        new AnnotatedTestRunner().runTests(this.getClass(), MyCounter.class);
24    }
25}

در این کد دو نخ تعریف کرده‌ایم که تلاش می‌کنند یک شمارنده را افزایش دهند. Thread Weaver تلاش می‌کند تا این تست را با این نخ‌ها در همه سناریو‌های ممکن برای تداخل اجرا کند. بدین ترتیب در یکی از تداخل‌ها نقصی را که در کد ما کاملاً هویدا است، شناسایی می‌کند.

فریمورک MultithreadedTC

MultithreadedTC نیز یک فریمورک دیگر برای تست اپلیکیشن‌های همروند است. این فریمورک یک مترونوم دارد که از آن برای کنترل دقیق توالی فعالیت‌ها در چندین نخ استفاده می‌شود. این فریمورک از تست کیس‌هایی پشتیبانی می‌کند که یک تداخل خاص از نخ‌ها را اجرا می‌کنند. از این رو می‌توانیم هر نوع تداخل مورد نظر را در یک نخ مجزا به صورت قطعی تست کنیم.

ارائه یک توضیح کامل در مورد این کتابخانه با امکانات متنوع فراتر از حیطه این مقاله است، اما قطعاً می‌توانیم ببینیم که روش تنظیم سریع تست‌ها چگونه است و چطور امکان ایجاد تداخل بین نخ‌های اجرایی را فراهم می‌سازد.

در ادامه با روش تست کد خودمان به روشی با قطعیت بیشتر با استفاده از MultithreadedTC آشنا خواهیم شد:

1public class MyTests extends MultithreadedTestCase {
2    private MyCounter counter;
3    @Override
4    public void initialize() {
5        counter = new MyCounter();
6    }
7    public void thread1() throws InterruptedException {
8        counter.increment();
9    }
10    public void thread2() throws InterruptedException {
11        counter.increment();
12    }
13    @Override
14    public void finish() {
15        assertEquals(2, counter.getCount());
16    }
17 
18    @Test
19    public void testCounter() throws Throwable {
20        TestFramework.runManyTimes(new MyTests(), 1000);
21    }
22}

در این کد، دو نخ را تنظیم می‌کنیم که روی شمارنده مشترکی عمل می‌کنند و آن را افزایش می‌دهند. ما MultithreadedTC را طوری پیکربندی می‌کنیم که این تست را با این نخ‌ها برای حداکثر یک‌هزار تداخل مختلف راه‌اندازی کند تا این که تداخلی که منجر به بروز شکست می‌شود را شناسایی کند.

کتابخانه Java jcstress

OpenJDK یک «پروژه ابزار کد» دارد که برای کار روی پروژه‌های OpenJDK دارد. چند ابزار مفید وجود دارند که تحت این پروژه قرار دارند و از آن جمله Java Concurrency Stress Tests (به اختصار jcstress) است. این ابزار به عنوان یک «مهار آزمایشی» و مجموعه‌ای از تست‌ها برای بررسی صحت پشتیبانی از همروندی در جاوا توسعه یافته است.

با این که این یک ابزار آزمایشی است، اما می‌توانیم از آن برای تحلیل کد همروند و نوشتن تست‌ها برای یافتن نقص‌های مرتبط با آن استفاده کنیم. در ادامه با شیوه نوشتن تست برای کدی که در بخش‌های قبلی ارائه کردیم، آشنا خواهیم شد. مفهوم کار از نظر کاربردی کاملاً مشابه است:

1@JCStressTest
2@Outcome(id = "1", expect = ACCEPTABLE_INTERESTING, desc = "One update lost.")
3@Outcome(id = "2", expect = ACCEPTABLE, desc = "Both updates.")
4@State
5public class MyCounterTests {
6 
7    private MyCounter counter;
8 
9    @Actor
10    public void actor1() {
11        counter.increment();
12    }
13 
14    @Actor
15    public void actor2() {
16        counter.increment();
17    }
18 
19    @Arbiter
20    public void arbiter(I_Result r) {
21        r.r1 = counter.getCount();
22    }
23}

در این کد، کلاس را با یک حاشیه‌نویسی State نشانه‌گذاری کرده‌ایم که نشان می‌دهد این کلاس داده‌هایی را نگهداری می‌کند که از سوی چند نخ تغییر می‌یابند. همچنین از یک حاشیه‌نویسی به نام Actor استفاده می‌کنیم که متدهایی را که اکشن‌های اجرایی نخ‌های مختلف را نگه‌داری می‌کنند، مشخص می‌سازد.

در نهایت متدی داریم که با حاشیه‌نویسی Arbiter نشانه‌گذاری شده است که اساساً تنها یک بار همه اکتورهایی که بازدید کرده است، بازدید می‌کند. همچنین از حاشیه‌نویسی Outcome برای تعریف انتظارات خود استفاده کرده‌ایم.

در مجموع تنظیمات کار کاملاً آسان و پیگیری آن سرراست است. ما می‌توانیم این کد را با استفاده از یک «مهار تست» که از سوی فریمورک ارائه می‌شود، اجرا کنیم و همه کلاس‌های حاشیه‌نویسی شده با JCStressTest را یافته و آن‌ها را در چند تکرار اجرا کنیم تا همه تداخل‌های ممکن را به دست آوریم.

روش‌های دیگر برای شناسایی مشکلات همروندی

نوشتن تست‌ها برای کد همروند کاری دشوار، اما ممکن است. در بخش‌های قبلی این مقاله، با برخی چالش‌های انجام این کار و همچنین روش‌های فائق آمدن بر آن‌ها آشنا شدیم. با این حال ممکن است نتوانیم همه مشکلات محتمل همروندی را صرفاً از طریق تست‌ها شناسایی کنیم. به خصوص در مواردی که هزینه‌های نموی نوشتن تست‌ها بر مزیت آن‌ها غلبه می‌کند، این موضوع مشهودتر است.

از این رو در مجموع با تعداد معقولی از تست‌های خودکار می‌توانیم از تکنیک‌های دیگر برای شناسایی مشکلات همروندی استفاده کنیم. به این ترتیب احتمال یافتن مشکلات همروندی بدون دیگری گسترده با پیچیدگی‌های تست‌های خودکار افزایش می‌یابد. برخی از این موارد را در این بخش توضیح خواهیم داد.

تحلیل استاتیک

منظور از تحلیل استاتیک، آنالیز یک برنامه بدون اجرای عملی آن است. شاید بپرسید چه نوع کدی می‌تواند چنین تحلیلی را اجرا کند. در ادامه این موضوع را توضیح خواهیم داد؛ اما ابتدا باید تفاوت تحلیل استاتیک را با تحلیل دینامیک متوجه شویم. تست‌های unit که تا به اینجا نوشتیم، باید اجرا می‌شدند تا می‌توانستند برنامه مورد نظر را تست کنند. به همین دلیل است که آن‌ها بخشی از چیزی هستند که «تحلیل دینامیک» (Dynamic Analysis) نامیده می‌شود.

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

با این که امکان بررسی چشمی کد و مقایسه آن با رویه‌های مناسب کدنویسی و قواعد تهیه شده وجود دارد، اما باید اذعان کرد که این کار در برنامه‌های بزرگ‌تر عملیاتی نیست. با این حال چندین ابزار وجود دارند که این تحلیل را برای ما انجام می‌دهند. این ابزارها نسبتاً بلوغ مناسبی یافته‌اند و قواعد گسترده‌ای برای انواع مختلف زبان‌های برنامه‌نویسی ارائه می‌کنند.

یک ابزار تحلیل استاتیک مناسب برای جاوا FindBugs است. این ابزار به دنبال وهله‌هایی از «الگوهای باگ» می‌گردد. منظور از الگوی باگ، قطعه کدی است که غالباً منجر به بروز خطا می‌شود. این خطا می‌تواند ناشی از دلایل مختلفی از قبیل قابلیت‌های دشوار زبان، وجود درک نادرست از متدها و نامتغیرهای اشتباه باشد.

FindBugs بایت‌کد جاوا را بررسی کرده و الگوهای باگ را بدون اجرای عملی آن شناسایی می‌کند. این یک روش کاملاً آسان برای استفاده است و اجرای سریعی نیز دارد. FindBugs باگ‌ها را از دسته‌بندی‌های مختلف مانند شرط‌ها، طراحی و کدهای تکراری گزارش می‌کند.

این ابزار نقص‌های مرتبط با همروندی را نیز شناسایی می‌کند. با این حال، باید اشاره کرد که FindBugs ممکن است موارد مثبت نادرستی را گزارش کند. این موارد در عمل اندک هستند، اما باید با تحلیل دستی بررسی شوند.

بررسی مدل

بررسی مدل یک روش برای بررسی این نکته است که آیا یک مدل «حالت متناهی» (finite-state)‌ از یک سیستم دارای شرایط خاصی هست یا نه. گرچه ممکن است این تعریف بیش از حد آکادمیک به نظر برسد، اما با توضیحی که در ادامه می‌دهیم روشن‌تر خواهد شد.

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

این مشخصه‌ها شیوه رفتار مدل را برای این که صحیح تلقی شود، معین می‌کنند. به طور کلی این مشخصه‌ها همه الزامات سیستمی که مدل ارائه می‌کند را در خود دارند. یکی از روش‌های به دست آوردن مشخصه‌ها، استفاده از فرمول منطق زمانی است که از سوی «امیر پنوئلی» (Amir Pnueli) تهیه شده است.

با این که از نظر منطقی، امکان اجرای بررسی مدل به صورت دستی وجود دارد، اما کاری کاملاً غیر بهینه است. خوشبختانه ابزارهای زیادی وجود دارند که در این زمینه به کمک ما می‌آیند. یکی از این ابزارها برای جاوا، Java PathFinder (به اختصار JPF است. JPF بر اساس سال‌ها تجربه و آزمون در ناسا توسعه یافته است.

JPF به طور اختصاصی یک بررسی‌‌کننده مدل برای بایت‌کد جاوا است. این بررسی‌کننده یک برنامه را در همه روش‌های ممکن اجرا می‌کند و به این ترتیب تخطی از مشخصه‌هایی مانند بن‌بست و استثناهای مدیریت‌نشده را در همه مسیرهای اجرایی محتمل بررسی می‌کند. از این رو می‌تواند در یافتن نقص‌های مرتبط با همروندی در هر برنامه‌ای مفید باشد.

دیگر راهکارهای باقیمانده

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

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

کاهش پیچیدگی

پیچیدگی عاملی است که موجب می‌شود تست کردن یک برنامه حتی بدون وجود عناصر همروند نیز دشوار شود. در صورت وجود همروندی این مسئله دوچندان غامض می‌شود. بدیهی است که درک برنامه‌های ساده‌تر و کوچک‌تر، راحت‌تر است و از این رو می‌توان به طرز مؤثرتری آن‌ها را تست کرد. چند الگوی بهینه مانند SPP یعنی «الگوی مسئولیت منفرد» (Single Responsibility Pattern) و KISS یعنی «کدنویسی به ساده‌ترین روش ممکن»‌ (Keep It Stupid Simple) و موارد دیگر از این دست وجود دارند که به ما کمک می‌کنند تا این مقصود را عملی سازیم.

با این که موارد مطرح شده در ادامه مستقیماً به تست کدهای همروند مربوط نمی‌شوند، اما در نهایت موجب آسان‌تر شدن این کار خواهند شد.

از عملیات اتمیک استفاده کنید

عملیات اتمیک به آن دسته از عملیات گفته می‌شود که کاملاً مستقل از یکدیگر اجرا می‌شوند. از این رو دشواری‌های پیش‌بینی و تست کردن تداخل‌ها کاملاً رفع می‌شود. برای نمونه عملیات «مقایسه و جایگزینی» (Compare-and-Swap) یک مثال خوب از دستورالعمل‌های اتمیک است. در این عملیات به بیان ساده محتوای یک مکان حافظه با یک مقدار مفروض مقایسه می‌شود و تنها در صورتی که با هم برابر باشند، محتوای آن مکان حافظه ویرایش خواهد شد.

اغلب ریزپردازنده‌های مدرن نسخه‌ای از این دستورالعمل را ارائه می‌کنند. جاوا یک دسته از کلاس‌های اتمیک مانند AtomicInteger و AtomicBoolean دارد که در پس‌زمینه از مزیت دستورالعمل‌های compare-and-swap بهره می‌گیرند.

از Immutability حداکثر بهره را بگیرید

در برنامه‌نویسی چندنخی، داده‌های مشترک که امکان دستکاری دارند، همواره می‌توانند موجب بروز خطا شوند. «تغییرناپذیری» (Immutability) به شرایطی اشاره دارد که در آن ساختمان داده نمی‌تواند پس از وهله‌سازی تغییر یابد. این یک گزینه ایده‌آل برای برنامه‌های همروند محسوب می‌شود. اگر حالت یک شیء نتواند پس از ایجاد شدن تغییر یابد، نخ‌های رقیب لزومی به استفاده از «انحصار متقابل» روی آن ندارند. به این ترتیب نوشتن و تست کردن برنامه‌های همروند به میزان زیادی تسهیل می‌شود.

با این حال، توجه داشته باشید که ممکن است همواره این آزادی عمل را برای انتخاب Immutability نداشته باشید، اما در مواردی که امکان آن میسر باشد، باید حتماً از آن بهره بگیرید.

از حافظه مشترک استفاده نکنید

اغلب مشکلات مرتبط با برنامه‌نویسی چندنخی را می‌توان به این مسئله نسبت داد که یک حافظه مشترک بین نخ‌های در حال رقابت وجود دارد. امکان رهایی از شر این حافظه مشترک وجود دارد، اما با این حال ما به یک سازوکار ارتباطی بین نخ‌ها نیاز داریم.

الگوهای طراحی جایگزین برای اپلیکیشن‌های همروند وجود دارند که این امکان ارتباط را در اختیار ما قرار می‌دهند. یکی از الگوهای رایج «مدل آکتور» (Actor Model) است که آکتور را به عنوان یک واحد ابتدایی همروندی تجویز می‌کند. در این مدل اکتورها از طریق ارسال پیام با یکدیگر ارتباط می‌گیرند.

Akka یک فریمورک است که در Scala نوشته شده است از مدل آکتور برای ارائه بهتر همروندی بهره می‌گیرد.

سخن پایانی

در این مقاله به بررسی مبانی مرتبط با برنامه‌نویسی همروند (Concurrent) پرداختیم و به طور خاص همروندی چندنخی را در جاوا بررسی کردیم. همچنین به بررسی چالش‌های تست کدهای همروند در جاوا به خصوص در زمان بهره‌گیری از داده‌های مشترک پرداختیم. به علاوه برخی ابزارها و تکنیک‌های موجود برای تست کد همروند را بررسی کردیم.

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

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

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