Go (Golang)
Kurs Go: gorutyny i kanały, błędy jako wartości, interfejsy strukturalne, context, generyki i pułapki współbieżności w praktyce produkcyjnej.
Go (Golang)
Kurs Go: gorutyny i kanały, błędy jako wartości, interfejsy strukturalne, context, generyki i pułapki współbieżności w praktyce produkcyjnej.
Gorutyny i kanały: model współbieżności CSP
na egzaminieGo nie oferuje wątków jako podstawowej abstrakcji, tylko gorutyny: lekkie jednostki wykonania zarządzane przez runtime, komunikujące się przez kanały zamiast dzielenia pamięci pod zamkiem.
Gorutyna: Funkcja uruchomiona słowem kluczowym `go`, wykonywana współbieżnie względem wywołującego; runtime Go planuje tysiące gorutyn na małą pulę wątków systemowych (model M:N, sterowany przez GOMAXPROCS).
Kanał (channel): Typowany potok komunikacyjny (`chan T`) do przekazywania wartości między gorutynami, tworzony przez `make(chan T)` (niebuforowany) lub `make(chan T, n)` (buforowany, pojemność n).
Synchronizuj przez przekazywanie wartości kanałem, nie przez mutex chroniący współdzieloną zmienną, wszędzie tam, gdzie to naturalne. Zasada Go: „Do not communicate by sharing memory; share memory by communicating.”
func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
for j := range jobs {
results <- j * j
}
}
func main() {
jobs := make(chan int, 100)
results := make(chan int, 100)
var wg sync.WaitGroup
for w := 1; w <= 3; w++ {
wg.Add(1)
go worker(w, jobs, results, &wg)
}
for j := 1; j <= 9; j++ {
jobs <- j
}
close(jobs)
wg.Wait()
close(results)
for r := range results {
fmt.Println(r)
}
} select {
case res := <-resultCh:
fmt.Println("wynik:", res)
case <-time.After(2 * time.Second):
fmt.Println("timeout: brak odpowiedzi w 2s")
} Wysłanie wartości do zamkniętego kanału powoduje panic w runtime; zamykanie kanału, do którego wciąż ktoś wysyła, to błąd projektowy, nie kosmetyczne niedopatrzenie.
- Kanał niebuforowany: wysyłka blokuje do momentu odbioru (rendezvous)
- Kanał buforowany: wysyłka blokuje dopiero po zapełnieniu bufora
- `v, ok := <-ch`: ok=false oznacza kanał zamknięty i wyczerpany
- `for v := range ch`: pętla kończy się automatycznie po close(ch)
Kanał jak taśma produkcyjna: nadawca pakuje towar, odbiorca zdejmuje z taśmy. Zamknięcie close(ch) gasi taśmę: nic nowego już na nią nie kładziesz, ale to, co leży, można jeszcze zdjąć.
| Cecha | Gorutyna | Wątek systemowy (OS thread) |
|---|---|---|
| Rozmiar startowy stosu | ~2 KB, rośnie dynamicznie | zwykle 1-8 MB, stały |
| Kto planuje | Scheduler Go (M:N na GOMAXPROCS wątków OS) | Scheduler systemu operacyjnego |
| Koszt utworzenia | Bardzo niski, miliony możliwe | Wysoki, praktyczny limit - tysiące |
- Generator Gorutyna emitująca wartości do kanału wyjściowego i zamykająca go po zakończeniu pracy.
- Etap przetwarzania Gorutyna czytająca z kanału wejściowego przez range, przetwarzająca wartość, wysyłająca dalej.
- Konsument Ostatnia gorutyna (lub main) odbierająca finalne wyniki przez range aż do zamknięcia kanału.
Wartości błędów, errors.Is/As i %w
na egzaminieGo nie ma wyjątków: błąd to zwykła wartość zwracana jako ostatni wynik funkcji, sprawdzana jawnie przez if err != nil, nigdy niewidocznym rzutem sterowania.
error: Wbudowany interfejs `type error interface { Error() string }`; każdy typ z metodą Error() string spełnia go niejawnie i może pełnić rolę błędu.
var ErrNotFound = errors.New("resource not found")
type ValidationError struct {
Field string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("invalid field: %s", e.Field)
}
func fetch(id int) error {
if id < 0 {
return &ValidationError{Field: "id"}
}
if id == 0 {
return fmt.Errorf("fetch failed: %w", ErrNotFound)
}
return nil
} func main() {
err := fetch(0)
if errors.Is(err, ErrNotFound) {
fmt.Println("nie znaleziono zasobu")
}
var valErr *ValidationError
if errors.As(err, &valErr) {
fmt.Println("błąd walidacji pola:", valErr.Field)
}
} fmt.Errorf("...: %v", err) gubi możliwość odnalezienia err przez errors.Is/As, bo nie tworzy metody Unwrap(); tylko %w buduje przeszukiwane drzewo błędów.
errors.Is
- Porównuje z konkretną wartością sentinel (np. io.EOF, sql.ErrNoRows)
- Przechodzi drzewo Unwrap w poszukiwaniu dopasowania (== lub metoda Is())
- Pytanie: „czy to JEST ten błąd?”
errors.As
- Szuka błędu określonego TYPU w drzewie i przypisuje go do wskaźnika
- Daje dostęp do pól/metod konkretnego typu błędu (np. *ValidationError)
- Pytanie: „czy w drzewie JEST błąd tego typu?”
- Sentinel errors (var ErrNotFound = errors.New(...)): stała, porównywalna tożsamość błędu
- Custom error types: niosą dodatkowe dane (pola struktury) o kontekście awarii
- errors.Join (od Go 1.20): łączy wiele błędów w jedno drzewo bez utraty żadnego z nich
Komunikat błędu w Go pisze się małą literą, bez kropki na końcu. Nie dodaje się zbędnego kontekstu, który doda wyżej wywołujący. Poprawnie: fmt.Errorf("reading config: %w", err), nie "Error: Could not read config file."
err != nil to jedyny „try/catch” w Go: sprawdzasz od razu po wywołaniu, nie na końcu bloku.
func validate(name string, age int) error {
var errs error
if name == "" {
errs = errors.Join(errs, errors.New("name is required"))
}
if age < 0 {
errs = errors.Join(errs, errors.New("age must not be negative"))
}
return errs // nil, jeśli obie walidacje przeszły
} Interfejsy i niejawna implementacja strukturalna
na egzaminieInterfejs w Go opisuje zachowanie, nie pochodzenie: typ implementuje interfejs automatycznie, gdy tylko posiada wymagany zestaw metod, bez deklaracji implements.
Structural typing: Kompilator sprawdza zgodność metod w czasie kompilacji, nie w runtime przez reflection; jeśli typ „chodzi jak kaczka”, jest kaczką dla danego interfejsu.
type Writer interface {
Write(p []byte) (n int, err error)
}
type UpperWriter struct {
w io.Writer
}
func (u UpperWriter) Write(p []byte) (int, error) {
return u.w.Write(bytes.ToUpper(p))
}
func main() {
var w Writer = UpperWriter{w: os.Stdout}
w.Write([]byte("hello\n"))
} Od Go 1.18 `any` to alias dla interface{}; oba oznaczają zbiór wszystkich typów, ale any jest preferowaną, czytelniejszą formą w nowym kodzie.
Zmienna typu error/interface może być != nil, mimo że przechowuje typowany nil wskaźnik. Interfejs to para (typ, wartość), a para (*MyError, nil) różni się od (nil, nil).
type MyError struct{}
func (e *MyError) Error() string { return "boom" }
func mayFail(fail bool) *MyError {
if fail {
return &MyError{}
}
return nil
}
func do() error {
var err *MyError = mayFail(false)
return err // interfejs niosący (typ=*MyError, wartość=nil) - to NIE jest nil interfejsu
}
func main() {
if err := do(); err != nil {
fmt.Println("err != nil, mimo że logicznie nic się nie stało:", err)
}
} - Interfejsy w Go trzyma się małe: 1-3 metody (np. io.Reader, io.Writer, sort.Interface)
- Zasada „accept interfaces, return structs”: parametry przyjmują interfejsy, funkcje zwracają konkretne typy
- Interfejs deklaruje się tam, gdzie jest konsumowany, nie tam, gdzie typ jest zdefiniowany
| Aspekt | Go (implicit) | Java/C# (explicit) |
|---|---|---|
| Deklaracja zgodności | Automatyczna, przez zestaw metod | Jawna przez implements/: |
| Sprzężenie modułów | Niskie - konsument definiuje potrzebny interfejs | Wyższe - typ musi znać interfejs przy definicji |
| Nowy interfejs dla istniejącego typu | Nie wymaga zmian w typie | Wymaga edycji deklaracji typu |
Nie pytaj „czym jesteś”, pytaj „co potrafisz zrobić” - to sedno interfejsów w Go.
- Zdefiniuj interfejs po stronie konsumenta Np. w pakiecie, który wywołuje funkcję, nie w pakiecie z implementacją.
- Ogranicz liczbę metod do minimum Mniejszy interfejs = łatwiejszy do zaimplementowania i przetestowania (mock).
- Zwracaj konkretny typ z konstruktora Wywołujący dostaje pełny dostęp do API, interfejs stosuje się dopiero tam, gdzie potrzebny jest polimorfizm.
defer, panic i recover
na egzaminiedefer, panic i recover to mechanizm Go do zarządzania zasobami i nietypowymi, katastrofalnymi błędami - nie zamiennik zwykłej obsługi błędów przez wartości error.
defer: Odracza wywołanie funkcji do momentu zwrotu z otaczającej funkcji; argumenty są ewaluowane natychmiast, samo wywołanie następuje później, w kolejności LIFO.
func closeAll() error {
f1, err := os.Open("a.txt")
if err != nil {
return fmt.Errorf("opening a.txt: %w", err)
}
defer f1.Close()
f2, err := os.Open("b.txt")
if err != nil {
return fmt.Errorf("opening b.txt: %w", err)
}
defer f2.Close()
fmt.Println("praca z plikami")
return nil
// kolejność zamykania: najpierw f2.Close(), potem f1.Close() (LIFO)
} panic / recover: panic przerywa normalny przepływ i zaczyna odwijać stos, wykonując po drodze odroczone defer. Funkcja recover, wywołana bezpośrednio w takim defer, zatrzymuje odwijanie i pozwala funkcji zwrócić się normalnie.
func process(items []int) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("process: recovered from panic: %v", r)
}
}()
for _, i := range items {
fmt.Println(100 / i) // panikuje przy i == 0 (dzielenie int przez zero)
}
return nil
} Funkcja recover() poza funkcją wywołaną bezpośrednio przez defer nie działa. Wywołana w innej gorutynie niż ta, w której wystąpił panic, również nic nie przechwyci. Każda gorutyna potrzebuje własnego recover.
- panic: naruszenie niezmiennika, błąd inicjalizacji pakietu, index out of range wykryty przez runtime, dzielenie int przez zero
- error: każdy oczekiwany, przewidywalny scenariusz awarii (brak pliku, timeout sieci, nieprawidłowe dane wejściowe)
- recover ma sens głównie na granicy systemu (np. middleware HTTP), by nie ubić całego procesu przez błąd w jednym żądaniu
Panic, którego żadna funkcja w stosie danej gorutyny nie przechwyci przez recover, kończy CAŁY proces (nie tylko gorutynę). Proces kończy się z niezerowym kodem wyjścia i wypisuje stack trace.
defer sprząta, panic krzyczy „coś jest fundamentalnie nie tak”, recover zatyka uszy tylko temu, kto stoi bezpośrednio obok.
func recoverMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
log.Printf("panic w handlerze: %v", rec)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
} Pakiet context: anulowanie i deadline'y
na egzaminieTyp context.Context niesie przez łańcuch wywołań sygnał anulowania, deadline i opcjonalnie wartości powiązane z pojedynczym żądaniem. To standardowy sposób na zatrzymanie pracy, gdy nikt już nie czeka na wynik.
context.Context: Interfejs z metodami Deadline(), Done() <-chan struct{}, Err() error, Value(key any) any; Done() zwraca kanał zamykany w momencie anulowania lub upłynięcia deadline'u.
func fetchWithTimeout(ctx context.Context, url string) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("building request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
} func worker(ctx context.Context, jobs <-chan int) {
for {
select {
case <-ctx.Done():
fmt.Println("worker: zatrzymano,", ctx.Err())
return
case j, ok := <-jobs:
if !ok {
return
}
fmt.Println("przetwarzam:", j)
}
}
} ctx context.Context zawsze jako pierwszy parametr funkcji, nazwany dosłownie ctx; nigdy jako pole struktury długożyjącego obiektu.
Zapomnienie wywołania cancel() zwróconego przez WithCancel/WithTimeout/WithDeadline (nawet po sukcesie) zostawia zasoby kontekstu żywe do czasu GC; zawsze defer cancel() zaraz po utworzeniu.
| Konstruktor | Kiedy zamyka Done() |
|---|---|
| context.Background() | Nigdy - to korzeń drzewa kontekstów |
| context.WithCancel(parent) | Ręcznie, przy wywołaniu zwróconej funkcji cancel() |
| context.WithTimeout(parent, d) | Po czasie d albo ręcznie przez cancel() |
| context.WithDeadline(parent, t) | O konkretnym czasie t albo ręcznie przez cancel() |
- context.Background(): korzeń w main, testach, inicjalizacji
- context.TODO(): tymczasowy placeholder, gdy docelowy kontekst jeszcze nie jest przekazywany - sygnał do refaktoryzacji
- context.WithValue: tylko dla danych per-żądanie (trace ID, uwierzytelniony użytkownik), nigdy dla wymaganych argumentów funkcji
Context to smycz: rodzic anuluje, dziecko czuje to natychmiast - ale nie odwrotnie.
Dobre użycie WithValue
- ID żądania do logowania/tracingu
- Tożsamość uwierzytelnionego użytkownika z middleware
Złe użycie WithValue
- Przekazywanie wymaganych parametrów biznesowych (jawny argument jest lepszy)
- Przechowywanie połączenia z bazą danych lub loggera zamiast wstrzyknięcia zależności
Pułapki współbieżności: wyścigi danych i wycieki gorutyn
na egzaminieKompilator Go nie chroni przed wyścigami danych ani wyciekami gorutyn - oba błędy są w pełni poprawne składniowo i ujawniają się dopiero w runtime, często niedeterministycznie.
go test -race ./... (oraz go run -race, go build -race) instrumentuje dostęp do pamięci i wykrywa brak synchronizacji happens-before między gorutynami.
Koszt to zwykle 2-20x wolniejsze wykonanie i 5-10x większe zużycie pamięci. Dlatego stosuje się -race w CI/testach, nie na produkcji.
var counter int
func incBroken() {
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++ // odczyt-modyfikacja-zapis bez ochrony: wyścig danych
}()
}
wg.Wait()
fmt.Println(counter) // niedeterministyczny wynik, zwykle < 1000
} func incSafe() {
var counter int64
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
atomic.AddInt64(&counter, 1)
}()
}
wg.Wait()
fmt.Println(atomic.LoadInt64(&counter)) // zawsze dokładnie 1000
} Polecenie go build bez -race skompiluje kod z wyścigiem danych bez ostrzeżenia, bo build tylko tworzy binarkę i nie uruchamia jej. Dopiero uruchomiony później plik wykonywalny ściga się po cichu.
Brak widocznego objawu przy niskim obciążeniu usypia czujność aż do produkcji pod dużym ruchem.
func leaky() {
ch := make(chan int) // niebuforowany
go func() {
ch <- computeExpensiveValue() // blokuje się w nieskończoność, gdy nikt nie odbiera
}()
// funkcja wraca bez odbioru z ch -> gorutyna wisi w pamięci na zawsze
} func notLeaky(ctx context.Context) {
ch := make(chan int, 1) // buforowany: wysyłka się nie zablokuje
go func() {
select {
case ch <- computeExpensiveValue():
case <-ctx.Done():
}
}()
} - Zablokowana wysyłka/odbiór na kanale bez partnera po drugiej stronie
- Zapomniany wg.Done() w defer przy panice w gorutynie roboczej
- Nieskończona pętla bez ścieżki wyjścia (brak select na ctx.Done() lub kanale stop)
| Mechanizm | Kiedy użyć |
|---|---|
| sync.Mutex | Ochrona złożonego stanu (kilka pól, mapa, struktura) |
| sync/atomic | Pojedyncza liczba/wskaźnik, wysoka częstotliwość aktualizacji |
| channel | Przekazanie własności danych lub sygnalizacja zdarzenia między gorutynami |
Jeśli nie potrafisz odpowiedzieć „kto i kiedy odbierze z tego kanału”, właśnie napisałeś wyciek gorutyny.
Generyki: parametry typu i constraints
na egzaminieOd Go 1.18 funkcje i typy mogą przyjmować parametry typu ograniczone przez constraints. Eliminuje to powielanie kodu dla int, float64, string, bez ucieczki do interface{} i rzutowań w runtime.
Parametr typu: Deklarowany w nawiasach kwadratowych po nazwie funkcji/typu, np. func Sum[T cmp.Ordered](nums []T) T. Parametr T zostaje ustalony przy wywołaniu, najczęściej przez inferencję z argumentów.
Constraint: Interfejs opisujący dozwolony zbiór typów konkretnych dla parametru typu; ~int dopuszcza int oraz każdy typ nazwany o bazowym typie int (underlying type).
import "cmp"
func Sum[T cmp.Ordered](nums []T) T {
var total T
for _, n := range nums {
total += n
}
return total
} type Numeric interface {
~int | ~int64 | ~float32 | ~float64
}
func Average[T Numeric](nums []T) float64 {
var sum T
for _, n := range nums {
sum += n
}
return float64(sum) / float64(len(nums))
} type Stack[T any] struct {
items []T
}
func (s *Stack[T]) Push(v T) {
s.items = append(s.items, v)
}
func (s *Stack[T]) Pop() (T, bool) {
var zero T
if len(s.items) == 0 {
return zero, false
}
n := len(s.items) - 1
v := s.items[n]
s.items = s.items[:n]
return v, true
} Predeklarowany constraint comparable dopuszcza typy wspierające ==/!=; wycinki (slices), mapy i funkcje NIE implementują comparable i nie mogą być parametrem typu ograniczonym przez comparable.
Nadużywanie generyków tam, gdzie wystarczy interfejs (jedna implementacja, brak potrzeby wielu konkretnych typów) dodaje złożoność czytania bez korzyści w wydajności czy bezpieczeństwie typów.
- slices.Sort, slices.Contains, maps.Keys - generyczne funkcje stdlib zamiast ręcznych pętli po typach
- Inferencja typu zwykle eliminuje potrzebę jawnego podania [T] przy wywołaniu
- Generyki nie zastępują interfejsów: interfejsy dają polimorfizm w runtime, generyki - reużywalność kodu w czasie kompilacji
Interfejs pyta „co ten obiekt potrafi teraz”, generyk pyta „dla jakiego typu ten kod ma zadziałać, ustalone przy kompilacji”.
Moduły, semantic import versioning i statyczna kompilacja
Go modules (od 1.11, domyślne i jedyne wspierane od 1.16) zarządzają zależnościami i wersjonowaniem. Kompilator generuje pojedynczy statyczny plik wykonywalny bez zależności od zewnętrznego runtime czy maszyny wirtualnej.
go.mod / go.sum: go.mod deklaruje nazwę modułu, wymaganą wersję Go i zależności; go.sum przechowuje kryptograficzne sumy kontrolne pobranych wersji dla weryfikowalnej odtwarzalności buildu.
module github.com/tenzanlogic/example
go 1.23
require (
github.com/google/uuid v1.6.0
) Od wersji major 2 wzwyż ścieżka modułu i importu musi zawierać sufiks /vN (np. github.com/tenzanlogic/example/v2). Dzięki temu v1 i v2 mogą współistnieć w jednym drzewie zależności jako odrębne, niekolidujące pakiety.
// go.mod: module github.com/tenzanlogic/example/v2
import "github.com/tenzanlogic/example/v2/pkg/data" CGO_ENABLED=0 trzeba ustawić jawnie, by zagwarantować w pełni statyczny, wolny od libc binarny plik. Przy natywnym buildzie z obecnym w systemie kompilatorem C domyślne jest CGO_ENABLED=1.
Przy natywnym buildzie pakiety stdlib takie jak net (rozwiązywanie DNS) czy os/user potrafią po cichu włączyć cgo i dynamicznie zlinkować libc. Dzieje się tak, mimo że kod użytkownika nigdy nie pisze import "C".
CGO_ENABLED=0 jest domyślne tylko przy cross-compilation na inny GOOS/GOARCH niż host. Z jawnie wymuszonym CGO_ENABLED=0 binarka Go działa nawet w kontenerze FROM scratch, bez systemu operacyjnego.
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o app-linux-arm64 ./cmd/app Włączenie cgo (CGO_ENABLED=1, np. przez rozwiązywanie DNS przez glibc lub sterowniki SQLite w C) przywraca dynamiczne zależności od libc. Psuje to przenośność binarki między dystrybucjami/kontenerami scratch.
- GOOS/GOARCH: docelowy system operacyjny/architektura bez potrzeby fizycznego dostępu do tej platformy
- CGO_ENABLED=0 trzeba ustawić jawnie dla gwarancji statycznego builda - nie jest to domyślne przy natywnym buildzie z dostępnym kompilatorem C
- Jeden binarny plik = prostszy deployment: brak instalacji runtime, brak zarządzania wersją interpretera na serwerze
Go nie pyta serwera „czy masz zainstalowany mój runtime” - przynosi go ze sobą w jednym pliku.
Sprawdź się
20 z 24 pytań, losowo, z wyjaśnieniem po każdym. Wynik zostaje na tym urządzeniu.
Rozpocznij quiz