Apakah async (launch :: async) di C ++ 11 membuat kumpulan thread menjadi usang untuk menghindari pembuatan thread yang mahal?

117

Ini terkait longgar dengan pertanyaan ini: Apakah std :: thread dikumpulkan dalam C ++ 11? . Meskipun pertanyaannya berbeda, tujuannya tetap sama:

Pertanyaan 1: Apakah masih masuk akal untuk menggunakan kumpulan utas Anda sendiri (atau pustaka pihak ketiga) untuk menghindari pembuatan utas yang mahal?

Kesimpulan dalam pertanyaan lain adalah bahwa Anda tidak dapat mengandalkan std::threaduntuk dikumpulkan (mungkin atau tidak). Namun, std::async(launch::async)tampaknya memiliki peluang yang jauh lebih tinggi untuk dikumpulkan.

Ini tidak berpikir bahwa itu dipaksakan oleh standar, tetapi IMHO saya berharap bahwa semua implementasi C ++ 11 yang baik akan menggunakan penggabungan benang jika pembuatan utas lambat. Hanya pada platform di mana tidak mahal untuk membuat utas baru, saya berharap mereka selalu menelurkan utas baru.

Pertanyaan 2: Ini hanya yang saya pikirkan, tapi saya tidak punya fakta untuk membuktikannya. Saya mungkin salah besar. Apakah ini tebakan terpelajar?

Akhirnya, di sini saya telah memberikan beberapa kode contoh yang pertama menunjukkan bagaimana menurut saya pembuatan utas dapat diekspresikan oleh async(launch::async):

Contoh 1:

 thread t([]{ f(); });
 // ...
 t.join();

menjadi

 auto future = async(launch::async, []{ f(); });
 // ...
 future.wait();

Contoh 2: Aktifkan dan lupakan utas

 thread([]{ f(); }).detach();

menjadi

 // a bit clumsy...
 auto dummy = async(launch::async, []{ f(); });

 // ... but I hope soon it can be simplified to
 async(launch::async, []{ f(); });

Pertanyaan 3: Apakah Anda lebih suka asyncversinya daripada threadversinya?


Selebihnya bukan lagi bagian dari pertanyaan, tetapi hanya untuk klarifikasi:

Mengapa nilai yang dikembalikan harus ditetapkan ke variabel dummy?

Sayangnya, gaya standar C ++ 11 saat ini yang Anda tangkap nilai kembaliannya std::async, karena jika tidak destruktor akan dieksekusi, yang memblokir hingga tindakan dihentikan. Ini oleh beberapa orang dianggap sebagai kesalahan dalam standar (misalnya, oleh Herb Sutter).

Contoh dari cppreference.com ini menggambarkannya dengan baik:

{
  std::async(std::launch::async, []{ f(); });
  std::async(std::launch::async, []{ g(); });  // does not run until f() completes
}

Klarifikasi lain:

Saya tahu bahwa kumpulan utas mungkin memiliki kegunaan lain yang sah tetapi dalam pertanyaan ini saya hanya tertarik pada aspek menghindari biaya pembuatan utas yang mahal .

Saya pikir masih ada situasi di mana kumpulan utas sangat berguna, terutama jika Anda membutuhkan lebih banyak kontrol atas sumber daya. Misalnya, server mungkin memutuskan untuk menangani hanya sejumlah permintaan tetap secara bersamaan untuk menjamin waktu respons yang cepat dan untuk meningkatkan prediktabilitas penggunaan memori. Kolam benang seharusnya baik-baik saja, di sini.

Variabel lokal-utas juga dapat menjadi argumen untuk kumpulan utas Anda sendiri, tetapi saya tidak yakin apakah itu relevan dalam praktiknya:

  • Membuat thread baru std::threaddimulai tanpa variabel thread-lokal yang diinisialisasi. Mungkin ini bukan yang Anda inginkan.
  • Dalam utas yang ditelurkan oleh async, itu agak tidak jelas bagi saya karena utas tersebut dapat digunakan kembali. Dari pemahaman saya, variabel lokal-thread tidak dijamin akan disetel ulang, tetapi saya mungkin salah.
  • Sebaliknya, menggunakan kumpulan utas Anda sendiri (ukuran tetap) memberi Anda kendali penuh jika Anda benar-benar membutuhkannya.
