Съдържание на курса

Урок 6 — Конкурентност

От Dorian Chávez · основател на Hábil и архитект на интеграции ·

Продължителност: 2 сесии по 60-90 минути. Това е причината Go да съществува и единственият урок в курса, който се преподава на няколко минавания: goroutines, после канали и WaitGroup, после context и ограничението на паралелизма, приложено към revisor. С едно минаване не се получава — потвърждава го същият ред, който използва курсът по Go с най-много учещи в света.

Като приключиш, ще можеш:

  • Да пуснеш goroutine и да обясниш, с истинска проба, защо програмата може да приключи, преди тя да е приключила.
  • Да използваш sync.WaitGroup, за да изчакаш група goroutines, и да разпознаеш точното съобщение, което Go дава, когато Add/Done не съвпадат.
  • Да обясниш защо revisor разпределя резултатите по канал, вместо да пише в споделен map, и сам да предизвикаш грешката, която би се случила, ако не го правеше.
  • Да четеш, дословно, изхода на go test -race, когато открие истинско състезание за данни (data race).
  • Да използваш context.WithTimeout, за да сложиш времево ограничение на една операция, и да обясниш защо defer cancelar() не е по избор.
  • Да ограничиш колко goroutines се изпълняват едновременно със семафор от канал и да измериш, че ограничението наистина се спазва.

Защо е важно

До урок 5 revisor проверява услугите една по една: ако имаш десет услуги и всяка отговаря за една секунда, целият отчет отнема десет секунди, какъвто и да е редът. Със сто услуги — почти две минути. А времето за чакане не го решаваш ти: решава го най-бавната услуга в списъка, умножено по броя им.

Не е задължително да е така, защото проверката на услуга е чакане, а не работа: почти през цялото време на това чакане процесорът не прави нищо, само чака мрежов отговор. Go е проектиран от хора, които в Google прекарваха дните си в чакане точно на това — мрежови отговори между хиляди услуги — и затова конкурентността не е библиотека, добавена по-късно: тя е част от езика още от първия ред (go, запазена дума, а не функция от библиотека).

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

🔑 И предупреждението, което прави този урок различен от предишните: в уроци 2 до 5 една програма, която се компилира и чиито тестове минават, почти винаги е наред. В конкурентността — не. Програма със състезание за данни може да се компилира, да се изпълнява, да минава тестовете си деветдесет и девет пъти и да се провали на стотния — или никога да не се провали на твоята машина и да се проваля всеки ден в продукция, с повече ядра и повече натоварване. Грешките в този урок са онези, които не се виждат с просто око, и затова точка 4 от този урок — истинските грешки, предизвикани и уловени — е най-важната в целия курс.


Понятията

6.1 Goroutines: пускането е тривиално, чакането — не

Пускането на goroutine е една дума:

go revisar(s)

И толкова. Това стига, за да може revisar(s) да се изпълнява конкурентно с останалата част от програмата, без да се чака да приключи, за да се продължи със следващия ред. Всяка струва приблизително 2 KB памет в началото (растат, ако е нужно), а не мегабайтите на нишка на операционната система — затова програма на Go може да има стотици хиляди живи goroutines, без да планира нищо специално, нещо, което би било немислимо с нишки на системата.

Но погледни тази програма, с най-честия бъг от първия ден на конкурентността в Go (пълна в programas/revisor/ejemplos/06-goroutines-sin-esperar/main.go):

func main() {
	servicios := []string{"catalogo", "pagos", "inventario"}
	for _, s := range servicios {
		go fmt.Println("revisando", s)
	}
	// el programa termina AQUÍ, sin haber esperado a ninguna goroutine
}

Пуснах я 260 пъти на тази машина, за да измеря колко често СЕ ВИЖДА проблемът, а не да го гадая: 40 с go run, още 20, налагайки един-единствен логически процесор (GOMAXPROCS=1, за да отнема на програмата всякакъв шанс някоя goroutine да успее да се изпълни на друго ядро, докато main приключва), и 200 с вече компилирания двоичен файл (go build + ./gor61, за да махна от пътя времето, което отнема компилирането). Резултатът:

$ go run main.go
$ go run main.go
$ go run main.go

Празно. И трите, и практически всичките 260 (само едно от 200-те пускания на компилирания двоичен файл отпечата нещо — останалите нищо).

