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

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

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

Слово «атомарно» здесь означает не то же, что в транзакциях базы: операция неделима относительно других наблюдателей, никто не увидит половину - и никакого откатa при этом не существует. Разбор всех трёх смыслов термина - в уроке про транзакции.

sync.Mutex

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

// NewSafeCounter - обязательный конструктор, а не удобство.
//
// Нулевое значение SafeCounter непригодно: map после `var c SafeCounter`
// равна nil, и первый же Inc даёт panic «assignment to entry in nil map».
// Мьютекс при этом рабочий, поэтому падение выглядит как проблема
// конкурентности, хотя причина в незаполненной карте.
//
// Правило общее: если нулевое значение типа непригодно, у типа должен быть
// конструктор - иначе `var x T` компилируется и падает в рантайме.
func NewSafeCounter() *SafeCounter {
    return &SafeCounter{v: make(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 обычно быстрее: читатели проходят одновременно, писатель получает эксклюзив.

RWMutex сложнее обычного мьютекса, и на коротких критических секциях эта сложность съедает выигрыш: `RLock`/`RUnlock` делают больше атомарных операций, чем `Lock`/`Unlock`, а на многих ядрах ещё и дёргают одну общую кеш-линию. На чтении из map по ключу разница может быть не в пользу RWMutex.

Плюс два ограничения, о которых узнают на отладке:

  • он не рекурсивный. Взял RLock и внутри вызвал функцию, которая берёт RLock ещё раз, - в норме это работает, но если между ними встанет ожидающий писатель, получишь дедлок: новые читатели блокируются, чтобы писатель не голодал;
  • RLock не превращается в Lock. Апгрейда нет: нужно отпустить чтение, взять запись и перепроверить условие - за это время состояние могло измениться.

Что делать: брать sync.Mutex по умолчанию, а RWMutex - когда замер на своей нагрузке показал выигрыш. go test -bench с -cpu=1,4,8 покажет разницу быстрее любых рассуждений.

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;

use PDO;

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.

`sync.Pool` не обещает сохранить положенное. Сборщик мусора очищает пул на каждом цикле, поэтому:
  • Get может вернуть свежий объект от New - в том числе сразу после Put. Считать, что объект «наш» и что-то в нём сохранилось, нельзя;
  • если New не задан, Get вернёт nil - это самый частый паникующий сценарий с пулами;
  • объект надо очищать перед возвратом (buf.Reset() в примере выше), иначе следующий владелец получит чужие данные. Для буферов это утечка данных между запросами, а не просто беспорядок;
  • не кладите в пул то, чей размер непредсказуем. Разросшийся до мегабайт буфер будет жить в пуле и держать память; в стандартной библиотеке из-за этого проверяют cap перед Put.

И главное: пул имеет смысл, когда профилирование показало давление на аллокатор. Добавленный «на всякий случай» он усложняет код и не меняет ничего, а иногда делает хуже - объекты живут дольше и переезжают в старшие поколения.

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;

use PDO;

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()

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