Горутины под капотом: планировщик и GMP

Горутины под капотом: планировщик и GMP

В базовом курсе Go мы запускали горутины через go. Теперь разберём, что происходит дальше: как runtime распределяет горутины по потокам ОС, как растёт стек, и почему горутины «утекают».

Модель GMP

GMP: P держит run queue из G, M исполняет; пустой P крадёт половину очереди соседа

Go runtime использует три сущности:

  • G (Goroutine) - задача для выполнения
  • M (Machine) - поток операционной системы
  • P (Processor) - логический процессор, содержит локальную очередь G
P0: [G1, G2, G3] → M0 (OS Thread)
P1: [G4, G5]     → M1 (OS Thread)
P2: [G6]         → M2 (OS Thread)

По умолчанию количество P равно числу CPU ядер (runtime.GOMAXPROCS). Каждый M должен «захватить» свободный P, чтобы исполнять горутины. Это позволяет планировщику работать без глобального лока в большинстве случаев - каждый P имеет собственную run queue.

Work stealing

Когда локальная очередь P пустеет, планировщик не оставляет M простаивать. M пытается украсть половину горутин из очереди другого P. Если воровать нечего, M проверяет глобальную очередь, затем network poller, и только потом паркуется. Это балансирует нагрузку без центральной координации.

Preemption: как горутина «отдаёт» процессор

  • До Go 1.14 - кооперативная преемпция: горутина переключалась только в «безопасных точках» (вход в функцию, чтение/запись канала, аллокация). CPU-bound цикл без вызовов функций мог занять P навсегда.
  • С Go 1.14 - signal-based преемпция: runtime посылает SIGURG горутине, превысившей квант (~10 мс), и принудительно её снимает.
// До Go 1.14 этот цикл мог заблокировать P, не давая другим G исполняться
for {
    x++ // нет точек переключения - runtime не может прервать
}
<?php
declare(strict_types=1);

// Аналог в ReactPHP: длинный CPU-цикл блокирует event loop
React\EventLoop\Loop::addPeriodicTimer(0.1, function (): void {
    echo "tick\n"; // НЕ выполнится, пока ниже идёт цикл
});

$x = 0;
while (true) {
    $x++; // ни один callback из loop не получит управление
}

// Идиоматичное решение - max_execution_time + вынос тяжёлого в Symfony Messenger
// или явная отдача управления через Loop::futureTick():
function chunkedWork(int $total, int $chunk = 1000): void
{
    $done = 0;
    $tick = function () use (&$done, &$tick, $total, $chunk): void {
        for ($i = 0; $i < $chunk && $done < $total; $i++, $done++) {
            // работа
        }
        if ($done < $total) {
            React\EventLoop\Loop::futureTick($tick); // вернуть управление loop'у
        }
    };
    $tick();
}

В PHP нет преемптивного планировщика на уровне языка. CPU-bound цикл блокирует весь request в FPM до завершения. Если запустить такой код в ReactPHP event loop, он заблокирует все остальные I/O-callback'и - кооперативная многозадачность не «вытесняет» сама.

Syscall handoff

Когда горутина уходит в блокирующий системный вызов (например, read файла), M залипает в ядре. Runtime отвязывает P от M, передаёт P свободному (или новому) M, и тот продолжает исполнять другие горутины. Когда syscall завершится, исходный M попытается вернуть свой P или встанет в спящий пул.

Стек горутины

Goroutine vs OS thread: в 1000 раз меньше стека и в 100 раз быстрее запуск

Горутина начинает с маленького стека (2-8 KB) и растёт по мере необходимости.

func main() {
    // Обычный поток ОС: ~1-8 MB стека
    // Горутина: ~2-8 KB начального стека
    fmt.Printf("Горутин до: %d\n", runtime.NumGoroutine())

    var wg sync.WaitGroup
    for i := 0; i < 100_000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            time.Sleep(time.Second)
        }()
    }

    fmt.Printf("Горутин: %d\n", runtime.NumGoroutine())
    wg.Wait()
}
<?php

declare(strict_types=1);

namespace App\Concurrency;

use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;

final readonly class SleepTask
{
    public function __construct(public int $id) {}
}

#[AsMessageHandler]
final readonly class SleepTaskHandler
{
    public function __invoke(SleepTask $task): void
    {
        sleep(1); // задача в отдельном воркер-процессе
    }
}

