در قسمت‌های قبل با زبان گو بیشتر آشنا شدیم و به ویژگی‌های شیءگرایی آن اشاره کردیم. در این پست به مفاهیم Concurrency و قابلیت Functional در زبان Go می‌پردازیم.

Go و Concurrency:

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

در ادامه با تعریف Rob Pike از این مفاهیم آشنا می‌شویم:

Concurrency: برنامه نویسی بر مبنای مجموعه از واحدهای اجرایی مستقل، که هدف مشترکی دارند.

Parallelism: توانایی اجرای چندین پردازش به صورت موازی، با هدف دستیابی به سرعتی بالتر.

Concurrency مدلی برای ساخت یک برنامه است، اما Parallelism مدلی برای اجرای برنامه هاست.

Concurrency در فاز ساخت برنامه اعمال می‌شود، اما Parallelism در زمان اجرای برنامه اتفاق می‌افتد.

در Concurrency واحدهای اجرای مستقل از یکدیگرند، ولی هدف مشترکی دارند.

در Parallelism پردازش‌ها ممکن است هیچ ربطی به هم نداشته باشند. (مثل پردازش دو برنامه جداگانه)

Concurrency اجرای Parallel واحدهای اجرایی را تضمین نمی‌کند! ممکن است برنامه شما Concurrent باشد اما اجزایش به صورت Parallel اجرا نشود (مثل Concurrency در Python). مهم این است که ساختار برنامه به صورت Concurrent نوشته شده باشد.

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

به عبارت دیگر، اگر برنامه‌ای به صورت Concurrent ساخته نشود، به صورت Parallel اجرا نخواد شد! (البته Load کردن چندین نمونه از یک برنامه در حافظه برای انجام پردازش Parallel مبحث جداگانه ایست که ربطی به بحث فعلی ندارد.)

هر برنامه نویس هم ممکن است به شیوه متفاوتی Concurrency را در ساختار برنامه اش اعمال کند. قانون ثابتی برای طراحی برنامه‌های Concurrent وجود ندارد.

لازم به ذکر است که برای دستیابی به پردازش Parallel،  حتما باید بیش از یک هسته CPU در دسترس باشد تا پردزش Parallel به شکل واقعی اتفاق بیفتد.

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

در این بین، زبان‌های تابع گرا و یا Functional که به طور ذاتی برنامه نویسی Concurrent را به شما تلقین می‌کنند در این زمینه پیش افتاده اند و هر روز بر محبویت شان افزوده می‌شود. زبان‌هایی مثل Erlang و Haskell و Clojure و ...

زبان‌های دستوری و یا Imperative هم هر کدام در این راستا تلاش‌هایی کرده اند، اما اکثر آن‌ها هنوز هم به طور مستقیم از Thread و Process‌ها استفاده می‌کنند.

درست است که مبنای کار همه پردازش‌ها در نهایت بر پایه Thread‌ها و Process‌ها خواهد بود، اما این‌ها جزو مفاهیم سطح پایین یک سیستم عامل محسوب می‌شوند و استفاده مستقیم از آن‌ها برای پیاده سازی Concurrency بسیار بسیار دشوار است و در عمل بهینگی لازم را ندارد.

به نظر می‌رسد استفاده از تکنیک عملیات نا همگام و یا Asynchronous تا حدودی به روند ساخت برنامه‌های Concurrent در این زبان‌ها کمک کرده و بهینگی لازم را برای آنان فراهم نبوده است.

کتابخانه‌هایی مثل gevent در Python یا محیط‌های همچون node.js برای JavaScript از نمونه‌های موفق در بکارگیری تکنیک عملیات Asynchronous می‌باشند. با اینکه چنین فریم ورک‌هایی از استقبال خوبی برخوردار شده اند، اما راه حلی برای برنامه نویسی Concurrent به حساب نمی‌آیند.

حقیقت این است که عملیات Asynchronous برای گونه خاصی از برنامه‌ها که رخدادهای I/O در مقیاس بالا در آن‌ها اتفاق می‌افتند بسیار خوب عمل می‌کنند (مثل وب سرورها) اما وقتی صحبت از پردازش‌ای سنگین می‌شود، مدل Asynchronous راه حل مناسبی ارایه نمی‌کند.

در مدل Asynchronous اگر قسمتی از برنامه نیاز به پردازش طولانی مدت داشته باشد، بقیه اجرا باید منتظر بمانند تا کار آن قسمت تمام شود؛ چرا که تمام اجزای برنامه فقط در یک Thread پردازش می‌شود. از همین رو پردازش‌ها باید بسیار کوچک و گذرا تعریف شوند.

