ОсновиPool

Pool

У главі про PDO Pool ми накидали пул на основі каналу в шість рядків і одразу визнали, що для реального життя він наївний: треба не забути повернути ресурс хай там що, розпізнавати зламані з'єднання і замінювати мертві. Для PDO ядро зробило все це за нас. Але checkAddress спілкується з GeoDirectory по HTTP, і відкривати свіже з'єднання до нього на кожен запит теж було б марнотратно. А завтра з'явиться Redis для кешування і SMTP для пошти.

Певно ж, ми не будемо власноруч будувати канальний пул для кожного окремого ресурсу. Саме так, для цього є готовий примітив, той самий, що працює під капотом PDO Pool: Async\Pool.

Фабрика і деструктор

Пул не знає, який саме ресурс він тримає. Ти повідомляєш йому дві речі: як створити ресурс і як знищити:

php
use Async\Pool;

$geo = new Pool(
    factory:    fn() => new GeoConnection('geodirectory.example.com'),
    destructor: fn($conn) => $conn->close(),
    min: 2,
    max: 10,
);

min: 2 означає, що два з'єднання відкриваються наперед, тож першим корутинам не доведеться чекати на рукостискання. max: 10 означає, що пул ніколи не створить більше десяти з'єднань, скільки б корутин їх не просило. Це знайомий ліміт конкурентності, тільки тепер він прив'язаний до самого ресурсу, а не до кількості воркерів.

Далі йде цикл, який ми вже будували вручну з каналом:

php
$conn = $geo->acquire();

try {
    $verdict = $conn->request("/check?address=$address");
} finally {
    $geo->release($conn);
}

acquire видає вільний ресурс. Якщо вільних немає, а ліміт не досягнуто, фабрика створює новий. Якщо ліміт досягнуто, корутина засинає, доки хтось інший не викличе release: та сама механіка, що й у recv на порожньому каналі. Зверни увагу на finally: ресурс повертається хай там що, включно з винятком чи скасуванням. Це саме те місце, де наш саморобний пул раніше спотикався.

Можна також чекати з обмеженням, звичним способом:

php
$conn = $geo->acquire(timeout: 3000); // TimeoutException, якщо не надійде протягом 3 секунд

Ресурси помирають

З'єднання, що півгодини пролежало в пулі, могло тихо померти: сервер закрив його після тайм-ауту, у мережі стався збій. Ми вже бачили в главі про PDO, до чого це призводить: наступна корутина отримує загадкову помилку невідомо звідки. Пул бореться з цим на трьох фронтах:

php
$geo = new Pool(
    factory:             fn() => new GeoConnection('geodirectory.example.com'),
    destructor:          fn($conn) => $conn->close(),
    healthcheck:         fn($conn) => $conn->ping(),
    beforeAcquire:       fn($conn) => $conn->isAlive(),
    beforeRelease:       fn($conn) => !$conn->isBroken(),
    min: 2,
    max: 10,
    healthcheckInterval: 30000,
);
  • healthcheck — кожні тридцять секунд пул сам обходить свої вільні ресурси і перевіряє їхній пульс. Мертві знищуються і замінюються новими.
  • beforeAcquire — остання перевірка перед видачею ресурсу. Якщо вона не пройшла, ресурс знищується, а корутина отримує наступний.
  • beforeRelease — перевірка при поверненні. Корутина могла зламати з'єднання посеред запиту; такий ресурс назад у пул не повертається.

Корутини нічого не знають про цю закулісну роботу: вони просять ресурс і отримують робочий. Управління здоров'ям виражене декларативно, у конструкторі, а не розмазане по коду купою if.

Circuit breaker

Тепер уяви, що GeoDirectory повністю впав. Усі десять з'єднань мертві, кожна корутина слухняно вичікує тайм-аут, створює нове з'єднання, чекає знову. Застосунок витрачає всю свою енергію на бомбардування служби, яка вже лежить, і тим самим не дає їй піднятися.

Електротехніка винайшла ліки саме для цього: автоматичний вимикач, що розмикає коло, доки несправність не усунено. Патерн названо на його честь, circuit breaker, і його вбудовано в пул. Пул має три стани: ACTIVE, усе працює; INACTIVE, службу оголошено недоступною і acquire одразу дає збій, без жодних тайм-аутів; RECOVERING, обережна перевірка, чи не повернулася служба до життя.

Стани можна перемикати вручну або віддати рішення стратегії:

php
use Async\CircuitBreakerStrategy;

final class FiveStrikes implements CircuitBreakerStrategy
{
    private int $failures = 0;

    public function reportSuccess(mixed $pool): void
    {
        $this->failures = 0;
        $pool->activate();
    }

    public function reportFailure(mixed $pool, Throwable $error): void
    {
        if (++$this->failures >= 5) {
            $pool->deactivate();
        }
    }

    public function shouldRecover(): bool
    {
        return true; // спробувати відновлення за першої ж нагоди
    }
}

$geo->setCircuitBreakerStrategy(new FiveStrikes());

Пул сам повідомляє стратегії про кожен успіх і кожну невдачу ресурсу і запитує через shouldRecover, чи не час обережно перевірити, чи повернулася служба. П'ять невдач поспіль, і коло розмикається: корутини отримують швидкий збій замість повільного катування тайм-аутами, а GeoDirectory перестає отримувати марні удари і має змогу спокійно відновитися.

Озираючись назад, Pool робить саме те, що ми власноруч будували з каналу в дев'ятій главі, плюс усе, що ти справді не хочеш будувати руками: гарантоване повернення, перевірки здоров'я, circuit breaker. PDO Pool це той самий примітив, лише схований за фасадом PDO. А для всього іншого, HTTP-клієнтів, Redis, сокетів і важких об'єктів взагалі, є Async\Pool.

На цей момент наш арсенал виглядає солідно: корутини, канали, scope, групи задач, пули. Але все це працює в одному потоці операційної системи і ділить одне ядро процесора. Поки задачі чекають на введення-виведення, це нікого не турбує. Але що, як робота обмежена не очікуванням, а обчисленнями? Тут уся картина змінюється, і про це наступна глава.