final readonly class TaskDispatcher
{
    public function __construct(private MessageBusInterface $bus) {}

    public function dispatchMany(int $count): void
    {
        for ($i = 0; $i < $count; $i++) {
            // НЕ запускаем процесс - кладём в очередь (Redis/AMQP)
            $this->bus->dispatch(new SleepTask($i));
        }
    }
}

В PHP нет native goroutines и собственного планировщика типа GMP. PHP-FPM запускает request → выполняет синхронно → завершается. Чтобы «запустить 100 000 задач параллельно», нужно либо вынести их в очередь (Symfony Messenger async transport + N воркеров), либо использовать event loop (ReactPHP), либо отдельную coroutine-runtime (Swoole go(function () {...})). Промышленный референс - Symfony Messenger: dispatch в очередь, воркеры consume.

Параллелизм даёт пул воркеров: bin/console messenger:consume async --limit=100000 запускает один процесс, для масштабирования - supervisor с N экземплярами. «Горутин» нет, есть M независимых OS-процессов.

Как именно стек растёт

  • До Go 1.4 - segmented stacks: при нехватке места выделялся новый сегмент, связанный с предыдущим. Проблема - «hot split»: цикл, балансирующий на границе сегментов, постоянно платил за аллокацию/освобождение.
  • С Go 1.4 - contiguous stacks: при нехватке места runtime аллоцирует буфер вдвое больше, копирует содержимое старого стека и обновляет указатели. Одноразовая стоимость роста, нет hot split.

Максимальный размер стека по умолчанию - 1 GB (debug.SetMaxStack). Выход за лимит - fatal error: stack overflow.

Escape analysis

Если компилятор видит, что переменная переживает функцию (например, возвращается через указатель), он размещает её на heap, а не на стеке. Это влияет на GC pressure:

go build -gcflags="-m" main.go
# main.go:10:2: moved to heap: x  ← переменная ушла на heap

Утечки горутин

Горутина, которая никогда не завершится - утечка. Она занимает стек, мешает GC освободить переменные, на которые горутина держит ссылки, и в долгоживущих сервисах съедает память до OOM.

Сценарий 1: чтение из канала без писателя

// УТЕЧКА: никто не пишет в канал и не закрывает его
func leak() <-chan int {
    ch := make(chan int)
    go func() {
        val := <-ch // заблокирована навсегда
        fmt.Println(val)
    }()
    return ch
}
<?php
declare(strict_types=1);

// PHP-эквивалент в ReactPHP: Promise без resolve - callback'и накапливаются в loop
use React\Promise\Deferred;

final class LeakyConsumer
{
    public function leak(): \React\Promise\PromiseInterface
    {
        $deferred = new Deferred();
        // никто не вызовет $deferred->resolve() - then-handler живёт в памяти loop'а
        return $deferred->promise()->then(static function (int $val): void {
            echo $val;
        });
    }
}

// Защита: всегда выставлять timeout
use React\Promise\Timer;

$promise = Timer\timeout($deferred->promise(), 5.0, React\EventLoop\Loop::get());
// reject через 5 секунд, если никто не resolve'нул

Сценарий 2: запись в канал без читателя

// УТЕЧКА: producer пишет, consumer ушёл по таймауту
func produce(ctx context.Context) <-chan int {
    out := make(chan int) // unbuffered
    go func() {
        for i := 0; i < 1000; i++ {
            out <- i // зависнет на втором итерации, если consumer ушёл
        }
    }()
    return out
}
<?php
declare(strict_types=1);

// PHP-эквивалент в Symfony Messenger: producer кладёт в очередь, никто не consume.
// Очередь растёт - аналог «канал переполнен», но без потери сообщений.
final class LeakyProducer
{
    public function __construct(
        private readonly \Symfony\Component\Messenger\MessageBusInterface $bus,
    ) {}

    public function produceMany(int $count): void
    {
        for ($i = 0; $i < $count; $i++) {
            $this->bus->dispatch(new Job($i));
            // Если consumer-воркер не запущен, очередь Redis/AMQP растёт до OOM брокера
        }
    }
}

// Защита: queue с TTL для сообщений (Redis: stream MAXLEN, AMQP: x-message-ttl)
// + мониторинг lag через messenger:stats

Сценарий 3: незакрытый ticker

func leakyTicker() {
    ticker := time.NewTicker(time.Second)
    go func() {
        for range ticker.C {
            // работа
        }
    }()
    // ticker.Stop() никогда не вызван - горутина и канал живут вечно
}
<?php
declare(strict_types=1);