🔴 И това число трябва да се чете много внимателно, защото е лесно да се стигне до грешното заключение. Дефектът не присъства „1 от всеки 260 пъти“: той присъства всичките 260 от 260, без изключение. В нито едно от 260-те пускания програмата не изчака своите goroutines — дори в единственото, което отпечата нещо. Онова, което се променя между пусканията, не е дали програмата чака (никога не го прави): а дали, по чист късмет в начина, по който операционната система разпределя времето между процесите, някоя goroutine успява да изпълни fmt.Println в процепа от време между момента, в който е пусната, и момента, в който main убива цялата програма. Този процеп почти никога не стига — затова почти никога не се вижда нищо — но програмата е също толкова счупена в 259-те тихи пускания, колкото и в онова, което отпечата.

Ето какво трябва да запомниш от това: винаги счупено, почти никога видимо. Не е "има малка вероятност да се провали" — да го прочетеш така е точно грешката, която трябва да се избегне: програмата никога не прави правилното и в повечето случаи симптомът не успява да се покаже навреме, за да го забележиш. Така този вид бъг се промъква: минава прегледа на кода (компилира се, изпълнява се, не гърми), минава ръчните тестове (кой пуска програма 260 пъти, за да ѝ се довери?), минава CI (който пуска веднъж, може би два пъти) — и предупреждава едва когато вече е в продукция, с повече натоварване, повече ядра и процеп от време, който един ден все пак успява да се отвори, в най-лошия възможен момент да го откриеш.

⚠️ Не го приемай и като "това никога не отпечатва нищо, на никоя машина". С повече натоварване, друга операционна система или просто лош късмет процепът може да се отваря по-често — всъщност се отвори веднъж в същите тези 260 пускания. Онова, което не се променя между машините, е, че програмата никога не чака: това е структурно, не зависи от късмета. Пусни сам експеримента на своя компютър, с достатъчно повторения, за да означава числото нещо, и сравни.

Това не е периодичен бъг на програмата: работата е там, че main приключва веднага щом стигне края на тялото си, без да я е грижа дали още има работещи goroutines — а когато main приключи, цялата програма приключва с нея, включително живите goroutines, без предупреждение и без грешка. Пускането на goroutine е лесно; истинската работа е да се увериш, че програмата ги изчаква да приключат, преди да продължи.

6.2 sync.WaitGroup: правилният начин да се чака

var wg sync.WaitGroup
for _, s := range servicios {
	wg.Add(1)
	go func() {
		defer wg.Done()
		fmt.Println("revisando", s)
	}()
}
wg.Wait() // aquí sí espera a que las tres hayan llamado Done

Шаблонът е винаги един и същ и си струва да се запомни в този ред:

  1. wg.Add(1) преди да пуснеш goroutine, а не вътре в нея. Ако го сложиш вътре, Wait() може да се изпълни, преди goroutine да е успяла да направи своя Add, и тогава не брои това чакане.
  2. defer wg.Done() като първи ред на goroutine. defer гарантира, че ще се изпълни, каквото и да стане вътре — дори ако goroutine предизвика panic — а поставянето му първо предпазва от това ранен return по средата на кода да забрави да го отчете.
  3. wg.Wait() там, където наистина ти трябва резултатът, обикновено точно преди да използваш онова, което goroutines са създали.

Какво става, ако пропуснеш стъпка 1 — липсващ Add —, предизвикано нарочно (пълна в programas/revisor/ejemplos/06-waitgroup-sin-add/main.go):

var wg sync.WaitGroup
for i := 0; i < 5; i++ {
	go func(n int) { // <- sin wg.Add(1) antes de esta línea
		defer wg.Done()
		time.Sleep(10 * time.Millisecond)
	}(i)
}
wg.Wait()
fmt.Println("listo")

Пуснах я шест пъти поред, с -race, за да видиш какво наистина се случва, а не само крайната грешка:

$ go run -race sinesperar.go
listo
panic: sync: negative WaitGroup counter
...
exit status 2

$ go run -race sinesperar.go
listo
panic: sync: negative WaitGroup counter
...
exit status 2

(cuatro corridas más, idénticas: "listo" primero, el panic después — 6 de 6, CON -race)

🔑 Прочети това внимателно, защото е най-подвеждащата грешка в урока: програмата отпечатва listo ПРЕДИ да гръмне, и шестте пъти. Това не е случайност на това пускане: wg.Wait() не чака нищо, защото броячът никога не е бил увеличен (никога не е имало Add(1)), така че Wait() се връща незабавно, main отпечатва listo, сякаш всичко е минало добре, и едва тогава една от изоставащите goroutines — която наистина се е изпълнявала, само че Go никога не я е изчакал — приключва своя Sleep и извиква Done(), като изважда единица от брояч, който вече е бил на нула. Panic не е първото, което виждаш: той е последното, след като програмата вече ти е казала, че всичко е наред.

