Chuyển đến nội dung

Coroutine C++20

Hiểu coroutine là các hàm có thể tạm dừng. Tìm hiểu co_yield, co_return, co_await, mẫu trình tạo và các trường hợp sử dụng thực tế như I/O không đồng bộ và máy trạng thái.

Coroutine là gì?

coroutine là một hàm có thể tạm dừng quá trình thực thi của nó và được tiếp tục sau đó. Không giống như các hàm thông thường chạy từ đầu đến cuối, coroutine có thể tạm dừng giữa quá trình thực thi, mang lại kết quả trung gian và tiếp tục nơi chúng đã dừng lại. Điều này về cơ bản khác với các luồng: coroutine có tính hợp tác (chúng chọn thời điểm tạm dừng), nhẹ (không cần luồng hệ điều hành) và mang tính xác định (không có sự chạy đua dữ liệu từ truy cập đồng thời).

C++20 giới thiệu tính năng hỗ trợ coroutine ở cấp độ ngôn ngữ với ba từ khóa mới: co_yield (tạm dừng và tạo giá trị), co_return (hoàn thành coroutine với giá trị cuối cùng) và co_await (tạm dừng cho đến khi thao tác không đồng bộ hoàn tất). Bất kỳ hàm nào chứa một trong những từ khóa này đều tự động là một coroutine.

Mẫu máy phát điện

Trình tạo là một coroutine tạo ra một chuỗi các giá trị một cách lười biếng. Mỗi lệnh gọi tớico_yieldsẽ tạm dừng coroutine và cung cấp một giá trị cho người gọi. Đây là một triển khai trình tạo hoàn chỉnh, có thể biên dịch được:

generator.cpp
#include <iostream>
#include <coroutine>
#include <optional>

// A simple Generator type
template<typename T>
struct Generator {
    struct promise_type {
        T current_value;

        Generator get_return_object() {
            return Generator{std::coroutine_handle<promise_type>::from_promise(*this)};
        }
        std::suspend_always initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        std::suspend_always yield_value(T value) {
            current_value = std::move(value);
            return {};
        }
        void return_void() {}
        void unhandled_exception() { std::terminate(); }
    };

    std::coroutine_handle<promise_type> handle;

    explicit Generator(std::coroutine_handle<promise_type> h) : handle(h) {}
    ~Generator() { if (handle) handle.destroy(); }

    // Move only
    Generator(Generator&& other) noexcept : handle(other.handle) { other.handle = nullptr; }
    Generator& operator=(Generator&&) = delete;
    Generator(const Generator&) = delete;

    bool next() {
        handle.resume();
        return !handle.done();
    }

    T value() const { return handle.promise().current_value; }
};

// A coroutine that generates Fibonacci numbers
Generator<long long> fibonacci() {
    long long a = 0, b = 1;
    while (true) {
        co_yield a;
        auto next = a + b;
        a = b;
        b = next;
    }
}

// A coroutine that generates a range
Generator<int> range(int start, int end) {
    for (int i = start; i < end; ++i) {
        co_yield i;
    }
}

int main() {
    auto fib = fibonacci();
    std::cout << "First 10 Fibonacci numbers: ";
    for (int i = 0; i < 10 && fib.next(); ++i) {
        std::cout << fib.value() << " ";
    }
    std::cout << "\n";  // 0 1 1 2 3 5 8 13 21 34

    auto r = range(1, 6);
    while (r.next()) {
        std::cout << r.value() << " ";  // 1 2 3 4 5
    }
    std::cout << "\n";

    return 0;
}

Máy móc Coroutine: Lời hứa & Xử lý

Mỗi coroutine đều có hai thành phần chính:

promise_type — Kiểm soát hành vi của coroutine. Trình biên dịch tìm kiếmpromise_typelồng nhau trong kiểu trả về của coroutine. Các phương pháp chính:
-get_return_object()— tạo đối tượng được trả về cho người gọi
-initial_suspend()— trả vềsuspend_always(bắt đầu lười biếng) hoặcsuspend_never(bắt đầu háo hức)
-final_suspend()— phải làm gì khi coroutine kết thúc (phải lànoexcept)
-yield_value(T)— được gọi trênco_yield
-return_value(T)hoặcreturn_void()— được gọi trênco_return
-unhandled_exception()— được gọi nếu có ngoại lệ thoát

std::coroutine_handle

— Một con trỏ được xóa kiểu tới khung coroutine. Cung cấp các phương thứcresume(),done()vàdestroy(). Khung coroutine nằm trên heap và chứa tất cả các biến cục bộ cũng như điểm treo.

Quy trình như sau: người gọi tạo coroutine ->initial_suspend-> người gọi gọiresume()-> coroutine chạy cho đến khico_yield/co_return/co_await-> điều khiển quay lại người gọi -> lặp lại.

co_await và các đối tượng có thể chờ đợi

co_awaittạm dừng một coroutine cho đến khi hoạt động không đồng bộ hoàn tất. Bất kỳ loại nào triển khai giao diện Awaitable đều có thể được sử dụng vớico_await. Đây là nền tảng cho các khung I/O không đồng bộ:

co_await_example.cpp
#include <iostream>
#include <coroutine>
#include <chrono>
#include <thread>

// A simple awaitable that simulates async work
struct AsyncTimer {
    std::chrono::milliseconds duration;

    bool await_ready() const noexcept {
        return duration.count() <= 0;  // skip suspend if no wait needed
    }

    void await_suspend(std::coroutine_handle<> handle) const {
        // In production, this would register with an event loop
        // Here we simulate with a thread
        std::thread([handle, d = duration]() {
            std::this_thread::sleep_for(d);
            handle.resume();  // resume the coroutine
        }).detach();
    }

    void await_resume() const noexcept {
        // Called when the coroutine resumes — return value becomes
        // the result of the co_await expression
    }
};

// The three Awaitable methods:
// 1. await_ready()  — can we skip suspending? (optimization)
// 2. await_suspend() — what to do when suspending
// 3. await_resume()  — what value to produce when resuming

// A Task type for async coroutines
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() { std::terminate(); }
    };
};

Task async_work() {
    std::cout << "Starting async work...\n";
    co_await AsyncTimer{std::chrono::milliseconds(100)};
    std::cout << "Phase 1 complete\n";
    co_await AsyncTimer{std::chrono::milliseconds(100)};
    std::cout << "Phase 2 complete\n";
}

int main() {
    async_work();
    std::this_thread::sleep_for(std::chrono::milliseconds(500));
    return 0;
}

Trường hợp sử dụng thực tế

Coroutine vượt trội trong một số tình huống thực tế:

Trình tạo / Trình tự lười — Tạo ra các giá trị theo yêu cầu mà không cần tính toán trước toàn bộ trình tự. Lý tưởng cho các chuỗi vô hạn, phân tích tệp và đường dẫn dữ liệu.

I/O không đồng bộ — Máy chủ mạng, truy vấn cơ sở dữ liệu và thao tác tệp có thể hoàn thànhco_awaitmà không chặn luồng. Các thư viện nhưcppcoro,libunifexvà Boost.Asio cung cấp các nguyên hàm I/O sẵn sàng cho coroutine.

Máy trạng thái — Mỗico_yieldhoặcco_awaitđại diện cho một quá trình chuyển đổi trạng thái. Các biến cục bộ của coroutine bảo toàn trạng thái một cách tự nhiên giữa các lần chuyển đổi, loại bỏ các cấu trúc trạng thái thủ công.

Lập trình hướng sự kiện — Khung giao diện người dùng và vòng lặp trò chơi có thể sử dụng coroutine để thể hiện logic tuần tự trải rộng trên nhiều khung hình hoặc sự kiện.

