پیاده سازی Promises در Golang

زبان Go با معماری CSP به ما قدرت خیلی زیادی میده که سیستم concurrent مورد نظرمون رو طراحی کنیم. اما حداقل برای من موقع شروع کار یکم گیج کننده بودن، چنل ها و گوروتین ها و اینکه چطور با ترکیبشون میتونم از تمام قدرت CPU مون برای یک برنامه استفاده کنیم.


یک مثال ساده، درخواست batch

فرض کنید یک سرویس دارید که چند هزار ریکوئست برثانیه میخواد پاسخ بده. این سرویس میخواد یک دیتایی رو از کش ردیس بخونه و به ما بده. حالا اگر برای هردرخواست ریکوئست بزنیم به ردیس i/o ما اذیت میشه، درحالی که میدونیم ردیس قابلیت درخواست های batch رو داره که میدونیم خیلی خیلی سریع تر پاسخشون میده. سوالی که مطرح میشه چطور از این قابلیت ردیس توی Go استفاده کنیم؟

حالت معمولی اینطوره که برای هر درخواست HTTP یک تابع صدا میزنیم که دیتا رو از ردیس بگیره. پس به ازای هر درخواست HTTP یک درخواست به redis داریم. اما کاری که میخوایم انجام بدیم اینه هر ۵۰ میلی‌ثانیه یک بار تمام درخواست های redis رو با هم بفرستیم. یعنی یک درخواست HTTP وقتی اون تابع رو صدا زده میشه باید صبر کنه که پنجره ۵۰میلی‌ثانیه ایی تموم شه که سرویس ما تمام درخواست ها رو با هم بفرسته.

چرا این موضوع میتونه پرفورمنس سیستم رو بهتر کنه؟
همون طوری که توی این پست صحبت کردیم، اگر از pipeline ردیس استفاده کنیم هم i/o ما فشار کمتری بهش میاد هم اینکه خود ردیس syscall های کمتری میزنه و به شکل چشم‌گیری پرفورمنس ما هم بهتر میشه.
اگر سیستم رو به حال خودش رها کنیم و ۲ هزار ریکوئست برثانیه داشته باشیم ۲ هزار ریکوئست برثانیه به ردیس میزنیم. اما اگر به صورت batch هر ۵۰ میلی‌ثانیه درخواست بزنیم همیشه ۲۰ درخواست در ثانیه به ردیس میزنیم.

البته به یک موضوعی باید توجه کرد. با این کار میانگین ریسپانس تایم سیستم رو در حال معمولی ۵۰ میلی‌ثانیه بیشتر میکنیم که عدد زیادیه. اما توی درخواست های این سیستم خودش رو نشون میده.

متوجه شدیم که میخوایم چیکار کنیم. اما چطور توی Go پیادش کنیم؟ با کمک channel های go ما میتونیم promises رو پیاده سازی کنیم. اینطوری به قضیه نگاه کنید. ما وقتی تابع رو صدا میزنیم، تابع به ما یک متغیر میده که میدونیم در لحظه هنوز توش مقداری ریخته نشده. اون متغیر میره توی thread های مختلف و ما اصلا نگران race condition نیستیم، ما فقط منتظریم تا توی اون متغیر مقداری ریخته بشه. درخواست HTTP ما قفل میشه تا وقتی مقداری توی اون متغیر ریخته بشه.

مثال Promises در Golang

نمونه کد زیر رو در نظر بگیرید:

func PromiseMe() chan bool {
   c := make(chan bool)
   go func() {
      time.Sleep(2 * time.Second)
      c <- true
   }()
   return c
}

توی این تابع Go ما میایم اول یک چنل میسازیم، در ادامه یک تابع رو با استفاده از go توی یک گوروتین‌ (thread خیلی سبک) اجرا میکنیم. گوروتین ما خیلی سادست، ۲ ثانیه صبر میکنه و بعدش مقداری رو توی چنل میریزه. اما میدونیم این تابع توی ترد دیگه ایی اجرا میشه. ما همون موقع داریم چنل رو برمیگردونیم.

func main() {
   promise := PromiseMe()
   response := <-promise
   if response {
      // we got true!
   }
}

حالا توی این نمونه کد ما چنل رو از تابع گرفتیم. میدونیم در لحظه مقداری توی چنل ریخته نشده، توی خط بعد ما با استفاده از <- داریم میگیم آخرین مقدار توی چنل رو به ما بده (چنل ها مثل صف میمونن و میتونن تعداد مقادیر مدنظرمون عضو نگه دارن). نکته ایی که هست چون چنل ما مقداری توش نیست اون خط کد قفل میشه (همچنین تردش) تا وقتی مقداری توی اون چنل ریخته بهشه.

