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

А в новом движке нужно обязательно сделать сеть, а чтобы вообще круто было - нужно сделать её сделать на основе корутин из C++ 20.

Как гордый велосипедостроитель, я сначала начал городить свои корутины. Но потом посмотрел на готовые библиотеки и понял, что городить мне ещё очень много, а чтобы добавить туда сеть, нужно городить ещё больше. Поэтому всё же решил взять готовую. Искать и пробовать разные либы пришлось довольно долго, так как я хотел выполнять корутины сразу в основном цикле движка, а практически все библиотеки заставляют использовать строго свой планировщик. Также во многих из них корутины были жёстко привязаны к своему планировщику, так что скакать между разными планировщиками нельзя было бы. Зачем мне скакать между ними? У меня в голове была идея "идеальной многозадачности", когда корутина прыгает между потоками-воркерами, где можно было бы провести вычисления, и основным потоком, где эти вычисления можно было бы применить.

Что я рассмотрел:

  • Boost.Asio - крутая библиотека, но у меня нет желания тянуть буст, а также писать двухэтажные выражения для работы с co_await. Решил оставить её на самый крайний случай;

  • Boost.Cobalt - библиотека заточенная уже только под корутины на основе asio. Только ещё тянет с собой весь буст, поэтому тоже нет;

  • cppcoro - одна из первых библиотек для корутин, но на данный момент заброшена, поэтому от неё тоже отказался.

Есть ещё другие, но что их перечислять-то, тут я уже нашёл libcoro, где было всё, что мне нужно: сеть, переключения, потоки-воркеры, а также достаточно простой API.

Я быстро всё переписал со своих костыльных корутин и потестил: у меня был простой поиск пути, который выполнялся в основном потоке.

Но, к сожалению, оказалось, что сеть поддерживается только на Linux и BSD системах, а у моих знакомых все компьютеры на Windows (что я успел исправить у нескольких человек, кстати). Я немного приуныл, так как уже успел внедрить библиотеку в движок, написать систему пакетов, сериализации/десериализации и клиент-серверную модель, а потестить со знакомыми не получится. В голове родилась мысль: "Ну, перенесу сеть на винду сам, сделаю PR. Что тут может быть сложного?"

Если честно, мне было довольно сложно к этому подойти. Раньше я смотрел на open source репозитории как на недосягаемые звёзды на небе. Точнее, на 900 звёзд, примерно столько у этой библиотеки было на GitHub. Было даже страшно, поэтому я решил сделать PR как можно позже, когда всё будет работать. Тогда может и великие контрибьюторы, ставленники Ричарда Столлмана, снизойдут ко мне и не закидают сразу помидорами.

Лёгких путей не искал, а поэтому поставил себе виртуалку с виндой, установил Visual Studio, форкнул репу и попытался скомпилировать. Очевидно, что ничего не вышло. Библиотеку я решил начать исправлять по методу "чиним, что упало". Какого-то системного вызова на винде нет? Гуглим. Какой-то epoll непонятный? Ясненько. "Скачать epoll на Windows бесплатно." Оказалось, что нельзя. Значит надо гуглить аналоги.

Только щас смотрю и понимаю, что этот подход ринуться чинить, толком не зная, как это всё работает, был так себе... Но всё-таки я заставил библиотеку работать на Windows. Как? Сейчас расскажу.

Кто эти ваши epoll и IOCP

Вернёмся немного назад. Я сначала попробовал скомпилировать библиотеку на винде, включив сетевую часть. Очевидно, что ничего не заработало, но теперь было от чего отталкиваться!

C++, как всегда, выдал ошибок на несколько томов "Войны и мира": несуществующие заголовки, отсутствующие структуры, вызовы и прочая белиберда. Начал потихоньку их читать, гуглить аналоги для винды (вдруг просто заголовок по-другому называется). Так я узнал про WinSock2, у которого был очень похожий API. Я этому обрадовался, не зная, что epoll на Windows нет.

Что такое epoll и зачем он нужен?

