Привет, Хабр! Меня зовут Алексей Макареня, я старший разработчик в «Лаборатории Касперского». Наша команда занимается централизованной системой контроля версий, и это тот самый поезд из заголовка. Через сервер этой системы идет работа с монорепозиторием, где лежит код десятков продуктов, поэтому остановить его на ремонт нельзя.

Сервер годами жил на акторах и дожил до того, что перед мелкой правкой разработчик по нескольку часов восстанавливал в голове поток управления. Под катом рассказываю, как я за восемь месяцев перевел его на корутины C++20 прямо на ходу, зачем для этого понадобилась собственная библиотека на 387 строк и почему иногда полезно сначала написать функцию на 565 строк.

Один большой или много маленьких?

Все мы знаем Git, где у каждого разработчика лежит полная копия проекта. Когда компания делает множество продуктов, удобно держать весь код в одном большом хранилище и не плодить десятки отдельных репозиториев и дополнительную доставку артефактов между ними. Такое хранилище на все называют монорепозиторием. К нему в разное время пришли Google, Microsoft, Яндекс и другие крупные компании.

Главная проблема большого монорепозитория в том, что он и правда очень большой. В докладе Яндекса про его систему Arc фигурировали два терабайта со всей историей. У нас с историей пока чуть больше 200 ГБ, а рабочая копия занимает десятки гигабайт, но хватает и этого. С обычным git за время одного pull успеваешь не только попить кофе, но и пообедать, а switch на ветку полугодовой давности лучше вовсе не запускать.

Поэтому у разработчика локально лежат не все файлы. Виртуальная файловая система показывает репозиторий так, будто он целиком находится на машине, но на деле скачиваются только файлы, с которыми человек работает. Остальное постепенно подгружается с сервера по мере необходимости. Из-за этого сервер становится центральной и очень нагруженной частью системы.

Большая часть нагрузки приходится на ввод-вывод, в пиковые моменты счет запросов у нас доходит до 20 тысяч в секунду и выше. Возьмем самую обычную обработку такого запроса: прочитать его, сходить в базу (один раз будет скучно, так что по разу на каждый ключ из запроса) и записать ответ. Синхронно это выглядело бы очень просто:

Request req = Read();

std::vector<Row> rows;
for (auto& key : req.keys) {
    rows.push_back(db.Get(key));
}

Write(Reply{rows});

Такой код укладывается в восемь строк, читается сверху вниз и отлаживается обычным отладчиком. На вычисления в нем уходит совсем немного, почти все время поток ждет ответа по сети. Если обслуживать много таких запросов отдельными потоками операционной системы, получатся тысячи потоков, которые в основном просто ждут. А потоки, как известно, обходятся недешево.

Как ждать, не занимая поток

Проблему ожидания классически решают колбэками, например в asio. Поток выполняет свою часть работы, доходит до асинхронной операции, возвращается в пул и позже продолжает работу через колбэк. По ресурсам это гораздо выгоднее, но вот во что превращаются наши восемь строк:

// состояние больше негде держать — только снаружи
struct Session {
  Request req;  std::vector<Row> rows;
  size_t i = 0;                       // ← счётчик цикла
};

void FetchNext(std::shared_ptr<Session> s) {
  if (s->i == s->req.keys.size()) {
    Write(Reply{s->rows}, [s](bool) { /* готово */ });
    return;
  }
  db.Get(s->req.keys[s->i], [s](Row row) {
    s->rows.push_back(std::move(row));
    ++s->i;
    FetchNext(s);                     // ← «следующая итерация»
  });
}

Потоки мы освободили, но разорвали структуру программы. Цикл исчез совсем: вместо него функция, которая вызывает сама себя, а счетчик итераций пришлось вынести наружу, в Session. Был простой линейный код с обычными циклами и условиями, появились отдельные обработчики, переходы и дополнительное состояние. У этого эффекта даже есть название: stack ripping, разрыв стека. Вместе со стеком ушла и простота чтения.

Чтобы вернуть коду структуру, мы перепробовали много вариантов. Помимо коллбэков есть много других вариантов. Один из них — акторы: по идее, они должны были все это упростить. Вот тот же пример на акторах:

