20 kesalahan teratas saat bekerja dengan multithreading di C ++ dan cara untuk menghindarinya

Halo, Habr! Saya membawa kepada Anda terjemahan dari artikel “20 kesalahan multithreading C ++ teratas dan bagaimana cara menghindarinya” oleh Deb Haldar.


Adegan dari film "The Loop of Time" (2012)

Multithreading adalah salah satu bidang yang paling sulit dalam pemrograman, terutama di C ++. Selama bertahun-tahun pembangunan, saya membuat banyak kesalahan. Untungnya, sebagian besar dari mereka diidentifikasi oleh kode ulasan dan pengujian. Namun demikian, beberapa entah bagaimana tergelincir pada yang produktif, dan kami harus mengedit sistem operasi, yang selalu mahal.

Pada artikel ini, saya mencoba mengategorikan semua kesalahan yang saya tahu dengan solusi yang mungkin. Jika Anda mengetahui adanya jebakan lain, atau memiliki saran untuk menyelesaikan kesalahan yang dijelaskan, silakan tinggalkan komentar Anda di bawah artikel.

Kesalahan # 1: Jangan gunakan join () untuk menunggu utas latar belakang sebelum keluar dari aplikasi


Jika Anda lupa untuk bergabung dengan aliran ( gabung () ) atau lepaskan sematan ( lepaskan () ) (buat itu tidak dapat digabungkan) sebelum program berakhir, ini akan menyebabkan crash. (Terjemahan akan berisi kata-kata join dalam konteks join () dan detach dalam konteks detach () , meskipun ini tidak sepenuhnya benar. Faktanya, join () adalah titik di mana satu utas eksekusi menunggu penyelesaian yang lain, dan tidak ada penggabungan atau penggabungan utas yang terjadi [penerjemah komentar]).

Pada contoh di bawah ini, kami lupa mengeksekusi join () dari utas t1 di utas utama:

#include "stdafx.h"
#include <iostream>
#include <thread>

using namespace std ;

void LaunchRocket ( )
{
cout << "Launching Rocket" << endl ;
}
int main ( )
{
thread t1 ( LaunchRocket ) ;
//t1.join(); // join-
return 0 ;
}


Kenapa programnya crash ?! Karena pada akhir fungsi main () , variabel t1 keluar dari ruang lingkup dan pemanggil thread dipanggil. Destuktor memeriksa apakah utas t1 dapat digabung . Thread dapat digabung jika belum dilepas. Dalam hal ini, std :: terminate disebut dalam destruktornya. Inilah yang, sebagai contoh, kompiler MSVC ++ tidak.

~thread ( ) _NOEXCEPT
{ // clean up
if ( joinable ( ) )
XSTD terminate ( ) ;
}


Ada dua cara untuk memperbaiki masalah, tergantung pada tugas:

1. Panggil join () dari utas t1 di utas utama:

int main ( )
{
thread t1 ( LaunchRocket ) ;
t1. join ( ) ; // join t1,
return 0 ;
}


2. Lepaskan aliran t1 dari arus utama, biarkan aliran berfungsi sebagai aliran "yang di-demonisasi":

int main ( )
{
thread t1 ( LaunchRocket ) ;
t1. detach ( ) ; // t1
return 0 ;
}


Kesalahan # 2: Mencoba melampirkan utas yang sebelumnya terlepas


Jika pada titik tertentu dalam program kerja Anda memiliki aliran pelepasan , Anda tidak dapat melampirkannya kembali ke aliran utama. Ini adalah kesalahan yang sangat jelas. Masalahnya adalah Anda dapat membatalkan sematan aliran, dan kemudian menulis beberapa ratus baris kode dan mencoba memasangnya kembali. Lagi pula, siapa yang ingat bahwa ia menulis 300 baris kembali, kan?

Masalahnya adalah ini tidak akan menyebabkan kesalahan kompilasi, sebaliknya program akan macet saat startup. Sebagai contoh:

#include "stdafx.h"
#include <iostream>
#include <thread>

using namespace std ;

void LaunchRocket ( )
{
cout << "Launching Rocket" << endl ;
}

int main ( )
{
thread t1 ( LaunchRocket ) ;
t1. detach ( ) ;
//..... 100 -
t1. join ( ) ; // CRASH !!!
return 0 ;
}


Solusinya adalah selalu memeriksa utas yang dapat digabung () sebelum mencoba melampirkannya ke utas panggilan.

int main ( )
{
thread t1 ( LaunchRocket ) ;
t1. detach ( ) ;
//..... 100 -

if ( t1. joinable ( ) )
{
t1. join ( ) ;
}

return 0 ;
}


Kesalahan # 3: Kesalahpahaman bahwa std :: thread :: join () memblokir panggilan thread eksekusi


Dalam aplikasi nyata, Anda sering harus memisahkan operasi "lama" dari pemrosesan jaringan I / O atau menunggu pengguna mengklik tombol, dll. Panggilan untuk bergabung () untuk alur kerja seperti itu (misalnya, utas render UI) dapat menyebabkan antarmuka pengguna hang. Ada metode implementasi yang lebih cocok.

