حالت تاریک
فصل ۲ — مدلسازی کد: Communicating Sequential Processes
تفاوت concurrency و parallelism
اینکه concurrency با parallelism متفاوت است، اغلب نادیده گرفته میشود یا اشتباه فهمیده میشود. در گفتوگوهای بین بسیاری از developerها، این دو اصطلاح اغلب بهجای هم بهکار میروند تا منظورشان «چیزی که همزمان با چیز دیگری اجرا میشود» باشد. گاهی استفاده از کلمه «parallel» در این زمینه درست است، اما معمولاً اگر developerها دربارهٔ code صحبت میکنند، واقعاً باید از کلمه «concurrent» استفاده کنند.
دلیل تمایز قائل شدن فراتر از وسواس لفظی است. تفاوت concurrency و parallelism در نهایت abstraction بسیار قدرتمندی برای مدلسازی code شماست و Go از این موضوع بهطور کامل بهره میبرد. بیایید ببینیم این دو مفهوم چگونه متفاوتاند تا قدرت این abstraction را درک کنیم. با یک جملهٔ بسیار ساده شروع میکنیم:
concurrency ویژگی code است؛ parallelism ویژگی برنامهٔ در حال اجراست.
این تمایز جالب است. آیا معمولاً این دو را یکسان در نظر نمیگیریم؟ code را مینویسیم تا بهصورت parallel اجرا شود. درست است؟ خب، یک لحظه دربارهٔ آن فکر کنیم. اگر code را با این قصد بنویسم که دو بخش از برنامه بهصورت parallel اجرا شوند، آیا تضمینی دارم که هنگام اجرای برنامه واقعاً این اتفاق بیفتد؟ اگر code را روی ماشینی با تنها یک core اجرا کنم چه میشود؟ بعضی از شما شاید فکر کنید «بهصورت parallel اجرا میشود»، اما این درست نیست! بخشهای برنامه ممکن است بهنظر parallel اجرا شوند، اما در واقع بهصورت sequential و سریعتر از آنچه قابل تشخیص باشد در حال اجرا هستند. CPU با context switch زمان را بین برنامههای مختلف تقسیم میکند و با granularity زمانی بهاندازهٔ کافی درشت، taskها parallel بهنظر میرسند. اگر همان binary را روی ماشینی با دو core اجرا کنیم، بخشهای برنامه ممکن است واقعاً parallel اجرا شوند.
این چند نکتهٔ جالب و مهم را آشکار میکند. اول اینکه ما parallel code نمینویسیم، فقط concurrent code مینویسیم که امیدواریم parallel اجرا شود. باز هم، parallelism ویژگی runtime برنامهٔ ماست، نه code.
نکتهٔ جالب دوم این است که میبینیم ممکن است — شاید حتی مطلوب باشد — نسبت به اینکه concurrent code ما واقعاً parallel اجرا میشود یا نه، بیخبر باشیم. این فقط بهلطف لایههای abstraction زیر مدل برنامهٔ ما ممکن است: concurrency primitiveها، runtime برنامه، operating system، platformی که OS روی آن اجرا میشود (در مورد hypervisorها، containerها و virtual machineها) و در نهایت CPUها. این abstractionها همان چیزهاییاند که به ما اجازه میدهند concurrency و parallelism را از هم جدا کنیم و در نهایت قدرت و انعطاف بیان خود را به ما میدهند. بعداً به این برمیگردیم.
نکتهٔ سوم و آخر این است که parallelism تابعی از زمان یا context است. در بخش «Atomicity» در صفحهٔ ۶ به مفهوم context بحث کردیم. آنجا context بهعنوان مرزهایی تعریف شد که یک operation در آنها atomic در نظر گرفته میشود. اینجا بهعنوان مرزهایی تعریف میشود که دو یا چند operation میتوانند parallel در نظر گرفته شوند.
مثلاً اگر context ما فضایی پنجثانیهای باشد و دو operation اجرا کنیم که هر کدام یک ثانیه طول بکشد، operationها را parallel اجراشده در نظر میگیریم. اگر context ما یک ثانیه باشد، operationها را sequential اجراشده در نظر میگیریم.
شاید تعریف مجدد context بهعنوان time slice زیاد به دردمان نخورد، اما به یاد داشته باشید context به زمان محدود نیست. میتوانیم context را processی که برنامه در آن اجرا میشود، OS thread آن یا ماشینش تعریف کنیم. این مهم است چون contextی که تعریف میکنید با مفهوم concurrency و correctness ارتباط نزدیکی دارد. همانطور که operationهای atomic بسته به context تعریفشده atomic در نظر گرفته میشوند، operationهای concurrent بسته به context تعریفشده درست هستند. همهچیز نسبی است.
کمی abstract است، پس بیایید مثالی ببینیم. فرض کنید contextی که بحث میکنیم کامپیوتر شماست. فیزیک نظری کنار، میتوانیم منطقی انتظار داشته باشیم processی که روی ماشین من اجرا میشود، منطق process روی ماشین شما را تحت تأثیر قرار ندهد. اگر هر دو یک process ماشینحساب را start کنیم و محاسبات سادهای انجام دهیم، محاسباتی که من انجام میدهم نباید محاسباتی که شما انجام میدهید را تحت تأثیر قرار دهد.
مثال احمقانهای است، اما اگر تجزیه کنیم، همهٔ قطعات را در جریان میبینیم: ماشینهای ما context هستند و processها operationهای concurrent. در این مورد، concurrent operationها را با فکر کردن به دنیا در قالب computerها، operating systemها و processهای جداگانه مدل کردهایم. این abstractionها به ما اجازه میدهند با اطمینان درستی را اعلام کنیم.
آیا این واقعاً مثال احمقانهای است؟
استفاده از computerهای جداگانه مثالی مصنوعی برای اثبات نقطه بهنظر میرسد، اما personal computer همیشه اینقدر همهجا نبودند! تا اواخر دههٔ ۱۹۷۰، mainframeها عرف بودند و context رایجی که developerها هنگام فکر کردن به مسائل بهصورت concurrent استفاده میکردند، process برنامه بود.
حالا که بسیاری از developerها با distributed system کار میکنند، دوباره به سمت دیگر در حال تغییر است! اکنون شروع میکنیم به hypervisorها، containerها و virtual machineها بهعنوان contextهای concurrent فکر کنیم.
میتوانیم منطقی انتظار داشته باشیم یک process روی یک ماشین از process روی ماشین دیگر بیتأثیر بماند (فرض بر اینکه بخشی از همان distributed system نباشند)، اما آیا میتوانیم انتظار داشته باشیم دو process روی یک ماشین منطق یکدیگر را تحت تأثیر قرار ندهند؟ process A ممکن است فایلهایی را که process B میخواند بازنویسی کند، یا در OS ناامن، process A حتی ممکن است memoryی که process B میخواند را خراب کند. انجام عمدی این کار، نحوهٔ کار بسیاری از exploitهاست.
با این حال، در سطح process، فکر کردن به این مسائل نسبتاً آسان میماند. اگر به مثال ماشینحساب برگردیم، هنوز منطقی است انتظار داشته باشیم دو user که دو process ماشینحساب روی یک ماشین اجرا میکنند، operationهایشان از نظر منطقی از یکدیگر جدا باشد. خوشبختانه، process boundary و OS به ما کمک میکنند این مسائل را بهصورت منطقی فکر کنیم. اما میبینیم developer شروع به تحمل برخی نگرانیهای concurrency میکند و این مشکل فقط بدتر میشود.
اگر یک سطح پایینتر به OS thread boundary برویم چه؟ اینجاست که همهٔ مشکلات فهرستشده در بخش «Why Is Concurrency Hard?» در صفحهٔ ۴ واقعاً خود را نشان میدهند: race conditionها، deadlockها، livelockها و starvation. اگر یک process ماشینحساب داشتیم که همهٔ userها روی یک ماشین view به آن داشتند، درست کردن concurrent logic سختتر میشد. باید نگران همگامسازی دسترسی به memory و برگرداندن نتایج درست برای user درست میشدیم.
آنچه رخ میدهد این است که با پایین رفتن در stack abstraction، مشکل مدلسازی چیزها بهصورت concurrent هم برای استدلال سختتر میشود و هم مهمتر. برعکس، abstractionهای ما برایمان مهمتر و مهمتر میشوند. به عبارت دیگر، هرچه درست کردن concurrency سختتر باشد، دسترسی به concurrency primitiveهایی که ترکیبپذیریشان آسان است مهمتر است. متأسفانه، بیشتر concurrent logic در صنعت ما در یکی از بالاترین سطوح abstraction نوشته میشود: OS threadها.
قبل از اینکه Go برای اولین بار به عموم معرفی شود، زنجیرهٔ abstraction برای بیشتر زبانهای برنامهنویسی محبوب همینجا تمام میشد. اگر میخواستید concurrent code بنویسید، برنامه را بر اساس thread مدل میکردید و دسترسی به memory بین آنها را synchronize میکردید. اگر چیزهای زیادی داشتید که باید بهصورت concurrent مدل میکردید و ماشینتان thread به این تعداد را تحمل نمیکرد، thread pool میساختید و operationهایتان را روی thread pool multiplex میکردید.
Go یک حلقهٔ دیگر به این زنجیره اضافه کرد: goroutine. علاوه بر این، Go چند مفهوم از کار computer scientist مشهور Tony Hoare قرض گرفته و primitiveهای جدیدی برای استفادهٔ ما معرفی کرده، یعنی channelها.
اگر خط استدلالی که دنبال کردهایم را ادامه دهیم، فرض میکنیم معرفی یک سطح abstraction دیگر زیر OS thread با دشواریهای بیشتری همراه باشد، اما نکتهٔ جالب این است که نیست. در واقع کار را آسانتر میکند. این به این دلیل است که واقعاً یک لایهٔ abstraction دیگر روی OS thread اضافه نکردهایم، آنها را جایگزین کردهایم.
threadها البته هنوز هستند، اما میبینیم که بهندرت مجبوریم فضای مسئله را بر اساس OS thread فکر کنیم. در عوض، چیزها را در goroutine و channel مدل میکنیم و گاهی shared memory. این به برخی ویژگیهای جالب منجر میشود که در بخش «این چطور به شما کمک میکند» در صفحهٔ ۲۹ بررسی میکنیم. اما ابتدا نگاه دقیقتری به جایی بیندازیم که Go بسیاری از ایدههایش را از آنجا گرفته — مقالهٔ ریشهٔ concurrency primitiveهای Go: مقالهٔ بنیادین Tony Hoare، «Communicating Sequential Processes».
CSP چیست؟
وقتی Go مطرح میشود، اغلب میشنوید مردم اختصار CSP را پرتاب میکنند. اغلب در همان نفس بهعنوان دلیل موفقیت Go یا راهحل جادویی برای concurrent programming ستایش میشود. برای کسانی که نمیدانند CSP چیست، کافی است فکر کنند علوم کامپیوتر تکنیک جدیدی کشف کرده که بهطور جادویی نوشتن برنامههای concurrent را به سادگی نوشتن procedural میکند.
در حالی که CSP کار را آسانتر و برنامهها را پایدارتر میکند، متأسفانه معجزه نیست. پس چیست؟ چه چیزی همه را اینقدر هیجانزده کرده؟
CSP مخفف «Communicating Sequential Processes» است که هم یک technique و هم نام مقالهای است که آن را معرفی کرد. در سال ۱۹۷۸، Charles Antony Richard Hoare مقاله را در Association for Computing Machinery (معروفتر به ACM) منتشر کرد.
در این مقاله، Hoare پیشنهاد میکند input و output دو primitive نادیدهگرفتهشدهٔ programming هستند — بهویژه در concurrent code. در زمانی که Hoare این مقاله را نوشت، هنوز پژوهش دربارهٔ چگونگی ساختاردهی برنامهها انجام میشد، اما بیشتر این تلاش به techniqueهای code sequential هدایت میشد: استفاده از goto statement مورد بحث بود و paradigm object-oriented شروع به ریشه دواندن میکرد. به concurrent operationها فکر زیادی نمیشد. Hoare خواست این را اصلاح کند و بنابراین مقالهٔ او و CSP متولد شدند.
در مقالهٔ ۱۹۷۸، CSP فقط یک زبان برنامهنویسی ساده بود که صرفاً برای نشان دادن قدرت communicating sequential process ساخته شده بود؛ در واقع، حتی در مقاله میگوید:
Thus the concepts and notations introduced in this paper should … not be regarded as suitable for use as a programming language, either for abstract or for concrete programming.
Hoare عمیقاً نگران بود techniqueهایی که ارائه میدهد چیزی برای پیشبرد مطالعهٔ درستی برنامهها انجام نمیدهند و techniqueها ممکن است در زبان real مبتنی بر کار خودش performant نباشند. در شش سال بعد، ایدهٔ CSP به representation formal چیزی به نام process calculus پالایش شد تا ایدههای communicating sequential process را بگیرد و واقعاً شروع به استدلال دربارهٔ correctness برنامه کند. process calculus راهی برای مدلسازی ریاضی concurrent system است و همچنین قوانین algebraic برای انجام transformation روی این systemها فراهم میکند تا ویژگیهای مختلف آنها، مثلاً efficiency و correctness را تحلیل کند.
اگرچه process calculi موضوع جالب خودشان هستند، خارج از scope این کتاباند. و چون مقالهٔ اصلی CSP و زبانی که از آن evolve شد، تا حد زیادی inspiration مدل concurrency Go بودند، روی اینها تمرکز میکنیم.
برای پشتیبانی از assertion که input و output باید language primitive در نظر گرفته شوند، زبان برنامهنویسی CSP Hoare primitiveهایی برای مدل کردن input و output، یا communication بین processها بهدرستی داشت (از اینجا نام مقاله میآید). Hoare اصطلاح process را به هر بخش encapsulated از logic که برای اجرا به input نیاز داشت و output تولید میکرد که processهای دیگر consume میکردند، اعمال کرد. Hoare احتمالاً میتوانست از کلمه «function» استفاده کند اگر debate دربارهٔ چگونگی ساختاردهی برنامهها در community هنگام نوشتن مقالهٔ او نبود.
برای communication بین processها، Hoare commandهای input و output ساخت: ! برای فرستادن input به یک process، و ? برای خواندن output از یک process. هر command باید یا یک output variable (در مورد خواندن variable از یک process) یا یک destination (در مورد فرستادن input به یک process) مشخص میکرد. گاهی این دو به یک چیز اشاره میکردند، که در آن صورت گفته میشد دو process correspond میکنند. به عبارت دیگر، output یک process مستقیماً به input process دیگر flow میکرد. جدول ۲-۱ چند مثال از مقاله را نشان میدهد.
جدول ۲-۱. بخشی از مثالهای مقاله CSP Hoare
| Operation | توضیح |
|---|---|
cardreader?cardimage | از cardreader، یک card بخوان و مقدار آن (آرایهای از characterها) را به متغیر cardimage نسبت بده. |
lineprinter!lineimage | به lineprinter، مقدار lineimage را برای چاپ بفرست. |
X?(x, y) | از process به نام X، یک جفت مقدار input کن و به x و y نسبت بده. |
DIV!(3*a+b, 13) | به process DIV، دو مقدار مشخصشده را output کن. |
*[c:character; west?c → east!c] | همهٔ characterهای outputشده توسط west را بخوان و یکییکی به east output کن. تکرار وقتی process west terminate شود پایان مییابد. |
شباهتها به channelهای Go آشکار است. توجه کنید در مثال آخر output از west به متغیر c فرستاده شد و input به east از همان متغیر دریافت شد. این دو process correspond میکنند. در مقالهٔ اول Hoare دربارهٔ CSP، processها فقط از طریق source و destination نامگذاریشده communicate میکردند. او پذیرفت که این برای embed کردن code بهعنوان library مشکل ایجاد میکند، چون consumerهای code باید نام inputها و outputها را بدانند. بهصورت غیررسمی به امکان register کردن چیزی که «port name» نامید، اشاره کرد، که در آن nameها در head دستور parallel اعلام میشدند، چیزی که احتمالاً named parameter و named return value میشناسیم.
زبان همچنین از چیزی به نام guarded command استفاده میکرد که Edgar Dijkstra در مقالهٔ قبلی نوشتهشده در ۱۹۷۴، «Guarded commands, nondeterminacy and formal derivation of programs» معرفی کرده بود. guarded command صرفاً statementی با سمت چپ و راست است که با → جدا شده. سمت چپ بهعنوان conditional یا guard برای سمت راست عمل میکرد به این معنا که اگر سمت چپ false بود یا در مورد command، false برمیگرداند یا exit کرده بود، سمت راست هرگز execute نمیشد. ترکیب اینها با I/O commandهای Hoare پایهٔ communicating processهای Hoare و در نتیجه channelهای Go را گذاشت.
با این primitiveها، Hoare چند مثال را گامبهگام پیش برد و نشان داد زبان با پشتیبانی first-class از مدلسازی communication، حل مسائل را سادهتر و قابل فهمتر میکند. بعضی از notationهایی که استفاده میکند کمی فشرده است (perl programmerها احتمالاً مخالفند!)، اما مسائلی که ارائه میدهد solutionهای فوقالعاده واضح دارند. solutionهای مشابه در Go کمی طولانیترند، اما این وضوح را هم با خود دارند.
تاریخ judgment کرده که پیشنهاد Hoare درست بوده؛ با این حال، جالب است توجه کنیم قبل از release Go، زبانهای کمی واقعاً پشتیبانی این primitiveها را به زبان آورده بودند. بیشتر زبانهای محبوب به sharing و synchronize کردن دسترسی به memory نسبت به style message-passing CSP ترجیح میدهند. exceptionهایی هست، اما متأسفانه به زبانهایی محدودند که adoption گسترده ندیدهاند. Go یکی از اولین زبانهایی است که principleهای CSP را در core خود گنجانده و این style concurrent programming را به عموم آورده. موفقیت آن باعث شده زبانهای دیگر هم تلاش کنند این primitiveها را اضافه کنند.
memory access synchronization ذاتاً بد نیست. بعداً در فصل (در «فلسفه Go در مورد concurrency» در صفحهٔ ۳۱) میبینیم که گاهی sharing memory در موقعیتهای خاص، حتی در Go، مناسب است. با این حال، shared memory model میتواند استفادهٔ درست از آن سخت باشد — بهویژه در برنامههای بزرگ یا پیچیده. به همین دلیل concurrency یکی از نقاط قوت Go در نظر گرفته میشود: از ابتدا با principleهای CSP در ذهن ساخته شده و بنابراین خواندن، نوشتن و استدلال دربارهٔ آن آسان است.
این چطور به شما کمک میکند
شاید همهٔ اینها برایتان جذاب باشد یا نباشد، اما احتمالاً اگر این کتاب را میخوانید مسائلی برای حل دارید و میپرسید چرا اینها مهم است. Go چه کار متفاوتی میکند که آن را از زبانهای محبوب دیگر در concurrency متمایز کرده؟
همانطور که در بخش «تفاوت concurrency و parallelism» در صفحهٔ ۲۳ برای مدلسازی مسائل concurrent بحث کردیم، رایج است زبانها زنجیرهٔ abstraction خود را در سطح OS thread و memory access synchronization تمام کنند. Go مسیر متفاوتی میرود و این را با مفهوم goroutine و channel جایگزین میکند.
اگر مقایسهای بین مفاهیم در دو روش abstraction concurrent code بکشیم، احتمالاً goroutine را با thread و channel را با mutex مقایسه میکنیم (این primitiveها فقط شباهت گذرا دارند، اما امیدواریم مقایسه به جهتیابی کمک کند). این abstractionهای متفاوت برای ما چه میکنند؟
goroutineها ما را از فکر کردن به فضای مسئله بر اساس parallelism رها میکنند و در عوض اجازه میدهند مسائل را نزدیکتر به سطح طبیعی concurrency مدل کنیم. اگرچه تفاوت concurrency و parallelism را مرور کردیم، ممکن است روشن نباشد این تفاوت چگونه بر مدلسازی solutionها تأثیر میگذارد. بیایید وارد یک مثال شویم.
فرض کنید باید web server بسازم که request روی یک endpoint handle کند. frameworkها را کنار بگذاریم، در زبانی که فقط thread abstraction ارائه میدهد، احتمالاً دربارهٔ سؤالات زیر فکر میکنم:
- آیا زبان من بهطور طبیعی از thread پشتیبانی میکند، یا باید library انتخاب کنم؟
- مرزهای thread confinement من کجا باید باشند؟
- threadها در این operating system چقدر سنگیناند؟
- operating systemهایی که برنامه روی آنها اجرا میشود threadها را چگونه متفاوت handle میکنند؟
- باید poolی از worker بسازم تا تعداد threadهایی که میسازم محدود شود. چگونه تعداد بهینه را پیدا کنم؟
همهٔ اینها چیزهای مهمی برای در نظر گرفتن هستند، اما هیچکدام مستقیماً به مسئلهای که میخواهید حل کنید مربوط نیست. فوراً به جزئیات فنی نحوهٔ حل مسئلهٔ parallelism کشیده شدهاید.
اگر یک قدم عقب برویم و به مسئلهٔ طبیعی فکر کنیم، میتوانیم آن را اینطور بیان کنیم: userهای فردی به endpoint من connect میشوند و session باز میکنند. session باید request آنها را handle کند و response برگرداند. در Go، میتوانیم تقریباً مستقیماً state طبیعی این مسئله را در code بازتاب دهیم: برای هر connection ورودی یک goroutine میسازیم، request را آنجا handle میکنیم (احتمالاً با goroutineهای دیگر برای data/service ارتباط برقرار میکنیم) و سپس از function goroutine برمیگردیم. نحوهٔ طبیعی فکر کردن ما به مسئله مستقیماً به نحوهٔ طبیعی code کردن در Go map میشود.
این با promiseای که Go به ما میدهد محقق میشود: goroutineها lightweight هستند و معمولاً نگران ساخت یکی نخواهیم بود. زمانهای مناسبی برای در نظر گرفتن چند goroutine در system در حال اجراست، اما انجام این از پیش بهوضوح premature optimization است. با threadها مقایسه کنید که عاقلانه است چنین مسائلی را از پیش در نظر بگیرید.
فقط به این دلیل که framework برای زبانی موجود است که نگرانیهای parallelism را برایتان abstract میکند، به این معنا نیست که این روش طبیعی مدلسازی مسائل concurrent مهم نیست! کسی باید framework را بنویسد و code شما روی هر پیچیدگیای که نویسنده(گان) با آن سر و کار داشتهاند قرار میگیرد. فقط به این دلیل که پیچیدگی از شما پنهان است به این معنا نیست که نیست، و پیچیدگی bug تولید میکند. در مورد Go، زبان حول concurrency طراحی شده، پس زبان با concurrency primitiveهایی که فراهم میکند ناهماهنگ نیست. این یعنی اصطکاک کمتر و bug کمتر!
نگاشت طبیعیتر به فضای مسئله یک مزیت عظیم است، اما عوارض جانبی مفیدی هم دارد. runtime Go goroutineها را بهصورت خودکار روی OS thread multiplex میکند و scheduling آنها را برای ما مدیریت میکند. این یعنی بهینهسازی runtime بدون تغییر نحوهٔ مدلسازی مسئلهٔ ما ممکن است؛ این جداسازی کلاسیک نگرانیها (separation of concerns) است. با پیشرفتها در parallelism، runtime Go بهبود مییابد و کارایی برنامهٔ شما هم — همه رایگان. release noteهای Go را زیر نظر داشته باشید و گاهی چیزهایی مثل این میبینید:
In Go 1.5, the order in which goroutines are scheduled has been changed.
نویسندگان Go پشت صحنه بهبود میدهند تا برنامهٔ شما سریعتر شود.
این decoupling concurrency و parallelism مزیت دیگری دارد: چون runtime Go scheduling goroutineها را برای ما مدیریت میکند، میتواند روی چیزهایی مثل goroutineهای blocked در انتظار I/O introspect کند و OS threadها را بهصورت هوشمندانه به goroutineهایی که blocked نیستند reallocate کند. این هم کارایی code شما را افزایش میدهد. بیشتر از آنچه runtime Go برایتان انجام میدهد در فصل ۶ بحث میکنیم.
مزیت دیگر نگاشت طبیعیتر بین فضای مسئله و code Go، احتمالاً مقدار بیشتری از فضای مسئله است که بهصورت concurrent مدل شده. چون مسائلی که بهعنوان developer روی آنها کار میکنیم اغلب بهطور طبیعی concurrent هستند، بهطور طبیعی concurrent code را در granularity ریزتری نسبت به شاید زبانهای دیگر مینویسیم؛ مثلاً اگر به مثال web server برگردیم، اکنون برای هر user یک goroutine داریم بهجای connectionهای multiplexشده روی thread pool. این granularity ریزتر به برنامه اجازه میدهد هنگام اجرا بهصورت پویا به میزان parallelism ممکن روی host برنامه scale کند — قانون Amdahl در عمل! این واقعاً شگفتانگیز است.
و goroutine فقط یک قطعه از پازل است. مفاهیم دیگر از CSP، channelها و select statement هم ارزش اضافه میکنند.
channelها، مثلاً، ذاتاً با channelهای دیگر composable هستند. این نوشتن systemهای بزرگ را سادهتر میکند چون input از چند subsystem را با compose کردن آسان output با هم coordinate میکنید. میتوانید input channelها را با timeout، cancellation یا message به subsystemهای دیگر ترکیب کنید. هماهنگ کردن mutex proposition بسیار سختتری است.
select statement مکمل channelهای Go است و چیزی است که همهٔ بخشهای سخت compose کردن channel را فعال میکند. select statementها به شما اجازه میدهند بهصورت کارآمد برای eventها wait کنید، message را از channelهای رقابتی بهصورت uniform random انتخاب کنید، اگر messageی در انتظار نیست ادامه دهید و بیشتر.
این بافت شگفتانگیز از primitiveهای الهامگرفته از CSP و runtime که از آن پشتیبانی میکند، چیزهاییاند که Go را قدرتمند میکنند. بقیهٔ کتاب را صرف کشف نحوهٔ کار، چرایی و استفاده از آنها برای نوشتن code فوقالعاده میکنیم.
فلسفه Go در مورد concurrency
CSP بخش بزرگی از آنچه Go حول آن طراحی شد بود؛ با این حال، Go همچنین از طریق memory access synchronization و primitiveهایی که از آن technique پیروی میکنند، روشهای سنتیتر نوشتن concurrent code را پشتیبانی میکند. structها و methodها در package sync و packageهای دیگر به شما اجازه میدهند lock انجام دهید، pool از resource بسازید، goroutineها را preempt کنید و بیشتر.
این توانایی انتخاب بین CSP primitive و memory access synchronization برای شما عالی است چون کنترل بیشتری روی style concurrent codeای که برای حل مسائل انتخاب میکنید میدهد، اما میتواند کمی گیجکننده باشد. تازهواردان به زبان اغلب این برداشت را میگیرند که style concurrency CSP تنها راه نوشتن concurrent code در Go است. مثلاً در documentation package sync میگوید:
Package sync provides basic synchronization primitives such as mutual exclusion locks. Other than the Once and WaitGroup types, most are intended for use by low-level library routines. Higher-level synchronization is better done via channels and communication.
در language FAQ میگوید:
Regarding mutexes, the sync package implements them, but we hope Go programming style will encourage people to try higher-level techniques. In particular, consider structuring your program so that only one goroutine at a time is ever responsible for a particular piece of data.
Do not communicate by sharing memory. Instead, share memory by communicating.
همچنین مقالهها، سخنرانیها و مصاحبههای متعددی هست که اعضای مختلف Go core team style CSP را بر primitiveهایی مثل sync.Mutex ترجیح میدهند.
بنابراین کاملاً قابل درک است که گیج شوید چرا Go team اصلاً memory access synchronization primitive را expose کرد. شاید حتی گیجکنندهتر این باشد که synchronization primitiveها را بهطور رایج در دنیای واقعی میبینید، مردم دربارهٔ overuse channel شکایت میکنند و بعضی از اعضای Go team هم میگویند استفاده از آنها OK است. نقلقول از Go Wiki در این مورد:
One of Go's mottos is "Share memory by communicating, don't communicate by sharing memory."
That said, Go does provide traditional locking mechanisms in the sync package. Most locking issues can be solved using either channels or traditional locks.
So which should you use?
Use whichever is most expressive and/or most simple.
این توصیهٔ خوبی است و guidelineای است که اغلب هنگام کار با Go میبینید، اما کمی مبهم است. چگونه بفهمیم چه چیزی expressiveتر و/یا سادهتر است؟ چه معیارهایی میتوانیم استفاده کنیم؟ خوشبختانه راهنماهایی داریم که به کار درست کمک میکنند. همانطور که میبینیم، روشی که عمدتاً میتوانیم تمایز قائل شویم از جایی میآید که سعی داریم concurrency را مدیریت کنیم: درون یک scope تنگ، یا بیرون در سراسر system. شکل ۲-۱ این راهنماها را در یک decision tree فهرست میکند.
شکل ۲-۱. Decision tree
آیا سعی دارید مالکیت داده را منتقل کنید؟ → channel آیا سعی دارید internal state یک struct را guard کنید؟ → memory access synchronization آیا سعی دارید چند بخش از logic را coordinate کنید؟ → channel آیا performance-critical section است؟ → memory access synchronization (با احتیاط)
بیایید این نقاط تصمیم را یکییکی مرور کنیم:
آیا سعی دارید مالکیت داده را منتقل کنید؟
اگر بخشی از code دارید که نتیجه تولید میکند و میخواهد آن نتیجه را با بخش دیگری از code share کند، آنچه واقعاً انجام میدهید انتقال مالکیت آن data است. اگر با مفهوم memory-ownership در زبانهایی که garbage collection پشتیبانی نمیکنند آشنا هستید، این همان ایده است: data مالک دارد و یکی از راههای safe کردن concurrent program این است که فقط یک concurrent context در هر زمان مالک data باشد. channelها به ما کمک میکنند این مفهوم را communicate کنیم چون آن intent را در type channel encode میکنند.
یک مزیت بزرگ انجام این کار این است که میتوانید buffered channel بسازید تا in-memory queue ارزان implement کنید و بنابراین producer را از consumer decouple کنید. مزیت دیگر این است که با استفاده از channel، بهطور ضمنی concurrent code خود را با concurrent code دیگر composable کردهاید.
آیا سعی دارید internal state یک struct را guard کنید؟
این گزینهٔ عالی برای memory access synchronization primitive است و نشانهٔ قوی که نباید از channel استفاده کنید. با استفاده از memory access synchronization primitive، میتوانید جزئیات پیادهسازی قفل کردن critical section را از callerهای خود پنهان کنید. مثال کوچکی از typeی که thread-safe است اما آن پیچیدگی را به callerهایش expose نمیکند:
go
type Counter struct {
mu sync.Mutex
value int
}
func (c *Counter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.value++
}اگر مفهوم atomicity را به یاد بیاورید، میتوانیم بگوییم آنچه اینجا انجام دادهایم scope atomicity برای type Counter تعریف کردهایم. call به Increment را میتوان atomic در نظر گرفت.
کلمهٔ کلیدی اینجا internal است. اگر lockها را فراتر از یک type expose میکنید، باید red flag بلند شود. سعی کنید lockها را به lexical scope کوچک محدود نگه دارید.
آیا سعی دارید چند بخش از logic را coordinate کنید؟
به یاد داشته باشید channelها ذاتاً composableتر از memory access synchronization primitive هستند. داشتن lock پراکنده در object-graph شما کابوس بهنظر میرسد، اما داشتن channel همهجا expected و encouraged است! میتوانم channel compose کنم، اما lock یا methodهایی که value برمیگردانند را بهراحتی compose نمیکنم.
کنترل پیچیدگی emergent که در software شما پدید میآید با channel بسیار آسانتر خواهد بود بهدلیل select statement Go و توانایی آنها در serve کردن بهعنوان queue و pass شدن safely. اگر برای فهمیدن نحوهٔ کار concurrent code خود مشکل دارید، چرا deadlock یا race رخ میدهد و از primitive استفاده میکنید، این احتمالاً نشانهٔ خوبی است که باید به channel switch کنید.
آیا performance-critical section است؟
این به هیچ وجه به این معنا نیست: «میخواهم برنامهام performant باشد، بنابراین فقط از mutex استفاده میکنم.» بلکه اگر sectionی از برنامه دارید که profile کردهاید و معلوم شده bottleneck اصلی است که orders of magnitude کندتر از بقیهٔ برنامه است، استفاده از memory access synchronization primitive ممکن است به این critical section کمک کند تحت load perform کند. این به این دلیل است که channelها برای کار از memory access synchronization استفاده میکنند، بنابراین فقط میتوانند کندتر باشند. قبل از در نظر گرفتن این، با این حال، performance-critical section ممکن است hint دهد که باید برنامه را restructure کنیم.
امیدواریم این وضوح کمی دربارهٔ استفاده از style concurrency CSP یا memory access synchronization بدهد. pattern و practiceهای دیگری هست که در زبانهایی که OS thread را بهعنوان means abstraction concurrency استفاده میکنند مفیدند. مثلاً چیزهایی مثل thread pool اغلب مطرح میشوند. چون بیشتر این abstractionها به نقاط قوت و ضعف OS thread target شدهاند، rule of thumb خوب هنگام کار با Go این است که این patternها را کنار بگذارید. این به این معنا نیست که اصلاً مفید نیستند، اما use caseها در Go قطعاً بسیار محدودترند. فضای مسئله را با goroutine مدل کنید، از آنها برای نمایش بخشهای concurrent workflow استفاده کنید و در start کردن آنها liberal نباشید. احتمالاً بیشتر نیاز دارید برنامه را restructure کنید تا اینکه به upper limit تعداد goroutineهایی که hardware شما پشتیبانی میکند برسید.
فلسفه Go در مورد concurrency را میتوان اینطور خلاصه کرد: سادگی را هدف بگیرید، در صورت امکان از channel استفاده کنید و goroutine را مثل منبع رایگان در نظر بگیرید.