متغیرهای اتمی در جاوا — به زبان ساده
حالت اشتراکی به سادگی موجب بروز مشکلاتی در زمان استفاده از «همزمانی» (Concurrency) میشود. اگر دسترسی به اشیای تغییرپذیر اشتراکی به درستی مدیریت نشود، اپلیکیشنها میتوانند به سرعت مستعد بروز خطا شوند و خطاهای همزمانی مختلف بروز یابند که تشخیص آنها دشوار است. در این مقاله، به بررسی استفاده از «قفلها» (lock-ها) برای مدیریت دسترسی همزمان میپردازیم. برخی از معایب مرتبط با قفلها را بررسی میکنیم و در نهایت متغیرهای اتمی در جاوا را به عنوان جایگزین مورد بحث و بررسی قرار میدهیم.
قفلها
در این بخش به بررسی کلاس زیر میپردازیم:
1public class Counter {
2 int counter;
3
4 public void increment() {
5 counter++;
6 }
7}
در مورد محیط «تکنخی» (single threaded) این راهحل به خوبی کار میکند، با این حال، به محض این که به بیش از یک نخ امکان نوشتن بدهیم، شروع به دریافت نتایج ناسازگار خواهیم کرد.
دلیل این امر آن است که عملگر افزایشی ساده (++counter) ممکن است در ظاهر مانند یک عملیات اتمی به نظر برسد، اما در عمل ترکیبی از سه عملیات به صورت به دست آوردن یک مقدار افزایش و نوشتن مجدد مقدار بهروزرسانی شده عمل میکند. اگر دو نخ تلاش کنند تا مقداری را به صورت همزمان به دست آورند، ممکن است موجب از دست رفتن بهروزرسانیها شوند.
یکی از روشهای مدیریت دسترسی به یک شیء استفاده از قفل است. این کار با استفاده از کلیدواژه synchronized در امضای متد increment میسر است. کلیدواژه synchronized ما را مطمئن میسازد که هر بار تنها یک نخ میتواند وارد متد شود:
1public class SafeCounterWithLock {
2 private volatile int counter;
3
4 public synchronized void increment() {
5 counter++;
6 }
7}
به علاوه باید کلیدواژه volatile را اضافه کنیم تا مطمئن شویم که ارجاع مناسبی میان نخها وجود دارد. استفاده از قفلها برای حل این مشکل موجب افت عملکرد میشود. زمانی که چند نخ تلاش میکنند تا یک قفل را به دست آورند، یکی از آنها موفق میشود، در حالی که بقیه نخها یا مسدود و یا معلق میشوند.
فرایند تعلیق و سپس ازسرگیری یک نخ بسیار پرهزینه است و بر کارایی کلی سیستم تأثیر میگذارد. در یک برنامه کوچک مانند counter زمان صرف شده روی برای سوئیچ کردن context ممکن است بسیار بیشتر از زمان اجرای واقعی کد باشد، از این رو موجب کاهش چشمگیری در کارایی کلی میشود.
عملیات اتمی
شاخهای از پژوهشها وجود دارند که روی ایجاد الگوریتمهای غیر مسدودساز برای محیطهای همزمان تمرکز دارد. این الگوریتمها از دستورالعملهای ماشین اتمی در سطح پایین مانند compare-and-swap به اختصار CAS بهره میگیرند تا از یکپارچگی دادهها مطمئن شوند. یک عملیات معمول CAS روی سه عملوند کار میکند:
- مکان حافظه که روی آن کار میکنی (M)
- مقدار مورد انتظار فعلی (A) برای متغیر
- مقدار جدید (B) که باید تعیین شود
عملیات CAS به صورت خودکار مقدار M را به B بهروزرسانی میکند، اما تنها در صورتی این کار را انجام میدهد که مقدار موجود در M با A مطابقت داشته باشد، در غیر این صورت هیچ عملی انجام نمیشود.
در هر دو مورد مقدار موجد در MM بازگشت مییابد. این کار موجب ترکیب سه مرحله دریافت مقدار، مقایسه مقدار و بهروزرسانی مقدار در یک عملیات منفرد سطح ماشین میشود.
زمانی که چند نخ تلاش کنند تا مقداری را از طریق CAS بهروزرسانی کنند، یکی از آنها برنده میشود و مقدار را بهروزرسانی میکند. با این حال برخلاف مورد قفلها، دیگر هیچ نخی تعلیق نمیشود. به جای آن، به اطلاع نخهای دیگر میرسد که موفق نشدهاند مقدار را بهروزرسانی کنند. این نخها میتوانند به کار خود ادامه دهند و از سوئیچ CAS به کلی خودداری میشود. همچنین باید سناریویی را که عملیات CAS موفق نمیشود را مدیریت کنیم. یک روش این است که این کار را تا زمان موفق شدن تکرار کنیم، یا این که میتوانیم هیچ کاری انجام ندهیم و بسته به مورد به کار خود ادامه دهیم.
متغیرهای اتمی در جاوا
رایجترین کلاسهای متغیر اتمی در جاوا شامل AtomicInteger ،AtomicLong ،AtomicBoolean و AtomicReference هستند. این کلاسها به ترتیب نماینده یک ارجاع int ،long ،Boolean و object هستند که میتوانند به صورت اتمی بهروزرسانی شوند. متدهای اصلی عرضه شده از سوی این کلاسها به شرح زیر هستند:
- ()get – این متد مقدار را از حافظه میگیرد، به طوری که تغییرهای ایجاد شده از سوی نخهای دیگر هویدا هستند که معادل خواندن یک متغیر فرّار (volatile) است.
- ()set – این متد مقدار را در حافظه مینویسد، به طوری که این تغییر در معرض دید نخهای دیگر است و معادل نوشتن یک متغیر فرّار است.
- ()lazySet – در نهایت مقدار را در حافظه مینویسد که ممکن است با عملیاتهای متعاقب مرتبط با حافظه ترتیب متفاوتی داشته باشد. یک کاربرد آن تهیسازی ارجاعها است که به منظور garbage collection انجام مییابد و دیگر هرگز نمیتوانند مورد دسترسی قرار گیرند. در این مورد عملکرد بهتر از طریق ایجاد تأخیر در نوشتن مقدار تهی در متغیر volatile به دست میآید.
- ()compareAndSet – چنان که در بخش قبلی توضیح دادیم، زمانی که موفق شود مقدار true و در غیر این صورت مقدار false بازگشت میدهد..
- ()weakCompareAndSet – همان طور که در بخش قبلی توضیح دادیم، اما این بار ضعیفتر عمل میکند چون پیش از تعیین ترتیب انجام نمییابد. این بدان معنی است که لزوماً بهروزرسانیهای انجامیافته از سوی متغیرهای دیگر را نمیبیند.
یک شمارنده «نخ-امن» که با AtomicInteger در مثال زیر پیادهسازی شده است:
1public class SafeCounterWithoutLock {
2 private final AtomicInteger counter = new AtomicInteger(0);
3
4 public int getValue() {
5 return counter.get();
6 }
7 public void increment() {
8 while(true) {
9 int existingValue = getValue();
10 int newValue = existingValue + 1;
11 if(counter.compareAndSet(existingValue, newValue)) {
12 return;
13 }
14 }
15 }
16}
چنان که میبینید عملیات compareAndSet را در صورت شکست تکرار میکنیم، چون میخواهیم مطمئن شویم که فراخوانی به متد increment همواره مقدار را 1 واحد افزایش میدهد.
سخن پایانی
در این راهنمای کوتاه به برررسی روش جایگزین برای مدیریت همزمانی به منظور جلوگیری از معایب روش قفلگذاری پرداختیم. همچنین متدهای اصلی عرضه شده از سوی کلاسهای متغیر اصلی در جاوا را بررسی کردیم. همه کدهای ارائه شده در این مقاله را میتوانید در این ریپوی گیت هاب (+) ملاحظه کنید.
اگر این مطلب برای شما مفید بوده است، آموزشهای زیر نیز به شما پیشنهاد میشوند:
- مجموعه آموزشهای برنامهنویسی جاوا (Java)
- آموزش کامل جاوا (Java)
- مجموعه آموزشهای برنامهنویسی
- آغاز یک نخ در جاوا — از صفر تا صد
- توقف نخ در جاوا — راهنمای جامع
==