Race conditions и тестирование конкурентного кода

Race conditions и тестирование конкурентного кода

Data race - это когда две горутины одновременно обращаются к одной переменной и хотя бы одна из них пишет.

Гонка на счётчике: две горутины читают одно и то же значение, оба пишут одно и то же - один инкремент теряется

Детектор гонок

go run -race main.go
go test -race ./...
go build -race
// Этот код содержит data race
func main() {
    counter := 0

    var wg sync.WaitGroup
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            counter++ // DATA RACE!
        }()
    }

    wg.Wait()
    fmt.Println(counter)
}

Флаг -race добавляет ~10x overhead по CPU и ~5-10x по памяти. Используй в тестах, не в продакшене.

PHP-эквивалент гонки - конкурентный UPDATE без блокировки:

<?php

declare(strict_types=1);

namespace App\Concurrency;

use Doctrine\DBAL\Connection;

final readonly class RacyCounter
{
    public function __construct(private Connection $connection) {}

    public function increment(string $key): void
    {
        // ГОНКА: read-modify-write без блокировки
        $current = (int) $this->connection->fetchOne(
            'SELECT value FROM counters WHERE key = ?',
            [$key],
        );
        $this->connection->update('counters', ['value' => $current + 1], ['key' => $key]);
    }

    public function safeIncrement(string $key): void
    {
        // Атомарный UPDATE - гонка устранена на уровне БД
        $this->connection->executeStatement(
            'UPDATE counters SET value = value + 1 WHERE key = ?',
            [$key],
        );
    }
}

В PHP race detector отсутствует - shared memory между PHP-FPM процессами нет, расы внутри одного request крайне редки. Зато расы возможны на уровне БД (UPDATE без блокировки между двумя одновременными HTTP-запросами) и на уровне распределённых воркеров. Эквивалент -race - стресс-тесты под нагрузкой (ab, wrk, vegeta) + проверка инвариантов после.

go test -race: два стека вызовов сходятся в один адрес памяти, отчёт WARNING: DATA RACE

Типичные гонки

// 1. Чтение и запись map
var m = map[string]int{}
go func() { m["a"] = 1 }() // RACE
go func() { _ = m["a"] }() // RACE

// 2. Закрытие канала из нескольких горутин
ch := make(chan int)
go func() { close(ch) }() // возможна двойная close = panic
go func() { close(ch) }()

// 3. Слайс без защиты
var results []int
go func() { results = append(results, 1) }() // RACE
go func() { results = append(results, 2) }()
<?php
declare(strict_types=1);

// PHP-аналоги (распределённый уровень, не in-process):

// 1. Чтение и запись общего значения в Redis без MULTI/EXEC - lost update
final class RacyRedisCounter
{
    public function __construct(private readonly \Redis $redis) {}

    public function unsafeIncrement(string $key): void
    {
        $value = (int) $this->redis->get($key); // T1: 5
        $this->redis->set($key, $value + 1);    // T1 и T2 оба пишут 6, должно быть 7
    }
}

// 2. Двойное удаление: два воркера consume одну и ту же задачу без идемпотентности
final class NonIdempotentHandler
{
    public function __invoke(DeleteOrderMessage $msg): void
    {
        // Если два воркера получили message (at-least-once delivery) - двойное удаление
        $this->orders->delete($msg->orderId);
    }
}

// 3. Конкурентная запись в shared file без LOCK_EX:
file_put_contents('/tmp/results.json', json_encode([1, 2, 3])); // race без блокировки
file_put_contents('/tmp/results.json', json_encode([4, 5, 6]), LOCK_EX); // защищено

Тестирование конкурентного кода

func TestConcurrentCounter(t *testing.T) {
    c := NewSafeCounter()
    var wg sync.WaitGroup

    n := 1000
    for i := 0; i < n; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            c.Inc("key")
        }()
    }

    wg.Wait()

    if got := c.Value("key"); got != n {
        t.Errorf("got %d, want %d", got, n)
    }
}
<?php

declare(strict_types=1);

namespace App\Tests\Concurrency;

use App\Concurrency\RacyCounter;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\TestCase;