struct Ctx {
  Request req;  std::vector<Row> rows;
  size_t i = 0;
  response_promise reply;
};

behavior service(event_based_actor* self, actor db) {
  return {
    [=](read_atom, Request r) {
      Ctx c{std::move(r), {}, 0, self->make_response_promise()};
      self->mail(next_atom_v, std::move(c)).send(self);
    },
    [=](next_atom, Ctx c) {                        // ← сам цикл
      if (c.i == c.req.keys.size()) {
        c.reply.deliver(Reply{std::move(c.rows)}); return; }
      self->mail(get_atom_v, c.req.keys[c.i], std::move(c)).send(db);
    },
    [=](row_atom, Row row, Ctx c) {                 // ← тело цикла
      c.rows.push_back(std::move(row)); ++c.i;
      self->mail(next_atom_v, std::move(c)).send(self);
    }};
}

Стало ли проще? Не очень. Цикл стал письмом самому себе, счетчик — полем в Ctx, порядок шагов теперь нигде не записан: чтобы его узнать, надо проследить, кто кому шлет сообщения. Я бы даже и не стал рассказывать ничего про акторы, если бы не одно «но». Сервер, который мне достался, был написан именно на них.

Почему когда-то выбрали именно акторы, я не знаю: проект стартовал четыре года назад, я в нем всего год. Важнее другое: все это работало годами, в проде, и работает прямо сейчас.

Как делать плохо, разобрались, теперь как делать хорошо. Первый вариант — файберы. Они позволяют освободить системный поток посреди выполнения функции, потом продолжить ее с того же места; делается это библиотеками вроде Boost.Context. Код при этом не просто становится лучше, он остается ровно таким, каким был:

// Да, это 100% копия синхронного кода
Request req = Read();

std::vector<Row> rows;
for (auto& key : req.keys) {
    rows.push_back(db.Get(key));
}

Write(Reply{rows});

Кажется, что переписывать вообще ничего не надо. Надо, к сожалению, и начинается все с примитивов синхронизации. Обычный mutex привязан к системному потоку, а файбер может уснуть в одном потоке и проснуться в другом. Отпустить мьютекс из чужого потока — получить дедлок. Значит, все примитивы синхронизации пришлось бы менять на версии, которые знают о файберах.

Со сторонними библиотеками еще хуже. Допустим, внешняя библиотека вызывает ваш колбэк, из него через цепочку вызовов срабатывает обычная функция бизнес-логики. Через полгода стажер добавляет в нее поход в базу, вот только библиотека звала колбэк под своим мьютексом. Получили дедлок из-за правки в файле, никак не связанном с библиотекой, и поди его найди.

До «Лаборатории Касперского» я работал в Яндексе, да и про язык Go слышал: и там, и там широко используют файберы. Но у этого есть причина: и Яндекс, и Go полностью владеют своей инфраструктурой.

В Яндексе нельзя просто взять стороннюю библиотеку: ее нужно согласовать, завести в монорепозиторий и подружить с файберами. Стандартный mutex и блокирующий ввод-вывод там под запретом: их меняют на свои примитивы или уносят в отдельный пул потоков. Когда владеешь всей инфраструктурой, файберы — отличный выбор. В готовом же продукте, который уже живет на сторонних библиотеках, такой путь куда как рискованнее.

И тогда мы приходим к корутинам. По сравнению с файберами поменялось не так уж многое: перед каждым местом, где мы ждем, появилось слово co_await. Всего три слова на функцию:

Request req = co_await Read();

std::vector<Row> rows;
for (auto& key : req.keys) {
    rows.push_back(co_await db.Get(key));
}

co_await Write(Reply{rows});

Но дело не в трех словах. Приостановиться здесь может только корутина и только на co_await, поэтому все места, где может смениться поток, видны прямо в коде. Обычный mutex снова можно использовать, а если его захват все-таки переживет co_await, об этом предупредит clang-tidy. И история со стажером не повторится: корутинный поход в базу из обычной функции просто не скомпилируется. Придется сделать корутиной и ее, потом выяснить, что ее не позвать из колбэка библиотеки, и идти разбираться. Долго и нудно, но в этом и ценность: вся цепочка заражения корутинностью видна в сигнатурах, а не всплывает дедлоком в проде.