با توضیحات بالا به نظر می‌رسد یک پیاده سازی مناسب از قابلیت Concurrency باید دارای خصوصیات زیر باشد:

  • پیاده سازی Concurrency باید ساده و آسان باشد.
  • پیاده سازی Concurrency باید بهینه و سبک باشد.
  • پیاده سازی Concurrency باید تا جایی که ممکن است همه منظوره باشد.

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

Go چنین بستری را مدیون تجربه سی ساله Rob Pike در زمینه طراحی سیستم عامل‌ها و زبان‌های Concurrent است. هر چه باشد، کار این افراد در گذشته و حال ساخت و طراحی سیستم عامل بوده است.

از آنجایی که برنامه نویسی Concurrent در Go اهمیت زیادی دارد، طراحان زبان یک قابلیت منحصر به فرد را برای این منظور در زبان جاسازی کرده اند و آن Goroutines است.

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

در زبان Go ساختاری به اسم Goroutine بیانگر این واحد اجرایی مستقل است.

یک Goroutine در واقع همانند یک Coroutine است و تشابه اسمی آن‌ها بی دلیل نیست. اما قبل از هر چیز توضیح کوتاهی داشته باشیم برای افرادی که با Coroutine‌ها آشنایی ندارند.

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

بعدها اگر دوباره به این تابع بازگشتیم، اجرای تابع از جایی که قبلا متوقف شده بود ادامه پیدا می‌کند. این قابلیت برنامه نویس را قادر می‌سازد تا Coroutine‌ها را زمانبندی نماید و یا بین آن‌ها سوییچ کند.

رمز کار در این است که علاوه بر داشتن یک Stack سراسری برای نگه داری وضعیت کلی برنامه، برای هر Coroutine نیز یک Stack جداگانه ساخته می‌شود تا Coroutine بتواند وضعیت فعلی خود را به هنگام سوییچ شدن در آن ذخیره کند.

یک Coroutine بسیار شبیه یک Thread است. وقتی سیستم عامل از یک Thread به Thread دیگری سوییچ می‌کند، Thread قبلی را به حالت Standby فرو می‌برد تا وقتی دوباره به آن برگشت آن Thread قادر به ادامه اجرایش باشد. برای همین است که گاهی Coroutine‌ها را با نام Green Thread هم صدا می‌زنند چرا که رفتار آن‌ها بسیار شبیه Thread‌ها است.

Coroutine‌ها در زبان‌هایی مثل Erlang ،Haskell، Scheme، Perl، Lua، Ruby، Python و خیلی زبان‌های دیگر وجود دارند، هرچند که ممکن است با اسم متفاوتی ظاهر شوند.

برای مثال در Python با نام Greenlet، در Ruby با نام Fiber، و یا در Erlang با نام Lightweight Process شناخته می‌شوند. البته هر کدام از این زبان‌ها سلیقه خاص خودشان را در پیاده سازی Coroutine‌ها اعمال کرده اند.

Coroutine‌ها چندین فرق اساسی با Thread‌ها دارند:

  • عمل زمانبندی و سوییچ کردن بین Thread‌ها به صورت اتوماتیک و توسط سیستم عامل انجام می‌شود. اما در Coroutine‌ها خود برنامه نویس باید به صورت دستی زمانبندی و کنترل اجرای Coroutine‌ها را مدیریت کند.
  • از آن جایی که Coroutine‌ها در واقع نوعی از توابع هستند، قالبا در داخل یک Thread اجرا می‌شوند و به همین خاطر قادر نیستند از چندین هسته پردازنده استفاده کنند. اما Thread‌ها می‌توانند براحتی روی هسته‌های مختلف پخش شوند.
  • Coroutine‌ها در لایه کاری خود زبان برنامه نویسی مثل سیستم Runtime یا VM آن زبان اجرا و مدیریت می‌شوند. اما Thread‌ها در لایه کاری سیستم عامل اجرا و مدیریت می‌شود.
  • چون Coroutine‌ها نوعی از توابع معمولی هستند و در لایه کاری زبان برنامه نویسی اجرا می‌شوند، پس اجرای آن‌ها در حافظه و یا سوییچ کردن بین آن‌ها ده‌ها و شاید هم صدها برابر سریعتر و بهینه تر از Thread هاست.