Misalnya, dalam aplikasi GUI, utas pekerja, setelah selesai, dapat mengirim pesan ke utas UI. Aliran UI memiliki loop pemrosesan acara sendiri seperti: menggerakkan mouse, menekan tombol, dll. Loop ini juga dapat menerima pesan dari utas pekerja dan meresponsnya tanpa harus memanggil metode blocking join () .

Untuk alasan ini, hampir semua interaksi pengguna di platform WinRT dari Microsoft dibuat tidak sinkron, dan alternatif sinkron tidak tersedia. Keputusan ini dibuat untuk memastikan bahwa pengembang akan menggunakan API yang memberikan pengalaman pengguna akhir sebaik mungkin. Anda dapat merujuk ke manual “ Modern C ++ dan Windows Store Apps ” untuk informasi lebih lanjut tentang topik ini.

Kesalahan # 4: Mengasumsikan bahwa argumen fungsi aliran diteruskan oleh referensi secara default


Argumen untuk fungsi aliran diteruskan oleh nilai secara default. Jika Anda perlu membuat perubahan pada argumen yang diteruskan, Anda harus meneruskannya dengan referensi menggunakan fungsi std :: ref () .

Di bawah spoiler, contoh-contoh dari artikel C ++ 11 lain Tutorial Multithreading melalui T&J - Dasar-dasar Manajemen Thread (Deb Haldar) , menggambarkan parameter yang lewat [kira-kira. penerjemah].

lebih detail:
Saat mengeksekusi kode:
#include "stdafx.h"
#include <string>
#include <thread>
#include <iostream>
#include <functional>

using namespace std ;

void ChangeCurrentMissileTarget ( string & targetCity )
{
targetCity = "Metropolis" ;
cout << " Changing The Target City To " << targetCity << endl ;
}

int main ( )
{
string targetCity = "Star City" ;
thread t1 ( ChangeCurrentMissileTarget, targetCity ) ;
t1. join ( ) ;
cout << "Current Target City is " << targetCity << endl ;

return 0 ;
}


Ini akan ditampilkan di terminal:
Changing The Target City To Metropolis
Current Target City is Star City


Seperti yang Anda lihat, nilai variabel targetCity yang diterima oleh fungsi yang dipanggil dalam aliran dengan referensi tidak berubah.

Tulis ulang kode menggunakan std :: ref () untuk memberikan argumen:

#include "stdafx.h"
#include <string>
#include <thread>
#include <iostream>
#include <functional>

using namespace std ;

void ChangeCurrentMissileTarget ( string & targetCity )
{
targetCity = "Metropolis" ;
cout << " Changing The Target City To " << targetCity << endl ;
}

int main ( )
{
string targetCity = "Star City" ;
thread t1 ( ChangeCurrentMissileTarget, std :: ref ( targetCity ) ) ;
t1. join ( ) ;
cout << "Current Target City is " << targetCity << endl ;

return 0 ;
}


Ini akan menampilkan:
Changing The Target City To Metropolis
Current Target City is Metropolis


Perubahan yang dilakukan pada utas baru akan memengaruhi nilai variabel targetCity yang dideklarasikan dan diinisialisasi dalam fungsi utama .

Kesalahan # 5: Jangan melindungi data dan sumber daya bersama dengan bagian kritis (misalnya, sebuah mutex)


Dalam lingkungan multi-utas, biasanya lebih dari satu utas bersaing untuk sumber daya dan data bersama. Seringkali ini mengarah ke keadaan yang tidak pasti untuk sumber daya dan data, kecuali ketika akses ke mereka dilindungi oleh beberapa mekanisme yang memungkinkan hanya satu utas eksekusi untuk melakukan operasi pada mereka kapan saja.

Pada contoh di bawah ini, std :: cout adalah sumber daya bersama yang bekerja dengan 6 utas (t1-t5 + main).

#include "stdafx.h"
#include <iostream>
#include <string>
#include <thread>
#include <mutex>

using namespace std ;

std :: mutex mu ;

void CallHome ( string message )
{
cout << "Thread " << this_thread :: get_id ( ) << " says " << message << endl ;
}

int main ( )
{
thread t1 ( CallHome, "Hello from Jupiter" ) ;
thread t2 ( CallHome, "Hello from Pluto" ) ;
thread t3 ( CallHome, "Hello from Moon" ) ;

CallHome ( "Hello from Main/Earth" ) ;

thread t4 ( CallHome, "Hello from Uranus" ) ;
thread t5 ( CallHome, "Hello from Neptune" ) ;

t1. join ( ) ;
t2. join ( ) ;
t3. join ( ) ;
t4. join ( ) ;
t5. join ( ) ;

return 0 ;
}


Jika kami menjalankan program ini, kami mendapatkan kesimpulan:

