Salah satu pengguna kompiler Visual C ++ memberikan contoh kode berikut dan bertanya mengapa perulangannya dengan kondisi dijalankan tanpa akhir, meskipun pada beberapa titik kondisinya harus berhenti dan siklus harus diakhiri:
#include <windows.h> int x = 0, y = 1; int* ptr; DWORD CALLBACK ThreadProc(void*) { Sleep(1000); ptr = &y; return 0; } int main(int, char**) { ptr = &x; // starts out pointing to x DWORD id; HANDLE hThread = CreateThread(nullptr, 0, ThreadProc, 0, &id); // , ptr // while (*ptr == 0) { } return 0; }
Bagi mereka yang tidak terbiasa dengan fitur-fitur khusus untuk platform Windows, berikut ini adalah yang setara dengan C ++ murni:
#include <chrono> #include <thread> int x = 0, y = 1; int* ptr = &x; void ThreadProc() { std::this_thread::sleep_for(std::chrono::seconds(1)); ptr = &y; } int main(int, char**) { ptr = &x; // starts out pointing to x std::thread thread(ThreadProc); // , ptr // while (*ptr == 0) { } return 0; }
Selanjutnya, pengguna membawa pemahamannya tentang program:
Loop bersyarat telah diubah menjadi tak terhingga oleh kompiler. Saya melihat ini dari kode assembler yang dihasilkan, yang pernah memuat nilai pointer ptr ke dalam register (pada awal loop), dan kemudian membandingkan nilai register ini dengan nol pada setiap iterasi. Karena pemuatan ulang nilai dari ptr tidak pernah terjadi lagi, siklus tidak pernah berakhir.
Saya mengerti bahwa mendeklarasikan ptr sebagai "volatile int *" harus menyebabkan kompiler untuk menjatuhkan optimisasi dan membaca nilai ptr pada setiap iterasi dari loop, yang akan memperbaiki masalah. Tetapi saya masih ingin tahu mengapa kompiler tidak cukup pintar untuk melakukan hal-hal seperti itu secara otomatis? Jelas, variabel global yang digunakan dalam dua utas yang berbeda dapat diubah, yang artinya tidak bisa hanya di-cache dalam register. Mengapa kompiler tidak dapat segera menghasilkan kode yang benar?
Sebelum menjawab pertanyaan ini, mari kita mulai dengan sedikit nit-picking: "volatile int * ptr" tidak mendeklarasikan variabel ptr sebagai "pointer yang dilarang mengoptimalkannya". Ini adalah "penunjuk normal ke variabel yang dilarang mengoptimalkannya." Apa yang dipikirkan oleh penulis pertanyaan di atas dinyatakan sebagai "int * volatile ptr".
Sekarang kembali ke pertanyaan utama. Apa yang sedang terjadi di sini?
Bahkan pandangan sekilas pada kode akan memberi tahu kita bahwa tidak ada variabel seperti std :: atomic, atau penggunaan std :: memory_order (baik eksplisit atau implisit). Ini berarti bahwa setiap upaya untuk mengakses ptr atau * ptr dari dua aliran berbeda mengarah ke perilaku yang tidak ditentukan. Secara intuitif, Anda dapat memikirkannya seperti ini: “Kompilator mengoptimalkan setiap utas seolah-olah itu berjalan sendiri dalam program. Satu-satunya titik di mana kompiler HARUS berpikir tentang mengakses data dari aliran yang berbeda menggunakan std :: atomic atau std :: memory_order. "
Ini menjelaskan mengapa program tidak berperilaku seperti yang diharapkan oleh programmer. Dari saat Anda mengizinkan perilaku yang tidak jelas - sama sekali tidak ada yang dapat dijamin.
Tapi oke, mari kita pikirkan bagian kedua dari pertanyaannya: mengapa kompiler tidak cukup pintar untuk mengenali situasi ini dan secara otomatis mematikan optimasi dengan memasukkan nilai pointer ke dalam register? Nah, kompiler secara otomatis menerapkan semua kemungkinan dan tidak bertentangan dengan standar optimisasi. Akan aneh untuk meminta dia untuk dapat membaca pikiran seorang programmer dan mematikan beberapa optimasi yang tidak bertentangan dengan standar, yang, mungkin, menurut programmer harus mengubah logika program menjadi lebih baik. “Oh, bagaimana jika siklus ini mengharapkan perubahan nilai variabel global di utas lain, meskipun belum diumumkan secara eksplisit? Saya akan melakukannya seratus kali untuk memperlambatnya agar siap untuk situasi ini! " Haruskah begitu? Hampir tidak.
Tetapi misalkan kita menambahkan aturan ke kompiler seperti "Jika optimasi telah menyebabkan munculnya loop tak terbatas, maka Anda perlu membatalkannya dan mengumpulkan kode tanpa optimasi." Atau bahkan seperti ini: "Secara berturut-turut batalkan optimisasi individu hingga hasilnya adalah loop yang tidak terbatas." Selain kejutan luar biasa yang akan terjadi, apakah akan memberi manfaat sama sekali?
Ya, dalam kasus teoretis ini kita tidak akan mendapatkan loop tak terbatas. Ini akan terganggu jika aliran lain menulis nilai bukan nol ke * ptr. Ini juga akan terganggu jika utas lain menulis nilai bukan nol ke variabel x. Menjadi tidak jelas seberapa dalam analisis ketergantungan harus dilakukan untuk “menangkap” semua kasus yang dapat mempengaruhi situasi. Karena kompiler tidak benar-benar meluncurkan program yang dibuat dan tidak menganalisis perilakunya pada saat runtime, satu-satunya jalan keluar adalah dengan mengasumsikan bahwa tidak ada panggilan ke variabel global, petunjuk dan tautan yang dapat dioptimalkan sama sekali.
int limit; void do_something() { ... if (value > limit) value = limit;
Ini sepenuhnya bertentangan dengan semangat C ++. Standar bahasa mengatakan bahwa jika Anda memodifikasi variabel dan berharap untuk melihat modifikasi ini di utas lain, Anda harus secara eksplisit mengatakan ini: menggunakan operasi atom atau mengatur akses ke memori (biasanya menggunakan objek sinkronisasi).
Jadi tolong lakukan saja.