چندنخی (Multithreading)
همه میدانیم که کامپیوترهای امروزی قابلیت اجرای چند برنامه به صورت همزمان را دارند که هر برنامه در حال اجرا یک process مخصوص به خود را دارد و به این قابلیت چند وظیفهای (Multitasking) میگویند.
قابلیت دیگری که در نرم افزارها وجود دارد چندنخی است. چندنخی این امکان را به برنامه میدهد تا دو یا چند کار را همزمان انجام دهد. منظور از نخ در واقع یک روند اجرای مستقل در برنامه است. هر برنامه به طور پیشفرض یک نخ دارد که به آن Main Thread یا نخ اصلی میگویند.
فرض کنید روند اجرای یک برنامه در نخ اصلی آن به صورت زیر باشد:
نمایش پیغام به کاربر > دانلود یک فایل > نمایش پیغامی دیگر
وقتی پیغام اول به کاربر نمایش داده شود و سپس دانلود فایل شروع شود، در زمان دانلود فایل برنامه عملا غیر قابل استفاده خواهد بود تا وقتی که دانلود تمام شود. این خاصیت ترتیبی اجرا شدن کدها در برنامه است. اما اگر ما همین عمل دانلود فایل را در یک نخ جدا تعریف کنیم و آن را اجرا کنیم آنگاه نه تنها برنامه غیر قابل استفاده نیست بلکه در طول دانلود فایل کاربر میتواند با برنامه تعامل داشته باشد و کارهای دیگری نیز با برنامه انجام دهد. بنابراین برنامههایی که امروزه وجود دارند همه از این قابلیت بهره میبرند و یادگیری این مبحث برای هر برنامه نویس الزامی است.
ایجاد یک Thread
سیستم چندنخی در جاوا بر پایه کلاس Thread و اینترفیس Runnable بنا نهاده شده است. برای ایجاد یک thread نیز باید یکی از دو روش زیر را انتخاب کنیم:
۱ – ایجاد یک کلاس و ارثبری آن از کلاس Thread
۲ – ایجاد یک کلاس و پیادهسازیکردن اینترفیس Runnable در آن
البته در هر دو روش باید در نهایت از کلاس Thread برای اجرای threadها استفاده کنیم.
همانطور که میدانید در جاوا فقط میتوان از یک کلاس ارثبری داشت بنابراین اگر بخواهیم کلاس thread ای بسازیم که خود از کلاس دیگری ارثبری داشته باشد ناچاریم از روش دوم استفاده کنیم.
ساخت thread با استفاده از اینترفیس Runnable
در اینترفیس Runnable فقط یک متد به نام run تعریف شده است که کلاسی که این اینترفیس را پیادهسازی میکند باید در بدنه این متد کاری که قرار است thread انجام دهد را تعریف کند. هر وقت اجرای متد run به پایان برسد thread نیز به پایان خواهد رسید.
نکته مهم: توجه داشته باشید که کلاسهایی که برای ساخت یک thread مینویسیم به خودی خود یک thread نیستند بلکه به هر تعداد از این کلاس میتوان شی ساخت که هر شی یک thread است و کاری که در کلاس تعریف شده را انجام میدهد.
مثال: میخواهیم با استفاده از اینترفیس Runnable یک thread بسازیم:
public class MyThread implements Runnable { private String name = "MyThread"; @Override public void run() { System.out.println(name + " is starting..."); try { for (int i = 1; i <= 10; i++) { System.out.println(i + " In " + name); Thread.sleep(1000); } } catch (InterruptedException e) { System.out.println(name + " interrupted!"); } System.out.println("End of " + name); } }
۱- کلاسی به نام MyThread ایجاد کردیم و متغیری به نام name که اسم thread را در خود نگهداری میکند تعریف کردیم. این متغیر برای مشخصکردن اینکه کد در حال اجرا از کدام thread اجرا میشود استفاده کردیم.
۲- حلقهای در متد run نوشتیم که اعداد ۱ تا ۱۰ را چاپ میکند و مینویسد که این کار (چاپ عدد) در کدام thread انجام شده است. بعد از چاپ عدد متد sleep از کلاس Thread فراخوانی میشود. فراخوانی این متد باعث میشود تا thread ای که این متد در آن فراخوانی شده به مدت زمان معینی متوقف شود. این مدت زمان باید بر حسب میلیثانیه باشد. توجه داشته باشید که فراخوانی این متد ممکن است با رخدادن InterruptedException همراه باشد و بنابراین باید آن را مدیریت کرد.
۳- thread بالا شروع به چاپ اعداد ۱ تا ۱۰ میکند که مدت زمان بین چاپ هر عدد ۱ ثانیه است.
بعد از اینکه کلاسی طراحی کردیم که اینترفیس Runnable را پیاده سازی کند باید شیئی از کلاس Thread بسازیم و شیئی از کلاسی که اینترفیس Runnable را پیادهسازی میکند ایجاد کنیم و آن را به سازنده کلاس Thread بدهیم.
کد زیر را در متد main برنامه خود بنویسید:
Thread t = new Thread(new MyThread()); t.start(); for (int i = 1; i <= 10; i++) { System.out.println("*"); try { Thread.sleep(1000); } catch (InterruptedException e) { System.out.println("Main thread interrupted!"); } }
۱- ابتدا شیئی از کلاس Thread ایجاد کردیم و به سازنده آن شیئی از کلاس MyThread که در مثال قبل نوشتیم دادیم. سپس با فراخوانی متد start از شی ایجاد شده، thread ای که در مثال قبل نوشتیم شروع به کار میکند.
۲- سپس حلقهای نوشتیم که ۱۰ بار تکرار میشود و هر بار کاراکتر * را چاپ میکند و بین هربار چاپ این کاراکتر ۱ ثانیه فاصله وجود دارد.
اگر کدهای بالا را اجرا کنید مشاهده میکنید که در پنجره کنسول بعد از چاپ هر عدد که در t رخ میدهد یک * نیز چاپ میشود. در واقع هم t و هم Main Thread همزمان با هم کار میکنند.
نکته: در یک برنامهای که از threadهای متعدد استفاده میکند بهتر است Main Thread آخرین نخی باشد که به پایان میرسد. با نحوه صحیح انجام این کار آشنا خواهید شد.
ساخت thread با ارثبری از کلاس Thread
همانطور که گفتیم راه دیگر ساخت thread ایجاد کلاسی است که از کلاس Thread ارثبری داشته باشد. (البته استفاده از Runnable رایجتر است)
مثال: میخواهیم کلاس MyThread که در مثالهای قبل نوشتیم را به گونهای تغییر دهیم که به جای پیادهسازی اینترفیس Runnable از کلاس Thread ارثبری داشته باشد:
public class MyThread extends Thread { public MyThread(String name) { super(name); start(); } @Override public void run() { System.out.println(getName() + " is starting..."); try { for (int i = 1; i <= 10; i++) { System.out.println(i + " In " + getName()); Thread.sleep(1000); } } catch (InterruptedException e) { System.out.println(getName() + " interrupted!"); } System.out.println("End of " + getName()); } }
سازندهای برای این کلاس نوشتیم که یک پارامتر از نوع String به نام name دارد و در این سازنده، سازنده کلاس پدر را که دقیقا مثل همین سازنده یک پارامتر به نام name میگیرد فراخوانی کردیم و مقدار name را به آن دادیم. میتوانیم به هر thread یک نام بدهیم تا بتوانیم آن را شناسایی کنیم. با متد getName میتوان به این فیلد دسترسی داشت.
سپس متد start را در همان سازنده فراخوانی کردیم و بنابراین هر وقت که شیئی از این کلاس ساخته شود یک thread نیز ساخته شده و متد run آن اجرا خواهد شد.
اگر کد زیر را بنویسیم:
MyThread trd = new MyThread("First Thread");
خواهید دید که thread شروع به کار خواهد کرد. اگر چند شی از این کلاس بسازید به تعداد اشیا ساخته شده thread ایجاد میشود.
نکته: بهتر است تا زمانی که نیاز به Override کردن متدهای کلاس Thread ندارید از این روش برای ساخت thread استفاده نکنید.
متدهای isAlive و join
گاهی اوقات لازم است بدانیم که یک thread چه زمانی به پایان میرسد. برای کنترل این کار دو متد isAlive و join وجود دارد.
مثال: میخواهیم thread ای از کلاس MyThread که در مثال قبل نوشتیم ایجاد کنیم و تا وقتی که thread به پایان نرسیده است پیغامی چاپ کنیم:
MyThread trd = new MyThread("First Thread"); while(trd.isAlive()) { try { System.out.println(trd.getName() + " is alive!"); Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } }
این حلقه تا وقتی که مقدار بازگشتی isAlive برابر با true باشد تکرار میشود و هر دو ثانیه یک بار یک پیغام مبنی بر زندهبودن (در حال کار بودن) trd چاپ میکند.
راه دیگری برای صبر کردن تا پایان کار یک thread وجود دارد و آن استفاده از متد join است.
مثال:
MyThread trd = new MyThread("First Thread"); try { trd.join(); System.out.println("End of Main Thread"); } catch (InterruptedException e) { e.printStackTrace(); }
در این کد فراخوانی متد join از trd باعث میشود تا کدهای بعد از join وقتی اجرا شوند که trd به پایان میرسد. در واقع thread ای که این متد را از thread دیگری فراخوانی میکند تا پایان اجرای آن thread صبر میکند و پایان نمییابد تا بعد از به پایان رسیدن آن thread کار خود را ادامه دهد. همچنین InterruptedException را باید هنگام استفاده از متد join مدیریت کرد.
اولویت threadها (Thread Priorities)
هر thread دارای یک اولویت است. در حالت کلی threadهای با اولویت بالاتر زودتر از thread های با اولویت پایینتر اجرا میشوند یا در بسیاری مواقع threadهای کماولویت وقتی اجرا میشوند که threadهای با اولویت بالا در حالت انتظار برای عمل خاصی قرار دارند. نکته مهمی که باید توجه داشته باشید این است که نمیتوان یک قانون کلی برای اولویت threadها گفت چون مسئله زمان و ترتیب اجرای thread ها به شدت تحت تاثیر عواملی مثل سیستم عامل و پردازنده و حتی رفتار JVM در مواجهه با مسائل مختلف است.
وقتی یک thread یک thread دیگر را اجرا میکند به thread اجرا شده thread فرزند گفته میشود. اولویت thread فرزند با thread پدر خود به صورت پیشفرض یکسان است. اولویت threadها با یک عدد بین ۱ تا ۱۰ مشخص میشود.
سه فیلد ثابت در کلاس Thread وجود دارند به نام MIN_PRIORITY که برابر با ۱ و NORM_PRIORITY که برابر با ۵ و MAX_PRIORITY که برابر با ۱۰ میباشد. اولویت پیشفرض یک thread برابر با NORM_PRIORITY میباشد البته ما مجبور به انتخاب یکی از این سه فیلد نیستیم و میتوانیم یک عدد از ۱ تا ۱۰ را مشخص کنیم.
مثال:
public class MyThread extends Thread { static boolean stop = false; static String currentName; int count; public MyThread(String name) { super(name); count = 0; currentName = name; } @Override public void run() { System.out.println(getName() + " Starting..."); while(stop == false && count < 1000000) { count++; if (!currentName.equals(getName())) { currentName = getName(); System.out.println("In " + currentName); } } stop = true; System.out.println(getName() + " Terminating..."); } }
متد run این thread حاوی یک حلقه میباشد که این حلقه وقتی به پایان میرسد که یا مقدار count برابر با ۱۰۰۰۰۰۰ شود و یا فیلد stop برابر با true شود. فیلد stop که استاتیک است (و متعلق به کلاس است نه اشیا ساخته شده از آن) با مقدار اولیه false تعریف شده است. میخواهیم دو شی از این کلاس بسازیم تا دو thread داشته باشیم. هر کدام از این thread ها که حلقه آن زودتر تمام شود متغیر stop را برابر با true کرده و thread بعدی دیگر حلقه خود را اجرا نمیکند. در هر بار اجرای حلقه اسم thread فعلی با currentName مقایسه میشود و اگر برابر نبود به معنی این است که بین thread ها switch رخ داده و thread دیگری به CPU دسترسی پیدا کرده است. بعد از اینکه هر دو thread پایان یافتند مقدار متغیر count در هر thread نمایش داده میشود.
حال میخواهیم thread ها را اجرا کنیم:
MyThread t1 = new MyThread("High Priority"); MyThread t2 = new MyThread("Low Priority"); t1.setPriority(7); t2.setPriority(3); t1.start(); t2.start(); try { t1.join(); t2.join(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("High Priority thread: " + t1.count); System.out.println("Low Priority thread: " + t2.count);
همانطور که میبینید اولویت t1 بالاتر از t2 است. برنامه را اجرا کنید و خروجی را مشاهده کنید. در کامپیوتر من دو خط آخر خروجی به صورت زیر است:
High Priority thread: 1000000
Low Priority thread: 2000
همانطور که میبینید t1 که اولویت بالاتری داشته توانسته زمان بیشتری از CPU را به خود اختصاص دهد و حلقه را تا آخر پیمایش کند و متغیر stop را true کند اما t2 به دلیل اولویت پایینتر زمان کمتری در اختیار داشته و وقتی به ۲۰۰۰ رسیده متغیر stop توسط t1 برابر با true شده و دیگر نتوانسته به کار خود ادامه دهد.
البته این نتیجه در کامپیوتر شما یا در هر بار اجرای برنامه متفاوت است اما همیشه مقدار count در t2 کمتر است. همچنین اگر خروجی را کامل مشاهده کنید میبینید که بیشتر پیغام In High Priority چاپ شده است که به این معنی است که پردازنده زمان بیشتری در اختیار t1 بوده است.
هماهنگسازی (Synchronization)
وقتی با برنامههای چندنخی کار میکنیم گاهی اوقات لازم است فعالیتهای thread ها را تنظیم کنیم که به این کار هماهنگسازی میگویند. در بسیاری از مواقع بعضی از کدها باید به گونهای نوشته شوند که در هر لحظه فقط یک thread بتواند به آن دسترسی داشته باشد. به عنوان مثال اگر دو thread در یک لحظه بخواهند به یک منبع (مثل فایل) دسترسی پیدا کنند و آن منبع اجازه دسترسی بیش از یک thread در یک زمان به خود را ندهد آنگاه برنامه با خطا مواجه خواهد شد.
در جاوا مفهومی به نام monitor وجود دارد که وظیفه آن کنترل دسترسی به شی است. هر شی در جاوا یک monitor دارد. هر thread میتواند monitor یک شی را قفل (lock) کند تا thread دیگری نتواند به آن شی دسترسی پیدا کند و وقتی thread ای که moniter آن شی را قفل کرده به پایان برسد unlock شود تا thread ای دیگر بتواند به آن شی دسترسی پیدا کند.
متدهای هماهنگسازیشده (Synchronized Methods)
وقتی متدی به صورت هماهنگسازیشده تعریف شود آنگاه thread ای که آن متد را فراخوانی میکند وارد monitor شیئی که متد از آن فراخوانیشده میشود سپس monitor آن شی را قفل میکند تا thread دیگری نتواند به آن متد یا متدهای هماهنگسازیشده دیگر شی دسترسی داشته باشد. وقتی کار متد تمام شود شی unlock خواهد شد و اجازه دسترسی threadهای دیگر به متد داده خواهد شد.
هماهنگسازی متدها با استفاده از کلمه کلیدی synchronized صورت میگیرد.
مثال: کلاس سادهای با یک متد به صورت زیر داریم. پارامتر این متد نام thread ای که این متد در آن فراخوانی شده را میگیرد تا هنگام چاپ اعداد بفهمیم که این متد در کدام thread فراخوانی شده است:
public class NumberPrinter { public synchronized void print(String threadName) { for (int i = 0; i < 5; i++) System.out.println(i + " From " + threadName); } }
سپس یک شی از این کلاس ساخته و دو thread میسازیم که در هر دو thread متد print از شیئی که از این کلاس ساختیم فراخوانی میشود:
NumberPrinter p = new NumberPrinter(); Thread t1 = new Thread(new Runnable() { @Override public void run() { p.print("T1"); } }); Thread t2 = new Thread(new Runnable() { @Override public void run() { p.print("T2"); } }); t1.start(); t2.start();
همانطور که میبینید از کلاسهای ناشناس برای ساخت شی Runnable استفاده کردیم. خروجی این برنامه به صورت زیر خواهد بود:
۰ From T1
۱ From T1
۲ From T1
۳ From T1
۴ From T1
۰ From T2
۱ From T2
۲ From T2
۳ From T2
۴ From T2
ابتدا t1 و سپس t2 شروع به کار میکند اما از آنجایی که ابتدا t1 شروع شده و متد print را فراخوانی کرده و از آنجایی که متد print به صورت synchronized تعریف شده است t1 مانیتور شی p را قفل کرده و بنابراین تا وقتی که کار متد print به پایان نرسیده thread دیگری نمیتواند متد print را از شی p فراخوانی کند و اگرچه t2 بلافاصله بعد از t1 شروع شده اما باید صبر کند تا t1 به پایان برسد (هر وقت متد print به پایان برسد t1 نیز به پایان میرسد چون غیر از فراخوانی متد print در متد run کار دیگری انجام نشده است) در نتیجه ابتدا یک بار متد print از t1 به طور کامل اجرا شده و سپس بار دیگر متد print از t2 به طور کامل اجرا خواهد شد.
اگر کلمه sychronized را از متد print حذف کنید و این برنامه را اجرا کنید با نتیجهای مشابه زیر روبرو خواهید شد:
۰ From T1
۰ From T2
۱ From T1
۱ From T2
۲ From T2
۳ From T2
۲ From T1
۴ From T2
۳ From T1
۴ From T1
و این به این دلیل است که t2 با فاصله زمانی بسیار کمی بعد از t1 شروع به کار کرده و هر دوی این thread ها متد print را فراخوانی کردهاند و در نتیجه دو حلقه همزمان اجرا شده و همزمان سعی میکنند اعداد ۰ تا ۴ را چاپ کنند.
بلاکهای هماهنگسازیشده (Synchronized Blocks)
اگرچه متدهای هماهنگسازیشده راهی مناسب و موثر هستند اما همیشه استفاده از آنها امکان پذیر نیست. گاهی اوقات از کلاسهایی استفاده میکنیم که کد آنها قابل ویرایش نیست (مثل کلاسهای api جاوا) و نمیتوانیم عبارت synchronized را به متدها اضافه کنیم ولی میخواهیم دسترسی به متدهای این کلاسها به صورت هماهنگسازیشده باشد یا گاهی اوقات میخواهیم فقط بخشی از کدهایمان هماهنگسازیشده باشد که در این صورت میتوانیم از این بلاکها استفاده کنیم.
مثال: کلاس NumberPrinter را به صورت زیر تغییر دادیم:
public static class NumberPrinter { public void print(String threadName) { System.out.println("This part of the method is not sychronized"); synchronized (this) { for (int i = 0; i < 5; i++) System.out.println(i + " From " + threadName); } } }
حال اگر برنامه را اجرا کنید خواهید دید که عبارت موجود در متد println دوبار پشت سر هم چاپ میشود چون این قطعه از کد در بلاک synchronized قرار نگرفته است اما کدهایی که در بلاک synchronized نوشته شدهاند مانند قبل عمل میکنند.
پارامتری که به بلاک synchronized میدهیم شیئی است که میخواهیم monitor آن برای دسترسی به قطعه کد مورد نظر قفل شود که در اینجا عبارت this را مشخص کردیم که بیانگر شیئی است که متد print از آن فراخوانی شده است. یعنی monitor هر شیئی که این متد از آن فراخوانی شده باید قفل شود تا thread دیگری از نتواند این قطعه کد را از آن شی اجرا کند.
با تشکر از زحمات جنابعالی
مطالب آموزشی جاوا در این دوره آموزشی بسیار کارآمد هستند
با تشکر از شما برای دنبال کردن این مجموعه.
خیلی عالی بود.
بسیار ممون.
عاااااااااالی…
با سلام
این جماه تون اشتباهه به نظر من:
“همچنین اگر خروجی را کامل مشاهده کنید میبینید که بیشتر پیغام In High Priority چاپ شده است که به این معنی است که پردازنده زمان بیشتری در اختیار t1 بوده است.”
چرا که تعداد چاپ در کنسول In High Priority یکبار فقط بیشتر از ترد دیگر است درستش اینه که بگیم مدت زمانی که عبارت In High Priority آخرین پیغام چاپ شده است بیشتر از ترد دیگه است
سلام امکانش هست در مورد چند نخی اندروید هم مطالبی بذارید . مثلا کلاس Asynctask
سپاسگزارم
چه یهو سخت شد 😐 🙂