// PHP-эквивалент в ReactPHP: периодический timer без cancelTimer()
use React\EventLoop\Loop;

final class LeakyTicker
{
    public function start(): void
    {
        $timer = Loop::addPeriodicTimer(1.0, static function (): void {
            // работа каждую секунду
        });
        // Если не вызвать Loop::cancelTimer($timer) - таймер живёт до конца процесса
    }

    public function safeStart(int $maxTicks): void
    {
        $count = 0;
        $timer = null;
        $timer = Loop::addPeriodicTimer(1.0, static function () use (&$count, &$timer, $maxTicks): void {
            if (++$count >= $maxTicks && $timer !== null) {
                Loop::cancelTimer($timer); // явный путь выхода
            }
        });
    }
}

Сценарий 4: забытый cancel от context.WithCancel

// УТЕЧКА: cancel не вызван → внутренние горутины context не освобождаются
func bad() {
    ctx, _ := context.WithCancel(context.Background())
    doWork(ctx)
    // забыли defer cancel()
}

Линтер govet-printf.funcs) и staticcheck (правило SA1029) ловят такие случаи.

<?php
declare(strict_types=1);

// В PHP нет context.Context. Эквивалентом «cancel» при долгой операции служит:
// - max_execution_time (set_time_limit) для FPM
// - --time-limit для Messenger воркера
// - Doctrine\DBAL\Connection statement_timeout для SQL

use Symfony\Component\Stopwatch\Stopwatch;

final class TimedWorker
{
    public function __construct(private readonly Stopwatch $stopwatch) {}

    public function process(iterable $items, float $deadlineSec): void
    {
        $this->stopwatch->start('process');
        foreach ($items as $item) {
            if ($this->stopwatch->getEvent('process')->getDuration() / 1000 >= $deadlineSec) {
                throw new \RuntimeException('deadline exceeded');
            }
            $this->handle($item);
        }
    }

    private function handle(mixed $item): void {}
}

В PHP-FPM «утечка горутины» внутри одного request невозможна - процесс завершится в конце request. Реальные эквиваленты: воркер Symfony Messenger не освобождает память (накапливает объекты), либо ReactPHP Promise не резолвится никогда (event loop держит callback в памяти), либо Swoole-таска зависла. Решение - таймауты на уровне очереди и $worker->stop() по --time-limit.

<?php

declare(strict_types=1);

namespace App\Concurrency;

use Psr\Log\LoggerInterface;
use React\EventLoop\Loop;
use React\Promise\Deferred;
use React\Promise\PromiseInterface;

final readonly class LeakyPromise
{
    public function __construct(private LoggerInterface $logger) {}

    public function badFetch(): PromiseInterface
    {
        $deferred = new Deferred();
        // УТЕЧКА: никто не вызовет $deferred->resolve() - callback живёт вечно
        return $deferred->promise();
    }

    public function safeFetch(int $timeoutMs): PromiseInterface
    {
        $deferred = new Deferred();
        $timer = Loop::addTimer($timeoutMs / 1000, function () use ($deferred): void {
            $deferred->reject(new \RuntimeException('timeout'));
        });

        // в реальной задаче resolve вызовется по результату I/O
        return $deferred->promise()->finally(static fn () => Loop::cancelTimer($timer));
    }
}

Запускать воркер Messenger с лимитом: messenger:consume async --time-limit=3600 --memory-limit=128M - аналог «явного пути завершения».

Защита от утечек: паттерны

Done-канал

func worker(done <-chan struct{}, jobs <-chan Job) {
    for {
        select {
        case <-done:
            return // явный путь выхода
        case j := <-jobs:
            process(j)
        }
    }
}
<?php
declare(strict_types=1);

// PHP-эквивалент через Symfony Messenger: воркер сам проверяет shouldStop()
use Symfony\Component\Messenger\Event\WorkerRunningEvent;
use Symfony\Component\Messenger\EventListener\StopWorkerOnSignalsListener;

// Symfony автоматически слушает SIGTERM/SIGINT и вызывает $worker->stop().
// В свежем worker loop'е перед обработкой следующего message проверяется shouldStop:
// аналог `<-done` в Go.

// Можно явно остановить:
final class GracefulStopper
{
    public function __construct(
        private readonly \Symfony\Component\EventDispatcher\EventDispatcherInterface $events,
    ) {}

