Spec-Zone.ru › C++

Корутины (C++20)

Корутина — это функция, выполнение которой может быть приостановлено и возобновлено позднее. Корутины не используют стек: они приостанавливают выполнение, возвращаясь к вызывающей функции, а данные, необходимые для возобновления выполнения, хранятся отдельно от стека. Это позволяет писать последовательный код, выполняющийся асинхронно (например, для обработки неблокирующего ввода-вывода без явных обратных вызовов), а также поддерживает алгоритмы для бесконечных последовательностей, вычисляемых по требованию, и другие сценарии использования.

Функция является корутиной, если её определение содержит что-либо из следующего:

  • выражение co_await — для приостановки выполнения до его возобновления
task<> tcp_echo_server()
{
    char data[1024];
    while (true)
    {
        std::size_t n = co_await socket.async_read_some(buffer(data));
        co_await async_write(socket, buffer(data, n));
    }
}
  • выражение co_yield — для приостановки выполнения с возвратом значения
generator<unsigned int> iota(unsigned int n = 0)
{
    while (true)
        co_yield n++;
}
  • оператор co_return — для завершения выполнения с возвратом значения
lazy<int> f()
{
    co_return 7;
}

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

Ограничения

В корутинах нельзя использовать аргументы переменного числа, обычные операторы return или типы возвращаемого значения-заполнители (auto или концепт).

Функции consteval, функции constexpr, конструкторы, деструкторы и функция main не могут быть корутинами.

Выполнение

С каждой корутиной связаны

  • объект обещания, которым управляют изнутри корутины. Корутина передаёт через этот объект результат или исключение. Объекты обещания никак не связаны с std::promise.
  • дескриптор корутины, которым управляют извне корутины. Это невладеющий дескриптор, используемый для возобновления выполнения корутины или уничтожения её состояния.
  • состояние корутины — внутренний объект с динамически выделяемой памятью (если только выделение не оптимизировано), содержащий
    • объект обещания
    • параметры (все копируются по значению)
    • некоторое представление текущей точки приостановки, чтобы при возобновлении было известно, с какого места продолжить, а при уничтожении — какие локальные переменные находились в области видимости
    • локальные переменные и временные объекты, время жизни которых охватывает текущую точку приостановки.

При начале выполнения корутина выполняет следующие действия:

  • выделяет память для объекта состояния корутины с помощью operator new.
  • копирует все параметры функции в состояние корутины: параметры, переданные по значению, перемещаются или копируются, а параметры, переданные по ссылке, остаются ссылками (поэтому они могут стать висячими, если корутина будет возобновлена после завершения времени жизни объекта, на который они ссылаются, — примеры приведены ниже).
  • вызывает конструктор объекта обещания. Если у типа обещания есть конструктор, принимающий все параметры корутины, вызывается этот конструктор с аргументами корутины после копирования. В противном случае вызывается конструктор по умолчанию.
  • вызывает promise.get_return_object() и сохраняет результат в локальной переменной. Результат этого вызова будет возвращён вызывающей функции, когда корутина впервые приостановится. Все исключения, выброшенные на этом этапе или ранее, передаются вызывающей функции, а не помещаются в объект обещания.
  • вызывает promise.initial_suspend() и co_await его результат. Типы Promise обычно возвращают либо std::suspend_always для корутин с отложенным запуском, либо std::suspend_never для корутин с немедленным запуском.
  • когда co_await promise.initial_suspend() возобновляется, начинает выполняться тело корутины.

Несколько примеров того, как параметр может стать висячим:

#include <coroutine>
#include <iostream>

struct promise;

struct coroutine : std::coroutine_handle<promise>
{
    using promise_type = ::promise;
};

struct promise
{
    coroutine get_return_object() { return {coroutine::from_promise(*this)}; }
    std::suspend_always initial_suspend() noexcept { return {}; }
    std::suspend_always final_suspend() noexcept { return {}; }
    void return_void() {}
    void unhandled_exception() {}
};

struct S
{
    int i;
    coroutine f()
    {
        std::cout << i;
        co_return;
    }
};

void bad1()
{
    coroutine h = S{0}.f();
    // S{0} destroyed
    h.resume(); // resumed coroutine executes std::cout << i, uses S::i after free
    h.destroy();
}

