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) + проверка инвариантов после.
Типичные гонки
// 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или SwooleCoroutine\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
# ожидаем точное число запросов, любое расхождение - гонка