    public function stopAfter(int $messages): void
    {
        $count = 0;
        $this->events->addListener(WorkerRunningEvent::class, static function (WorkerRunningEvent $e) use (&$count, $messages): void {
            if (++$count >= $messages) {
                $e->getWorker()->stop();
            }
        });
    }
}

Context propagation

func worker(ctx context.Context, jobs <-chan Job) {
    for {
        select {
        case <-ctx.Done():
            return
        case j := <-jobs:
            process(ctx, j) // context дальше по цепочке
        }
    }
}
<?php
declare(strict_types=1);

// PHP-эквивалент context.Context - request-scoped DI-сервис, передающий correlation_id,
// deadline и cancellation signal через всю цепочку вызовов.
final class RequestContext
{
    private bool $cancelled = false;
    private readonly float $deadlineTs;

    public function __construct(
        public readonly string $correlationId,
        float $timeoutSec,
    ) {
        $this->deadlineTs = microtime(true) + $timeoutSec;
    }

    public function cancel(): void { $this->cancelled = true; }

    public function isDone(): bool
    {
        return $this->cancelled || microtime(true) >= $this->deadlineTs;
    }

    public function ensureNotDone(): void
    {
        if ($this->isDone()) {
            throw new \RuntimeException('context cancelled or deadline exceeded');
        }
    }
}

final class WorkerService
{
    public function process(RequestContext $ctx, iterable $jobs): void
    {
        foreach ($jobs as $job) {
            $ctx->ensureNotDone();
            $this->handle($ctx, $job); // ctx идёт дальше по цепочке
        }
    }

    private function handle(RequestContext $ctx, mixed $job): void {}
}

Правило: каждая горутина должна иметь явный путь завершения - либо канал закроется, либо ctx.Done() сработает, либо отработает счётчик задач.

Обнаружение утечек

// В тестах: проверяем количество горутин
func TestNoLeaks(t *testing.T) {
    before := runtime.NumGoroutine()

    // ... тестируемый код ...

    time.Sleep(100 * time.Millisecond)
    after := runtime.NumGoroutine()

    if after > before+1 {
        t.Errorf("goroutine leak: before=%d, after=%d", before, after)
    }
}

// Через pprof
import _ "net/http/pprof"

go func() {
    log.Println(http.ListenAndServe(":6060", nil))
}()
// go tool pprof http://localhost:6060/debug/pprof/goroutine
<?php
declare(strict_types=1);

// PHP не имеет «горутин», но есть аналог для long-running воркеров:
// проверка memory_get_usage() и количества подписок event-loop'а.
use PHPUnit\Framework\TestCase;
use React\EventLoop\Loop;

final class WorkerLeakTest extends TestCase
{
    public function testNoMemoryLeak(): void
    {
        $before = memory_get_usage();

        // ... тестируемый код через event loop ...
        $service = new EventLoopService();
        $service->run(iterations: 1000);

        gc_collect_cycles();
        $after = memory_get_usage();

        // ReactPHP loop должен освободить все timer/promise callback'и
        self::assertLessThan($before * 1.05, $after, 'memory grew >5% - possible leak');
    }
}

// Для production-мониторинга: prometheus-метрика messenger_queue_size + memory_get_usage
// плюс php-prometheus-exporter для счётчиков queue lag.
Пакет `go.uber.org/goleak` автоматически проверяет утечки горутин в тестах. Вызов `goleak.VerifyNone(t)` в конце теста падает, если осталась лишняя горутина - без ручного подсчёта.

Паттерны конкурентности

Worker pool

Фиксированное число горутин обрабатывает поток задач. Защищает от «горутина на задачу» при больших всплесках.

func workerPool(ctx context.Context, jobs <-chan Job, workers int) {
    var wg sync.WaitGroup
    for i := 0; i < workers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for j := range jobs {
                select {
                case <-ctx.Done():
                    return
                default:
                    process(j)
                }
            }
        }()
    }
    wg.Wait()
}

В PHP worker pool - это не «горутины в одном процессе», а N независимых процессов-воркеров. Конфигурируется через supervisor (systemd/supervisord), где каждый воркер - bin/console messenger:consume. Канал - очередь (Redis/AMQP), worker pool - конфигурация supervisord.

; /etc/supervisor/conf.d/messenger.conf
[program:messenger-async]
command=php /app/bin/console messenger:consume async --time-limit=3600
numprocs=4
process_name=%(program_name)s_%(process_num)02d
autostart=true
autorestart=true

Альтернатива для in-process pool - Swoole:

<?php

declare(strict_types=1);

namespace App\Concurrency;

