توضیحی در مورد threadها در زبان جاوا

توضیح: این متن رو به عنوان توضیح thread چیه به شکل غیررسمی نوشتم، تصمیم گرفتم اینجا هم منتشرش کنم شاید برای کسی مفید بود.

برنامه‌نویسی بدون ترد رو انجام دادیم ولی برامون راحته ولی با ترد می‌تونیم به کارایی بالاتر و کدهای منطم‌تری دست پیدا کنیم یا جتی چیزاهایی رو بتونیم انجام بدیم که قبلا نمی تونستیم. در این مطلب اصول اولیه thread رو یاد می‌گیریم.

ما بدون آگاهی از thread، برنامه‌هامون یک نخ پردازشی داشت، همه چیز دنبالِ هم اجرا می‌شد.

اول دستورِ اول، بعد دستورِ دوم همینطور تا اخر (طبیعتا توی حلقه‌ها این اتفاق نمی‌افتاد ولی منظورم مشخصه)

اما سیستم‌های ما چند تا هسته‌ی پردازشی دارند یعنی در آن واحد می‌تونن چند تا کار رو همزمان انجام بدن. (چرا چند تا هسته دارند؟ چون نمیشه یه هسته‌ی پردازشی رو از یه حدی قوی‌تر کرد، پس میان چند تا هسته پردازشی رو توی یه چیپِ واحد قرار می‌دن و اینطوری توان پردازشی رو زیاد می‌کنن)

حالا چطوری می‌شه؟ یک پردازنده داریم.

این پردازنده در آنِ واحد چند تا هسته پردازشی داره (مثلا چندتا سی‌پی‌یو کوچولو در پردازنده اصلی!)

یه سری پردازش داریم، مثلا برنامه فایرفاکس، تلگرام و برنامه جاوایی شما که توی jvm داره اجرا میشه.

این پردازش‌ها اگر برنامه‌نویسشون مبحث thread رو بلد نباشه، هر کدوم روی یه هسته اجرا می شن، مثلا روی هسته صفر، فایرفاکس اجرا می‌شه. روی هسته ی ۱ تلگرام، بازم روی هسته‌ی ۱ برنامه جاوای شما (برنامه‌ریزی این که کی کجا اجرا بشه به عهده سیستم‌عامله.)

حالا نکته‌ای که هست اینه که به ۲ دلیل ما ممکنه نخواهیم برنامه مون در آنِ واحد فقط یک کار رو انجام بده.

دلیل ۱: وقتی پردازنده بیشتر از یک هسته پردازشی داره، اینکه برنامه‌ما فقط یک thread داشته باشه و فقط روی یک هسته پردازشی اجرا بشه به لحاظ قدرت پردازشیِ به یک هسته از پردازنده محدود هستیم اما اگر چند تا thread داشته باشیم توان پردازشی بیشتری در اختیارمون قرار می‌گیره. (البته به صورت خطی رشد نمی‌کنه، اگر خیلی زیاد thread داشته باشیم اثر عکس میده)

دلیل ۲: برنامه ما، چند تا کار همزمان رو باید انجام بده، مثلا تلگرام همزمان داره تایپ کردنِ من رو هندل می‌کنه و همچنین پیام‌هایی که میاد رو از سرور می‌گیره و رابط ظاهری رو آپدیت می‌کنه.

پس ما از ترد‌های مختلف (یا فارسی بگم، نخ پردازشی) استفاده می‌کنیم.

همین مثال تلگرام رو بریم جلو، ۲ هسته پردازشی براش فرض کنید.

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

هسته دوم هم کارهای پس‌زمینه مثلا ارتباط با سرور رو انجام می‌ده.

اگر این ۲ تا هسته نباشن (و فقط یک هسته باشه) برنامه‌نویسی چنین چیزی خیلی سخت می‌شه مثلا من وقتی تایپ می‌کنم نمی‌تونه اطلاعات رو از سرور آپدیت کنه، یا اگر بخواد سرور رو بخونه، نمی‌تونه همزمان به تایپِ من رسیدگی کنه.

(البته این سختی برای جاوا و بدون امکانات خاص مثل callback یا java.nio برقراره، ولی event loop در javascript این کار رو خیلی زیبا انجام می‌ده.)

اما وقتی که چند تا ترد داشته‌باشیم، این ترد‌ها به شکل «تقریبا مستقل از هم» به کارشون ادامه می‌دن.

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

اما به هم مربوطن و مثل ۲ تا برنامه جدا نیستند. چرا؟

چون مثلا وقتی که تایپم تموم بشه و ارسال کنم، دکمه ارسال رو می‌زنم، باید قسمت UI به صفِ کارهای تردِ شبکه این رو اضافه کنه که این پیام رو بفرست و خبرش رو بده بهم که اگر ارسال شد تیک بزنمش.

یا اینکه می‌پرسه از تردِ شبکه که آیا پیام جدیدی اومد که من نشونش بدم؟

یعنی این‌ تردها به شدت با هم ارتباط دارند و به هم نیازمند هستند.

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