coroutine bad2()
{
    S s{0};
    return s.f(); // returned coroutine can't be resumed without committing use after free
}

void bad3()
{
    coroutine h = [i = 0]() -> coroutine // a lambda that's also a coroutine
    {
        std::cout << i;
        co_return;
    }(); // immediately invoked
    // lambda destroyed
    h.resume(); // uses (anonymous lambda type)::i after free
    h.destroy();
}

void good()
{
    coroutine h = [](int i) -> coroutine // make i a coroutine parameter
    {
        std::cout << i;
        co_return;
    }(0);
    // lambda destroyed
    h.resume(); // no problem, i has been copied to the coroutine
                // state as a by-value parameter
    h.destroy();
}

Когда корутина достигает точки приостановки

  • объект возвращаемого значения, полученный ранее, возвращается вызывающей функции или функции, возобновившей выполнение, после неявного преобразования к типу возвращаемого значения корутины, если это необходимо.

При достижении оператором корутины co_return выполняются следующие действия:

  • вызывает promise.return_void() для
    • co_return;
    • co_return expr;, где expr имеет тип void
  • или вызывает promise.return_value(expr) для co_return expr;, где expr имеет тип, отличный от void
  • уничтожает все переменные с автоматическим временем хранения в порядке, обратном порядку их создания.
  • вызывает promise.final_suspend() и применяет co_await к результату.

Выход за конец корутины эквивалентен co_return;, за исключением того, что поведение не определено, если в области видимости Promise не найдено объявлений return_void. Функция, в теле которой нет ни одного из ключевых слов, определяющих корутину, не является корутиной независимо от типа её возвращаемого значения; выход за конец такой функции приводит к неопределённому поведению, если тип возвращаемого значения не является (возможно, cv-квалифицированным) void.

// assuming that task is some coroutine task type
task<void> f()
{
    // not a coroutine, undefined behavior
}

task<void> g()
{
    co_return;  // OK
}

task<void> h()
{
    co_await g();
    // OK, implicit co_return;
}

Если выполнение корутины завершается необработанным исключением, она выполняет следующие действия:

  • перехватывает исключение и вызывает promise.unhandled_exception() из блока catch;
  • вызывает promise.final_suspend() и применяет co_await к результату (например, чтобы возобновить продолжение или опубликовать результат). Возобновление корутины на этом этапе приводит к неопределённому поведению.

Когда состояние корутины уничтожается — из-за завершения через co_return или необработанное исключение либо из-за уничтожения через её дескриптор, — выполняются следующие действия:

  • вызывается деструктор объекта обещания.
  • вызываются деструкторы копий параметров функции.
  • вызывается operator delete для освобождения памяти, использованной состоянием корутины.
  • выполнение передаётся обратно вызывающей функции или функции, возобновившей выполнение.

Динамическое выделение памяти

Память для состояния корутины динамически выделяется с помощью оператора operator new, не являющегося оператором для массивов.

Если тип Promise определяет замену на уровне класса, используется она; в противном случае используется глобальный operator new.

Если тип Promise определяет размещающую форму operator new, принимающую дополнительные параметры, и они соответствуют списку аргументов, в котором первый аргумент — запрашиваемый размер (типа std::size_t), а остальные — аргументы функции-корутины, эти аргументы передаются в operator new (это позволяет использовать для корутин соглашение о передаче аллокатора первым аргументом).

Вызов operator new может быть оптимизирован (даже при использовании пользовательского аллокатора), если

  • время жизни состояния корутины строго вложено во время жизни вызывающей функции и
  • размер состояния корутины известен в месте вызова.

В этом случае состояние корутины размещается в кадре стека вызывающей функции (если вызывающая функция является обычной функцией) или в состоянии вызывающей корутины (если вызывающая функция является корутиной).

Если выделение памяти завершается неудачей, корутина выбрасывает std::bad_alloc, если только тип Promise не определяет функцию-член Promise::get_return_object_on_allocation_failure(). Если эта функция-член определена, для выделения памяти используется форма operator new, не выбрасывающая исключений, и при ошибке выделения корутина немедленно возвращает вызывающей функции объект, полученный вызовом Promise::get_return_object_on_allocation_failure(), например:

struct Coroutine::promise_type
{
    /* ... */