Я сразу же столкнулся в лоб с тем, что никакого epoll на Windows и в помине нет. Так что мне нужно было сначала разобраться, что это, и как это работает: пришлось читать man, какие-то статьи в Интернете, опрашивать искуственного идиота, который вредил больше, чем помогал.

Если кратко, то epoll - механизм оповещений для организации эффективного ввода-вывода.

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

### Пример select на псевдокоде
# Дескрипторы наших соединений
connections: list[int] = [
    client1,
    client2
]
timeout = 5s
assert(len(connections) <= FD_SETSIZE) # маскимальное количество ограничено ядром (1024)
while should_work():
    # Нужно работать с копией, потому что select() изменит переданный массив
    connections_copy = copy(connections)
    # Также нужно передать максимальное число дескриптора + 1
    ntfds = max(connections_copy) + 1
    
    # через &arg буду указывать аргументы, которые ядро перезаписывает
    result = select(ntfds, &connections_copy, NULL, NULL, timeout)
    
    if result == -1:
        # что-то пошло не так
        exit()
    elif result == 0:
        # никто ничего не прислал
    else:
        # Теперь проходим по всем дескрипторам и проверяем
        for connection in connections:
            if connection in connections_copy:
                # Ура, дескриптор "проснулся"
                process(connection)

С увеличением количества пользователей интернета стало очевидно, что такой подход очень неэффективен.  Поэтому придумали epoll. Вместо того, чтобы каждый раз копировать туда-сюда массивы сокетов, мы создаем epoll-дескриптор, в который регистрируем нужные сокеты. Теперь, даже если у нас 100 тысяч соединений, можно просто спросить систему "какие сокеты готовы" и получить их.

### Пример epoll
max_size = 32
timeout = 5s
# создаём epoll
epoll_fd = epoll_create(0)
# "Записываемся" на события об интересующих нас сокетах
event = Event(flags = OP_READ, userdata = 42) # интересующие события
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client1, event)
event = Event(flags = OP_WRITE, userdata = 69)
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client2, event)
while should_work():
    epoll_results: list[Event] = [None] * max_size
    # Тут поток засыпает и ждёт событий
    event_count = epoll_wait(epoll_fd, &epoll_results, max_size, timeout)
    
    # Теперь просто проходимся ��о дескрипторам, которые epoll
    # поместил в массив
    for event in epoll_results[:event_count]:
        connection_fd = event.data.fd
        
        process(connection_fd, event.userdata)

IOCP

Казалось бы, схема идеальная. Но тут приходит Windows со своим IOCP, который и является аналогом epoll. Хотя аналогом это не назвать, тут совершенно другой принцип: если в epoll было "скажи мне, когда можно начать читать", то в IOCP - "держи буфер, хочу читать сюда. Как дочитается - постучи".

### Пример IOCP на псевдокоде
# Создаём очередь IOCP (порт завершения)
iocp = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0)
# Привязываем сокет к порту.
# Заметьте, привязаться можно только на ВСЕ события.
CreateIoCompletionPort(client, iocp, key = 123)
# Сразу запускаем операцию
# Не ждём данных, а так сказать, оставляем заявку.
# Буфер должен быть всё время жив, а также в него нельзя
# писать, пока операция не завершится
buf = Buffer(1024)
# Структура для наших и системных данных. Тоже должна жить
overlapped = Overlapped(buf, userdata = 42)
# Функция возвращает управление СРАЗУ
# Есть вероятность, что операция может быть завершена тоже сразу,
# и ждать не придётся
WSARecv(client, buf, overlapped)
while should_work():
    key = 0
    bytes_transferred = 0
    overlapped = Overlapped()
    
    # Поток блокируется и мы ждём
    result = GetQueuedCompletionStatus(iocp, &bytes_tranferred, &key, &overlapped, timeout)
    
    if result == True:
        # Буфер можно получить в overlapped.buf
        process(overlapped)
        
        # Следующая "заявка"
        WSARecv(...)

P.S. На Windows select и poll тоже есть, но как я понял, они сделаны на всех костылях мира, до которых смогли дотянуться. Поэтому их лучше не использовать.

Что имеем в итоге?