⚠️ И тук идва частта, която измерих погрешно първия път, и си струва поправката да остане записана: без -race същата тази програма никога не гърми. Същите шест пускания, без -race:

$ go run sinesperar.go
listo

$ go run sinesperar.go
listo

(cuatro corridas más, idénticas: "listo" y nada más — 6 de 6, SIN -race, saliendo con código 0)

time.Sleep(10 * time.Millisecond) не е онова, което КАРА panic да се появи — то е онова, което го ПРЕДОТВРАТЯВА, без -race. main стига до wg.Wait(), който се връща незабавно, отпечатва listo и цялата програма приключва много преди да изтекат 10-те милисекунди — така че нито една от петте изоставащи goroutines не успява дори да се събуди от своя Sleep и да извика Done(). Програмата изглежда, буквално, "приключила успешно": код на изход 0, без никаква следа, че нещо е било сглобено погрешно. Онова, което наистина кара panic да се появява последователно, е инструментирането на -race: наблюдението на всеки достъп до паметта, за да се открият състезания, кара програмата да работи забележимо по-бавно, и тази допълнителна бавност е точно онова, което дава време на някоя изоставаща goroutine да завърши своя Sleep и да извика Done(), преди процесът да приключи.

Това е точно видът грешка, която не се проявява винаги, и затова е най-ценната в този урок — и сега с правилните данни: току-що измери, че дори не е нужна "по-кратка работа", за да остане незабелязана. Пусната както си е, БЕЗ -race, тя вече остава незабелязана 6 от 6 пъти. Учещ, който пусне този пример без -race — очевидното, ако никой не го предупреди — ще види listo и нищо повече и ще заключи, с пълно право, че програмата работи. Не работи: Add(1) продължава да липсва по абсолютно същия начин. Единственото, което се е променило, е дали нещо е успяло да се покаже навреме, за да го издаде — същият шаблон "винаги счупено, почти никога видимо" от раздел 6.1, тук с различен участник: не е натоварването на машината, а дали си пуснал с включен детектор на състезания (race detector), или не.

Done() изважда единица от вътрешния брояч на WaitGroup. Ако никога не си направил Add(1), броячът започва от нула и изваждането на единица го прави отрицателен — и Go, вместо да го пропусне тихо, предизвиква panic със съобщение, което казва точно какво не е наред: negative WaitGroup counter. Това дословно съобщение е твоята следа: ако го видиш, почти винаги липсва Add(1) някъде или има повече Done(), отколкото би трябвало. Но истинската поука не е съобщението на panic — а това, че програмата вече е казала listo, преди то да се появи.

6.3 Защо revisor не споделя map: канали

Девизът на езика, и си струва да го запомниш дословно: „не общувай, като споделяш памет; споделяй памет, като общуваш“. Вместо всяка goroutine да пише резултата си в споделен map или slice — което би изисквало всеки достъп да се защити със заключване (lock) —, всяка изпраща резултата си по канал, и една-единствена goroutine (или основният код) ги събира от другата страна.

ch := make(chan servicio.Estado)         // sin buffer: cada envío espera a que alguien reciba
ch := make(chan servicio.Estado, 10)     // con buffer: hasta 10 caben sin esperar a que nadie reciba

ch <- estado        // enviar
e := <-ch           // recibir
close(ch)            // cerrar: después de cerrado, recibir sigue funcionando hasta vaciarlo
for e := range ch { ... }   // recibe hasta que el canal se cierre Y se vacíe

Проверих какво става, ако вместо канал използвам споделен slice без защита, точно грешката, която каналите предотвратяват. Тази програма пуска хиляда goroutines, които увеличават една и съща променлива (пълна в programas/revisor/ejemplos/06-carrera-de-datos/main.go):

contador := 0
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
	wg.Add(1)
	go func() {
		defer wg.Done()
		contador++ // dos goroutines pueden leer el mismo valor antes de que la otra escriba
	}()
}
wg.Wait()
fmt.Println("contador:", contador)

Без детектора на състезания програмата не гърми — само дава грешен резултат, и различен всеки път:

$ go run carrera.go
contador: 956