    // ensure the use of non-throwing operator-new
    static Coroutine get_return_object_on_allocation_failure()
    {
        std::cerr << __func__ << '\n';
        throw std::bad_alloc(); // or, return Coroutine(nullptr);
    }
    
    // custom non-throwing overload of new
    void* operator new(std::size_t n) noexcept
    {
        if (void* mem = std::malloc(n))
            return mem;
        return nullptr; // allocation failure
    }
};

Обещание

Тип Promise определяется компилятором на основе типа возвращаемого значения корутины с помощью std::coroutine_traits.

Формально, пусть

  • R и Args... обозначают соответственно тип возвращаемого значения корутины и список типов её параметров;
  • ClassT обозначает тип класса, которому принадлежит корутина, если она определена как нестатическая функция-член;
  • cv обозначает cv-квалификацию, указанную в объявлении функции, если она определена как нестатическая функция-член,

тогда тип Promise определяется следующим образом:

  • std::coroutine_traits<R,Args...>::promise_type, если корутина не определена как функция-член с неявным объектом;
  • std::coroutine_traits<R,cvClassT&,Args...>::promise_type, если корутина определена как функция-член с неявным объектом без квалификатора rvalue-ссылки;
  • std::coroutine_traits<R,cvClassT&&,Args...>::promise_type, если корутина определена как функция-член с неявным объектом и квалификатором rvalue-ссылки.

Например:

Если корутина определена как ... то её тип Promise — это ...
task<void> foo(int x); std::coroutine_traits<task<void>,int>::promise_type
task<void> Bar::foo(int x) const; std::coroutine_traits<task<void>,constBar&,int>::promise_type
task<void> Bar::foo(int x) &&; std::coroutine_traits<task<void>,Bar&&,int>::promise_type

co_await

Унарный оператор co_await приостанавливает корутину и возвращает управление вызывающей функции.

co_await expr

Выражение co_await может встречаться только в потенциально вычисляемом выражении внутри обычного тела функции (включая тело функции лямбда-выражения) и не может встречаться

  • в обработчике;
  • в операторе объявления, если только оно не находится в инициализаторе этого оператора объявления;
  • в простом объявлении init-statement (см. if, switch, for и [[../range-for|range-for]]), если только оно не находится в инициализаторе этого init-statement ;
  • в аргументе по умолчанию или
  • в инициализаторе переменной с областью видимости блока и статическим или потоковым временем хранения.

Выражение co_await не может быть потенциально вычисляемым подвыражением предиката контрактного утверждения.

(начиная с C++26)

Сначала expr преобразуется в объект, допускающий ожидание, следующим образом:

  • если expr получено из начальной точки приостановки, конечной точки приостановки или выражения yield, объектом, допускающим ожидание, является само expr без изменений;
  • в противном случае, если тип Promise текущей корутины содержит функцию-член await_transform, объектом, допускающим ожидание, является promise.await_transform(expr);
  • в противном случае объектом, допускающим ожидание, является само expr без изменений.

Затем следующим образом получается объект-ожидатель:

  • если разрешение перегрузки для operatorco_await выбирает единственную наиболее подходящую перегрузку, объектом-ожидателем становится результат её вызова:
    • awaitable.operator co_await() для перегрузки-члена;
    • operator co_await(static_cast<Awaitable&&>(awaitable)) для перегрузки, не являющейся членом.
  • в противном случае, если при разрешении перегрузки оператор co_await не найден, объектом-ожидателем становится сам объект, допускающий ожидание, без изменений;
  • в противном случае, если разрешение перегрузки неоднозначно, программа некорректна.

Если приведённое выше выражение является prvalue, объект-ожидатель представляет собой временный объект, материализованный из него. В противном случае, если приведённое выше выражение является glvalue, объектом-ожидателем является объект, на который оно ссылается.

Затем вызывается awaiter.await_ready() (это позволяет избежать затрат на приостановку, если известно, что результат уже готов или может быть получен синхронно). Если результат этого вызова, контекстно преобразованный к bool, равен false, то

