GoSuda

Go 1.27, или как се научих да престана да се тревожа и да обичам обобщените методи

By iwanhae views ...

Преглед на Go 1.27

Go 1.27 излиза през август 2026 г., и този път самият език се промени. Не в смисъл на „добавихме нова функция към slices“, а наистина. Генеричните методи вече са налични. Един единайсетгодишен проблем беше затворен. encoding/json беше тихо заменен с нов engine, докато самолетът беше във въздуха.

Всеки пример по-долу беше изпълнен на go1.27rc3 (darwin/arm64). Всеки изходен блок е реален изход, копиран директно, включително съобщенията за грешки. Ако нещо изглежда странно, то е защото това е точно казаното от компилатора.

1go install golang.org/dl/go1.27rc3@latest
2go1.27rc3 download

1. Езикът се промени (да, наистина)

1.1. Генерични методи

От Go 1.18 имаме генерици и от Go 1.18 имаме и The Conversation. Той протича така:

„Нека просто напиша метод Map за моя тип слайс—“

method must have no type parameters

„...добре. Значи функция на ниво пакет.“

Методите можеха да използват само типови параметри, декларирани от receiver-а. Собственият ти метод не можеше да въвежда нови. Затова всяка генерична трансформация беше изгонена на ниво пакет, където стоеше редом със седемнайсет други свободно висящи помощни функции с някакъв вариант на името MapSlice.

Go 1.27 оправя това:

 1type List[E any] []E
 2
 3// F е типов параметър, деклариран от самия метод
 4func (l List[E]) Apply[F any](f func(E) F) List[F] {
 5	r := make(List[F], len(l))
 6	for i, x := range l {
 7		r[i] = f(x)
 8	}
 9	return r
10}
11
12func main() {
13	l := List[int]{1, 2, 3}
14	fmt.Println(l.Apply(func(i int) string { return fmt.Sprint(i * 10) }))
15	// [10 20 30]
16}

Receiver-ът дори не трябва да е генеричен. Обикновена структура може да има генеричен метод:

1type Bag struct{ items []any }
2
3func (b *Bag) Add[T any](v T) { b.items = append(b.items, v) }

Сега, преди да рефакторираш цялата си кодова база още този следобед, има две ограничения, и те са по-важни, отколкото изглеждат:

  1. Методите на интерфейси не могат да декларират типови параметри.
  2. Генеричните методи не могат да имплементират методи на интерфейси.

Второто е това, което ще те ухапе:

1type Adder interface{ Add(int) int }
2
3type G struct{}
4
5func (G) Add[T any](v T) T { return v }
6
7var _ Adder = G{}
1cannot use G{} (value of struct type G) as Adder value in variable declaration:
2	G does not implement Adder (wrong type for method Add)
3		have Add[T any](T) T
4		want Add(int) int

Така че генеричните методи и динамичното диспечиране не се смесват. Това не е скъперничеството на екипа на Go: генеричният метод има безбройно много инстанциации, а таблицата с методи на интерфейс трябва да е крайна и известна по време на компилация. Не можеш да сложиш безкрайно нещо в крайна таблица. Вселената каза не.

Практичният извод: генеричните методи са за конкретни типове с API във вид на помощни функции. Всичко, което трябва да се крие зад интерфейс, все още се нуждае от стария подход.

Стандартната библиотека вече се възползва. Rand от math/rand/v2 получи генеричен метод:

1func (r *Rand) N[Int intType](n Int) Int
1r := rand.New(rand.NewPCG(1, 2))
2fmt.Println(r.N(int32(100)))     // 76
3fmt.Println(r.N(time.Second))    // 616.436223ms

Досега само функцията на ниво пакет rand.N беше генерична, което означаваше използване на глобалния източник. Сега твоят собствен инициализиран *Rand получава същото удобство. Малка работа. Хубава работа.

1.2. Селектори на полета в литерали на структури, или: Issue #9859 най-накрая получава почивка

Вграждането ти дава u.ID. Вграждането не ти дава User{ID: 1}. Вместо това ти дава User{Base: Base{ID: 1}}, което е начинът на Go да попита дали наистина си го имал предвид.

Issue #9859 беше подаден през 2015 г. Сега е затворен. Някъде един gopher, който оттогава е сменил кариерата си два пъти, получава известие от GitHub.

 1type Object struct{ name, color string }
 2type Point3D struct {
 3	Object
 4	x, y, z float64
 5}
 6type Line struct {
 7	Object
 8	p, q Point3D
 9}
10
11// Go 1.27: използвай повишеното поле директно
12line := Line{name: "diagonal", q: Point3D{y: -4, z: 12.3}}

name не е поле на Line. То е поле на вграденото Object. Преди трябваше да напишеш Line{Object: Object{name: "diagonal"}, ...}.

Сега дребният шрифт, който проверих, като реално опитах да компилирам всичко.

(a) Ключът все още е обикновен идентификатор. Не можеш да напишеш произволен път на селектор.

1_ = Line{Object.name: "x"}   // invalid field name Object.name in struct literal
2_ = Line{p.x: 1}             // invalid field name p.x in struct literal

Така че функцията не означава „ключовете работят като достъп до полета сега“. Тя означава конкретно „неявно повишените имена на полета са разрешени“. Малко по-тясно от заглавието.

(б) Не можеш едновременно да укажеш вградено поле и нещо, повишено от него.

1obj := Object{"edge", "black"}
2_ = Line{Object: obj, name: "diagonal"}
3// cannot specify promoted field name and enclosing embedded field Object

Разумно. Казал си две противоречиви неща за една и съща памет и компилаторът отказа да гадае.

(в) Вграждането на указатели не е поощрявано.

