Nikita Shirikov Ed blog

Lockfree, coroutines & fibers

Данный пост является объединением 3-ех лекций из “Продвинутого курса C++” для второго курса ПМИ ВШЭ.

Неблокирующая многопоточность

Чем отличаются atomic’и и mutex’ы? Они представляют 2 различных подхода к многопточности.

~ Замеряем производительность атомиком и мьютексов ~

Итоги: на одном потоке мьютекс быстрее, на 2 и более мьютексы становятся быстрее. Зачем же нужны атомики?

Мьютекс не гарантирует время исполнения операции.

Любой многопоточный алгоритм: lock-free, wait-free, либо никакой. Wait-free - все потоки одновременно продвигаются. Lock-free - хоть один поток делает прогресс в системе.

Как же писать lock free код.

fetch методы атомика

ЗАДАЧА: fetch-mul

Пример некорректоного кода:

#include <atomic>
int FetchMul(std::atomic<int>& a, int b) {-
    int value = a.load();- 1
    a.store(d: value * b);~ return value;-
    return value;
}
void FetchMul(std::atomic<int>& a, int b) {
    int exptected = a.load();

    // compare and swap, Cas
    if (a.compare_exchange_strong(expected, expected * b)) {
        // return true
    } else {
        // update expected
    }

    a.compare_exchange_weak(); // может сфейлиться даже, если значения совпали, но на уровне железа работает быстрее
}

Lock-free stack

#include <stack> 
template <typename T>
class LockFreeStack {
public:
    void Push(T value) {
        Node* expected_head = nullptr;-
        Node* new_head = new Node{expected_head, std::move(value)};

        while (!head_.compare_exchange_weak(expected_head, new_head)) {
            new_head next = expected_head;
        }
    }

    std::optinal<T> Pop() {
        Node* expected_head = head.load();

        // Hazard pointers - один из вариантов
        // dodo
    }
}

Корутины

Функции, которые могут приостанавливаться (сохраняя состояния) и передавать управление другой функции.

statefull (хранит состояние на куче) / stateless (на стеке)

без поддержки компилятора / обязательно поддержка

библиотека: cppcoro

Корутины в C++20

Корутина - функция, где есть использование одного из трех co_ - операторов

Такие названия нужны ради обратной совместимости. Первые 2 слова - сахар Важнее co_await

Утиная типизация для корутин.

Смотрим свойства:

Корутина - это обычная функция Много соглашений, много соглашений …

поведение функции определяется типом возврата (return object)

return_object promise_type <- сохраняет состояние coroutine_handle <- ручка

// generated code
nonstd::generator<int> numbers(int from, int to, int step) {
    using return_object = nonstd:: generator<int>; // реализовываем мы
    using promise_type = typename return_object::promise_type; // реализовываем мы
    using coroutine_handle = std:: coroutine_handle<promise_type>;

    promise_type promise(from, to, steps);
    return_object res = promise.get_return_object();
    //...
}

co_yield -> co_await promise.yield_value() co_return -> co_await promise.return_value()

co_await - приостанавливает управление и передает его вызывающей стороне

пример типичной корутины

// generated code
nonstd:: generator<int> numbers (int from, int to, int step) {
    using return_object = nonstd::generator<int>;
    using promise_type = typename return_object::promise_type;

    promise_type promise{from, to, step};
    return_object res = promise.get_return_object();
    co_await promise.initial_suspend();

    try {
    /* coroutine body */
    } catch (...) {
        promise.unhandled_exception();
    }
    co_await promise.final_suspend ();
}

Promise Что должно быть в минимальном promise?

resumable hello_world() {
    std::cout < "Hello, ";
    co_await std::suspend_always{}; // awaitable, стандарнтные примитивы suspend_always и suspend_never
    std::cout < "world!\n";
}

int main() {
    auto coro = hello_world();
    coro.resume();
    coro.resume();
}

Когда нужны корутины?

Когда программа много работает с внешним миром. Когда программа не cpu bound.

lection12.cpp - пример простой корутины lection12-gen.cpp - пример генератора

обсудили teleport_to - awaitable

void await_suspend(std::corutine_handle<> handle) const noexcept {
    target = std::thread{[handle] {
        handle.resume();
    }};
}

Что такое fiber и почему они крутые?

Сначала идет повторение матриала про корутины и их пользу (при работе с внешними вызовами).

Проблема: в не cpu-bounded задачах мы не знаем, сколько потоков запускать.

Сейчас можно пользоваться асинхроннными системными вызовами и т.д.

Чтобы было удобно со всем этим добром работать, придумали абстракции coroutines / fiber для асинхронного программирования.

Проблема корутин - тяжело использовать. Много багов, много проблем из-за памяти.

Корутины сильно делят кодовую базу на 2 типа функций, что неудобно, много кода написано без корутин.

Компиляторы только-только научились корутинам, еще в clang-17 есть множество критических багов.

Но как же люди живут на самом деле?

Ответ: fiber (или stackfull coroutines)

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

Вся магия в следующем коде:

#pragma once
struct Context {
    void* ip;
    void* sp;
    void* regs regs[10];
};

enum class ESaveContextResult : int {
    Saved  = 0,
    Resumed = 1,
};

// Implimented in context.S
extern "C" ESaveContextResult SaveContext(Context* ctx) __attribute__((returns_twice));

extren "C" void JumpContext(Context* ctx) __attribute__((noreturn)); // атрибут, обозначающий, что мы не вернемся

Дальше в качестве демонстрации идет код на ассемблере, который собственно и сохраняет контекст.

Резко переходим к fiber’ам - оберткой над всей этой темой.

Fiber’ы проще корутин. Но дороже, и по памяти, и по cpu.

В плюсах нет одного общего движка Fiber’ов.

Вывод: нужно разбираться дальше, но тема крутая

#Cpp