پیاده سازی batch با استفاده از channels

مثالی که اول مقاله گفتم یکم پیچیده هست (قبلا توی این مقاله نمونه کدش رو زده بودیم، میتونید اینجا یه نیم نگاهی بندازید) بیاید یک مثال ساده بزنیم. فرض کنید یک تابع داریم که بهش یک متن میدی و اون رو نمایش میده. حالا میخوایم جای اینکه هربار تابع رو صدا زدیم متن رو نشون بده، جمع کنه هر ۵ ثانیه همه رو با هم نشون بده.

type Printer struct {
   channel chan string
}
func (p *Printer) Print(s string) {
   p.channel <- s
}
func (p *Printer) work() {
   for {
      time.Sleep(5 * time.Second)
      // reading
      batch := &quot&quot
      lenChannel := len(p.channel)
      if lenChannel == 0 {
         continue
      }
      for i := 0; i < lenChannel; i++ {
         batch += <-p.channel + &quot\n&quot
      }
      fmt.Println(&quot\n> batch print on &quot, time.Now())
      fmt.Println(batch)
   }
}
func NewPrinter() *Printer {
   p := &Printer{
      channel: make(chan string, 100),
   }
   go p.work()
   return p
}

همونطوری که میبینید یک تایپ جدید به اسم Printer نوشتیم که دو تا متد داره. اولی Print هست که خیلی ساده هرمقداری که میگیره رو توی چنلی که قبلا برای Printer تعریف کردیم میریزه. همچنین متد work که جلوتر میبینیم قراره توی یک گوروتین جدا اجرا بشه. این تابع ۵ ثانیه صبرمیکنه، چک میکنه چقدر توی چنلمون ریخته شده، تک تکشون رو میخونه و درنهایت با هم نمایش میده.

نکته ایی که هست تابع NewPrinter هست که یک چنل با ظرفیت ۱۰۰ میسازه، متد work رو به صورت گوروتین اجرا میکنه و در نهایت Printer ایی که ساخته رو برمیگردونه.

func main() {
   printer := NewPrinter()
   for {
      printer.Print(&quotHello -&quot + time.Now().String())
      time.Sleep(1 * time.Second)
   }
}

در نهایت توی تابع main یک پرینتر میسازیم، و هر ثانیه یک متن جدید رو باهاش پرینت میکنیم. خروجی چطور میشه؟

> batch print on  2021-04-11 22:39:48.922123619 +0430 +0430 m=+10.000904303
Hello -2021-04-11 22:39:43.922688513 +0430 +0430 m=+5.001469122
Hello -2021-04-11 22:39:44.923071867 +0430 +0430 m=+6.001852526
Hello -2021-04-11 22:39:45.92339546 +0430 +0430 m=+7.002176162
Hello -2021-04-11 22:39:46.92362527 +0430 +0430 m=+8.002405921
Hello -2021-04-11 22:39:47.924194516 +0430 +0430 m=+9.002975169
> batch print on  2021-04-11 22:39:53.92252801 +0430 +0430 m=+15.001308662
Hello -2021-04-11 22:39:48.924275894 +0430 +0430 m=+10.003056544
Hello -2021-04-11 22:39:49.92445176 +0430 +0430 m=+11.003232412
Hello -2021-04-11 22:39:50.924880096 +0430 +0430 m=+12.003660754
Hello -2021-04-11 22:39:51.925031043 +0430 +0430 m=+13.003811747
Hello -2021-04-11 22:39:52.925309521 +0430 +0430 m=+14.004090169

اگر میخواید یک کد یکم پیچیده‌تر ولی با همین منطق رو ببینید حتما این مقاله رو مطالعه کنید. از ۸۰۰ ریکوئست برثانیه میرسیم به ۱۴ هزار تا :)

چند تا مثال دیگه

unc PromiseMe() chan bool {
   c := make(chan bool)
   go func() {
      time.Sleep(2 * time.Second)
      c <- true
   }()
   return c
}

func main() {
   // Simple promise
   a1 := PromiseMe()
   <-a1
   // Wait for all
   a2 := PromiseMe()
   b2 := PromiseMe()
   c, d := <-a2, <-b2
   // Wait for the first one
   a3 := PromiseMe()
   b3 := PromiseMe()
   var c3 bool
   select {
   case c3 = <-a3:
   case c3 = <-b3:
   case <-time.After(time.Second * 2): // 2 seconds timeout
      c3 = false
      // handling timeout
   }
   // all with timeout
   a4 := PromiseMe()
   b4 := PromiseMe()
   ctx, _ := context.WithTimeout(context.Background(), time.Second*1)
   select {
   case <-a4:
   case <-ctx.Done():
   }
   select {
   case <-b4:
   case <-ctx.Done():
   }
}