Thread 0x1000fb5c0 says Hello from Main/Earth
Thread Thread Thread 0x700005bd20000x700005b4f000 says says Thread Thread Hello from Pluto0x700005c55000Hello from Jupiter says 0x700005d5b000Hello from Moon
0x700005cd8000 says says Hello from Uranus

Hello from Neptune


Ini karena lima utas secara bersamaan mengakses aliran output dalam urutan acak. Untuk membuat kesimpulan lebih spesifik, Anda harus melindungi akses ke sumber daya bersama menggunakan std :: mutex . Cukup ubah fungsi CallHome () sehingga ia menangkap mutex sebelum menggunakan std :: cout dan membebaskannya setelah itu.

void CallHome ( string message )
{
mu. lock ( ) ;
cout << "Thread " << this_thread :: get_id ( ) << " says " << message << endl ;
mu. unlock ( ) ;
}


Kesalahan # 6: Lupa melepaskan kunci setelah keluar dari bagian kritis


Di paragraf sebelumnya, Anda melihat cara melindungi bagian penting dengan mutex. Namun, memanggil metode kunci () dan membuka kunci () langsung pada mutex bukan pilihan yang disukai karena Anda mungkin lupa memberikan kunci yang ditahan. Apa yang akan terjadi selanjutnya? Semua utas lainnya yang sedang menunggu rilis sumber daya akan diblokir tanpa batas dan program dapat hang.

Dalam contoh sintetis kami, jika Anda lupa membuka kunci mutex di panggilan fungsi CallHome () , pesan pertama dari stream t1 akan menjadi output ke stream standar dan program akan macet. Ini disebabkan oleh fakta bahwa utas t1 menerima kunci mutex, dan utas lainnya menunggu kunci ini dilepaskan.

void CallHome ( string message )
{
mu. lock ( ) ;
cout << "Thread " << this_thread :: get_id ( ) << " says " << message << endl ;
//mu.unlock();
}


Berikut ini adalah output dari kode ini - program macet, menampilkan satu-satunya pesan di terminal, dan tidak berakhir:

Thread 0x700005986000 says Hello from Pluto



Kesalahan seperti itu sering terjadi, itulah sebabnya mengapa tidak diinginkan untuk menggunakan metode kunci () / membuka kunci () langsung dari mutex. Sebagai gantinya, gunakan kelas template std :: lock_guard , yang menggunakan idiom RAII untuk mengontrol masa pakai kunci. Ketika objek lock_guard dibuat, ia mencoba untuk mengambil alih mutex. Ketika program meninggalkan ruang lingkup objek lock_guard , destructor dipanggil, yang membebaskan mutex.

Kami menulis ulang fungsi CallHome () menggunakan objek std :: lock_guard :

void CallHome ( string message )
{
std :: lock_guard < std :: mutex > lock ( mu ) ; //
cout << "Thread " << this_thread :: get_id ( ) << " says " << message << endl ;
} // lock_guard


Kesalahan # 7: Buat ukuran bagian kritis lebih besar dari yang diperlukan


Ketika satu utas dieksekusi di dalam bagian kritis, semua yang lain yang mencoba memasukkannya pada dasarnya diblokir. Kita harus menyimpan instruksi sesedikit mungkin di bagian kritis. Sebagai ilustrasi, contoh kode buruk dengan bagian kritis besar diberikan:

void CallHome ( string message )
{
std :: lock_guard < std :: mutex > lock ( mu ) ; // , std::cout

ReadFifyThousandRecords ( ) ;

cout << "Thread " << this_thread :: get_id ( ) << " says " << message << endl ;

} // lock_guard mu


Metode ReadFifyThousandRecords () tidak mengubah data. Tidak ada alasan untuk menjalankannya di bawah kunci. Jika metode ini dijalankan selama 10 detik, membaca 50 ribu baris dari database, semua utas lainnya akan diblokir untuk seluruh periode ini secara tidak perlu. Ini serius dapat mempengaruhi kinerja program.

Solusi yang benar adalah dengan tetap di bagian kritis hanya bekerja dengan std :: cout .

void CallHome ( string message )
{
ReadFifyThousandRecords ( ) ; // ..
std :: lock_guard < std :: mutex > lock ( mu ) ; // , std::cout
cout << "Thread " << this_thread :: get_id ( ) << " says " << message << endl ;

} // lock_guard mu


Kesalahan # 8: Mengambil banyak kunci dalam urutan berbeda



Ini adalah salah satu penyebab kebuntuan yang paling umum, situasi di mana utas diblokir tanpa batas karena menunggu akses ke sumber daya diblokir oleh utas lain. Pertimbangkan sebuah contoh:

aliran 1aliran 2
kunci Akunci B
// ... beberapa operasi// ... beberapa operasi
kunci Bkunci A
// ... beberapa operasi lain// ... beberapa operasi lain
membuka kunci Bmembuka kunci A
membuka kunci Amembuka kunci B

Suatu situasi dapat muncul di mana utas 1 akan mencoba untuk menangkap kunci B dan diblokir karena utas 2 telah menangkapnya. Pada saat yang sama, utas kedua mencoba menangkap kunci A, tetapi tidak dapat melakukan ini, karena ia ditangkap oleh utas pertama. Thread 1 tidak dapat melepaskan kunci A sampai mengunci B, dll. Dengan kata lain, program macet.

