14. 01. X v = fx.get(); // if necessary, wait for the value to get computed
02.
03. void f(promise<X>& px) // a task: place the result in px
04. {
05. // ...
06. try {
07. X res;
08. // ... compute a value for res ...
09. px.set_value(res);
10. }
11. catch (...) { // oops: couldn't compute res
12. // pass the exception to the future's thread:
13. px.set_exception(current_exception());
14. }
15. }
15. 01. void g(future<X>& fx) // a task: get the result from fx
02. {
03. // ...
04. try {
05. X v = fx.get(); // if necessary, wait for the value to get computed
06. // ... use v ...
07. }
08. catch (...) { // oops: someone couldn't compute v
09. // ... handle error ...
10. }
11. }
16. 01. double comp2(vector<double>& v)
02. {
03. // type of task
04. using Task_type = double(double*,double*,double);
05. // package the task (i.e., accum)
06. packaged_task<Task_type> pt0 {accum};
07. packaged_task<Task_type> pt1 {accum};
08. // get hold of pt0's future
09. future<double> f0 {pt0.get_future()};
10. // get hold of pt1's future
11. future<double> f1 {pt1.get_future()};
12. double* first = &v[0];
13. // start a thread for pt0
14. thread t1 {move(pt0),first,first+v.size()/2,0};
15. // start a thread for pt1
16. thread t2 {move(pt1),first+v.size()/2,first+v.size(),0};
17. // ...
18. return f0.get()+f1.get(); // get the results
19. }
17. 01. double comp4(vector<double>& v)
02. // spawn many tasks if v is large enough
03. {
04. if (v.size()<10000) return accum(v.begin(),v.end(),0.0);
05. auto v0 = &v[0];
06. auto sz = v.size();
07. auto f0 = async(accum,v0,v0+sz/4,0.0); // first quarter
08. auto f1 = async(accum,v0+sz/4,v0+sz/2,0.0); // second quarter
09. auto f2 = async(accum,v0+sz/2,v0+sz*3/4,0.0); // third quarter
10. auto f3 = async(accum,v0+sz*3/4,v0+sz,0.0); // fourth quarter
11. // collect and combine the results
12. return f0.get()+f1.get()+f2.get()+f3.get();
13. }
Editor's Notes
ما در این ویدیو وارد فصل 5 کتاب شدیم که در مورد concurrency و utilityهای کتابخونه استاندارده.
توی این ویدیو بحث مهم concurrency رو داریم و مفاهیم مربوط به اون رو میگم. بعد در مورد ابزار ها concurrency صحبت می کنیم و thread رو معرفی می کنم و در آخر در مورد synchronization صحبت خواهم کرد.
مفهوم concurrency اینه که ما بتونیم به صورت همزمان چند تا task رو با هم انجام بدیم. Concurrency برای بالا بردن throughput سیستم و همینطور کم کردن زمان پاسخ به یه درخواست بکار میره. تمام زبانهای برنامه نویسی مدرن امکاناتی برای همروندی برنامه ها ارائه میدن. در C++ کتابخونه استاندارد یسری امکانات برای این منظور فراهم کرده. هدف از این فراهم کردن این امکانات هم اینه که یه سری پشتیبانی از قابلیت های سیستمی رو فراهم کنه ولی در عین حال نمیخواد امکانات خیلی سطح بالا و پیچیده رو به کتابخونه استاندارد اضافه کنه. کتابخونه استاندارد امکاناتی برای ایجاد کردن threadها داره و یه سری عملیات های atomic رو هم پشتیبانی میکنه.
امکاناتی که کتابخونه استاندارد به ما میده شامل thread، mutexها، lookها، packaged_taskها و futureهاست. این موارد به صورت مستقیم با سیستم عامل و امکاناتی که سیستم عامل داره در ارتباط هستند.
در ادامه به صورت مختصر هر کدوم رو معرفی خواهم کرد.
ما تمامی محاسبات یا عملیات هایی که می تونیم به صورت هم روند یا Concurrent اجرا کنیم task میگیم. Threadیه representation ی system-level از task هست که در برنامه ها می تونیم استفاده کنیم. یه Task میتونه به صورت هم زمان با بقیه task ها اجرا بشه. Threadهای یه Porcess ، یک فضای آدرس مشترک رو به اشتراک میذارن. اما Processها به خودی خود هیچ داده ای رو با هم به اشتراک نمی ذارن. در threadها بخاط اینکه address space به اشتراک گذاشته میشه میتونیم از طریق objectهایی که به اشتراک گداشته میشن با هم تبادل اطلاعات کنن. این تبادل اطلاعات با استفاده از lookها کنترل میشه تا جلوی race بین threadها گرفته بشه.
با استفاده از std::thread که در هدر فایل thread هست میتونیم thread ایجاد کنیم. در خط 9 با استفاده از یه تابع و توی خط 10 با یه فانکتور دوتا thread ایجاد کردیم.
با فراخوانی تابع join مطمئن میشیم که تا زمانی که thread تموم نشده از تابع User خارج نمی شیم. معنی join اینه که صبر کن تا thread تموم بشه.
برنامه های concurrent اگه به خوبی نوشته نشده باشن میتونن رفتار های خیلی عجیبی داشته باشن. برای مثال فرض کنید که دو تا task به این صورت نوشتیم. توی هر 2 تا یه پیغامی رو چاپ می کنیم. حالا اگه بین این 2 تا task هماهنگی نباشه و با هم synchronize نشن خروجی غیر قابل پیشبینی میشه و در نهایت خراب میشه. در برنامه های concurrent ترتیب اجرای taskها قابل پیش بینی نیست درنتیجه ممکنه در هر لحظه یه task اجراش متوقف بشه و یهtask دیگه انجام بشه و به همین ترتیب task دوم متوقف بشه و task اول دوباره از ادامه ی اون جایی که متوقف شده ادامه پیدا کنه.
اگه این برنامه اجرا بشه ممکنه خروجی یه چیزی شبیه این بشه. خروجی به صورت نامفهومه چون وسط چاپ کردن یه پیغام task متوقف شده و task دیگه اجرا شده. این اتفاق بخاطر این میفته که هر 2 تا task از cout به صورت مشترک و بدون synchronization استفاده کردن. خروجی خیلی غیر قابل پیش بینیه حتی ممکنه یه وقتایی هم درست چاپ بشه. زمانی که ما taskهای concurrent تعریف میکنیم دوست داریم تا جایی که ممکنه این taskها از هم جدا باشن و به هم وابسته نباشن. نحوه ی ارتباط برقرار کردن اونا به هم باید خیلی ساده و واضح باشه.
یه راه ساده برای اینکه مفهوم taskهای concurrent رو متوجه بشیم اینه که فرض کنیم که اونا functionهایی هستند که همزمان، با کسی که اونا رو فراخوانی کرده در حال انجام شدن هستند. این taskها که ما مثل functionها فرض کردیم، برای اینکه یه عملیاتی رو انجام بدن باید آرگمان هایی بهشون پاس بشه و نتیجه ی عملیات اونا هم باید برگردونده بشه.
برای ایجاد کردن thread ما 2 راه داریم استفاده از function معمولی و استفاده از functor. وقتی از function استفاده می کنیم می تونیم یه reference به عنوان ورودی بهش بدیم و خروجی کار thread رو هم reference برگردونیم.
برای استفاده از functor هم یه reference از آرگمانی که میخواد روش یه عملیاتی رو انجام بده رو داخلش به صورت member نگه می داریم
Thread ی t1 با تابع f ساخته می شه و این تابع رو توی یه thread جدا اجرا میکنه. آرگمانهای ورودی thread هم به عنوان آرگمانهای تابع f بهش پاس داده میشن.
و thread ی t2 رو با functor میسازیم و اپراتور () اون توسط thread اجرا خواهد شد.
توی این اسلاید در مورد برگردوندن نتیجه از thread یه مثال می بینیم. در مثال قبلی تونستیم با Reference const یسری آرگمان رو به thread پاس کنیم. برای اینکه نتیجه عملیات رو از thread دریافت کنیم میتونیم بجای const reference از reference و یا Pointer استفاده کنیم.
بعضی وقتا نیاز داریم که بین taskها یه سری data رو به اشتراک بذاریم. در این صورت هر وقت می خوایم به اون data ، access داشته باشیم نیاز به مکانیزم هایی برای synchronization داریم . یکی از پایه ای ترین چیزایی که برای همگام سازی استفاده میشه Mutex هست. Mutex مخفف mutual exclusion object هست. یه mutex با فراخوانی تابع Lock، acquire میشه.
Unique_lock در constructorش تابع Lock رو call میکنه و در صورتی که یه thread دیگه زودتر این mutex رو acquire کرده باشه این thread رو block میکنه و زمانی که اون thread کارش تموم بشه و Mutex رو آزاد کنه block می مونه. زمانی که کار thread با اونdataیی که share شده تموم بشه و از تابع خارج بشه در destructorی unique_lock، تابع unlock صدا زده میشه و Mutex آزاد میشه در نتیجه اون threadیی که block شده بود از block در میاد و می تونه به dataی share شده دسترسی داشته باشه.
معمولا ما نیاز داریم همزمان چندتا resource رو بین چند تا thread به اشتراک بذاریم. این وضعیت ممکنه باعث بن بست در اجرای برنامه بشه. برای مثال فرض کنید که threadشماره 1، Mutex1 رو در اختیار بگیره و سعی کنه که Mutex2 رو که قبلا thread2 در اختیار گرفته رو در اختیار بگیره. و در همون زمان thread2 هم بیاد mutex1 رو بخواد در اختیار بگیره. دراین وضعیت هر 2 تا thread؛ block میشن و هر کدوم منتظرن اون یکی منابع رو آزاد کنه در صورتی که هیچ وقت اون منابعی که در اختیار گرفتن آزاد نمی شه و تا ابد هر 2 تا thread در وضعیت block می مونن. به این حالت deadlock یا بن بست گفته میشه.
کتابخونه استاندارد امکاناتی برای در اختیار گرفتن همزمان چند تا Mutex به ما میده.
این Lock تنها در صورت انجام میشه که همه ی mutexهایی که به عنوان آرگمان داده شدن با هم قابل acquire شدن باشن در غیر این صورت block نمیشه.
گاهی وقت ها یه thread باید منتظر وقوع یه رخداد خارجی باشه، مثلا باید تا زمانی که یه thread دیگه کارش تموم نشده، اجراش متوقف شده باشه یا مثلا تا یه مدت زمانی منتظر بمونه و زمانی که timeoutشد شروع به کار بکنه. ساده ترین راه برای اینکه یه thread منتظر بمونه اینه که یه مدت زمانی رو براش مشخص کنیم.
توی این مثال ما هیچ threadیی رو اجرا نکردیم ولی هر برنامه ای حداقل یه thread داره که main داره توش اجرا میشه. اون threadیی که الان داره اجرا میشه با this_thread مشخص میشه و با تابع sleep_for می تونیم به مدت زمان مشخصی یه thread رو به حالت sleep در بیاریم. کتابخونه استاندارد برای کار با متغییرهایی از نوع زمان یه کتابخونه به نام chrono داره که امکاناتی در این مورد محاسبات زمانی و تبدیلات زمانها به هم رو ما میده. امکاناتی مشابه ctime میده ولی خیلی از نظر امکانات پیشرفته تره و type safeتره.
یکی دیگه از امکاناتی که به ما اجازه میده که منتظر رخدادهای خارجی بمونیم condition_variableها هستند. این مکانیزم به ما اجازه میده که یه thread منتظر یه شرایطی بمونه. به این شرایط میگیم event، و معمولا هم نتیجه ی کار یه thread دیگست. فرض کنید 2 تا thread داریم که با message passing بینشون با استفاه از صف با هم ارتباط بر قرار میکنن.
یه صف به نام mqueue، یه condition_variable به نام mcond و یه mutex به نام mmutex داریم. و یه کلاس message داریم که تمام اطلاعاتی که 2 تا thread می خوان به هم پاس بدن رو درش پیاده سازی کردیم.
یه consumer یا مصرف کننده داریم که messageها رو از توی صف بر میداره و مصرف میکنه.
ما با استفاده از lck تمام عملیات هایی که روی صف قراره انجام بشه رو محافظت کردیم و با lock کردن mmutex می تونیم مطمئن بشیم که thread دیگه ای به صف دسترسی نخواهد داشت.
Condition_variable با آزاد کردن lck به حالت wait میره و منتظر می مونه که یه نفر اون رو مطلع کنه که صف خالی نیست. به محض اینکه صف پر باشه از حالت wait در میاد و lck رو lock می کنه تا thread دیگه ای به صف دسترسی نداشته باشه.
کدهای Producer یا تولید کننده هم به این صورت خواهد بود. تولید کننده یه message رو میسازه و اونو با اطلاعاتی که داره پر میکنه. بعد با lock کردن lck درصورتی که thread ی consumer اونو در اختیار نداشته باشه، صف رو در اختیار میگیره و میتونه Messageیی که ساخته رو درون اون Push کنه. و در آخر هم با استفاده از condition_variable ، thread ی consumer رو از پر شدن صف مطلع می کنه.
به این صورت ما با استفاده از condition_variable 2 تا thread رو به هم مرتبط کردیم. Condition_variable امکانات خیلی زیادی برای share کردن اطلاعات در اختیار ما میذاره که خیلی بهیه سازی شدن و کاربرد دارن.
کتابخونه استاندارد یه سری امکاناتی برای ارتباط بین task ها هم فراهم کرده.
Future و promise برای برگردوندن یه مقدار به عنوان حاصل یه سری عملیات هست که توسط یه thread انجام شده و میخوایم توی یه thread دیگه ازش استفا ده کنیم.
Packaged_task برای کمک کردن به اجرای taskها و ایجاد یه مکانیزم برای برگردوندن نتایج بکار میره و در واقع یه wrapper روی future و promise هست.
Async() هم برای اجرای task مشابه function call بکار میره
در ادامه هر کدوم رو مختصرا شرح میدم.
ایده ی پشت future و promise اینه که دوتا task بتونن بدو اینکه به صورت مستقیم از lock استفاده کنن یه مقداری رو به هم دیگه انتقال بدن. پیاده سازی به این صورته که زمانی که یه task می خواد یه مقداری رو به یه task دیگه منتقل کنه اونو در promise میذاره و اون taskیی که به اوم مقدار نیاز داره از طریق future متناظر promise به اون دسترسی پیدا میکنه.
اگر یه future داشته باشیم می تونیم یه مقدار رو با استفاده از get() از اون بخونیم. در صورتی که هنوز مقدار اون future توسط taskیی که باید اونو فراهم کنه حاضر نباشه کسی که get رو فراخوانی کرده به وضعیت wait میره و block میشه. اگه اصلا اون threadیی که باید یه مقداری رو برای ما فراهم که نتونه محاسبات رو انجام بده و مقداری رو نتونه قرار بده تابع get یه exception رو throw می کنه.
با Promise ما می تونیم مقداری که قراره محاسبه بشه رو برای کسی که منتظر این مقدار هست بفرستیم . با استفاده از set_value و set_exception میتونیم این کار رو بکنیم تا در future با تابع get اون مقدار دریافت بشه.
تابع current_exception ، exceptionیی رو بر میگردونه که الان catch شده و با استفاده از set_exception اون exception رو برای Future می فرستیم.
Threadیی هم که به عنوان future هست به این صورت خواهد شد.
با تابع get مقداری که قراره در یه thread دیگه محاسبه بشه رو ،دریافت می کنیم.
و اگه exception رخ داده باشه و اون thread اصلا نتونه محاسبات رو انجام بده هم در catch متوجه خطا میشیم.
کتابخونه استاندارد برای راحت تر استفاده کردن از future و promise یه warpper به نام packaged_task روی اونا کشیده تا کارها رو ساده تر کنه. به این صورت که در اون taskیی که نیاز داریم مقداری رو بخونیم از تابع get_future استفاده می کنیم و همین طور برای قراردادن مقدار محاسبه شده و یا exception رخداده هم می شه از packaged_task استفاده کرد.
فرض کنید که دوتا task داریم که توی هر کدون نصفی از یه وکتور رو میدیم و حاصل جمع تمام عناصر اون قسمت از وکتور رو حساب می کنیم و بر می گردونیم. با اون 2 تا packeged_task ، 2 تا thread ایجاد می کنیم و چون نمی تونیم packeged_task رو کپی کنیم باید اونو move کنیم.
حالا میتونیم مقدار محاسبه شده توسط هر task رو با get بگیریم و بعد با جمع کردن اون 2 تا، حاصل جمع کل وکتور رو محاسبه کنیم.
همون طور که میبینید ما مستقیما از lock توی این کدها استفاده نکردیم.
در این فصل هدف من این بوده که به ساده ترین صورت ممکن مفاهیم رو مطرح کنم و در عین حال قوی ترین ابزار هایی که در اختیار داریم رو هم معرفی کنم. مثلا با task مثل یه function رفتار کنیم که به صورت همروند یا concurrent با بقیه taskها اجرا میشه. این ساده سازی خیلی از نیاز های ما رو جواب میده. حالا بعد از ساده سازی task به function ما با استفاده از async ما قسمتی که باید function call بکنیم از قسمتی که باید نتایج رو دریافت کنیم از هم جدا می کنیم. در اصطلاح call part رو از قسمت get the result part جدا میکنیم. وقتی از async استفاده می کنیم دیگه لازم نیست حتی به thread و lock هم فکر کنیم بلکه به این فکر می کنیم که یه task چیزیه که به صورت asynchronously یا غیر همزمان اجرا میشه و نتیجه رو بر میگردونه. ولی این روش همچنان محدودیت هایی داره مثلا اگه یه resource باید با lock کردن share بشه نمیشه از async استفاده کرد. اما با async شما دیگه حتی لازم نیست که بدونید که threadها چطور ساخته میشن. خود async این کار ها رو انجام میده. میشه از async برای ایجاد کردن taskها استفاده کرد مثلا برای گرفتن ورودی از کاربر در محیط های گرافیکی و GUI خیلی استفاده میشه. توی این محیط ها شما یه thread اصلی دارین که UI ها رو روی صفحه رسم میکنه و یک یا چند thread دیگه دارید که از ورودی هایی مثل mouse و keyboard دارن اطلاعات رو میخونن.