مدیریت سیگنال ها برای Graceful Shutdown در Go

در این مقاله یاد خواهیم گرفت چگونه سیگنال‌های ورودی سیستم عامل را برای انجام graceful shutdown در یک وب سرور نوشته‌می‌شوند و ما آن‌ها را به زبان Go مدیریت کنیم. قبل از هرچی باید بگویم که در این بخش، توجه ما بیشتر روی سیگنال‌های async(غیرهم‌زمان) متمرکز است و باید بدانید که این سیگنال‌ها توسط خطاهای برنامه ایجاد نمی‌شوند بلکه از طریق هسته سیستم عامل و یا برنامه‌های دیگر به برنامه ما ارسال می‌شوند.

انواع سیگنال

  • سیگنال SIGHUP هنگامی ارسال میشود که برنامه ترمینال کنترل کننده خود را از دست میدهد
  • سیگنال SIGINT هنگامی ارسال میشود که کاربر در ترمینال کنترل کننده برنام کاراکتر interrupt را ارسال کند (به صورت پیش فرض این کاراکتر C^ یا Control-C است‌)
  • سیگنال SIGQUIT هنگامی ارسال میشود که کاربر در ترمینال کنترل کننده برنامه کاراکتر quit را ارسال کند (به صورت پیش فرض این کاراتر \^ یا Control-Backslash است)

توجه: به صورت کلی هنگامی که ما قصد بستن برنامه‌ای را در ترمینال داریم، برای خروج به صورت ساده کلید‌های C^ و برای خروج با stack dump  کلید .^ را می‌زنیم.

رفتار سیگنال ها در زبان Go

یک سیگنال synchronous  در زبان Go به صورت پیش‌فرض منجر به ایجاد runtime panic می‌شود و ارسال هر کدام از سیگنال‌های SIGHUP، SIGINT, SIGTERM منجر به خروج برنامه خواهد شد.

مدیریت سیگنال ها

ایده ما در این بخش دریافت‌کردن این سیگنال‌ها و متوقف کردن آن‌هاست که به اصطلاح به آن متوقف کردن مودبانه وب سرور(graceful shutdown)می‌گویند.

متوقف کردن مودبانه یا graceful shutdown به معنی خاموش کردن یک سرور بدون ایجاد اختلال در هرکدام از ارتباطات فعال در روال خاموش شدن سرور است.

مراحل کار به ترتیب زیر است:

۱. بستن تمامی listener های باز
۲. بستن تمامی connection های بیکار
۳. منتظر ماندن به صورت نامحدود برای برگشت وضعیت connection های باز به حالت بیکار
۴. در نهایت خاموش کردن سرور

اگر با مفهوم concurrency در زبان Go آشنا باشید می‌دانید مفهومی به نام Channel وجود دارد و یکی از بزرگترین منافع و مهمترین کاربردهای آن ماهیت بلاک‌کننده آن است، یعنی اگر شما در جایی منتظر دریافت اطلاعاتی از یک کانال باشید و برای آن کانال از جایی اطلاعاتی ارسال نشود کد شما در آن قسمت متوقف خواهد شد تا اطلاعات بدست آن کانال برسد.

برای مثال بیایید کد زیر را باهم بررسی کنیم :

https://gist.github.com/subzerobo/4811e39c09f7923f31c7f3d2e606e74f

در خط اول، از تابع main یک کانال به نام done ایجاد می‌کنیم، این کانال تنها مقدار os.Signal را با ظرفیت ۱ پیام دریافت می‌کند.

در خط دوم، signal.Notify باعث می‌شود که پکیج signal تمامی سیگنال‌ها از نوع SIGINT و SIGTERM را به سمت کانال done هدایت کند و در بخش ‍done-> سعی میکند تا ورودی از کانال done را دریافت کند.

در خط پانزدهم، اگر ما برنامه را اجرا کنیم با توجه به توضیحات بالا،  پیام "Server Stopped" نمایش داده‌نمی‌شود، اما اگر سعی کنید برنامه را با فشردن کلید Ctrl-C متوقف کنید خط ۱۵ اجرا شده و پیام Server Stopped نمایش داده خواهد‌شد.

یه وب سرور نه چندان ساده !