Contoh kode ini akan membantu Anda mereproduksi kebuntuan :

#include "stdafx.h"
#include <iostream>
#include <string>
#include <thread>
#include <mutex>

using namespace std ;

std :: mutex muA ;
std :: mutex muB ;

void CallHome_Th1 ( string message )
{
muA. lock ( ) ;
// -
std :: this_thread :: sleep_for ( std :: chrono :: milliseconds ( 100 ) ) ;
muB. lock ( ) ;

cout << "Thread " << this_thread :: get_id ( ) << " says " << message << endl ;

muB. unlock ( ) ;
muA. unlock ( ) ;
}

void CallHome_Th2 ( string message )
{
muB. lock ( ) ;
// -
std :: this_thread :: sleep_for ( std :: chrono :: milliseconds ( 100 ) ) ;
muA. lock ( ) ;

cout << "Thread " << this_thread :: get_id ( ) << " says " << message << endl ;

muA. unlock ( ) ;
muB. unlock ( ) ;
}

int main ( )
{
thread t1 ( CallHome_Th1, "Hello from Jupiter" ) ;
thread t2 ( CallHome_Th2, "Hello from Pluto" ) ;

t1. join ( ) ;
t2. join ( ) ;

return 0 ;
}


Jika Anda menjalankan kode ini, itu akan macet. Jika Anda masuk lebih dalam ke debugger di jendela utas, Anda akan melihat bahwa utas pertama (dipanggil dari CallHome_Th1 () ) sedang mencoba untuk mendapatkan kunci B mutex, sementara utas 2 (dipanggil dari CallHome_Th2 () ) mencoba untuk memblokir mutex A. Tidak ada utas tidak bisa berhasil, yang mengarah ke jalan buntu!


(gambar dapat diklik)

Apa yang bisa kamu lakukan? Solusi terbaik adalah merestrukturisasi kode sehingga mengunci kunci terjadi dalam urutan yang sama setiap kali.

Bergantung pada situasinya, Anda dapat menggunakan strategi lain:

1. Gunakan kelas wrapper std :: scoped_lock untuk bersama-sama menangkap beberapa kunci:

std :: scoped_lock lock { muA, muB } ;

2. Gunakan kelas std :: timed_mutex , di mana Anda dapat menentukan batas waktu, setelah itu kunci akan dilepaskan jika sumber daya belum tersedia.

std :: timed_mutex m ;

void DoSome ( ) {
std :: chrono :: milliseconds timeout ( 100 ) ;

while ( true ) {
if ( m. try_lock_for ( timeout ) ) {
std :: cout << std :: this_thread :: get_id ( ) << ": acquire mutex successfully" << std :: endl ;
m. unlock ( ) ;
} else {
std :: cout << std :: this_thread :: get_id ( ) << ": can't acquire mutex, do something else" << std :: endl ;
}
}
}


Kesalahan # 9: Mencoba meraih std :: mutex lock dua kali


Mencoba mengunci kunci dua kali akan menghasilkan perilaku yang tidak terdefinisi. Di sebagian besar implementasi debug, ini akan macet. Misalnya, dalam kode di bawah ini, LaunchRocket () akan mengunci mutex dan kemudian memanggil StartThruster () . Yang aneh, dalam kode di atas Anda tidak akan menemukan masalah ini selama operasi normal program, masalah hanya terjadi ketika pengecualian dilemparkan, yang disertai dengan perilaku yang tidak ditentukan atau program berakhir secara tidak normal.

#include "stdafx.h"
#include <iostream>
#include <thread>
#include <mutex>

std :: mutex mu ;

static int counter = 0 ;

void StartThruster ( )
{
try
{
// -
}
catch ( ... )
{
std :: lock_guard < std :: mutex > lock ( mu ) ;
std :: cout << "Launching rocket" << std :: endl ;
}
}

void LaunchRocket ( )
{
std :: lock_guard < std :: mutex > lock ( mu ) ;
counter ++ ;
StartThruster ( ) ;
}

int main ( )
{
std :: thread t1 ( LaunchRocket ) ;
t1. join ( ) ;
return 0 ;
}


Untuk mengatasi masalah ini, Anda harus memperbaiki kode sedemikian rupa untuk mencegah pengambilan kembali kunci yang diterima sebelumnya. Anda dapat menggunakan std :: recursive_mutex sebagai solusi kruk, tetapi solusi seperti itu hampir selalu menunjukkan arsitektur program yang buruk.

Kesalahan # 10: Gunakan mutex ketika std :: jenis atom sudah cukup



Saat Anda perlu mengubah tipe data sederhana, seperti nilai Boolean atau penghitung bilangan bulat, menggunakan std: atomic umumnya akan memberikan kinerja yang lebih baik daripada menggunakan mutex.

Misalnya, alih-alih menggunakan konstruk berikut:

int counter ;
...
mu. lock ( ) ;
counter ++ ;
mu. unlock ( ) ;


Lebih baik mendeklarasikan variabel sebagai std :: atomic :

std :: atomic < int > counter ;
...
counter ++ ;


Untuk perbandingan terperinci tentang mutex dan atom, lihat Perbandingan: Pemrograman tanpa kunci dengan atom dalam C ++ 11 vs. mutex dan kunci RW »

Kesalahan # 11: Membuat dan menghancurkan sejumlah besar utas secara langsung, alih-alih menggunakan kumpulan utas gratis


Membuat dan menghancurkan utas adalah operasi yang mahal dalam hal waktu prosesor. Bayangkan sebuah upaya untuk membuat aliran saat sistem melakukan operasi intensif secara komputasi, misalnya, merender grafik atau menghitung fisika gim. Pendekatan yang sering digunakan untuk tugas-tugas tersebut adalah untuk membuat kumpulan untaian pra-dialokasikan yang dapat menangani tugas-tugas rutin, seperti menulis ke disk atau mengirim data melalui jaringan sepanjang seluruh siklus hidup proses.

Keuntungan lain dari kumpulan utas dibandingkan dengan pemijahan dan penghancuran utas sendiri adalah bahwa Anda tidak perlu khawatir tentang berlebihnya thread (situasi di mana jumlah utas melebihi jumlah inti yang tersedia dan sebagian besar waktu prosesor dihabiskan untuk mengubah konteks [kira-kira. penerjemah]). Ini dapat mempengaruhi kinerja sistem.

Selain itu, penggunaan kumpulan menyelamatkan kita dari kepedihan mengelola siklus hidup utas, yang pada akhirnya diterjemahkan menjadi kode yang lebih ringkas dengan lebih sedikit kesalahan.

Dua perpustakaan paling populer yang mengimplementasikan kumpulan thread adalah Intel Thread Building Blocks (TBB) dan Microsoft Parallel Patterns Library (PPL) .

Kesalahan No. 12: Jangan menangani pengecualian yang terjadi di utas latar belakang


Pengecualian yang dilemparkan dalam satu utas tidak dapat ditangani pada utas lainnya. Mari kita bayangkan bahwa kita memiliki fungsi yang melempar pengecualian. Jika kami menjalankan fungsi ini dalam utas terpisah yang bercabang dari utas utama eksekusi, dan berharap bahwa kami akan menangkap pengecualian yang dilemparkan dari utas tambahan, maka ini tidak akan berfungsi. Pertimbangkan sebuah contoh:

#include "stdafx.h"
#include<iostream>
#include<thread>
#include<exception>
#include<stdexcept>

static std :: exception_ptr teptr = nullptr ;

void LaunchRocket ( )
{
throw std :: runtime_error ( "Catch me in MAIN" ) ;
}

int main ( )
{
try
{
std :: thread t1 ( LaunchRocket ) ;
t1. join ( ) ;
}
catch ( const std :: exception & ex )
{
std :: cout << "Thread exited with exception: " << ex. what ( ) << " \n " ;
}

return 0 ;
}


Ketika program ini dieksekusi, ia akan crash, namun, catch block di fungsi main () tidak akan dieksekusi dan tidak akan menangani pengecualian yang dilemparkan ke thread t1.

Solusi untuk masalah ini adalah dengan menggunakan fitur-fitur dari C ++ 11: std :: exception_ptr digunakan untuk menangani pengecualian yang dilemparkan di utas latar belakang. Berikut langkah-langkah yang perlu Anda ambil:

  • Buat instance global dari std :: exception_ptr class diinisialisasi ke nullptr
  • Di dalam fungsi yang berjalan di utas terpisah, menangani semua pengecualian dan mengatur nilai std :: current_exception () dari variabel global std :: exception_ptr dideklarasikan pada langkah sebelumnya
  • Periksa nilai variabel global di dalam utas utama
  • Jika nilainya diatur, gunakan fungsi std :: rethrow_exception (exception_ptr p) untuk berulang kali memanggil pengecualian yang sebelumnya ditangkap, meneruskannya dengan referensi sebagai parameter

Memanggil pengecualian dengan referensi tidak terjadi di utas tempat ia dibuat, jadi fitur ini bagus untuk menangani pengecualian di utas berbeda.

Dalam kode di bawah ini, Anda dapat dengan aman menangani pengecualian yang dilemparkan ke utas latar belakang.

#include "stdafx.h"
#include<iostream>
#include<thread>
#include<exception>
#include<stdexcept>

static std :: exception_ptr globalExceptionPtr = nullptr ;

void LaunchRocket ( )
{
try
{
std :: this_thread :: sleep_for ( std :: chrono :: milliseconds ( 100 ) ) ;
throw std :: runtime_error ( "Catch me in MAIN" ) ;
}
catch ( ... )
{
//
globalExceptionPtr = std :: current_exception ( ) ;
}
}

