پرش به محتوا
برنامه‌نویسی و بک‌اند

goroutine چیست و چرا Go همزمانی را برای بک اند ساده کرده است؟

۸ دقیقه مطالعه

goroutine یه تابع معمولی توی Go هست که با گذاشتن go جلوش، هم زمان با بقیه ی برنامه اجرا می شه. می بینیم چرا از thread سبک تره و چطور کار بک اند رو راحت می کنه.

تابعی با کلمه ی go در مرکز که به چند کارت سبک وظیفه و یک زمان بند مشترک متصل شده است

goroutine چیست؟ یه تابع معمولی توی Go که اگه قبل از صدا زدنش کلمه ی go رو بذاری، هم زمان با بقیه ی برنامه اجرا می شه. goroutine سبکه و زمان بندی اش رو خود Go انجام می ده، نه سیستم عامل. برای همین یه سرویس بک اند می تونه هزاران درخواست یا کار موازی رو بدون دردسر threadهای سنتی کنار هم نگه داره.

از نگاه محصول هم مهمه. کاربری که دکمه ی «پرداخت» رو زده نباید منتظر بمونه تا پنل پیامک جواب بده. ما هم با همون پرسش هایی جلو می ریم که معمولاً موقع شروع پیش میاد.

goroutine چیست و با یه go ساده چی عوض می شه؟

فرض کن تابع sendSMS() داری که با پنل پیامک حرف می زنه و دو ثانیه طول می کشه. اگه عادی صداش بزنی، برنامه همون جا می ایسته تا کارش تموم شه. اگه بنویسی go sendSMS(...)، Go اون تابع رو جدا اجرا می کنه و برنامه بدون صبر ادامه می ده.

یه مثال کوتاه

package main

import (
	"fmt"
	"time"
)

func sendSMS(phone string) {
	time.Sleep(2 * time.Second) // شبیه سازی صدا زدن پنل پیامک
	fmt.Println("پیامک رفت برای", phone)
}

func main() {
	go sendSMS("0912xxxxxxx")
	fmt.Println("ادامه ی کار بدون صبر")
	time.Sleep(3 * time.Second) // فقط برای همین دمو
}

خروجی نمونه:

ادامه ی کار بدون صبر
پیامک رفت برای 0912xxxxxxx

اون Sleep آخر فقط برای نمایش مثاله. چند بخش بعد می بینی چرا راه حل درستی نیست.

goroutine شیء یا کلاس نیست، فقط «اجرای مستقل یه تابع»ه. ساختنش ارزونه و برای شروع هیچ تنظیمی لازم نداری.

یه تفاوت هست که زیاد قاطی می شه. همزمانی (concurrency) یعنی چند کار رو توی هم جلو ببری. موازی سازی (parallelism) یعنی واقعاً چند کار توی یه لحظه روی چند هسته اجرا بشن. Go اول همزمانی رو ساده می کنه و اجرای موازی روی چند هسته نتیجه ی جانبی اشه.

فرقش با thread چیه و چرا سبک تره؟

یه رستوران شلوغ رو تصور کن. سفارش ها همون goroutineها هستن، آشپزها threadهای سیستم عامل، و سرآشپزی که سفارش ها رو بین آشپزها پخش می کنه همون scheduler داخلی Go. وقتی یه سفارش منتظر فر یا مواد می مونه، آشپز وایمیسته نگاهش نمی کنه و می ره سراغ سفارش بعدی. goroutineی که منتظر شبکه یا دیتابیسه هم معمولاً همین کار رو می کنه و thread رو آزاد می ذاره.

توی مدل سنتی «یه thread برای هر درخواست»، هر سفارش یه آشپز اختصاصی می خواد، حتی وقتی آشپزه فقط داره به فر نگاه می کنه.

ویژگیgoroutinethread سیستم عامل
حافظه ی اولیه ی stackاز چند کیلوبایت شروع می شه و در صورت نیاز بزرگ می شهمعمولاً از حدود یک مگابایت تا چند مگابایت رزرو می شه
ساخت و جابه جاییداخل برنامه انجام می شهبه سیستم عامل نیاز داره
زمان بندیبا scheduler خود Goبا سیستم عامل

Go از مدل M:N استفاده می کنه، یعنی تعداد زیادی goroutine روی تعداد کمی thread سوار می شن. تعداد threadهایی که همزمان کد Go رو اجرا می کنن با GOMAXPROCS کنترل می شه و پیش فرضش برابر تعداد هسته های CPU هست. برای شروع لازم نیست دستش بزنی.