1type PtrEmbed struct {
2	*Object
3	z int
4}
5
6_ = PtrEmbed{name: "x"}
7// invalid implicit pointer indirection to reach name

За да проследиш указател, ти трябва указател, който да проследиш, а по времето на конструиране на литерала такъв още няма. И това е честно.

Добрата новина: go fix ще пренапише старите ти литерали вместо теб. По-подробно по-късно.

1.3. Изводът на типове за функции стана по-малко произволен

Изводът на типове за генерични функции преди работеше на някои места, а на други не, без ясен принцип зад това кое къде работи. Присвояване на променлива? Наред. Поставяне на точно същата функция в поле на структура? Грешка при компилация, моля напиши double[int] все едно сме през 2022.

Go 1.27 прави извеждането да работи във всеки контекст, в който целевият тип е недвусмислено известен:

 1func double[T ~int | ~float64](v T) T { return v * 2 }
 2
 3type S struct{ f func(int) int }
 4type A [1]func(int) int
 5
 6func main() {
 7	s := S{f: double}          // 1.26: изискваше double[int]
 8	a := A{double}             // 1.26: изискваше double[int]
 9
10	c := make(chan func(int) int, 1)
11	c <- double                // 1.26: изискваше double[int]
12
13	var fn func(float64) float64 = double  // това винаги е работило
14
15	fmt.Println(s.f(21), a[0](5), (<-c)(7), fn(1.5))
16	// 42 10 14 3
17}

Един double, инстанциран като func(int) int на три места и като func(float64) float64 на четвърто, изцяло от контекста. Ако пишеш структури с опции или таблици с обработчици, пълни със стойности на функции, куп шум от [T] е на път да изчезне от кодовата ти база.

2. Runtime: безплатна производителност и детектор за течове

2.1. Алокация, специализирана по размер

Компилаторът вече генерира извиквания към алокационни рутини, специализирани по размер, за малки обекти. Release notes твърдят до 30% по-малко време за алокации под 80 байта и около 1% общо за програми с интензивни алокации.

Не вярвам на release notes без измерване, затова:

 1type small struct{ a, b, c, d int }
 2
 3var sink any
 4
 5func BenchmarkAlloc(b *testing.B) {
 6	for b.Loop() {
 7		sink = &small{}
 8	}
 9}
10
11func BenchmarkSlice(b *testing.B) {
12	for b.Loop() {
13		sink = make([]byte, 48)
14	}
15}
1# по подразбиране (size-specialized malloc включен)
2BenchmarkAlloc-12     3000000     5.358 ns/op
3BenchmarkSlice-12     3000000    13.58 ns/op
4
5# GOEXPERIMENT=nosizespecializedmalloc
6BenchmarkAlloc-12     3000000     8.722 ns/op
7BenchmarkSlice-12     3000000    20.81 ns/op
8

30–35% по-бързо в микробенчмарк, който не прави нищо друго освен алокации на Apple Silicon. Иначе казано: това е най-добрият случай, постигнат при лабораторни условия от бенчмарк, проектиран така, че числото да изглежда добре. В реална програма GC и действителната работа ще погребат по-голямата част от това. Твърдението за ~1% е честното.

Цената е в размера на бинарния файл. Hello World:

12413202 bytes  (по подразбиране)
22361826 bytes  (nosizespecializedmalloc)

Около 50KB, фиксирано, независимо от програмата ти. Можеш да се откажеш с GOEXPERIMENT=nosizespecializedmalloc, но този изход за спасение е планиран за премахване в Go 1.28, затова го третирай като заобиколно решение за докладване на грешки, а не като начин на живот.

2.2. Профилът за течове на горутини вече е реален

Експериментален в Go 1.26, общодостъпен в 1.27. GOEXPERIMENT goroutineleakprofile го няма; просто работи.

 1func leak() {
 2	ch := make(chan int) // никой никога няма да изпрати. никой.
 3	go func() {
 4		<-ch
 5	}()
 6}
 7
 8func main() {
 9	for range 3 {
10		leak()
11	}
12	runtime.GC()
13	runtime.GC()
14	pprof.Lookup("goroutineleak").WriteTo(os.Stdout, 1)
15}
1goroutineleak profile: total 3
23 @ 0x104de3f18 0x104d7f0b0 0x104d7ec34 0x104e374f4 0x104dea024
3#	0x104e374f3	main.leak.func1+0x23	/tmp/go127/leak/main.go:12

Три горутини, завинаги блокирани, с файл и номер на ред, които сочат точно мястото, където си го направил.

Трикът зад това е наистина умен: той използва повторно анализа на достижимостта на garbage collector-а. Ако горутина G е блокирана на примитив P и P е недостижим от каквато и да е изпълнима горутина (или от нещо, което тези горутини биха могли да събудят), тогава никой не може никога да докосне P отново, следователно G никога няма да се събуди. Това не е евристика, а доказателство.

Същият дизайн ти дава и ограничението безплатно: ако каналът или мютексът са достижими през глобална променлива или през локална променлива на някоя още работеща горутина, GC все още ги вижда и runtime не може да заключи нищо. Забележи, че дори малкият пример по-горе се нуждае от две извиквания на runtime.GC(), за да докладва. Няма да хване всичко.

Ще хване обаче класическото „забравих да отменя контекста, горутината наworker-а живее вечно“, което е най-честият теч през повечето време.

Ако импортираш net/http/pprof, той е достъпен и на /debug/pprof/goroutineleak. Включи го в staging, проверявай го всяка седмица и бъди тихо ужасен.

Тази работа е допринесена от Vlad Saioc в Uber, който заслужава напитка по свой избор.