Philipp Claßen
sumber
8
"Namun, std::async(launch::async)tampaknya memiliki peluang yang jauh lebih tinggi untuk dikumpulkan." Tidak, saya yakin itu std::async(launch::async | launch::deferred)yang mungkin dikumpulkan. Dengan hanya launch::asynctugas tersebut seharusnya diluncurkan di utas baru terlepas dari tugas lain apa yang sedang berjalan. Dengan adanya kebijakan tersebut launch::async | launch::deferredmaka pelaksana dapat memilih kebijakan yang mana, namun yang lebih penting adalah penundaan pemilihan kebijakan yang mana. Artinya, itu bisa menunggu hingga utas di kumpulan utas tersedia dan kemudian memilih kebijakan asinkron.
bames53
2
Sejauh yang saya tahu hanya VC ++ menggunakan kolam benang dengan std::async(). Saya masih penasaran untuk melihat bagaimana mereka mendukung destruktor thread_local non-sepele di kumpulan utas.
bames53
2
@ bames53 Saya melangkah melalui libstdc ++ yang disertakan dengan gcc 4.7.2 dan menemukan bahwa jika kebijakan peluncuran tidak tepat launch::async maka ia memperlakukannya seolah-olah hanya launch::deferreddan tidak pernah menjalankannya secara asinkron - jadi pada dasarnya, versi libstdc ++ itu "memilih" untuk selalu menggunakan deferred kecuali dipaksa sebaliknya.
doug65536
3
@ Doug65536 Maksud saya tentang penghancur thread_local adalah bahwa penghancuran pada keluarnya benang tidak sepenuhnya benar saat menggunakan kumpulan benang. Saat tugas dijalankan secara asinkron, tugas tersebut dijalankan 'seolah-olah di utas baru', menurut spesifikasi, yang berarti setiap tugas asinkron mendapatkan objek thread_local-nya sendiri. Implementasi berbasis kumpulan utas harus berhati-hati untuk memastikan bahwa tugas yang berbagi utas pendukung yang sama tetap berperilaku seolah-olah mereka memiliki objek thread_local sendiri. Pertimbangkan program ini: pastebin.com/9nWUT40h
bames53
2
@ bames53 Menggunakan "seolah-olah di utas baru" dalam spesifikasi adalah kesalahan besar menurut saya. std::asyncbisa menjadi hal yang indah untuk kinerja - itu bisa menjadi sistem eksekusi tugas yang berjalan pendek standar, yang secara alami didukung oleh kumpulan utas. Saat ini, hanya std::threaddengan beberapa omong kosong yang ditempelkan untuk membuat fungsi utas dapat mengembalikan nilai. Oh, dan mereka menambahkan fungsi "ditangguhkan" yang berlebihan yang tumpang tindih dengan tugas std::functionsepenuhnya.
doug65536

Jawaban:

55

Pertanyaan 1 :

Saya mengubah ini dari aslinya karena aslinya salah. Saya mendapat kesan bahwa pembuatan thread Linux sangat murah dan setelah pengujian saya memutuskan bahwa overhead pemanggilan fungsi di thread baru vs. yang normal sangat besar. Overhead untuk membuat utas untuk menangani panggilan fungsi adalah sesuatu seperti 10.000 kali atau lebih lambat daripada panggilan fungsi biasa. Jadi, jika Anda mengeluarkan banyak panggilan fungsi kecil, kumpulan utas mungkin merupakan ide yang bagus.

Sangat jelas bahwa pustaka C ++ standar yang disertakan dengan g ++ tidak memiliki kumpulan utas. Tapi saya pasti bisa melihat kasus untuk mereka. Bahkan dengan overhead karena harus mendorong panggilan melalui semacam antrean antar-utas, kemungkinan akan lebih murah daripada memulai utas baru. Dan standar memungkinkan ini.

IMHO, orang-orang kernel Linux harus bekerja untuk membuat pembuatan thread lebih murah daripada saat ini. Namun, pustaka C ++ standar juga harus mempertimbangkan penggunaan kumpulan untuk diimplementasikan launch::async | launch::deferred.

Dan OPnya benar, menggunakan ::std::threaduntuk meluncurkan utas tentu saja memaksa pembuatan utas baru daripada menggunakan utas dari kumpulan. Jadi ::std::async(::std::launch::async, ...)lebih disukai.

Pertanyaan 2 :

Ya, pada dasarnya ini 'secara implisit' meluncurkan utas. Tapi sungguh, masih cukup jelas apa yang terjadi. Jadi menurut saya kata itu tidak secara implisit adalah kata yang sangat bagus.

