1. Niyə Shutdown lazımdır və timeout niyə məcburidir
İlk dəfə HTTP server-i işə salanda elə görünür ki, onu “dayandırmaq” sadəcə main()-dən çıxmaqdır. Təəssüf ki, server sosial bir varlıqdır: onun aktiv bağlantıları, “prosesdə” olan sorğuları, buferləri və düzgün davranmağa borclu olmayan klientləri var. Bu bölmədə Shutdown-un nə etdiyini və timeout olmadan niyə proses-zombi riski yaratdığınızı izah edəcəyik: o, “guya bitməlidir”, amma nədənsə bitmir.
(*http.Server).Shutdown(ctx) — yumşaq dayandırma-dır. Məna budur: server yeni bağlantıları qəbul etməyi dayandırır və artıq başlanmış sorğulara “normal” bitmək şansı verir. Amma buradakı əsas söz — şans. Əgər sorğu ilişibsə, klient bağlantını saxlayırsa, handler çox uzanırsa və ya siz hardasa xarici resursu gözləyirsinizsə — gözləmə uzana bilər.
Məhz burada vacib ideya ortaya çıxır: context gözləmənin yuxarı sərhədini müəyyən edir. Go-da context deadline/timeout və ləğv vasitəsilə “vaxt bitdi” mesajını bildirə bilir. context.WithTimeout isə deadline ilə törəmə context yaratmaq üçün standart yoldur.
Əgər Shutdown(context.Background()) çağırsaq, siz “nə qədər lazımdırsa gözlə” deyirsiniz. Prod mühit üçün bu, “beş dəqiqəyə qayıdıram” deyib üç gün yoxa çıxmağa bənzəyir. Ona görə qayda sadə və sərtdir:
Shutdown həmişə context.WithTimeout ilə yaradılmış context ilə çağırılır (timeout məcburidir).
Qısa orientir cədvəli:
| Metod | Məna etibarilə nə edir | Aktiv sorğularla nə olur | Harda tələ var |
|---|---|---|---|
|
yumşaq dayandırma | bitməsini gözləməyə çalışır | timeout olmadan sonsuz gözləyə bilər |
|
sərt dayandırma | bağlantılar qırılır | istifadəçilər kəsilmə/səhv ala bilər |
2. WithTimeout ilə shutdown üçün kanonik şablon
Əsas pattern: WithTimeout + defer cancel() + Shutdown
Bir çox yeni başlayanlar “küncdən kəsməyə” çalışır: ya srv.Shutdown(context.Background()) yazırlar, ya da timeout yaradırlar, amma cancel()-i unuturlar. Bu normaldır: beyin enerjiyə qənaət edir və düşünür ki, “onsuz da özü olacaq”. Amma context-in resursları var (timer-lər, bağlantılar) və düzgün şablon sadəcə bürokratiya deyil, kod gigiyenasıdır.
Dayandırma üçün kanonik pattern belədir: timeout ilə context yaradırıq, dərhal defer cancel() qoyuruq, sonra Shutdown çağırırıq. WithTimeout vaxt bitəndə avtomatik ləğv olunacaq törəmə context yaradır.
Mini-nümunə (sanki hər şeydən kənarda, signals və server olmadan):
package main
import (
"context"
"log"
"net/http"
"time"
)
func stopServer(srv *http.Server) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("shutdown error: %v", err)
}
}
Burada vacib olan təkcə timeout-un olması deyil, həm də cancel()-in mütləq çağırılmasıdır. Bəli, context onsuz da timeout ilə ləğv olunacaq, amma cancel() — “stolu özündən sonra yığışdırmaq” deməkdir: əgər daha tez bitmişiksə, bağlı resursları əvvəlcədən azad edirik.
Shutdown xətasını necə loglamaq olar
Yeni başlayanlar çox vaxt gözləyir ki, Shutdown ya “uğurludur”, ya da “sındı”. Reallıqda üçüncü rejim də var: “cəhd etdik, amma vaxt bitdi”. Bu həmişə fəlakət deyil — amma həmişə o deməkdir ki, tətbiqiniz cancel-ə ideal şəkildə hörmət etmir və ya timeout həqiqətən çox kiçikdir.
Əgər timeout bitibsə, adətən context.DeadlineExceeded ilə bağlı xəta görəcəksiniz. ctx.Err() də həmin context üçün context.DeadlineExceeded qaytaracaq. Bu, normal mexanizmdir: deadline çatdı — context ləğv olundu — əməliyyatlar bitməlidir.
Lazımsız dramatika olmadan daha “danışan” diaqnostika nümunəsi:
package main
import (
"context"
"errors"
"log"
"net/http"
"time"
)
func shutdownWithLog(srv *http.Server) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
if errors.Is(err, context.DeadlineExceeded) {
log.Printf("shutdown: timeout exceeded")
return
}
log.Printf("shutdown error: %v", err)
}
}
İki xəta qatını qarışdırmamaq vacibdir:
- http.ErrServerClosed — server normal bağlanarkən ListenAndServe-dən gözlənilən xətadır (onu server-i işə saldığımız yerdə filtr edirik).
- context.DeadlineExceeded — shutdown prosedurunun deadline-a sığmadığını göstərən siqnaldır (onu shutdown qolunda loglayırıq).
3. Shutdown timeout-u necə seçmək lazımdır
Hamıya uyğun sehrli bir rəqəm vermək istəyirsən: “5 saniyə qoyun və hər şey yaxşı olacaq”. Amma proqramlaşdırmada magic number-lər pis işləyir (yalnız 42 istisnadır, amma bu başqa kursdur). Bunun əvəzinə düşünmək lazımdır: server-in sorğuları düzgün bağlaması üçün nə qədər vaxta ehtiyacı var?
Məntiq belədir: shutdown timeout-u kifayət qədər böyük olmalıdır ki, “adi” sorğularınız bitsin, və kifayət qədər kiçik olmalıdır ki, proses sonsuzluğa qədər asılı qalmasın. Əgər tipik sorğu 20–50 millisanilə işləyən API yazırsınızsa, 30 saniyəlik shutdown kifayət qədər səxavətli görünür. Əgər sizdə 2–3 saniyə çəkən əməliyyatlar varsa (məsələn, ağır aqreqasiya), 1 saniyəlik shutdown prosesi tez-tez vaxtından əvvəl kəsəcək.
Go-da context deadline və cancel-u çağırış steki boyunca aşağıya ötürdüyünə görə, düzgün arxitektura adətən handler-lərin və asılılıqların ya ağlabatan vaxtda bitməsinə, ya da context vasitəsilə korrekt ləğv olunmasına gətirir.
Tədris məqsədli task server-də (şərti todo/tasks) 5 saniyə ilə başlamaq olar. Əsas məsələ rəqəm deyil, onun olması və kodda açıq şəkildə yazılması-dır.
Yaxşı üslub — timeout-u konstanta çıxarmaqdır ki, məntiq arasında itib batmasın:
package main
import "time"
const shutdownTimeout = 5 * time.Second
4. main()-də graceful shutdown: signal, timeout, Shutdown
Ümumi sxem
main()-də server adətən işə düşür və ListenAndServe() vasitəsilə thread-i bloklayır. Eyni zamanda bitirmə siqnalı “kənardan” gəlir, ona görə də siqnalı gözləyib dayandırmanı başlatdığımız yer lazımdır. Biz bunu signal.NotifyContext və <-sigCtx.Done()-un gözlənilməsi ilə edirik, sonra isə timeout ilə düzgün Shutdown əlavə edirik.
Sxematik olaraq belə görünür:
flowchart TD
A[main: HTTP server-i işə salırıq] --> B[ListenAndServe bloklanır]
A --> C["goroutine: <-sigCtx.Done() gözləyirik"]
C --> D["shutdownCtx = WithTimeout(...) yaradırıq"]
D --> E["srv.Shutdown(shutdownCtx) çağırırıq"]
E --> F[ListenAndServe başa çatır]
Server-i işə salmaq və signal gözləmək
İşə salma kodu (fərz edək ki, mux artıq hazırdır):
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
mux := http.NewServeMux()
srv := &http.Server{Addr: ":8080", Handler: mux}
sigCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go shutdownOnSignal(sigCtx, srv)
err := srv.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Printf("server error: %v", err)
}
}
Ən maraqlı hissə shutdownOnSignal-də gizlidir. Onu elə yazaq ki, timeout mütləq olsun:
package main
import (
"context"
"log"
"net/http"
"time"
)
const shutdownTimeout = 5 * time.Second
func shutdownOnSignal(sigCtx context.Context, srv *http.Server) {
<-sigCtx.Done() // dayanma hadisəsini gözləyirik
ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("shutdown error: %v", err)
}
}
Diqqət yetirin: biz WithTimeout(context.Background(), ...) edirik, WithTimeout(sigCtx, ...) deyil. Niyə belə? Çünki sigCtx-yə artıq shutdown-a çatanda ləğv olunmuş olur (biz axı <-sigCtx.Done()-dan çıxmışıq), və əgər onu parent etsək, “dərhal ləğv olunmuş” context alarıq. Onda shutdown faktiki olaraq ümumiyyətlə gözləməz.
Məhz buna görə shutdown qolunda çox vaxt ayrıca “neytral” parent istifadə olunur — context.Background().
Niyə Shutdown-u sadəcə ListenAndServe-dən sonra çağırmaq olmaz
Başlayanların tipik tələsi belə görünür:
err := srv.ListenAndServe()
srv.Shutdown(...)
İlk baxışda məntiqli görünür: “server işi bitirəndə shutdown edək”. Amma ListenAndServe bloklayıcı çağırışdır: server işlədiyi müddətdə bu sətir geri dönməyəcək. Deməli, Shutdown-a heç çatmayacaqsınız.
Ona görə shutdown “yan tərəfdə” başladılır, çox vaxt goroutine ilə: əsas axın server-i xidmət edir, paralel axın isə dayandırma siqnalını gözləyir və shutdown çağırır. Bu “sehr” deyil, sadəcə iki paralel tapşırıqdır.
Oxunaqlılıq üçün main()-in mini-refaktoru
Kod böyüdükcə main() “əriştəyə” çevrilir: marşrutlar, DI, loglama və shutdown hamısı iç-içə qarışır. Biz hələ dərin laylı arxitekturaya keçmirik, amma kiçik refaktor artıq faydalıdır: server quruluşunu funksiyaya çıxarmaq və main()-də yalnız həyat dövrünü saxlamaq.
Nümunə:
package main
import (
"net/http"
)
func newServer() *http.Server {
mux := http.NewServeMux()
// mux.HandleFunc(...) // sizin handler-lər artıq əvvəl yazılıb
return &http.Server{Addr: ":8080", Handler: mux}
}
Onda main() daha “təmiz” olur:
package main
import (
"context"
"os"
"os/signal"
"syscall"
)
func main() {
srv := newServer()
sigCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go shutdownOnSignal(sigCtx, srv)
runServer(srv)
}
runServer isə http.ErrServerClosed-i filtr etdiyimiz yerdir:
package main
import (
"errors"
"log"
"net/http"
)
func runServer(srv *http.Server) {
err := srv.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Printf("server error: %v", err)
}
}
Funksiyalara belə bölmək ona görə yaxşıdır ki, indi siz demək olar ki, təsadüfən Shutdown-u timeout-suz çağıra bilməzsiniz: shutdown-un yaşadığı bir funksiya var və orada artıq WithTimeout yerləşir.
5. Server.Shutdown(ctx) və timeout-larda tipik səhvlər
Səhv №1: Shutdown(context.Background()) “çünki daha asandır”.
Bu “asan” versiyanın ən təhlükəli formasıdır. O, prosesin bitməsini lotereyaya çevirir: əgər hansısa sorğu ilişsə, Shutdown çox uzun gözləyə bilər. Düzəliş sadə və intizamlıdır: shutdown-context yalnız context.WithTimeout(...) ilə yaradılır və timeout dəyəri konstanta kimi açıq yazılır.
Səhv №2: WithTimeout yaradırlar, amma cancel()-i unudurlar.
Timeout özü işləsə belə, cancel() context ilə bağlı resursların düzgün azad edilməsidir. Go-da cancel()-i həmişə çağırmaq qəbul olunub, adətən context yarandıqdan dərhal sonra defer cancel() ilə. Bu, böyük sistemlərdə gözlənilmədən çox əsəb qənaət edən kiçik vərdişdir.
Səhv №3: artıq ləğv olunmuş context-i shutdown-context üçün parent kimi istifadə edirlər.
Tez-tez belə olur: <-sigCtx.Done()-u gözləyirsiniz, sonra context.WithTimeout(sigCtx, 5*time.Second) edirsiniz. Amma sigCtx artıq ləğv olunub, deməli uşağ context də dərhal ləğv olunacaq. Nəticədə shutdown “ani” olur, gözləmə baş vermir. Düzgünü shutdown qolunda context.Background()-dan yaratmaqdır.
Səhv №4: http.ErrServerClosed-i “server error” kimi loglayırlar.
Bu, ListenAndServe-in normal dayandırma zamanı gözlənilən bitməsidir. Onu xəta kimi loglasanız, loglarda səs-küy və yalan siqnallar yaranacaq. Server-i işə saldığınız yerdə errors.Is(err, http.ErrServerClosed) ilə filtr edin.
Səhv №5: Shutdown xətasını ignore edirlər.
Əgər Shutdown timeout-a sığmadısa, bunu ən azı loglardan bilmək istəyirsiniz. Hətta tədris layihəsində belə mesaj çıxarmaq faydalıdır, yoxsa “hər şey gözəl bağlandı” deyə düşünərsiniz, halbuki əslində sorğuların bir hissəsi deadline ilə kəsilib.
GO TO FULL VERSION