Di C ++ 20, kesempatan untuk bekerja dengan coroutine di luar kotak akan segera muncul. Topik ini dekat dan menarik bagi kami di Yandex.Taxi (untuk kebutuhan kami sendiri, kami sedang mengembangkan kerangka kerja asinkron). Oleh karena itu, hari ini kami akan menunjukkan kepada pembaca Habr menggunakan contoh nyata bagaimana bekerja dengan C ++ stackless coroutine.
Sebagai contoh, mari kita ambil sesuatu yang sederhana: tanpa bekerja dengan antarmuka jaringan asinkron, timer asinkron, yang terdiri dari satu fungsi. Misalnya, mari kita coba sadari dan tulis ulang "mie" ini dari callback:

void FuncToDealWith() { InCurrentThread(); writerQueue.PushTask([=]() { InWriterThread1(); const auto finally = [=]() { InWriterThread2(); ShutdownAll(); }; if (NeedNetwork()) { networkQueue.PushTask([=](){ auto v = InNetworkThread(); if (v) { UIQueue.PushTask([=](){ InUIThread(); writerQueue.PushTask(finally); }); } else { writerQueue.PushTask(finally); } }); } else { finally(); } }); }
Pendahuluan
Coroutine atau coroutine adalah kemampuan untuk menghentikan fungsi dari mengeksekusi di lokasi yang telah ditentukan; melewati suatu tempat seluruh negara dari fungsi berhenti bersama dengan variabel lokal; jalankan fungsinya dari tempat yang sama tempat kami menghentikannya.
Ada beberapa rasa coroutine: stackless dan stackful. Kami akan membicarakan ini nanti.
Pernyataan masalah
Kami memiliki beberapa antrian tugas. Setiap tugas berisi tugas-tugas tertentu: ada antrian untuk menggambar grafik, ada antrian untuk interaksi jaringan, dan ada antrian untuk bekerja dengan disk. Semua antrian adalah instance dari kelas WorkQueue yang memiliki metode PushTask void (std :: function <void ()> task); Antrian hidup lebih lama dari semua tugas yang ditempatkan di dalamnya (situasi dimana kami menghancurkan antrian ketika ada tugas-tugas luar biasa di dalamnya tidak boleh terjadi).
Fungsi FuncToDealWith () dari contoh mengeksekusi beberapa logika dalam antrian yang berbeda dan, tergantung pada hasil eksekusi, menempatkan tugas baru dalam antrian.
Kami menulis ulang "mie" dari callback dalam bentuk kode pseudo linier, menandai di mana antrian kode yang mendasarinya harus dijalankan:
void CoroToDealWith() { InCurrentThread();
Kira-kira hasil yang ingin saya capai ini.
Ada batasan:
- Antarmuka antrian tidak dapat diubah - mereka digunakan di bagian lain aplikasi oleh pengembang pihak ketiga. Anda tidak dapat memecahkan kode pengembang atau menambahkan instance antrian baru.
- Anda tidak dapat mengubah cara Anda menggunakan fungsi FuncToDealWith. Anda hanya dapat mengubah namanya, tetapi Anda tidak dapat membuatnya mengembalikan objek yang harus disimpan pengguna di rumah.
- Kode yang dihasilkan harus sama produktifnya dengan yang asli (atau bahkan lebih produktif).
Solusi
Menulis Ulang FuncToDealDengan Fungsi
Dalam Coroutines TS, penyetelan coroutine dilakukan dengan mengatur jenis nilai pengembalian fungsi. Jika jenisnya memenuhi persyaratan tertentu, maka di dalam badan fungsi Anda dapat menggunakan kata kunci baru co_await / co_return / co_yield. Dalam contoh ini, untuk beralih antar antrian, kami akan menggunakan co_yield:
CoroTask CoroToDealWith() { InCurrentThread(); co_yield writerQueue; InWriterThread1(); if (NeedNetwork()) { co_yield networkQueue; auto v = InNetworkThread(); if (v) { co_yield UIQueue; InUIThread(); } } co_yield writerQueue; InWriterThread2(); ShutdownAll(); }
Ternyata sangat mirip dengan pseudocode dari bagian terakhir. Semua "sihir" untuk bekerja dengan coroutine disembunyikan di kelas CoroTask.
CoroTask
Dalam kasus yang paling sederhana (dalam kasus kami), isi kelas "tuner" dari coroutine hanya terdiri dari satu alias:
#include <experimental/coroutine> struct CoroTask { using promise_type = PromiseType; };
tipe janji adalah tipe data yang harus kita tulis sendiri. Ini berisi logika yang menjelaskan:
- apa yang harus dilakukan ketika keluar dari coroutine
- apa yang harus dilakukan ketika Anda pertama kali memasukkan corutin
- siapa yang membebaskan sumber daya
- apa yang harus dilakukan dengan pengecualian yang keluar dari coroutine
- Cara membuat objek CoroTask
- apa yang harus dilakukan jika di dalam corutins disebut co_yield
Alias ββjanji_type harus disebut seperti itu. Jika Anda mengubah nama alias menjadi sesuatu yang lain, kompiler akan bersumpah dan mengatakan bahwa Anda salah mengeja CoroTask. Nama CoroTask dapat diubah sesuka Anda.
Tetapi mengapa CoroTask ini diperlukan jika semuanya dijelaskan dalam jenis janji?Dalam kasus yang lebih kompleks, Anda dapat membuat CoroTask yang memungkinkan Anda berkomunikasi dengan coroutine yang terhenti, mengirim dan menerima data darinya, membangunkan dan menghancurkannya.
Jenis Janji
Mendapatkan bagian yang menyenangkan. Kami menggambarkan perilaku corutin:
class WorkQueue;
Pada kode di atas, Anda dapat melihat tipe data std :: eksperimental :: suspend_never. Ini adalah tipe data khusus yang mengatakan bahwa corutin tidak perlu dihentikan. Ada juga kebalikannya - tipe std :: eksperimental :: suspend_always, yang memberitahu Anda untuk menghentikan corutin. Jenis-jenis ini adalah apa yang dinamakan menunggu. Jika Anda tertarik dengan struktur internal mereka, maka jangan khawatir, kami akan segera menulis Awaitables kami.
Tempat paling non-sepele dalam kode di atas adalah final_suspend (). Fungsi ini memiliki efek yang tidak terduga. Jadi, jika kita tidak menghentikan eksekusi dalam fungsi ini, maka sumber daya yang dialokasikan ke coroutine oleh kompiler akan membersihkan kompiler untuk kita. Tetapi jika dalam fungsi ini kita menghentikan eksekusi coroutine (misalnya, dengan mengembalikan std :: experimental :: suspend_always {}), maka Anda harus secara manual membebaskan sumber daya dari suatu tempat di luar: Anda harus menyimpan pointer pintar ke coroutine di suatu tempat dan secara eksplisit menyebutnya hancurkan (). Untungnya, ini tidak perlu untuk contoh kita.
INCORRECT PromiseType :: yield_value
Tampaknya menulis PromiseType :: yield_value cukup sederhana. Kami memiliki garis; coroutine, yang harus ditangguhkan dan pada gilirannya ini menempatkan:
auto PromiseType::yield_value(WorkQueue& wq) {
Dan di sini kita dihadapkan dengan masalah yang sangat besar dan sulit dideteksi. Faktanya adalah pertama-tama kita meletakkan coroutine dalam antrian dan baru kemudian menangguhkannya. Mungkin saja coroutine dihapus dari antrian dan mulai dieksekusi bahkan sebelum kita menangguhkannya di utas saat ini. Ini akan menyebabkan kondisi balapan, perilaku tidak terdefinisi, dan kesalahan runtime yang benar-benar gila.
Jenis Promise yang benar :: yield_value
Jadi, pertama-tama kita harus menghentikan corutin dan baru kemudian menambahkannya ke antrian. Untuk melakukan ini, kami akan menulis Awaitable kami dan menyebutnya schedule_for_execution:
auto PromiseType::yield_value(WorkQueue& wq) { struct schedule_for_execution { WorkQueue& wq; constexpr bool await_ready() const noexcept { return false; } void await_suspend(std::experimental::coroutine_handle<> this_coro) const { wq.PushTask(this_coro); } constexpr void await_resume() const noexcept {} }; return schedule_for_execution{wq}; }
Kelas std :: experimental :: suspend_always, std :: eksperimental :: suspend_never, schedule_for_execution dan Awaitables lainnya harus berisi 3 fungsi. await_ready dipanggil untuk memeriksa apakah coroutine harus dihentikan. await_suspend dipanggil setelah program dihentikan, pegangan coroutine yang terhenti diteruskan ke sana. await_resume dipanggil ketika eksekusi coroutine dilanjutkan.
Dan apa yang dapat ditulis dalam skrabs segitiga std :: experimental :: coroutine_handle <>?Anda dapat menentukan jenis PromiseType di sana, dan contohnya akan bekerja sama persis :)
std :: experimental :: coroutine_handle <> (alias std :: eksperimental :: coroutine_handle <void>) adalah tipe dasar untuk semua std :: experimental :: coroutine_handle <DataType>, di mana DataType harus merupakan jenis janji_tipe dari coroutine saat ini. Jika Anda tidak perlu mengakses konten internal DataType, Anda dapat menulis std :: eksperimental :: coroutine_handle <>. Ini bisa berguna di tempat-tempat di mana Anda ingin mengabstraksi dari tipe tertentu dari tipe janji dan gunakan tipe penghapusan.
Selesai
Anda dapat
mengkompilasi, menjalankan contoh online dan bereksperimen dengan segala cara .
Dan jika saya tidak suka co_yield, dapatkah saya menggantinya dengan sesuatu?Dapat diganti dengan co_await. Untuk melakukan ini, tambahkan fungsi berikut ke PromiseType:
auto await_transform(WorkQueue& wq) { return yield_value(wq); }
Tetapi bagaimana jika saya tidak suka co_await?Masalahnya buruk. Tidak ada yang berubah.
Lembar curang
CoroTask adalah kelas yang menyesuaikan perilaku coroutine. Dalam kasus yang lebih kompleks, ini memungkinkan Anda untuk berkomunikasi dengan coroutine yang dihentikan dan mengambil data apa pun darinya.
CoroTask :: janji_type menjelaskan bagaimana dan kapan coroutine berhenti, bagaimana membebaskan sumber daya, dan bagaimana membangun CoroTask.
Awaitables (std :: eksperimental :: suspend_always, std :: eksperimental :: suspend_never, schedule_for_execution dan lain-lain) memberi tahu kompilator apa yang harus dilakukan dengan coroutine pada titik tertentu (apakah perlu menghentikan corutin, apa yang harus dilakukan dengan menghentikan corutin dan apa yang harus dilakukan ketika corutin bangun) .
Optimalisasi
Ada kekurangan dalam PromiseType kami. Sekalipun saat ini kami sedang menjalankan antrian tugas yang benar, memanggil co_yield masih akan menangguhkan coroutine dan menempatkannya kembali di antrian tugas yang sama. Akan jauh lebih optimal untuk tidak menghentikan eksekusi coroutine, tetapi untuk melanjutkan eksekusi segera.
Mari kita perbaiki cacat ini. Untuk melakukan ini, tambahkan bidang pribadi ke PromiseType:
WorkQueue* current_queue_ = nullptr;
Di dalamnya, kita akan memegang pointer ke antrian di mana kita sedang mengeksekusi.
Selanjutnya, tweak PromiseType :: yield_value:
auto PromiseType::yield_value(WorkQueue& wq) { struct schedule_for_execution { const bool do_resume; WorkQueue& wq; constexpr bool await_ready() const noexcept { return do_resume; } void await_suspend(std::experimental::coroutine_handle<> this_coro) const { wq.PushTask(this_coro); } constexpr void await_resume() const noexcept {} }; const bool do_not_suspend = (current_queue_ == &wq); current_queue_ = &wq; return schedule_for_execution{do_not_suspend, wq}; }
Di sini kami mengutak-atik schedule_for_execution :: await_ready (). Sekarang fungsi ini memberi tahu kompiler bahwa coroutine tidak perlu ditangguhkan jika antrian tugas saat ini cocok dengan yang kita coba mulai.
Selesai Anda dapat
bereksperimen dengan segala cara .
Tentang kinerja
Dalam contoh asli, dengan setiap panggilan ke WorkQueue :: PushTask (std :: function <void ()> f), kami membuat instance dari kelas std :: function <void ()> dari lambda. Dalam kode sebenarnya, lambda ini sering berukuran cukup besar, itulah sebabnya std :: function <void ()> dipaksa mengalokasikan memori secara dinamis untuk menyimpan lambdas.
Pada contoh coroutine, kita membuat instance std :: function <void ()> dari std :: experimental :: coroutine_handle <>. Ukuran std :: eksperimental :: coroutine_handle <> tergantung pada implementasinya, tetapi sebagian besar implementasi mencoba menjaga ukurannya seminimal mungkin. Jadi pada dentang ukurannya sama dengan sizeof (void *). Ketika membangun std :: function <void ()>, alokasi dinamis tidak terjadi dari objek kecil.
Total - dengan Coroutines kami menyingkirkan beberapa alokasi dinamis yang tidak perlu.
Tapi! Kompiler seringkali tidak bisa hanya menyimpan semua coroutine di stack. Karena itu, satu alokasi dinamis tambahan dimungkinkan ketika memasuki CoroToDealWith.
Stackless vs stackful
Kami baru saja bekerja dengan coroutine Stackless, yang membutuhkan dukungan dari kompiler untuk bekerja dengannya. Ada juga Stackful Coroutine yang dapat diimplementasikan sepenuhnya di tingkat perpustakaan.
Yang pertama memungkinkan alokasi memori yang lebih ekonomis, berpotensi lebih baik dioptimalkan oleh kompiler. Yang kedua lebih mudah diimplementasikan dalam proyek-proyek yang ada, karena mereka membutuhkan lebih sedikit modifikasi kode. Namun, dalam contoh ini Anda tidak dapat merasakan perbedaannya, dibutuhkan contoh yang lebih rumit.
Ringkasan
Kami memeriksa contoh dasar dan mendapatkan CoroTask kelas universal, yang dapat digunakan untuk membuat coroutine lainnya.
Kode dengannya menjadi lebih mudah dibaca dan sedikit lebih produktif daripada dengan pendekatan naif:
Apakah | Dengan coroutine |
---|
void FuncToDealWith() { InCurrentThread(); writerQueue.PushTask([=]() { InWriterThread1(); const auto fin = [=]() { InWriterThread2(); ShutdownAll(); }; if (NeedNetwork()) { networkQueue.PushTask([=](){ auto v = InNetThread(); if (v) { UIQueue.PushTask([=](){ InUIThread(); writerQueue.PushTask(fin); }); } else { writerQueue.PushTask(fin); } }); } else { fin(); } }); } | CoroTask CoroToDealWith() { InCurrentThread(); co_yield writerQueue; InWriterThread1(); if (NeedNetwork()) { co_yield networkQueue; auto v = InNetThread(); if (v) { co_yield UIQueue; InUIThread(); } } co_yield writerQueue; InWriterThread2(); ShutdownAll(); } |
Di atas kapal ada saat-saat:
- cara memanggil coroutine lain dari corutin dan menunggu selesainya
- hal-hal berguna apa yang dapat Anda masukkan di CoroTask
- contoh yang membuat perbedaan antara Stackless dan Stackful
Lainnya
Jika Anda ingin mempelajari tentang hal baru lain dari bahasa C ++ atau berkomunikasi secara pribadi dengan kolega Anda di plus, maka lihat di konferensi C ++ Rusia. Yang berikutnya akan diadakan
pada 6 Oktober di Nizhny Novgorod .
Jika Anda memiliki rasa sakit yang terkait dengan C ++ dan ingin meningkatkan sesuatu dalam bahasa tersebut atau hanya ingin mendiskusikan kemungkinan inovasi, maka selamat datang di
https://stdcpp.ru/ .
Nah, jika Anda terkejut bahwa Yandex.Taxi memiliki sejumlah besar tugas yang tidak terkait dengan grafik, maka saya harap ini ternyata menjadi kejutan yang menyenangkan bagi Anda :)
Kunjungi kami pada 11 Oktober , kami akan berbicara tentang C ++ dan banyak lagi.