نمودار زمان بندی goroutineها روی threadها و مقایسه با مدل یک thread برای هر درخواست

چرا برنامه تموم می شه و goroutine اجرا نمی شه؟

تقریباً همه ی تازه کارها این سوال رو می پرسن. جوابش اینه: وقتی تابع main تموم شه، کل برنامه بسته می شه و goroutineهای نیمه کاره هم همون لحظه از بین می رن. Go منتظرشون نمی مونه.

time.Sleep برای صبر کردن راه حل نیست، چون فقط حدس می زنی کار چقدر طول می کشه و یه روز حدست غلط درمیاد. راه درست sync.WaitGroup هست:

var wg sync.WaitGroup
for _, p := range phones {
	wg.Add(1)
	go func(phone string) {
		defer wg.Done()
		sendSMS(phone)
	}(p)
}
wg.Wait()

Add تعداد کارها رو می شماره، Done وقتی هر کار تموم شد خبر می ده، و Wait تا صفر شدن شمارنده صبر می کنه.

اشتباه رایج: متغیر حلقه

توی نسخه های قدیمی تر Go اگه داخل goroutine مستقیم از متغیر حلقه (مثلاً p) استفاده می کردی، ممکن بود همه ی goroutineها آخرین مقدار رو ببینن، چون همه یه متغیر مشترک رو می خوندن. از Go 1.22 به بعد این رفتار عوض شده و هر دور حلقه متغیر خودش رو داره، ولی این تغییر به نسخه ی اعلام شده توی go.mod بستگی داره. اگه روی پروژه ی قدیمی کار می کنی، مثل مثال بالا مقدار رو به عنوان آرگومان بده تا خیالت راحت باشه.

goroutineها چطور با هم حرف می زنن؟

با channel. channel مثل یه لوله عمل می کنه: یه goroutine چیزی توش می ذاره و یکی دیگه برمی داره.

results := make(chan string)

go func() {
	results <- "نتیجه ی استعلام"
}()

fmt.Println(<-results)

خط آخر صبر می کنه تا مقداری توی لوله بیاد. یعنی channel هم داده رو جابه جا می کنه، هم کارها رو هماهنگ می کنه. توصیه ی معروف جامعه ی Go هم همینه: به جای اشتراک حافظه، با فرستادن پیام ارتباط بگیر.

اگه دو goroutine یه متغیر رو هم زمان عوض کنن؟

اینجا data race پیش میاد. نتیجه بسته به شانس و زمان بندی عوض می شه و باگ هم فقط گاهی ظاهر می شه، که بدترین حالته. دو راه داری: یا داده رو با channel دست به دست کنی، یا با sync.Mutex دسترسی به متغیر رو قفل کنی. برای پیدا کردن این باگ ها برنامه رو با فلگ -race اجرا کن و تست ها رو هم همین طوری بگیر:

go run -race main.go
go test -race ./...

توی یه سرویس واقعی کجا به کار میاد؟

اول یه چیز که خیلی ها نمی دونن: سرور net/http خود Go هر درخواست ورودی رو توی یه goroutine جدا اجرا می کنه. یعنی بدون اینکه کاری کرده باشی، از همین مدل استفاده می کنی. جاهایی که خودت go می نویسی معمولاً این ها هستن:

  • چند فراخوانی مستقل برای ساختن یه پاسخ. صفحه ی سبد خرید هم موجودی انبار رو می خواد، هم اطلاعات کاربر، هم هزینه ی ارسال. اگه این سه تا رو هم زمان بفرستی، زمان کل تقریباً برابر کندترینشونه، نه جمع هر سه.
  • کار جانبی که کاربر نباید منتظرش بمونه. مثلاً ارسال پیامک تأیید سفارش. پنل پیامک و درگاه پرداخت ممکنه توی ساعت های شلوغی کند جواب بدن، و کاربر نباید پشت این کندی گیر کنه.
  • پردازش دسته ای با سقف مشخص. اگه ده هزار تماس با یه سرویس بیرونی داری، فرستادن ده هزار goroutine همزمان ایده ی خوبی نیست. چند goroutine ثابت می سازی که از یه channel کار برمی دارن (worker pool).