При этом я не считаю корутины кратковременной модой. Посмотрите на другие языки. В Python это async/await и asyncio, в JavaScript то же самое поверх промисов, в Kotlin — suspend-функции, корутины прямо в стандарте языка. Rust и Swift тоже пришли к async/await. C# вообще первопроходец: async/await появился там почти 15 лет назад, и с тех пор весь язык на них и живет. Это текущий state of the art. Из всех способов ждать, которые мы перебрали, корутины лучше всего подходили нашей задаче: их можно внедрять постепенно, не переписывая разом всю окружающую инфраструктуру.

С чего начинали: сервер на акторах

Исходный сервер был построен на акторах, и их было много: 73 файла, 16 акторных классов, шесть сервисов. На каждый пришедший запрос создавался свой актор. Внутреннего состояния у него не было. Только этот запрос, поэтому он просто предобрабатывал данные и пересылал их актору сервиса. Тот выполнял бизнес-логику и по пути ходил в базу данных. И у каждого сервиса был файл обвязки строк на триста, в котором нет ни строчки бизнес-логики: регистрация методов, разбор запроса, сборка ответа, протаскивание контекста. Этот файл просто есть, потому что без него gRPC не заведется.

Отдельную роль играл сам gRPC: помимо обычного запроса-ответа, он поддерживает стриминг. И клиент, и сервер могут слать целые потоки сообщений, по несколько и с паузами, итого четыре вида RPC в зависимости от того, кто и как шлет. Для нашего сервера это было важно: поток сообщений — цикл, который в акторной системе превращается в набор обработчиков сообщений и разделяемое между ними состояние.

Контекст операции путешествовал между акторами: в нем хранились индексы циклов и промежуточные поля, одни обработчики заполняли данные, другие читали. Обработчиков много, все разорваны, и в итоге бизнес-логика оказалась размазана по ним.

Любая задача стала занимать вчетверо больше времени. И это если фича вообще укладывалась в существующий дизайн. А переписать сам дизайн акторной системы (какие акторы где возникают, как обмениваются сообщениями) никто даже не хотел. Поэтому новую функциональность продолжали втискивать в старую конструкцию, даже когда она подходила все хуже. Начали с лапши — и лапшу только увеличивали. Рефактор был нужен, это было очевидно.

Как менять систему, которая продолжает работать

Переписать такой сервер разом нельзя. Пока идет рефакторинг, в тот же код продолжают приезжать чужие коммиты: фичи вливаются, баги чинятся. Все это время сервер круглосуточно обслуживает реальных клиентов. Права сломать его у меня не было: это рабочая лошадка. На всю миграцию вышло порядка полусотни PR.

К счастью, система уже была разделена на шесть сервисов: сопоставимых по сложности и объему, независимых друг от друга. Поскольку акторную структуру все эти годы никто не трогал, топология была заморожена: список того, что предстояло перевести, я знал целиком с первого дня. Для миграции это подарок — цель не убегает, пока ты в нее целишься. Мы решили переводить сервисы по одному, отдельным PR каждый: какое-то время старые и новые части живут рядом. Параллельно строилась корутинная инфраструктура, о ней поговорим дальше.

У такого подхода давно есть название: Strangler Fig Pattern, или паттерн «душащий фикус». Название дал Мартин Фаулер. Новая система постепенно обрастает вокруг старой и забирает ее функции, старая часть при этом шаг за шагом исчезает.

Своя библиотека на 387 строк

Дальше нам понадобилась библиотека, которая свяжет корутины с существующим кодом. Ограничения были жесткие: не тащить ради одной задачи монстров вроде Boost или Folly; библиотека должна быть живой — реальная активность за последние полгода; без боли стыковаться с уже написанным кодом на колбэках и акторах; и, кроме Linux под продом, поддерживать Windows и MacOS. Тесты поднимают легкую версию того же сервера под каждую платформу.