int main ( )
{
std :: thread t1 ( LaunchRocket ) ;
t1. join ( ) ;

if ( globalExceptionPtr )
{
try
{
std :: rethrow_exception ( globalExceptionPtr ) ;
}
catch ( const std :: exception & ex )
{
std :: cout << "Thread exited with exception: " << ex. what ( ) << " \n " ;
}
}

return 0 ;
}


Kesalahan # 13: Gunakan utas untuk mensimulasikan operasi asinkron, alih-alih menggunakan std :: async


Jika Anda memerlukan kode untuk dieksekusi secara tidak sinkron, mis. tanpa memblokir utas eksekusi, pilihan terbaik adalah menggunakan std :: async () . Ini setara dengan membuat aliran dan meneruskan kode yang diperlukan untuk mengeksekusi dalam aliran ini melalui pointer ke fungsi atau parameter dalam bentuk fungsi lambda. Namun, dalam kasus terakhir, Anda perlu memantau kreasi, lampiran / pelepasan utas ini, serta penanganan semua pengecualian yang mungkin terjadi pada utas ini. Jika Anda menggunakan std :: async () , Anda membebaskan diri dari masalah ini dan juga secara tajam mengurangi peluang Anda untuk menemui jalan buntu .

Keuntungan signifikan lainnya dari menggunakan std :: async adalah kemampuan untuk mendapatkan hasil dari operasi asinkron kembali ke utas panggilan menggunakan objek std :: future . Bayangkan bahwa kita memiliki fungsi ConjureMagic () yang mengembalikan int. Kita bisa memulai operasi asinkron, yang akan menetapkan nilai di masa depan ke objek di masa depan , ketika tugas selesai, dan kita bisa mengekstraksi hasil eksekusi dari objek ini dalam aliran eksekusi dari mana operasi dipanggil.

// future
std :: future asyncResult2 = std :: async ( & ConjureMagic ) ;

//... - future

// future
int v = asyncResult2. get ( ) ;


Mendapatkan hasilnya kembali dari utas berjalan ke pemanggil lebih rumit. Ada dua cara yang mungkin:

  1. Melewati referensi ke variabel output ke aliran yang akan menyimpan hasilnya.
  2. Menyimpan hasil dalam variabel bidang objek alur kerja, yang dapat dibaca segera setelah utas menyelesaikan eksekusi.

Kurt Guntheroth menemukan bahwa dalam hal kinerja, overhead untuk menciptakan aliran adalah 14 kali lipat menggunakan async .

Intinya: gunakan std :: async () secara default sampai Anda menemukan argumen kuat yang mendukung penggunaan std :: thread secara langsung.

Kesalahan No. 14: Jangan gunakan std :: launch :: async jika diperlukan sinkronisasi


Fungsi std :: async () bukan nama yang tepat, karena secara default mungkin tidak berjalan secara asinkron!

Ada dua kebijakan runtime std :: async :

  1. std :: launch :: async : fungsi yang diteruskan mulai dijalankan segera di utas terpisah
  2. std :: launch :: deferred : fungsi yang diteruskan tidak segera dimulai, peluncurannya ditunda sebelum panggilan get () atau wait () dilakukan pada objek std :: future , yang akan dikembalikan dari panggilan std :: async . Di tempat memanggil metode ini, fungsi akan dieksekusi secara serempak.

Ketika kita memanggil std :: async () dengan parameter default, itu dimulai dengan kombinasi dari dua parameter ini, yang pada kenyataannya mengarah pada perilaku yang tidak dapat diprediksi. Ada sejumlah kesulitan lain yang terkait dengan penggunaan std: async () dengan kebijakan startup default:

  • ketidakmampuan untuk memprediksi akses yang benar ke variabel aliran lokal
  • tugas asinkron mungkin tidak dimulai sama sekali karena fakta bahwa panggilan ke metode get () dan wait () mungkin tidak dipanggil selama eksekusi program
  • ketika digunakan dalam loop di mana kondisi keluar mengharapkan objek std :: future siap, loop ini mungkin tidak pernah berakhir, karena std :: masa depan dikembalikan oleh panggilan ke std :: async dapat mulai dalam keadaan ditangguhkan.

Untuk menghindari semua kesulitan ini, selalu panggil std :: async dengan kebijakan peluncuran std :: launch :: async .

Jangan lakukan ini:

// myFunction std::async
auto myFuture = std :: async ( myFunction ) ;


Sebaliknya, lakukan ini:

// myFunction
auto myFuture = std :: async ( std :: launch :: async , myFunction ) ;


Poin ini dipertimbangkan secara lebih rinci dalam buku oleh Scott Meyers "C ++ Efektif dan Modern".

Kesalahan # 15: Memanggil metode get () objek std :: future dalam blok kode yang waktu eksekusi-nya sangat kritis


Kode di bawah ini memproses hasil yang diperoleh dari objek std :: future dari operasi asinkron. Namun, loop sementara akan dikunci hingga operasi asinkron selesai (dalam kasus ini, selama 10 detik). Jika Anda ingin menggunakan loop ini untuk menampilkan informasi di layar, ini dapat menyebabkan penundaan yang tidak menyenangkan dalam rendering antarmuka pengguna.