وقتی به اکثر مثال‌ها و مقالات موجود در خصوص graceful shutdown نگاه می‌کنیم اکثرا با یک مثال ساده وب‌سرور رو بیان می‌کنند که عملا وب سرور را در فایل اصلی پروژه اجرا می‌کنند، ولی باید بگویم که در شرایط واقعی و برنامه‌های واقعی، کمتر پیش می‌آید که به این شکل نوشته شوند. به همین منظور در ادامه یک مثال پیچیده‌تر را که به شرایط واقعی بیشتر شبیه باشد، انتخاب کردم؛ به تکه کد زیر دقت کنید:
در کد زیر ما برای وب سرور خود یک structure به نام App ایجاد کردیم و این استراکچر درون خود یک وب سرور ساده دارد، از طرف دیگر این برنامه هر ۱۰ ثانیه یک بار باید یک سری از کارهای مشخص را در تابع DoBackgroundJob انجام‌دهد.

https://gist.github.com/subzerobo/f7ef90f280e61c58f08fd8503cdadc1d


در خط‌های ۲۸ تا ۳۰، یک کانال به نام done از نوع bool و یک کانال دیگر از نوع signal ایجاد کردیم و در ادامه در صورت دریافت سیگنال‌های SIGINT و SIGTERM مقدار را از طریق پیکج signal برای quitSignal ارسال می‌کنیم.
در خط ۳۲، بعد از ایجاد مقدار myApp  تابع myApp.GracefulShutdown با ورودی‌های quitSignal و done به صورت goroutine اجرا می‌شود و در نهایت myApp.Start وب سرور را استارت می‌کند.
بیایید کد را باهم اجرا کنیم:

1234567go run main.go
http: 2020/07/13 15:21:51 Operation: app.Start, HTTP Server Starting on Address ::8080 [GIN] 2020/07/13 - 15:21:56 | 200 |      70.872µs |             ::1 | GET      &quot/&quot
[GIN] 2020/07/13 - 15:21:56 | 200 |      100.92µs |             ::1 | GET      &quot/&quot
^C
http: 2020/07/13 15:22:03 Operation: app.GracefulShutdown, Graceful Shutdown Process Started 
http: 2020/07/13 15:22:03 Operation: app.Start, HTTPServer exited blocking mode 
http: 2020/07/13 15:22:03 Server stopped

بعد از اجرا کردن برنامه در محیط مرورگر خود دو بار به صفحه http://localhost:8080 مراجعه کردیم و میبینم که لاگ مربوطه در خط‌های سوم و چهارمِ خروجی ترمینال به چشم می‌خورد.

هنگامی که در ترمینال Control-C را فشار دهیم مراحل زیر به ترتیب اتفاق خواهد افتاد :

  • سیگنال SIGINT برای کد ارسال می‌شود و در تابع GracefulShutdown که منتظر دریافت اطلاعات از quitSignal است، کار خود را ادامه می‌دهد و با ایجاد یک context زمان‌دار با تایم اوت ۳۰ ثانیه‌ای منتظر می‌ماند و به HTTPServer اجازه می‌دهد تا مراحل Shutdown را اجرا کند.
  • لازم به ذکر است اگر HTTPServer در مراحل shutdown دچار مشکلی شود و یا نتواند در زمان ۳۰ ثانیه، کار خود را به اتمام برساند خطای مربوطه را در خط ۱۳۹ نمایش خواهد داد و در نهایت کانال done را می‌بندد.
  • در خط ۱۱۶ کد شروع وب سرور که به صورت Blocking مانده‌است با بسته شدن HTTPServer از حالت Blocking در می‌آید
  • در خط ۱۲۳ خروجی چاپ می‌شود و کار تابع myApp.Start به پایان می‌رسد.
  • در تابع main برنامه با اتمام کار تابع myApp.Start به کار خود ادامه می‌دهد و منتظر دریافت اطلاعات از کانال done می‌شود که قبل تر در مرحله ۱ این کانال بسته شده است
  • در خط ۴۵ تابع cancel از context اصلی برنامه را اجرا خواهد کرد.

تا اینجای کار سرور HTTP ما به صورت محترمانه‌ای کشته‌شد! اما یک سوال، اگر این بسته‌شدن برنامه در میان کار تابع DoBackgroundJob اتفاق بیافتد چه خواهد شد؟‌
یک بار دیگه نرم‌افزار را اجرا می‌کنیم و این‌بار چند ثانیه صبر می‌کنیم تا به زمان اجرای ۱۰ ثانیه‌‌ای DoBackgroundJob برسیم و در میان کار آن، با فشار دادن Control-C برنامه رو می‌بندیم:

123456789http: 2020/07/13 15:31:23 Operation: app.Start, HTTP Server Starting on Address ::8080 
http: 2020/07/13 15:31:38 Background Job Started
http: 2020/07/13 15:31:38 Operation: app.BackgroundJobsHandler, Exited !
http: 2020/07/13 15:31:39 Background Job Step 1 Done
http: 2020/07/13 15:31:40 Background Job Step 2 Done
^C
http: 2020/07/13 15:31:40 Operation: app.GracefulShutdown, Graceful Shutdown Process Started 
http: 2020/07/13 15:31:40 Operation: app.Start, HTTPServer exited blocking mode 
http: 2020/07/13 15:31:40 Server stopped

مشکل را دیدید‌‌؟‌ مراحل اجرایی DoBackgroundJob در وسط کار متوقف شد! و ما منتظر به پایان رسیدن آن نشدیم! خوب مشکل از کجا بود؟‌

  • همانطور که در کد می‌بینید، هنگامی که تابع myApp.Start صدا زده شد ما context را به صورت ورودی برای آن ارسال کردیم.
  • در خط ۹۲ برنامه، هنگامی که قصد داشیم کنترل کننده BackgroundJobsHandler را صدا کنیم، دوباره context را به صورت ورودی برای آن فرستادیم.
  • وقتی مراحل shutdown به جریان افتاد در آخرین مرحله ما تابع cancel را صدا کردیم که این به دنبال آن باید پیام مربوط در خط ۶۵، پیام چاپ می‌شد که این اتفاق نیفتاد، زیرا برنامه قبل از آنکه فرصتی برای ادامه کار داشته‌باشد، بسته شد!

نکته: اگر قبل از بسته شدن یک ثانیه صبر می‌کردیم ممکن بود آن پیام نمایش داده‌شود و یا حتی اگر چند ثانیه بیشتر صبر می‌کردیم شاید مراحل ۳ گانه DoBackgroundJob هم به اتمام میرسید نه؟‌ جواب اینه که شاید ! ولی هیچ تضمینی برای اجرای درست کامل DoBackgroundJob وجود ندارد.

استفاده از WaitGroup

من معمولا این مشکل را به این شکل حل می‌کنم که مراحل graceful shutdown در HTTPServer را در کنار graceful shutdown دیگر بخش‌های برنامه باهم انجام می‌دهم. بیایم یک کد اصلاح شده را باهم ببینیم:

https://gist.github.com/subzerobo/ffb7903486f3d845e3d27730a6ba4897

برای این کار با استفاده از اصل ترکیب یا composition ساختار App struct را از نوع sync.WaitGroup تعریف کردم هر موقع که قصد دارم یک Background Job را که باید حتما بعد از شروع به طور کامل اجرا شود استارت کنم با اجرای دستور app.Add در خط ۷۴ یک مقدار به App WaitGroup اضافه می‌کنم و با پایان کار با صدا کردن app.Done یکی از مقدار WaitGroup را کم می‌کنیم. و در انتهای کد در تابع main به راحتی با صدا کردن دستور app.Wait منتظر می‌شوم تا تمامی کارهای در حال اجرا به اتمام برسند

حالا یک بار دیگر کد را اجرا می‌کنیم و این بار هم دقیقا در میان کار DoBackgroundJob برنامه را با Control-C می‌بندیم:

1234567891011http: 2020/07/13 16:06:20 Operation: app.Start, HTTP Server Starting on Address ::8080 http: 2020/07/13 16:06:35 Background Job Started
http: 2020/07/13 16:06:35 Operation: app.BackgroundJobsHandler, Exited !
http: 2020/07/13 16:06:36 Background Job Step 1 Done
^C
http: 2020/07/13 16:06:36 Operation: app.GracefulShutdown, Graceful Shutdown Process Started 
http: 2020/07/13 16:06:36 Operation: app.Start, HTTPServer exited blocking mode 
http: 2020/07/13 16:06:36 Server stopped
http: 2020/07/13 16:06:37 Background Job Step 2 Done
http: 2020/07/13 16:06:38 Background Job Step 3 Done
http: 2020/07/13 16:06:39 Background Job Finished
http: 2020/07/13 16:06:39 Main Shutdown successfully, see you next time ;-)

همانطور که مشاهده می‌کنید، این بار با فشرده شدن Control-C وب سرور به صورت درست بسته شد و از طرف دیگر هم در خط ۴۸ کد، منتظر WaitGroup اپ شدیم تا کار(های) شروع شده به طور کامل به پایان برسد.