Program multithreading terjebak dalam mode yang dioptimalkan tetapi berjalan secara normal di -O0

68

Saya menulis program multithreading sederhana sebagai berikut:

static bool finished = false;

int func()
{
    size_t i = 0;
    while (!finished)
        ++i;
    return i;
}

int main()
{
    auto result=std::async(std::launch::async, func);
    std::this_thread::sleep_for(std::chrono::seconds(1));
    finished=true;
    std::cout<<"result ="<<result.get();
    std::cout<<"\nmain thread id="<<std::this_thread::get_id()<<std::endl;
}

Ini berperilaku normal dalam mode debug di Visual studio atau -O0di gc c dan mencetak hasilnya setelah beberapa 1detik. Tetapi macet dan tidak mencetak apa pun dalam mode Rilis atau -O1 -O2 -O3.

sz ppeter
sumber
Komentar bukan untuk diskusi panjang; percakapan ini telah dipindahkan ke obrolan .
Samuel Liew

Jawaban:

100

Dua utas, mengakses variabel non-atomik, non-dijaga adalah UB Kekhawatiran ini finished. Anda dapat membuat finishedtipe std::atomic<bool>untuk memperbaikinya.

Perbaikan saya:

#include <iostream>
#include <future>
#include <atomic>

static std::atomic<bool> finished = false;

int func()
{
    size_t i = 0;
    while (!finished)
        ++i;
    return i;
}

int main()
{
    auto result=std::async(std::launch::async, func);
    std::this_thread::sleep_for(std::chrono::seconds(1));
    finished=true;
    std::cout<<"result ="<<result.get();
    std::cout<<"\nmain thread id="<<std::this_thread::get_id()<<std::endl;
}

Keluaran:

result =1023045342
main thread id=140147660588864

Demo langsung di coliru


Seseorang mungkin berpikir 'Ini bool- mungkin sedikit. Bagaimana ini bisa non-atom? ' (Saya lakukan ketika saya mulai dengan multi-threading sendiri.)

Tetapi perhatikan bahwa kekurangan air mata bukanlah satu-satunya hal yang terjadi std::atomic memberi Anda. Itu juga membuat akses baca + tulis bersamaan dari banyak utas didefinisikan dengan baik, menghentikan kompiler dari asumsi bahwa membaca ulang variabel akan selalu melihat nilai yang sama.

Membuat boolnon-atomic yang tidak dijaga dapat menyebabkan masalah tambahan:

  • Compiler mungkin memutuskan untuk mengoptimalkan variabel menjadi register atau bahkan beberapa akses CSE menjadi satu dan menambah beban keluar dari satu loop.
  • Variabel mungkin di-cache untuk inti CPU. (Dalam kehidupan nyata, CPU memiliki cache yang koheren . Ini bukan masalah nyata, tetapi standar C ++ cukup longgar untuk mencakup implementasi C ++ hipotetis pada memori bersama non-koheren di mana atomic<bool>dengan memory_order_relaxedstore / load akan bekerja, tetapi di mana volatiletidak. Menggunakan volatile untuk ini adalah UB, meskipun itu bekerja dalam praktik pada implementasi C ++ nyata.)

Untuk mencegah hal ini terjadi, kompiler harus diberitahu secara eksplisit untuk tidak melakukannya.


Saya sedikit terkejut tentang diskusi yang berkembang tentang potensi hubungan volatiledengan masalah ini. Jadi, saya ingin menghabiskan dua sen:

Scheff
sumber
4
Saya melihat sekilas func()dan berpikir "Saya bisa mengoptimalkannya begitu saja" Pengoptimal tidak peduli untuk thread sama sekali, dan akan mendeteksi loop tak terbatas, dan dengan senang hati akan mengubahnya menjadi "sementara (Benar)" Jika kita melihat godbolt .org / z / Tl44iN kita bisa melihat ini. Jika sudah selesai Trueia kembali. Jika tidak, itu akan menjadi lompatan tanpa syarat kembali ke dirinya sendiri (sebuah loop tak terbatas) pada label.L5
Baldrickk
2
@val: pada dasarnya tidak ada alasan untuk menyalahgunakan volatiledi C ++ 11 karena Anda bisa mendapatkan asm identik dengan atomic<T>dan std::memory_order_relaxed. Itu bekerja meskipun pada perangkat keras nyata: cache adalah koheren sehingga instruksi beban tidak bisa terus membaca nilai basi begitu toko pada inti lain berkomitmen untuk melakukan cache di sana. (MESI)
Peter Cordes
5
@PeterCordes Menggunakan volatilemasih UB sekalipun. Anda benar-benar tidak boleh berasumsi sesuatu yang pasti dan jelas UB aman hanya karena Anda tidak bisa memikirkan cara itu bisa salah dan berhasil ketika Anda mencobanya. Itu telah membuat orang terbakar berulang kali.
David Schwartz
2
@Damon Mutex memiliki rilis / akuisisi semantik. Kompiler tidak diperbolehkan untuk mengoptimalkan pembacaan jika mutex dikunci sebelumnya, jadi lindungi finisheddengan std::mutexkarya (tanpa volatileatau atomic). Faktanya, Anda dapat mengganti semua atomik dengan skema nilai + mutex "sederhana"; masih akan bekerja dan hanya lebih lambat. atomic<T>diizinkan menggunakan mutex internal; hanya atomic_flagdijamin bebas kunci.
Erlkoenig
42