Phân tích cú pháp — Trình tạo mã thông báo và trình phân tích cú pháp có thể mã thông báoco_yieldhoặc nút AST khi chúng xử lý dữ liệu đầu vào, duy trì trạng thái phân tích cú pháp một cách ngầm định.

Thực hành tốt

Coroutine và thread giải quyết các vấn đề khác nhau:

Sử dụng coroutine khi: bạn cần thực hiện đa nhiệm hợp tác, đánh giá từng phần hoặc I/O không đồng bộ mà không cần sử dụng các luồng hệ điều hành. Coroutine không cần chi phí chuyển đổi ngữ cảnh, không cần nguyên tắc đồng bộ hóa và có thể mở rộng quy mô thành hàng triệu tác vụ đồng thời.

Sử dụng luồng khi: bạn cần tính song song thực sự giữa các lõi CPU cho công việc liên quan đến CPU hoặc khi bạn cần lên lịch ưu tiên (một coroutine không bao giờ tạm dừng sẽ chặn luồng của nó).

Phương pháp tốt nhất: Sử dụng coroutine cho tính đồng thời liên kết với I/O và các luồng cho tính song song liên kết với CPU. Nhiều máy chủ hiệu suất cao kết hợp cả hai: một nhóm luồng chạy coroutine, trong đó mỗi coroutine xử lý một kết nối bằngco_awaitcho I/O.

Lỗi thường gặp

Tham chiếu lơ lửng: Các tham số coroutine được sao chép vào khung, nhưng các tham chiếu đến các tham chiếu tạm thời có thể bị treo. Luôn lấy tham số theo giá trị hoặc đảm bảo đối tượng được tham chiếu tồn tại lâu hơn coroutine.

Phân bổ vùng heap: Mỗi coroutine phân bổ một khung trên vùng heap. Đối với các coroutine rất nóng, tồn tại trong thời gian ngắn, đây có thể là một vấn đề đáng lo ngại về hiệu suất (mặc dù trình biên dịch có thể tối ưu hóa thông qua Tối ưu hóa eLision phân bổ Heap - HALO).

Khó khăn khi gỡ lỗi: Khó kiểm tra ngăn xếp Coroutine hơn trong trình gỡ lỗi. Trạng thái tạm dừng nằm trên vùng heap chứ không phải ngăn xếp cuộc gọi. Sử dụng tính năng ghi nhật ký và đồng thời có cấu trúc để duy trì khả năng gỡ lỗi.

Không có loại thư viện tiêu chuẩn: C++20 cung cấp ngôn ngữ nguyên thủy nhưng không có loạiGeneratorhoặcTasktiêu chuẩn. Bạn phải tự viết hoặc sử dụng thư viện. C++23 thêmstd::generator.

Điểm chính
  • Coroutine là các hàm có thể tạm dừng — chúng tạm dừng vớico_yield/co_awaitvà tiếp tục lại sau
  • Mẫu trình tạo tạo ra các chuỗi lười biếng: mỗico_yieldtạm dừng và cung cấp một giá trị
  • co_awaittạm dừng cho đến khi thao tác không đồng bộ hoàn tất - ba phương thức có thể chờ đợi sẽ kiểm soát hành vi
  • promise_typevàcoroutine_handletạo thành bộ máy kết nối coroutine với phương thức gọi của nó
  • Các coroutine có tính hợp tác (không có quyền ưu tiên) và nhẹ (không có luồng hệ điều hành) — lý tưởng cho hoạt động đồng thời có giới hạn I/O
  • C++20 chỉ cung cấp các bản gốc; sử dụngstd::generator(C++23) hoặc các thư viện như cppcoro cho mã sản xuất

Câu đố — Kiểm tra kiến thức

(20 XP)

1. Từ khóa nào biến hàm thành coroutine trong C++20?

2. Mục đích của`promise_type::yield_value(T)`là gì?

3. Coroutine khác với thread như thế nào?