یک Groutine در واقع پیاده سازی منحصر به فرد زبان Go از Coroutine هاست که به عنوان واحد اصلی Concurrency در این زبان جاسازی شده است. در ادامه با خصوصیات Goroutine‌ها آشنا می‌شوید:

  • اجرای Goroutine‌ها در لایه سیستم عامل صورت نمی‌گیرد و در لایه خود زبان (سیستم Runtime) مدیریت می‌شود. (البته وظیفه اجرای Grotoutine‌ها رد لایه سیستم عامل به عهده Thread هاست.)
  • زمانبندی Goroutine‌ها به طور اتوماتیک توسط سیستم Runtime زبان انجام می‌شود و این مسئولیت از دوش برنامه نویس برداشته شده است.
  • سیستم Runtime می‌تواند Goroutine‌ها را روی چندین Thread پخش کند و چون Thread‌ها نیز می‌توانند روی هسته‌های مختلف CPU پخش شوند، پس اجرای واقعی Parallel اتفاق می‌افتد.
  • سیستم Runtime در Go فقط مسئول کنترل Goroutine‌ها در یک ماشین است. یعنی یک ماشین با چند CUP، یا یک CPU چند هسته ای. پردازش‌های Distributed باید توسط خود برنامه نویس طراحی شود.
  • Goroutine‌ها بسیار سبک و بهینه هستند. در کامپیوتری که ممکن است با ایجاد 1.000 عدد Thread کرش کند، می‌توان 1.000.000 عدد از Goroutine را اجرا کرد! سایز پیش فرض Stack برای هر Goroutine فقط 4kB است.
  • Goroutine‌ها بلاک نمی‌شوند. اگر در یک Goroutine عملیات بلاک شونده I/O صورت بگیرد، بقیه Goroutine‌ها در Thread دیگری به اجرای خودشان ادامه می‌دهند.
  • در جایی هم که ممکن باشد، سیستم Runtime خود به خود از عملیات Asynchronous برای رخدادهای I/O استاده می‌کند. شما نیاز نیست با مدل برنامه نویسی Asynchronous درگیر شوید.
  • Goroutine‌ها بر مبنای سیستم انتقال پیام (Message Passing) کار می‌کنند و به این شیوه قادرند با یکدیگر در ارتباط باشند. در Go پیام‌ها توسط Channel‌ها که در واقع همان کانال‌های ارتباطی بین Goroutine‌ها هستند رد و بدل می‌شوند.
  • به صورت پیش فرض، عمل انتقال پیام در Go به شکل Synchronous اتفاق می‌افتد. یعنی پیام فقط زمانی فرستاده می‌شود. که فرستنده و گیرنده هر دو آماده باشند. این قضیه باعث ساده تر شدن برنامه نویسی می‌شود. البته در صورت لزوم می‌توانید عمل انتقال پیام را به شکل Asynchronous نیز انجام دهید.
  • Channel‌های Go مانند خود زبان Static Type هستند. اگر یک channel تعیین کند که قرار است فقط داده‌های int را ردو بدل کند، داده دیگری از آن نخواهد گذشت.

احتمالا متوجه شباهت Goroutine‌های Go و Lightweight‌های Erlang شده اید. البته این دو جدای از شباهت ظاهری، تفاوت‌های پایه‌ای بسیاری با یکدیگر دارند. Goroutine‌ها بر مبنای مدل CSP پیاده سازی شده اند در حالی که Lightweight Process‌ها بر مبنای مدل Actor توسعه پیدا کرده اند.

مهم ترین تفاوت مدل CSP و مدل Actor به شرح زیر است:

  • در مدل CSP واحدهای اجرایی بی نام هستند در حالی در مدل Actor دارای شناسه می‌باشند.
  • ارسال پیام در مدل CSP به شکل Synchronous انجام می‌گیرد در حالی که در مدل Actor به شکل Asynchronous اتفاق می‌افتد.
  • ارسال پیام در مدل CSP به کمک Channel‌ها انجام می‌گیرد ولی در مدل Actor مستقیم و بدون واسطه است.

هر کدام از این مدل‌ها مزایا و معایب خودشان را دارند. همچنین باید دقت داشت که Go مانند Erlang یک زبان Functional نیست و از ساختار Immutable استفاده نمی‌کند، پس لازم است برنامه نویس کمی بیشتر در ساخت برنامه‌های Concurrent محتاط باشد.

Rob Pike در یک ویدیو آموزشی در YouTube به نام Go Concurrency Patterns مثالی جالب از توانایی Goroutine‌ها را به همگان نشان داد. او کدی نوشته بود که صدهزار Goroutine را در حافظه ایجاد می‌کرد. سپس یک عدد int بین این Goroutine‌ها دست به دست می‌چرخید و هر Goroutine هم یک واحد به آن اضافه می‌کرد.

