برنامه نویسی ناهمزمان در جاوا اسکریپت برای مبتدیان

سلام به همه! در این مقاله قصد داریم به یک موضوع کلیدی در مورد برنامه نویسی نگاهی بیندازیم: مدیریت ناهمزمانی.
ما با ارائه یک پایه نظری در مورد چیستی ناهمزمانی و چگونگی ارتباط آن با اجزای کلیدی جاوا اسکریپت شروع می کنیم: رشته اجرا، پشته فراخوان و حلقه رویداد.
و سپس قصد دارم سه روشی را ارائه کنم که از طریق آنها می توانیم وظایف ناهمزمان را در جاوا اسکریپت انجام دهیم: Callbacks، Process و async/wait.
سرگرم کننده به نظر می رسد، درست است؟ بیا بریم!
هر برنامه کامپیوتری چیزی نیست جز یک سری وظایف که ما به کامپیوتر نیاز داریم تا آنها را اجرا کند. در جاوا اسکریپت، وظایف را می توان به انواع همزمان و ناهمزمان طبقه بندی کرد.
کارهای سنکرون آنهایی هستند که به صورت متوالی و یکی پس از دیگری اجرا می شوند و در حین اجرا هیچ چیز دیگری انجام نمی شود. در هر خط از برنامه، مرورگر منتظر می ماند تا کار به پایان برسد و سپس به خط بعدی بپرد.
ما می گوییم این نوع کارها "مسدود کننده" هستند، زیرا در حین اجرا، رشته اجرا را مسدود می کنند (من در یک ثانیه توضیح می دهم که این چیست) و از انجام هر کار دیگری جلوگیری می کند.
از طرف دیگر، وظایف ناهمزمان آنهایی هستند که در حین اجرا، رشته اجرا را مسدود نمی کنند. پس برنامه همچنان میتواند کارهای دیگر را در حین اجرای کار ناهمزمان انجام دهد.
به همین دلیل است که می گوییم این نوع کارها "غیر مسدود کننده" هستند. این تکنیک مخصوصاً برای کارهایی مفید است که اجرای آنها به زمان زیادی نیاز دارد، زیرا با مسدود نکردن رشته اجرا، برنامه قادر به اجرای کارآمدتر است.
طبق اسناد موزیلا :
برنامه نویسی ناهمزمان تکنیکی است که برنامه شما را قادر می سازد تا یک کار بالقوه طولانی مدت را شروع کند و همچنان بتواند به رویدادهای دیگر در حین اجرا شدن آن کار پاسخگو باشد ، نه اینکه منتظر بمانید تا آن کار به پایان برسد. پس از اتمام آن کار، برنامه شما با نتیجه نمایش داده می شود.
اکنون که ما یک ایده کم و بیش روشن از ناهمزمانی چیست، بیایید به چیزهای جالب پیچیده بپردازیم – اینکه چگونه جاوا اسکریپت این را ممکن می کند.
یکی از اولین پارادوکسهای ظاهری جاوا اسکریپت – و چند مورد وجود دارد – که هنگام یادگیری زبان با آن مواجه خواهید شد این است که جاوا اسکریپت یک زبان رشتهای است.
"Single Threaded" به این معنی است که یک رشته اجرا دارد. این بدان معنی است که برنامه های جاوا اسکریپت تنها می توانند یک کار را در یک زمان اجرا کنند.
برای مثال، برای زبانهایی مانند جاوا یا روبی که میتوانند رشتههای اجرایی مختلفی ایجاد کنند و به این ترتیب بسیاری از وظایف را به طور همزمان اجرا کنند، این مورد صادق نیست.