Корутина приостанавливается (её состояние заполняется локальными переменными и текущей точкой приостановки).
Вызывается awaiter.await_suspend(handle), где handle — дескриптор корутины, представляющий текущую корутину. Внутри этой функции приостановленное состояние корутины доступно через этот дескриптор, а сама функция должна запланировать её возобновление в некотором исполнителе или уничтожение (возврат false считается планированием)
  • если await_suspend возвращает void, управление немедленно возвращается вызывающей функции или функции, возобновившей текущую корутину (эта корутина остаётся приостановленной); в противном случае
  • если await_suspend возвращает bool,
    • значение true возвращает управление вызывающей функции или функции, возобновившей текущую корутину;
    • значение false возобновляет текущую корутину.
  • если await_suspend возвращает дескриптор другой корутины, эта корутина возобновляется (вызовом handle.resume()) (обратите внимание: это может запустить цепочку вызовов, которая в итоге приведёт к возобновлению текущей корутины);
  • если await_suspend выбрасывает исключение, оно перехватывается, корутина возобновляется, и исключение немедленно выбрасывается повторно.

Наконец, вызывается awaiter.await_resume() (независимо от того, была ли корутина приостановлена), и его результат становится результатом всего выражения co_await expr.

Если корутина была приостановлена в выражении co_await и позднее возобновлена, выполнение продолжается непосредственно перед вызовом awaiter.await_resume().

Обратите внимание, что корутина полностью приостанавливается до входа в awaiter.await_suspend(). Её дескриптор можно передать другому потоку и возобновить до того, как функция await_suspend() вернёт управление. (Обратите внимание, что стандартные правила безопасности памяти по-прежнему применяются: если дескриптор корутины передаётся между потоками без блокировки, ожидатель должен использовать как минимум семантику release, а возобновляющий поток — как минимум семантику acquire.) Например, дескриптор корутины можно поместить в обратный вызов, запланированный для выполнения в пуле потоков после завершения асинхронной операции ввода-вывода. В этом случае, поскольку текущая корутина могла быть возобновлена и, следовательно, выполнить деструктор объекта-ожидателя, пока await_suspend() продолжает выполнение в текущем потоке, await_suspend() должен считать *this уничтоженным и не обращаться к нему после публикации дескриптора в других потоках.

Пример

#include <coroutine>
#include <iostream>
#include <stdexcept>
#include <thread>

auto switch_to_new_thread(std::jthread& out)
{
    struct awaitable
    {
        std::jthread* p_out;
        bool await_ready() { return false; }
        void await_suspend(std::coroutine_handle<> h)
        {
            std::jthread& out = *p_out;
            if (out.joinable())
                throw std::runtime_error("Output jthread parameter not empty");
            out = std::jthread([h] { h.resume(); });
            // Potential undefined behavior: accessing potentially destroyed *this
            // std::cout << "New thread ID: " << p_out->get_id() << '\n';
            std::cout << "New thread ID: " << out.get_id() << '\n'; // this is OK
        }
        void await_resume() {}
    };
    return awaitable{&out};
}

struct task
{
    struct promise_type
    {
        task get_return_object() { return {}; }
        std::suspend_never initial_suspend() { return {}; }
        std::suspend_never final_suspend() noexcept { return {}; }
        void return_void() {}
        void unhandled_exception() {}
    };
};

task resuming_on_new_thread(std::jthread& out)
{
    std::cout << "Coroutine started on thread: " << std::this_thread::get_id() << '\n';
    co_await switch_to_new_thread(out);
    // awaiter destroyed here
    std::cout << "Coroutine resumed on thread: " << std::this_thread::get_id() << '\n';
}

int main()
{
    std::jthread out;
    resuming_on_new_thread(out);
}

Возможный вывод:

Coroutine started on thread: 139972277602112
New thread ID: 139972267284224
Coroutine resumed on thread: 139972267284224

Примечание: объект-ожидатель является частью состояния корутины (как временный объект, время жизни которого охватывает точку приостановки) и уничтожается до завершения выражения co_await. Его можно использовать для хранения состояния отдельной операции, необходимого некоторым API асинхронного ввода-вывода, без дополнительных динамических выделений памяти.

Стандартная библиотека определяет два тривиальных объекта, допускающих ожидание: std::suspend_always и std::suspend_never.

Пример использования promise_type::await_transform и ожидателя, предоставленного программой

Пример

#include <cassert>
#include <coroutine>
#include <iostream>