دقت کنید که برنامه کامپایل نشده بود. بنابراین وقتی Pike دکمه Run را فشار می‌داد، برنامه باید کامپایل می‌شد، لینک می‌شد، در حافظه اجرا می‌شد، و جواب اجرا برگشت داده می‌شد... کل این پروسه فقط یک ثانیه به طول انجامید.

احتمالاً اجرای یک برنامه Hello World در Java یا C# که از قبل هم کامپایل شده باشد، ممکن است بیشتر از یک ثانیه به طول انجامد! این حرف جنبه شوخی داشت و از نظر علمی چیزی را ثابت نمی‌کند.

Go و قابلیت‌های Functional

در سال‌ها اخیر زبان‌های Functional از سایه بیرون آمده اند و محبویت خوبی برای خود دست و پا کرده اند. مخصوصا بعد از معرفی مدل MapReduce از طرف گوگل که با الهام گیری از زبان‌های Functional شکل گرفته بود، و همچنین رایج شدن CPU‌های چند هسته‌ای و نیاز به بستر مناسب برای Concurrency و پیشرو بودن زبان‌های Functional در این زمینه، توجه همه به آن‌ها معطوف شده است.

توابع map() و reduce() از توابع اساسی زبان‌های Functional می‌باشند و تقریبا در تمام زبان‌های Functional حضور دارند.

حتی زبان‌های غیر Functional مثل Python و Ruby و JavaScript و ... هم با اینکه جزو زبان‌های Functional به حساب نمی‌آیند اما دلشان نیامده تا بعضی از ویژگی‌های زبان‌های Functional را ارایه نکنند.

Go یک زبان سیستمی است؛ یعنی هر چقدر هم که ساده باشد، باید همانند C جنبه‌های سطح پایین خود را حفظ کند. نباید انتظار داشت که چنین زبانی گرایش Functional داشته باشد. اما طراحان Go ترجیح داده اند که کمی هم چاشنی Functional به زبان اضافه کنند.

در Go، توابع جزو اعضای درجه اول زبان به حساب می‌آیند (First-Class) یعنی می‌توان:

  • یک تابع را همانند مقادیر معمولی به عنوان آرگومان به توابع دیگر ارسال کرد.
  • یک تابع را به عنوان جواب خروجی از تابع دیگر برگشت داد.
  • یک تابع را به یک متغیر نسبت داد؛ به همان صورتی که یک عدد را به یک متغیر int نسبت می‌دهیم.

Go همچنین داری قابلیت استفاده از توابع به نام (Anonymous Functions) است. توابع Anonymous توابعی هستند که می‌توانند به صورت لحظه‌ای تولید شوند و مورد استفاده قرار بگیرند؛ یعنی نیازی نیست که مانند توابع معمولی از قبل آن‌ها را در جایی از کدهایتان تعریف کرده باشید. این توابع خصوصا هنگام کار با Goroutine‌ها بسیار مفید واقع می‌شوند.

وقتی زبانی دارای توابع First-Class باشد، و امکان تعریف توابع Anonymous را هم داشته باشد، یعنی  Closure‌ها نیز در آن زبان حضور دارند. کار Closure‌ها بر بنای توابع تو در تو استوار است. ممکن است برنامه نویسان که کمتر با زبان‌های Functional آشنایی دارند با Closure‌ها آشنا نباشند. پس بهتر است کمی در باره Closure‌ها توضیح دهیم.

توضیح درباره Closure ها:

به حالت معمول فیلدهایی که داخل یک تابع تعریف می‌شوند پس از اتمام اجرای آن تابع از بین می‌روند. در زبان‌هایی که توابع در آن‌ها First-Class هستند، خروجی یک تابع می‌تواند یک تابع دیگر باشد؛ به همان صورتی که ممکن است یک عدد int به عنوان خروی در نظر گرفته شود.

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

اما اگر تابع Inner که به عنوان خروجی برگشت داده شده، از فیلدهای تابع Outer استفاده کرده باشد، آن فیلدها تا زمان زنده بودن تابع Inner از بین نخواهند رفت! بنابراین تابع Inner می‌تواند حتی بعد از اتمام کار تابع Outer هم از فیلدهای آن استفاده کند. تابعی مانند Inner که می‌تواند برای استفاده داخلی خودش، داده‌هایی از خارج را به خود وابسته سازد، Closure نامیده می‌شود.

درک بهتر Closure‌ها به کمی زمان و تمرین نیاز دارد؛ اما بدانید که اگر با کتابخانه Query کار می‌کنید، احتمالا ده‌ها بار بدن اینکه خودتان متوجه شوید از Closure‌ها استفاده کرده اید.

تگ ها: golang