Большинство вариантов отпали сами собой. cppcoro и concurrencpp мертвы, последние коммиты у них двух- и трехлетней давности. Boost.Cobalt и folly::coro — монстры. libcoro под Windows превращается в тыкву: ни networking, ни TLS. stdexec вообще другая парадигма, sender/receiver. Оставались asio и async_simple. Последний я нашел уже после рефактора, так что он в отборе и не участвовал. asio казался тогда избыточным — не блокер, но некоторые вещи в нем выходили сложнее, чем хотелось бы.

Обе библиотеки подошли бы, но выбрали мы в итоге третий вариант, мою собственную библиотеку. Хотя ладно, кому я вру: дело не в технических преимуществах, просто любой уважающий себя программист пилит велосипеды. В первой ревизии было 387 строк, сейчас чуть больше тысячи; вся она вычитывается за час. И главное, ее не жалко выбросить: у нас до сих пор в планах заменить самописный YDB-бэкенд на Яндексовый и получить их корутинную реализацию даром, а вместе с бэкендом уйдет и библиотека.

Базовая версия состоит из трех примитивов: Task — сама корутина, Start — запуск из обычного кода и Promise — мостик между корутинным и колбэкным миром. Promise самый важный: даешь функцию, принимающую колбэк, и получаешь значение через co_await. Название и дизайн честно украдены у JavaScript. Объект resume живет прямо в кадре корутины, поэтому указатель на него можно отдать во внешний сервис как есть. Ровно то, что нужно для gRPC и легаси-колбэков. Так существующий асинхронный метод с колбэком вызывается из корутины без переписывания нижнего слоя:

Coro::Task<void> PrintName(Db& db, ObjectId id) {
    auto name = co_await Coro::Promise<std::string>([&](auto& resume) {
        db.GetNameAsync(id, [&](std::future<std::string> value) {
            try { resume(value.get()); }
            catch (...) { resume.SetException(std::current_exception()); }
        });
    });
    std::cerr << "name is: " << name << "\n";
}

Coro::Start(PrintName(db, item_id));   // из main, из насоса очереди — откуда угодно

Первым же коммитом, переведя один сервис из шести, я удалил 954 строки акторной обвязки.

Корутины поверх gRPC

В gRPC для C++ есть целых три модели API. Синхронный читать проще всего: генератор создает интерфейс сервиса, мы реализуем методы и пишем бизнес-логику. Все легко, за исключением одного: потоков нужно очень много, скорее всего, больше, чем операционка вам позволит.

Есть еще callback API с реакторами: по классу на каждый запрос, и реактор подозрительно похож на актора. На каждый асинхронный чих у него вызывается метод. Последний — асинхронный API на Completion Queue: каждой операции отдаешь tag типа void*, а потом достаешь его же из очереди завершений — единого цикла, вокруг которого крутится весь сервер. Мощно и быстро, вот только авторы gRPC про этот API как-то забыли: там даже нет ручки, чтобы посмотреть, корректно завершился поток входящих сообщений запроса или это оборвалась связь.

Сравнение gRPC API и схема Completion Queue и tag
Сравнение gRPC API и схема Completion Queue и tag

Наш сервер был написан именно на Completion Queue. Колбэчный, честно говоря, тоже подошел бы, и с акторами, и с корутинами, но это два рефактора сразу: при баге не скажешь, какой слой его принес. Поэтому оставили async API как есть и обернули его корутинами.

Обвязка вышла минимальной. Асинхронная операция оборачивается в Promise, а в качестве тега ей отдается сам resume. Он и так лежит в кадре корутины:

co_await Coro::Promise<bool>([&](auto& resume) {
    reader->Read(&request, &resume);
});

Насос очереди приводит указатель обратно к типу и будит корутину:

while (cq->Next(&tag, &ok)) {
    (*static_cast<Coro::Resume<bool>*>(tag))(ok);
}

Ни таблицы соответствий «тег → корутина», ни оберток, ни аллокаций на операцию.

Оборачивать так каждый вызов вручную быстро надоело: вместо одной строки — три, и так в каждом месте.

Дальше напрашивалось само: раз gRPC и так генерирует C++-код из .proto-описания сервиса, пусть рядом генерируется и корутинный. Так появился свой плагин к protoc. Он вызывает штатный генератор, а потом дописывает к его выводу еще один класс: было V1::SomeService, появится V1::CoroSomeService. Интерфейс — калька с синхронного API, только методы возвращают Task<grpc::Status>; генерируются и серверный каркас, и клиентский Stub. Внутри все тот же асинхронный API: бизнес-код выглядит обычным и последовательным, при этом под капотом работает прежний механизм.