use Swoole\Coroutine;
use Swoole\Coroutine\Channel;

final readonly class SwoolePool
{
    public function run(array $jobs, int $workers): void
    {
        $channel = new Channel($workers);

        Coroutine\run(function () use ($jobs, $workers, $channel): void {
            for ($i = 0; $i < $workers; $i++) {
                Coroutine::create(function () use ($channel): void {
                    while (($job = $channel->pop()) !== false) {
                        $this->process($job);
                    }
                });
            }

            foreach ($jobs as $job) {
                $channel->push($job);
            }
            $channel->close();
        });
    }

    private function process(mixed $job): void
    {
        // I/O-bound работа
    }
}

Fan-out / Fan-in

Один источник → несколько обработчиков → один сток. Используется для CPU-bound параллелизации.

func fanOut(in <-chan int, workers int) []<-chan int {
    outs := make([]<-chan int, workers)
    for i := 0; i < workers; i++ {
        out := make(chan int)
        outs[i] = out
        go func() {
            defer close(out)
            for v := range in {
                out <- heavyCompute(v)
            }
        }()
    }
    return outs
}
<?php
declare(strict_types=1);

// PHP-эквивалент через Symfony Messenger: один transport - один очередь,
// N воркеров (numprocs=N в supervisord) - fan-out параллелизация.
use Symfony\Component\Messenger\MessageBusInterface;

final readonly class HeavyComputeMessage
{
    public function __construct(public int $value) {}
}

final class FanOutDispatcher
{
    public function __construct(private readonly MessageBusInterface $bus) {}

    public function dispatchAll(iterable $values): void
    {
        // dispatch в общий transport - воркеры разберут параллельно
        foreach ($values as $v) {
            $this->bus->dispatch(new HeavyComputeMessage($v));
        }
    }
}

// Результаты собираются через отдельную result-очередь или через DB - fan-in.
// Запуск: supervisord с 4 workers consuming тот же transport.

Pipeline

Цепочка стадий, каждая в своей горутине, соединённых каналами. Backpressure обеспечивается размером буфера канала.

nums := generate(ctx)        // stage 1
squared := square(ctx, nums) // stage 2
for v := range squared {     // stage 3 (consumer)
    fmt.Println(v)
}
<?php
declare(strict_types=1);

// PHP-эквивалент в Symfony Messenger - chain of message handlers через несколько transport'ов:
// transport "generated" → handler → dispatch в "squared" → handler → dispatch в "consumed"
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;

final readonly class GeneratedNumber { public function __construct(public int $n) {} }
final readonly class SquaredNumber   { public function __construct(public int $n) {} }

#[AsMessageHandler]
final readonly class SquareStage
{
    public function __construct(private MessageBusInterface $bus) {}

    public function __invoke(GeneratedNumber $msg): void
    {
        $this->bus->dispatch(new SquaredNumber($msg->n * $msg->n));
    }
}

#[AsMessageHandler]
final readonly class ConsumeStage
{
    public function __invoke(SquaredNumber $msg): void
    {
        echo $msg->n . "\n";
    }
}
// Backpressure - max-size + DLQ для каждого transport'а в messenger.yaml

Когда горутина не бесплатна

Создание горутины стоит 2-8 KB стека + регистрация в P + потенциальная GC pressure на переменных, ушедших на heap. На малых задачах это перевешивает выгоду от параллелизма.

СценарийЗапускать горутину?
HTTP-handler на запросДа - фреймворк уже делает это
Лёгкая чистая функция (< 1 мкс)Нет - синхронный вызов быстрее
CPU-bound вычисление с большими даннымиWorker pool, не «горутина на элемент»
I/O fan-out (DB + HTTP параллельно)Да - errgroup
Очередь задач из каналаWorker pool

Эмпирически: если задача < 10 мкс, оверхед горутины съест выгоду. Бенчмарк с b.RunParallel подтверждает или опровергает гипотезу.

runtime полезности

runtime.NumGoroutine()     // количество горутин
runtime.NumCPU()           // количество CPU ядер
runtime.GOMAXPROCS(0)      // текущее GOMAXPROCS (0 = чтение)
runtime.Gosched()          // уступить время другим горутинам (редко нужен с 1.14+)
runtime.Goexit()           // завершить текущую горутину (defer'ы выполнятся)
<?php
declare(strict_types=1);