Посмотрел я на это дело, и тут мой план начал трещать по швам, потому что API библиотеки libcoro был явно заточен под epoll.

### Пример чтения d libcoro
constexpr auto timeout = std::chrono::milliseconds(500);
std::string buf(256, '\0'); // буфер для чтения
auto client = smth::make_tcp_client("127.0.0.1", 8080);
co_await client.connect();
// Ждём, когда можно начать читать
auto pstatus = co_await client.poll(coro::net::poll_op::read, timeout)
if (pstatus != coro::net::poll_status::event) {
    handleError();
    co_return;
}
// И только теперь читаем.
auto [recv_status, read_bytes] = client.recv(buf);
if (recv_status != coro::net::recv_status::event) {
    handleError();
    co_return;
}

А чтобы отправить/прочитать ровно N байт, нужно городить цикл, т.к. за один send/recv большое количество данных отправить нельзя, только по частям. Очевидно, что писать каждый раз такие конструкции никто не будет, всё вынесут в какую-нибудь функцию-хелпер. Так почему тогда в библиотеке сразу нет такой функции?

Первые проблемы

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

Но самое главное - нужно было привести API в подходящий вид, так как очевидно, что библиотечный co_await poll() + do_operation() использовать на Windows невозможно. Поэтому нужно было придти к простому co_await do_operation(). Такой API хорошо ложится и на epoll, и на IOCP.

### Реализация чтения в IOCP
async def client::read(buffer, timeout) -> status:
    op_info = {
        ov = Overlapped(), # внутренняя структура для ядра
        poll_info = PollInfo(), # информация для планировщика
        bytes_transferred = 0,
        socket = this->socket
    }
    buf = WSABuf(buffer) # буфер для WinSock2
    
    # Читаем
    result = WSARecv(this->socket, &buf, &op_info.bytes_transferred, &op_info as Overlapped*)
    
    # Операция завершилась синхронно
    if result == 0:
        return "ok"
    # Или случилась ошибка
    elif WSAGetLastError() != WSA_IO_PENDING:
        raise error
    
    # Всеми любимые goto
    try_again:
    # Иначе нужно подождать.
    status = await this->scheduler.poll(op_info.poll_info, timeout)
    
    # Всё ок
    if status == poll_status::ok:
        return ...
    # Время вышло, нужно отменить операцию
    elif status == poll_status::timeout:
        success = CancelIoEx(this->socket, &op_info as Overlapped*)
        if not success:
            # Операция успела завершиться, идём обратно
            timeout = 0
            goto try_again
        return "timeout"
    ... # обработка ошибок

Сигналы в epoll

Так как epoll_wait блокирует поток, в котором он вызывается, до получения каких-либо событий, то библиотеке нужен был какой-нибудь механизм, чтобы досрочно завершить ожидание (например, программа завершилась). Иначе epoll_wait может ждать вечно, если не будет каких-либо событий и не установлен timeout.

Чтобы будить поток, планировщик библиотеки использовал каналы (pipes). От гугла я уз��ал, что это такой механизм для одноправленной передачи данных между процессами. В нашем случае каналы используются не по назначению, а как механизм для пробуждения epoll.

Мы можем создать канал с помощью вызова pipe(), который вернёт нам два дескриптора: один для чтения, другой для записи. Когда мы пишем во второй, то данные приходят в первый.

/// The event loop fd to trigger a shutdown.
std::array<fd_t, 2> m_shutdown_fd{-1};  
  