راستی یادمون نره که ما ۲ تا مفهوم داریم، یکی هم‌روندی (concurrency) و یکی اجرای موازیه (parallel)

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

اما مفهوم دوم اجرای همزمانه. (parallel)

به این صورت که این ۲ تا ترد «واقعا همزمان روی ۲ تا هسته مختلف» اجرا بشن.

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


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

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

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

چیکار می‌کنیم؟ ۴ تا ترد می‌سازیم که هر کدوم یه آرایه رو محاسبه کنن.

بعد لازمه توی هسته اصلیمون، نتیجه رو بگیریم ازشون (مثلا یه جایی ذخیره کردن) و عملیات نهایی رو انجام بدیم روش.

باید یه جوری بگیم آقا یا خانمِ ترد، شما اجرا شو، کارت تموم شد خبر بده بهم.

اینجا از start و join استفاده می‌کنیم.

مثلا میگیم:


// main thread:
t1.start();
t2.start();
t3.start();
t4.start();

// now they wre working concurrently

t1.join(); //wait for t1
t2.join(); //wait for t2
t3.join(); //wait for t3
t4.join(); //wait for t4
long sum = t1.ans + t2.ans + t3.ans + t4.ans;


متد join، در واقع یه جور delay برای هسته فعلیمون هست. به این صورت که اینقدر صبر کن تا اون هسته مد نظر کارش تموم شه.

خب یعنی اینطوری میشه برنامه که به همه ۴ تا ترد دیگه می‌گه کارشون رو شروع کنن و بعد به اولی میگه من صبر می‌کنم تو کارت تموم شه.

حالا اولش کارش تموم شد همین رو به دومی میگه.

حالا ممکنه دومی کارش زودتر تموم شده باشه و دیگه این delay نخوره یا ممکنه دومی هم کارش طول بکشه حالا برای دومی صبر می‌کنه.

همینطور برای سومی و چهارمی.