final class CounterRaceTest extends TestCase
{
    public function testSafeIncrementUnderLoad(): void
    {
        $connection = self::getConnection();
        $counter = new RacyCounter($connection);
        $connection->executeStatement('INSERT INTO counters (key, value) VALUES (?, 0)', ['test']);

        $workers = 10;
        $perWorker = 100;
        $pids = [];

        for ($i = 0; $i < $workers; $i++) {
            $pid = \pcntl_fork();
            if ($pid === 0) {
                $forkConn = self::getConnection(); // отдельное соединение
                $forkCounter = new RacyCounter($forkConn);
                for ($j = 0; $j < $perWorker; $j++) {
                    $forkCounter->safeIncrement('test');
                }
                exit(0);
            }
            $pids[] = $pid;
        }

        foreach ($pids as $pid) {
            \pcntl_waitpid($pid, $status);
        }

        $value = (int) $connection->fetchOne('SELECT value FROM counters WHERE key = ?', ['test']);
        self::assertSame($workers * $perWorker, $value);
    }

    private static function getConnection(): Connection
    {
        // фабрика тестового соединения
        throw new \LogicException('configure in real project');
    }
}

Конкурентность в PHPUnit тесте in-process не воспроизводится - один процесс, однопоточный. Реальный тест расы требует параллельных HTTP-запросов: либо через Symfony\WebTestCase + ReactPHP loop, либо внешний load-tool. Для БД-уровня - pgbench. Самый надёжный способ - unit-тест через pcntl_fork или Swoole Coroutine\run в CLI.

t.Parallel()

func TestFetch(t *testing.T) {
    tests := []struct {
        name string
        url  string
    }{
        {"google", "https://google.com"},
        {"github", "https://github.com"},
    }

    for _, tt := range tests {
        tt := tt
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel() // запускает подтесты параллельно
            resp, err := http.Get(tt.url)
            if err != nil {
                t.Fatal(err)
            }
            defer resp.Body.Close()
        })
    }
}
<?php
declare(strict_types=1);

// PHPUnit имеет paratest для параллельного запуска ТЕСТОВ (отдельные процессы),
// но не подтестов внутри одного. Аналог t.Parallel() - запуск через paratest:
// vendor/bin/paratest --processes=4 tests/

// Внутри одного теста параллелизм возможен через ReactPHP Promise\all:
use function React\Promise\all;
use React\Http\Browser;

final class FetchTest extends \PHPUnit\Framework\TestCase
{
    public function testFetchMultipleInParallel(): void
    {
        $browser = new Browser();
        $promises = [
            $browser->get('https://google.com'),
            $browser->get('https://github.com'),
        ];

        $results = \React\Async\await(all($promises));
        self::assertCount(2, $results);
    }
}

atomic для простых случаев

import "sync/atomic"

var ops atomic.Int64

func worker() {
    ops.Add(1)
}

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            worker()
        }()
    }
    wg.Wait()
    fmt.Println("ops:", ops.Load()) // всегда 1000
}
<?php

declare(strict_types=1);

namespace App\Concurrency;

use Predis\ClientInterface;

final readonly class AtomicCounter
{
    public function __construct(private ClientInterface $redis) {}

    public function increment(string $key): int
    {
        // Redis INCR - атомарная операция, аналог atomic.Int64.Add(1)
        return (int) $this->redis->incr($key);
    }

    public function get(string $key): int
    {
        return (int) $this->redis->get($key);
    }
}

Прямого sync/atomic в PHP нет (внутри одного процесса не нужен). Между процессами атомарные операции делегируются Redis (INCR, INCRBY) или БД (UPDATE ... SET value = value + 1). Это и есть «атомарный счётчик» для распределённой системы.

Стресс-тест через wrk или ab снаружи:

# 100 одновременных клиентов, 10000 запросов, проверяем итог
wrk -t10 -c100 -d10s http://localhost/counter/inc
redis-cli GET counters:test
# ожидаем точное число запросов, любое расхождение - гонка
Всегда запускай `go test -race` в CI. Data race - это undefined behavior. Программа может работать «правильно» на твоей машине и падать в продакшене.

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