Shanraq.org Shanraq.org
Горутины и каналы: зачем они вебу
IT

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 ставят внутри неё.

Считать, что горутины — это про скорость. Они про ожидание. Пока один запрос ждёт базу, второй считает — вот и вся выгода. Складывать числа они быстрее не станут.

Карта урока

Карта урока: общая память под замком, работа через канал

Скажите своими словами

Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.

  1. Почему гонка опаснее обычной ошибки?
  2. Зачем defer рядом с Lock?
  3. Что случится с фоновой горутиной, если main завершится?

Задание

Обязательное. Заведите в блоге счётчик просмотров в памяти: обработчик статьи увеличивает его, страница «о блоге» показывает. Сначала без замка — и запустите go test -race ./..., чтобы увидеть отчёт. Потом закройте замком и убедитесь, что отчёт исчез.

Всё это сделано в step-27 — сверьтесь после того, как напишете сами.

По желанию.

  • Замените мьютекс на sync.RWMutex и объясните, что даёт RLock при частом чтении.
  • Отправляйте запись в журнал через канал одному работнику и проверьте, что ответы стали быстрее.
  • Уберите wg.Wait() из примера с работником и посмотрите, сколько задач успеет сделаться.

Куда это встанет в блоге

Блог получает первое общее состояние в памяти — и вместе с ним привычку запускать тесты с -race.

Долги. Счётчик живёт до перезапуска: чтобы он пережил его, нужна база. Работник у нас один и без очереди в базе — при падении процесса задачи теряются. И context, который умеет останавливать работу, когда читатель закрыл вкладку, — следующий урок.

Ответы

Показать ответы
  1. Потому что она не воспроизводится по требованию: та же программа то работает правильно, то теряет данные, то падает с fatal error. Обычная ошибка ломается одинаково, гонка ждёт нагрузки — то есть рабочего сервера.
  2. Чтобы замок открылся при любом выходе из функции, включая ранний return и панику. Забытый Unlock останавливает не одну горутину, а все, которые придут за ней.
  3. Она исчезнет вместе с программой, не доделав работу. Горутина не переживает свою программу, поэтому её дожидаются — например, через WaitGroup.

Источники

Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом

Проверить задание

Сначала решите и запустите в VS Code — редактор покажет ошибку на месте. Готовое решение вставьте сюда. Проверяет модель: она укажет на ошибку, но не даст готовый ответ.

Чтобы проверить, нужно войти. Войти

Комментарии (0)

Пока нет комментариев. Будьте первым.