Lebih lanjut tentang Coroutine di C ++

Halo kolega.

Sebagai bagian dari pengembangan tema C ++ 20, kami pernah menemukan artikel yang agak lama (September 2018) dari hublog Yandex, yang disebut β€œ Bersiap untuk C ++ 20. Coroutines TS dengan Contoh Nyata ”. Itu berakhir dengan pemungutan suara yang sangat ekspresif berikut:



"Mengapa tidak," kami memutuskan dan menerjemahkan sebuah artikel oleh David Pilarski dengan judul "Pengantar Coroutines". Artikel ini diterbitkan sedikit lebih dari setahun yang lalu, tetapi mudah-mudahan Anda akan menemukannya sangat menarik.

Begitulah yang terjadi. Setelah banyak keraguan, kontroversi, dan persiapan fitur ini, WG21 memperoleh pendapat umum tentang seperti apa coroutine di C ++ - dan sangat mungkin mereka akan dimasukkan ke dalam C ++ 20. Karena ini adalah fitur utama, saya pikir ini saatnya untuk menyiapkan dan mempelajarinya. sekarang (seperti yang Anda ingat, masih ada lebih banyak modul, konsep, rentang untuk dipelajari ...)

Banyak yang masih menentang coroutine. Seringkali mereka mengeluh tentang kompleksitas perkembangan mereka, banyak poin penyesuaian dan, mungkin, kinerja yang kurang optimal karena, mungkin, alokasi memori dinamis yang kurang optimal (mungkin;)).

Sejalan dengan pengembangan spesifikasi teknis (TS) yang disetujui (dipublikasikan), bahkan upaya telah dilakukan untuk pengembangan paralel mekanisme lain dari corutin. Di sini kita akan berbicara tentang coroutine yang dijelaskan dalam TS ( spesifikasi teknis ). Pendekatan alternatif, pada gilirannya, milik Google. Akibatnya, ternyata pendekatan Google mengalami banyak masalah, solusinya sering membutuhkan fitur tambahan aneh dari C ++.

Pada akhirnya, diputuskan untuk mengadopsi versi Corutin yang dikembangkan oleh Microsoft (disponsori oleh TS). Ini tentang coroutine yang akan dibahas dalam artikel ini. Jadi, mari kita mulai dengan pertanyaan ...

Apa itu coroutine?


Coroutine sudah ada dalam banyak bahasa pemrograman, misalnya, dalam Python atau C #. Coroutine adalah cara lain untuk membuat kode asinkron. Bagaimana mereka berbeda dari aliran, mengapa coroutine harus diimplementasikan sebagai fitur bahasa khusus dan, akhirnya, apa penggunaannya akan dijelaskan di bagian ini.

Ada kesalahpahaman serius tentang apa coroutine itu. Tergantung pada lingkungan di mana mereka digunakan, mereka dapat dipanggil:

  • Stoutless Coroutines
  • Tumpukan coroutine
  • Aliran hijau
  • Serat
  • Gorutin

Berita baiknya: tumpukan corutin, aliran hijau, serat, dan gorutin adalah satu hal yang sama (tetapi kadang-kadang digunakan dengan cara yang berbeda). Kita akan membicarakannya nanti di artikel ini dan kita akan menyebutnya serat atau tumpukan coroutine. Tetapi coroutine stackless memiliki beberapa fitur yang perlu dibahas secara terpisah.

Untuk memahami coroutine, termasuk pada level intuitif, mari kita secara singkat mengenal fungsinya dan (mari kita begini) "API mereka". Cara standar untuk bekerja dengan mereka adalah menelepon dan menunggu sampai selesai:

void foo(){
     return; //     
}	
foo(); //   / 

Setelah memanggil fungsi, sudah tidak mungkin untuk menjeda, atau melanjutkan pekerjaannya. Anda hanya dapat melakukan dua operasi pada fungsi: startdan finish. Ketika fungsi diluncurkan, Anda harus menunggu sampai selesai. Jika fungsi dipanggil lagi, eksekusinya akan berjalan dari awal.

Dengan coroutine, situasinya berbeda. Anda tidak hanya dapat memulai dan menghentikannya, tetapi juga menjeda dan melanjutkannya. Mereka masih berbeda dari aliran inti, karena coroutine itu sendiri tidak berkerumun (di sisi lain, coroutine biasanya merujuk pada aliran, dan aliran berkerumun keluar). Untuk memahami hal ini, pertimbangkan generator yang didefinisikan dalam Python. Biarkan hal seperti itu disebut generator dengan Python, dalam C ++ itu akan disebut coroutine. Contoh diambil dari situs ini :

