Saya tahu bahwa jika tidak ada std::thread::detach atau std::thread::join yang dipanggil. Objek utas memanggil std::terminate di dalam destruktor. Saya bertanya-tanya apa pilihan desain ini? Mengapa ia memanggil std::terminaten di dalam destruktor. Selanjutnya, jika tidak memanggil std::terminate di dalam destruktor, perilaku no-detach sama dengan apa yang dilakukan detach sekarang. Jadi mengapa tidak hanya menyimpan thread::join dan menghapus panggilan penghentian saat mendesain utas API? Apa pertimbangan di bawah tenda?

1
Tinggo 24 Desember 2020, 06:54

3 jawaban

Jawaban Terbaik

Ini adalah subjek dari banyak perdebatan sebelum C++11.

Pertanyaan Anda membuat asumsi yang berani: bahwa ketidakmelekatan jelas merupakan perilaku yang benar. Tapi Anda tidak pernah membuktikannya. Memang, ada banyak argumen yang menentang gagasan ini, dan panitia mempertimbangkannya.

Saya akan mengambil contoh ini dari makalah menguraikan argumen yang menentangnya:

int fib(int n) {
    if (n <= 1) return n;
    int fib1, fib2;
    
    std::thread t([=, &fib1]{fib1 = fib(n-1);});
    fib2 = fib(n-2);
    if (fib2 < 0) throw ...
    t.join();
    return fib1 + fib2;
}

Setelah Anda mulai melemparkan pengecualian, perilaku detasemen default tidak lagi berguna. Memang, Anda dapat membayangkan kasus yang lebih kompleks, di mana pengecualian berasal dari sesuatu yang non-lokal ke rutinitas pembuatan utas. Pertimbangkan contoh ini dari makalah selanjutnya:

      std::vector<std::pair<unsigned int, unsigned int>> partitions =
        utils::partition_indexes(0, size-1, num_threads);
      std::vector<std::thread> threads;

      LOG(LOG_DEBUG, "controller::reload_all: starting reload threads...");
      for (unsigned int i=0; i<num_threads-1; i++) {
        threads.push_back(std::thread(reloadrangethread(this,
        partitions[i].first, partitions[i].second, size, unattended)));
      }

      LOG(LOG_DEBUG, "controller::reload_all: starting my own reload...");
      this->reload_range(partitions[num_threads-1].first,
        partitions[num_threads-1].second, size, unattended);

      LOG(LOG_DEBUG, "controller::reload_all: joining other threads...");
      for (size_t i=0; i<threads.size(); i++) {
        threads[i].join();
      }   

push_back dapat gagal karena kurangnya memori untuk mengalokasikan ulang array. Jika itu terjadi, Anda kehilangan akses ke semua utas tersebut, dan program Anda rusak.

Kedua skenario ini mengarah ke program yang rusak, apakah Anda default untuk melepaskan atau default ke terminate. Tetapi jika program akan rusak, sebaiknya program tersebut segera rusak saat masalah terjadi, daripada di beberapa lokasi kemudian dalam kode.

Sekarang, solusi lebih aman adalah join di destructor. Tapi itu tidak terjadi karena berbagai alasan lain. Konsensus (sayangnya) adalah, jika Anda tidak mengatakan apa yang ingin Anda lakukan, maka kode Anda rusak dan akan meledak.

Untungnya, C++20 memberi kami std::jthread, yang bergabung secara default di destruktornya.

Gagasan umum dari perilaku destruktor std::thread sangat sederhana:

  1. Pengguna tidak mengatakan apa yang harus dilakukan (yaitu: tidak menelepon join atau detach).
  2. Tidak ada jawaban yang jelas yang benar.

Anda mengklaim bahwa hanya melakukan detach adalah solusi yang tepat. Tapi kenapa benar? Detasemen adalah hal yang sangat tidak aman untuk dilakukan karena Anda kehilangan kemampuan untuk join dengan utas lagi.

Ada juga masalah RAII. Pengecualian dapat menyebabkan beberapa objek thread dihancurkan secara tidak sengaja. Jika itu terjadi, dan perilaku defaultnya adalah melepaskan, apakah program Anda masih dalam status fungsional? Bagaimana jika sisa program Anda mengharapkan join utas tersebut, dan sekarang itu tidak mungkin?

4
Nicol Bolas 24 Desember 2020, 04:24

Kehilangan utas itu buruk; shutdown program Anda menjadi sangat macet di hampir setiap platform (maksud saya Anda bisa beruntung ...).

