Nhìn vào Unity khiến tôi hiểu được quan điểm của coroutines C++
Tin tức chung·Hacker News·0 lượt xem

Nhìn vào Unity khiến tôi hiểu được quan điểm của coroutines C++

Looking at Unity made me understand the point of C++ coroutines

AI Summary

Anh em dev C++ ơi, để ý vụ C++ coroutines nhé. Tác giả thấy C# coroutines trong Unity hay lắm, dùng để xử lý mấy thứ "thoáng qua" trong game như hiệu ứng hình ảnh ngon lành. Kiểu này giúp code trông thẳng băng, dễ đọc, viết như điều khiển từng bước, tạm dừng ở các `yield` point mà không cần bày vẽ quản lý state thủ công phức tạp. Ngược lại, C++ coroutines lại chưa phổ biến lắm, chắc do nó hơi "low-level", phức tạp, với lại ít thấy use case nào thực sự rõ ràng, thuyết phục ngoài async IO. Tuy nhiên, anh em dev C++ nên thử cân nhắc coroutines cho mấy đoạn logic tuần tự, có tính thời gian trong dự án của mình. Nhớ cái cách mà game engine dùng nó cho các tác vụ diễn ra qua nhiều frame ấy, áp dụng vào C++ cũng hay ho phết.

Ngày 20 tháng 3 năm 2026 trên C++, Phát triển trò chơiTôi đã thấy nhiều cuộc thảo luận về coroutine nhưng chưa bao giờ thực sự nhấn mạnh đến việc tôi có thể sử dụng chúng ngoài IO không đồng bộ. Cho đến khi tôi xem cách Unity sử dụng chúng trong C#.Coroutines có...

Tôi đã xem nhiều cuộc thảo luận về coroutine nhưng chưa bao giờ thực sự hiểu rõ rằng tôi có thể sử dụng chúng ngoài môi trường không đồng bộ IO. Cho đến khi tôi xem cách Unity sử dụng chúng trong C#.

Coroutine đã có mặt trong C++ được 6 năm rồi. Và tôi vẫn chưa gặp bất kỳ mã nào trong mã sản xuất. Điều này có thể là do bản thân chúng là một tính năng cấp thấp. Hay chính xác hơn, chúng là một tính năng cấp cao yêu cầu nhiều mã cấp thấp phức tạp (và riêng biệt) để đưa vào dự án. Nhưng tôi nghi ngờ một vấn đề khác, thậm chí còn lớn hơn, khi triển khai coroutines trong C++ là thiếu ví dụ cụ thể. Rốt cuộc, bạn có thường xuyên phải tính Fibonacci trong đời thực không?

Gần đây, tôi đã xem xét Unity, công ty chủ yếu sử dụng C# cho mã trò chơi máy khách (bạn có thể thực hiện C++ nhưng điều này không phổ biến). Và cụ thể hơn, tôi đã tìm hiểu cách họ sử dụng coroutine để tạo hiệu ứng sinh sản và các hành vi phù du khác. Sau đây là ví dụ từ hướng dẫn sử dụng Tôi sẽ sao chép lại ở đây nhằm mục đích minh họa cho bài viết này:

void Cập nhật()
{
    if (Nhập.GetKeyDown("f"))
    { StartCoroutine(Fade());
    

IEnumerator Làm mờ()
{
    Màu sắc c = trình kết xuất.chất liệu.màu sắc;
    for (float alpha = 1f ; alpha >= 0; alpha -= 0.1f)
    {
        c.a = alpha;
        trình kết xuất.chất liệu.màu sắc = c;
        lợi nhuận lợi nhuận null;
    

Những người theo chủ nghĩa thuần túy C# và/hoặc coroutine có thể cảm thấy khó chịu khi sử dụng sản lượng này. Sau tất cả các ngữ nghĩa ở đây đều sai. Chúng tôi không thu được kết quả gì khi cố gắng thể hiện điều gì đó giống với await NextFrame(). Theo những gì tôi có thể đọc được thì đây là một tạo phẩm được kế thừa từ việc thiếu hỗ trợ await khi chúng được thêm vào C# lần đầu (chúng chỉ hỗ trợ kiểu trình tạo sản lượng), khiến Unity sử dụng bản hack này vẫn còn tồn tại cho đến ngày nay. Tôi không chỉ đề cập đến nó như một mẩu chuyện ngẫu nhiên về lịch sử mà sau này nó sẽ trở nên có liên quan.

Tại sao lại dùng coroutines?

Ví dụ này vẫn hơi cơ bản và có thể không làm rõ ngay lý do tại sao chúng ta lại thích viết các hiệu ứng của mình theo cách này. Rốt cuộc, điều này có thể được tạo thành một lambda đơn giản với biến alpha có thể thay đổi mà chúng ta sẽ thúc đẩy mỗi lệnh gọi. Nhưng hãy thử với một hiệu ứng phức tạp hơn một chút:

IEnumerator TimeWarp()
{
    // Đó chỉ là một bước nhảy sang trái
    chuyển đổi.vị thế.x -= 1.f;
    lợi nhuận trả lại null;
     // Sau đó một bước sang phải
    for (int i = 0; i < 4; ++i)
    {
        biến đổi.vị trí.x += 0,2f;
        lợi nhuận  trả về null;
    
    // Đặt tay lên hông
    // ...
    // Hãy thực hiện lại việc dịch chuyển thời gian!
    for (int i = 0; i < 4; ++i)
    {
         biến đổi.Xoay(0.f, 90.f * i, 0.f);
        lợi nhuận trả lại null;
    

Bây giờ, việc biến cái này thành một hàm functor hoặc lambda thông thường sẽ trở nên thực sự khó khăn. Viết nó bằng C++ sẽ biến nó thành một loại máy trạng thái xấu xí như thế này:

class TimeWarp
{
    enum lớp Trạng thái
    {
        Nhảy,
        StepRight,
        Chắp tay,
        // ...
        Làm lại
    };
     Trạng thái _state = Trạng thái::Nhảy;
    int _i = 0;
    Chuyển đổi* _transform;
    TimeWarp(Chuyển đổi& chuyển đổi) : _transform( &chuyển đổi) {>
    bool toán tử()()
    {
        chuyển đổi ( _state )
        {
            trường hợp Trạng thái::Nhảy:
                _transform->vị trí.x -= 1.f;
                _state = Tiểu bang::StepRight;
                ngắt;
            trường hợp Trạng thái::StepRight:
                _transform->vị trí.x  += 0,2f;
                if ( ++_i == 4 )
                {
                    _state = Tiểu bang::HandsOnHip;
                    _i = 0;
                 ngắt;
            // ...
            trường hợp Trạng thái::Thực hiện lại:
                _transform->Xoay(0.f, 90.f  i, 0.f);
                nếu ( ++_i == 4 )
                {
                    // Cho biết chúng tôi đã hoàn tất
                    trở lại true;
                
                ngắt;
        
        trả về false;
    
 

Khá xấu phải không? Bạn có để nó vượt qua việc xem xét mã không? Thay vào đó, bạn sẽ đề xuất điều gì khác?

Tôi đoán có lẽ tôi sẽ khuyên tác giả nên chia TimeWarp thành các bước di chuyển thành phần của nó và xử lý các chuyển đổi trạng thái bằng cách xếp hàng hiệu ứng tiếp theo dưới dạng phần tiếp theo. Nhưng có lẽ tôi sẽ không hài lòng về điều đó.

Đối với tôi, đây là một trường hợp hiển nhiên mà tôi khao khát được bán theo giá trị của coroutines. Việc gói một vòng lặp có thể không gây rắc rối khi tìm ra cách tích hợp các coroutine trong cơ sở mã của bạn, nhưng việc gói một chuỗi thao tác bằng trạng thái chắc chắn sẽ làm được. Tất cả chỉ là việc biến một máy trạng thái khó đọc thành một hàm rất đơn giản.

Triển khai C++23

Vì vậy, hãy cùng thực hiện lại thời gian trong C++ thì.

std::generator<std::monostate> TimeWarp(GameObject& obj )
{
    // Đó chỉ là một bước nhảy sang trái
    obj.chuyển đổi.vị trí.x -= 1.f;
    co_yield {};
    // Sau đó một bước sang phải
    for (int i = 0; i < 4; ++i)
    {
        obj.chuyển đổi.vị trí.x += 0,2f;
        co_yield {};
    
    // Đặt tay lên hông
    // ... // Hãy thực hiện lại việc dịch chuyển thời gian!
    for (int i = 0; i < 4; ++i)
    {
        obj.chuyển đổi.Xoay(0.f, 90. f * i, 0.f);
        co_yield {};
    

Độc giả của tôi có thể phản đối rằng đây là một vụ hack. Trên thực tế, đây là vụ hack tương tự như Unity đã thực hiện cách đây một thập kỷ và có một số thay đổi trước đây. Và đó chính xác là vấn đề. Vì những lý do tương tự.

Hãy xem, lý do thực sự khiến chúng ta chủ yếu thấy các trình tạo Fibonacci trong các trang trình bày là vì việc sử dụng co_yield khá dễ dàng (tương đối), đặc biệt là vì C++ 23 đã cung cấp cho chúng ta . Nhưng việc sử dụng co_awaitkhó. Việc thu được từ một coroutine khá đơn giản và chung chung. Luồng điều khiển rất đơn giản, chúng tôi tạm dừng và quay lại với người gọi và họ quyết định khi nào chúng tôi sẽ thức dậy tiếp theo. Mặt khác, việc xử lý co_await yêu cầu phải trả lời rất nhiều câu hỏi không có câu trả lời rõ ràng. Chúng ta sẽ chờ đợi điều gì? Làm thế nào họ sẽ báo hiệu rằng họ đã sẵn sàng để tiếp tục? Chúng ta có thể sử dụng tín hiệu/ngắt thay vì bỏ phiếu không? Ai sẽ kiểm tra xem chúng đã sẵn sàng để chạy lại chưa? Họ cũng sẽ đánh thức (chạy) coroutine hay sẽ đưa chúng trở lại hàng đợi thực thi? Hàng đợi thực thi nào? Một chủ đề nền? Một nhóm chủ đề? Sử dụng triển khai gì? Danh sách vẫn tiếp tục.

Để trích dẫn sai Kennedy, “chúng tôi đã chọn tập trung các coroutine vào trình tạo trong C++23, không phải vì nó khó mà vì nó dễ.

C++26 nên triển khai thực thi và cung cấp cho chúng tôi một khuôn khổ để có thể sử dụng co_await, nhưng tôi cho rằng đây sẽ là một cuộc chiến khó khăn. Suy cho cùng, hầu hết các dự án đều phải có giải pháp đồng thời của riêng mình và do tiêu chuẩn có rất ít bên cạnh các cấu trúc cấp thấp, điều đó có nghĩa là rất nhiều sự khác biệt sẽ cần phải được đưa trở lại vào mô hình thực thi. Tôi hy vọng hầu hết các dự án đều có bộ lập lịch tùy chỉnh, nhóm luồng và những thứ tương tự. Hoặc sử dụng thứ gì đó như TBB để nhận một cái.

Có lẽ cơ sở mã của bạn đã sử dụng boost::asio trong trường hợp đó bạn đã hỗ trợ coroutines. Nếu không, bạn sẽ phải đợi C++26 và chuyển đổi/tích hợp với thực thi hoặc triển khai lời hứa của riêng bạn và các sản phẩm có thể chờ để phù hợp với mô hình phân luồng của bạn.

Hoặc bạn có thể sử dụng bản hack Unity.

Trình chạy coroutines giống Unity trong C++

Tôi mất chưa đầy một giờ để triển khai một Trình thực thi coroutine theo phong cách Unity đơn giản trong chuỗi chính của trò chơi đồ chơi của tôi. Đây là toàn bộ nội dung:

lớp effect_manager
{
công khai:
    void thêm( std::máy phát điện<std::monostate> hiệu ứng )
    {
        _effect.push_back ( std::di chuyển( hiệu ứng ) );
        _iterators.push_back( _effect.quay lại().bắt đầu() );
    
    void chạy()
    {
         // Xóa những cái đã xong
        // (được tinh chỉnh https://en.cppreference.com/w/cpp/algorithm/remove.html#Version_3)
        int đầu tiên = 0;
        for ( ; đầu tiên != _effect.kích thước()
                 && _iterators [ first ] != _effect[ first ].end(); ++đầu tiên );
        if ( đầu tiên != _effect.kích thước() )
        {
            cho (  int i = đầu tiên; ++i != _effect.kích thước(); )
            {
                if ( _iterators[ i ] != _effect[ i ].end () )
                {
                    _effect[ first ] = std::di chuyển( _effect[ i ] );
                    _iterators[ đầu tiên ] = std:: di chuyển( _iterators[ i ] );
                    đầu tiên;
                
            
            _effect.xóa( bắt đầu( _effect ) + đầu tiên, kết thúc ( _effect ) );
            _iterators.xóa( bắt đầu( _iterators ) + đầu tiên, end( _iterators ) );
        
        // Chạy các hiệu ứng
        cho  ( int i = 0; i < _effect.kích thước(); ++i )
        {
            ++_iterators[ i ];
        
    
riêng tư:
    std:: vector<std::máy phát điện<std::monostate>> _effect;
    sử dụng effect_iterator = decltype( std::declval<std::máy phát điện <std::monostate>>().bắt đầu() );
    std::vector<effect_iterator> _iterators;
};

Vậy đó. Phần khó duy nhất là vòng lặp loại bỏ các coroutine đã kết thúc quá trình thực thi bằng cách viết tay một biến thể std::remove_if hoạt động với 2 mảng được nén. Nếu bạn đã có tiện ích cho nó, toàn bộ nội dung sẽ mất ít hơn 20 dòng.

Bây giờ, bạn có thể kích hoạt hiệu ứng bằng cách viết một cái gì đó như effect.add(TimeWarp(object)) và chúng ta chỉ cần nhớ gọi effect.run() trong vòng lặp chính của mình.

Thực hiện theo cách “thích hợp” sẽ yêu cầu viết một trình chờ khung tiếp theo tùy chỉnh để chèn tay cầm coroutine của chúng ta vào hàng đợi khung tiếp theo. Mặc dù điều đó có thể thực hiện được nhưng điều này đòi hỏi sự hiểu biết sâu sắc hơn về nội bộ của coroutine để triển khai. Và thành thật mà nói, tôi khá thích cách tiếp cận lợi nhuận có nghĩa là “kiểm soát lợi nhuận cho đến khung tiếp theo”.

Lợi ích bổ sung

Khi viết bài này, tôi cũng nhận ra rằng sẽ không mất nhiều thời gian để biến việc triển khai hiện tại của chúng tôi thành một trình tạo phù hợp thay vì dựa vào các tác dụng phụ gọi coroutine của chúng tôi. Thay vì monostate, chúng ta có thể trả về một đối tượng có thể kết xuất.

std::generator<Draw> TimeWarp(const Model& model)
{
    // Đó chỉ là một bước nhảy sang trái
    vec3 vị trí{  -1.f, 0.f, 0.f };
    co_yield Vẽ{ .mô hình = mô hình, .chuyển đổi{ .vị trí = vị trí  };
     // Sau đó một bước sang phải
    for (int i = 0; i < 4; ++i)
    {
        vị trí.x += 0,2f;
        co_yield Hòa{ .mô hình = mô hình, .chuyển đổi{ .vị trí = vị trí  };
    
    // Đặt tay lên hông
    // ...
    // Hãy thực hiện lại việc dịch chuyển thời gian!
    for (int i  = 0; i < 4; ++i)
    {
        obj.chuyển đổi.Xoay(0.f, 90.f * i, 0.f);
         co_yield Vẽ{ .mô hình = mô hình,
                       .chuyển đổi{ .vị trí = vị trí,
                                   .xoay = Xoay(0.f , 90.f * i, 0.f)  };
    

Bây giờ chúng ta thay đổi phương thức run() để điền vào một vectơ vẽ:

std::vector<Draw> run()
 {
    // Xóa những cái đã làm xong ()
    // ...
    // Chạy các hiệu ứng
    std::vector<Vẽ> vẽ hòa;
    hòa.dự trữ( _effect.kích thước() );
    cho ( int i = 0; i < _effect.kích thước(); ++i )
    {
        hòa.push_back( *_iterators[ i ] );
         _iterators[ i ];
    
    trở lại hòa;

Và trong khi làm điều đó, chúng tôi thậm chí có thể làm cho vòng lặp của mình chạy song song ngay bây giờ vì chúng tôi đã loại bỏ các tác dụng phụ:

// Chạy các hiệu ứng
std::vectơ<Vẽ>  hòa( _effect.kích thước() );
tbb::parallel_for( 0zu, _effect.kích thước(), [điều này, &hòa]( size_t i ) {
                       hòa[ i ] = *_iterators[ i ];
                       ++_iterators[ i ];
                    );
trở lại hòa;

Đó. Một hệ thống hiệu ứng đơn giản và tương đối hiệu quả cho trò chơi của chúng tôi, cho phép các nhà thiết kế triển khai tất cả những thứ thú vị được đặt riêng như các coroutine dễ đọc và toàn bộ hệ thống này khiến chúng tôi mất chưa đến một trăm dòng để viết.

Bây giờ, chẳng phải bạn sẽ nói rằng điều này trông thú vị hơn nhiều so với việc tôi cho bạn xem một trình tạo Fibonacci khác phải không?

Tác giả: ingve

#discussion