// как в синхронном API gRPC
class Service {
  virtual grpc::Status Get(ServerContext* ctx,
                           const Request* request,
                           Response* response) = 0;
};

// как у нас
class CoroService {
  virtual Coro::Task<grpc::Status> Get(ServerContext* ctx,
                                       const Request& request,
                                       Response* response) = 0;
};

Заодно вернули и ту самую потерянную ручку IsCanceled: оборвался поток входящих сообщений или закончился сам. В колбэчном API метод для этого есть, в асинхронный его просто не завезли, так что пришлось лезть во внутренности gRPC core. Наружу эта информация не выставлена вовсе.

Это оказалось выгодно. Помните файл обвязки на триста строк у каждого сервиса, который «просто есть, потому что без него gRPC не заведется»? Руками это шесть раз одна и та же работа и шесть разных опечаток в ней. Плагин и описанный дальше middleware написаны один раз на все сервисы: обвязка перестала существовать как код, который кто-то пишет и кто-то ревьюит. Общее правило: переписываете N однотипных мест — перепишите генератор, а не N мест.

Служебный код в одном месте

В продакшене одного вызова RPC мало: нужно авторизовать клиента, определить его логин, проверить доступ, иногда заблокировать запрос, собрать метрики и обработать результат. Эту логику можно было вставлять в начало и конец каждого метода, но так снова получился бы boilerplate, поэтому в генераторе появился промежуточный слой middleware.

Сгенерированный код зовет метод сервиса не напрямую, а через middleware: тот может сделать что-то до и после вызова, отловить исключения, и он же объявляет, какие аргументы приедут в метод. По умолчанию подставляется ровно то же, что у обычного gRPC, а именно ServerContext*; тогда сервис неотличим от стандартного, только методы корутинные.

Наш middleware идет дальше: разбор контекста и авторизации вынесен в него, и в метод приезжают уже готовый контекст репозитория и учетные данные. Метод начинается сразу с бизнес-логики. Причем разбор асинхронный: middleware может сходить в базу или в кэш прав, и ни одна сигнатура от этого не поменяется.

// middleware выбираешь ты — он и объявляет, что приедет в каждый метод
class SomeService
  : public V1::CoroSomeService::ServiceTemplate<SomeMiddleware> {...};

// подпись метода с middleware по умолчанию — как у обычного gRPC,
// только корутинная
Coro::Task<grpc::Status> Get(
    grpc::ServerContext* ctx,
    const Request& request,
    Response* response);
// авторизация — сам, в каждом методе

// а с нашим middleware метод получает уже готовое
Coro::Task<grpc::Status> Get(
    IRepositoryContext& repo,          // выбранный репозиторий
    std::optional<Credentials> creds,  // кто пришёл
    const Request& request,
    Response* response);

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

YDB: обёртка вместо переписывания

С базой данных у нас отдельная история. Доступ к ней спрятан за интерфейсом с несколькими реализациями — для продакшена и для тестов. Сам продакшен-бэкенд написан на акторах. Когда его писали, официального C++ SDK у YDB еще не существовало, и велосипед был не опцией, а жестким требованием. На корутины его переводить мы не стали. За интерфейсом он полностью самодостаточен, акторная модель здесь как раз на месте. Долгоживущий пул соединений к базе и есть их законная территория. К тому же мы все равно планируем заменить его на официальный SDK.

Так что базу хватило обернуть. Рядом с каждым колбэчным методом интерфейса появилась корутинная версия: старый метод не тронут, новый стоит рядом и просто дожидается его результата. У приема, как водится, тоже есть имя: Wrap Method у Физерса.

// было: колбэк в интерфейсе
virtual std::future<void> GetBranch(
    const RepositoryId& repo,
    const std::string& name,
    std::function<void(std::future<Branch>)> on_done) = 0;