Saya juga tidak yakin bahwa memaksa Anda untuk menunggu pengembalian sebelum kehancuran selalu merupakan kesalahan. Saya tidak tahu bahwa Anda harus menggunakan asyncpanggilan tersebut untuk membuat utas 'daemon' yang tidak diharapkan kembali. Dan jika mereka diharapkan untuk kembali, tidak masalah untuk mengabaikan pengecualian.

Pertanyaan 3 :

Secara pribadi, saya suka peluncuran utas menjadi eksplisit. Saya sangat menghargai pulau-pulau di mana Anda dapat menjamin akses serial. Jika tidak, Anda akan berakhir dengan keadaan yang bisa berubah bahwa Anda selalu harus membungkus mutex di suatu tempat dan mengingat untuk menggunakannya.

Saya menyukai model antrian kerja yang jauh lebih baik daripada model 'masa depan' karena ada 'pulau serial' yang tergeletak di sekitar sehingga Anda dapat menangani status yang bisa berubah secara lebih efektif.

Tapi sungguh, itu tergantung pada apa yang Anda lakukan.

Uji kinerja

Jadi, saya menguji kinerja berbagai metode panggilan dan menemukan angka-angka ini pada sistem 8 inti (AMD Ryzen 7 2700X) yang menjalankan Fedora 29 yang dikompilasi dengan versi clang 7.0.1 dan libc ++ (bukan libstdc ++):

   Do nothing calls per second:   35365257                                      
        Empty calls per second:   35210682                                      
   New thread calls per second:      62356                                      
 Async launch calls per second:      68869                                      
Worker thread calls per second:     970415                                      

Dan asli, di MacBook Pro 15 "(Intel (R) Core (TM) i7-7820HQ CPU @ 2.90GHz) saya dengan Apple LLVM version 10.0.0 (clang-1000.10.44.4)OSX 10.13.6, saya mendapatkan ini:

   Do nothing calls per second:   22078079
        Empty calls per second:   21847547
   New thread calls per second:      43326
 Async launch calls per second:      58684
Worker thread calls per second:    2053775

Untuk utas pekerja, saya memulai utas, lalu menggunakan antrian tanpa kunci untuk mengirim permintaan ke utas lain dan kemudian menunggu balasan "Selesai" dikirim kembali.

"Tidak melakukan apa-apa" hanya untuk menguji bagian atas harness uji.

Jelas bahwa overhead peluncuran utas sangat besar. Dan bahkan thread pekerja dengan antrian antar-thread memperlambat segalanya dengan faktor 20 atau lebih pada Fedora 25 di VM, dan sekitar 8 pada OS X asli.

Saya membuat proyek Bitbucket dengan memegang kode yang saya gunakan untuk uji kinerja. Ini dapat ditemukan di sini: https://bitbucket.org/omnifarious/launch_thread_performance

Beraneka ragam
sumber
3
Saya setuju dengan model antrian kerja, namun hal ini memerlukan model "pipa" yang mungkin tidak berlaku untuk setiap penggunaan akses bersamaan.
Matthieu M.
1
Menurut saya, templat ekspresi (untuk operator) dapat digunakan untuk membuat hasil, untuk pemanggilan fungsi Anda akan memerlukan metode panggilan, tetapi karena kelebihan beban, ini mungkin sedikit lebih sulit.
Matthieu M.
3
"sangat murah" relatif terhadap pengalaman Anda. Saya menemukan overhead pembuatan thread Linux menjadi substansial untuk penggunaan saya.
Jeff
1
@Jeff - Saya pikir itu jauh lebih murah daripada yang sebenarnya. Saya memperbarui jawaban saya beberapa waktu yang lalu untuk mencerminkan tes yang saya lakukan untuk menemukan biaya sebenarnya.
Omnifarious
4
Di bagian pertama, Anda agak meremehkan seberapa banyak yang harus dilakukan untuk membuat ancaman, dan betapa sedikit yang harus dilakukan untuk memanggil suatu fungsi. Pemanggilan dan pengembalian fungsi adalah beberapa instruksi CPU yang memanipulasi beberapa byte di atas tumpukan. Penciptaan ancaman berarti: 1. mengalokasikan tumpukan, 2. menjalankan syscall, 3. membuat struktur data di kernel dan menghubungkannya, membuat kunci grapping di sepanjang jalan, 4. menunggu penjadwal untuk mengeksekusi utas, 5. beralih konteks utas. Setiap langkah ini sendiri membutuhkan waktu lebih lama daripada panggilan fungsi yang paling kompleks.
cmaster