حالا که هر ۴ تا عملیاتشون تموم شده و نتیجه آماده است و از ans استفاده می‌کنیم. (فرض کنیم نتیجه جمعشون رو توی یه متغیر توی شی خودشون به نام ans نوشتن.


حالا همه این‌ها برای این بود که ۲ تا کد موازی اجرا بشن.

اما اگر بخوان که از یه «منبع مشترک» استفاده کنن چی؟

مثلا یه متغیر شمارنده (چیزهای پیچیده‌تر هم می‌تونن باشن فقط آرایه یا فایل مشترک)

مثلا هر کدوم می‌خوان یه متغیر مثل count رو زیاد کنند.


چاره چیه؟

اول بگم مشکلی که پیش میاد چیه؟

فرض کنید که ۲ تا ترد دقیقا همزمان بخوان count رو زیاد کنن.

هر کدوم نگاهش می‌کنن می‌گن خب صفر هست و من باید ۱ بنویسم توش، بعد توش یک می‌نویسن ولی در واقع باید ۲ نوشته بشه.


اینجا ۲ تا پیشنهاد برای رفع مشکل داریم.

یکی اینکه عملیات رو atomic تعریف کنیم، یعنی به جای int معمولی، بیایم atomic integer استفاده کنیم.

اتمیک یعنی هرکاری با من داری رو توی یه حرکت انجام بده، و تا زمانی که تو داری استفاده می‌کنی کس دیگه‌ای دست نمی‌زنه.

یعنی چی؟ یعنی ۲ تا تردی که همزمان می‌خواد به int count دست بزنن، به یکیشون می‌گه دست نگه دار، به دومی می‌گه خب هرکاری داری انجام بده، بعد که کارش تموم شد نوبت دومی میشه. اتمیک بودنش یعنی اینطوری نیست که اول بگه حالا می‌خونم بعد کارم رو انجام می‌دم و می‌نویسم، خوندن و نوشتن رو توی یه مرحله انجام می‌ده و تا زمانی که توی این مرحله هست، ترد دیگه ای حتی اجازه خوندن هم نداره. (مطالعه بیشتر)


اما یه چیز دیگه هم داریم به اسم بلوک‌های سنکرون.

به این صورته که ما یه سری بلوک سنکرون (یا متد سنکرون) داریم. این بلوک‌های سنکرون اینطوری هستند که برنامه‌نویس می‌گه بلوک‌ها قراره روی یه منابع مشترکی کار کنند که اگر همزمان دست بزنن بهش، خراب می‌شه. پس از جاوا می‌خوایم دسترسی یکیشون رو در آن واحد میسر کنه (به عبارت دقیق تر mutual exclusion).

حالا چطوری بگیم این بلوک سنکرون روی چی داره کار می‌کنه که کس دیگه ای روی این شی خاص دست نبره؟

به هر بوک سنکرون یه شی می‌دیم، این قفل در واقع یه object یا یه class هست. تا وقتی که یه بلوک سنکرون با شی X در جال اجراست، هیچ بلوک سنکرون دیگه ای نمی‌تونه قفل شی X رو بگیره و عملا اجرا یشه.

می تونه روی شی Y از همون کلاس کار کنه ها، ولی همون شی نه.

حالا متد سنکرون هم عملا یه بلوک هست که روی this یا همون آبجکت لاک می‌شه، تا وقتی که این متد در حال اجرا یاشه، هیچ متد سنکرون دیگه‌ای «روی این شی» اجرا نمیشه.



سینتکس ساخت ترد هم دو مدله.

اولی اینکه یه کلاس داشته باشیم و از Thread ارث‌بری کنه و متد runش رو override کنیم و توش دستوراتمون رو بنویسیم. ساخت این مدل ترد سینتکسش راحت تره ولی سختیش اینه که باید کلاسمون الزاما از Thread ارث‌بری کنه و نه چیز دیگر.

دومی هم اینکه کلاسمون اینترفیس Runnable رو implement کنه، باز هم کدهامون رو توی تابع runش می‌نویسیم با این تفاوت که موقع ترد ساختن، یه شی از اون کلاسمون که Runnable رو implement کرده به ترد می‌دیم.

هر دو تا حالت، در صورت اجرای متد start روی thread، میاد و تابع ران رو به صورت موازی اجرا می‌کنه.

سینتکس ترد را اینجا ببینید.

نکته کنکوری: چرا ما تابع run رو نوشتیم ولی باید start رو صدا کنیم؟

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


دیگه نکته اضافه اینکه ساخت تردِ معمولی (همین که ما می‌سازیم و به kernel thread هم معروفه)، کار خیلی کندیه، نسبت به اجرای یه تابع معمولی.

و هر ترد هم برای خودش منابع می‌گیره و اینکه ما بیایم تردهای زیاد بسازیم اتفاق خوبی نمی‌افته، فقط فشار روی سیستم‌عامل برای مدیریت ترد‌ها بیشتر می‌شه. مثلا ما یه آرایه ۱۰۰۰ در n داریم و می‌خوایم مجموع همش رو حسابی کنیم، خوب نیست که بیایم ۱۰۰۰ تا ترد بسازیم، چون اینطوری کارایی از همون یک ترد هم کم‌تر می‌شه، بلکه ۴-۶ تا ترد می‌سازیم. توجه: در زبان های دیگه مثل go ترد‌های غیرکرنلی که سبک‌تر باشن هم وجود داره. در مورد go routine می‌تونید بخونید.


در مورد ترتیب و مدیریت ترد‌ها: از اون جا که سیستم‌عامل مدیریت منابع و ترد‌ها رو داره، روی ترتیب اجرای تردها هیچ حسابی نمی تونیم بکنیم. ممکنه ۲ تا کد که انتظار داریم همزمان اجرا بشن، اصلا دومی رو اجرا نکنه به مدت یک دقیقه، بعد ۳ دقیقه دومی رو اجرا کنه (اصولا اگه سیتسم‌عامل باشعوری(!) باشه این کارو نمی‌کنه چون همه چی خراب می‌شه ولی امکانش هست). حالا این مثال فرضی بود ولی عملی ترش اینه که اگه ما توی ۲ تا ترد بگیم یکی ۱ تا ۱۰۰ رو چاپ کنه و دومی a تا z رو، هیچ حسابی روی ترتیب چاپشون نمی‌تونیم بکنیم، ممکنه اول ۱ تا ۱۰ چاپ بشه بعد a تا c و باز همینطوری، ممکنه اول ۱ تا ۱۰۰ چاپ یشه. توی یه سیستم واحد هم با هر بار اجرا ترتیب‌ها عوض میشه!


در مورد گرسنگی یا starvation، اینطوریه که وقتی یک ترد روی یه شی قفل کرده و توی بلوک سنکرون داره کلی عملیات انجام می ده (این دیگه دست برنامه نویسه)

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


حالتِ بدِ دیگه، deadlock هست. بن‌بست!

اینطوریه که ترد دوم منتظره ترد اول تموم شه.

ترد اول هم منتظره ترم دوم کارش تموم شه، یا منتظره بره توی بلوک سنکرونش (در حالی که اولی هم توی بلوک سنکرونه) اینجا هیچ کدوم نمی تونه پیش‌رفت کنه و تو همین وضعیت باقی می‌مونیم!

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


در مورد ساحتمان داده‌های هم‌روند (یا thread-safe) هم بگم که اینا یه سری ساختمان داده هستند که در مقابل استفاده چند ترد همزمان امن هستند، یعنی چی؟ مثل اون atomic integer که یه متغیر امن بود، این‌ها لیست و مپ و غیره هستند.

به طور ساده بخوایم در نظر بگیریم لیست thread-safe که یه لیست معمولی هست که همه متدهاش سنکرون شده!

کاراییش البته معمولا از چیزی که گفتم بهتره چون پیاده سازیش واقعا اینطوری نیست، مثلا عملیات‌های get به صورت همزمان انجام می‌شه ولی عملیات‌های set به صورت یکی یکی و سنکرون.

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


در نهایت این رو ببینید اگر دوست داشتید:

https://www.youtube.com/watch?v=alJuV66KMEM