Let's Go Further ارسال ایمیل › خاموش شدن آرام وظایف پس‌زمینه
قبلی · فهرست مطالب · بعدی
فصل ۱۳.۵.

خاموش شدن آرام وظایف پس‌زمینه

ارسال ایمیل خوش‌آمدگویی در پس‌زمینه به خوبی کار می‌کند، اما هنوز مشکلی وجود دارد که باید به آن بپردازیم.

وقتی خاموش شدن آرام برنامه خود را آغاز می‌کنیم، برنامه منتظر goroutine‌های پس‌زمینه‌ای که راه‌اندازی کرده‌ایم نمی‌ماند تا کارشان تمام شود

. بنابراین — اگر در لحظه‌ای نامناسب سرور خود را خاموش کنیم — ممکن است کاربر جدیدی در سیستم ما ایجاد شود اما هرگز ایمیل خوش‌آمدگویی خود را دریافت نکند.

خوشبختانه، می‌توانیم از این اتفاق جلوگیری کنیم با استفاده از قابلیت sync.WaitGroup در زبان Go برای هماهنگ‌سازی خاموش شدن آرام و goroutine‌های پس‌زمینه‌ای.

آشنایی با sync.WaitGroup

وقتی می‌خواهید منتظر بمانید تا مجموعه‌ای از goroutine‌ها کار خود را تمام کنند، ابزار اصلی برای کمک به این کار نوع sync.WaitGroup است.

نحوه کار sync.WaitGroup از نظر مفهومی کمی شبیه یک ‘شمارنده’ است. هر بار که یک goroutine پس‌زمینه را با استفاده از متد WaitGroup.Go() راه‌اندازی می‌کنید، شمارنده ۱ واحد افزایش می‌یابد، و وقتی هر goroutine تمام می‌شود، شمارنده ۱ واحد کاهش می‌یابد. سپس می‌توانید از متد WaitGroup.Wait() استفاده کنید تا اجرای کد خود را مسدود کرده و تا زمانی که شمارنده به صفر برسد منتظر بمانید — در این نقطه می‌دانید که تمام goroutine‌های پس‌زمینه‌ای شما تمام شده‌اند.

بیایید نگاهی سریع به یک مثال مستقل از نحوه کار آن در عمل بیندازیم.

در کد زیر، پنج goroutine راه‌اندازی می‌کنیم که "hello from a goroutine" را چاپ می‌کنند، و از sync.WaitGroup استفاده می‌کنیم تا منتظر بمانیم همه آنها قبل از خروج برنامه تمام شوند.

package main

import (
	"log"
	"sync"
	"time"
)

func main() {
    // Declare a new WaitGroup.
    var wg sync.WaitGroup

    // Execute a loop 5 times.
    for range 5 {
        // Use the Go() method to launch a new goroutine, which sleeps for 1
        // second and then prints a log message.
        wg.Go(func() {
            time.Sleep(time.Second)
            log.Println("hello from a goroutine ")
        })
    }

    log.Println("all goroutines launched")

    // Wait() blocks until the WaitGroup counter is zero --- essentially blocking until all
    // goroutines have completed.
    wg.Wait()

    log.Println("all goroutines finished")
}

اگر کد بالا را اجرا کنید، خروجی به این شکل خواهد بود:

2009/11/10 23:00:00 all goroutines launched
2009/11/10 23:00:01 hello from a goroutine 
2009/11/10 23:00:01 hello from a goroutine 
2009/11/10 23:00:01 hello from a goroutine 
2009/11/10 23:00:01 hello from a goroutine 
2009/11/10 23:00:01 hello from a goroutine 
2009/11/10 23:00:01 all goroutines finished

از پیام‌های log و زمان‌ها می‌توانیم ببینیم که تمام goroutine‌ها راه‌اندازی شده‌اند، اما فراخوانی wg.Wait() اجرای بیشتر کد ما را مسدود می‌کند تا زمانی که تمام goroutine‌ها حدود ۱ ثانیه بعد تمام شوند. وقتی همه آنها تمام شدند، مسدود بودن برداشته شده و پیام log نهایی چاپ می‌شود.

رفع مشکل برنامه خود

بیایید برنامه خود را به‌روزرسانی کنیم تا یک sync.WaitGroup که خاموش شدن آرام و goroutine‌های پس‌زمینه‌ای را هماهنگ می‌کند، به آن اضافه کنیم.

از فایل cmd/api/main.go شروع می‌کنیم و struct مربوط به application را ویرایش می‌کنیم تا یک sync.WaitGroup جدید در خود داشته باشد. به این صورت:

فایل: cmd/api/main.go
package main