// Уникальные адреса для идентификации в epoll
static constexpr const int   m_shutdown_object{0};  
static constexpr const void* m_shutdown_ptr = &m_shutdown_object;  
io_scheduler::io_scheduler(options&& opts, private_constructor)  
    : ...
{  
    ...
    
    // Создаём канал
    m_shutdown_fd = std::array<fd_t, 2>{};  
    ::pipe(m_shutdown_fd.data());  
    
    // Подписываемся на регистр чтения и m_shutdown_ptr, 
    // чтобы потом идентифицировать потом пришедшее событие. 
    // По умолчанию, если событие сработает, то epoll ничего 
    // не отправит до следующей записи. Поэтому мы ставим 
    // флаг persist (true), чтобы epoll возвращал событие 
    // до тех пор, пока данные не прочитают.
    m_io_notifier.watch(m_shutdown_fd[0], coro::poll_op::read, const_cast<void*>(m_shutdown_ptr), true);
    
    ...
}
...
// Здесь планировщик обрабатывает все пришедшие события
auto io_scheduler::process_events_execute(std::chrono::milliseconds timeout) -> void  
{  
    ...
    // Ждём события от epoll.
    m_recent_events.clear();
    m_io_notifier.next_events(m_recent_events, timeout);  
  
    for (auto& [handle_ptr, poll_status] : m_recent_events)  
    {
        // Тут обрабатываются таймеры, к ним ещё вернёмся
        if (handle_ptr == m_timer_ptr)  
        {  
            process_timeout_execute();
        }
        // Тут обрабатывается сигнал
        else if (handle_ptr == m_schedule_ptr)  
        {  
            // Process scheduled coroutines.  
            process_scheduled_execute_inline();  
        }
        // Тут тоже сигнал
        else if (handle_ptr == m_shutdown_ptr) [[unlikely]]  
        {  
            // Nothing to do, just needed to wake-up and smell the flowers
        }  
        else  
        {  
            // Individual poll task wake-up.  
            process_event_execute(static_cast<detail::poll_info*>(handle_ptr), poll_status);  
        }  
    }
    ...
}

На Windows перенести подобное поведение должно быть несложно, верно?

Первым ударом стало то, что пайпы на Винде есть, но они только для межпроцессового взаимодействия. Есть анонимные пайпы, но они не работают с select или IOCP как обычные сокеты. Нужно либо городить что-то через CreateEvent и MsgWaitForMultipleObjects (что превращает код в ад), либо как-то создавать локальный сокет, чтобы будить себя же, что звучит как костыль размером с Останкинскую башню.

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

signal_unix::signal_unix()  
{  
    ::pipe(m_pipe.data());  
}  
signal_unix::~signal_unix()  
{  
    for (auto& fd : m_pipe)  
    {  
        if (fd != -1)  
        {  
            close(fd);  
            fd = -1;  
        }  
    }  
}  
void signal_unix::set()  
{  
    const int value{1};
    ::write(m_pipe[1], reinterpret_cast<const void*>(&value), sizeof(value));  
}  
void signal_unix::unset()  
{
    int control = 0;  
    ::read(m_pipe[0], reinterpret_cast<void*>(&control), sizeof(control));
}

Тогда я этого не замечал, но в коде есть несколько проблем:

  1. Если мы вызовем set() несколько раз, то в канале накопится много байт и одного вызова unset() не хватит, чтобы их все прочитать, поэтому сигнал не "выключится";

  2. Если мы вызовем unset() без вызова set() ранее, то поток просто заблокируется до тех пор, пока мы не вызовем set(), так как канал будет пустой.

Сигналы в IOCP

Но давайте вернёмся к IOCP. После чтения документации, я узнал, что события в очередь IOCP можно посылать самостоятельно через PostQueuedCompletionStatus() (считаю, что Windows выигрывает по API в этом плане), что решает проблему, но не полностью. Так как это буквально очередь, то нельзя сделать так, чтобы IOCP каждый раз присылал нам это событие, пока мы не решим его снять. Поэтому их можно просто сохранить в массив, где их можно будет добавлять и удалять.

// signal_win32.hpp
class signal_win32
{
    struct Event;
    friend class io_notifier_iocp;
public:
    signal_win32();
    void set();
    void unset();
private:
    mutable void*                  m_iocp{};
    mutable void*                  m_data{};
    std::unique_ptr<Event>         m_event;
    static constexpr const int signal_key = 1;
};
// signal_win32.cpp
// Структура события для очереди
truct signal_win32::Event
{
    OVERLAPPED          overlapped;
    void*               data;
    bool                is_set;
};
signal_win32::signal_win32() : m_event(std::make_unique<Event>())
{
}
void signal_win32::set()
{
    m_event->is_set = true;
    m_event->data = m_data;
    // Отправляем наше событие в очередь
    // signal_key нужен для идентификации, что это именно сигнал
    PostQueuedCompletionStatus(m_iocp, 0, (ULONG_PTR)signal_key, (LPOVERLAPPED)(void*)m_event.get());
}
void signal_win32::unset()
{
    // То же самое, но is_set = false
    m_event->is_set = false;
    m_event->data = m_data;
    PostQueuedCompletionStatus(m_iocp, 0, (ULONG_PTR)signal_key, (LPOVERLAPPED)(void*)m_event.get());
}