956, а не 1000. Никаква грешка, никакъв panic, никакъв знак, че нещо се е объркало — само число, по-малко от онова, което би трябвало да бъде, защото някои от хилядата събирания са се загубили, когато две goroutines са прочели contador едновременно, и двете са добавили единица към една и съща стара стойност, и едно от двете записвания е презаписало другото, без никой да разбере. Това е най-опасният бъг на конкурентността: не се проваля, дава правдоподобен и грешен резултат. Такава програма може да мине преглед, да мине повърхностни тестове и да се проваля в продукция спорадично в продължение на месеци, преди някой да забележи.

6.4 go test -race: как детекторът намира бъга

Същата програма, пусната с -race, наистина го издава — с точни доказателства къде:

$ go run -race carrera.go
==================
WARNING: DATA RACE
Read at 0x00c00013c018 by goroutine 11:
  main.main.func1()
      carrera.go:15 +0x68

Previous write at 0x00c00013c018 by goroutine 8:
  main.main.func1()
      carrera.go:15 +0x78

Goroutine 11 (running) created at:
  main.main()
      carrera.go:13 +0x6c

Goroutine 8 (finished) created at:
  main.main()
      carrera.go:13 +0x6c
==================
contador: 847
Found 2 data race(s)
exit status 66

Прочети го отгоре надолу, защото всеки блок отговаря на различен въпрос:

  • Read at ... by goroutine 11 и Previous write at ... by goroutine 8: две различни goroutines са докоснали един и същ адрес в паметта (0x00c00013c018, променливата contador), едната чете, а другата пише, без какъвто и да е механизъм, който да гарантира, че едната чака другата.
  • carrera.go:15 и в двете: точният ред от кода, където се е случило (contador++), един и същ ред за двете, защото това е единственият ред, който докосва тази променлива.
  • Goroutine 11 (running) created at ... carrera.go:13: откъде е дошла тази goroutine — редът на go func() {...}() вътре във for —, за да можеш да проследиш кое пускане е било.
  • Found 2 data race(s) и exit status 66: детекторът не спира на първото състезание, което намери; продължава да работи и ги докладва всичките, а програмата завършва с код на изход, различен от 0 и от 1 (66 е кодът, който Go запазва за това), за да може pipeline за непрекъсната интеграция да го различи от обикновен провал.

А крайното число, contador: 847, се промени спрямо пускането без -race (956). Не по случайност: -race кара програмата да работи по-бавно и с повече инструментиране, което променя точния ред, в който goroutines се преплитат — още една причина този бъг да е толкова коварен: грешното число дори не е едно и също грешно число всеки път.

🔴 Правилото, което следва от това, без изключение: конкурентна програма, която минава тестовете си без -race, не е тествана. Пускай -race още от първия тест на конкурентност, който напишеш, а не когато "нещо изглежда странно" — защото, както току-що видя, нищо не изглежда странно, докато не стане твърде късно.

6.5 Другите два начина да се провалиш: panic и взаимно блокиране (deadlock)

Panic, ако затвориш канал неправилно:

  • Изпращането към вече затворен канал предизвиква panic: panic: send on closed channel.
  • Затварянето на канал два пъти предизвиква panic: panic: close of closed channel.

Правилото, което предотвратява и двете: канала го затваря онзи, който изпраща, никога онзи, който получава, и го затваря само веднъж, обикновено от отделна goroutine, която знае кога вече няма да има повече изпращания (ще го видиш в раздел 6.7, с wg.Wait(), последвано от close).

Взаимно блокиране (deadlock), ако никой от другата страна не слуша. Предизвиках го с възможно най-кратката програма (пълна в programas/revisor/ejemplos/06-deadlock/main.go):

func main() {
	canal := make(chan int)
	canal <- 1 // nadie del otro lado está leyendo: se bloquea
	fmt.Println(<-canal)
}
$ go run candado.go
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan send]:
main.main()
	candado.go:7 +0x38
exit status 2

Това поправя нещо, което много обяснения приемат за даденост: deadlock в Go не винаги "увисва завинаги". Самият runtime на Go има детектор на deadlocks: ако в някой момент всички goroutines на програмата спят в очакване на нещо, което никога няма да се случи, Go го забелязва и убива програмата с fatal error: all goroutines are asleep - deadlock!, с точния ред, където е заседнала ([chan send] в този случай, защото е изпращала). Ако програмата ти изглежда "увиснала завинаги", вместо да приключи с тази грешка, почти сигурно не е истински deadlock: по-вероятно е да имаш поне една жива goroutine, която прави нещо друго (например таймер или HTTP сървър, който слуша), и тя пречи на runtime да обяви, че "всички" спят.

