Go: с нуля до своего блога Урок 42 из 50
Горутины и каналы: зачем они вебу
Сорок второй урок курса по Go. Сервер конкурентен с урока про HTTP, и вы этого не выбирали: каждый запрос — своя горутина. Измерено, как счётчик без замка сначала теряет один просмотр из пятисот, а потом роняет весь сервер, и как go test -race находит это заранее.
Зачем это нужно
Про горутины обычно рассказывают на примере, где что-то надо ускорить. Для веба это неверный заход: ваш сервер уже конкурентный, и стал он таким без вашего участия.
http.Server запускает каждый запрос в отдельной горутине. Пока один читатель ждёт ответа, второй уже обслуживается. Это удобно и опасно одновременно: всё, что обработчики трогают вместе, они трогают одновременно.
Пока блог хранил всё в базе, беды не было: SQLite сам разбирается, кто пишет. Стоит завести переменную в памяти — счётчик просмотров, кеш, что угодно — и появляется гонка.
Сразу целиком
Новая папка, go mod init sabaq39:
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"sync"
)
// views считает просмотры. Обработчик выполняется в своей горутине на каждый
// запрос, значит эту переменную трогают одновременно.
type views struct {
mu sync.Mutex
n map[string]int
}
func newViews() *views { return &views{n: map[string]int{}} }
// Add без замка — так писать нельзя, и ниже видно почему.
func (v *views) AddUnsafe(slug string) { v.n[slug]++ }
// Add с замком: одновременно внутрь пускают одного.
func (v *views) Add(slug string) {
v.mu.Lock()
defer v.mu.Unlock()
v.n[slug]++
}
func (v *views) Count(slug string) int {
v.mu.Lock()
defer v.mu.Unlock()
return v.n[slug]
}
func handler(v *views, safe bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if safe {
v.Add("dala")
} else {
v.AddUnsafe("dala")
}
fmt.Fprint(w, "ok")
})
}
// hammer шлёт сто запросов одновременно и ждёт, пока все закончатся.
func hammer(h http.Handler, n int) {
srv := httptest.NewServer(h)
defer srv.Close()
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
resp, err := http.Get(srv.URL)
if err == nil {
resp.Body.Close()
}
}()
}
wg.Wait()
}
func main() {
v := newViews()
hammer(handler(v, true), 500)
fmt.Println("с замком: ", v.Count("dala"), "из 500")
u := newViews()
hammer(handler(u, false), 500)
fmt.Println("без замка: ", u.Count("dala"), "из 500")
}
Запускаем — и получаем разное:
с замком: 500 из 500
без замка: 499 из 500
с замком: 500 из 500
fatal error: concurrent map writes
goroutine 5411 [running]:
Одна и та же программа: в одном запуске потеряла один просмотр из пятисот, в другом уронила себя целиком.
Разбор
Гонка: программа, которая иногда врёт, а иногда падает
AddUnsafe делает v.n[slug]++. Выглядит как одно действие, а на деле их три: прочитать, прибавить, записать.
Две горутины успевают прочитать одно и то же значение, каждая прибавляет свою единицу, каждая записывает — и один просмотр исчезает. Так получается 499 из 500.
С картой всё хуже. Одновременная запись в map — не просто неверное число: Go специально проверяет это и валит программу с fatal error: concurrent map writes. Не паника, которую можно поймать recover, а немедленная смерть процесса. Ваш сервер перестаёт отвечать всем.
Образ. Два человека правят одну строку в бумажном журнале. Каждый прочитал «17», каждый зачеркнул и написал «18». Посетителей было двое, а записан один.
Хуже всего третий исход — тот, что в первом выводе: программа отработала правильно. Гонка не воспроизводится по требованию. Она ждёт нагрузки, то есть выстреливает на рабочем сервере, а не у вас на ноутбуке.
-race: находит то, чего не видно
Поэтому гонку не ищут глазами. Её ищет Go:
с замком: 500 из 500
==================
WARNING: DATA RACE
Read at 0x00c000dd2780 by goroutine 3668:
runtime.mapdelete_faststr()
/usr/local/go/src/internal/runtime/maps/runtime_faststr_swiss.go:396 +0x8c
main.(*views).AddUnsafe()
main.go:20 +0x58
main.main.handler.func2()
main.go:40 +0x68
net/http.HandlerFunc.ServeHTTP()
/usr/local/go/src/net/http/server.go:2294 +0x48
net/http.serverHandler.ServeHTTP()
/usr/local/go/src/net/http/server.go:3301 +0x298
Пути в выдержке сокращены до имени файла.
go run -race . и go test -race ./... включают детектор гонок. Он следит за тем, кто и когда трогает память, и печатает обе стороны конфликта с именем файла и строкой — здесь main.go:20, то самое v.n[slug]++.
Детектор находит гонку, даже если в этот раз всё сошлось. Он замедляет программу в несколько раз, поэтому его не держат включённым в бою — его запускают в тестах, и обычно это одна строка в проверке перед отправкой кода.
Правило простое: -race в тестах обязателен для любой программы, где есть общее состояние. У веба оно есть всегда.
Замок: sync.Mutex и defer Unlock
Лечится это замком:
func (v *views) Add(slug string) {
v.mu.Lock()
defer v.mu.Unlock()
v.n[slug]++
}
Lock пускает внутрь одну горутину; остальные ждут на этой строке, пока не будет Unlock. defer здесь не украшение: без него любой ранний return или паника оставят замок закрытым навсегда, и сервер встанет намертво.
Мьютекс кладут рядом с тем, что он защищает, — полем в той же структуре. Тогда видно, что именно он охраняет, и не приходится помнить это в голове.
Что стоит помнить: под замком делают минимум. Ходить в базу или в сеть, держа замок, — верный способ превратить быстрый сервер в очередь.
Канал: очередь фоновой работы
Мьютекс — про общую переменную. Канал — про передачу работы из одной горутины в другую, и в вебе он нужен для другого: чтобы читатель не ждал того, чего ему ждать незачем.
package main
import (
"fmt"
"sync"
"time"
)
func main() {
// Очередь фоновой работы. Буфер на сто задач: обработчик кладёт задачу и
// сразу отвечает читателю, а не ждёт, пока она сделается.
jobs := make(chan string, 100)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
// Цикл по каналу заканчивается сам, когда канал закроют.
for slug := range jobs {
time.Sleep(50 * time.Millisecond) // будто отправка в телеграм
fmt.Println(" отправили:", slug)
}
}()
start := time.Now()
for _, slug := range []string{"dala", "salem", "shanyraq"} {
jobs <- slug
fmt.Printf("ответили читателю за %s\n", time.Since(start).Round(time.Millisecond))
}
// Закрыть канал — сказать «задач больше не будет». Без этого цикл в
// горутине ждал бы вечно, а Wait не дождался бы никогда.
close(jobs)
wg.Wait()
fmt.Println("всё сделано за", time.Since(start).Round(10*time.Millisecond))
}
ответили читателю за 0s
ответили читателю за 0s
ответили читателю за 0s
отправили: dala
отправили: salem
отправили: shanyraq
всё сделано за 150ms
Читателю ответили мгновенно три раза, а работа на сто пятьдесят миллисекунд доделалась потом. В блоге так отправляют пост в телеграм, шлют письмо, чистят кеш — всё, чей результат читателю не нужен.
Буфер в сто задач — не роскошь, а решение: если очередь заполнилась, jobs <- slug заблокируется, и обработчик начнёт ждать. Иногда это правильно (лучше подождать, чем потерять), иногда нет, и тогда пишут select с default и роняют задачу с записью в журнал.
WaitGroup и закрытый канал
Две строки в конце важнее, чем кажутся.
close(jobs) говорит «задач больше не будет». Цикл for slug := range jobs заканчивается сам только после закрытия; без него горутина ждала бы вечно.
wg.Wait() держит main до тех пор, пока горутина не доработает. Без него программа завершится и заберёт все горутины с собой — вместе с недоделанной работой. Это, кстати, ответ на частый вопрос: горутина не переживает свою программу.
Чего не делают
Горутину на каждый чих. go doSomething() в обработчике выглядит бесплатным, но каждая такая горутина живёт своей жизнью: её никто не ждёт, её паника роняет весь сервер, а при наплыве запросов их станут тысячи. Фоновую работу отдают одному-двум работникам через канал, как выше.
Ловить панику в горутине обёрткой. recoverPanic из урока про ошибки защищает только ту горутину, в которой стоит. Паника в фоновой горутине убивает процесс целиком, поэтому recover ставят внутри неё.
Считать, что горутины — это про скорость. Они про ожидание. Пока один запрос ждёт базу, второй считает — вот и вся выгода. Складывать числа они быстрее не станут.
Карта урока
Скажите своими словами
Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.
- Почему гонка опаснее обычной ошибки?
- Зачем
deferрядом сLock? - Что случится с фоновой горутиной, если
mainзавершится?
Задание
Обязательное. Заведите в блоге счётчик просмотров в памяти: обработчик статьи увеличивает его, страница «о блоге» показывает. Сначала без замка — и запустите go test -race ./..., чтобы увидеть отчёт. Потом закройте замком и убедитесь, что отчёт исчез.
Всё это сделано в step-27 — сверьтесь после того, как напишете сами.
По желанию.
- Замените мьютекс на
sync.RWMutexи объясните, что даётRLockпри частом чтении. - Отправляйте запись в журнал через канал одному работнику и проверьте, что ответы стали быстрее.
- Уберите
wg.Wait()из примера с работником и посмотрите, сколько задач успеет сделаться.
Куда это встанет в блоге
Блог получает первое общее состояние в памяти — и вместе с ним привычку запускать тесты с -race.
Долги. Счётчик живёт до перезапуска: чтобы он пережил его, нужна база. Работник у нас один и без очереди в базе — при падении процесса задачи теряются. И context, который умеет останавливать работу, когда читатель закрыл вкладку, — следующий урок.
Ответы
Показать ответы
- Потому что она не воспроизводится по требованию: та же программа то работает правильно, то теряет данные, то падает с
fatal error. Обычная ошибка ломается одинаково, гонка ждёт нагрузки — то есть рабочего сервера. - Чтобы замок открылся при любом выходе из функции, включая ранний
returnи панику. ЗабытыйUnlockостанавливает не одну горутину, а все, которые придут за ней. - Она исчезнет вместе с программой, не доделав работу. Горутина не переживает свою программу, поэтому её дожидаются — например, через
WaitGroup.
Источники
Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом
Комментарии (0)
Войдите, чтобы оставить комментарий →
Пока нет комментариев. Будьте первым.