متدهای wait و notify در جاوا — از صفر تا صد
در این مقاله به بررسی یکی از بنیادیترین سازوکارهای جاوا میپردازیم که «همگامسازی نخ» (Thread Synchronization) نام دارد. ابتدا برخی اصطلاحهای مرتبط با همزمانی را معرفی میکنیم. سپس یک اپلیکیشن ساده مینویسیم که مشکلات همزمانی در آن بررسی میشوند. هدف از ین مقاله آشنایی بیشتر با متدهای wait و notify در جاوا است.
همگامسازی نخ در جاوا
در محیط چندنخی، نخهای مختلف تلاش میکنند تا منابع یکسانی را ویرایش کنند. اگر نخها به درستی مدیریت نشوند، این وضعیت منجر به مشکلاتی در یکپارچگی میشود.
بلوکهای محافظت شده در جاوا
یکی از ابزارهایی که میتوان در جاوا برای هماهنگ کردن کارها روی نخهای مختلف استفاده کرد، «بلوکهای محافظ» (Guarded Blocks) نام دارند. این بلوکها پیش از ازسرگیری یک اجرا، شرط خاصی را بررسی میکنند. بدین ترتیب میتوان کارهای زیر را انجام داد:
- ()Object.wait – برای تعلیق یک نخ.
- ()Object.notify – برای ازسرگیری یک نخ.
این موضوع را با مشاهده نمودار زیر که چرخه عمر یک نخ را به تصویر کشیده است، بهتر درک میکنیم:
توجه کنید که روشهای مختلفی برای کنترل کردن چرخه عمر وجود دارند؛ با این حال در این مقاله قصد داریم صرفاً روی ()wait و ()notify تمرکز کنیم.
متد ()wait
اگر بخواهیم ساده بیان کنیم، وقتی متد ()wait فراخوانی میشود، نخ جاری باید تا زمانی که نخ دیگری ()notify یا ()notifyAll را روی همان شیء فراخوانی نکرده است، صبر کند.
به این منظور نخ جاری باید روی شیء نظارت داشته باشد. بر اساس مستندات جاوا این اتفاق در موارد زیر رخ میدهد:
- وهله همگامسازی شدهای از متد برای شیء مفروض اجرا شده باشد.
- بدنه یک بلوک همگامسازی شده روی شیء مفروض اجرا شده باشد.
- متدهای استاتیک همگامسازیشده برای اشیای آن نوع کلاس اجرا شود.
توجه کنید که در هر زمان، تنها یک نخ فعال میتواند نظارت شیء را بر عهده داشته باشد.
متد ()wait سه امضای overload شده دارد که در ادامه آنها را بررسی میکنیم.
()Wait
متد ()wait موجب میشود که نخ جاری به صورت نامحدود تا زمانی که نخ روی این شیء ()notify یا ()notifyAll را اجرا کند، منتظر بماند.
wait(long timeout)
با استفاده از این متد، میتوانیم یک timeout تعریف کنیم، که پس از رسیدن به آن زمان، نخ به صورت خودکار بیدار میشود. البته نخ میتواند پیش از رسیدن به این زمان خاص با استفاده از ()notify یا ()notifyAll بیدار شود.
توجه کنید که فراخوانی wait(0) همانند فراخوانی ()wait عمل میکند.
wait(long timeout, int nanos)
این نیز یک امضای دیگر متد است که همان کارکرد را ارائه میکند، اما تنها تفاوت آن در این است که دقت بالاتری ارائه کرد. دوره زمانی کلی (با مقیاس نانوثانیه) به صورت زیر محاسبه میشود:
1_000_000*timeout + nanos
متدهای ()notify و ()notifyAll
متد ()notify برای بیدار کردن نخهایی استفاده میشود که منتظر دسترسی به ناظر شیء هستند. چهار روش برای ارسال اعلان (notify) به نخهای منتظر وجود دارد.
()notify
متد ()notify موجب میشود که همه نخهای منتظر روی ناظر شیء به صورت دلخواه بیدار شوند. انتخاب این که دقیقاً کدام نخ باید بیدار شود غیرقطعی است و به پیادهسازی بستگی دارد.
از آنجا که ()notify یک نخ منفرد تصادفی را بیدار میکند، میتوان از آن برای پیادهسازی قفلگذاری «متقابلاً منحصر» در مواردی که نخها کارهای مشابهی انجام میدهند استفاده کرد، اما در اغلب موارد بهتر است این کار از طریق ()notifyAll انجام یابد.
()notifyAll
این متد همه نخهایی که روی ناظر شیء منتظر هستند را بیدار میکند. نخهای بیدار شده به روش معمول یعنی مانند همه نخهای دیگر عمل میکنند. اما پیش از آن که اجازه دهیم اجرای آنها تداوم یابد، باید همواره یک بررسی سریع برای شرط الزامی جهت ادامه نخ اجرا کنیم. دلیل این امر آن است که ممکن است موقعیتهایی وجود داشته باشند که در آن نخی بدون دریافت اعلان بیدار شده باشد. این حالت را در ادامه با بررسی مثالی معرفی میکنیم.
مسئله همگامسازی فرستنده-گیرنده
اکنون که با مقدمات موضوع آشنا شدیم، بهتر است یک اپلیکیشن ساده «فرستنده-گیرنده» (Sender-Receiver) را بررسی کنیم که از متدهای ()wait و ()notify برای هماهنگی بین آنها استفاده میکند:
- فرستنده بستههای دادهای را به گیرنده ارسال میکند.
- گیرنده نمیتواند بستههای داده را تا زمانی که ارسال آنها از سوی فرستنده پایان نیافته است، پردازش کند.
- به طور مشابه فرستنده نباید اقدام به ارسال بسته دیگری بکند تا این که گیرنده، بسته قبلی را پردازش کند.
ابتدا کلاس Data را تعریف میکنیم که شامل بستههای دادهای است که از فرستنده به گیرنده ارسال میشوند. ما از متدهای ()wait و ()notifyAll برای هماهنگی بین این دو استفاده میکنیم:
1public class Data {
2 private String packet;
3
4 // True if receiver should wait
5 // False if sender should wait
6 private boolean transfer = true;
7
8 public synchronized void send(String packet) {
9 while (!transfer) {
10 try {
11 wait();
12 } catch (InterruptedException e) {
13 Thread.currentThread().interrupt();
14 Log.error("Thread interrupted", e);
15 }
16 }
17 transfer = false;
18
19 this.packet = packet;
20 notifyAll();
21 }
22
23 public synchronized String receive() {
24 while (transfer) {
25 try {
26 wait();
27 } catch (InterruptedException e) {
28 Thread.currentThread().interrupt();
29 Log.error("Thread interrupted", e);
30 }
31 }
32 transfer = true;
33
34 notifyAll();
35 return packet;
36 }
37}
اتفاقاتی که در کد فوق رخ میدهند، به شرح زیر هستند:
- متغیر packet نشان میدهد که دادهای روی شبکه انتقال یافته است.
- یک متغیر بولی به نام transfer وجود دارد که فرستنده و گیرنده از آن برای همگامسازی استفاده میکنند.
- اگر این متغیر مقدار true داشته باشد، گیرنده باید منتظر فرستنده بماند تا پیامی ارسال کند.
- اگر مقدار آن false باشد، فرستند باید منتظر گیرنده بماند تا پیام را دریافت کند.
- فرستنده از متد ()send برای ارسال دادهها به گیرنده استفاده میکند.
- اگر transfer مقدار false داشته باشد، با فراخوانی متد ()wait روی این نخ، منتظر میمانیم.
- اما اگر مقدار آن true باشد، حالت را عوض میکنیم، پیام را تنظیم کرده و متد ()notifyAll را فرامیخوانیم تا نخهای دیگر را بیدار کنیم و مشخص سازیم که رویداد خاصی رخ داده است و میتوانند بررسی کنند آیا باید اجرای خود را ادامه دهند یا نه.
- به طور مشابه، گیرنده از متد receive() استفاده میکند:
- اگر transfer از سوی فرستنده به صورت false تعیین شده باشد، تنها آن است که ادامه مییابد، در غیر این صورت ()wait را روی این نخ فرامیخوانیم.
- زمانی که شرط برقرار باشد، حالت عوض میشود و همه نخهای در وضعیت منتظر، اعلانی دریافت میکنند که بیدار شوند و بسته دادهای که Receiver دارد بازگشت دهند.
چرا باید ()wait را درون یک حلقه while قرار دهیم؟
از آنجا که ()notify و ()notifyAll به صورت تصادفی نخهایی که روی این ناظر شیء منتظر هستند را بیدار میکند، مهم نیست که همواره شرط برقرار باشد. برخی اوقات ممکن است موقعیتی پیش بیاید که نخ بیدار شود، اما شرط هنوز تحقق نیافته باشد.
همچنین میتوانیم یک بررسی اجرا کنیم، تا از بیدار شدن بیهوده نخها جلوگیری کنیم. در این حالت یک نخ میتواند بدون دریافت هیچ اعلانی هم از حالت انتظار بیدار شود.
چرا باید متدهای send() و receive() را همگامسازی کنیم؟
ما سه متد را درون متدهای همگامسازیشده قرار میدهیم تا «قفلهای داخلی» (intrinsic locks) ارائه کنیم. اگر یک نخ متد ()wait را فراخوانی کند و قفل داخلی را نداشته باشد، خطایی ایجاد خواهد شد. به این ترتیب Sender و Receiver را پیادهسازی کرده و اینترفیس Runnable را روی هر دو اجرا میکنیم، به طوری که وهلههای آنها میتوانند از سوی یک نخ اجرا شوند. ابتدا به بررسی طرز کار Sender میپردازیم:
1public class Sender implements Runnable {
2 private Data data;
3
4 // standard constructors
5
6 public void run() {
7 String packets[] = {
8 "First packet",
9 "Second packet",
10 "Third packet",
11 "Fourth packet",
12 "End"
13 };
14
15 for (String packet : packets) {
16 data.send(packet);
17
18 // Thread.sleep() to mimic heavy server-side processing
19 try {
20 Thread.sleep(ThreadLocalRandom.current().nextInt(1000, 5000));
21 } catch (InterruptedException e) {
22 Thread.currentThread().interrupt();
23 Log.error("Thread interrupted", e);
24 }
25 }
26 }
27}
در مورد این فرستنده شرایط زیر وجود دارد:
- برخی بستههای داده تصادفی ایجاد میکنیم که در آرایه []packets و روی شبکه ارسال میشوند.
- برای هر بسته صرفاً متد vsend فراخوانی میشود.
- سپس متد ()Thread.sleep با یک بازه تصادفی فراخوانی میشود تا حالت پردازش سنگین در سمت سرور را شبیهسازی کنیم.
در نهایت بخش گیرنده (Receiver) را پیادهسازی میکنیم:
1public class Receiver implements Runnable {
2 private Data load;
3
4 // standard constructors
5
6 public void run() {
7 for(String receivedMessage = load.receive();
8 !"End".equals(receivedMessage);
9 receivedMessage = load.receive()) {
10
11 System.out.println(receivedMessage);
12
13 // ...
14 try {
15 Thread.sleep(ThreadLocalRandom.current().nextInt(1000, 5000));
16 } catch (InterruptedException e) {
17 Thread.currentThread().interrupt();
18 Log.error("Thread interrupted", e);
19 }
20 }
21 }
22}
در این کد صرفاً متد ()load.receive را درون یک حلقه فراخوانی میکنیم تا این که آخرین بسته داده End را دریافت کنیم. در ادامه این اپلیکیشن را در عمل بررسی میکنیم:
1public static void main(String[] args) {
2 Data data = new Data();
3 Thread sender = new Thread(new Sender(data));
4 Thread receiver = new Thread(new Receiver(data));
5
6 sender.start();
7 receiver.start();
8}
بدین ترتیب خروجی زیر به دست میآید:
- First packet
- Second packet
- Third packet
- Fourth packet
اکنون همه بستههای داده را با ترتیب و توالی صحیح دریافت میکنیم و شیوه ارتباطی مناسبی بین فرستنده و گیرنده برقرار ساختهایم.
سخن پایانی
در این مقاله، برخی از مفاهیم بنیادی «همگامسازی» (synchronization) را در جاوا مورد بررسی قرار دادیم. به طور خاص روی شیوه استفاده از متدهای ()wait و ()notify برای حل مشکلات متداول همگامسازی تمرکز کردیم. در نهایت یک مثال کد را معرفی کرده و مفاهیم مطرح شده در این مقاله را به صورت عملی در آن بررسی کردیم. در انتها باید اشاره کنیم که همه این API-های سطح پایین از قبیل ()wait() ،notify و ()notifyAll متدهای سنتی هستند که گرچه به خوبی کار میکنند، اما برخی مکانیسمهای سطح بالاتر مانند اینترفیسهای بومی جاوا به نام Lock و Condition نیز وجود دارند که استفاده از آنها سادهتر و بهتر است.
اگر این مطلب برای شما مفید بوده است، آموزشهای زیر نیز به شما پیشنهاد میشوند:
- مجموعه آموزشهای جاوا (Java)
- مجموعه آموزشهای برنامهنویسی
- گنجینه آموزشهای جاوا (Java)
- آشنایی با امکانات جدید جاوا 14 - راهنمای کاربردی
- اسکنر جاوا - به زبان ساده
==