// PHP-аналоги для интроспекции воркеров и процессов:
$workerCount = (int) shell_exec("pgrep -c -f 'messenger:consume async' || echo 0");
$cpuCount = (int) shell_exec('nproc'); // или sysctl -n hw.ncpu на macOS

// Память процесса:
$mem = memory_get_usage(real_usage: true);
$peak = memory_get_peak_usage(real_usage: true);

// «Уступить управление» в ReactPHP loop:
React\EventLoop\Loop::futureTick(static fn () => null); // отдать тик loop'у

// Корректное завершение Messenger воркера:
final class WorkerStopper
{
    public function stopGracefully(\Symfony\Component\Messenger\Worker $worker): void
    {
        $worker->stop(); // обработает текущее message и выйдет
    }
}

Типичные ошибки

1. Захват переменной цикла (до Go 1.22)

// БАГ до Go 1.22: все горутины напечатают одно и то же значение i
for i := 0; i < 5; i++ {
    go func() {
        fmt.Println(i) // i делится между всеми горутинами
    }()
}

// FIX: передать i параметром
for i := 0; i < 5; i++ {
    go func(i int) {
        fmt.Println(i)
    }(i)
}
<?php
declare(strict_types=1);

// В PHP замыкание захватывает переменные ПО ЗНАЧЕНИЮ (по умолчанию), а не по ссылке.
// Аналогичной ловушки нет - каждый callback видит свой $i:
$callbacks = [];
for ($i = 0; $i < 5; $i++) {
    $callbacks[] = static function () use ($i): void {
        echo $i . "\n"; // 0, 1, 2, 3, 4
    };
}
foreach ($callbacks as $cb) $cb();

// А вот если использовать `use (&$i)` - ловушка возвращается:
$callbacks = [];
for ($i = 0; $i < 5; $i++) {
    $callbacks[] = static function () use (&$i): void {
        echo $i . "\n"; // 5, 5, 5, 5, 5 - захват по ссылке
    };
}

С Go 1.22 каждая итерация создаёт новую переменную i - баг исчез. Но в кодовых базах на старых версиях ловушка живёт.

2. wg.Add внутри горутины

// БАГ: wg.Wait может пройти до того, как Add успеет выполниться
for _, item := range items {
    go func() {
        wg.Add(1) // СЛИШКОМ ПОЗДНО
        defer wg.Done()
        process(item)
    }()
}
wg.Wait()
<?php
declare(strict_types=1);

// PHP-аналог в ReactPHP: Promise\all() ждёт завершения всех заранее переданных promises.
// Если добавлять promise В цикле resolve - аналогичная гонка: all() уже резолвится.
use function React\Promise\all;
use React\Promise\PromiseInterface;

final class BatchProcessor
{
    /** @param list<PromiseInterface> $tasks */
    public function processAll(array $tasks): PromiseInterface
    {
        // Правильно: список promises собран ДО all()
        return all($tasks);
    }
}

// Анти-паттерн через симфоновый MessageBatch - assert(count >= N) до dispatch:
final class SafeBatch
{
    public function dispatch(array $messages): void
    {
        assert(count($messages) > 0, 'batch must contain at least 1 message');
        // Только теперь dispatch - аналог wg.Add() до go-spawn
    }
}

Правило: wg.Add(n) вызывать до go, в синхронной части.

3. Race на shared state

// БАГ: конкурентная запись в map → panic «concurrent map writes»
m := map[string]int{}
for _, k := range keys {
    go func(k string) {
        m[k]++ // race
    }(k)
}
<?php
declare(strict_types=1);

// В PHP-FPM нет shared state между запросами - race в этом смысле невозможна.
// Но между Messenger воркерами или ReactPHP-промисами в одном loop'е race на
// shared resource (БД, Redis, файл) - реальна. Решение - distributed lock или
// atomic-операции на уровне хранилища.
use Symfony\Component\Lock\LockFactory;

final class SafeCounter
{
    public function __construct(
        private readonly \Redis $redis,
        private readonly LockFactory $lockFactory,
    ) {}

    // Без атомарности - race condition между воркерами:
    public function unsafeIncrement(string $key): void
    {
        $current = (int) $this->redis->get($key);
        $this->redis->set($key, $current + 1); // lost update при параллельных вызовах
    }

    // Идиоматично через INCR (atomic на уровне Redis):
    public function safeIncrement(string $key): int
    {
        return $this->redis->incr($key); // одна операция, atomic
    }
}

Решение - sync.Mutex, sync.Map, или sharded map. Запускать тесты через go test -race обязательно.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.