Никаких пайпов, мы просто отправляем некоторые данные в очередь IOCP. Так как в IOCP невозможно сделать так, чтобы событие приходило каждый раз (ведь это очередь), мы записываем его, а затем отдаём планировщику каждый раз.

auto io_notifier_iocp::next_events(
    std::vector<std::pair<detail::poll_info*, coro::poll_status>>& ready_events, 
    std::chrono::milliseconds timeout
) -> void
    ...
    // Обрабатываем событие из очереди
    if (completionKey == signal_win32::signal_key)
    {
        if (!overlapped)
            continue;
    
        auto* event = reinterpret_cast<signal_win32::Event*>(overlapped);
        set_signal_active(event->data, event->is_set);
        continue;
    }
    ...
    
    // Все активные сигналы отправляем планировщику
    for (void* data : m_active_signals)
    {
        // poll_status doesn't matter.
        ready_events.emplace_back(static_cast<poll_info*>(data), poll_status::event);
    }
}
void io_notifier_iocp::set_signal_active(void* data, bool active)
{
    // Удаляем или сохраняем сигнал
    std::scoped_lock lk{m_active_signals_mutex};
    if (active) {
        m_active_signals.emplace_back(data);
    }
    else {
        m_active_signals.erase(std::remove(std::begin(m_active_signals), std::end(m_active_signals), data));
    }
}

Костыль работает? Работает. Значит можно идти к следующему.

Таймеры

Почти у всех операций есть тайм-аут, чтобы не ждать вечно, если данные ну никак не приходят. Но одно дело тайм-аут у какого-нибудь epoll_wait или GetQueuedCompletionStatus, где система сама о нём позаботится и разбудит поток, когда нужно, а другое - у нашего API. Здесь тайм-аут нужно реализовывать самому. В библиотеке это было сделано примерно так:

### Упрощённый псевдокод (на деле всё сложнее)
async def poll(operation, timeout) -> Status:
    # Создаём таймер
    timer = Timer(timeout)
    
    # Объект, который ждёт событие
    poll_info = backend.poll(operation)
    
    # Ждём, что раньше сработает
    result = when_any(timer.start(), poll_info)
    
    if result is Timer:
        poll_info.cancel()
        return TIMEOUT
        
    return result as Status

В библиотеке таймер был реализовал через механизм ядра timerfd. Работают такие такие таймеры примерно так же, как и каналы. Мы подписываем epoll на него, а затем через timerfd_settime просто ставим нужное время.

Очевидно, что на Windows никакого timerfd нет. А если быть точнее, то IOCP вообще не может работать с таймерами. Там такого механизма не существует вообще. Поэтому надо что делать? Городить велосипед. К счастью, я оказался не единственным, кому нужны были таймеры на IOCP, но готового кода я всё равно не нашёл.

На каком-то форуме советовали использовать SetWaitableTimer, а в коллбеке просто посылать событие в поток. Я решил так и сделать.

### Псевдокод работы таймера
def create_and_watch_timer(iocp_handle, timerdata, duration):
    // Наш таймер и нужные данные
    timer = {
        timer_handle = CreateWaitableTimerW(),
        iocp = iocp_handle,
        wait_handle = nullptr,
        custom_ptr = 0xBEEF
    }
    ok = SetWaitableTimer(timer, duration)
    
    if not ok:
        raise error
        
    def callback():
        PostQueuedCompletion(iocp_handle, completion_key::timer, timer as OVERLAPPED)
    
    ok = RegisterWaitFoorSignelObject(
        timer.timer_handle,
        &timer.wait_handle,
        callback,
        only_once = true
    )
    if not ok:
        raise error
