آغاز یک نخ در جاوا – از صفر تا صد


در این راهنما به بررسی روشهای مختلف آغاز یک نخ در جاوا و اجرای وظایف موازی میپردازیم. این موضوع به طور خاص در مواردی که با عملیات طولانی یا مکرر سر و کار داریم و نمیتوانیم آن را روی «نخ اصلی» (Main Thread) اجرا کنیم، مفید خواهد بود. همچنین در مواردی که تراکنش UI را نمیتوان به منظور انتظار برای نتایج عملیات متوقف ساخت، میتوان از این تکنیک استفاده کرد.
مقدمات اجرای یک نخ
میتوان به سادگی منطقی نوشت که یک نخ موازی را با استفاده از فریمورک Thread در جاوا اجرا کند.
در ادامه مثال سادهای از این مورد را با بسط دادن کلاس Thread میبینید:
اینک میتوانیم کلاس دوم را برای مقداردهی آغاز یک نخ بنویسیم:
اکنون فرض کنید لازم است چند نخ را آغاز کنیم:
کد ما بسیار ساده است و کاملاً مشابه نمونههایی است که میتوان در هر مرجع آنلاینی دید.
البته این کد فاصله زیادی با کد عملیاتی دارد، چون در آنجا مدیریت منابع به روش صحیح حائز اهمیتی ضروری است و باید از سوئیچ کردن زیاد بین context و مصرف بیش از حد حافظه خودداری کرد. بنابراین برای این که کد آماده استفاده در محیط پروداکشن بنویسیم، باید کد قالبی (boilerplate) بیشتری ارائه کنیم که موارد زیر را لحاظ کرده باشد:
- ایجاد مداوم نخهای جدید
- تعداد نخهای زنده همزمان
- آزادسازی نخها: این موضوع برای نخهای daemon حائز اهمیت بالایی است و از نشت حافظه جلوگیری میکند.
اگر بخواهیم، میتوانیم کد مناسب برای لحاظ کردن همه موارد فوق بنویسیم، اما چرا باید چرخ را از نو اختراع کنیم؟
فریمورک ExecutorService
ExecutorService الگوی طراحی «استخر نخ» (Thread Pool) را پیادهسازی کرده است و مسئولیت مدیریت نخ را که در بخش قبلی اشاره کردیم بر عهده می گرد. علاوه بر آن برخی قابلیتهای کاملاً مفید مانند قابلیت استفاده مجدد از نخ و صفبندی وظایف را نیز عرضه میکند. این الگو به نام «ورکر مکرر» (Replicated Worker) یا مدل worker-crew نیز نامیده میشود.
به طور خاص قابلیت استفاده مجدد از نخ، بسیار مهم است زیرا در اپلیکیشنهای بزرگ، تخصیص و آزادسازی اشیای نخ اصلی موجب ایجاد سربار زیادی برای مدیریت حافظه میشود. با استفاده از نخهای ورکر میتوانیم سربار ناشی از ایجاد نخ را کاهش دهیم. فریمورک ExecutorService برای سهولت پیکربندی نخ، یک سازنده ساده و چند گزینه پیکربندی مانند نوع صف، کمینه و بیشینه تعداد نخها و روش نامگذاریشان ارائه کرده است.
آغاز یک وظیفه با Executor
به لطف فریمورک قوی ExecutorService میتوانیم نقطه تمرکز خود را از آغاز نخها به ارائه وظایف متوجه کنیم. در ادامه با شیوه تحویل یک وظیفه ناهمگام به Executor آشنا میشوید:
دو متد وجود دارند که میتوان استفاده کرد. یکی execute است که هیچ چیز بازگشت نمیدهد و دیگری submit است که یک Future بازگشت میدهد. درون Future نتیجه محاسبات قرار دارد.
آغاز یک وظیفه با CompletableFutures
برای بازیابی نتیجه نهایی از یک شیء Futures میتوانیم از متد get که در شیء وجود دارد استفاده کنیم، اما این کار موجب انسداد نخ والد تا زمان پایان محاسبات میشود. به طور جایگزین میتوانیم با افزودن مقداری منطق بیشتر به وظیفه از انسداد بلوک جلوگیری کنیم، اما این کار موجب افزایش پیچیدگی کد میشود. جاوا 1.8 یک فریمورک جدید بر مبنای سازنده Future برای بهبود کار با نتیجه محاسبات ارائه کرده است که CompletableFuture نام دارد.
CompletableFuture اقدام به پیادهسازی CompletableStage میکند که مجموعه وسیعی از متدها برای الحاق callback-ها و همچنین اجتناب از عملیات مختلف plumbing برای اجرای یک کار خاص روی نتیجه پس از آماده شدن در بر دارد. پیادهسازی تحویل یک وظیفه بسیار آسانتر است:
در کد فوق supplyAsync یک Supplier میگیرد که شامل کدی است که میخواهیم به صورت ناهمگام اجرا شود و در این مورد خاص یک پارامتر لامبدا است. این وظیفه اکنون صراحتاً به ForkJoinPool.commonPool() تحویل شده است، همچنین میتوانیم Executor ترجیحی خودمان را به عنوان پارامتر دوم بیان کنیم.
اجرای با تأخیر یا دورهای وظایف
زمانی که روی وب اپلیکیشن پیچیده کار میکنیم، ممکن است لازم باشد وظایفی را در زمانهای خاص و شاید به طور منظم اجرا کنیم. جاوا ابزارهای مختلفی برای کمک به اجرای با تأخیر یا مکرر عملیات مختلف ارائه کرده است:
- java.util.Timer
- java.util.concurrent.ScheduledThreadPoolExecutor
تایمر
تایمر (Timer) امکانی است که موجب زمانبندی وظایف برای اجرای آتی در یک نخ پسزمینه میشود. وظایف را میتوان برای اجرای یکباره زمانبندی کرد یا آن را برای اجرای مکرر در بازههای منظم برنامهریزی نمود.
در مثال زیر به برسی کدی میپردازیم که در صورت نیاز به اجرای یک وظیفه پس از یک ثانیه تأخیر باید نوشت:
اکنون یک زمانبندی اجرای مکرر نیز اضافه میکنیم:
این بار وظیفه پس از تأخیر مشخص شده اجرا میشود و پس از سپری شدن دوره زمانی مورد نظر دوباره اجرا خواهد شد.
ScheduledThreadPoolExecutor
ScheduledThreadPoolExecutor متدهایی مشابه کلاس تایمر دارد:
در نهایت فرایند اجرای مکرر وظایف را نیز اضافه میکنیم:
کد فوق یک وظیفه را پس از تأخیر اولیه 100 میلیثانیه اجرا میکند و سپس همان وظیفه را پس از هر 450 میلیثانیه اجرا خواهد کرد.
اگر پردازنده نتواند اجرای وظیفه را در زمانی پیش از رخداد بعدی به پایان ببرد، ScheduledExecutorService صبر میکند تا وظیفه کنونی کامل شود و سپس آن را اجرا میکند.
برای جلوگیری از این زمان انتظار میتوانیم از ()scheduleWithFixedDelay استفاده کنیم که چنان که از نامش پیدا است، تضمین میکند که یک تأخیر با طول ثابت همواره ببن تکرارهای وظیفه وجود داشته باشد.
کدام ابزار بهتر است؟
اگر مثال فوق را اجرا کنیم، نتیجه محاسبات در همه موارد یکسان است. زمانی که یک فریمورک چندین گزینه ارائه میکند، درک فناوریهای زیربنایی آنها در تصمیمگیری آگاهانه مؤثر است. در ادامه به بررسی دقیقتر کارکرد درونی این موارد میپردازیم.
Timer
- تضمین زمان حقیقی را ارائه نمیکند، وظایف با استفاده از متد Object.wait(long) زمانبندی میشوند.
- یک نخ پسزمینه منفرد وجود دارد و از این رو وظایف به ترتیب اجرا میشوند و در صورتی که یک وظیفه تأخیر داشته باشد، بقیه موارد نیز به تأخیر میافتند.
- استثناهای زمان اجرا که در یک TimerTask رخ میدهند میتوانند تنها نخ موجود را از بین ببرند و از این رو timer متوقف میشود.
ScheduledThreadPoolExecutor
- با هر تعداد نخ میتواند پیکربندی شود.
- میتوان از مزیت هستههای موجود CPU بهره گرفت.
- استثناهای زمان اجرا به دام میافتند و میتوان در صورت نیاز آنها را با override کردن متد afterExecute از ThreadPoolExecutor مدیریت کرد.
- وظیفهای که استثنا دارد را لغو میکند و به وظایف دیگر اجازه میدهد که به اجرای خود تداوم بدهند.
- به زمانبندی سیستم عامل برای ردگیری مناطق زمانی، تأخیرها، زمان خورشیدی و غیره تکیه دارد.
- اگر نیاز به هماهنگی بین وظایف چندگانه داشته باشیم، مثلاً منتظر تکمیل شدن یک وظیفه تحویل شده بمانیم، API همکاری ارائه میکند.
- API بهتری برای مدیریت چرخه عمر نخ ارائه کرده است.
تفاوت بین 3 Future و ScheduledFuture
در نمونه کدهای این مقاله میتوان دید که ScheduledThreadPoolExecutor نوع خاصی از Future به نام ScheduledFuture بازگشت میدهد. ScheduledFuture هم اینترفیس Future و هم Delayed را بسط میدهد. از این رو متد اضافی getDelay را که تأخیر باقی مانده برای وظیفه کنونی را بازگشت میدهد، به ارث میبرد. این Future از سوی RunnableScheduledFuture بسط یافته است که یک متد برای بررسی دورهای بدون وظیفه اضافه میکند.
ScheduledThreadPoolExecutor همه این سازهها را از طریق کلاس داخلی ScheduledFutureTask پیادهسازی میکند و از آنها برای کنترل چرخه عمر وظیفه بهره میگیرد.
سخن پایانی
در این راهنما به بررسی فریمورکهای مختلف موجود برای آغاز نخ و اجرای موازی وظایف در جاوا پرداختیم. سپس به بررسی تفصیلی تفاوتهای بین Timer و ScheduledThreadPoolExecutor پرداختیم. کدهای مطرح شده در این مقاله را میتوانید در این ریپوی گیتهاب (+) ملاحظه کنید.
اگر این مطلب برای شما مفید بوده است، آموزشهای زیر نیز به شما پیشنهاد میشوند:
- مجموعه آموزشهای جاوا (Java)
- مجموعه آموزشهای برنامهنویسی
- آموزش برنامه نویسی جاوا
- مفاهیم برنامهنویسی شیئگرا در جاوا — به زبان ساده
- زبان برنامه نویسی جاوا (Java) — از صفر تا صد
==