// стало: обёртка рядом
Coro::Task<Branch> GetBranchCoro(
    const RepositoryId& repo,
    const std::string& name) {
  co_return co_await Coro::Promise<Branch>(
    [&, this](auto& resume) {
      GetBranch(repo, name, resume);   // ← resume передан как колбэк
    });
}

Вся механика сводится к одной строке: resume уходит старому методу прямо в качестве колбэка. Чаще всего колбэк означает «позови меня, когда будет ответ», и Resume умеет конвертироваться в него сам. Так система менялась слой за слоем, при этом реализации базы не переписывались ни разу.

Мегафункция: сначала собрать разорванный код обратно

Самое сложное началось, когда мы добрались до реального бизнес-кода. Перед глазами был разорванный стек, десятки обработчиков и логика, которая годами росла внутри старого дизайна. Нужно было ее понять, переписать и при этом ничего не потерять. С чего начать, тоже было неочевидно: в этой мешанине не про всякую функцию можно было сказать, что именно она делает.

Мегафункция, Inline Function
Мегафункция, Inline Function

Первый прием я звал мегафункцией. По сути, это Inline Function по Фаулеру, только в масштабе акторной системы: старое разделение на функции и акторы вырывается, код вставляется на место вызовов, и все собирается в одну большую функцию. Работает LSP, где может: где есть inline — он, где нет — хотя бы переименование аргументов так, чтобы совпали с принимающей стороной. Правил два: никакого творчества, только переименования и копипаст, и код обязан собираться после каждого шага. Пока оба соблюдаются, сломать бизнес-логику нечем: любую оплошность ловит компилятор.

Вторая часть творческая: восстановление стека. Вижу, что актору ушло сообщение, позже приехал ответ: на это место встает Promise. Рекурсию, в которую когда-то превратился цикл, оформляю обратно в цикл, те самые места, где разрыв стека убил структуру. На выходе получаем мегакорутину: весь механизм впервые виден целиком, сверху вниз. Собирается все это за один заход: пока смотришь, понятнее, чем было; отвлекся, и перед тобой монстр.

Дальше функцию можно не торопясь вычесывать и делить обратно, по нормальным смысловым границам, маленькими правками и под тестами. Один из механизмов выглядел так: шесть файлов, 1316 строк, состояние размазано между ними. После сборки получилась одна функция на 565 строк, страшненькая, зато вся перед глазами. После вычесывания получились четыре класса и 187 строк тестов: те же механизмы, что были размазаны по шести файлам, только теперь с именами и границами.

Мега-PR, который никто не ревьюит

Второй прием: огромный черновой PR. При серьезном рефакторинге быстро выясняется, что изменение затрагивает десятки мест. Я не пытался сразу нарезать его на аккуратную серию маленьких PR. Сначала собирал большой рабочий черновик, который никто не должен был ревьюить и тем более куда-то вливать: его задача — показать весь фронт работ. Когда за деревьями виден лес, уже можно находить в нем отдельные механизмы, изолировать их и выносить в нормальные небольшие PR.

Мега-PR и Mikado Method
Мега-PR и Mikado Method

Бывало, три недели от меня не было ни одного коммита и ни одного PR, потом появлялись сразу шесть, и не маленьких. Так и задумано: пока ветка не показала реальные границы, вырезать из нее нечего.

У подхода есть имя: Mikado Method. Пишешь желаемое изменение как получается, наивно: задача ветки не поставить код, а вскрыть препятствия. Дальше из нее вырезаются самодостаточные механизмы, каждый дорабатывается и вливается начисто. Тут важная деталь: в изоляции механизм сильно меняется и взрослеет по сравнению с наброском. Копировать из наивной ветки обычно нечего.

Затем ребейз: он вычищает из ветки все, что уже уехало в мастер, и остается ровно еще не сделанная работа. Ветка начинает работать списком дел, который сам укорачивается. Повторять, пока делится; когда вырезать больше нечего, ветка выбрасывается: все ценное уже в мастере.

Мегаветок за рефактор у меня было четыре. До мастера доехала одна, остальные к этому моменту отдали все свое и были выброшены: это штатный финал метода, а не провал.

Что пришлось доделывать по дороге