// В самом цикле IOCP
...
switch (completion_key) 
{
    ...
    case completion_key::timer:  
        auto timer = reinterpret_cast<timer_handle*>(overlapped);
        // Отдаём custom_ptr в планировщик
        ready_events.emplace_back(  
            static_cast<detail::poll_info*>(const_cast<void*>(timer->custom_ptr)),  
            coro::poll_status::event);  
    
        // Удаляем объект ожидания (не таймер)
        UnregisterWaitEx(timer->m_wait_handle, INVALID_HANDLE_VALUE);  
        timer->m_wait_handle = nullptr;
        break;
    ...
}
...

Первый PR

Сеть на этот моменте уже работала. Правда, начало зависать множество тестов, что мне пришлось с трудом отдебажить и исправить.

И на этом моменте я решил закинуть свой первый PR на GitHub. Нажал кнопку "Make Pull Request" и затаил дыхание. Раньше я никогда PR не делал и не знал, чего ждать.

Уже на следующий день мне ответили.

И тут случилось выгорание, как это принято называть (и немного оффтопа)

Я плотно сел за исправления, но серьёзно застрял, так как некоторые части библиотеки нужно было серьёзно менять, а я не мог найти подход. Тем более в одном таком огромном PR было сложно что-то пробовать. Застрял я тут надолго.

Отошёл я от этого дела только тогда, когда начал близиться конец приёмной компании в ВУЗы. Тут я немного отойду от темы.

В 2025-м году я заканчивал 11-й класс, и близились экзамены (а это огромный источник стресса), к которым мне нужно было готовиться. Параллельно где-то с января я писал свой очередной движок, который "в этот раз точно нормальный будет"; разбирался в работе корутин в C++20 (даже купил книжку по 20-м плюсам); писал свою библиотеку для них.

Передо мной был выбор: экзамены или свой небольшой проект с корутинами и блекджеком. Ну, и так вышло, что я выбрал проект, так что к экзаменам я толком готов не был, и сдал их на 249 баллов (90 по информатике, и сколько-то по русскому и математике). Поступить в ВУЗ мечты я уже очевидно не мог, поэтому долго и упорно начал изучать доступные варианты. Потом ко мне пришёл мой новый ноут, где поставил Линукс и всё нужное, но... я тупо не смог нормально себе перетащить репозиторий в виртуалку с Windows. Всё просто ломалось и никак не работало. Я несколько дней пытался решить эту проблему, но в итоге всё бросил. Желания налаживать всё это уже не было совсем. Когда твой основной цикл разработки — это коммит, пуш, переключение в виртуалку, которая отъедает 8 ГБ оперативки, пулл изменений, и ожидание, пока Intellisense в VS соизволит проиндексировать проект (и не сломаются нахрен)... это точно не способствует получению удовольствия от программирования.

Потом мне пришло моё письмо счастья, и я поступил куда-то в Питер (туда, где мне дали +10 баллов за то, что я как-то по приколу участвовал в олимпиаде по программированию и даже получил какое-то место).

К этому проекту я очень долго не возвращался, и решил попробовать что-нибудь другое: написал приложение расписания на Flutter, собрал себе бомжесервер на двух зеонах (с целыми 32 ГБ ОЗУ!) за ~10к, начал на нём изучать микросервисы, написал сервис для пересылки сообщений из Макса в Телегу, ну, и по мелочи. Параллельно участвовал в различных форумах, мероприятиях, даже в одном геймджеме, где почти прошёл в финал. Для меня был очень сильный контраст по сравнению с тем, как я жил раньше. До этого у меня был основной цикл "проснуться-школа-дом-прогать-спать". И всё.

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

Начало плана "Б" или: вы видите IPv6? И я не вижу. А его и не было никогда

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

