1. Зачем нам конкурентность
Если до этого момента программирование казалось вам чем-то вроде «написал команды — компьютер послушно сделал», то конкурентность слегка меняет правила игры: теперь вы пишете команды, а порядок некоторых событий становится… не вашим личным делом. И это не баг, а важная фича: мир вокруг программы не ждёт, пока вы закончите одну функцию, чтобы начать другую.
Представьте типичную прикладную ситуацию: мы читаем файл, обрабатываем данные, печатаем результат; параллельно хочется логировать прогресс, реагировать на отмену, обслуживать входящие запросы или просто не «замораживать» интерфейс. В таких сценариях конкурентность — это не «ускоритель», а способ организации работы. Go как раз знаменит тем, что даёт удобную модель конкурентности «из коробки».
Конкурентность и параллелизм: в чём разница
Слова действительно похожи, поэтому их часто путают, а потом удивляются результатам. Конкурентность — про структуру программы (как мы организуем несколько задач), параллелизм — про железо (как реально исполняются инструкции). Можно иметь конкурентность без параллелизма, и можно иметь параллелизм без «хорошей» конкурентности в смысле удобной модели кода.
Давайте зафиксируем определения максимально практично.
| Термин | Простое определение | Что вы обычно “получаете” |
|---|---|---|
| Конкурентность (concurrency) | Несколько логических задач продвигаются “вперемешку во времени” | Удобную структуру: ожидание I/O, фоновые действия, обработка нескольких запросов |
| Параллелизм (parallelism) | Реальное одновременное выполнение на нескольких ядрах CPU | Потенциальное ускорение вычислений (но не гарантированное) |
Хорошая аналогия (одна, обещаю, без фан-клуба аналогий): конкурентность — это когда у повара одновременно варится суп и жарится котлета, он переключается между ними. Параллелизм — когда у вас два повара и они реально готовят одновременно. Можно одному повару организовать готовку конкурентно (успевает больше), но это всё равно один человек. И наоборот: два повара могут мешать друг другу, если план плохой.
2. Рантайм Go и планировщик горутин
Процесс, потоки ОС и «магия» горутин
Когда вы запускаете программу, ОС создаёт процесс, а внутри процесса — потоки (threads) и прочую инфраструктуру. Если писать конкурентный код «в лоб» через потоки ОС, обычно получается тяжеловато: потоки дорогие, их много не создашь, а синхронизация быстро превращается в квест «угадай, где дедлок».
Go делает трюк, который на практике ощущается как «магию завезли»: вместо того чтобы заставлять вас напрямую управлять потоками ОС, Go даёт горутины — очень лёгкие единицы выполнения. Но горутины не живут сами по себе: ими управляет рантайм Go.
Рантайм Go — это часть платформы, которая выполняется вместе с вашим кодом и отвечает за несколько больших вещей. Он планирует горутины, занимается сборкой мусора (GC), помогает с сетевыми ожиданиями и вообще делает так, чтобы ваш код выглядел простым, а работал быстро. Исторически улучшения производительности Go затрагивали и планировщик горутин; например, в заметках о релизе Go 1.1 отдельно упоминались оптимизации, включая scheduler, а также появление race detector как важного инструмента для конкурентного кода.
Упрощённая модель G / M / P
Сейчас будет важный момент: мы не уходим в устройство рантайма как в «курс по внутренностям Go». Но нам нужна такая модель, чтобы вы могли правильно думать о поведении конкурентной программы и не строить логику на случайных совпадениях.
В Go часто используют упрощённую модель из трёх букв:
| Буква | Как расшифровать | Интуитивный смысл |
|---|---|---|
| G | goroutine | «задача», которая хочет выполняться |
| M | machine (thread) | поток ОС, который реально исполняет инструкции |
| P | processor (scheduler context) | «разрешение» выполнять Go-код; посредник между G и M |
Если совсем по-человечески, то горутины (G) — это очередь задач. Потоки ОС (M) — реальные рабочие руки. А P — это ограничитель и диспетчер: сколько «рабочих мест» для выполнения Go-кода сейчас активно.
Схематично это можно представить так:
flowchart LR
G1[Goroutine G] --> P1[P: планирование]
G2[Goroutine G] --> P1
P1 --> M1[M: OS thread]
P2[P: планирование] --> M2[M: OS thread]
Про GOMAXPROCS
Есть параметр GOMAXPROCS, который определяет, сколько «P» рантайм использует для выполнения Go-кода. В современных версиях Go значение по умолчанию обычно соответствует количеству доступных CPU (ядер/логических процессоров), и это часто означает: параллелизм возможен, но всё равно не гарантирован «по вашему желанию».
Вы можете просто посмотреть текущее значение так:
package main
import (
"fmt"
"runtime"
)
func main() {
fmt.Println(runtime.GOMAXPROCS(0)) // например: 8
}
Здесь runtime.GOMAXPROCS(0) возвращает текущее значение, не меняя его. Для нас сейчас важна не настройка, а мысль: рантайм решает, сколько реально одновременно может выполняться Go-кода.
4. Нетерминированность и жизненный цикл программы
Почему порядок выполнения — не контракт
Это сердце сегодняшней лекции. Пока у вас один поток выполнения, вы можете мысленно читать программу сверху вниз и примерно понимать, что будет происходить. Как только вы добавляете конкурентность, у вас появляется планировщик, который может переключать выполнение между задачами. Значит, некоторые вещи (например, порядок печати) становятся недетерминированными.
Сначала — контрольный пример: обычный последовательный код. Здесь порядок железобетонный:
package main
import "fmt"
func main() {
fmt.Println("A") // A
fmt.Println("B") // B
}
Теперь минимально включим конкурентность (да, просто одним словом go). И внезапно ваш внутренний «предсказатель будущего» начинает ошибаться:
package main
import (
"fmt"
"time"
)
func main() {
go fmt.Println("A")
go fmt.Println("B")
time.Sleep(10 * time.Millisecond) // костыль для демонстрации
}
Что здесь важно понять (и это прям жирный маркер): порядок “A/B” не гарантирован. Иногда будет A потом B, иногда B потом A, иногда вам вообще покажется, что компьютер издевается (на самом деле — нет, он просто честно работает).
time.Sleep здесь нужен только для того, чтобы программа не завершилась мгновенно. Это не «правильный способ ждать», это демонстрационный реквизит, как картонная гитара на уроке музыки: похожа, но играть на концерте нельзя.
main закончился — процесс закончился
Есть правило, которое новички почти всегда узнают «экспериментально», то есть через удивление: если функция main завершилась, завершается весь процесс, и никакие незавершённые горутины не будут «вежливо дожидаться», чтобы дописать свои дела. Они просто прекращаются вместе с программой.
Посмотрим пример:
package main
import (
"fmt"
"time"
)
func main() {
go func() {
time.Sleep(50 * time.Millisecond)
fmt.Println("goroutine finished") // может не напечататься
}()
fmt.Println("main finished") // main finished
}
Здесь main печатает "main finished" и выходит. После этого процесс имеет полное право завершиться раньше, чем горутина проснётся после Sleep. Иногда (в зависимости от планировщика и скорости) вам повезёт и вы увидите обе строки, но это будет именно «повезёт», а не контракт.
Это правило особенно важно для реальных приложений (CLI, серверов, воркеров): если вы запускаете фоновую работу, вы должны явно продумать, кто и как дождётся её завершения.
5. Производительность и ожидание
Почему конкурентность не обязана ускорять
Очень человеческая мысль: «Если я сделаю параллельно, будет быстрее». Иногда да. Иногда нет. Иногда станет даже медленнее — и это тоже нормально.
Конкурентность даёт выигрыш особенно часто в I/O-сценариях: сеть, диски, ожидание таймеров, запросы. Там вы не упираетесь в CPU постоянно, и возможность «переключиться на другое дело» реально повышает общую пропускную способность.
Но для CPU-bound задач (когда вы тупо считаете) ускорение зависит от количества ядер, от накладных расходов на планирование, от работы GC, от того, не начали ли горутины бодро бороться за один и тот же ресурс. То есть конкурентность — это в первую очередь про организацию, а не про «турбо-режим».
Если хочется совсем коротко и честно: конкурентность делает программу более способной делать много дел, а параллелизм (если он есть) может сделать некоторые вычисления быстрее, но только если вы не мешаете сами себе.
Почему Sleep — не синхронизация
Сейчас будет момент, который очень любят новички, потому что он «работает». А потом его начинают ненавидеть, потому что «на другом компьютере не работает». Речь про попытку синхронизации через time.Sleep.
Представим, что мы хотим «подождать», пока горутина что-то сделает. Самый соблазнительный путь выглядит так:
package main
import (
"fmt"
"time"
)
func main() {
go func() {
fmt.Println("work done") // work done
}()
time.Sleep(1 * time.Millisecond) // "надеемся, что хватит"
}
Проблема в том, что это не протокол ожидания, а ставка в казино. На вашей машине может «хватать», на CI — нет, на ноутбуке в режиме энергосбережения — «иногда», а в момент, когда рядом открылся браузер с 40 вкладками — вообще грустно.
Правильный вывод из этой лекции звучит так: Sleep может помогать показать эффект конкурентности в примерах, но не должен быть способом делать программу корректной.
6. Где конкурентность появляется в реальных приложениях
Даже если вы сегодня не напишете ни одной горутины, конкурентность уже рядом, потому что современные программы редко живут в идеальном вакууме. Как только у нас появляется сервер, обработка нескольких запросов, фоновые задачи, логирование, таймауты, корректное завершение — мы начинаем мыслить задачами, которые живут «одновременно».
Например, когда мы говорим про аккуратное завершение сервера, мы хотим, чтобы сервер перестал принимать новые запросы, но дождался завершения текущих. В экосистеме Go это важный и давно поддерживаемый сценарий: в заметках к релизу Go 1.8, например, отдельно упоминается graceful shutdown HTTP-сервера как возможность «минимизировать downtime», дожидаясь запросов «в полёте».
Сегодняшняя лекция нужна вам именно как ментальная база: когда вы позже увидите код, который «запускает работу отдельно» и «ждёт группу задач», вы не будете воспринимать это как магический ритуал. Вы будете понимать: рантайм планирует выполнение, порядок не гарантирован, а корректность требует явных договорённостей.
7. Типичные ошибки и почему они происходят
Ошибка №1: путать конкурентность с ускорением и разочаровываться.
Новичок запускает несколько задач «одновременно», ожидает, что всё станет быстрее в два раза, а получает то же время или даже хуже. Это происходит потому, что конкурентность в первую очередь про структуру, а ускорение возможно только при наличии параллелизма и правильном разделении работы без лишней борьбы за ресурсы.
Ошибка №2: считать порядок выполнения (или печати) фиксированным.
Как только появляется конкурентность, порядок событий становится недетерминированным. Если вы пишете код, который «логически зависит» от того, что сначала напечатается "A", потом "B", то вы строите дом на песке. Иногда он стоит, иногда нет — и самое неприятное, что ломается «редко», то есть в самый неудобный момент.
Ошибка №3: использовать time.Sleep как механизм ожидания завершения.
Sleep — это пауза, а не договорённость. Она не говорит рантайму «дождись горутину», она говорит вашему потоку «полежи». На быстрых машинах вы «случайно» попадаете в нужный интервал, на медленных — нет. Надёжные программы так не строят: они используют явные протоколы ожидания завершения работы.
Ошибка №4: думать, что горутины удержат программу живой.
Это частая психологическая ловушка: раз горутина что-то делает, значит программа должна подождать. Но Go устроен иначе: завершение main завершает процесс целиком, и незавершённые горутины прекращаются. Это не жестокость языка — это простое и полезное правило жизненного цикла.
Ошибка №5: пытаться угадывать поведение планировщика.
Иногда хочется думать: «ну он же сначала запустит вот эту горутину, потому что я её первой создал». Планировщик никому ничего не обещал. Более того, реальное поведение зависит от версии рантайма, нагрузки, числа ядер, таймингов, GC и множества мелочей. Единственная стабильная стратегия — не строить корректность на предположениях о порядке и времени выполнения без явного согласования.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