Это итоговая глава раздела — консолидированный «мостик» между конкурентностью в .NET и в Go. Здесь мы собираем воедино то, что разбиралось в предыдущих главах, и явно проговариваем главное фундаментальное различие двух моделей: async/await с «цветом функций» против горутин без цвета. Если вы усвоите только один раздел этого учебника — пусть это будет он, потому что именно здесь чаще всего ломается интуиция .NET-разработчика.
Асинхронность в .NET кооперативная и построена на async/await поверх Task. Ключевая особенность — «цвет функции» (function coloring): асинхронность «заразна» и протекает вверх по сигнатурам.
- Метод, который хочет
await-ить, обязан сам бытьasyncи возвращатьTask/Task<T>/ValueTask. - Тот, кто вызывает
async-метод и хочет его результат, тоже долженawait-ить — и, значит, тоже статьasync. И так до самого верха (до точки входа или обработчика фреймворка). - Смешивать «цвета» больно: синхронный вызов асинхронного кода через
.Result/.Wait()грозит дедлоками (классическая проблемаSynchronizationContextв старом ASP.NET/WinForms) и истощением пула потоков. - Отсюда же
ConfigureAwait(false)— необходимость явно говорить «не возвращай продолжение на захваченный контекст синхронизации», чтобы избежать дедлоков и лишних переключений в библиотечном коде.
// .NET: async «красит» весь стек вызовов
async Task<string> GetUserNameAsync(int id)
{
var user = await _repo.GetUserAsync(id); // await → метод обязан быть async
return user.Name;
}
async Task<string> HandleAsync(int id) // вызывающий тоже async
{
return await GetUserNameAsync(id); // и так до самого верха
}«Цвет» — это не просто стилистика: он делит весь код на два мира (sync/async), которые плохо стыкуются, и заставляет дублировать API (Read и ReadAsync), прокидывать CancellationToken руками и помнить про ConfigureAwait.
В Go этой дихотомии нет. Вы пишете обычный, последовательный, синхронный на вид код. Блокировка — это нормально и дёшево: когда горутина «блокируется» на канале, мьютексе или сетевом вызове, рантайм просто снимает её с потока и ставит на её место другую (см. главу 1 про планировщик и netpoller). Поток ОС при этом не простаивает.
// Go: обычная синхронная сигнатура, никакого «цвета»
func GetUserName(ctx context.Context, id int) (string, error) {
user, err := repo.GetUser(ctx, id) // выглядит синхронно; рантайм паркует горутину
if err != nil {
return "", err
}
return user.Name, nil
}
func Handle(ctx context.Context, id int) (string, error) {
return GetUserName(ctx, id) // вызывающий — такая же обычная функция
}Следствия отсутствия «цвета»:
- Нет деления API на sync/async. Один метод
conn.Read(...)— и он же неблокирующий под капотом. Нет парFoo/FooAsync. - Нет
ConfigureAwait— потому что нетSynchronizationContextи нет «захвата контекста» для продолжения. Конфигурировать нечего. - Нет «заразности». Любую функцию можно вызвать как из горутины, так и напрямую; сигнатура не меняется от того, делает ли функция I/O.
- Конкурентность отделена от блокировки. Чтобы сделать что-то конкурентно, вы пишете
go f()— это отдельное, явное и локальное решение, а не свойство, протекающее по всем сигнатурам.
Это и есть суть. В .NET асинхронность — свойство типа функции (она
asyncили нет). В Go конкурентность — свойство точки вызова (вы написалиgoили нет), а сама функция остаётся обычной. Поэтому в Go нельзя «случайно» сделать пол-API асинхронным: код просто пишется один раз, синхронно, а конкурентность добавляется снаружи.
Важная оговорка про честность сравнения. Отсутствие «цвета» в Go — следствие того, что горутины это, по сути, очень дешёвые «зелёные потоки» с собственными стеками, а блокировка реализована переключением в user-space. .NET сознательно пошёл другим путём (async/await без выделенного стека на каждую операцию — это экономнее по памяти на огромном числе одновременных операций и не требует копирования стеков). У обоих подходов своя цена: Go платит за горутины памятью под стеки и работой планировщика, .NET — сложностью модели и «цветом». Это инженерный компромисс, а не «одно лучше другого во всём».
Различие, на котором спотыкаются в первый день.
Task<T>— это объект-обещание будущего результата. Его можно сохранить, передать,await-ить, скомбинировать (WhenAll/WhenAny), навесить продолжение, получить из него исключение.- Горутина — это просто запущенное выполнение. Она ничего не возвращает и не является объектом, который можно «подождать» напрямую. Результат и факт завершения вы организуете сами — через канал (для результата) и/или
WaitGroup(для ожидания).
Перевод идиомы «получить результат фоновой работы»:
// .NET: результат «живёт» в Task<T>
Task<int> task = Task.Run(() => Compute());
int result = await task; // забираем результат// Go: «Task<T>» = горутина + канал результата
resultCh := make(chan int, 1) // буфер на 1, чтобы горутина не утекла (глава 4)
go func() {
resultCh <- Compute() // результат уходит в канал
}()
result := <-resultCh // забираем результат из каналаА «дождаться нескольких» и «дождаться первого»:
await Task.WhenAll(t1, t2, t3); // все
Task done = await Task.WhenAny(t1, t2); // первый// все: WaitGroup
var wg sync.WaitGroup
for _, job := range jobs {
wg.Add(1)
go func(j Job) { defer wg.Done(); process(j) }(job)
}
wg.Wait()
// первый: select по каналам результатов
select {
case r := <-ch1:
use(r)
case r := <-ch2:
use(r)
}| Аспект | Task<T> (.NET) |
горутина (Go) |
|---|---|---|
| Возвращает результат | да, Task<T> |
нет — результат через канал |
| Это объект-хендл | да (можно хранить/передать) | нет |
| Ожидание завершения | await / .Wait() |
канал / sync.WaitGroup |
| Исключения/ошибки | агрегируются в Task |
передаются явно (канал с error) |
| Комбинирование | WhenAll/WhenAny |
select / WaitGroup вручную |
Заметьте: в Go ошибку из горутины тоже передают явным значением — обычно через канал результата делают структуру struct{ val T; err error } или отдельный канал ошибок. Паника в горутине без recover роняет весь процесс — она не «всплывает» к вызывающему, как исключение из await-нутой Task.
System.Threading.Channels — самый прямой аналог каналов Go и появился в .NET во многом под их влиянием.
.NET Channel<T> |
Go chan T |
|
|---|---|---|
| Создание | Channel.CreateBounded<T>(n) / CreateUnbounded |
make(chan T, n) / make(chan T) |
| Запись/чтение | writer.WriteAsync / reader.ReadAsync |
ch <- v / <-ch |
| Разделение направлений | ChannelWriter<T> / ChannelReader<T> |
chan<- T / <-chan T |
| Завершение | writer.Complete() |
close(ch) |
| Итерация | await foreach (reader.ReadAllAsync()) |
for v := range ch |
| Ожидание нескольких | вручную (Task.WhenAny по ReadAsync) |
select — оператор языка |
| Неограниченный буфер | есть (CreateUnbounded) |
нет (только небуф./огранич.) |
Главное преимущество Go — оператор select на уровне языка: ждать сразу нескольких каналов, делать неблокирующие операции (default), комбинировать с таймаутом — всё это синтаксис, а не ручная сборка из WhenAny. Повторное закрытие/отправка в закрытый канал в Go — паника (в .NET — исключение); к закрытию в Go надо относиться дисциплинированно (закрывает отправитель).
TPL Dataflow (BufferBlock<T>, TransformBlock<T,T>, ActionBlock<T>, соединяемые LinkTo) — это надстройка для конвейеров с встроенным батчингом, дросселированием и параллелизмом блоков. В Go готового аналога нет: конвейеры (pipeline, fan-out/fan-in из главы 2) собираются вручную из голых каналов и горутин — гибче и без зависимостей, но и без коробочных возможностей Dataflow.
Подробно — в главе 4; здесь главное:
C# lock (Monitor) |
Go sync.Mutex |
|
|---|---|---|
| Реентерабельность | ✅ да (recursive) | ❌ нет (повторный Lock = самодедлок) |
| Синтаксис | lock (obj) { ... } |
mu.Lock(); defer mu.Unlock() |
| Освобождение при панике/исключении | автоматически (блок) | через defer |
| Reader/Writer-вариант | ReaderWriterLockSlim |
sync.RWMutex |
Запомните как мантру: sync.Mutex нереентерабельный. Паттерн из C# «публичный метод под lock зовёт другой метод под тем же lock» в Go — мгновенный вечный дедлок. Решение — публичный метод берёт блокировку и вызывает приватный ...Locked-метод без блокировки.
Подробно — в главе 3; сводно:
| .NET | Go | |
|---|---|---|
| Источник отмены | CancellationTokenSource |
context.WithCancel (+ возвращает cancel) |
| Токен/сигнал | CancellationToken, token.WaitHandle |
ctx, ctx.Done() (канал) |
| Запустить отмену | cts.Cancel() |
cancel() |
| Проверка | token.IsCancellationRequested / ThrowIfCancellationRequested() |
ctx.Err() / select { case <-ctx.Done() } |
| Таймаут/дедлайн | cts.CancelAfter() / new CTS(timespan) |
context.WithTimeout / WithDeadline |
| Связывание (дерево) | CreateLinkedTokenSource(parent) |
производный контекст наследует отмену родителя |
| Значения по запросу | отдельно: AsyncLocal<T> |
встроено: context.WithValue |
Ключевое: context.Context несёт отмену, дедлайн и значения единым объектом, который вы и так прокидываете первым аргументом сквозь весь стек. В .NET эти три заботы разнесены (CancellationToken + AsyncLocal<T> + дедлайн внутри CTS). И в Go реакция на отмену всегда явная — нужно самому слушать ctx.Done(); «магического» прерывания горутины извне нет.
Ограничение числа одновременных операций (например, не больше N параллельных запросов).
// .NET
var sem = new SemaphoreSlim(3); // не больше 3 одновременно
await sem.WaitAsync();
try { await DoWork(); }
finally { sem.Release(); }// Go: буферизованный канал как счётный семафор — идиоматично
sem := make(chan struct{}, 3) // ёмкость 3 = «3 разрешения»
sem <- struct{}{} // занять слот (блокирует, если все 3 заняты)
go func() {
defer func() { <-sem }() // освободить слот
doWork()
}()Буферизованный канал — это, по сути, счётный семафор: ёмкость буфера = число разрешений, отправка = Wait, приём = Release. Для более богатого API (взять/вернуть несколько разрешений за раз, отмена через context) есть официальный пакет golang.org/x/sync/semaphore с методами Acquire(ctx, n) / Release(n) — он ближе к SemaphoreSlim по возможностям.
Подробно — в главе 1; сводно:
| .NET ThreadPool / TPL | Go runtime (GMP) | |
|---|---|---|
| Единица планирования | Task / work item (делегат) |
горутина (G) |
| Носитель | поток-воркер пула | M (поток ОС), привязанный к P |
| Параллелизм | размер пула (динамический) | GOMAXPROCS (= число P) |
| Балансировка | work-stealing очереди | work-stealing между P + глобальная очередь |
| Где живёт | библиотека поверх CLR | встроено в рантайм языка |
| Вытеснение | потоки вытесняются ОС превентивно | кооперативное + сигнальное (Go 1.14+) |
| I/O | IOCP/epoll, продолжения по готовности | netpoller, парковка горутины |
Идея work-stealing общая для обоих. Но горутина — это не Task и не поток: это легковесная сущность рантайма со своим растущим стеком, которую планировщик мультиплексирует на потоки ОС. Знаменитое «ThreadPool starvation» (исчерпание пула из-за блокирующих вызовов в async-коде) в Go проявляется иначе и мягче: на блокирующем syscall P отцепляется на другой M (handoff), а сетевые ожидания вообще не держат поток.
| Концепция / задача | .NET | Go |
|---|---|---|
| Запустить фоновую работу | Task.Run(...) |
go f() |
| Модель асинхронности | async/await, «цвет функции» |
обычный синхронный код, нет цвета |
| Конфиг продолжения | ConfigureAwait(false) |
— (не нужно) |
| Вернуть результат фоновой работы | Task<T> |
горутина + chan T |
| Дождаться всех | Task.WhenAll / WaitAll |
sync.WaitGroup |
| Дождаться первого | Task.WhenAny |
select |
| Задержка/таймаут | Task.Delay |
time.After / time.NewTimer |
| Отмена | CancellationToken(Source) |
context.Context / WithCancel |
| Дедлайн/таймаут отмены | cts.CancelAfter |
context.WithTimeout/WithDeadline |
| Связанные отмены | CreateLinkedTokenSource |
производный контекст |
| Request-scoped значения | AsyncLocal<T> |
context.WithValue |
| Очередь сообщений | System.Threading.Channels |
chan T |
| Конвейеры данных | TPL Dataflow | каналы + горутины (вручную) |
| Взаимное исключение | lock (Monitor, реентерабельный) |
sync.Mutex (нереентерабельный) |
| Read/Write блокировка | ReaderWriterLockSlim |
sync.RWMutex |
| Атомарные операции | Interlocked / Volatile |
sync/atomic |
| Однократная инициализация | Lazy<T> / LazyInitializer |
sync.Once |
| Ограничение параллелизма | SemaphoreSlim |
буф. канал / x/sync/semaphore |
| Потокобезопасный словарь | ConcurrentDictionary |
map+RWMutex / sync.Map (нишево) |
| Планировщик | ThreadPool + TPL scheduler | GMP в рантайме |
| Поиск гонок | сторонние инструменты | go test -race (встроенный) |
| Характерная «утечка» | забытые/незавершённые Task |
goroutine leak |
- Главное различие — «цвет функции». .NET: асинхронность это свойство типа функции (
async), заразное и протекающее вверх по сигнатурам, сConfigureAwaitи парамиFoo/FooAsync. Go: конкурентность это свойство точки вызова (go), а функции остаются обычными, синхронными на вид; блокировка дёшева, цвета нет. - Task ≠ goroutine.
Task<T>возвращает результат и является хендлом; горутина не возвращает ничего — результат идёт через канал, ожидание черезWaitGroup/select, ошибки передаются явным значением. - Каналы Go ≈
Channel<T>, но сselectна уровне языка; TPL Dataflow в Go собирается вручную из каналов. sync.Mutexнереентерабельный — главная ловушка для пришедших из C# с реентерабельнымlock.context.ContextбогачеCancellationToken: отмена + дедлайн + значения единым прокидываемым объектом.- GMP мультиплексирует горутины на потоки ОС с work-stealing — концептуально как ThreadPool/TPL, но на уровне рантайма языка и над легковесными горутинами, а не делегатами.
На этом раздел о конкурентности завершён. Вы понимаете модель планировщика, семантику каналов и контекста, примитивы синхронизации и то, как всё это ложится на привычные конструкции .NET — и, что важнее, чем принципиально отличается.
⌂ Главная · ↑ Раздел · ← Предыдущий: Жизненный цикл горутины и закрытие ресурсов