class coro::net::tcp::server
{
public:
    struct options
    {
        net::ip_address address{net::ip_address::from_string("0.0.0.0")};
        uint16_t port{8080};
        int32_t backlog{128};
    };
    explicit server(
        std::unique_ptr<coro::scheduler>& scheduler,
        options                           opts = options{
                                  .address = net::ip_address::from_string("0.0.0.0"),
                                  .port    = 8080,
                                  .backlog = 128,
        });
    ...
}

Для клиента coro::net::tcp::client::options, для UDP coro::net::udp::peer::options, для TLS тоже.
Думаю, догадаться, что в каждом классе код их обработки повторялся, несложно.

auto client::connect(std::chrono::milliseconds timeout) -> coro::task<connect_status>
{
    ...
    sockaddr_in server{};
    server.sin_family = static_cast<int>(m_options.address.domain());
    server.sin_port   = htons(m_options.port);
    server.sin_addr   = *reinterpret_cast<const in_addr*>(m_options.address.data().data());
    auto cret = ::connect(m_socket.native_handle(), reinterpret_cast<struct sockaddr*>(&server), sizeof(server));
   ...
}

Видите, что не так? Тут всё не так. IPv4 работать будет, но ничего более, потому что структура sockaddr_in предназначена только для IPv4.

Но это ещё цветочки. Посмотрите на код сервера.

auto server::accept() -> coro::net::tcp::client
{
    sockaddr_in         client{};
    constexpr const int len = sizeof(struct sockaddr_in);
    net::socket         s{::accept(
        m_accept_socket.native_handle(),
        reinterpret_cast<struct sockaddr*>(&client),
        const_cast<socklen_t*>(reinterpret_cast<const socklen_t*>(&len)))}; // UB!!!
    std::span<const uint8_t> ip_addr_view{
        reinterpret_cast<uint8_t*>(&client.sin_addr.s_addr),
        sizeof(client.sin_addr.s_addr),
    };
    return tcp::client{
        m_scheduler,
        std::move(s),
        client::options{
            .address = net::ip_address{ip_addr_view, static_cast<net::domain_t>(client.sin_family)},
            .port    = ntohs(client.sin_port),
        }};
};

Во-первых, опять же sockaddr_in. Во-вторых, const_cast<socklen_t*>(reinterpret_cast<const socklen_t*>(&len))... Весь смысл передачи этого параметра в том, чтобы система его изменила. А мы его делаем constexpr, так ещё и const-кастим к изменяемому. Что тут может произойти, даже компилятор не знает, ведь это Undefined Behaviour.

Как это всё красиво исправить?

Чтобы исправить, нужно ввести абстракцию. Я решил ввести socket_address c простым API.

Для корректной работы с адресами, нужно использовать не sockaddr_in, а sockaddr_storage, который имеет достаточный размер, чтобы вместить в себя любой адрес. Поэтому и нужно передавать в accept размер этой структуры, чтобы система знала, сколько у нас есть места под адрес.

// Всё, что не нужно для понимания, вырезано
class socket_address
{
public:
    socket_address(std::string_view ip, std::uint16_t port, domain_t domain = domain_t::ipv4);
    socket_address(const ip_address& ip, std::uint16_t port);
    /**
     * @brief Gets a pointer to underlying sockaddr structure.
     * Suitable for systemcalls like connect(), bind() or sendto().
     * @return A pair containing the const sockaddr pointer and its length.
     */
    [[nodiscard]] auto data() const& -> std::pair<const sockaddr*, socklen_t>
    {
        return {reinterpret_cast<const sockaddr*>(&m_storage), m_len};
    }
    /**
     * @brief Provides access to the storage for modification.
     * Suitable for system calls like accept() or recvfrom().
     * @return A pair containing the sockaddr pointer and a pointer to its length.
     * @see make_unitialised()
     */
    [[nodiscard]] auto native_mutable_data() & -> std::pair<sockaddr*, socklen_t*>
    {
        return {reinterpret_cast<sockaddr*>(&m_storage), &m_len};
    }
    /**
     * @brief Creates an empty endpoint for late initialisation.
     */
    static auto make_uninitialised() -> socket_address { return socket_address{}; }
    ...
private:
    // It's private to avoid default empty initialisation
    socket_address() {}
    sockaddr_storage m_storage{}; // zero-filled
    socklen_t        m_len = sizeof(sockaddr_storage);
};