Utas yang terlepas tidak dapat hilang melalui futures "siapkan saat utas keluar". Tetapi memanggil detach() tanpa mengatur cara untuk menyinkronkan akhir utas Anda agar terjadi sebelum akhir main() berarti perilaku program Anda mungkin menjadi tidak terdefinisi oleh standar C++ (kebanyakan kode di sebagian besar utas penuh dengan kode yang tidak dapat dijalankan dengan aman setelah ujung utama, jadi utas yang hidup setelah akhir main() bukanlah ide yang baik; tanpa bergabung atau setara, ada "perlombaan" antara penyelesaian utas dan main() melakukan jadi, tidak peduli berapa banyak panggilan "tidur" yang Anda buat. Adanya perlombaan seperti itu biasanya cukup untuk membuat C++ secara formal menghapus spesifikasi perilaku program Anda).

Gagasan bahwa "Saya harus melepaskan diri" adalah salah. Gagasan bahwa utas harus default ke detach() adalah gila.

Default ke join() adalah posisi yang lebih masuk akal daripada detach(). Tapi bergabung dengan bisa melempar. Melempar destruktor itu buruk, karena mereka dievaluasi selama lemparan itu sendiri, dan jika kode yang terlibat dalam pelepasan selama lemparan pada gilirannya melempar maka program berakhir. Terlebih lagi, jalur pengecualian itu, jika tidak diperiksa, kemungkinan mengandung kondisi kebuntuan, karena Anda mungkin setengah jalan melalui jabat tangan dengan utas lain dan tidak tahu untuk menutup sendiri.

std::thread bukan primitif threading aman yang ramah pengguna; membuat utas ramah pengguna jauh di luar jangkauannya. Primitif threading aman yang ramah pengguna dapat dibangun di atasnya. Ini adalah satu langkah dihapus dari pthread mentah. Sebagai contoh perpustakaan seperti IPP atau sejenisnya. Apa yang dilakukannya adalah membuat penulisan kode berulir mungkin dalam C++ tanpa ekstensi khusus platform.

Dengan mengakhiri saat dihancurkan, kami memberikan umpan balik kepada programmer selama fase desain bahwa mereka harus menangani masalah dengan cerdas, dan tidak mengabaikannya. Ini membuatnya sedikit lebih sulit untuk digunakan, tetapi 99,9% kesulitan penggunaan utas yang tepat tidak memanggil join().

Pengurutan sulit dilakukan dengan benar. Itu juga cenderung biasanya-bekerja-ok ketika Anda salah; kemudian terkunci atau crash jarang, dan hanya pada beberapa sistem pengguna lain dengan simbol dilucuti. Anda tidak dapat mengandalkan "Saya mencobanya dan berhasil" setelah Anda menambahkan threading ke suatu program. Anda bahkan biasanya tidak dapat mengandalkan "kode ini benar secara lokal", karena sebagian besar desain konkurensi tidak menyusun - tiga subprogram "benar" berpasangan dapat, bila digabungkan, menjadi salah.

Perpustakaan seperti TBB atau sejenisnya, atau gulung sendiri, dapat sedikit mengurangi masalah ini. Seperti keadaan yang tidak dapat diubah dan operasi fungsional. Atau banyak hal desain ketat lainnya. Semuanya berakhir dengan menulis kerangka kerja di atas sesuatu yang serendah std::thread.

1
Yakk - Adam Nevraumont 24 Desember 2020, 04:56

Ini setengah jawaban, hanya berfokus pada "mengapa tidak dilepas".

std::thread mewakili utas eksekusi. Mengikuti paradigma RAII, konstruktor membuat utas eksekusi, dan destruktor menghancurkannya (dengan peringatan yang sesuai untuk objek "kosong" yang saat ini tidak mewakili utas eksekusi). Melepaskan dari utas tidak mengakhiri utas eksekusi, sehingga tidak cocok secara konseptual untuk destruktor.

Bergabung dengan utas menunggu akhir utas eksekusi, sementara penghentian memaksa akhir utas eksekusi. Salah satu dari pendekatan ini adalah kecocokan konseptual dengan destruktor std::thread. Saya akan menyerahkannya kepada orang lain untuk mendiskusikan mengapa satu opsi dipilih di atas yang lain. (Versi singkatnya adalah bahwa ada banyak perdebatan; tidak ada pilihan yang tidak diragukan lagi lebih baik dari yang lain, namun keputusan harus dibuat.)

Jawaban yang lebih baik telah diberikan oleh orang lain. Saya menawarkan jawaban ini untuk mereka yang mencari sesuatu yang singkat dan konseptual.

1
JaMiT 24 Desember 2020, 05:35