Pada awal November, Minsk menjadi tuan rumah konferensi C ++ berikutnya C ++ CoreHard Autumn 2018 konferensi. Ini menyampaikan laporan
kapten "Aktor vs CSP vs Tugas ..." , yang berbicara tentang bagaimana aplikasi tingkat yang lebih tinggi daripada "dapat melihat dalam C ++" bare multithreading ”, model pemrograman yang kompetitif. Di bawah versi cut dari laporan ini, diubah menjadi sebuah artikel. Disisir, dipangkas di beberapa tempat, ditambah di beberapa tempat.
Saya ingin menggunakan kesempatan ini untuk mengucapkan terima kasih kepada komunitas
CoreHard yang telah menyelenggarakan konferensi besar berikutnya di Minsk dan atas kesempatan untuk berbicara. Dan juga untuk publikasi cepat
laporan video dari laporan di YouTube .
Jadi, mari kita beralih ke topik pembicaraan utama. Yaitu, pendekatan apa yang dapat kita gunakan untuk menyederhanakan pemrograman multi-threaded di C ++, bagaimana beberapa pendekatan ini akan terlihat dalam kode, fitur apa yang melekat pada pendekatan tertentu, apa yang umum di antara mereka, dll.
Catatan: kesalahan dan kesalahan ketik ditemukan dalam presentasi asli laporan, sehingga artikel akan menggunakan slide dari versi yang diperbarui dan diedit, yang dapat ditemukan di
Google Slides atau di
SlideShare .
Multithreading telanjang adalah kejahatan!
Anda harus mulai dengan banality berulang, yang, bagaimanapun, masih relevan:
Pemrograman C ++ multithreaded melalui thread telanjang, mutex dan variabel kondisi adalah keringat , rasa sakit dan darah .
Sebuah contoh yang baik baru-baru ini dijelaskan di sini dalam artikel ini di sini di Habré: "
Arsitektur server-meta dari penembak online seluler Tacticool ". Di dalamnya, orang-orang berbicara tentang bagaimana mereka berhasil mengumpulkan, tampaknya, berbagai macam penggaruk terkait dengan pengembangan kode multi-threaded di C dan C ++. Ada "memory pass" sebagai hasil dari balapan, dan kinerja rendah karena paralelisasi yang gagal.
Akibatnya, semuanya berakhir secara alami:
Setelah beberapa minggu menghabiskan waktu menemukan dan memperbaiki bug yang paling kritis, kami memutuskan bahwa lebih mudah untuk menulis ulang semuanya dari awal daripada mencoba untuk memperbaiki semua kekurangan dari solusi saat ini.
Orang-orang memakan C / C ++ ketika bekerja pada versi pertama dari server mereka dan menulis ulang server dalam bahasa lain.
Peragaan yang sangat baik tentang bagaimana, di dunia nyata, di luar komunitas C ++ kami yang nyaman, pengembang menolak untuk menggunakan C ++ bahkan di mana penggunaan C ++ masih sesuai dan dibenarkan.
Tapi mengapa?
Tetapi mengapa, jika berulang kali dikatakan bahwa "bare multithreading" di C ++ itu jahat, orang terus menggunakannya dengan ketekunan yang layak untuk aplikasi yang lebih baik? Apa yang harus disalahkan:
- ketidaktahuan?
- kemalasan?
- Sindrom NIH?
Bagaimanapun, ada jauh dari satu pendekatan yang diuji oleh waktu dan banyak proyek. Khususnya:
- aktor
- mengkomunikasikan proses sekuensial (CSP)
- tugas (async, janji, masa depan, ...)
- aliran data
- pemrograman reaktif
- ...
Diharapkan alasan utama masih ketidaktahuan. Sepertinya ini tidak diajarkan di universitas. Jadi profesional muda, yang memasuki profesi, menggunakan sedikit yang sudah mereka ketahui. Dan jika kemudian penyimpanan pengetahuan kemudian tidak diisi ulang, maka orang-orang terus menggunakan benang telanjang, mutex, dan condition_variables.
Hari ini kita akan berbicara tentang tiga pendekatan pertama dari daftar ini. Dan kita akan berbicara tidak secara abstrak, tetapi pada contoh satu tugas sederhana. Mari kita coba tunjukkan bagaimana kode yang memecahkan masalah ini akan terlihat seperti menggunakan Aktor, proses dan saluran CSP, serta menggunakan Tugas.
Tantangan untuk eksperimen
Diperlukan untuk mengimplementasikan server HTTP yang:
- menerima permintaan (ID gambar, ID pengguna);
- memberikan gambar dengan "tanda air" yang unik untuk pengguna ini.
Misalnya, server semacam itu mungkin diperlukan oleh beberapa layanan berbayar yang mendistribusikan konten dengan berlangganan. Jika gambar dari layanan ini kemudian "muncul" di suatu tempat, maka dengan "tanda air" di atasnya akan mungkin untuk memahami siapa yang perlu "memblokir oksigen".
Tugas ini abstrak, dirumuskan secara khusus untuk laporan ini di bawah pengaruh proyek demo kami Udang (kami sudah membicarakannya:
No. 1 ,
No. 2 ,
No. 3 ).
Server HTTP kami ini akan berfungsi sebagai berikut:
Setelah menerima permintaan dari klien, kami beralih ke dua layanan eksternal:
- yang pertama mengembalikan informasi pengguna kepada kami. Termasuk dari sana kita mendapatkan gambar dengan "tanda air";
- yang kedua mengembalikan kita gambar aslinya
Kedua layanan ini bekerja secara independen dan kami dapat mengakses keduanya secara bersamaan.
Karena pemrosesan permintaan dapat dilakukan secara independen satu sama lain, dan bahkan beberapa tindakan saat memproses permintaan tunggal dapat dilakukan secara paralel, penggunaan daya saing menunjukkan sendiri. Hal paling sederhana yang terlintas dalam pikiran adalah membuat utas terpisah untuk setiap permintaan yang masuk:
Tetapi model one-request = one-workflow terlalu mahal dan tidak bisa diukur dengan baik. Kami tidak membutuhkan ini.
Bahkan jika kita mendekati jumlah alur kerja dengan sia-sia, kita masih membutuhkan sejumlah kecil dari mereka:
Di sini kita membutuhkan aliran terpisah untuk menerima permintaan HTTP masuk, aliran terpisah untuk permintaan HTTP keluar kita sendiri, aliran terpisah untuk mengoordinasikan pemrosesan permintaan HTTP yang diterima. Serta kumpulan alur kerja untuk melakukan operasi pada gambar (karena manipulasi pada gambar paralel dengan baik, memproses gambar dengan beberapa aliran sekaligus akan mengurangi waktu pemrosesan).
Oleh karena itu, tujuan kami adalah menangani sejumlah besar permintaan masuk bersamaan pada sejumlah kecil utas kerja. Mari kita lihat bagaimana kita mencapai ini melalui berbagai pendekatan.
Beberapa penafian penting
Sebelum beralih ke cerita utama dan contoh kode parsing, beberapa catatan perlu dibuat.
Pertama, semua contoh berikut ini tidak terikat pada kerangka atau pustaka tertentu. Kecocokan apa pun dalam nama panggilan API adalah acak dan tidak disengaja.
Kedua, tidak ada penanganan kesalahan dalam contoh di bawah ini. Ini dilakukan dengan sengaja, sehingga slide menjadi padat dan terlihat. Dan juga agar materi sesuai dengan waktu yang ditentukan untuk laporan.
Ketiga, contoh-contoh menggunakan entitas entity_excontext tertentu, yang berisi informasi tentang apa lagi yang ada di dalam program. Mengisi entitas ini tergantung pada pendekatannya. Dalam hal aktor, eksekusi_konteks akan memiliki tautan ke aktor lain. Dalam kasus CSP, dalam eksekusi_context akan ada saluran CSP untuk komunikasi dengan proses CSP lainnya. Dll
Pendekatan # 1: Aktor
Singkatnya Model Aktor
Ketika menggunakan Model Aktor, solusinya akan dibangun dari objek-aktor yang terpisah, masing-masing memiliki negara pribadi dan negara ini tidak dapat diakses oleh siapa pun kecuali aktor itu sendiri.
Aktor berinteraksi satu sama lain melalui pesan asinkron. Setiap aktor memiliki kotak suratnya sendiri (antrian pesan), di mana pesan yang dikirim ke aktor disimpan dan dari mana mereka diambil untuk diproses lebih lanjut.
Aktor bekerja pada prinsip yang sangat sederhana:
- seorang aktor adalah entitas dengan perilaku;
- aktor merespons pesan yang masuk;
- Setelah menerima pesan, aktor dapat:
- mengirim sejumlah (final) pesan ke aktor lain;
- membuat sejumlah (final) sejumlah aktor baru;
- Tetapkan perilaku baru untuk memproses pesan berikutnya.
Di dalam aplikasi, aktor dapat diimplementasikan dengan berbagai cara:
- setiap aktor dapat direpresentasikan sebagai aliran OS yang terpisah (ini terjadi, misalnya, di pustaka C :: Just :: Thread Pro Actor Edition);
- setiap aktor dapat direpresentasikan sebagai coroutine yang penuh;
- setiap aktor dapat direpresentasikan sebagai objek di mana seseorang memanggil metode panggilan balik.
Dalam keputusan kami, kami akan menggunakan aktor dalam bentuk objek dengan callback, dan meninggalkan coroutine untuk pendekatan CSP.
Skema keputusan berdasarkan Model Aktor
Berdasarkan aktor, skema umum untuk menyelesaikan masalah kita akan terlihat seperti ini:
Kami akan memiliki aktor yang dibuat pada awal server HTTP dan ada sepanjang waktu saat server HTTP berfungsi. Ini adalah aktor seperti: HttpSrv, UserChecker, ImageDownloader, ImageMixer.
Setelah menerima permintaan HTTP masuk yang baru, kami membuat instance baru dari aktor RequestHandler, yang akan dimusnahkan setelah mengeluarkan respons terhadap permintaan HTTP yang masuk.
Kode Aktor RequestHandler
Implementasi aktor request_handler, yang mengoordinasikan pemrosesan permintaan HTTP yang masuk, dapat terlihat seperti ini:
class request_handler final : public some_basic_type { const execution_context context_; const request request_; optional<user_info> user_info_; optional<image_loaded> image_; void on_start(); void on_user_info(user_info info); void on_image_loaded(image_loaded image); void on_mixed_image(mixed_image image); void send_mix_images_request(); ...
Mari kita uraikan kode ini.
Kami memiliki kelas dalam atribut yang kami simpan atau akan menyimpan apa yang kami butuhkan untuk memproses permintaan. Juga di kelas ini ada satu set panggilan balik yang akan dipanggil pada satu waktu atau yang lain.
Pertama, ketika aktor baru saja dibuat, panggilan balik on_start () dipanggil. Di dalamnya, kami mengirim dua pesan ke aktor lain. Pertama, ini adalah pesan check_user untuk memverifikasi ID klien. Kedua, ini adalah pesan download_image untuk mengunduh gambar asli.
Di setiap pesan yang dikirim, kami meneruskan tautan ke diri kami sendiri (metode panggilan ke diri () mengembalikan tautan ke aktor yang dipanggil sendiri () dipanggil). Ini diperlukan agar aktor kami dapat mengirim pesan sebagai tanggapan. Jika kami tidak mengirim tautan ke aktor kami, misalnya, dalam pesan check_user, maka aktor UserChecker tidak akan tahu kepada siapa harus mengirim informasi pengguna.
Ketika pesan user_info dengan informasi pengguna dikirimkan kepada kami sebagai respons, panggilan balik on_user_info () dipanggil. Dan ketika pesan image_loaded dikirim kepada kami, panggilan balik on_image_loaded () dipanggil pada aktor kami. Dan sekarang di dalam dua panggilan balik ini kita melihat fitur yang melekat dalam Model Aktor: kita tidak tahu persis bagaimana urutan kita akan menerima pesan tanggapan. Karena itu, kita harus menulis kode kita sehingga tidak tergantung pada urutan pesan masuk. Oleh karena itu, di setiap prosesor, pertama-tama kita menyimpan informasi yang diterima dalam atribut yang sesuai, dan kemudian memeriksa apakah kita sudah mengumpulkan semua informasi yang kita butuhkan? Jika demikian, maka kita bisa melanjutkan. Jika tidak, maka kami akan menunggu lebih jauh.
Itulah mengapa kita memiliki if_ on_user_info () dan on_image_loaded () yang dijalankan ketika send_mix_images_request () dipanggil.
Pada prinsipnya, dalam implementasi Model Aktor dapat terdapat mekanisme seperti selektif terima dari Erlang atau simpanan dari Akka, yang melaluinya Anda dapat memanipulasi urutan pemrosesan pesan yang masuk, tetapi kami tidak akan membicarakan hal ini hari ini, agar tidak menyelidiki rincian detail dari berbagai implementasi Model tersebut. Aktor.
Jadi, jika semua informasi yang kita butuhkan dari UserChecker dan ImageDownloader diterima, maka metode send_mix_images_request () dipanggil, di mana pesan mix_images dikirim ke aktor ImageMixer. Callback on_mixed_image () dipanggil ketika kami menerima pesan respons dengan gambar yang dihasilkan. Di sini kita mengirim gambar ini ke aktor HttpSrv dan menunggu sampai HttpSrv membentuk respons HTTP dan menghancurkan RequestHandler yang telah menjadi tidak perlu (meskipun, pada prinsipnya, tidak ada yang mencegah aktor RequestHandler merusak diri di panggilan on_mixed_image ()).
Itu saja.
Implementasi aktor RequestHandler ternyata cukup produktif. Tapi ini karena fakta bahwa kita perlu menggambarkan kelas dengan atribut dan panggilan balik, dan kemudian juga menerapkan panggilan balik. Tetapi logika pekerjaan RequestHandler sangat sepele, dan memahaminya, terlepas dari jumlah kode di kelas request_handler, mudah.
Fitur yang melekat pada aktor
Sekarang kita dapat mengatakan beberapa kata tentang fitur Model Aktor.
Reaktor
Sebagai aturan, aktor hanya merespons pesan yang masuk. Ada pesan - aktor memprosesnya. Tidak ada pesan - aktor tidak melakukan apa pun.
Ini terutama berlaku untuk implementasi Model Aktor di mana aktor direpresentasikan sebagai objek dengan panggilan balik. Kerangka kerja menarik callback aktor dan jika aktor tidak mengembalikan kontrol dari callback, kerangka kerja tidak dapat melayani aktor lain dalam konteks yang sama.
Aktor kelebihan beban
Pada aktor, kita dapat dengan mudah membuat aktor-produser menghasilkan pesan untuk konsumen-aktor pada kecepatan yang jauh lebih cepat daripada aktor-konsumen yang dapat memproses.
Ini akan mengarah pada fakta bahwa antrian pesan yang masuk untuk aktor-konsumen akan terus bertambah. Pertumbuhan antrian, mis. peningkatan konsumsi memori dalam aplikasi akan mengurangi kecepatan aplikasi. Ini akan mengarah pada pertumbuhan antrian yang lebih cepat dan, sebagai akibatnya, aplikasi mungkin menurun sehingga tidak dapat dioperasikan sepenuhnya.
Semua ini adalah konsekuensi langsung dari interaksi aktor yang tidak sinkron. Karena operasi pengiriman umumnya non-pemblokiran. Dan untuk membuatnya tidak mudah, karena seorang aktor dapat mengirim ke dirinya sendiri. Dan jika antrian untuk aktor penuh, maka pada send-to-yourself aktor akan diblokir dan ini akan menghentikan pekerjaannya.
Jadi ketika bekerja dengan aktor, perhatian serius harus diberikan pada masalah kelebihan beban.
Banyak aktor tidak selalu solusinya.
Sebagai aturan, aktor adalah entitas yang ringan dan ada godaan untuk membuatnya dalam aplikasi mereka dalam jumlah besar. Anda dapat membuat sepuluh ribu aktor, dan seratus ribu, dan satu juta. Dan bahkan seratus juta aktor, jika besi memungkinkan Anda.
Tetapi masalahnya adalah bahwa perilaku sejumlah besar aktor sulit dilacak. Yaitu Anda mungkin memiliki beberapa aktor yang jelas bekerja dengan benar. Beberapa aktor yang jelas-jelas bekerja salah atau tidak bekerja sama sekali, dan Anda tahu pasti. Tetapi mungkin ada sejumlah besar aktor tentang siapa Anda tidak tahu apa-apa: apakah mereka bekerja sama sekali, apakah mereka bekerja dengan benar atau salah. Dan semua karena ketika Anda memiliki seratus juta entitas otonom dengan logika perilaku Anda sendiri dalam program Anda, maka memantau ini sangat sulit untuk semua orang.
Oleh karena itu, mungkin ternyata saat membuat sejumlah besar aktor dalam aplikasi, kami tidak memecahkan masalah yang kami terapkan, tetapi mendapatkan masalah lain. Dan, oleh karena itu, mungkin bermanfaat bagi kita untuk meninggalkan aktor sederhana yang menyelesaikan satu tugas, demi aktor yang lebih kompleks dan berat yang melakukan beberapa tugas. Tetapi kemudian akan ada lebih sedikit aktor "berat" dalam aplikasi dan akan lebih mudah bagi kita untuk mengikuti mereka.
Di mana mencarinya, apa yang harus diambil?
Jika seseorang ingin mencoba bekerja dengan aktor dalam C ++, maka tidak ada gunanya membangun sepeda Anda sendiri, ada beberapa solusi yang sudah jadi, khususnya:
Tiga opsi ini hidup, berkembang, lintas platform, didokumentasikan. Anda juga dapat mencobanya secara gratis. Ditambah beberapa opsi lagi dengan tingkat kesegaran yang berbeda-beda dapat ditemukan
dalam daftar di Wikipedia .
SObjectizer dan CAF dirancang untuk digunakan dalam tugas-tugas tingkat tinggi di mana pengecualian dan memori dinamis dapat diterapkan. Dan kerangka kerja QP / C ++ mungkin menarik bagi mereka yang terlibat dalam pengembangan yang disematkan, seperti di bawah ceruk inilah dia "dipenjara."
Pendekatan # 2: CSP (mengkomunikasikan proses berurutan)
CSP di jari dan tanpa matan
Model CSP sangat mirip dengan Model Aktor. Kami juga membangun solusi kami dari sekumpulan entitas otonom, yang masing-masing memiliki negara pribadi dan berinteraksi dengan entitas lain hanya melalui pesan asinkron.
Hanya entitas ini dalam model CSP yang disebut "proses."
Proses dalam CSP ringan, tanpa paralelisasi pekerjaan mereka di dalamnya. Jika kita perlu memparalelkan sesuatu, maka kita cukup memulai beberapa proses CSP, di dalamnya tidak ada paralelisasi lagi.
Proses CSP berinteraksi satu sama lain melalui pesan asinkron, tetapi pesan dikirim bukan ke kotak surat, seperti dalam Model Aktor, tetapi ke saluran. Saluran dapat dianggap sebagai antrian pesan, biasanya berukuran tetap.
Tidak seperti Model Aktor, di mana kotak surat secara otomatis dibuat untuk setiap aktor, saluran di CSP harus dibuat secara eksplisit. Dan jika kita membutuhkan dua proses untuk berinteraksi satu sama lain, maka kita harus membuat saluran sendiri, dan kemudian memberi tahu proses pertama "Anda akan menulis di sini", dan proses kedua harus mengatakan: "Anda akan membaca di sini dari sini."
Pada saat yang sama, saluran memiliki setidaknya dua operasi yang harus dipanggil secara eksplisit. Yang pertama adalah operasi tulis (kirim) untuk menulis pesan ke saluran.
Kedua, ini adalah operasi baca (terima) untuk membaca pesan dari saluran. Dan kebutuhan untuk secara eksplisit memanggil read / accept membedakan CSP dari Actors Model, karena dalam kasus aktor, operasi baca / terima umumnya dapat disembunyikan dari aktor. Yaitu Kerangka kerja aktor dapat mengambil pesan dari antrian aktor dan memanggil penangan (panggilan balik) untuk pesan yang diambil.
Sedangkan proses CSP itu sendiri harus memilih saat untuk panggilan baca / terima, maka proses CSP harus menentukan pesan apa yang diterima dan memproses pesan yang diekstraksi.
Di dalam aplikasi "besar" kami, proses CSP dapat diimplementasikan dengan berbagai cara:
- Proses CSP-shny dapat diimplementasikan sebagai OS utas terpisah. Ternyata solusi yang mahal, tetapi dengan multitasking preemptive;
- Proses CSP dapat diimplementasikan oleh coroutine (stackful coroutine, fiber, green thread, ...). Ini jauh lebih murah, tetapi multitasking hanya kooperatif.
Lebih lanjut, kami mengasumsikan bahwa proses CSP disajikan dalam bentuk stackout coroutine (meskipun kode yang ditunjukkan di bawah ini mungkin dapat diimplementasikan pada utas OS).
Diagram Solusi Berbasis CSP
Skema solusi berdasarkan model CSP akan sangat mirip dengan skema serupa untuk Model Aktor (dan ini bukan kecelakaan):
Akan ada entitas yang mulai pada awal server HTTP dan berfungsi sepanjang waktu - ini adalah proses CSP HttpSrv, UserChecker, ImageDownloader dan ImageMixer. Untuk setiap permintaan masuk baru, proses CSP RequestHandler baru akan dibuat. Proses ini mengirim dan menerima pesan yang sama seperti ketika menggunakan Model Aktor.
Kode Proses CSP RequestHandler
Ini mungkin terlihat seperti kode fungsi yang mengimplementasikan proses CSP RequestHandler:
void request_handler(const execution_context ctx, const request req) { auto user_info_ch = make_chain<user_info>(); auto image_loaded_ch = make_chain<image_loaded>(); ctx.user_checker_ch().write(check_user{req.user_id(), user_info_ch}); ctx.image_downloader_ch().write(download_image{req.image_id(), image_loaded_ch}); auto user = user_info_ch.read(); auto original_image = image_loaded_ch.read(); auto image_mix_ch = make_chain<mixed_image>(); ctx.image_mixer_ch().write( mix_image{user.watermark_image(), std::move(original_image), image_mix_ch}); auto result_image = image_mix_ch.read(); ctx.http_srv_ch().write(reply{..., std::move(result_image), ...}); }
Di sini semuanya sangat sepele dan secara teratur mengulangi pola yang sama:
- Pertama, kami membuat saluran untuk menerima pesan tanggapan. Ini perlu karena proses CSP tidak memiliki kotak surat default sendiri, seperti aktor. Karena itu, jika proses CSP-shny ingin menerima sesuatu, maka itu harus dibuat bingung oleh pembuatan saluran tempat "sesuatu" ini akan ditulis;
- kemudian kami mengirim pesan kami ke proses master CSP. Dan dalam pesan ini kami menunjukkan saluran untuk pesan respons;
- kemudian kami melakukan operasi baca dari saluran di mana kami akan dikirimi pesan tanggapan.
Ini sangat jelas terlihat dalam contoh komunikasi dengan proses CSP ImageSPixer:
auto image_mix_ch = make_chain<mixed_image>();
Namun secara terpisah ada baiknya fokus pada fragmen ini:
auto user = user_info_ch.read(); auto original_image = image_loaded_ch.read();
Di sini kita melihat perbedaan serius lain dari Model Aktor. Dalam hal CSP, kami dapat menerima pesan respons sesuai urutan yang kami inginkan.
Ingin menunggu user_info dulu? Tidak masalah, tidurlah pada baca sampai user_info muncul. Jika image_loaded sudah dikirim kepada kami saat ini, maka ia hanya akan menunggu di salurannya sampai kita membacanya.
Itu, pada kenyataannya, adalah semua yang dapat menyertai kode yang ditunjukkan di atas. Kode berbasis CSP lebih kompak daripada rekannya yang berbasis aktor. Yang tidak mengherankan sejak itu di sini kita tidak perlu menggambarkan kelas yang terpisah dengan metode panggilan balik. Dan bagian dari proses CSP-pemalu kami RequestHandler hadir secara implisit dalam bentuk argumen ctx dan req.
Fitur CSP
Reaktivitas dan proaktif proses CSP
Tidak seperti aktor, proses CSP dapat menjadi reaktif, proaktif, atau keduanya. Katakanlah proses CSP memeriksa pesan yang masuk, jika ada, itu memprosesnya. Dan kemudian, melihat bahwa tidak ada pesan masuk, dia berjanji untuk melipatgandakan matriks.
Setelah beberapa waktu, proses CSP dari matriks sudah bosan mengalikan, dan dia sekali lagi memeriksa pesan yang masuk. Tidak ada yang baru? Baiklah, mari kita gandakan matriks lebih jauh.
Dan kemampuan proses CSP ini untuk melakukan beberapa pekerjaan bahkan tanpa adanya pesan masuk membuat model CSP sangat berbeda dari Model Aktor.
Mekanisme perlindungan overload asli
Karena, sebagai aturan, saluran adalah antrian pesan dengan ukuran terbatas dan upaya untuk menulis pesan ke saluran yang diisi menghentikan pengirim, maka di CSP kami memiliki mekanisme perlindungan bawaan terhadap kelebihan beban.
Memang, jika kita memiliki proses produsen gesit dan proses konsumen lambat, maka proses produser akan dengan cepat mengisi saluran dan itu akan ditangguhkan untuk operasi pengiriman berikutnya. Dan proses produser akan tidur sampai proses konsumen membebaskan ruang di saluran untuk pesan baru. Segera setelah tempat itu muncul, proses produksi bangun dan melemparkan pesan baru ke saluran.
Jadi, ketika menggunakan CSP, kita bisa lebih sedikit khawatir tentang masalah kelebihan daripada dalam kasus Model Aktor. Benar, ada jebakan di sini, yang akan kita bicarakan nanti.
Bagaimana proses CSP diimplementasikan
Kita harus memutuskan bagaimana proses CSP kita akan diimplementasikan.
Hal ini dapat dilakukan agar setiap proses CSP-shny akan diwakili oleh utas OS yang terpisah. Ternyata solusi yang mahal dan tidak scalable. Tetapi di sisi lain, kita mendapatkan multitasking preemptive: jika proses CSP kami mulai mengalikan matriks atau membuat semacam panggilan pemblokiran, maka OS pada akhirnya akan mendorongnya keluar dari inti komputasi dan memberikan proses CSP lain kesempatan untuk bekerja.
Dimungkinkan untuk membuat setiap proses CSP diwakili oleh coroutine (stackful coroutine). Ini adalah solusi yang jauh lebih murah dan terukur. Tapi di sini kita hanya akan memiliki multitasking kooperatif. Oleh karena itu, jika tiba-tiba proses CSP mengambil perkalian matriks, untaian kerja dengan proses CSP ini dan proses CSP lain yang dilampirkan akan diblokir.
Mungkin ada trik lain. Misalkan kita menggunakan perpustakaan pihak ketiga, di dalamnya kita tidak bisa memengaruhi. Dan di dalam perpustakaan, variabel TLS digunakan (mis. Thread-local-storage). Kami melakukan satu panggilan ke fungsi perpustakaan dan perpustakaan menetapkan nilai beberapa variabel TLS. Kemudian coroutine kami "bergerak" ke utas yang lain, dan ini dimungkinkan, karena pada prinsipnya, coroutine dapat berpindah dari satu utas kerja ke utas lainnya. Kami membuat panggilan berikut ke fungsi perpustakaan dan perpustakaan mencoba membaca nilai variabel TLS. Tetapi mungkin sudah ada makna yang berbeda! Dan mencari bug seperti itu akan sangat sulit.
Karena itu, Anda perlu mempertimbangkan pilihan metode untuk mengimplementasikan proses CSP-shnyh dengan hati-hati. Masing-masing opsi memiliki kekuatan dan kelemahannya sendiri.
Banyak proses tidak selalu merupakan solusi.
Seperti para aktor, kemampuan untuk menciptakan banyak proses CSP dalam program Anda tidak selalu merupakan solusi untuk masalah yang diterapkan, tetapi menciptakan masalah tambahan untuk diri Anda sendiri.
Selain itu, visibilitas yang buruk dari apa yang terjadi di dalam program hanya merupakan satu bagian dari masalah. Saya ingin fokus pada perangkap lain.
Faktanya adalah bahwa pada saluran CSP-shnyh Anda dapat dengan mudah mendapatkan analog dari kebuntuan. Proses A mencoba menulis pesan ke saluran lengkap C1 dan proses A dijeda. Dari saluran C1, proses B, yang mencoba menulis ke saluran C2, yang penuh, harus dibaca, dan karena itu, proses B ditangguhkan. Dan dari saluran C2, proses A adalah untuk membaca. Itu saja, kita menemui jalan buntu.
Jika kita hanya memiliki dua proses CSP, maka kita dapat menemukan kebuntuan selama debugging atau bahkan dengan prosedur peninjauan kode. Tetapi jika kita memiliki jutaan proses dalam program, mereka secara aktif berkomunikasi satu sama lain, maka kemungkinan kebuntuan tersebut meningkat secara signifikan.
Di mana mencarinya, apa yang harus diambil?
Jika seseorang ingin bekerja dengan CSP di C ++, maka pilihan di sini, sayangnya, tidak sebesar untuk aktor. Ya, atau saya tidak tahu ke mana harus mencari dan bagaimana mencarinya. Dalam hal ini, saya harap komentar akan membagikan tautan lain.
Tetapi, jika kita ingin menggunakan CSP, pertama-tama kita harus melihat ke arah
Boost.Fiber . Ada serat (mis. Coroutine), dan saluran, dan bahkan primitif tingkat rendah seperti mutex, condition_variable, barrier. Semua ini bisa diambil dan digunakan.
Jika Anda puas dengan proses CSP dalam bentuk utas, maka Anda dapat melihat pada
SObjectizer . Ada juga analog saluran CSP dan aplikasi multi-utas kompleks pada SObjectizer dapat ditulis tanpa aktor sama sekali.
Aktor vs CSP
Aktor dan CSP sangat mirip satu sama lain. Berulang kali saya menemukan pernyataan bahwa kedua model ini setara satu sama lain. Yaitu apa yang dapat dilakukan pada aktor dapat hampir 1-in-1 diulangi pada proses CSP dan sebaliknya. Mereka mengatakan bahwa itu bahkan terbukti secara matematis. Tapi di sini saya tidak mengerti apa-apa, jadi saya tidak bisa mengatakan apa-apa. Tetapi dari pikiran saya sendiri di suatu tempat pada tingkat akal sehat sehari-hari, semua ini terlihat cukup masuk akal. Dalam beberapa kasus, memang, aktor dapat digantikan oleh proses CSP, dan proses CSP oleh aktor.
Namun, ada beberapa perbedaan antara aktor dan CSP yang dapat membantu menentukan di mana masing-masing model ini bermanfaat atau tidak menguntungkan.
Saluran vs kotak surat
Seorang aktor memiliki "saluran" tunggal untuk menerima pesan masuk - ini adalah kotak suratnya, yang secara otomatis dibuat untuk setiap aktor. Dan aktor mengambil pesan dari sana secara berurutan, persis dalam urutan pesan di kotak surat.
Dan ini adalah pertanyaan yang cukup serius. Katakanlah ada tiga pesan di kotak surat aktor: M1, M2 dan M3. Aktor saat ini hanya tertarik pada M3.
Tetapi sebelum sampai ke M3, aktor pertama-tama akan mengekstrak M1, kemudian M2. Dan apa yang akan dia lakukan dengan mereka?Sekali lagi, sebagai bagian dari percakapan ini, kami tidak akan menyentuh mekanisme penerimaan selektif dari Erlang dan menyembunyikan dari Akka.
Sedangkan proses CSP-shny memiliki kemampuan untuk memilih saluran dari mana saat ini ia ingin membaca pesan. Jadi, proses CSP dapat memiliki tiga saluran: C1, C2, dan C3. Saat ini, proses CSP hanya tertarik pada pesan dari C3. Saluran inilah yang dibaca proses. Dan dia akan kembali ke isi saluran C1 dan C2 ketika dia tertarik dengan ini.Reaktivitas dan Proaktif
Sebagai aturan, aktor reaktif dan hanya berfungsi ketika mereka memiliki pesan masuk.Sedangkan proses CSP dapat melakukan beberapa pekerjaan bahkan tanpa adanya pesan yang masuk. Dalam beberapa skenario, perbedaan ini dapat memainkan peran penting.Mesin negara
Faktanya, aktor adalah mesin negara hingga (KA). Oleh karena itu, jika ada banyak mesin negara terbatas di area subjek Anda, dan bahkan jika mereka mesin mesin negara terbatas hirarkis, maka bisa lebih mudah bagi Anda untuk mengimplementasikannya berdasarkan model aktor daripada dengan menambahkan implementasi pesawat ruang angkasa ke proses CSP.Di C ++, belum ada dukungan CSP asli.
Pengalaman bahasa Go menunjukkan betapa mudah dan nyamannya menggunakan model CSP ketika dukungannya diimplementasikan pada tingkat bahasa pemrograman dan pustaka standarnya.Di Go, mudah untuk membuat "proses CSP" (alias goroutine), mudah untuk membuat dan bekerja dengan saluran, ada sintaks bawaan untuk bekerja dengan beberapa saluran sekaligus (Pilih, yang berfungsi tidak hanya untuk membaca tetapi juga untuk menulis), perpustakaan standar tahu tentang goroutin dan dapat mengubahnya ketika goroutin membuat panggilan pemblokiran dari stdlib.Di C ++, sejauh ini tidak ada dukungan untuk coroutine stackful (di tingkat bahasa). Oleh karena itu, bekerja dengan CSP di C ++ mungkin terlihat, di beberapa tempat, jika bukan penopang, maka ... Itu tentu saja membutuhkan lebih banyak perhatian pada dirinya sendiri daripada dalam kasus Go yang sama.Pendekatan No. 3: Tugas (async, masa depan, wait_all, ...)
Tentang pendekatan berbasis tugas dengan kata-kata yang paling umum
Arti dari pendekatan berbasis tugas adalah bahwa jika kita memiliki operasi yang kompleks, maka kita membagi operasi ini menjadi langkah-langkah tugas yang terpisah, di mana setiap tugas (itu adalah tugas) melakukan satu sub-operasi tunggal.Kami memulai tugas ini dengan operasi khusus async. Operasi async mengembalikan objek masa depan di mana, setelah tugas selesai, nilai yang dikembalikan oleh tugas akan ditempatkan.Setelah kami meluncurkan tugas N dan menerima objek N-masa depan, kita perlu merajut semua ini dalam sebuah rantai. Tampaknya ketika tugas No. 1 dan No. 2 selesai, nilai yang dikembalikan oleh mereka harus jatuh ke dalam tugas No. 3. Dan ketika tugas No. 3 selesai, nilai yang dikembalikan harus ditransfer ke tugas No. 4, No. 5, dan No. 6. Dll, dll.Untuk "dasi", cara khusus digunakan. Seperti, misalnya, metode .then () dari objek masa depan, serta fungsi wait_all (), wait_any ().Penjelasan seperti itu "di jari" mungkin tidak begitu jelas, jadi mari kita beralih ke kode. Mungkin dalam percakapan tentang kode tertentu situasinya akan menjadi lebih jelas (tetapi bukan fakta).Kode Request_handler untuk pendekatan berbasis tugas
Kode untuk memproses permintaan HTTP masuk berdasarkan tugas dapat terlihat seperti ini: void handle_request(const execution_context & ctx, request req) { auto user_info_ft = async(ctx.http_client_ctx(), [req] { return retrieve_user_info(req.user_id()); }); auto original_image_ft = async(ctx.http_client_ctx(), [req] { return download_image(req.image_id()); }); when_all(user_info_ft, original_image_ft).then( [&ctx, req](tuple<future<user_info>, future<image_loaded>> data) { async(ctx.image_mixer_ctx(), [&ctx, req, d=std::move(data)] { return mix_image(get<0>(d).get().watermark_image(), get<1>(d).get()); }) .then([req](future<mixed_image> mixed) { async(ctx.http_srv_ctx(), [req, im=std::move(mixed)] { make_reply(...); }); }); }); }
Mari kita coba mencari tahu apa yang terjadi di sini.Pertama, kami membuat tugas yang harus diluncurkan dalam konteks klien HTTP kami sendiri dan yang meminta informasi tentang pengguna. Objek masa depan yang dikembalikan disimpan dalam variabel user_info_ft.Selanjutnya, kami membuat tugas serupa, yang juga harus dijalankan dalam konteks klien HTTP kami sendiri dan yang memuat gambar asli. Objek masa depan yang dikembalikan disimpan dalam variabel original_image_ft.Selanjutnya, kita harus menunggu dua tugas pertama selesai. Apa yang kami tulis secara langsung: when_all (user_info_ft, original_image_ft). Ketika kedua objek di masa depan mendapatkan nilainya, maka kita akan menjalankan tugas lain. Tugas ini akan mengambil bitmap dengan tanda air dan gambar asli dan menjalankan tugas lain dalam konteks ImageMixer. Tugas ini akan mencampur gambar dan ketika selesai, tugas lain akan diluncurkan pada konteks server HTTP, yang akan menghasilkan respons HTTP.Mungkin penjelasan seperti itu tentang apa yang terjadi dalam kode tidak banyak diklarifikasi. Karena itu, mari beri nomor tugas kita:Dan mari kita lihat dependensi di antara mereka (dari mana urutan tugas mengalir):Dan jika sekarang kita overlay gambar ini pada kode sumber kita, maka saya harap itu menjadi lebih jelas:Fitur dari pendekatan berbasis tugas
Visibilitas
Fitur pertama yang seharusnya sudah jelas adalah visibilitas kode pada Tugas. Tidak semua baik-baik saja dengannya.Di sini Anda dapat menyebutkan hal seperti neraka panggilan balik. Pemrogram Node.js sangat akrab dengannya. Tapi nama julukan C ++ yang bekerja erat dengan Task juga masuk ke neraka panggilan balik ini.Menangani kesalahan
Fitur menarik lainnya adalah penanganan kesalahan.Di satu sisi, dalam hal menggunakan async dan masa depan dengan penyampaian informasi kesalahan kepada pihak yang berkepentingan, hal itu bahkan bisa lebih mudah daripada dalam kasus aktor atau CSP. Lagi pula, jika dalam proses CSP A mengirimkan permintaan untuk memproses B dan menunggu pesan respons, maka ketika B menemukan kesalahan saat menjalankan permintaan, kita perlu memutuskan bagaimana mengirimkan kesalahan ke proses A:- atau kami akan membuat jenis pesan yang terpisah dan saluran untuk menerimanya;
- atau kami mengembalikan hasilnya dengan satu pesan, yang akan menjadi std :: varian untuk hasil yang normal dan salah.
Dan dalam hal masa depan, semuanya lebih sederhana: kita mengekstraksi dari masa depan baik hasil normal, atau pengecualian dilemparkan kepada kita.Tetapi, di sisi lain, kita dapat dengan mudah mengalami serangkaian kesalahan. Misalnya, pengecualian terjadi pada tugas No. 1, pengecualian ini jatuh ke objek masa depan, yang diteruskan ke tugas No. 2. Dalam tugas No. 2, kami mencoba mengambil nilai dari masa depan, tetapi menerima pengecualian. Dan, kemungkinan besar, kami akan membuang pengecualian yang sama. Dengan demikian, itu akan jatuh ke masa depan berikutnya, yang akan pergi ke tugas No. 3. Juga akan ada pengecualian, yang, sangat mungkin, juga akan dirilis. Dll
Jika pengecualian kita dicatat, maka dalam log kita bisa melihat pengulangan berulang dari pengecualian yang sama, yang beralih dari satu tugas dalam rantai ke tugas lain.Batalkan Tugas dan Pengatur Waktu / Waktu Habis
Dan fitur lain yang sangat menarik dari kampanye berbasis Tugas adalah pembatalan tugas jika terjadi kesalahan. Bahkan, katakanlah kita membuat 150 tugas, menyelesaikan 10 tugas pertama, dan menyadari bahwa tidak ada gunanya melanjutkan pekerjaan. Bagaimana kita membatalkan 140 yang tersisa? Ini adalah pertanyaan yang sangat, sangat bagus :)Pertanyaan serupa lainnya adalah bagaimana cara membuat teman-teman tugas dengan timer dan timeout. Misalkan kita mengakses beberapa sistem eksternal dan ingin membatasi waktu tunggu hingga 50 milidetik. Bagaimana kita mengatur timer, bagaimana bereaksi terhadap berakhirnya batas waktu, bagaimana mengganggu rantai tugas jika batas waktu telah berakhir? Sekali lagi, bertanya lebih mudah daripada menjawab :)Curang
Nah, dan berbicara tentang fitur pendekatan berbasis tugas. Dalam contoh yang ditunjukkan, sedikit kecurangan diterapkan: auto user_info_ft = async(ctx.http_client_ctx(), [req] { return retrieve_user_info(req.user_id()); }); auto original_image_ft = async(ctx.http_client_ctx(), [req] { return download_image(req.image_id()); });
Di sini saya mengirim dua tugas ke konteks server HTTP kami sendiri, yang masing-masing melakukan operasi pemblokiran di dalamnya. Bahkan, untuk dapat memproses dua permintaan ke layanan pihak ketiga secara paralel, di sini Anda harus membuat rantai tugas asinkron Anda sendiri. Tetapi saya tidak melakukan ini untuk membuat solusi lebih atau kurang terlihat dan sesuai pada slide presentasi.Aktor / CSP vs Tugas
Kami memeriksa tiga pendekatan dan melihat bahwa jika para aktor dan proses CSP mirip satu sama lain, maka pendekatan berbasis tugas tidak seperti salah satu dari mereka. Dan mungkin terlihat bahwa Aktor / CSP harus dikontraskan dengan Tugas.Tapi secara pribadi, saya suka sudut pandang yang berbeda.Ketika kita berbicara tentang Model Aktor dan CSP, maka kita berbicara tentang dekomposisi tugas kita. Dalam tugas kami, kami memilih entitas independen yang terpisah dan menggambarkan antarmuka entitas ini: pesan mana yang mereka kirim, yang mana yang mereka terima, melalui saluran mana pesan itu pergi.Yaitu
bekerja dengan aktor dan CSP kita berbicara tentang antarmuka.Tetapi misalkan kita membagi tugas menjadi aktor dan proses CSP yang terpisah. Bagaimana tepatnya mereka melakukan pekerjaan mereka?Ketika kami mengambil pendekatan berbasis tugas, kami mulai berbicara tentang implementasi. Tentang bagaimana pekerjaan tertentu dilakukan, sub-operasi apa yang dilakukan, dalam urutan apa, bagaimana sub-operasi ini terhubung menurut data, dll.Yaitu
bekerja dengan Tugas, kita berbicara tentang implementasi.Oleh karena itu, Aktor / CSP dan Tugas tidak begitu saling bertentangan, tetapi saling melengkapi. Aktor / CSP dapat digunakan untuk menguraikan tugas dan mendefinisikan antarmuka antar komponen. Dan Tugas kemudian dapat digunakan untuk mengimplementasikan komponen tertentu.Misalnya, saat menggunakan Aktor, kami memiliki entitas seperti ImageMixer, yang perlu dimanipulasi dengan gambar di kumpulan utas. Secara umum, tidak ada yang mencegah kita menggunakan aktor ImageMixer untuk menggunakan pendekatan berbasis tugas.Di mana mencarinya, apa yang harus diambil?
Jika Anda ingin bekerja dengan Tugas di C ++, Anda bisa melihat ke perpustakaan standar C ++ 20 mendatang. Mereka telah menambahkan metode .then () ke masa depan, serta fungsi bebas wait_all () dan wait_any. Lihat preferensi cp untuk referensi .Sudah ada juga jauh dari perpustakaan async ++ baru . Di mana, pada prinsipnya, ada semua yang Anda butuhkan, hanya sedikit dengan saus yang berbeda.Dan ada perpustakaan Microsoft PPL yang bahkan lebih tua . Yang juga memberikan semua yang Anda butuhkan, tetapi dengan saus Anda sendiri.Pisahkan tambahan tentang perpustakaan Intel TBB. Tidak disebutkan dalam cerita tentang pendekatan berbasis tugas karena, menurut pendapat saya, grafik tugas dari TBB sudah menjadi pendekatan aliran data. Dan, jika laporan ini berlanjut, pembicaraan tentang Intel TBB pasti akan datang, tetapi dalam konteks kisah tentang aliran data.
Lebih menarik
Baru-baru ini di sini, di Habré, ada sebuah artikel oleh Anton Polukhin: "Kami sedang mempersiapkan C ++ 20. Coroutines TS menggunakan contoh nyata ."Ini berbicara tentang menggabungkan pendekatan berbasis tugas dengan coroutine stackless dari C ++ 20. Dan ternyata kode berdasarkan pembacaan Tugas mendekati pembacaan kode pada proses CSP.Jadi, jika seseorang tertarik pada pendekatan berbasis tugas, maka masuk akal untuk membaca artikel ini.Kesimpulan
Nah, ini saatnya untuk beralih ke hasil, karena jumlahnya tidak begitu banyak.Hal utama yang ingin saya katakan adalah bahwa di dunia modern Anda mungkin perlu melakukan multithreading hanya jika Anda sedang mengembangkan semacam kerangka kerja atau menyelesaikan beberapa tugas tingkat rendah dan spesifik.Dan jika Anda menulis kode aplikasi, maka Anda hampir tidak memerlukan utas kosong, primitif sinkronisasi tingkat rendah atau semacam algoritma bebas kunci bersama dengan wadah bebas kunci. Untuk waktu yang lama ada pendekatan yang teruji oleh waktu dan telah membuktikan diri dengan baik:- aktor
- mengkomunikasikan proses sekuensial (CSP)
- tugas (async, janji, masa depan, ...)
- aliran data
- pemrograman reaktif
- ...
Dan yang paling penting, ada alat yang siap pakai untuk mereka di C ++. Anda tidak perlu siklus apa pun, Anda dapat mengambil, mencoba dan, jika Anda suka, operasikan.Sangat sederhana: ambil, coba dan operasikan.