Jawaban Scheff menjelaskan cara memperbaiki kode Anda. Saya pikir saya akan menambahkan sedikit informasi tentang apa yang sebenarnya terjadi dalam kasus ini.

Saya mengkompilasi kode Anda di godbolt menggunakan optimasi level 1 ( -O1). Fungsi Anda mengkompilasi seperti:

func():
  cmp BYTE PTR finished[rip], 0
  jne .L4
.L5:
  jmp .L5
.L4:
  mov eax, 0
  ret

Jadi, apa yang terjadi di sini? Pertama, kami memiliki perbandingan: cmp BYTE PTR finished[rip], 0- ini memeriksa untuk melihat apakah finishedsalah atau tidak.

Jika tidak salah (alias benar) kita harus keluar dari loop pada proses pertama. Hal ini dicapai dengan jne .L4yang j umps ketika n ot e qual ke label .L4dimana nilai i( 0) disimpan dalam register untuk digunakan dan fungsi kembali.

Jika adalah palsu namun, kami pindah ke

.L5:
  jmp .L5

Ini adalah lompatan tanpa syarat, untuk memberi label .L5yang kebetulan merupakan perintah lompatan itu sendiri.

Dengan kata lain, utas dimasukkan ke dalam loop sibuk tanpa batas.

Jadi mengapa ini terjadi?

Sejauh menyangkut optimiser, utas berada di luar ruang lingkupnya. Ini mengasumsikan utas lainnya tidak membaca atau menulis variabel secara bersamaan (karena itu akan menjadi data-ras UB). Anda perlu mengatakan bahwa itu tidak dapat mengoptimalkan akses jauh. Di sinilah jawaban Scheff masuk. Saya tidak akan repot-repot mengulanginya.

Karena pengoptimal tidak diberitahu bahwa finishedvariabel berpotensi berubah selama eksekusi fungsi, ia melihat bahwa finisheditu tidak dimodifikasi oleh fungsi itu sendiri dan mengasumsikan bahwa itu konstan.

Kode yang dioptimalkan menyediakan dua jalur kode yang akan dihasilkan dari memasukkan fungsi dengan nilai bool konstan; baik itu menjalankan loop secara tak terbatas, atau loop tidak pernah berjalan.

di -O0compiler (seperti yang diharapkan) tidak mengoptimalkan loop body dan perbandingannya:

func():
  push rbp
  mov rbp, rsp
  mov QWORD PTR [rbp-8], 0
.L148:
  movzx eax, BYTE PTR finished[rip]
  test al, al
  jne .L147
  add QWORD PTR [rbp-8], 1
  jmp .L148
.L147:
  mov rax, QWORD PTR [rbp-8]
  pop rbp
  ret

oleh karena itu fungsinya, ketika tidak dioptimalkan berhasil, kurangnya atomisitas di sini biasanya tidak menjadi masalah, karena kode dan tipe data sederhana. Mungkin yang terburuk kita bisa lari ke sini adalah nilai iyang off per satu untuk apa yang harus menjadi.

Sistem yang lebih kompleks dengan struktur data jauh lebih mungkin menghasilkan data yang rusak, atau eksekusi yang tidak tepat.