و در اینجا تناقض این است: اگر جاوا اسکریپت می تواند تنها یک وظیفه را در یک زمان اجرا کند، چگونه می شود که وظایف همزمان اجرا شوند در حالی که وظایف ناهمزمان "در پس زمینه" کامل می شوند؟ چگونه است که وظایف ناهمزمان رشته اجرا را مسدود نمی کند؟ آن وقت چگونه اعدام می شوند؟
برای توضیح این موضوع، باید به طور خلاصه توضیح دهیم که مرورگرهای وب چگونه کد جاوا اسکریپت و برخی از اجزای اصلی آن را اجرا می کنند: پشته تماس، وب API، صف برگشت تماس و حلقه رویداد.
پشته تماس چیست؟
همانطور که در حال حاضر ممکن است، پشته نوعی ساختار داده است که در آن عناصر بر اساس الگوی LIFO (آخرین ورود، اولین خروج) اضافه و حذف می شوند. مرورگرها از چیزی به نام پشته تماس برای خواندن و اجرای هر کار موجود در یک برنامه جاوا اسکریپت استفاده می کنند.
نظر جانبی: اگر با ساختارهای داده آشنایی ندارید، می توانید به این مقاله که چندی پیش نوشتم نگاهی بیندازید.
روش کار بسیار ساده است. هنگامی که یک کار قرار است اجرا شود، به پشته تماس اضافه می شود. وقتی تمام شد، از پشته تماس حذف می شود. این عمل مشابه برای هر کار تکرار می شود تا زمانی که برنامه به طور کامل اجرا شود.
بیایید این را با یک مثال ساده ببینیم. اگر این سه خط کد را داشتیم:
console.log('task 1') console.log('task 2') console.log('task 3')
پشته تماس ما به این شکل خواهد بود:

پشته تماس در شروع برنامه خالی شروع می شود.
اولین وظیفه به پشته تماس اضافه شده و اجرا می شود.
اولین کار پس از اتمام از پشته تماس حذف می شود.
وظیفه دوم به پشته تماس اضافه شده و اجرا می شود.
وظایف دوم پس از اتمام از پشته تماس حذف می شود.
وظیفه سوم به پشته تماس اضافه شده و اجرا می شود.
وظیفه سوم پس از اتمام از پشته تماس حذف می شود. پایان برنامه.
آسان درست است؟ حالا بیایید یک مثال کمی پیچیده تر با این خطوط کد ببینیم:
const multiply = (a, b) => a*b const square = n => multiply(n, n) const printSquare = n => console.log(square(n)) printSquare(4)
در اینجا ما printSquare()
فراخوانی میکنیم که خود square()
را فراخوانی میکند، که خودش multiply()
را فراخوانی میکند. با این برنامه، پشته تماس ما ممکن است به شکل زیر باشد:

پشته تماس در شروع برنامه خالی شروع می شود.
printSquare(4)
به پشته تماس اضافه شده و اجرا می شود.
همانطور که printSquare(4)
تابع square(4)
را فراخوانی می کند، square(4)
به پشته فراخوانی اضافه می شود و همچنین اجرا می شود. توجه داشته باشید که از آنجایی که اجرای printSquare(4)
هنوز به پایان نرسیده است، در پشته نگه داشته می شود.
همانطور که square(4)
فراخوانی می کند multiply(4,4)
، multiply(4,4)
به پشته تماس اضافه می شود و همچنین اجرا می شود.
multiply(4,4)
پس از اتمام از پشته تماس حذف می شود.
square(4)
پس از اتمام از پشته تماس حذف می شود.
printSquare(4)
پس از اتمام از پشته تماس حذف می شود. پایان برنامه.
در این مثال میتوانیم به وضوح الگوی LIFO را ببینیم که پشته تماس از آن برای گفت ن و حذف وظایف به آن استفاده میکند.
نکته مهمی که در اینجا باید به آن توجه کرد این است که وظایف تا زمانی که به پایان نرسیده اند از پشته حذف نمی شوند. به این صورت است که تماس های همزمان همزمان کار می کنند.
هنگامی که یک تابع تابع دیگری را فراخوانی می کند، پاسخ تماس به پشته اضافه شده و اجرا می شود. پس از اتمام اجرای callback، از پشته حذف می شود و اجرای تابع اصلی به پایان می رسد.
Web API، Callback Queue و Event Loop
تا اینجا خیلی خوبه، درسته؟ با استفاده از پشته تماس، جاوا اسکریپت هر کار را حساب می کند، آن را اجرا می کند و سپس به کار بعدی می رود. نسبتا ساده.
حالا بیایید مثال زیر را تحلیل کنیم:
console.log('task1') setTimeout(() => console.log('task2'), 0) console.log('task3')
در اینجا، ما سه رشته جداگانه را ثبت میکنیم، و در رشته دوم setTimeout
برای ثبت آن پس از 0 میلیثانیه استفاده میکنیم. که طبق منطق رایج باید فوراً باشد. پس باید انتظار داشت که کنسول Log کند: "task1"، سپس "task2" و سپس "task3".
اما این چیزی نیست که اتفاق می افتد:

و اگر در طول برنامه به پشته تماس خود نگاهی بیندازیم، به این صورت خواهد بود:

پشته تماس در شروع برنامه خالی شروع می شود.
console.log('task1')
به پشته تماس اضافه شده و اجرا می شود.
console.log('task1')
پس از اتمام از پشته تماس حذف می شود.
setTimeout(console.log('task2'))
به پشته تماس اضافه می شود، اما اجرا نمی شود .
setTimeout(console.log('task2'))
"به طور مرموزی" از پشته تماس ناپدید می شود.
console.log('task3')
به پشته تماس اضافه شده و اجرا می شود.
console.log('task4')
پس از اتمام از پشته تماس حذف می شود.
console.log('task2')
"به طور مرموزی" به پشته تماس می رود و اجرا می شود.
برای توضیح این ناپدید شدن و ظاهر شدن مجدد " معمولا " کار setTimeout
، باید سه جزء دیگر را که بخشی از زمان اجرای مرورگر هستند معرفی کنیم: Web APIها، صف برگشت تماس و حلقه رویداد.
وب API چیست؟
Web API ها مجموعه ای از ویژگی ها و قابلیت هایی هستند که مرورگر برای فعال کردن جاوا اسکریپت برای اجرا استفاده می کند. این ویژگی ها شامل دستکاری DOM، تماس های AJAX و setTimeout
از جمله موارد دیگر است.
برای سادهتر کردن درک این موضوع، به جای پشته تماس، به آن مانند یک «محل اجرای» متفاوت فکر کنید. وقتی پشته تماس تشخیص میدهد که وظیفهای که پردازش میکند مربوط به وب API است، از وب API میپرسد «Hey API، من باید این کار را انجام دهم» و Web API از آن مراقبت میکند و به پشته تماس اجازه میدهد تا با وظیفه بعدی در پشته
صف برگشت تماس و حلقه رویداد چیست؟
در مثال کدی که قبلا دیدیم، دیدیم که setTimeout(console.log('task2'))
"به طور مرموزی" از پشته تماس ناپدید شد. ما اکنون می دانیم که در واقع ناپدید نشد - به API وب ارسال شد.
اما پس از آن دوباره "به طور مرموزی" دوباره ظاهر شد، پس چگونه کار می کند؟ خب، این کار صف برگشت تماس و حلقه رویداد است.
صف برگشت تماس صفی است که وظایفی را که API های وب برمی گردانند ذخیره می کند. هنگامی که وب API اجرای وظیفه داده شده را به پایان می رساند (که در این مورد setTimeout
پردازش می کرد) پاسخ تماس را به صف برگشت ارسال می کند.
صف ها نوعی ساختار داده هستند که در آن عناصر به دنبال الگوی FIFO (اول به داخل، اول خارج) اضافه و حذف می شوند. باز هم، اگر با ساختارهای داده آشنایی ندارید، می توانید نگاهی به این مقاله بیندازید.
حلقه رویداد یک حلقه است (اوه... واقعا؟) که دائماً دو چیز را تحلیل می کند:
اگر پشته تماس خالی باشد
اگر وظیفه ای در صف پاسخ به تماس وجود دارد
اگر هر دوی این شرایط برآورده شوند، وظیفه موجود در صف برگشت به پشته تماس ارسال می شود تا اجرای آن کامل شود.
اکنون که در مورد وب APIها، صف برگشت تماس و حلقه رویداد می دانیم، می توانیم بدانیم که در مثال قبلی ما واقعاً چه اتفاقی افتاده است:

با پیروی از خطوط قرمز، میتوانیم ببینیم که وقتی پشته تماس تشخیص داد که وظیفه شامل setTimeout
است، آن را برای پردازش آن به APIهای وب فرستاد.
هنگامی که APIهای وب وظیفه را پردازش کردند، پاسخ تماس را در صف پاسخ به تماس قرار دادند.
و به محض اینکه حلقه رویداد تشخیص داد که پشته تماس خالی است و یک تماس برگشتی در صف پاسخگویی وجود دارد، برای تکمیل اجرای آن، آن را در پشته تماس قرار داد.
اینگونه است که جاوا اسکریپت ناهمزمانی را ممکن می کند. وظایف ناهمزمان به جای پشته تماس، که فقط وظایف همزمان را انجام می دهد، توسط API های وب پردازش می شوند.
به این ترتیب، پشته تماس فقط میتواند وظایف ناهمزمان را به APIهای وب استخراج کند و هر آنچه را که در پشته وجود دارد را اجرا کند. و به لطف صف برگشت تماس و حلقه رویداد، هنگامی که وظیفه ناهمزمان توسط APIهای وب مدیریت شد، پاسخ تماس مجدداً در پشته تماس وارد میشود.
مهم است که به یاد داشته باشید که جاوا اسکریپت همیشه فقط یک کار را در یک زمان اجرا می کند. "جادوی" ناهمزمانی با وجود API های وب، صف برگشت تماس و حلقه رویداد که مسئول مدیریت وظایف ناهمزمان هستند امکان پذیر است.
نظر جانبی: اگر تعجب می کنید که چگونه همه اینها در Node به جای مرورگر کار می کند، تقریباً مشابه است. به جای وب API، API های C++ دارید. پشته تماس، صف برگشت تماس و حلقه رویداد دقیقاً یکسان عمل می کنند.
اگر میخواهید توضیح دقیقتری درباره همه این موضوعات ارائه دهید، توصیه میکنم به این سخنرانی معروف فیلیپ رابرتز نگاهی بیندازید.
اکنون که پایههای نظری این را داریم که چگونه جاوا اسکریپت ناهمزمانی را ممکن میکند، بیایید ببینیم که چگونه همه اینها در کد پیادهسازی میشوند.
به طور عمده سه راه وجود دارد که از طریق آنها می توانیم ناهمزمانی را در جاوا اسکریپت کدنویسی کنیم: توابع پاسخ به تماس، وعده ها و async-await.
من آنها را به ترتیب زمانی ارائه خواهم کرد که جاوا اسکریپت این ویژگی ها را ارائه کرده است (اول فقط توابع پاسخ به تماس وجود داشت، سپس وعده ها آمد، و در نهایت async-wait). اما به خاطر داشته باشید که رایج ترین و توصیه شده ترین عمل امروزه استفاده از async-await است. ;)
بیشتر بخوانید
نحوه عملکرد توابع برگشت به تماس
Callback ها توابعی هستند که به عنوان آرگومان به توابع دیگر ارسال می شوند. تابعی که آرگومان را دریافت می کند "تابع مرتبه بالاتر" و تابعی که به عنوان آرگومان ارسال می شود "Callback" نامیده می شود.
ما می توانیم این را در عمل در مثال زیر مشاهده کنیم:
const callbackFunc = () => console.log('Im the callback') const higherOrderFunction = callback => callback() higherOrderFunction(callbackFunc)
نظر جانبی: امکان انتقال توابع به عنوان پارامتر به سایر توابع یکی از ویژگی هایی است که توابع را شهروندان درجه یک در جاوا اسکریپت می کند.
تفاوت بین تماس های همزمان و ناهمزمان به نوع وظیفه ای که تابع اجرا می شود بستگی دارد. هیچ تفاوت نحوی بین هر نوع وجود ندارد. بیایید این را در کد ببینیم.
const arr = [1,2,3] console.log('logging...') arr.map(e => console.log('sync item', e)) // This is a synchronous callback arr.map(e => setTimeout(() => console.log('async item', e), 0)) // This is an asynchronous callback console.log('the stuff')
در اینجا ما یک آرایه از سه عنصر، چند s console.log
و دو تابع map
داریم. کاری که map
انجام می دهد این است که روی هر عنصر آرایه تکرار می شود و برای هر عنصر آرایه یک تابع انجام می دهد. آن تابع به عنوان یک تماس (Callback) تعریف می شود.
در map
اول، ما آیتم را با console.log
ثبت می کنیم. در مورد دوم، ما همین کار را انجام میدهیم اما از setTimeout
استفاده میکنیم (که همانطور که قبلاً دیدیم یک کار ناهمزمان است که توسط APIهای وب انجام میشود).
در نتیجه، کنسول ما به شکل زیر خواهد بود:

ابتدا تمام تماسهای همگام اجرا میشوند و سپس تماسهای ناهمزمان شروع میشوند.
همانطور که می بینیم، این واقعیت که توابع به صورت ناهمزمان اجرا می شوند، به این واقعیت مربوط نمی شود که آنها پاسخگو هستند یا نه، بلکه به نوع وظیفه ای که تابع اجرا می شود مربوط می شود. از آنجایی که setTimeout
یک کار ناهمگام است، این فراخوان ها به صورت ناهمزمان اجرا می شوند.
وعده ها چگونه کار می کنند
یک رویکرد مدرن تر برای مقابله با ناهمزمانی استفاده از وعده ها است. پروتئين نوع خاصی از شی در جاوا اسکریپت است که دارای 3 حالت ممکن است:
در حال تعلیق: این حالت اولیه است و نشان می دهد که کار مربوطه هنوز حل نشده است.
Fulfilled: یعنی کار با موفقیت انجام شده است.
Rejected: به این معنی است که کار نوعی خطا ایجاد کرده است.
برای مشاهده عملی این موضوع، از یک مورد واقع بینانه استفاده می کنیم که در آن برخی از داده ها را از یک نقطه پایانی API واکشی می کنیم و آن داده ها را در کنسول خود ثبت می کنیم. ما از fetch
API ارائه شده توسط مرورگرها و یک API عمومی استفاده خواهیم کرد که جوک های چاک نوریس را برمی گرداند.
در اینجا تابع ما یک درخواست GET HTTP را به نقطه پایانی اجرا میکند، و ما از روشهای then
and catch
که آبجکت وعده دارد برای پردازش پاسخ قول استفاده میکنیم.
const fetchJokeWithPromises = () => { console.log('fetching with promises...') fetch('https://api.chucknorris.io/jokes/random') .then(res => res.json()) .then(res => console.log('res', res)) .catch(error => console.error('There was an error!', error)) } fetchJokeWithPromises()
اما بیایید قدم به قدم به این موضوع نگاه کنیم.
اگر فقط خط واکشی را وارد کنیم، مانند زیر:
console.log('fetch', fetch('https://api.chucknorris.io/jokes/random'))
می بینیم که یک وعده با وضعیت معلق دریافت می کنیم:

سپس اگر متد first then
اجرا کنیم و نتیجه آن را ثبت کنیم، به صورت زیر میرسیم:
fetch('https://api.chucknorris.io/jokes/random') .then(res => console.log('res', res))

می بینیم که در اینجا دیگر قولی نداریم، بلکه پاسخ واقعی از نقطه پایانی است. متد then
منتظر می ماند تا وعده تکمیل شود و سپس نتیجه را در اختیار ما قرار می دهد که به عنوان پارامتری برای متد موجود است.
اما برای خواندن بدنه پاسخ واقعی (که در کنسول ما میتوانیم آن را ReadableStream
ببینیم)، باید متد .json()
را روی آن فراخوانی کنیم. این خود وعده دیگری را برمی گرداند. به همین دلیل ما .then()
دیگری نیاز داریم.
fetch('https://api.chucknorris.io/jokes/random') .then(res => res.json()) .then(responseBody => console.log('responseBody', responseBody))

و در اینجا، در نهایت می توانیم پاسخ کامل و شوخی خود را در ویژگی value
مشاهده کنیم. ;)
کاری که متد catch
انجام می دهد این است که هر زمان که یک وعده رد شد اجرا می شود. معمولاً catch
برای رسیدگی به یک خطا استفاده میشود، مانند نشان دادن یک پیام خاص به کاربر در صورت عدم پاسخگویی API.
برای مشاهده آن در عمل، از یک نقطه پایان تصادفی مانند این استفاده می کنیم:
fetch('https://asdadsasdasd/') .then(res => res.json()) .then(resp => console.log('resp', resp)) .catch(error => console.error('There was an error!', error))
و در کنسول ما میتوانیم متد catch
اجرا شده را ببینیم:

نکته مهمی که باید به آن توجه کرد این است که در موقعیتهایی مانند این، که در آن روشهای مختلف .then
زنجیرهای داریم، فقط باید از یک روش .catch
استفاده کنیم. این به این دلیل است که آن یک .catch
خطاهای موجود در تمام وعده های زنجیره ای را پردازش می کند.
مجدداً، برای اینکه اکنون آن را در عمل مشاهده کنیم، به نقطه پایانی قبلی خود بازگردیم و با فراخوانی .json()
اشتباه می کنیم.
fetch('https://api.chucknorris.io/jokes/random') .then(res => res.jon()) .then(resp => console.log('resp', resp)) .catch(error => console.error('There was an error!', error))

برای جمع کردن وعدهها، یک روش اضافی توسط وعدهها ارائه میشود که .finally
. این همیشه پس از حل شدن وعده، چه با موفقیت یا نه، اجرا می شود.
fetch('https://api.chucknorris.io/jokes/random') .then(res => res.json()) .then(resp => console.log('resp', resp)) .catch(error => console.error('There was an error!', error)) .finally(() => console.log('Promised resolved!'))
Async-Await چگونه کار می کند
Async-await جدیدترین روش مقابله با ناهمزمانی است که توسط جاوا اسکریپت ارائه شده است. اساساً این فقط قند نحوی است که به ما امکان می دهد با قول ها به روشی مختصرتر از استفاده از روش های .then
برخورد کنیم.
بیایید این را در عمل به دنبال همان مثال قبلی ببینیم.
const fetchJokeWithAsyncAwait = async () => { try { const res = await fetch('https://api.chucknorris.io/jokes/random') const data = await res.json() console.log('async-await data', data) } catch (error) { console.error('There was an error!', error) } } fetchJokeWithAsyncAwait()
در اینجا ما تابعی داریم که واکشی را اجرا می کند و پاسخ را ثبت می کند. ببینید که وقتی تابع را اعلام می کنیم با استفاده از کلمه کلیدی async
شروع می کنیم. این یک الزام برای همه توابعی است که از async-await استفاده می کنند.
سپس تماس واکشی خود را در دستور try-catch
قرار می دهیم. این مورد ضروری است زیرا با async-await از روش .catch
استفاده نمی کنیم. اما هنوز باید خطاهای احتمالی را پردازش کنیم.
ما این را با استفاده از try-catch
به دست می آوریم. اگر هر چیزی در دستور try
یک خطا را برمی گرداند، دستور catch
اجرا می شود و خطا را به عنوان پارامتر دریافت می کند.
همانطور که می بینید، ما نتیجه fetch
را به متغیری به نام res
نسبت می دهیم. و قبل از fetch
از کلمه کلیدی await
استفاده می کنیم.
const res = await fetch('https://api.chucknorris.io/jokes/random')
این بدان معناست که جاوا اسکریپت قبل از تخصیص مقدار آن به متغیر منتظر می ماند تا وعده حل شود. و هر عملیاتی که روی آن متغیر انجام شود، تنها زمانی اجرا می شود که مقدار آن تخصیص داده شود.
در خط بعدی، متد .json()
را روی متغیر res
فراخوانی میکنیم، و دوباره از کلمه کلیدی await
قبل از آن استفاده میکنیم تا نتیجه قول به متغیر data
اختصاص داده شود.
و در آخر، ما data
خود را ثبت می کنیم.

همانطور که گفته شد، async-await فقط قند نحوی است. کاری متفاوت از روش های .then
و .catch
انجام نمی دهد. نوشتن و خواندن آن ساده تر است.
خوب همه، مثل همیشه، امیدوارم از مقاله لذت برده باشید و چیز جدیدی یاد گرفته باشید.
در صورت تمایل، می توانید من را در لینکدین یا توییتر نیز دنبال کنید. در قسمت بعدی می بینمت!

ارسال نظر