Теория языков программирования
CSP: философия конкурентности Go
Цели урока
- Понять философию CSP: общение вместо разделения памяти
- Использовать goroutines для лёгкой конкурентности
- Создавать channels для безопасной коммуникации
- Применять select для мультиплексирования каналов
- Знать паттерны: fan-out/fan-in, worker pools, pipelines
Предварительные знания
- Что такое concurrency и parallelism
- Проблемы threads: race conditions, deadlocks
- Базовое понимание любого языка программирования
- Желательно: опыт с async/await (JavaScript, Python)
10,000 одновременных подключений к серверу. Threads? Это 10GB RAM и context switch hell. Async/await? Callback не так уж и умер. Go предлагает третий путь: goroutines дешевле threads, а channels проще callbacks.
- **Docker & Kubernetes**: написаны на Go - конкурентность в ДНК
- **Twitch**: миллионы стримов через Go микросервисы
- **Uber**: real-time геолокация на Go
- **Cloudflare**: edge-серверы на Go обрабатывают миллионы RPS
- **Prometheus & Grafana**: мониторинг инфраструктуры мира на Go
От теории к практике: 1978 → 2009
В 1978 году британский учёный Тони Хоар опубликовал работу "Communicating Sequential Processes" - математическую модель конкурентных вычислений. Идея: вместо разделяемой памяти процессы общаются через каналы. 30 лет спустя Rob Pike (создатель UTF-8 и Plan 9) воплотил эти идеи в Go. Он работал в Bell Labs вместе с Кеном Томпсоном (создатель Unix), и они хотели язык для современных серверов Google. CSP стал идеальным фундаментом.
Проблема: threads - это дорого
Сценарий: веб-сервер. Каждый запрос - отдельный поток. Проблемы:
Async/await (Node.js, Python) решает это через event loop, но код превращается в "callback soup" или "async всё".
Goroutines: лёгкие "потоки"
Go предлагает **goroutines** - функции, которые выполняются конкурентно. Они НЕ являются OS threads!
**Ключевое слово `go`**: одно слово превращает любой вызов функции в конкурентный. Никаких Thread, ExecutorService, async/await. Просто `go f()`.
Channels: безопасное общение
Goroutines должны как-то обмениваться данными. Shared memory? Race conditions! Go предлагает **channels** - типизированные "трубы" для передачи данных.
**Мантра Go**: "Don't communicate by sharing memory; share memory by communicating." Не общайтесь через разделяемую память - делитесь памятью через общение.
Аналогия: обычная почта. Письмо опускается в ящик (`chan <-`), адресат достаёт из своего (`<-chan`). Письмо физически перемещается - нет двух копий, нет race condition.
Buffered vs Unbuffered Channels
Select: мультиплексирование каналов
Что если нужно слушать несколько каналов одновременно? **Select** - как switch для каналов.
Timeout и default
Паттерны CSP
Pipeline: конвейер обработки
Fan-out / Fan-in: параллельная обработка
Worker Pool: ограничение параллелизма
**Частые ошибки с goroutines:** 1. **Забыть про close(ch)** - получатель с `range` будет ждать вечно 2. **Goroutine leak** - запустили goroutine, но не дали ей завершиться 3. **Закрыть канал дважды** - panic! 4. **Записать в закрытый канал** - panic!
Практика
CSP: разделяй коммуникацией, не памятью
В чём главное отличие CSP от традиционного подхода с threads?
Суть CSP: вместо mutex вокруг shared data - goroutines передают данные по channels. Один владеет данными в каждый момент времени → race condition невозможен по конструкции.
Goroutines, channels и select
Что делает `select` в Go?
select ждёт первый готовый case. Если несколько готовы одновременно - выбирает случайный. default case делает select неблокирующим. Это основа fan-in паттерна.
Паттерны конкурентности в CSP
Как предотвратить утечку горутин (goroutine leak)?
Горутина живёт пока не вернётся из функции. Без done-channel горутина может ждать вечно на заблокированном channel. context.WithCancel даёт сигнал всем горутинам в дереве завершиться.
| Проблема | OS Thread | Последствия |
|---|---|---|
| Память | ~1-8 MB stack на поток | 10K потоков = 10-80 GB RAM |
| Создание | ~1ms | Медленный старт для short-lived задач |
| Context switch | ~1-10µs | CPU тратит время на переключение |
| Лимиты | ~10K потоков на процесс | Не масштабируется |
| OS Thread | Goroutine | |
|---|---|---|
| Память | ~1-8 MB | ~2 KB (в 1000 раз меньше!) |
| Создание | ~1 ms | ~1 µs (в 1000 раз быстрее!) |
| Количество | ~10K | ~1M (легко миллион!) |
| Управление | OS Scheduler | Go Runtime Scheduler |
| Context switch | ~1-10 µs | ~100 ns |
Почему можно создать миллион goroutines, но не миллион OS threads?
Goroutine стартует с ~2KB стека и растёт по необходимости. OS thread требует заранее выделить ~1-8MB. Миллион goroutines = ~2GB RAM, миллион threads = ~1-8TB RAM (невозможно).
| Тип | Когда использовать |
|---|---|
| Unbuffered | Нужна синхронизация, handshake между goroutines |
| Buffered | Producer быстрее consumer, batch processing |
Что произойдёт при отправке в unbuffered channel без получателя?
Unbuffered channel требует, чтобы отправитель и получатель встретились. Если получателя нет - отправитель ждёт вечно. Go runtime обнаружит deadlock и завершит программу с ошибкой.
Реализуйте функцию, которая делает HTTP запросы к нескольким URL параллельно и возвращает первый успешный ответ (race pattern). ```go func fetchFirst(urls []string) (string, error) { // Ваш код здесь // Запустите goroutine для каждого URL // Верните первый успешный результат } func main() { urls := []string{ "https://api1.example.com/data", "https://api2.example.com/data", "https://api3.example.com/data", } result, err := fetchFirst(urls) if err != nil { log.Fatal(err) } fmt.Println("Got:", result) } ```
func fetchFirst(urls []string) (string, error) { results := make(chan string, len(urls)) errors := make(chan error, len(urls)) for _, url := range urls { go func(u string) { resp, err := http.Get(u) if err != nil { errors <- err return } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) results <- string(body) }(url) } select { case result := <-results: return result, nil case <-time.After(5 * time.Second): return "", fmt.Errorf("all requests timed out") } } // Бонус: собрать все ошибки если все запросы упали func fetchFirstWithErrors(urls []string) (string, error) { type result struct { body string err error } results := make(chan result, len(urls)) for _, url := range urls { go func(u string) { resp, err := http.Get(u) if err != nil { results <- result{err: err} return } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) results <- result{body: string(body)} }(url) } var errs []error for i := 0; i < len(urls); i++ { select { case r := <-results: if r.err == nil { return r.body, nil } errs = append(errs, r.err) case <-time.After(5 * time.Second): return "", fmt.Errorf("timeout") } } return "", fmt.Errorf("all failed: %v", errs) }
Реализуйте rate limiter: не более N запросов в секунду. ```go // Rate limiter должен пропускать не более rps запросов в секунду func NewRateLimiter(rps int) *RateLimiter { // Ваш код } func (r *RateLimiter) Wait() { // Блокирует пока не придёт "разрешение" } func main() { limiter := NewRateLimiter(5) // 5 запросов в секунду for i := 0; i < 20; i++ { limiter.Wait() fmt.Printf("Request %d at %v\n", i, time.Now()) } } ```
type RateLimiter struct { tokens chan struct{} stop chan struct{} } func NewRateLimiter(rps int) *RateLimiter { r := &RateLimiter{ tokens: make(chan struct{}, rps), // Burst capacity stop: make(chan struct{}), } // Заполняем начальный буфер for i := 0; i < rps; i++ { r.tokens <- struct{}{} } // Генерируем токены с нужной частотой go func() { ticker := time.NewTicker(time.Second / time.Duration(rps)) defer ticker.Stop() for { select { case <-ticker.C: select { case r.tokens <- struct{}{}: // Добавили токен default: // Буфер полон, пропускаем } case <-r.stop: return } } }() return r } func (r *RateLimiter) Wait() { <-r.tokens // Блокируется пока нет токена } func (r *RateLimiter) Stop() { close(r.stop) }
CSP в контексте
Как Go's CSP соотносится с другими моделями конкурентности
- Async/Await — JS/Python подход - event loop вместо goroutines
- Actor Model — Erlang подход - похож на CSP, но с mailboxes
- Threads & Locks — Традиционный подход - что Go заменяет
- Rust Ownership — Другой способ предотвращения data races
Итоги
- **Goroutines** - лёгкие (~2KB) единицы конкурентности, можно создать миллион
- **Channels** - типизированные "трубы" для безопасной коммуникации
- **Select** - мультиплексирование нескольких каналов
- **Философия** - "share memory by communicating", не наоборот
- **Паттерны** - pipeline, fan-out/fan-in, worker pool
- **Преимущество** - проще чем threads+locks, эффективнее чем async/await
Вопросы для размышления
- В каких проектах CSP подошёл бы лучше, чем async/await?
- Почему Kubernetes и Docker написаны именно на Go?
- Как объяснить разницу между goroutines и threads джуну?