def generate_nums():
     num = 0
     while True:
          yield num
          num = num + 1	

nums = generate_nums()
	
for x in nums:
     print(x)
	
     if x > 9:
          break

Inilah cara kerja kode ini: panggilan fungsi generate_numsmengarah ke pembuatan objek coroutine. Pada setiap langkah penghitungan objek coroutine, coroutine itu sendiri kembali berfungsi dan berhenti hanya setelah kata kunci yielddalam kode; kemudian bilangan bulat berikutnya dari urutan dikembalikan (loop for adalah sintaksis gula untuk memanggil fungsi next()yang melanjutkan coroutine). Kode mengakhiri loop dengan menemukan pernyataan break. Dalam hal ini, corutin tidak pernah berakhir, tetapi mudah untuk membayangkan situasi di mana corutin mencapai ujung dan ujung. Seperti yang kita lihat, untuk operasi yang berlaku korutine start, suspend, resumedan akhirnya,finish. [Catatan: C ++ juga menyediakan operasi penciptaan dan penghancuran, tetapi mereka tidak penting dalam konteks pemahaman intuitif coroutine].

Coroutines sebagai perpustakaan


Jadi, sekarang sudah hampir jelas apa coroutine itu. Anda mungkin tahu bahwa ada perpustakaan untuk membuat objek serat. Pertanyaannya adalah, mengapa kita perlu coroutine dalam bentuk fitur bahasa khusus, dan bukan hanya perpustakaan yang akan bekerja dengan coroutine.

Di sini kami mencoba untuk menjawab pertanyaan ini dan menunjukkan perbedaan antara coroutine stacked dan stackless. Perbedaan ini adalah kunci untuk memahami corutin sebagai bagian dari bahasa.

Tumpukan coroutine


Jadi, mari kita bahas dulu apa itu stack coroutine, bagaimana cara kerjanya, dan mengapa mereka bisa diimplementasikan sebagai perpustakaan. Menjelaskannya relatif sederhana, karena mereka menyerupai aliran dalam hal desain.

Fibre atau stack corutin memiliki stack terpisah yang dapat digunakan untuk menangani panggilan fungsi. Untuk memahami dengan tepat bagaimana coroutine dari jenis ini bekerja, kita secara singkat melihat frame fungsi dan panggilan fungsi dari sudut pandang tingkat rendah. Tapi pertama-tama, mari kita bicara tentang sifat serat.

  • Mereka memiliki tumpukan mereka sendiri,
  • Masa pakai serat tidak tergantung pada kode yang memanggilnya (biasanya mereka memiliki penjadwal yang ditentukan pengguna),
  • Serat dapat terlepas dari satu utas dan melekat pada lainnya,
  • Perencanaan koperasi (serat harus memutuskan untuk beralih ke serat / penjadwal lain),
  • Tidak dapat bekerja secara bersamaan di utas yang sama.

Efek berikut ini dihasilkan dari sifat-sifat di atas:

  • mengalihkan konteks serat harus dilakukan oleh pengguna serat, dan bukan OS (selain itu, OS dapat melepaskan serat, melepaskan benang tempat kerjanya),
  • Tidak ada ras data nyata antara kedua serat, karena pada waktu tertentu hanya satu dari mereka yang bisa aktif,
  • Perancang serat harus dapat memilih tempat dan waktu yang tepat, di mana dan kapan waktu yang tepat untuk mengembalikan daya komputasi ke penjadwal atau pemanggil yang mungkin.
  • Operasi input / output dalam serat harus asinkron, sehingga serat lain dapat melakukan tugasnya tanpa saling menghalangi.

Sekarang mari kita melihat lebih dekat pada operasi serat dan pertama-tama menjelaskan bagaimana tumpukan berpartisipasi dalam pemanggilan fungsi.

Jadi, stack adalah blok memori berkelanjutan yang diperlukan untuk menyimpan variabel lokal dan argumen fungsi. Tetapi, yang lebih penting, setelah setiap pemanggilan fungsi (dengan beberapa pengecualian), informasi tambahan didorong ke tumpukan yang memberi tahu fungsi yang dipanggil cara untuk kembali ke pemanggil dan mengembalikan register prosesor.

Beberapa register ini memiliki tugas khusus, dan ketika memanggil fungsi, mereka disimpan di stack. Ini adalah register (dalam kasus arsitektur ARM):

SP - stack pointer
LR -
PC register komunikasi - program counter

stack pointer(SP) adalah register yang berisi alamat awal tumpukan yang terkait dengan panggilan fungsi saat ini. Berkat nilai yang ada, Anda dapat dengan mudah merujuk argumen dan variabel lokal yang disimpan di tumpukan.