И теперь дышать становится намного легче, а ещё не нужно следить за всеми участками кода, где мы копипастили логику обработки адресов. Она теперь в одном месте.

// tcp/client.cpp
auto client::connect(std::chrono::milliseconds timeout) -> coro::task<connect_status>
{
    ...
    auto [sockaddr, len] = m_endpoint.data();
    auto cret = ::connect(m_socket.native_handle(), sockaddr, len);
    ...
}
// tcp/server.cpp
auto server::accept() -> coro::net::tcp::client
{
    auto client_endpoint = socket_address::make_uninitialised();
    auto [sockaddr, len] = client_endpoint.native_mutable_data();
    net::socket s{::accept(m_accept_socket.native_handle(), sockaddr, len)};
    
    return tcp::client{m_scheduler, std::move(s), client_endpoint};
}

Меньше кода, меньше копирования и уж точно меньше UB.

Проблемы пришли, откуда не ждали

Я закинул свой код в CI, где всё попадало. Ничего страшного: открыл логи тестов на Ubuntu, поправил ошибки и снова запустил. На всех системах тесты прошли успешно, кроме macOS. Падала ошибка при сравнении одинаковых адресов.

Я посмотрел на это и ничего не понял. Что не так?

Внимательно осмотрел оператор сравнения:

auto operator==(const socket_address& other) const -> bool
{
    return m_len == other.m_len && std::memcmp(&m_storage, &other.m_storage, m_len) == 0;
}

На вид всё в порядке... Но раз на Линуксе всё работает, на macOS нет, значит проблема как-то связана с платформой.

Оказалось, что структуры sockaddr на Linux и BSD системах отличаются. Почему memcmp() меня предал? На macOS поле sin_len заполняется ядром при вызове accept или recvfrom. Но если я создаю структуру вручную (для сравнения), то в этом поле остаются нули, так как я о нём буквально не знал. В итоге визуально адреса одинаковые, но побайтово — нет. Поэтому лучше не сравнивать системные структуры через memcmp()

struct sockaddr_in {
    uint8_t        sin_len; // ни в Линуксе, ни в POSIX этого поля НЕТ
    sa_family_t    sin_family;
    in_port_t    sin_port;
    struct    in_addr sin_addr;
    char    sin_zero[8];
};

socket_address я, конечно же, поправил, и теперь это поле тоже заполняется. Но и оператор сравнения тоже изменил, чтобы поведение было более предсказуемым.

auto operator==(const socket_address& other) const -> bool
{
    return m_len == other.m_len && domain() == other.domain() && port() == other.port() && ip() == other.ip();
}

Я совсем не ожидал, что чтобы просто поправить IPv6, мне придётся изучать различия в заголовках Linux и BSD.

Что дальше?

Мой PR с socket_address с радостью приняли, а я принялся писать переход на унифицированное для разных платформ API, где я хочу уйти от модели co_await poll() + recv() к чистому co_await read(). Это позволит библиотеке частично абстрагироваться от того, что было под капотом. Затем ещё несколько абстрагирующих PR и только затем я начну реализовывать IOCP, который наконец станет работать в libcoro.

Потом можно будет добавить и io_uring, который тоже проактор, но под Линукс. Это позволит реализовать работу с файлами, которую epoll и libcoro не поддерживают.

Уроки, которые я извлёк

  1. Лучше не пытаться "съесть слона целиком". Я сделал свой первый PR огромным, из-за чего же сам и застрял. Лучше декомпозировать на малые куски, как socket_address и read/write

  2. CI на разных платформах обязателен. Раньше я вообще с понятием CI знаком не был, но он меня очень сильно выручил.

  3. В Open Source пишут код такие же люди. Да, там есть крутые инженеры, но даже они могут написать const_cast над constexpr, который будет сидеть годами. И его никто не заметит.

А какие цели я преследовал, занимаясь всем этим? Да фиг его знает. Что ещё на первом курсе делать.