6.6 context: как се отменя и как се слага времево ограничение

ctx, cancelar := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelar() // SIEMPRE, incluso si terminas antes de que se cumplan los 5 segundos

req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := http.DefaultClient.Do(req) // se aborta solo si pasan los 5 s

context е стандартният подход в Go за две неща: да се сложи времево ограничение на операция, която може да отнеме твърде дълго, и да се разпространи отмяна надолу (ако горният бъде отменен, всичко, което зависи от него, също се отменя, без всяка функция да трябва да преоткрива собствен механизъм).

⚠️ defer cancelar() не е по избор, дори когато операцията вече е приключила сама. context.WithTimeout пуска вътрешен таймер; ако никога не извикаш функцията за отмяна, този таймер остава жив, докато изтече първоначалният срок, като задържа памет през цялото това време — а ако програмата ти създава такива контексти постоянно (по един за всяка проверена услуга, като revisor), без defer cancelar() натрупваш течове на памет, пропорционални на броя на направените проверки. Това е най-честото изтичане в програмите на Go, които използват context, и затова си струва да напишеш defer на същия ред, на който създаваш контекста, преди да напишеш каквото и да е друго.

6.7 Todos: функцията, която събира трите части

Ето как наистина изглежда централната функция на revisor (programas/revisor/internal/revisar/todos.go), след като са събрани goroutines, канали, семафор и context:

func Todos(ctx context.Context, r Revisor, servicios []servicio.Servicio, paralelo int) []servicio.Estado {
	if len(servicios) == 0 {
		return nil
	}
	if paralelo <= 0 {
		paralelo = ParaleloPorOmision
	}

	resultados := make(chan servicio.Estado, len(servicios))
	semaforo := make(chan struct{}, paralelo)

	var wg sync.WaitGroup
	for _, s := range servicios {
		wg.Add(1)
		go func() {
			defer wg.Done()

			semaforo <- struct{}{}        // pide turno; se bloquea si ya hay «paralelo» corriendo
			defer func() { <-semaforo }() // devuelve el turno pase lo que pase

			// Cada servicio tiene su propio tiempo límite, hijo del general. Si
			// el de arriba se cancela, este muere con él.
			propio, cancelar := context.WithTimeout(ctx, s.TimeoutEfectivo())
			defer cancelar() // sin esto, el temporizador no se libera: es la fuga más común de Go

			resultados <- r.Revisar(propio, s)
		}()
	}

	// Quien envía cierra, nunca quien recibe. Y se cierra desde otra goroutine
	// porque Wait tiene que poder esperar mientras el bucle de abajo ya está
	// recibiendo: si cerráramos aquí mismo, con el canal lleno nos trabaríamos.
	go func() {
		wg.Wait()
		close(resultados)
	}()

	estados := make([]servicio.Estado, 0, len(servicios))
	for e := range resultados {
		estados = append(estados, e)
	}
	return estados
}

Прочети я с още пресни предишните раздели, защото всяка част отговаря на проблем, който вече видя:

  • Една goroutine на услуга (6.1), отчитана с wg.Add(1)/defer wg.Done() (6.2), за да знае програмата кога са приключили всички.
  • Канал resultados с буфер (6.3) събира състоянията: никой не пише в споделен slice или map, така че не е нужно никакво заключване за тази част.
  • semaforo := make(chan struct{}, paralelo) е канал, използван като квота: има място за paralelo стойности, така че paralelo + 1-вата goroutine, която опита да пише в него (semaforo <- struct{}{}), се блокира, докато друга не освободи мястото си (<-semaforo, в defer). Това е същият канал с буфер от раздел 6.3, използван не за пренасяне на данни, а за водене на сметка колко "реда" остават.
  • Собствен context.WithTimeout за всяка услуга (6.6), дете на общия ctx: ако горният бъде отменен (например ако изтече общото време на отчета), всички деца се отменят заедно с него.
  • close(resultados) от ДРУГА goroutine, след wg.Wait() — и това заслужава обяснение, защото е частта, която се вижда най-трудно с просто око: ако затворим канала в същата goroutine, която прави for e := range resultados по-долу, щяхме да заседнем, защото Wait() има нужда всички goroutines да четат от канала, за да се освободи място и да могат да върнат реда си, но цикълът за четене никога нямаше да тръгне, защото първо щяхме да чакаме Wait(). Като се пусне отделно, затварянето и четенето се случват едновременно, а не едно след друго.