Register komunikasi (LR) sangat penting saat memanggil fungsi. Ini menyimpan alamat pengirim (alamat pihak yang menelepon), di mana kode akan dieksekusi setelah eksekusi fungsi saat ini selesai. Ketika fungsi dipanggil, PC disimpan dalam LR. Ketika fungsi kembali, PC dikembalikan menggunakan LR.

Penghitung Program (PC) adalah alamat dari instruksi yang sedang dijalankan.
Setiap kali suatu fungsi dipanggil, daftar tautan disimpan, sehingga fungsi tersebut tahu di mana program harus kembali setelah selesai.



Perilaku PC dan LR mendaftar saat memanggil dan mengembalikan fungsi

Ketika menjalankan stack coroutine, fungsi yang dipanggil menggunakan stack yang sebelumnya dialokasikan untuk menyimpan argumen dan variabel lokalnya. Karena semua informasi pada setiap fungsi yang dipanggil pada stack corutin disimpan di stack, fiber dapat menangguhkan fungsi apa pun di dalam corutin itu.



Mari kita lihat apa yang terjadi pada gambar ini. Pertama, setiap serat dan ulir memiliki tumpukan terpisah. Warna hijau menunjukkan nomor seri yang menunjukkan urutan tindakan.

  1. Panggilan fungsi reguler di dalam utas. Memori dialokasikan pada tumpukan.
  2. . . , . . , .
  3. .
  4. . .
  5. .
  6. .
  7. . , , , .
  8. .
  9. .
  10. – , .
  11. , .
  12. . .
  13. . : , . , ( ) .
  14. , .
  15. .
  16. . . . , .
  17. .
  18. , , .

Saat bekerja dengan stack coroutine, tidak perlu fitur bahasa khusus yang akan memastikan penggunaannya. Seluruh tumpukan korutiny diimplementasikan dengan baik menggunakan perpustakaan, dan perpustakaan sudah ada yang dirancang khusus untuk tujuan ini:

swtch.com/libtask
code.google.com/archive/p/libconcurrency
www.boost.org Boost.Fiber
www.boost.org Boost .Coroutine

Dari semua perpustakaan ini, hanya Peningkatan adalah C ++, dan sisanya adalah C.
Untuk penjelasan rinci tentang cara kerja perpustakaan ini, lihat dokumentasi. Tetapi, secara umum, semua pustaka ini memungkinkan Anda untuk membuat tumpukan terpisah untuk serat dan memberikan kesempatan untuk melanjutkan coroutine (atas inisiatif si penelepon) dan menjeda (dari dalam).

Pertimbangkan sebuah contoh Boost.Fiber:

#include <cstdlib>
#include <iostream>
#include <memory>
#include <string>
#include <thread>
	
#include <boost/intrusive_ptr.hpp>
	
#include <boost/fiber/all.hpp>
	
inline
void fn( std::string const& str, int n) {
     for ( int i = 0; i < n; ++i) {
          std::cout << i << ": " << str << std::endl;
               boost::this_fiber::yield();
     }
}
	
int main() {
     try {
          boost::fibers::fiber f1( fn, "abc", 5);
          std::cerr << "f1 : " << f1.get_id() << std::endl;
          f1.join();
          std::cout << "done." << std::endl;
	
          return EXIT_SUCCESS;
     } catch ( std::exception const& e) {
          std::cerr << "exception: " << e.what() << std::endl;
     } catch (...) {
          std::cerr << "unhandled exception" << std::endl;
     }
     return EXIT_FAILURE;
}

Dalam kasus Boost.Fiber , perpustakaan memiliki penjadwal bawaan untuk coroutine. Semua serat bekerja di utas yang sama. Karena perencanaan corutin kooperatif, serat pertama-tama harus memutuskan kapan harus mengembalikan kontrol ke penjadwal. Dalam contoh ini, ini terjadi ketika fungsi hasil dipanggil, yang menjeda coroutine.

Karena tidak ada serat lain, perencana serat selalu memutuskan untuk melanjutkan coroutine.

Stoutless Coroutines


Coroutine stackless sedikit berbeda dalam properti dari stack. Namun, mereka memiliki karakteristik dasar yang sama, karena coroutine non-stack juga dapat dimulai, dan setelah suspensi mereka dapat dilanjutkan. Coroutine dari jenis ini kemungkinan besar akan kita temukan di C ++ 20.