Baldrickk
sumber
3
C ++ 11 membuat thread dan model memori yang sadar thread menjadi bagian dari bahasa itu sendiri. Ini berarti kompiler tidak dapat menemukan penulisan bahkan untuk non- atomicvariabel dalam kode yang tidak menulis variabel-variabel itu. misal if (cond) foo=1;tidak bisa ditransformasikan menjadi asm seperti itu foo = cond ? 1 : foo;karena load + store (bukan atom RMW) dapat menginjak tulisan dari utas lainnya. Kompiler sudah menghindari hal-hal seperti itu karena mereka ingin berguna untuk menulis program multi-utas, tetapi C ++ 11 membuatnya resmi bahwa kompiler tidak boleh memecahkan kode tempat 2 utas menulis a[1]dana[2]
Peter Cordes
2
Tapi ya, selain itu melebih-lebihkan tentang bagaimana kompiler tidak mengetahui utas sama sekali , jawaban Anda benar. Balap data UB adalah yang memungkinkan pengangkatan banyak variabel non-atom termasuk global, dan optimasi agresif lainnya yang kami inginkan untuk kode single-threaded. Pemrograman MCU - Optimasi C ++ O2 terputus saat loop pada electronics.SE adalah versi saya dari penjelasan ini.
Peter Cordes
1
@PeterCordes: Salah satu keunggulan Java menggunakan GC adalah bahwa memori untuk objek tidak akan didaur ulang tanpa penghalang memori global yang mengintervensi antara penggunaan lama dan baru, yang berarti bahwa setiap inti yang memeriksa suatu objek akan selalu melihat beberapa nilai yang dimilikinya. diadakan beberapa saat setelah referensi pertama kali diterbitkan. Sementara hambatan memori global bisa sangat mahal jika mereka sering digunakan, mereka dapat sangat mengurangi kebutuhan akan hambatan memori di tempat lain bahkan ketika digunakan dengan hemat.
supercat
1
Ya, saya tahu itu yang ingin Anda katakan, tetapi saya tidak berpikir kata Anda 100% artinya. Mengatakan pengoptimal "sepenuhnya mengabaikan mereka." tidak sepenuhnya benar: diketahui bahwa benar-benar mengabaikan threading ketika mengoptimalkan dapat melibatkan hal-hal seperti memuat kata / memodifikasi byte di toko kata / kata, yang dalam praktiknya telah menyebabkan bug di mana satu utas akses ke char atau bitfield menginjak langkah menulis ke anggota struct yang berdekatan. Lihat lwn.net/Articles/478657 untuk cerita selengkapnya, dan bagaimana hanya model memori C11 / C ++ 11 yang membuat optimasi seperti itu ilegal, tidak hanya tidak diinginkan dalam praktiknya.
Peter Cordes
1
Tidak, itu bagus .. Terima kasih @PeterCordes. Saya menghargai peningkatannya.
Baldrickk
5

Demi kelengkapan dalam kurva pembelajaran; Anda harus menghindari menggunakan variabel global. Anda melakukan pekerjaan dengan baik dengan menjadikannya statis, sehingga akan bersifat lokal ke unit terjemahan.

Berikut ini sebuah contoh:

class ST {
public:
    int func()
    {
        size_t i = 0;
        while (!finished)
            ++i;
        return i;
    }
    void setFinished(bool val)
    {
        finished = val;
    }
private:
    std::atomic<bool> finished = false;
};

int main()
{
    ST st;
    auto result=std::async(std::launch::async, &ST::func, std::ref(st));
    std::this_thread::sleep_for(std::chrono::seconds(1));
    st.setFinished(true);
    std::cout<<"result ="<<result.get();
    std::cout<<"\nmain thread id="<<std::this_thread::get_id()<<std::endl;
}

Langsung di kotak tongkat

Pelupaan
sumber
1
Bisa juga mendeklarasikan finishedsebagai staticdalam blok fungsi. Ini masih akan diinisialisasi hanya sekali, dan jika itu diinisialisasi ke konstanta, ini tidak memerlukan penguncian.
Davislor
Akses ke finishedjuga bisa menggunakan std::memory_order_relaxedbeban dan toko yang lebih murah ; tidak perlu memesan wrt. variabel lain di kedua utas. Tapi saya tidak yakin saran Davislor staticmasuk akal; jika Anda memiliki beberapa utas spin-count, Anda tidak perlu ingin menghentikannya dengan flag yang sama. Anda ingin menulis inisialisasi finisheddengan cara yang mengkompilasi hanya inisialisasi, bukan toko atom. (Seperti yang Anda lakukan dengan finished = false;sintaksis initializer C ++ 17. Godbolt.org/z/EjoKgq ).
Peter Cordes
@PeterCordes Menempatkan bendera di objek memang memungkinkan ada lebih dari satu, untuk kumpulan utas yang berbeda, seperti yang Anda katakan. Desain aslinya memiliki satu bendera untuk semua utas.
Davislor