6.8 Тестване без мрежа: Falso и измереното ограничение на паралелизма

Тестването на Todos срещу истински услуги би било бавно и недетерминирано. revisor използва фалшив Revisor (programas/revisor/internal/revisar/falso.go), който симулира отговори, забавяния и дори услуги, които никога не отговарят, всичко в паметта:

type Falso struct {
	Respuestas map[string]RespuestaFalsa
	mu         sync.Mutex
	Contador   int
}

func (f *Falso) Revisar(ctx context.Context, s servicio.Servicio) servicio.Estado {
	f.mu.Lock()
	f.Contador++
	f.mu.Unlock()
	// ...
}

🔒 Тук наистина е нужен mutex и това е изключението от правилото "предпочитай канали" от раздел 6.3. Contador е споделено цяло число, което много goroutines увеличават едновременно по време на тестовете (Falso.Revisar се извиква конкурентно, по веднъж за всяка услуга, която Todos проверява). Канал би свършил работа, за да се изпращат резултати, но за прост споделен брояч sync.Mutex около единствения ред, който го докосва, е по-просто и по-ясно. Правилото не е "никога не използвай mutex": то е "преди да използваш такъв, запитай се дали канал изразява по-добре онова, което правиш" — и за изпращане на резултати почти винаги да; за споделен брояч почти винаги mutex е правилният инструмент.

С Falso този тест измерва нещо, което иначе би било почти невъзможно да се провери уверено: че семафорът от раздел 6.7 наистина ограничава колко проверки се изпълняват едновременно, а не само че "работи като цяло" (пълна версия, без съкращения, в programas/revisor/internal/revisar/todos_test.go):

func TestTodos_elParaleloLimitaCuantasCorrenALaVez(t *testing.T) {
	const totalServicios = 20
	const limite = 3
	var enVuelo, maximoObservado int32
	// medidorDeConcurrencia cuenta, con un contador atómico, cuántas llamadas
	// a Revisar están abiertas AL MISMO TIEMPO, y se queda con el máximo.
	medidor := &medidorDeConcurrencia{enVuelo: &enVuelo, maximoObservado: &maximoObservado, espera: 15 * time.Millisecond}
	servicios := make([]servicio.Servicio, totalServicios)
	// ... llena servicios ...
	Todos(context.Background(), medidor, servicios, limite)
	if max := atomic.LoadInt32(&maximoObservado); max > int32(limite) {
		t.Errorf("se observaron %d consultas simultáneas; el límite era %d", max, limite)
	}
}

Истинско пускане, с активен детектор на състезания (за да се потвърди, че дори самото измерване не внася състезание):

$ go test ./internal/revisar/... -race -v -run TestTodos_elParaleloLimitaCuantasCorrenALaVez
=== RUN   TestTodos_elParaleloLimitaCuantasCorrenALaVez
--- PASS: TestTodos_elParaleloLimitaCuantasCorrenALaVez (0.11s)
PASS
ok  	github.com/habil/revisor/internal/revisar	1.435s

20 услуги, ограничение 3, и тестът потвърждава, че никога не е имало повече от 3 извиквания на Revisar, изпълнявани едновременно — не защото го приемаме от кода, а защото атомарен брояч го е измерил, докато е работел.

6.9 Времето, измерено: последователно срещу паралелно

С Falso, настроен да симулира истински забавяния, тестът TestTodos_respetaElTimeoutPorServicio потвърждава другата страна на монетата: услуга, която никога не отговаря (Colgado: true), с Timeout: 30 * time.Millisecond не може да накара Todos да отнеме повече от това:

$ go test ./internal/revisar/... -run TestTodos_respetaElTimeoutPorServicio -v
=== RUN   TestTodos_respetaElTimeoutPorServicio
--- PASS: TestTodos_respetaElTimeoutPorServicio (0.03s)

0.03 секунди, не повече. context.WithTimeout от раздел 6.7, дете на общия ctx, прекъсна тази проверка точно когато трябваше, без останалата част от програмата да трябва да разбира, че се е случило.


Грешката, която ще видиш

Всички тези са дословни, предизвикани нарочно за този урок:

Симптомът Дословно съобщение / доказателство Какво става и какво да направиш
main приключва, преди goroutines да се изпълнят (никакъв изход или частичен изход, непоследователен между пусканията) Липсва sync.WaitGroup (или канал), който да накара main да изчака. Раздел 6.1
Add/Done не съвпадат Програмата отпечатва, че всичко е минало добре първо, а panic: sync: negative WaitGroup counter идва СЛЕД това Липсва wg.Add(1) преди пускането на някоя goroutine или има излишен Done(). Wait() се е върнал, без да чака нищо. Раздел 6.2
Споделени данни без защита WARNING: DATA RACE + Found 2 data race(s) + exit status 66 (с -race); грешно число без обяснение, без -race Две goroutines докосват една и съща памет без синхронизация. Използвай канал, за да изпратиш резултата, или mutex, ако наистина ти трябва споделен брояч. Раздели 6.3 и 6.4
Изпращане към затворен канал panic: send on closed channel Някой друг вече е затворил канала или го е затворил онзи, който не е трябвало. Затваряй винаги от страната на изпращащия, само веднъж
Затваряне два пъти на един и същ канал panic: close of closed channel Две goroutines (или два пътя в кода) опитват да затворят един и същ канал. Централизирай затварянето на едно място
Никой от другата страна на канал без буфер fatal error: all goroutines are asleep - deadlock! Runtime на Go открива, че цялата програма е заспала в очакване на нещо, което никога няма да се случи, и я убива с точно този ред. Раздел 6.5
Забравил си defer cancelar() (без незабавна грешка; изтичане на памет, натрупано с времето) Всеки неотменен context.WithTimeout държи таймера си жив, докато изтече сам. Раздел 6.6

И инструкцията, която обобщава целия този урок: ако ще пишеш конкурентност, пускай -race още от първия си тест, а не когато нещо замирише — защото, както видя в раздел 6.3, нищо не мирише, докато не стане твърде късно.


Какво се прави погрешно

  • Пускане на goroutine за всеки елемент, без никакво ограничение, срещу външен ресурс. С 5 услуги не се забелязва. С 5,000 програмата отваря 5,000 връзки наведнъж и тясното място престава да бъде отдалечената услуга и става собствената ти машина (или мрежата, или самият сървър, върху който се изсипват 5,000 едновременни заявки). Семафорът от раздел 6.7 съществува точно за това — никога не позволявай "колко goroutines пускам" да зависи само от "колко елемента имам".
  • Пренебрегване на context, който ти дават, или неразпространяването му. Ако функция получава ctx и извиква друга операция, която може да се забави, без да ѝ го подаде, тази операция няма да се отмени, когато оригиналният ctx бъде отменен — имаш два часовника, които не си говорят. Всичко, което може да се забави, получава ctx от онзи, който го е извикал.
  • Незатваряне на отвореното. context.WithTimeout без своя cancelar(), HTTP връзка без resp.Body.Close() (урок 7), незатворен файл: всяко е различно изтичане, но начинът да се избегнат е един и същ — defer веднага след отварянето, преди да напишеш какъвто и да е друг ред.
  • Споделяне на памет, вместо да се комуникира, "защото е по-бързо за писане". Споделен map с mutex около целия блок може да изглежда по-кратко от сглобяването на канал, но е по-лесно да забравиш Lock() в една-единствена точка на достъп, отколкото да забравиш да изпратиш по канал — а първото забравяне не се забелязва, докато детекторът на състезания (или, по-лошо, продукцията) не го открие.
  • Да разчиташ, че "никога не се е проваляло" означава "наред е". Състезание за данни може да мине деветдесет и девет пускания и да се провали на стотното или да се проваля само с повече ядра от тези на лаптопа ти. Единственото доказателство, че една конкурентна програма няма състезания, е да я пуснеш с -race, а не да я видиш да минава няколко пъти без него.

Упражнения

  1. Напиши програмата от раздел 6.1 (goroutines без чакане) и я пусни пет пъти поред. Запиши колко пъти е отпечатала нещо и колко пъти не.
  2. Добави ѝ правилен sync.WaitGroup (раздел 6.2) и потвърди, че сега трите реда се отпечатват винаги, при петте пускания.
  3. Възпроизведи бъга с липсващ Add от раздел 6.2 и постави целия panic в дневника си.
  4. Възпроизведи състезанието за данни от раздел 6.3 (споделен брояч без защита) и пусни същата програма с и без -race. Сравни двете крайни числа и постави целия изход от -race в дневника си.
  5. Възпроизведи deadlock от раздел 6.5 с канал без буфер и без получател. Потвърди, че виждаш fatal error: all goroutines are asleep - deadlock!, а не тихо увисване.
  6. Вземи своя собствен revisor (или този от курса) и пусни TestTodos_elParaleloLimitaCuantasCorrenALaVez, като смениш limite на 1, а после на 10. Обясни със свои думи защо резултатът от теста не се променя (винаги минава), но времето, което отнема, се променя.
  7. (Малко по-трудно) Махни семафора от Todos (остави всички goroutines да се пускат без ограничение) и пусни отново TestTodos_elParaleloLimitaCuantasCorrenALaVez. Потвърди, че сега се проваля, и постави точното съобщение за грешка, което дава t.Errorf, с максимума, който е бил наблюдаван.