struct tunable_coro
{
    // An awaiter whose "readiness" is determined via constructor's parameter.
    class tunable_awaiter
    {
        bool ready_;
    public:
        explicit(false) tunable_awaiter(bool ready) : ready_{ready} {}
        // Three standard awaiter interface functions:
        bool await_ready() const noexcept { return ready_; }
        static void await_suspend(std::coroutine_handle<>) noexcept {}
        static void await_resume() noexcept {}
    };

    struct promise_type
    {
        using coro_handle = std::coroutine_handle<promise_type>;
        auto get_return_object() { return coro_handle::from_promise(*this); }
        static auto initial_suspend() { return std::suspend_always(); }
        static auto final_suspend() noexcept { return std::suspend_always(); }
        static void return_void() {}
        static void unhandled_exception() { std::terminate(); }
        // A user provided transforming function which returns the custom awaiter:
        auto await_transform(std::suspend_always) { return tunable_awaiter(!ready_); }
        void disable_suspension() { ready_ = false; }
    private:
        bool ready_{true};
    };

    tunable_coro(promise_type::coro_handle h) : handle_(h) { assert(h); }

    // For simplicity, declare these 4 special functions as deleted:
    tunable_coro(tunable_coro const&) = delete;
    tunable_coro(tunable_coro&&) = delete;
    tunable_coro& operator=(tunable_coro const&) = delete;
    tunable_coro& operator=(tunable_coro&&) = delete;

    ~tunable_coro()
    {
        if (handle_)
            handle_.destroy();
    }

    void disable_suspension() const
    {
        if (handle_.done())
            return;
        handle_.promise().disable_suspension();
        handle_();
    }

    bool operator()()
    {
        if (!handle_.done())
            handle_();
        return !handle_.done();
    }
private:
    promise_type::coro_handle handle_;
};

tunable_coro generate(int n)
{
    for (int i{}; i != n; ++i)
    {
        std::cout << i << ' ';
        // The awaiter passed to co_await goes to promise_type::await_transform which
        // issues tunable_awaiter that initially causes suspension (returning back to
        // main at each iteration), but after a call to disable_suspension no suspension
        // happens and the loop runs to its end without returning to main().
        co_await std::suspend_always{};
    }
}

int main()
{
    auto coro = generate(8);
    coro(); // emits only one first element == 0
    for (int k{}; k < 4; ++k)
    {
        coro(); // emits 1 2 3 4, one per each iteration
        std::cout << ": ";
    }
    coro.disable_suspension();
    coro(); // emits the tail numbers 5 6 7 all at ones
}

Вывод:

0 1 : 2 : 3 : 4 : 5 6 7

co_yield

Выражение co_yield возвращает значение вызывающей функции и приостанавливает текущую корутину: это основной строительный блок возобновляемых функций-генераторов.

co_yield expr
co_yield braced-init-list

Это эквивалентно

co_await promise.yield_value(expr)

Типичный yield_value генератора сохраняет (копирует, перемещает или просто сохраняет адрес, поскольку время жизни аргумента охватывает точку приостановки внутри co_await) свой аргумент в объекте генератора и возвращает std::suspend_always, передавая управление вызывающей функции или функции, возобновившей выполнение.

#include <coroutine>
#include <cstdint>
#include <exception>
#include <iostream>

template<typename T>
struct Generator
{
    // The class name 'Generator' is our choice and it is not required for coroutine
    // magic. Compiler recognizes coroutine by the presence of 'co_yield' keyword.
    // You can use name 'MyGenerator' (or any other name) instead as long as you include
    // nested struct promise_type with 'MyGenerator get_return_object()' method.
    // (Note: It is necessary to adjust the declarations of constructors and destructors
    //  when renaming.)

    struct promise_type;
    using handle_type = std::coroutine_handle<promise_type>;

    struct promise_type // required
    {
        T value_;
        std::exception_ptr exception_;

        Generator get_return_object()
        {
            return Generator(handle_type::from_promise(*this));
        }
        std::suspend_always initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        void unhandled_exception() { exception_ = std::current_exception(); } // saving
                                                                              // exception

        template<std::convertible_to<T> From> // C++20 concept
        std::suspend_always yield_value(From&& from)
        {
            value_ = std::forward<From>(from); // caching the result in promise
            return {};
        }
        void return_void() {}
    };

