Пакет sync: мьютексы, пулы и одноразовая инициализация

Пакет sync: мьютексы, пулы и одноразовая инициализация

Каналы - не единственный способ синхронизации. Пакет sync даёт низкоуровневые примитивы.

sync.Mutex

type SafeCounter struct {
    mu sync.Mutex
    v  map[string]int
}

func (c *SafeCounter) Inc(key string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.v[key]++
}

func (c *SafeCounter) Value(key string) int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.v[key]
}
<?php

declare(strict_types=1);

namespace App\Concurrency;

use Symfony\Component\Lock\LockFactory;

final readonly class SafeCounter
{
    public function __construct(
        private LockFactory $lockFactory,
        private CounterStorageInterface $storage,
    ) {}

    public function inc(string $key): void
    {
        $lock = $this->lockFactory->createLock(\sprintf('counter:%s', $key));
        $lock->acquire(true); // блокирующий
        try {
            $this->storage->increment($key);
        } finally {
            $lock->release();
        }
    }
}

В PHP-FPM in-process mutex не нужен: каждый request - отдельный процесс, shared memory между запросами нет. Реальная задача - синхронизация между процессами/воркерами/серверами. Промышленный референс - symfony/lock с file/Redis/Postgres backend (distributed lock). Скорость - миллисекунды, а не наносекунды, как у sync.Mutex.

Конфигурация Redis-lock:

framework:
    lock:
        counter: '%env(REDIS_URL)%' # redis://redis:6379

Альтернатива - БД-уровень через Postgres advisory lock или SELECT ... FOR UPDATE в транзакции.

sync.RWMutex

Когда чтений много, а записей мало - RWMutex эффективнее.

Mutex сериализует всё; RWMutex пропускает читателей параллельно, писатель эксклюзивен

type Cache struct {
    mu   sync.RWMutex
    data map[string]string
}

func (c *Cache) Get(key string) (string, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    val, ok := c.data[key]
    return val, ok
}

func (c *Cache) Set(key, value string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.data[key] = value
}
<?php

declare(strict_types=1);

namespace App\Concurrency;

use Symfony\Component\Cache\Adapter\RedisAdapter;
use Symfony\Component\Lock\LockFactory;
use Symfony\Contracts\Cache\ItemInterface;

final readonly class DistributedCache
{
    public function __construct(
        private RedisAdapter $cache,
        private LockFactory $lockFactory,
    ) {}

    public function get(string $key): ?string
    {
        return $this->cache->get($key, static fn (ItemInterface $item): ?string => null);
    }

    public function set(string $key, string $value): void
    {
        $lock = $this->lockFactory->createLock(\sprintf('cache:%s', $key));
        $lock->acquire(true);
        try {
            $item = $this->cache->getItem($key);
            $item->set($value);
            $this->cache->save($item);
        } finally {
            $lock->release();
        }
    }
}

symfony/lock не различает read/write локи - это всегда эксклюзивная блокировка. В монопроцессном PHP-FPM RWMutex и не нужен: shared memory нет. Если задача в распределённой системе и read-heavy - используют cache layer (symfony/cache с Redis), писатели идут через lock, читатели читают из кеша без блокировки.

sync.Once

Гарантирует, что функция вызовется ровно один раз.

var (
    instance *Database
    once     sync.Once
)

func GetDB() *Database {
    once.Do(func() {
        instance = connectToDatabase()
    })
    return instance
}
<?php

declare(strict_types=1);

namespace App\Concurrency;

use Doctrine\DBAL\Connection;

// сервис shared по умолчанию - инициализируется один раз на процесс
final readonly class DatabaseProvider
{
    public function __construct(private Connection $connection) {}

    public function get(): Connection
    {
        return $this->connection;
    }
}

В PHP-FPM «однократная инициализация» автоматическая на уровне DI-контейнера: Symfony создаёт singleton-сервис один раз в lifecycle процесса. Эквивалент sync.Once - service definition с дефолтным shared: true (по умолчанию). Между запросами FPM состояние не разделяет, кеш bootcache решает холодный старт.

Для lazy-init внутри одного request - private property + ??=:

<?php

declare(strict_types=1);

namespace App\Concurrency;

final class LazyResource
{
    private ?\PDO $pdo = null;

    public function __construct(private readonly string $dsn) {}

    public function pdo(): \PDO
    {
        return $this->pdo ??= new \PDO($this->dsn);
    }
}

sync.Map

Оптимизированный для конкурентного доступа map (как устроен изнутри). Полезен когда ключи стабильны или при disjoint access.

var cache sync.Map

cache.Store("key", "value")

val, ok := cache.Load("key")
if ok {
    fmt.Println(val.(string))
}

cache.Range(func(key, value any) bool {
    fmt.Printf("%v: %v\n", key, value)
    return true // false чтобы остановить
})
Для большинства случаев обычный `map` + `sync.RWMutex` быстрее. `sync.Map` хорош для кешей с read-heavy нагрузкой и для случаев, когда разные горутины работают с разными ключами.

sync.Pool

Повторное использование объектов для снижения нагрузки на GC.

var bufferPool = sync.Pool{
    New: func() any {
        return new(bytes.Buffer)
    },
}

func processRequest() {
    buf := bufferPool.Get().(*bytes.Buffer)
    defer func() {
        buf.Reset()
        bufferPool.Put(buf)
    }()

    buf.WriteString("processing...")
    // используем buf
}
<?php

declare(strict_types=1);

namespace App\Concurrency;

final class ConnectionPool
{
    /** @var list<\PDO> */
    private array $available = [];

    public function __construct(
        private readonly string $dsn,
        private readonly int $maxSize = 10,
    ) {}

    public function acquire(): \PDO
    {
        return \array_pop($this->available) ?? new \PDO($this->dsn);
    }

    public function release(\PDO $pdo): void
    {
        if (\count($this->available) < $this->maxSize) {
            $this->available[] = $pdo;
        }
    }
}

Прямого аналога sync.Pool в PHP нет: между запросами FPM зачистит память, внутри одного request объект-аллокации редко становятся узким местом. Если и нужен «пул объектов», то для тяжёлых ресурсов (DB-соединения, gRPC клиенты) - это решается на уровне DI-контейнера (singleton-сервисы) или connection pooling в Doctrine. Для длинноживущих воркеров (Swoole / ReactPHP / Symfony Messenger) можно делать пул вручную через массив.

sync.Cond (редко нужен)

var (
    mu    sync.Mutex
    cond  = sync.NewCond(&mu)
    ready bool
)

// Ожидающая горутина
go func() {
    mu.Lock()
    for !ready {
        cond.Wait() // атомарно: Unlock → sleep → Lock
    }
    fmt.Println("ready!")
    mu.Unlock()
}()

// Сигнализирующая горутина
mu.Lock()
ready = true
cond.Signal() // или cond.Broadcast() для всех
mu.Unlock()

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