#include "stdafx.h"
#include <future>
#include <iostream>

int main ( )
{
std :: future < int > myFuture = std :: async ( std :: launch :: async , [ ] ( )
{
std :: this_thread :: sleep_for ( std :: chrono :: seconds ( 10 ) ) ;
return 8 ;
} ) ;

//
while ( true )
{
//
std :: cout << "Rendering Data" << std :: endl ;
int val = myFuture. get ( ) ; // 10
// - Val
}

return 0 ;
}


Catatan : masalah lain dari kode di atas adalah bahwa ia mencoba mengakses objek std :: future untuk kedua kalinya, meskipun status objek std :: future diambil pada iterasi pertama dari loop dan tidak dapat diambil.

Solusi yang benar adalah dengan memeriksa validitas objek std :: future sebelum memanggil metode get () . Dengan demikian, kami tidak memblokir penyelesaian tugas asinkron dan tidak mencoba untuk menginterogasi objek std :: future yang sudah diekstraksi.

Cuplikan kode ini memungkinkan Anda untuk mencapai ini:

#include "stdafx.h"
#include <future>
#include <iostream>

int main ( )
{
std :: future < int > myFuture = std :: async ( std :: launch :: async , [ ] ( )
{
std :: this_thread :: sleep_for ( std :: chrono :: seconds ( 10 ) ) ;
return 8 ;
} ) ;

//
while ( true )
{
//
std :: cout << "Rendering Data" << std :: endl ;

if ( myFuture. valid ( ) )
{
int val = myFuture. get ( ) ; // 10

// - Val
}
}

return 0 ;
}


№16: , , , std::future::get()


Bayangkan kita memiliki fragmen kode berikut, menurut Anda apa hasil dari memanggil std :: future :: get () ? Jika Anda berasumsi bahwa program akan macet - Anda memang benar! Pengecualian yang dilemparkan dalam operasi asinkron dilemparkan hanya ketika metode get () dipanggil pada objek std :: future . Dan jika metode get () tidak dipanggil, maka pengecualian akan diabaikan dan dibuang ketika objek std :: future keluar dari ruang lingkup. Jika operasi asinkron Anda dapat melempar pengecualian, Anda harus selalu membungkus panggilan ke std :: future :: get () di blok coba / tangkap . Contoh bagaimana ini terlihat:

#include "stdafx.h"
#include <future>
#include <iostream>

int main ( )
{
std :: future < int > myFuture = std :: async ( std :: launch :: async , [ ] ( )
{
throw std :: runtime_error ( "Catch me in MAIN" ) ;
return 8 ;
} ) ;

if ( myFuture. valid ( ) )
{
int result = myFuture. get ( ) ;
}

return 0 ;
}








#include "stdafx.h"
#include <future>
#include <iostream>

int main ( )
{
std :: future < int > myFuture = std :: async ( std :: launch :: async , [ ] ( )
{
throw std :: runtime_error ( "Catch me in MAIN" ) ;
return 8 ;
} ) ;

if ( myFuture. valid ( ) )
{
try
{
int result = myFuture. get ( ) ;
}
catch ( const std :: runtime_error & e )
{
std :: cout << "Async task threw exception: " << e. what ( ) << std :: endl ;
}
}
return 0 ;
}


№17: std::async,


Meskipun std :: async () cukup dalam banyak kasus, ada beberapa situasi di mana Anda mungkin perlu kontrol yang hati-hati atas eksekusi kode Anda dalam aliran. Misalnya, jika Anda ingin mengikat utas tertentu ke inti prosesor tertentu dalam sistem multiprosesor (misalnya, Xbox).

Fragmen kode yang diberikan menetapkan pengikatan utas ke inti prosesor ke-5 dalam sistem. Hal ini dimungkinkan karena metode native_handle () keberatan std :: benang , dan transfer ke fungsi mengalir Win32 API . Ada banyak fitur lain yang disediakan melalui streaming Win32 API yang tidak tersedia di std :: thread atau std :: async () . Saat mengerjakan

#include "stdafx.h"
#include <windows.h>
#include <iostream>
#include <thread>

using namespace std ;

void LaunchRocket ( )
{
cout << "Launching Rocket" << endl ;
}

int main ( )
{
thread t1 ( LaunchRocket ) ;

DWORD result = :: SetThreadIdealProcessor ( t1. native_handle ( ) , 5 ) ;

t1. join ( ) ;

return 0 ;
}


std :: async (), fungsi platform dasar ini tidak tersedia, yang membuat metode ini tidak cocok untuk tugas yang lebih kompleks.

Alternatifnya adalah membuat std :: packaged_task dan memindahkannya ke utas eksekusi yang diinginkan setelah menyetel properti utas.

Kesalahan # 18: Membuat lebih banyak thread "running" daripada core tersedia