یادت باشه goroutine فقط توی حافظه ی همون پروسه زندگی می کنه. اگه سرویس وسط کار ری استارت شه، پیامکی که هنوز نرفته بود برای همیشه گم می شه. برای کارهایی که نباید از دست برن، صف کار در بک اند چیست و چه وقت به آن نیاز پیدا می کنیم؟ رو بخون. معمولاً ترکیب هر دو بهترین جواب رو می ده.

یک درخواست که در سرور به سه goroutine موازی تقسیم می شود و نتیجه ها دوباره جمع می شوند

کجا goroutine دردسر درست می کنه؟

go ساده ست، ولی همین سادگی یه تله هم داره: شروع کردن آسونه، درست تموم کردن نه. رایج ترین دردسرها این ها هستن:

  • goroutine leak. goroutineی که منتظر channelی می مونه که هیچ وقت چیزی توش نمیاد، تا آخر عمر پروسه حافظه رو نگه می داره. برای فراخوانی های بیرونی همیشه context با timeout (مثل context.WithTimeout) بده و توی خود goroutine هم ctx.Done() رو چک کن.
  • ساخت بی حد و حصر. سبک بودن یعنی ارزون، نه رایگان. اگه برای هر ورودی غیرقابل کنترل یه goroutine بسازی، یه روز حافظه تموم می شه.
  • خطا و panic. panic داخل goroutine اگه recover نشه، کل برنامه رو از کار میندازه، نه فقط همون goroutine رو.
  • کار سنگین CPU. اگه کارت محاسبه ی خالصه، بیشتر از تعداد هسته ها goroutine ساختن سرعت رو بالا نمی بره.
  • خوانایی. دیباگ کردن کد همزمان سخت تره. اگه یه حلقه ی ساده هم کارت رو راه می ندازه، همون رو نگه دار.

اگه می خوای بدونی Go برای پروژه ی تو انتخاب درستیه یا نه، Go برای بک اند؛ کجا انتخاب خوبیه و کجا نه؟ رو ببین. اگه هم سرویس جدیدی داری که باید از اول درست چیده شه، تیم ما توی طراحی و توسعه API همین تصمیم ها رو سر پروژه ها می گیره.

سوالات متداول

goroutine همون thread هست؟

نه. thread رو سیستم عامل مدیریت می کنه، ولی goroutine رو scheduler خود Go روی تعداد کمی thread پخش می کنه. برای همین ساخت و جابه جایی اش خیلی ارزون تره، هرچند در نهایت همه چیز روی threadهای واقعی اجرا می شه.

چند goroutine همزمان می شه ساخت؟

هزاران، و حتی خیلی بیشتر، ولی عدد قطعی نداریم. سقف واقعی به حافظه ی سرور و کاری که هر goroutine انجام می ده بستگی داره. اگه کارشون فراخوانی بیرونیه، بهتره تعدادشون رو با worker pool محدود کنی.

اگه توی یه goroutine خطا (panic) بده چی می شه؟

اگه recover نکنی، کل برنامه می ایسته، نه فقط اون goroutine. برای goroutineهایی که توی سرویس واقعی کار مستقل می کنن، یه defer با recover بذار و خطا رو لاگ کن.

goroutine جای صف کار رو می گیره؟

نه. goroutine برای کار همزمان داخل یه پروسه خوبه، ولی اگه پروسه بسته شه کارهای نیمه تموم گم می شن. صف کار برای کارهاییه که باید بعد از ری استارت و خطا هم حفظ بشن و دوباره تلاش بشن.

از کجا شروع کنی

اول یه کار واقعی و کوچیک توی سرویست پیدا کن، مثلاً دو فراخوانی مستقل که الان پشت هم اجرا می شن. اون ها رو با WaitGroup یا channel هم زمان کن و با -race تست بگیر. بعدش برای هر فراخوانی بیرونی timeout بذار و تعداد goroutineهای موازی رو محدود کن. با همین سه قدم، بیشتر مزیت همزمانی Go رو بدون دردسرش می گیری.

  • goroutine
  • زبان Go
  • همزمانی
  • بک اند
  • thread

مقاله‌های مرتبط

ادامه همین موضوع در وبلاگ.

همه مقاله‌ها
کمک فنی

برای اجرای این کار کمک می‌خواهید؟

تیم فنی Wbweb از طراحی سایت تا هاست و سرور کنار شماست؛ درخواستتان را ثبت کنید یا تیکت بزنید.