    handle_type h_;

    Generator(handle_type h) : h_(h) {}
    ~Generator() { h_.destroy(); }
    explicit operator bool()
    {
        fill(); // The only way to reliably find out whether or not we finished coroutine,
                // whether or not there is going to be a next value generated (co_yield)
                // in coroutine via C++ getter (operator () below) is to execute/resume
                // coroutine until the next co_yield point (or let it fall off end).
                // Then we store/cache result in promise to allow getter (operator() below
                // to grab it without executing coroutine).
        return !h_.done();
    }
    T operator()()
    {
        fill();
        full_ = false; // we are going to move out previously cached
                       // result to make promise empty again
        return std::move(h_.promise().value_);
    }

private:
    bool full_ = false;

    void fill()
    {
        if (!full_)
        {
            h_();
            if (h_.promise().exception_)
                std::rethrow_exception(h_.promise().exception_);
            // propagate coroutine exception in called context

            full_ = true;
        }
    }
};

Generator<std::uint64_t>
fibonacci_sequence(unsigned n)
{
    if (n == 0)
        co_return;

    if (n > 94)
        throw std::runtime_error("Too big Fibonacci sequence. Elements would overflow.");

    co_yield 0;

    if (n == 1)
        co_return;

    co_yield 1;

    if (n == 2)
        co_return;

    std::uint64_t a = 0;
    std::uint64_t b = 1;

    for (unsigned i = 2; i < n; ++i)
    {
        std::uint64_t s = a + b;
        co_yield s;
        a = b;
        b = s;
    }
}

int main()
{
    try
    {
        auto gen = fibonacci_sequence(10); // max 94 before uint64_t overflows

        for (int j = 0; gen; ++j)
            std::cout << "fib(" << j << ")=" << gen() << '\n';
    }
    catch (const std::exception& ex)
    {
        std::cerr << "Exception: " << ex.what() << '\n';
    }
    catch (...)
    {
        std::cerr << "Unknown exception.\n";
    }
}

Вывод:

fib(0)=0
fib(1)=1
fib(2)=1
fib(3)=2
fib(4)=3
fib(5)=5
fib(6)=8
fib(7)=13
fib(8)=21
fib(9)=34

Примечания

Макрос проверки поддержки возможностей Значение Стандарт Возможность
__cpp_impl_coroutine 201902L (C++20) Корутины (поддержка компилятора)
__cpp_lib_coroutine 201902L (C++20) Корутины (поддержка библиотеки)
__cpp_lib_generator 202207L (C++23) std::generator: синхронный генератор корутин для диапазонов

Ключевые слова

co_await, co_return, co_yield

Поддержка библиотеки

Библиотека поддержки корутин определяет несколько типов, обеспечивающих поддержку корутин во время компиляции и выполнения.

Отчёты об ошибках

Следующие отчёты об ошибках, изменяющие поведение, были ретроактивно применены к ранее опубликованным стандартам C++.

DR Применяется к Поведение согласно опубликованной версии Корректное поведение
CWG 2556 C++20 некорректный return_void приводил к неопределённому поведению при
выходе за конец корутины
в этом случае программа
некорректна
CWG 2668 C++20 co_await нельзя было использовать в лямбда-выражениях разрешено
CWG 2754 C++23 *this использовался при создании объекта обещания
для функций-членов с явным объектом
*this в этом случае
не используется

См. также

execution::task
(C++26)
представляет отправителя, который можно использовать как тип возвращаемого значения функций-корутин
(шаблон класса)
generator
(C++23)
view, представляющее генератор синхронных корутин
(шаблон класса)
coroutine_traits
(C++20)
тип-признак для определения типов обещаний корутин
(шаблон класса)

Внешние ссылки

1. Lewis Baker, 2017–2022 — Асимметричная передача управления.
2. David Mazières, 2021 — Руководство по корутинам C++20.
3. Chuanqi Xu & Yu Qi & Yao Han, 2021 — Принципы и применение корутин в C++20. (на китайском)
4. Simon Tatham, 2023 — Создание пользовательских систем корутин C++20.

© cppreference.com
Licensed under the Creative Commons Attribution-ShareAlike Unported License v3.0.
https://en.cppreference.com/cpp/language/coroutines

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API