Dari sudut pandang arsitektur, aliran dapat diklasifikasikan ke dalam dua kelompok: "berlari" dan "menunggu".

Thread yang berjalan memanfaatkan 100% waktu prosesor dari kernel yang mereka jalankan. Ketika lebih dari satu thread berjalan dialokasikan ke satu inti, efisiensi penggunaan CPU turun. Kami tidak mendapatkan peningkatan kinerja jika kami menjalankan lebih dari satu utas berjalan pada satu inti prosesor - pada kenyataannya, kinerja turun karena beralih konteks tambahan.

Utas Menunggu hanya menggunakan beberapa siklus clock yang menjalankannya saat mereka menunggu peristiwa sistem atau I / O jaringan, dll. Dalam hal ini, sebagian besar waktu prosesor yang tersedia dari kernel tetap tidak digunakan. Satu utas tunggu dapat memproses data, sedangkan yang lainnya sedang menunggu peristiwa untuk dipicu - inilah mengapa menguntungkan untuk mendistribusikan beberapa utas tunggu ke satu inti. Menjadwalkan beberapa pending threads per core dapat memberikan kinerja program yang jauh lebih besar.

Jadi, bagaimana memahami berapa banyak thread yang berjalan yang didukung sistem? Gunakan metode std :: thread :: hardware_concurrency () . Fungsi ini biasanya mengembalikan jumlah inti prosesor, tetapi memperhitungkan inti yang berperilaku sebagai dua atau lebih inti logis karenahipertensi .

Anda harus menggunakan nilai yang diperoleh dari platform target untuk merencanakan jumlah maksimum utas yang berjalan secara bersamaan dari program Anda. Anda juga dapat menetapkan satu inti untuk semua utas tertunda, dan menggunakan sisa jumlah inti untuk menjalankan utas. Misalnya, dalam sistem quad-core, gunakan satu inti untuk SEMUA utas tertunda, dan untuk tiga core sisanya, tiga utas berjalan. Bergantung pada efisiensi penjadwal utas Anda, beberapa utas yang dapat dieksekusi Anda dapat mengalihkan konteks (karena kegagalan akses halaman, dll.), Membiarkan kernel tidak aktif untuk beberapa waktu. Jika Anda mengamati situasi ini selama pembuatan profil, Anda harus membuat jumlah utas yang dieksekusi sedikit lebih besar daripada jumlah inti, dan mengonfigurasi nilai ini untuk sistem Anda.

Kesalahan # 19: Menggunakan kata kunci yang mudah menguap untuk sinkronisasi


Kata kunci yang mudah menguap , sebelum menentukan jenis variabel, tidak membuat operasi pada atom atau utas variabel ini aman. Yang mungkin Anda inginkan adalah std :: atomic .

Lihat diskusi tentang stackoverflow untuk detail lebih lanjut.

Kesalahan # 20: Menggunakan Arsitektur Bebas Kunci kecuali benar-benar diperlukan


Ada sesuatu dalam kompleksitas yang disukai setiap insinyur. Membuat program bebas kunci terdengar sangat menggoda dibandingkan dengan mekanisme sinkronisasi biasa seperti mutex, variabel kondisional, asinkron, dll. Namun, setiap pengembang C ++ berpengalaman yang saya ajak bicara memiliki pendapat bahwa penggunaan pemrograman non-penguncian sebagai opsi awal adalah semacam optimisasi prematur yang dapat berjalan menyamping pada saat yang paling tidak tepat (pikirkan tentang kegagalan dalam sistem operasi ketika Anda tidak memiliki heap dump penuh!).

Dalam karir saya di C ++, hanya ada satu situasi yang mengharuskan eksekusi kode tanpa kunci, karena kami bekerja dalam sistem dengan sumber daya terbatas, di mana setiap transaksi dalam komponen kami harus tidak lebih dari 10 mikrodetik.

Sebelum berpikir untuk menerapkan pendekatan pengembangan tanpa memblokir, harap jawab tiga pertanyaan:

  • Sudahkah Anda mencoba merancang arsitektur sistem Anda sehingga tidak memerlukan mekanisme sinkronisasi? Sebagai aturan, sinkronisasi terbaik adalah kurangnya sinkronisasi.
  • Jika Anda membutuhkan sinkronisasi, sudahkah Anda membuat profil kode Anda untuk memahami karakteristik kinerja? Jika ya, sudahkah Anda mencoba mengoptimalkan kemacetan?
  • Bisakah Anda membuat skala secara horizontal, bukan skala secara vertikal?

Singkatnya, untuk pengembangan aplikasi normal, harap pertimbangkan pemrograman non-penguncian hanya ketika Anda telah kehabisan semua alternatif lain. Cara lain untuk melihat ini adalah bahwa jika Anda masih membuat beberapa 19 kesalahan di atas, Anda mungkin harus menjauh dari pemrograman tanpa memblokir.

[Dari. penerjemah: terima kasih banyak kepada vovo4K karena telah membantu saya mempersiapkan artikel ini.]

Source: https://habr.com/ru/post/id443406/


All Articles