Skip to content

Latest commit

 

History

History
248 lines (191 loc) · 22.3 KB

File metadata and controls

248 lines (191 loc) · 22.3 KB

Сравнение с .NET

Это итоговая глава раздела — консолидированный «мостик» между конкурентностью в .NET и в Go. Здесь мы собираем воедино то, что разбиралось в предыдущих главах, и явно проговариваем главное фундаментальное различие двух моделей: async/await с «цветом функций» против горутин без цвета. Если вы усвоите только один раздел этого учебника — пусть это будет он, потому что именно здесь чаще всего ломается интуиция .NET-разработчика.

Фундаментальное различие: «цвет функции» vs его отсутствие

Как это устроено в .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

В 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 vs goroutine: где результат?

Различие, на котором спотыкаются в первый день.

  • 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.

Каналы: chan + select vs Channels / TPL Dataflow

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.

lock() vs sync.Mutex

Подробно — в главе 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-метод без блокировки.

CancellationToken vs context.Context

Подробно — в главе 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(); «магического» прерывания горутины извне нет.

SemaphoreSlim vs буферизованный канал

Ограничение числа одновременных операций (например, не больше 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 по возможностям.

ThreadPool / планировщик задач vs планировщик GMP

Подробно — в главе 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

Концепция / задача .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 — и, что важнее, чем принципиально отличается.


⌂ Главная · ↑ Раздел · ← Предыдущий: Жизненный цикл горутины и закрытие ресурсов