Решения

1-2. Няма едно-единствено число: зависи от машината ти. Важното е сравнението — без WaitGroup непоследователно; с него винаги трите реда.

  1. Програмата отпечатва listo първо — Wait() се е върнал незабавно, защото броячът никога не е бил увеличен — а panic: sync: negative WaitGroup counter идва след това, с трасировка, която включва sync.(*WaitGroup).Add(...). При няколко пускания поред редът е винаги един и същ: listo, после panic.

  2. Без -race — число, по-малко от очакваното (например 956 от 1000), без никаква грешка. С -race — блокът WARNING: DATA RACE с точния ред от кода, плюс Found N data race(s) и exit status 66.

  3. fatal error: all goroutines are asleep - deadlock!, с goroutine 1 [chan send]: (или [chan receive], според това от коя страна е заседнала) и точния ред на канала.

  4. Тестът минава и в двата случая, защото измерва истинския максимум и го сравнява с ограничението, което самият той е настроил (1 или 10) — никога с фиксирано очаквано число. Общото време обаче се променя: с limite=1 20-те проверки са строго последователни (едната чака другата), с limite=10 се изпълняват на две партиди по 10 вместо на двадесет по една.

  5. Без семафор всичките 20 goroutines се изпълняват едновременно, така че maximoObservado ще бъде близо до 20 (не точно ограничението на теста, който продължава да иска 3). t.Errorf казва нещо като se observaron 20 consultas simultáneas; el límite era 3 — тестът НАИСТИНА открива регресията, което е точно онова, за което съществува.


Как разбирам, че съм успял

  • Видях със собствените си очи програма да приключва, без да е изчакала своите goroutines (раздел 6.1).
  • Предизвиках panic: sync: negative WaitGroup counter и видях, че програмата отпечата listo ПРЕДИ panic — и мога да обясня защо.
  • Предизвиках истинско състезание за данни и видях разликата между пускането му с и без -race.
  • Мога да прочета блок WARNING: DATA RACE и да кажа кой ред, кои goroutines и коя променлива са в конфликт.
  • Предизвиках fatal error: all goroutines are asleep - deadlock! и знам защо Go го открива, вместо да остане увиснал завинаги.
  • Мога да обясня, с истинския код на Todos, за какво служи всяка от четирите му части: goroutines, канал за резултати, семафор и context за всяка услуга.
  • Измерих, с истински тест (не по памет), че ограничението на паралелизма на revisor наистина се спазва.
  • Знам как да обясня защо Falso.Contador използва mutex вместо канал и защо това не противоречи на общото правило от раздел 6.3.

За допълнително четене

  1. A Tour of Go: Concurrency — официалната обиколка на goroutines, канали и sync.WaitGroup, интерактивна.
  2. Go Blog: Share Memory By Communicating — оригиналната статия, в която се обяснява девизът, цитиран в раздел 6.3.
  3. Package context — официалната документация, с четирите канонични случая на употреба (WithCancel, WithTimeout, WithDeadline, WithValue).
  4. Data Race Detector — официалната документация на -race: какво открива, какво НЕ открива (например не намира deadlocks, само състезания за данни) и цената му по време на изпълнение.

Термини от този урок

Термин Какво означава
goroutine функция, която се изпълнява конкурентно с останалата част от програмата, много по-евтина от нишка на операционната система
sync.WaitGroup механизъм за изчакване група goroutines да приключи, чрез броене на Add/Done
състезание за данни (data race) две goroutines достъпват една и съща памет едновременно, поне едната пише, без синхронизация
канал (chan) механизмът на Go, чрез който една goroutine изпраща данни на друга без споделена памет
семафор от канал канал с буфер, използван като квота от свободни редове, а не за пренасяне на данни
context стандартен механизъм за разпространяване на времеви ограничения и отмяна между функции
deadlock състояние, в което всички goroutines на програмата спят в очакване на нещо, което никога няма да се случи; runtime на Go го открива и прекратява програмата
Да обсъдим вашия проект

Предпочитате имейл? Пишете ни на hola@habil.mx