import (
    "context"
    "database/sql"
    "flag"
    "log/slog"
    "os"
    "sync" // New import
    "time"

    "greenlight.alexedwards.net/internal/data"
    "greenlight.alexedwards.net/internal/mailer"

    _ "github.com/lib/pq"
)

...

// Include a sync.WaitGroup in the application struct. The zero value for a
// sync.WaitGroup type is a valid, usable, sync.WaitGroup with a 'counter' value of 0,
// so we don't need to do anything else to initialize it before we can use it.
type application struct {
    config config
    logger *slog.Logger
    models data.Models
    mailer *mailer.Mailer
    wg     sync.WaitGroup
}

...

سپس به فایل cmd/api/helpers.go می‌رویم و helper مربوط به app.background() را به‌روزرسانی می‌کنیم تا goroutine‌ها را با استفاده از این sync.WaitGroup راه‌اندازی کند، به این صورت:

فایل: cmd/api/helpers.go
package main

...

func (app *application) background(fn func()) {
    // Use the Go() method to launch the background goroutine. The code inside 
    // this is exactly the same as in the previous chapter --- we're just now 
    // launching the goroutine using the WaitGroup's Go() method instead of the 
    // regular go() keyword.
	app.wg.Go(func() {
		defer func() {
			pv := recover()
			if pv != nil {
				app.logger.Error(fmt.Sprintf("%v", pv))
			}
		}()

		fn()
	})
}

در نهایت، کاری که باید انجام دهیم به‌روزرسانی قابلیت خاموش شدن آرام است به طوری که منتظر بماند تا هر یک از این goroutine‌های پس‌زمینه قبل از خاتمه دادن به برنامه، کار خود را تمام کنند. می‌توانیم این کار را با تطبیق متد app.serve() به این صورت انجام دهیم:

فایل: cmd/api/server.go
package main

...

func (app *application) serve() error {
    srv := &http.Server{
        Addr:         fmt.Sprintf(":%d", app.config.port),
        Handler:      app.routes(),
        IdleTimeout:  time.Minute,
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
        ErrorLog:     slog.NewLogLogger(app.logger.Handler(), slog.LevelError),
    }

    shutdownError := make(chan error)

    go func() {
        quit := make(chan os.Signal, 1)
        signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
        s := <-quit

        app.logger.Info("shutting down server", "signal", s.String())

        ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
        defer cancel()

        // Call Shutdown() on the server like before, but now we only send on the
        // shutdownError channel if it returns an error.
        err := srv.Shutdown(ctx)
        if err != nil {
            shutdownError <- err
        }

        // Log a message to say that we're waiting for any background goroutines to
        // complete their tasks.
        app.logger.Info("completing background tasks", "addr", srv.Addr)

        // Call Wait() to block until our WaitGroup counter is zero --- essentially
        // blocking until the background goroutines have finished. Then we return nil on
        // the shutdownError channel, to indicate that the shutdown completed without
        // any issues.
        app.wg.Wait()
        shutdownError <- nil
    }()

    app.logger.Info("starting server", "addr", srv.Addr, "env", app.config.env)

    err := srv.ListenAndServe()
    if !errors.Is(err, http.ErrServerClosed) {
        return err
    }

    err = <-shutdownError
    if err != nil {
        return err
    }

    app.logger.Info("stopped server", "addr", srv.Addr)

    return nil
}

برای تست این قابلیت، API را مجدداً راه‌اندازی کنید و سپس یک درخواست به endpoint POST /v1/users ارسال کنید و بلافاصله بعد از آن سیگنال SIGTERM را ارسال کنید. به عنوان مثال:

$ BODY='{"name": "Edith Smith", "email": "edith@example.com", "password": "pa55word"}'
$ curl -d "$BODY" localhost:4000/v1/users & pkill -SIGTERM api &

وقتی این کار را انجام دهید، logهای سرور شما باید مشابه خروجی زیر باشند:

$ go run ./cmd/api
time=2023-09-10T10:58:12.124+02:00 level=INFO msg="database connection pool established"
time=2023-09-10T10:58:12.722+02:00 level=INFO msg="starting server" addr=:4000 env=development
...
time=2023-09-10T10:59:14.494+02:00 level=INFO msg="shutting down server" signal=terminated
time=2023-09-10T10:59:14.569+02:00 level=INFO msg="completing background tasks" addr=:4000
time=2023-09-10T10:59:16.348+02:00 level=INFO msg="stopped server" addr=:4000

توجه کنید که بعد از ثبت پیام "completing background tasks" چند ثانیه مکث وجود دارد، در حالی که ارسال ایمیل پس‌زمینه تکمیل می‌شود، و در نهایت پیام "stopped server" نمایش داده می‌شود؟

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