2.3. Traceback-овете вече казват кой заявка е умряла

За модули, декларирани с Go 1.27 или по-нова версия, редовете със заглавие на traceback сега включват етикетите на горутините от runtime/pprof.

 1func handle(ctx context.Context) {
 2	var wg sync.WaitGroup
 3	wg.Go(func() {
 4		buf := make([]byte, 4<<10)
 5		os.Stdout.Write(buf[:runtime.Stack(buf, true)])
 6	})
 7	wg.Wait()
 8}
 9
10func main() {
11	labels := pprof.Labels("request", "GET /orders/42", "tenant", "acme")
12	pprof.Do(context.Background(), labels, handle)
13}
1goroutine 3 [running] {request: "GET /orders/42", tenant: acme}:
2main.handle.func1()
3	/tmp/go127/tblabel/main.go:15 +0x40
4sync.(*WaitGroup).Go.func1()
5	...
6goroutine 1 [sync.WaitGroup.Wait] {request: "GET /orders/42", tenant: acme}:
7...

Важният детайл: етикетите се наследяват от дъщерните горутини. Горутина 3 никога не е задавала етикет. Тя е получила такъв от родителя си.

Затова следващият път, когато продукцията се deadlock-не и някой изпрати SIGQUIT на процеса, вместо 4000 горутини, които всички изглеждат еднакво, ще получиш 4000 горутини, отбелязани с това към коя заявка и кой tenant принадлежат. Три реда pprof.Do във входната точка на заявките ти ти осигуряват това.

Тъй като етикетите могат да съдържат неща, които не искаш да изхвърляш в stderr, GODEBUG=tracebacklabels=0 го изключва, а това опция за отказ е изрично предназначена да остане завинаги.

2.4. asynctimerchan вече е окончателно премахнат

Go 1.23 направи канализите на таймерите небуферирани (синхронни) и предложи asynctimerchan=1 като начин за връщане към старото поведение. В 1.27 тази настройка е окончателно премахната. Каналите на пакета time са синхронни, без обсъждане.

Интересната част е политиката, въведена заедно с това. Премахнат GODEBUG, оставен в твоя go.mod, не чупи автоматично билда; той го чупи само ако е зададен на старата стойност:

1# go.mod съдържа: godebug asynctimerchan=1
2go: error loading go.mod:
3go.mod:5: removed GODEBUG "asynctimerchan" set to old value "1" (https://go.dev/doc/godebug#go-127)
4
5# go.mod съдържа: godebug asynctimerchan=0
6(builds fine)

Това означава, че единствените хора, на които се карат, са тези, които действително са разчитали на премахнатото поведение. Всеки, който я е задал на бъдещото по подразбиране и я е забравил, може да продължи да не мисли за нея. Това е разумно парче API археология.

3. Стандартна библиотека

3.1. encoding/json/v2: Двигателят беше сменен в движение

Това е голямата промяна. Над две години обсъждане на предложението най-накрая се реализира.

Сега има три пакета, и разбирането на разделението е почти половината от битката:

ПакетЗадача
encoding/jsonAPI-то v1, което познаваш. Поведението е 100% непроменено. Сега е имплементиран върху v2
encoding/json/v2Семантична обработка. Go стойности ↔ JSON
encoding/json/jsontextСинтактична обработка. JSON като поток от токени

Заглавието е, че encoding/json беше изграден наново върху напълно различна имплементация и се държи идентично. Ето как:

1// encoding/json.Unmarshal в Go 1.27, съкратено
2func Unmarshal(data []byte, v any) error {
3	return jsonv2.Unmarshal(data, v, DefaultOptionsV1())
4}

Всяка наследена особеност на v1 е кодирана като опция, събрана в DefaultOptionsV1(), и API-то v1 винаги прилага този пакет. Което означава, че това се държи еднакво в 1.26 и 1.27:

1var m map[string]int
2json.Unmarshal([]byte(`{"a":1,"a":2}`), &m)  // <nil>, map[a:2]

Да, v1 все още безшумно приема дублирани ключове и взима последния. Винаги е правил така. И все още прави. Съвместимостта означава съвместимост и с лошите части.

v2, от друга страна, има мнения:

1var m map[string]int
2jsonv2.Unmarshal([]byte(`{"a":1,"a":2}`), &m)
3// jsontext: duplicate object member name "a"
4
5var s string
6jsonv2.Unmarshal([]byte("\"\xff\""), &s)
7// jsontext: invalid UTF-8 after offset 1

Дублираните ключове и невалидният UTF-8 се отхвърлят. И двете са наистина опасни, а не просто неугледни: когато два парсера не са съгласни кой дублиран ключ печели, получаваш уязвимости в сигурността. CVE-2017-12635 за CouchDB беше точно това: JSON тяло с два ключа roles, където валидаторът прочете единия, а слоят за съхранение прочете другия. Отхвърлянето е правилното решение.

Опциите са вариадични:

 1type Config struct {
 2	Name    string   `json:"name"`
 3	Tags    []string `json:"tags,omitzero"`
 4	Timeout int      `json:"timeout,omitzero"`
 5}
 6
 7out, _ := jsonv2.Marshal(Config{Name: "api"})
 8// {"name":"api"}
 9
10// сортирани ключове на map + индентация
11out, _ = jsonv2.Marshal(map[string]int{"b": 2, "a": 1},
12	jsonv2.Deterministic(true), jsontext.WithIndent("  "))
13// {
14//   "a": 1,
15//   "b": 2
16// }
17
18// отхвърляне на неизвестни полета — преди изискваше Decoder и DisallowUnknownFields()
19var c Config
20err := jsonv2.Unmarshal([]byte(`{"name":"api","nope":1}`), &c,
21	jsonv2.RejectUnknownMembers(true))
22// json: cannot unmarshal JSON string into Go main.Config:
23//   unknown object member name "nope"

Deterministic, MatchCaseInsensitiveNames, StringifyNumbers, FormatNilSliceAsNull, OmitZeroStructFields — повечето от нещата, които преди решаваше с външна библиотека или ръчно написан MarshalJSON, сега са флагове.

А историята с миграцията е най-добрата част. По-късните опции печелят, така че можеш да приемаш строгостта на v2 по едно поведение наведнъж:

1// запази семантиката на v1, но отхвърляй дублирани ключове като v2
2jsonv2.Unmarshal(data, &v,
3	json.DefaultOptionsV1(),
4	jsontext.AllowDuplicateNames(false))
5// duplicate object member name

Не е нужно да портираш кодова база от 200 000 реда към v2, за да спреш да приемаш дублирани ключове. Превключваш една опция. За всичко голямо това е реалистичният път.

Производителност: маршалирането е приблизително на нивото на предишното, демаршалирането е значително по-бързо. Ако нещо се обърка, GOEXPERIMENT=nojsonv2 възстановява старата имплементация, а този изход за спасение също ще бъде премахнат в някакъв момент, така че подай issue, вместо да се установяваш.

jsontext е нискоуровневият слой: Encoder/Decoder, които обхождат JSON като Token и Value със state machine, която те държи коректен. Посегни към него, когато пишеш поточен трансформатор или JSON филтър и не искаш изобщо да материализираш Go стойности.

3.2. Стандартен пакет uuid

Най-накрая. RFC 9562, в стандартната библиотека, без нужда от go get.

 1import "uuid"
 2
 3func main() {
 4	fmt.Println(uuid.NewV4())
 5	// b97aa695-da08-472c-af81-ff088129019f
 6
 7	// v7: горните 48 бита са времева марка, затова те винаги се сортират по ред на създаване
 8	a, b := uuid.NewV7(), uuid.NewV7()
 9	fmt.Println(a.Compare(b) < 0) // true
10
11	u, err := uuid.Parse("urn:uuid:0198a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b")
12	fmt.Println(u, err)
13	// 0198a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b <nil>
14
15	fmt.Println(uuid.Nil(), uuid.Max())
16	// 00000000-0000-0000-0000-000000000000 ffffffff-ffff-ffff-ffff-ffffffffffff
17}

Целият API е New, NewV4, NewV7, Nil, Max, Parse, MustParse и един тип: type UUID [16]byte. Толкова. Можеш да прочетеш цялата документация на пакета, докато кафето ти изстива.

Детайли, които си струва да знаеш:

  • UUID е [16]byte, така че == работи и може директно да се използва като ключ на map. Същият дизайн като google/uuid.
  • Nil и Max са функции, а не променливи. Защото пакетна var Nil UUID е заредено оръжие, насочено към крака ти, и някой, някъде, рано или късно ще му присвои стойност.
  • Случайните битове идват от криптографски сигурен генератор.
  • Имплементира encoding.TextMarshaler/TextUnmarshaler/TextAppender, така че се вписва директно в JSON структури.
  • NewV7 е този, който искаш за първични ключове в бази данни. Подредянето по време означава, че B-дървесният индекс спира да се фрагментира, което UUID v4 са известни с това, че правят зле.

Сега можеш да премахнеш една зависимост. Ако имаш нужда от v1/v3/v5 или по-сложни опции за парсване, външните библиотеки все още имат работа.

3.3. crypto/mldsa: Постквантови подписи и те са огромни

Go 1.24 ни даде crypto/mlkem за постквантов обмен на ключове. Go 1.27 носи другата половина: подписи ML-DSA, стандартизирани като FIPS 204.

 1sk, _ := mldsa.GenerateKey(mldsa.MLDSA65())
 2pk := sk.PublicKey()
 3
 4msg := []byte("release the gophers")
 5opts := &mldsa.Options{Context: "gosuda.org/blog"}
 6
 7sig, _ := sk.Sign(rand.Reader, msg, opts)
 8
 9fmt.Println("private key seed:", len(sk.Bytes()), "bytes")  // 32
10fmt.Println("public key:", len(pk.Bytes()), "bytes")        // 1952
11fmt.Println("signature:", len(sig), "bytes")                // 3309
12
13fmt.Println(mldsa.Verify(pk, msg, sig, opts))
14// <nil>
15fmt.Println(mldsa.Verify(pk, msg, sig, &mldsa.Options{Context: "other"}))
16// mldsa: invalid signature

Погледни тези числа. Един подпис е 3309 байта. Ed25519 е 64. Това е 50-кратно увеличение, а публичният ключ е почти 2KB отгоре. Наблъскай няколко от тях във верига от сертификати и TLS handshake-ът ти ще започне да се нуждае от собствена MTU стратегия.

Това е действителната цена на квантовата устойчивост днес и затова никой не превключва всичко утре. Но сега е в стандартната библиотека, което е мястото, където искаш да е, преди да ти трябва.

Options.Context е разделяне на домейни: подписвай със същия ключ за различни цели, използвай различен контекст за всяка, и подпис от един контекст няма да се верифицира в друг. Примерът по-горе показва точно това: същ ключ, също съобщение, различен контекст, отхвърлен.

PrivateKey имплементира crypto.Signer, така че се вписва в съществуващи интерфейси. crypto/x509 работи с ключове и подписи ML-DSA, а crypto/tls поддържа схемите за подписи MLDSA44/MLDSA65/MLDSA87 в TLS 1.3.

Има и SignDeterministic, който пропуска случайността, полезно за тестове и възпроизводими билдове.

3.4. simd: Векторни инструкции без асемблер

Go 1.26 въведе архитектурно специфичния simd/archsimd като експеримент. Go 1.27 добавя simd — преносим и независим от ширината на вектора. Включва се с GOEXPERIMENT=simd.

 1// dst = a*x + y
 2func axpy(dst, x, y []float32, a float32) {
 3	va := simd.BroadcastFloat32s(a)
 4	w := va.Len()
 5
 6	i := 0
 7	for ; i+w <= len(x); i += w {
 8		vx := simd.LoadFloat32s(x[i:])
 9		vy := simd.LoadFloat32s(y[i:])
10		vx.MulAdd(va, vy).Store(dst[i:])
11	}
12	// обработка на опашката с частично зареждане/запис — без скаларен цикъл за почистване
13	if i < len(x) {
14		vx, _ := simd.LoadFloat32sPart(x[i:])
15		vy, _ := simd.LoadFloat32sPart(y[i:])
16		vx.MulAdd(va, vy).StorePart(dst[i:])
17	}
18}
1$ GOEXPERIMENT=simd go1.27rc3 run ./simddemo
2vector bits: 128 emulated: false
3[4 7 10 13 16 19 22 25 28 31]

Изборът на дизайн, който има значение: ширината на вектора никога не е твърдо кодирана. Питаш va.Len() по време на изпълнение. VectorBitSize() ти казва действителната ширина, Emulated() ти казва дали имаш реален хардуер или учтива софтуерна имитация. Моят Mac докладва 128-битов NEON; машина с AVX-512 докладва повече; машина без нищо докладва емулация и кодът пак работи.

LoadFloat32sPart/StorePart заслужават специално споменаване. Най-досадната част от ръчно написания SIMD е винаги неравната опашка в края на масива, а това я обработва без отделен скаларен цикъл.

Все още експериментален, API все още нестабилен, не го слагай в продукция. Но фактът, че това е Go код, а не асемблер или cgo, е наистина голяма работа.

3.5. hash/maphash.Hasher

Нов интерфейс, описващ договора между типа стойност и контейнерите, базирани на хеширане:

1type Hasher[T any] interface {
2	Hash(*Hash, T)
3	Equal(x, y T) bool
4}

Защо? Вградената map на Go приема само comparable ключове. Не можеш да използваш слайс като ключ. Не можеш да дефинираш „равни без значение от регистъра“. Hasher решава и двете наведнъж:

 1type CaseInsensitive struct{}
 2
 3func (CaseInsensitive) Hash(h *maphash.Hash, s string) {
 4	h.WriteString(strings.ToLower(s))
 5}
 6func (CaseInsensitive) Equal(x, y string) bool {
 7	return strings.ToLower(x) == strings.ToLower(y)
 8}
 9
10var seed = maphash.MakeSeed()
11
12func hashOf[T any](hr maphash.Hasher[T], v T) uint64 {
13	var h maphash.Hash
14	h.SetSeed(seed)   // същият seed, иначе нищо от това няма смисъл
15	hr.Hash(&h, v)
16	return h.Sum64()
17}
18
19fmt.Println(hashOf(CaseInsensitive{}, "Go") == hashOf(CaseInsensitive{}, "GO"))
20// true

За обикновената семантика на == съществува ComparableHasher[T]:

1hashOf(maphash.ComparableHasher[int]{}, 42)

Уловката: Hasher е интерфейс, а не структура от данни. Все още няма стандартна хеш таблица или Bloom филтър, които да го консумират. Това е основа за бъдещ пакет с контейнери. Днес би го използвал, когато строиш собствена структура, или би консумирал съществуваща имплементация като go/types.Hasher (която ти позволява да използваш types.Type като ключ на map, съобразявайки се с Identical).

Управлението на seed е във вашите ръце. Ако hashOf по-горе създаваше нов seed при всяко извикване, еднаквите стойности щяха да се хешират различно и примерът щеше да отпечата false — точно бъгът, който написах при първия си опит. Един seed на контейнер. (Seed-ът е рандомизиран, за да се защити от DoS атаки чрез наводняване на хеш таблицата, затова не е просто константа.)

3.6. httptest.NewTestServer + synctest.Sleep: Удар по Sleeper-а

Ако възприемеш само едно нещо от това издание, нека бъде това.

testing/synctest излезе от експеримент в Go 1.25 и ни даде фалшив часовник за тестване на конкурентен код. Но в момента, в който тестът докоснеше реална мрежа, илюзията се разпадаше. httptest.NewTestServer в Go 1.27 използва мрежа в паметта, така че балонът остава непокътнат. А synctest.Sleep (= time.Sleep + synctest.Wait) го допълва.

Ето тест за повторен опит с експоненциално изчакване:

 1func TestRetryWithBackoff(t *testing.T) {
 2	synctest.Test(t, func(t *testing.T) {
 3		var hits int
 4		srv := httptest.NewTestServer(t, http.HandlerFunc(
 5			func(w http.ResponseWriter, r *http.Request) {
 6				hits++
 7				if hits < 3 {
 8					w.WriteHeader(http.StatusServiceUnavailable)
 9					return
10				}
11				io.WriteString(w, "ok")
12			}))
13
14		client := srv.Client()
15		start := time.Now()
16		var body string
17		for attempt := range 5 {
18			resp, err := client.Get(srv.URL)
19			if err != nil {
20				t.Fatal(err)
21			}
22			b, _ := io.ReadAll(resp.Body)
23			resp.Body.Close()
24			if resp.StatusCode == http.StatusOK {
25				body = string(b)
26				break
27			}
28			synctest.Sleep(time.Duration(1<<attempt) * time.Second)
29		}
30
31		if body != "ok" {
32			t.Fatalf("got %q", body)
33		}
34		// backoff трябва да е точно 1s + 2s = 3s
35		if elapsed := time.Since(start); elapsed != 3*time.Second {
36			t.Fatalf("elapsed = %v, want 3s", elapsed)
37		}
38		t.Logf("hits=%d elapsed=%v", hits, time.Since(start))
39	})
40}
1=== RUN   TestRetryWithBackoff
2    x_test.go:48: hits=3 elapsed=3s
3--- PASS: TestRetryWithBackoff (0.00s)

Прочети това два пъти. Симулира три секунди backoff срещу реален HTTP сървър и приключи за 0.00 секунди. А проверката е elapsed == 3*time.Second — точно три секунди, а не „поне три секунди, плюс-минус джитър на планировчика“. Фалшивите часовници не се джитърят.

synctest.Sleep съществува по конкретна причина: ако тестът ти спи за същата продължителност като тествания код, кой ще се събуди първи е загадка. synctest.Sleep спи и след това чака, докато всяка друга горутина в балона не бъде трайно блокирана, така че наблюдаваш системата след като се е уталожила.

Таймаутите, повторните опити, circuit breaker-ите, rate limiter-ите — всеки тест на HTTP клиент, зависим от времето, който притежаваш, може да стане бърз и детерминистичен. Ако тестовият ти пакет в момента се държи заедно с time.Sleep(100 * time.Millisecond) и надежда, това е твоят изход.

3.7. Промени в net/http, които всъщност засягат продукцията

Тихи, но ще се появят в метриките ти.

Телата на HTTP/1 отговорите се изпразват автоматично при Close. Непрочетеното съдържание сега се изчерпва (до консервативен лимит) при затваряне на тялото, за да може връзката да се използва повторно. Което означава, че най-накрая можеш да изтриеш това заклинание, което всеки е копирал от един и същ Stack Overflow отговор от 2016 г.:

1// вече не е необходимо
2defer func() {
3	io.Copy(io.Discard, resp.Body)
4	resp.Body.Close()
5}()

За повечето програми това е без ефект или малка печалба. Ако нещо се влошава, вероятно си в категорията, която release notes деликатно описват: Transport.MaxIdleConns, зададен на 0, или нов Client за всяка заявка, които заобикалят напълно лимита на неактивните връзки. Transport.DisableKeepAlives = true ще го скрие, но действителният съвет в release notes е, че „по-дълбок поглед вероятно би бил полезен“, което на езика на екипа на Go означава имаш по-големи проблеми.

Приоритет на клиента в HTTP/2 (RFC 9218). Сървърът вече зачита сигналите за приоритет от клиента. Ако предпочиташ старото round-robin планиране, Server.DisableClientPriority = true.

Server.MaxHeaderValueCount. Ограничава колко стойности на заглавни полета сървърът ще приеме, по подразбиране DefaultMaxHeaderValueCount. Още една врата, затворена за „изпрати десет хиляди заглавия и виж какво се случва“.

ALPN при потребителски предоставени връзки. Ако твоят net.Conn имплементира ConnectionState() tls.ConnectionState, Transport и Server ще извършат TLS ALPN договаряне върху него — така че персонализираните dialer-и, минаващи през прокси, пак могат да договорят HTTP/2.

3.8. Дребните неща, които носят радост

strings.CutLast / bytes.CutLast. Cut разделя при първия разделител. Нямаше вариант „последен“, затова всички ръчно писаха LastIndex плюс нарязване, и около 30% от нас сгрешиха с едно на първия опит.

1name, ext, ok := strings.CutLast("archive.tar.gz", ".")
2// archive.tar gz true

Чудесно за файлови разширения и за парсване на host:port — където трябва да намериш последното двоеточие, защото IPv6 адресите са пълни с тях.

math/big.Int.Divide. Деление с явен режим на закръгляне, заменящо залагането „Quo или Div ли искам?“:

1x, y := big.NewInt(-7), big.NewInt(2)
2
3new(big.Int).Divide(x, y, new(big.Int), big.Trunc)  // q=-3 r=-1
4new(big.Int).Divide(x, y, new(big.Int), big.Floor)  // q=-4 r=1
5new(big.Int).Divide(x, y, new(big.Int), big.Round)  // q=-4 r=1
6new(big.Int).Divide(x, y, new(big.Int), big.Ceil)   // q=-3 r=-1

Ако работиш в област, където правилото за закръгляне е записано в регламент, това е за теб.

url.URL.Clone и url.Values.Clone. Дълбоки копия. Старото плитко копие *u споделяше указателя Userinfo, което породи точно вида бъг, който отнема ден за откриване и пет секунди за поправяне.

1orig, _ := url.Parse("https://gosuda.org/blog?tag=go&tag=1.27")
2clone := orig.Clone()
3clone.Host = "example.com"
4fmt.Println(orig.Host, clone.Host)  // gosuda.org example.com

Методите за четене на net.UnixConn вече връщат io.EOF директно, вместо да го обвиват в net.OpError. err == io.EOF работи сега, както винаги трябваше.

database/sql.ConvertAssign и driver.RowsColumnScanner. Функции за автори на драйвери. Първата излага преобразуванията на типове, които Rows.Scan извършва; втората позволява на драйверите да сканират директно в потребителски местоназначения, пропускайки междинна алокация.

unicode 15 → 17. Две версии с един скок. Поведението при класификация и нормализация на низове може да се промени фино. Ако имаш тестове, които зависят от това, ще разбереш.

compress/flate стана по-бърз — и байтовете на изхода му може да се различават от Go 1.26. Това се разпространява към archive/zip, compress/gzip, compress/zlib и image/png. Ако имаш golden-file тестове, които хешират компресиран изход, те ще се счупят и това няма да е регресия. Провери това преди да надградиш, а не по време на разбора на инцидента.

4. Инструментариум

4.1. go fix получи още модернизатори

go fix тихо се превръща в инструмент за модернизиране на кода. Go 1.27 добавя atomictypes, embedlit, slicesbackward и unsafefuncs. Използвай -diff, за да видиш предварително:

 1package fixdemo
 2
 3import (
 4	"sync"
 5	"sync/atomic"
 6)
 7
 8type Base struct{ ID int }
 9type User struct {
10	Base
11	Name string
12}
13
14func New() User {
15	return User{Base: Base{ID: 1}, Name: "gopher"}
16}
17
18var counter int64
19
20func Incr() { atomic.AddInt64(&counter, 1) }
21
22func Reverse(s []string) {
23	for i := len(s) - 1; i >= 0; i-- {
24		_ = s[i]
25	}
26}
27
28func Spawn(wg *sync.WaitGroup) {
29	wg.Add(1)
30	go func() {
31		defer wg.Done()
32	}()
33}
1$ go1.27rc3 fix -diff ./fixdemo
 1 import (
 2+	"slices"
 3 	"sync"
 4 	"sync/atomic"
 5 )
 6
 7 func New() User {
 8-	return User{Base: Base{ID: 1}, Name: "gopher"}
 9+	return User{ID: 1, Name: "gopher"}
10 }
11
12-var counter int64
13+var counter atomic.Int64
14
15-func Incr() { atomic.AddInt64(&counter, 1) }
16+func Incr() { counter.Add(1) }
17
18 func Reverse(s []string) {
19-	for i := len(s) - 1; i >= 0; i-- {
20-		_ = s[i]
21+	for _, v := range slices.Backward(s) {
22+		_ = v
23 	}
24 }
25
26 func Spawn(wg *sync.WaitGroup) {
27-	wg.Add(1)
28-	go func() {
29-		defer wg.Done()
30-	}()
31+	wg.Go(func() {
32+	})
33 }

Четири модернизатора се задействаха върху един малък файл:

  • embedlit — пренаписва вградените литерали, използвайки новия синтаксис от §1.2
  • atomictypes — atomic.AddInt64(&x, 1) става atomic.Int64.Add(1), което прави неатомарния достъп невъзможен и тихо оправя бъговете с 32-битовото подравняване, за които не си знаел
  • slicesbackward — обратните цикли стават slices.Backward
  • waitgroupgo — ритуалът Add/go/Done става wg.Go (преименуван от waitgroup в 1.26, за да се избегне двусмисленост)

go tool fix help изброява всички 26; go tool fix help <name> обяснява един. Освен това fmtappendf беше премахнат „поради стилистични съображения“, което е красиво дипломатичен начин да се опише случилото се в тази нишка на issue.

Пускането на go fix ./... върху стара кодова база е наистина удовлетворяващ следобед. Очевидно първо прочети diff-а.

4.2. go test по подразбиране изпълнява stdversion

Това е промяната, която най-вероятно ще прекъсне деня ти.

go test сега по подразбиране изпълнява проверката stdversion на vet, която маркира символи от стандартната библиотека, по-нови от това, което позволява директивата go в твоя go.mod:

1// go.mod казва: go 1.24
2package sv
3
4import "strings"
5
6func Ext(name string) string {
7	_, ext, _ := strings.CutLast(name, ".")  // CutLast е символ от 1.27
8	return ext
9}
1$ go1.27rc3 test ./...
2# sv
3./x.go:6:23: strings.CutLast requires go1.27 or later (module is go1.24)
4FAIL	sv [build failed]

Това слага край на класическия провал, при който всичко работи на твоята машина (най-новият toolchain) и се взривява в CI или в по-старата среда на потребител. Ако публикуваш библиотеки с консервативна директива go, това е подарък.

4.3. go test -json получи OutputType

Редовете "Action":"output" вече носят незадължително поле "OutputType": "error", "error-continue" или "frame".

1'frame'  '=== RUN   TestFail\n'
2None     '    x_test.go:6: hello\n'
3'error'  '    x_test.go:7: boom\n'
4'frame'  '--- FAIL: TestFail (0.00s)\n'
5'frame'  'FAIL\n'

Изходът от t.Log (без поле), изходът от t.Error (error) и редовете, генерирани от фреймуърка (frame), вече са различими. Всеки, който е писал регулярен израз, за да разбере кои редове от тестовия изход са „истински“, вече може да го изтрие.

4.4. Подобрения на go doc

Синтаксис package@version. Чети документацията за конкретна версия, без да я добавяш към твоя модул:

1$ go1.27rc3 doc golang.org/x/sync/errgroup@v0.10.0
2package errgroup // import "golang.org/x/sync/errgroup"
3...

Удобно за проверка на промените преди надграждане.

-ex и извеждане на изходния код на примерите:

 1$ go1.27rc3 doc -ex strings | grep Example
 2    func ExampleClone()
 3    func ExampleCompare()
 4    func ExampleContains()
 5    func ExampleCut()
 6    func ExampleCutPrefix()
 7    ...
 8
 9$ go1.27rc3 doc strings.ExampleCut
10package main
11
12import (
13	"fmt"
14	"strings"
15)
16
17func main() {
18	show := func(s, sep string) {
19		before, after, found := strings.Cut(s, sep)
20		fmt.Printf("Cut(%q, %q) = %q, %q, %v\n", s, sep, before, after, found)
21	}
22	show("Gopher", "Go")
23	...
24}
25
26Output:
27Cut("Gopher", "Go") = "", "pher", true

Изходният код на примера и очакваният изход, в терминала, без да отваряш браузърен таб, който ще е отворен и следващия четвъртък.

4.5. go mod tidy почиства блоковете require

За модули с go 1.27 или по-нова версия, go mod tidy слива разпръснатите блокове require в най-много два (директни и индиректни), като запазва прикачените коментари.

 1// Преди
 2module tidydemo
 3
 4go 1.27
 5
 6require golang.org/x/sync v0.10.0
 7
 8// networking
 9require golang.org/x/net v0.33.0
10
11require (
12	golang.org/x/sys v0.28.0 // indirect
13)
14
15require golang.org/x/text v0.21.0 // indirect
 1// След: go mod tidy
 2module tidydemo
 3
 4go 1.27
 5
 6require (
 7	// networking
 8	golang.org/x/net v0.33.0
 9	golang.org/x/sync v0.10.0
10)

Забележи, че коментарът // networking е запазен. Това основно почиства след разрешаване на конфликти при Git merge, където се раждат разпилените блокове require. Ако екипът ти е повече от трима души, които добавят зависимости, конфликтите в go.mod ще станат забележимо по-тихи.

4.6. Разни

  • Поддръжката на bzr е премахната. Ако това те засяга, наистина бих искал да чуя историята.
  • go tool trace -http се свързва към localhost, когато е зададен само порт. -http=:6060 вече не слуша на всички интерфейси. Използвай -http=0.0.0.0:6060, ако си го имал предвид. Това вече съвпада с go tool pprof и предотвратява случайния публичен профайлър.
  • Файлове с отговори (@file) се поддържат от compile, link, asm, cgo, cover и pack, в формат, съвместим с GCC. За билд системи, които надхвърлят лимитите на дължината на командния ред — здравей, Bazel.

5. Компилатор, линкер, платформи

Компилатор. Относителните имена на файлове в директивите //line вече се разрешават спрямо директорията на съдържащия файл, съвпадайки с go/scanner. Важно, ако пишеш генератори на код.

Имената на символи за функционални литерали (closures) също са по-прости — същото име независимо от инлайнването, а няколко инстанции на един и същ литерал може да споделят код в бинарния файл. Без функционална промяна, освен: код, който сравнява идентичността на функции чрез reflect.Value.Pointer, ще вижда „равно“ по-често от преди. Това сравнение никога не е било валидно, но ако го имаш, сега е моментът, в който ще те лъже по-силно.

Линкер. Новите опции -macos и -macsdk задават версиите на ОС и SDK в командата за зареждане LC_BUILD_VERSION на macOS.

Платформи.

  • Darwin вече изисква macOS 13 Ventura или по-нова, както беше обявено в 1.26. Провери CI runner-ите си.
  • PowerPC (GOOS=linux GOARCH=ppc64) премина към ABI ELFv2. Изисква Linux ядро 3.13+ (RHEL7 с backport към 3.10). Cgo, PIE и външното свързване вече се поддържат. Ако използваш cgo, но се нуждаеш от статичен чисто Go бинарен файл, задай CGO_ENABLED=0.

6. Контролен списък за надграждане

Go 1.27 взема съвместимостта насериозно, но провери тези неща, преди да вдигнеш версията:

  • Golden-file тестове върху компресиран изход. compress/flate промени енкодерите; байтовете на gzip/zip/png може да се различават.
  • Тестове, които съпоставят низове със съобщения за грешки в JSON. Поведението е идентично; текстът на грешката не е.
  • Премахнати GODEBUG в go.mod. asynctimerchan, gotypesalias, tlsrsakex, tls3des, tls10server, tlsunsafeekm, x509keypairleaf — тези чупят билда само ако са зададени на старите си стойности.
  • Нарушения на stdversion. go test ги хваща сега. Пусни го рано, ако поддържаш библиотека с консервативна директива go.
  • CI runner-и с macOS 12 или по-стар. Поддръжката е прекратена.
  • Тестове, които проверяват имената на символите на функционални литерали, и сравнения на функции чрез reflect.Value.Pointer.
  • Transport.MaxIdleConns = 0 или Client за всяка заявка. Заедно с автоматичното изпразване на телата на отговорите това може да забави нещата.

Заключителни мисли

Go 1.27 е изданието, в което куп отложена поддръжка дойде на кредит наведнъж.

Генеричните методи бяха липсващото парче от работата по генериците от 1.18. Селекторите на полета в литерали на структури затвориха единайсетгодишен issue. encoding/json/v2 беше две години обсъждане на предложение. А uuid запълни дупка, която почти всеки Go проект беше кръпкал със същата външна зависимост в продължение на десетилетие.

Ако искаш приоритетен ред за реално извличане на полза от това:

  1. httptest.NewTestServer + synctest.Sleep — тестовете на HTTP, зависими от времето, стават бързи и детерминистични. Най-добро съотношение усилие към полза в цялото издание.
  2. Профилът за течове на горутини — изложи един endpoint в staging и се подгответе да се смирите.
  3. Етикети на горутините в traceback-овете — три реда pprof.Do във входната точка на заявките превръщат следващия ти stack dump в 3 през нощта в нещо четимо.
  4. go fix ./... — остави инструмента да извърши модернизирането.
  5. encoding/json/v2 — без бързане. Възприемай го по една опция наведнъж.

Вземи go1.27rc3 и пусни тестовия си пакет срещу него още сега. Всичко в този контролен списък е значително по-приятно да се открие във вторник следобед, отколкото по време на инцидент.

Препратки