Jika kita berbicara tentang sifat-sifat serupa dari corutin - coroutine dapat:

  • Corutin berhubungan erat dengan peneleponnya: ketika coroutine dipanggil, eksekusi ditransfer kepadanya, dan hasil coroutine ditransfer kembali ke penelepon.
  • Masa hidup stack corutin sama dengan umur stacknya. Umur coroutine tanpa tumpukan sama dengan umur objeknya.

Namun, dalam kasus coroutine tanpa tumpukan, tidak perlu mengalokasikan seluruh tumpukan. Mereka mengkonsumsi memori jauh lebih sedikit daripada yang stack, tetapi ini justru karena beberapa keterbatasan mereka.

Untuk memulainya, jika mereka tidak mengalokasikan memori untuk stack, lalu bagaimana cara kerjanya? Di mana dalam kasus mereka semua data yang disimpan harus disimpan di stack ketika bekerja dengan stack coroutine. Jawab: di atas tumpukan pemanggil.

Rahasia untuk coroutine tanpa tumpukan adalah bahwa mereka hanya dapat menangguhkan diri mereka sendiri dari fungsi paling atas. Untuk semua fungsi lainnya, datanya terletak pada tumpukan sisi yang dipanggil, jadi semua fungsi yang dipanggil dari corutin harus diselesaikan sebelum pekerjaan corutin ditunda. Semua data yang dibutuhkan oleh coroutine untuk mempertahankan statusnya dialokasikan secara dinamis di heap. Ini biasanya memerlukan beberapa variabel dan argumen lokal, yang jauh lebih kompak daripada seluruh tumpukan yang harus dialokasikan sebelumnya.

Lihatlah bagaimana cara kerja stackless corutins:



Menantang stackless corutin

Seperti yang Anda lihat, sekarang hanya ada satu tumpukan - ini adalah tumpukan utama utas. Mari kita selangkah demi selangkah melihat apa yang diperlihatkan dalam gambar ini (kerangka aktivasi coroutine di sini adalah dua warna - hitam menunjukkan apa yang disimpan di stack, dan biru - apa yang disimpan di heap).

  1. Panggilan fungsi reguler yang bingkainya disimpan di tumpukan
  2. Fungsi ini menciptakan coroutine . Yaitu, itu mengalokasikan bingkai aktivasi untuk suatu tempat di heap.
  3. Panggilan fungsi normal.
  4. Panggil Corutin . Tubuh Corutin menonjol dalam tumpukan reguler. Program dijalankan dengan cara yang sama seperti dalam kasus fungsi biasa.
  5. Panggilan fungsi reguler dari coroutine. Sekali lagi, semuanya masih terjadi pada stack [Catatan: Anda tidak dapat menjeda coroutine dari titik ini, karena ini bukan fungsi teratas di coroutine]
  6. [: .]
  7. – , , .
  8. – , + .
  9. 5.
  10. 6.
  11. . .

Jadi, jelas bahwa dalam kasus kedua perlu mengingat data jauh lebih sedikit untuk semua operasi menangguhkan dan melanjutkan pekerjaan corutin, namun, coroutine dapat melanjutkan dan hanya menangguhkan sendiri, dan hanya dari fungsi paling atas. Semua panggilan fungsi dan coroutine terjadi dengan cara yang sama, namun beberapa data tambahan harus disimpan di antara panggilan, dan fungsi tersebut harus dapat melompat ke titik suspensi dan mengembalikan keadaan variabel lokal. Tidak ada perbedaan lain antara bingkai coroutine dan bingkai fungsi.

Corutin juga dapat menyebabkan coroutine lain (tidak diperlihatkan dalam contoh ini). Dalam kasus coroutine stackless, setiap panggilan menghasilkan alokasi ruang baru untuk data corutin baru (dengan panggilan coroutine berulang, memori dinamis juga dapat dialokasikan beberapa kali).

Alasan mengapa coroutine perlu menyediakan fitur bahasa khusus adalah karena kompilator perlu memutuskan variabel mana yang menggambarkan keadaan coroutine dan membuat kode stereotip untuk melompat ke titik suspensi.

Penggunaan praktis dari corutin


Coroutine dalam C ++ dapat digunakan dengan cara yang sama seperti dalam bahasa lain. Coroutine akan menyederhanakan ejaan:

  • generator
  • kode input / output asinkron
  • komputasi malas
  • aplikasi berbasis acara

Ringkasan


Saya harap dengan membaca artikel ini Anda akan menemukan:

  • mengapa di C ++ Anda perlu mengimplementasikan coroutine sebagai fitur bahasa khusus
  • Apa perbedaan antara coroutine stacked dan stackless?
  • mengapa coroutine dibutuhkan

All Articles