Часть проблем была видна до старта. Promise хорош, когда коллбэк срабатывает ровно один раз: сходить в базу и дождаться ответа. Но кое-где API звал коллбэк многократно, а внутри такого коллбэка co_await не написать: цикл ведет вызываемая библиотека, и корутина обязана вести цикл сама.

В таких местах интерфейс менялся заранее: вместо «позови меня на каждом объекте» появлялся итератор, по которому корутина идет обычным циклом и на каждом шаге делает co_await. Кто владеет циклом, тот и решает, можно ли внутри ждать. За приемом стоит имя preparatory refactoring Кента Бека: сделай изменение простым, потом сделай простое изменение. Два таких PR уехали в мастер еще до первой корутины, и оба в минус по строкам.

Заранее видно было не все. Один из API выдавал кипу разнородных объектов через интерфейс с целым набором методов OnSomething: они срабатывали многократно и в разном порядке, и в аккуратный итератор это не сворачивалось. Механизм дожил до середины переезда и получил канал. Коллбэки складывают события в очередь, параллельная корутина читает их по одному и обрабатывает, а queue.Stop() говорит ей «все»: co_await вернет пустой optional.

struct QueueCallback : ICallback {
  void OnObjectOne(int f1, ...) override { queue_->Push(ObjectOne{.field1 = f1, ...}); }
  void OnObjectTwo(bool f1, ...) override { queue_->Push(ObjectTwo{.field1 = f1, ...}); }
  Coro::Queue<std::variant<ObjectOne, ObjectTwo, ...>>* queue_;
};

Coro::Task<void> Enumerate() {
  Coro::Queue<std::variant<ObjectOne, ObjectTwo, ...>> queue;
  auto processor = Coro::Start([&] -> Coro::Task<void> {
    while (auto object = co_await queue) { ... }   // ← корутинная сторона, по одному
  });
  EnumerateObjects(QueueCallback(&queue));         // ← нативная: синхронно и до конца
  queue.Stop();                                    // ← co_await отдаст пустой optional
  co_await std::move(processor);
}

Емкость очереди неограниченная: продюсер ждать не умеет, он бросает данные, пока они у него есть. Забить память этим в принципе можно. Но старая акторная очередь жила ровно по тому же принципу и работала годами, так что и здесь рискнули.

Еще одна вещь, которую я при планировании переезда не предусмотрел вовсе: корутине нужно уметь просто подождать. Поспать до момента X, не занимая поток. Пришлось дописывать шедулер с двумя формами ожидания. Они различаются реакцией на остановку шедулера:

// остановили — исключение
co_await scheduler.Schedule(next_run);

// остановили — false, вышли из цикла
while (co_await scheduler.TrySchedule(next_run)) {
    co_await SendMetrics();
    next_run += period;
}

Для периодических процессов вторая форма идеально подходит. К концу переезда библиотека выросла с 387 строк до тысячи с лишним, и ни одна из них не была написана заранее: каждый примитив датируется местом, об которое споткнулись.

Последняя история из тех, что достаются вместе с собственным рантаймом. У точки приостановки, await_suspend, есть три формы: void, bool и возврат coroutine_handle, а именно symmetric transfer, самый быстрый способ сказать, кого выполнять дальше. Мы выбрали красивую, и она оказалась с подвохом. Корутина считается приостановленной еще до входа в await_suspend и дожить до выхода из него не обязана: ее могут разбудить с другого потока прямо посреди нашей функции. А возвращаемое значение, handle, MSVC в этот момент кладет в кадр корутины. Кадр уже разрушен, пишем в разрушенное. Тот же код clang раскладывал без гонки, MSVC с гонкой.

Самое коварное: прод не падал, так как он только под Linux. Падала автосборка при приемке PR. Она гоняется на всех платформах: около месяца она падала через раз. Ловится такое только нагрузкой: функциональный тест смотрит на результат, а тут все про то, кто кого и когда возобновил. Починка: вернуть bool вместо handle:

// было: symmetric transfer
std::coroutine_handle<> await_suspend(std::coroutine_handle<> h) {
    ...
    if (sync_.exchange(true)) {
      return h;
    }
    return std::noop_coroutine();
}

// стало
bool await_suspend(std::coroutine_handle<> h) {
    ...
    return !sync_.exchange(true);
}

В VS 2026 это починили, оба варианта теперь равнозначны.

Стоило ли писать свою библиотеку

Очевидный вопрос: зачем писать свою библиотеку, если есть готовые решения? Сегодня для нового проекта я бы сначала посмотрел на существующие, но на старте миграции мы сами еще плохо знали корутины. Возьми мы сразу большую внешнюю библиотеку, пришлось бы одновременно разбираться и в механике корутин C++, и во внутреннем устройстве этой библиотеки, чтобы понимать, откуда берутся ошибки.

При этом велосипед оказался не только способом разобраться в технологии. Он почти не оставляет артефактов в окружающем коде, в отличие от акторного фреймворка, который диктовал архитектуру целых сервисов. Если однажды придется переехать на что-то взрослое, тот же Asio, большинство замен сведется к replace in files (замена по файлам): бизнес-код трогать не придется.

Когда библиотека устоялась, вскрылась человеческая проблема: это знаю я, а команда — нет. Пришлось читать внутренний курс по корутинам: пара встреч про механику, времена жизни и правила нашей библиотеки. Сработало наполовину. Корутинные сервисы и бизнес-логику после этого действительно смотрели по существу, а саму библиотеку так никто и не открыл. Честный итог: научить читать корутинный код можно за пару часов, а читать корутинный рантайм — нет.

Зато откатиться назад было нельзя, и это досталось даром. В больших миграциях механизм «назад дороги нет» обычно строят специально: он называется ratchet. Мне достались два, оба бесплатно. Первый — компилятор: если сделал функцию корутиной, то обязан переписать ее вызывающих, иначе не соберется. Второй — та самая замороженная топология: раз в акторную структуру никто не лез, новых акторов за переезд не появилось, цель не убегала. Запрета на новые акторы не было, он и не понадобился.

Что дали восемь месяцев миграции

Главный результат: код снова можно менять. До рефакторинга в некоторые части сервера страшно было пускать разработчика уровнем ниже сеньора. Слишком много неявного состояния и разорванного управления, и задачи под него было попросту некому отдать. Теперь правки приносят люди, которые об акторной модели не знают ничего, вплоть до стажеров.

После миграции многие методы сократились до десятка-полутора строк, половина из которых — заполнение протобуфа. Сложная бизнес-логика сложной и осталась, но ее сложность стала структурированной: обычные циклы, обычные if, последовательные вызовы, явные co_await.

По ходу переезда вскрылись старые ошибки, которые годами прятались в запутанной структуре. Гонка двух соседних битовых полей в рантайме акторов. Разыменование пустого итератора в обходе истории. Ретраи, которые молча не случались. Прод от этого не падал, он иногда странно себя вел, теперь понятно, почему.

Заодно прояснилась история с graceful shutdown. Его не было: объекты и контексты в акторной системе удерживали друг друга по кругу, и сервер в некоторых сценариях просто не завершался штатно, его останавливали kill’ом. До миграции акторную систему допинали до «останавливается в девяти случаях из десяти», проверку «не зависли ли акторы» пришлось понизить с terminate до предупреждения, она срабатывала. Честным останов стал только после переезда: когда владение и жизненный цикл упростились, круги удержания исчезли.

Вся миграция заняла около восьми месяцев, и все это время продукт продолжал развиваться. Упрощение структуры помогло и с наблюдаемостью: метрик стало больше, сервер на графиках понятнее.

И акторы никуда не делись. Из 73 файлов на них осталось около десятка: пул сессий к базе, keep-alive, менеджер узлов. Это места, где нужна долгоживущая сущность с собственным состоянием и почтовым ящиком. Ровно то, для чего акторная модель и придумана. Целью никогда не было «убрать акторы»; нужно было перестать применять их там, где нужна была просто последовательность асинхронных шагов.

В заключение могу сказать, что корутины не серебряная пуля. Они дали форму кода, в которой снова видна последовательность действий, а дальше работали старые добрые правила: по кускам, которые разработка проглотит не заметив, с мостом в обе стороны, пока половина кода уже новая, половина еще старая. Поезд за эти восемь месяцев не остановился ни на день.