Praktik terbaik multithreading di lapangan: edisi C++ — menghapus kecelakaan secara struktural dengan RAII dan jthread

· · Windows, Multithreading, C++, Visual Studio, Aplikasi bisnis, Investigasi bug, Desain

«Desain yang berjalan baik di C# mulai crash sesekali setelah kami porting ke C++.» «Kami memakai std::thread, dan ketika pengecualian dilempar seluruh aplikasi mati seketika lewat terminate.» «Kami menghentikan hal-hal dengan bendera volatile bool, tetapi hanya di build rilis, ia tidak berhenti.» — Multithreading C++ membawa bahaya yang tidak dimiliki bahasa terkelola: data race, apa adanya, adalah perilaku tak terdefinisi (UB). Bukan hanya Anda mungkin membaca nilai rusak; asumsi optimasi kompiler runtuh, dan Anda berakhir di keadaan di mana secara harfiah apa pun bisa terjadi.

Artikel ini adalah edisi C++ dari seri multithreading praktis kami. Ditujukan bagi pengembang yang menulis aplikasi bisnis, perangkat lunak kendali peralatan, dan DLL dalam C++ modern (C++17/20), artikel ini menerjemahkan prinsip umum desain multithread — jangan menambah thread secara langsung, kurangi keadaan berubah bersama, terapkan disiplin kunci, rancang cara berhenti sebelum apa pun — ke perkakas C++ dan Windows, bersama jebakan khas C++, semuanya berpijak pada sumber primer per Agustus 2026. Ditulis agar bisa berdiri sendiri. Prinsip yang sama, dijabarkan untuk bahasa lain, juga ada di «Edisi .NET», «Edisi C», dan «Edisi Java».

1. Intinya dulu

  • Di C++, data race bukan «Anda mungkin membaca nilai rusak» — itu perilaku tak terdefinisi. Tidak menyisakan satu pun akses berubah bersama yang tidak tersinkronisasi di mana pun dalam kode adalah syarat mutlak, lebih dari di bahasa lain.1
  • Jangan memakai std::thread telanjang. Jika destruktor std::thread berjalan sementara thread masih joinable, std::terminate membunuh proses seketika. std::jthread C++20 otomatis join di destruktornya dan punya mekanisme permintaan berhenti (stop_token) bawaan.23
  • Selalu pegang kunci lewat RAII. Berhenti menulis mtx.lock() secara manual; pakai lock_guard / scoped_lock. Destruktor melepaskan kunci dengan andal bahkan jika pengecualian dilempar. Ketika memperoleh beberapa kunci sekaligus, scoped_lock mengurusnya dengan algoritma hindar-deadlock.4
  • volatile bukan alat sinkronisasi. Pakai std::atomic untuk bendera dan penghitung bersama, dan std::mutex untuk melindungi beberapa variabel bersama-sama. std::atomic memberi baik keatomikan maupun pengurutan berdasarkan memory_order.5
  • Lakukan tunggu rendezvous dengan wait berbentuk predikat milik condition_variable. Condition variable rentan spurious wakeup (bangun tanpa diberitahu), jadi memanggil wait tanpa predikat adalah sarang bug.6
  • jthread + stop_token (C++20) adalah bentuk dasar cara menghentikan thread. Di lingkungan sebelum itu, bangun penghentian kooperatif secara manual dengan std::atomic<bool> plus condition variable. Anggap penghentian paksa thread sebagai sesuatu yang di dunia C++ sama sekali tidak ada.3
  • Ketahui bahwa destruktor future bisa memblokir sebelum memakai std::async. Buang nilai kembalian dan Anda mendapat efek yang sama dengan eksekusi serial.7
  • Objek sinkronisasi Win32 hanya pantas untuk skenario «bekerja dengan API tunggu Win32» dan «lintas proses». Di tempat lain, menulis terhadap pustaka standar lebih baik untuk portabilitas dan pemeliharaan.8

2. Mengapa multithreading sulit — kondisi balapan, deadlock, dan perilaku tak terdefinisi

Dipadatkan, masalah yang dibawa multithreading ada dua jenis, terlepas dari bahasanya.

Kondisi balapan (race condition) adalah bug di mana hasil bergantung pada urutan beberapa thread mencapai potongan kode tertentu. Contoh klasiknya adalah penghitung bersama: ekspresi tunggal ++count terpecah di tingkat kode mesin menjadi tiga langkah — baca, tambah, tulis kembali. Jika dua thread masuk ke tiga langkah itu pada saat yang sama, penambahan satu thread ditimpa dan hilang oleh penulisan kembali yang lain. Hasil berubah dari satu jalan ke jalan lain, dan hasil mana yang Anda dapat tidak dapat diprediksi.

Thread BVariabel bersama countThread AThread BVariabel bersama countThread Acount = 10Dua increment terjadi,tetapi count = 11 — penambahan Thread A hilangBaca (10)Baca (10)Tambah secara lokal (11)Tambah secara lokal (11)Tulis kembali (11)Tulis kembali (11)

Gambar 1: Kondisi balapan klasik di mana increment pada penghitung bersama hilang. Jika thread lain menyisip di antara tiga langkah ++count, penulisan kembali mana pun yang terakhir menimpa yang lain

Deadlock adalah keadaan di mana dua thread masing-masing menunggu kunci yang dipegang yang lain, sehingga tidak satu pun bisa maju. Thread A memegang kunci 1 dan menunggu kunci 2; thread B memegang kunci 2 dan menunggu kunci 1 — itu saja cukup agar keduanya berhenti selamanya.

menunggu memperoleh kunci 2menunggu memperoleh kunci 1Thread Amemegang kunci 1Thread Bmemegang kunci 2

Gambar 2: Tunggu melingkar sebuah deadlock. Begitu panah menunggu membentuk cincin, setiap thread dalam cincin itu berhenti selamanya

Yang membuat keduanya merepotkan adalah ketergantungan pada waktu. Sebuah jalinan yang di mesin pengembangan hanya mengenai sekali dalam puluhan ribu jalan bisa terjadi setiap hari di mesin pelanggan, dengan jumlah inti berbeda dan waktu berbeda. «Tidak mereproduksi saat debugger terpasang» dan «hilang ketika saya menambah pencatatan» keduanya terjadi karena observasi sendiri mengubah waktu — itu perilaku klasik bug balapan. Itulah tepatnya mengapa setiap prinsip dalam artikel ini mengarah ke satu arah: kurangi tempat yang perlu disinkronkan, sebelum khawatir menyinkronkannya dengan benar.

2.1. Di C++, data race langsung menjadi perilaku tak terdefinisi

Di atas itu, C++ punya lapisan lebih jauh yang tidak dimiliki bahasa lain. Menurut standar C++, jika beberapa thread mengakses lokasi memori yang sama tanpa sinkronisasi dan setidaknya satu menulis, itu data race, dan itu perilaku tak terdefinisi. Bab konkurensi C++ Core Guidelines (CP.2, «Avoid data races») menyatakan ini sebagai aturan mutlak yang paling pertama.1 Perilaku tak terdefinisi bukan kisah lunak «Anda mungkin membaca nilai lama atau yang baru». Kompiler mengoptimasi dengan premis tidak ada data race, jadi perilaku yang tidak pernah bisa Anda prediksi dari kode sumber — pemeriksaan syarat yang hilang dari loop, penulisan yang diurut ulang atau digabung — terjadi secara sah. Kecelakaan klasik di mana «bendera berhenti volatile bool hanya gagal di build rilis» adalah kasus buku teks persis ini.

2.2. RAII adalah fondasi

Premis lain yang khas C++ adalah pengecualian dan pengelolaan sumber daya. C++ tidak punya finally; sebagai gantinya ia punya RAII (pelepasan otomatis lewat destruktor), dan perkakas multithreading dirancang dengan asumsi Anda akan memakainya. «Kelola kunci lewat masa hidup objek»; «jamin join thread lewat masa hidup objek juga» — mengikuti konvensi itu adalah fondasi untuk menulis C++ multithread dengan aman.

3. Cara memulai thread — jebakan thread, dan jthread

3.1. Destruktor std::thread «dirancang untuk menimbulkan kecelakaan»

std::thread punya jebakan yang dikenal luas. Jika destruktornya berjalan sementara thread masih joinable (belum di-join maupun di-detach), std::terminate dipanggil dan proses mati seketika.9

void process()
{
    std::thread worker([]{ HeavyWork(); });
    DoSomething();      // ← jika pengecualian dilempar di sini...
    worker.join();      // ← join tidak pernah tercapai; destruktor worker memanggil terminate
}

Membuat ini aman terhadap pengecualian menuntut menjamin join dengan try/catch — keadaan yang menyimpang dalam bahasa RAII di mana justru thread harus dikelola secara manual. std::jthread C++20 menyelesaikan ini. Karena destruktornya secara otomatis mengeluarkan permintaan berhenti lalu join, kode di atas menjadi aman terhadap pengecualian hanya dengan beralih ke std::jthread.2 Di MSVC, <stop_token> dan jthread tersedia dari Visual Studio 2019 16.9 ke atas.3

std::threadbelum di-join maupun di-detachstd::threadsudah di-joinstd::jthread - C++20Thread telah dimulaiApa yang terjadiketika lingkup keluar?std::terminateproses mati seketikaJoin dengan amanrequest_stop + join otomatisaman meski pengecualian dilempar

Gambar 3: Masa hidup objek thread dan cara berakhirnya. std::thread ditetapkan mati seketika jika Anda lupa join, jadi dari C++20 ke atas jadikan jthread sebagai default

Pada dasarnya, jangan memakai detach(). Thread yang kehilangan segala cara join menjadi penyebab klasik crash saat shutdown, berlomba dengan penghancuran variabel statis dan heap ketika proses keluar.

3.2. Perkakas «di atas tingkat thread» — async, future, dan algoritma paralel

Prinsip edisi .NET «jangan membuat thread sendiri» dipetakan ke perkakas berikut di C++.

  • std::async + std::future: untuk tugas asinkron sekali pakai dan menerima hasilnya. Namun ada keanehan penting: future (atau shared_future terakhir) yang terikat pada tugas yang diluncurkan lewat std::async memblokir sampai selesai jika destruktornya berjalan sementara tugas masih belum lengkap.7 Untuk pekerjaan yang benar-benar diluncurkan dengan std::launch::async, membuang future yang dikembalikan setara dengan eksekusi sinkron di titik itu. Lebih buruk, jika Anda tidak menentukan kebijakan peluncuran, implementasi bebas memilih deferred (eksekusi malas) secara default; dalam hal itu, jika tidak ada yang memanggil get() / wait(), pekerjaan tidak pernah dijalankan sama sekali dan hilang diam-diam. Jika Anda ingin menjamin eksekusi konkuren, tentukan std::launch::async secara eksplisit, dan biarkan pemilik mengelola masa hidup future.
  • PPL - Parallel Patterns Library - concurrency::parallel_for / parallel_for_each: menerapkan pekerjaan secara paralel pada setiap elemen koleksi. Namun, jika pekerjaan dalam satu iterasi terlalu kecil, overhead fork/join memakan keuntungannya, jadi pada dasarnya paralelkan di loop luar.10
  • Algoritma paralel C++17 - std::execution::par: di MSVC algoritma utama diparalelkan (tidak semuanya).11 Catat bahwa jika pengecualian lolos dari pemrosesan elemen di bawah kebijakan eksekusi, std::terminate dipanggil. Menempatkan batas pengecualian sendiri (try/catch) di dalam callback mengikuti pemikiran yang sama dengan batas thread di bagian 6.

Garis yang ditarik di tempat lain — «menunggu I/O bukan sesuatu yang Anda selesaikan dengan menambah thread» — tetap berlaku tanpa perubahan. Untuk kode Windows native, I/O OVERLAPPED dan IOCP adalah perkakas yang menampung pekerjaan itu (untuk mekanismenya, lihat «Kedalaman I/O Windows, bagian 2»).

4. Mengurangi keadaan berubah bersama — membagi, melewatkan menurut nilai, const, dan antrean

Persaingan hanya muncul ketika «beberapa thread» dan «data berubah bersama» keduanya ada. Jumlah thread ditentukan persyaratan, jadi yang bisa dipotong desain adalah berbagi. Caranya jatuh ke tiga keluarga — membagi, membuat tak berubah, dan menyerahkan data — dan berikut cara menulis masing-masing di C++.

Membagi. Dalam pekerjaan seperti agregasi paralel, alih-alih setiap thread menulis ke total bersama, beri setiap thread subtotal lokalnya sendiri dan gabungkan sekali, di akhir. Penulisan ke nilai bersama turun dari «setiap iterasi» menjadi «sekali per thread», memotong baik biaya sinkronisasi maupun jendela persaingan berlipat ganda. Langkah gabungan tunggal itu bisa dilakukan dengan std::mutex atau dengan fetch_add pada std::atomic — keduanya baik.

Melewatkan menurut nilai. Jika Anda menyerahkan data yang dibutuhkan thread kepadanya lewat salinan (atau move) saat mulai, data itu menjadi milik eksklusif thread, dan sinkronisasi tidak diperlukan. Menangkap lambda menurut referensi ([&]) lalu menyentuh variabel yang masa hidupnya sudah berakhir adalah kecelakaan umum, jadi lambda yang dilewatkan ke thread harus memakai tangkap eksplisit, menurut salinan atau move sebagai aturan. Itu kata, «disalin, maka eksklusif» hanya berlaku ketika nilainya adalah graf nilai dalam yang tidak berisi alias seperti pointer atau shared_ptr. Menyalin struct yang berisi pointer mentah tetap meninggalkan apa yang ditunjuknya terbagi.

Berbagi sebagai const. Data yang hanya pernah dibaca aman dibaca dari berapa pun banyak thread sekaligus. Nilai konfigurasi, data induk, masukan komputasi, dan sejenisnya bisa dibagi tanpa sinkronisasi jika Anda membuatnya berbagi const yang tidak ditulis ulang setelah konstruksi (std::shared_ptr<const Config>, misalnya). Satu peringatan: yang dilarang shared_ptr<const T> hanya mutasi lewat handle tertentu itu. Jika alias non-const bertahan di tempat lain, atau anggota mutable ditulis ulang, persaingan tetap ada — jadi rancang itu juga, sampai «setelah konstruksi selesai, lepaskan referensi non-const dan jangan biarkan siapa pun menulis sesudahnya». Sekadar memutuskan bahwa «ketika perubahan dibutuhkan, bangun objek baru dan tukar, alih-alih memutasi di tempat» menghapus satu potong keadaan berubah yang kalau tidak harus Anda jaga (untuk pengelolaan masa hidup tukar itu sendiri, lihat peringatan di bagian 5.2).

Menyerahkan lewat antrean. Arahkan aliran data antar-thread lewat antrean produsen/konsumen alih-alih variabel bersama. Standar C++ tidak punya tipe saluran, jadi menulis antrean kecil dengan std::mutex + std::condition_variable adalah pola yang mapan.

template <typename T>
class BlockingQueue {
public:
    explicit BlockingQueue(std::size_t capacity) : capacity_(capacity)
    {
        if (capacity == 0)                          // kapasitas 0 adalah jebakan di mana setiap Push menunggu selamanya
            throw std::invalid_argument("capacity must be positive");
    }

    // Menunggu sampai ada ruang (atau permintaan berhenti) jika penuh. false berarti permintaan berhenti.
    bool Push(T item, std::stop_token st)
    {
        {
            std::unique_lock lock(mtx_);
            if (!not_full_.wait(lock, st, [this]{ return queue_.size() < capacity_; }))
                return false;                       // dibangunkan oleh permintaan berhenti
            if (st.stop_requested())                // jika ruang dan berhenti terjadi bersama, utamakan berhenti,
                return false;                       // dan tolak dorongan setelah berhenti dimulai
            queue_.push(std::move(item));
        }
        not_empty_.notify_one();   // beritahu di luar kunci
        return true;
    }

    // Menunggu permintaan berhenti (stop_token) atau item tiba. nullopt ketika berhenti.
    std::optional<T> Pop(std::stop_token st)
    {
        std::optional<T> item;
        {
            std::unique_lock lock(mtx_);
            if (!not_empty_.wait(lock, st, [this]{ return !queue_.empty(); }))
                return std::nullopt;                // dibangunkan oleh permintaan berhenti
            if (st.stop_requested())                // jika item dan berhenti terjadi bersama, utamakan berhenti,
                return std::nullopt;                // dan jangan mulai pekerjaan baru setelah berhenti dimulai
            item = std::move(queue_.front());
            queue_.pop();
        }
        not_full_.notify_one();
        return item;
    }

private:
    const std::size_t capacity_;
    std::mutex mtx_;
    std::condition_variable_any not_empty_;   // condition_variable_any, untuk memakai wait yang sadar stop_token
    std::condition_variable_any not_full_;
    std::queue<T> queue_;
};

Ada dua poin desain di sini. Pertama, batasi kapasitas dan buat sisi produsen menunggu ketika penuh. Antrean tanpa batas atas menjadi bom waktu dalam susunan di mana produksi mengungguli konsumsi: ia «tetap berjalan», tetapi memori terus tumbuh. Membuat Push memblokir ketika penuh bertindak sebagai tekanan balik alami, menyebarkan kelebihan beban ke hulu secara mekanis. Kedua, karena condition variable rentan spurious wakeup (bangun tanpa pemberitahuan), selalu panggil wait dengan predikat. Bentuk predikat wait menjalankan logika «loop sampai syarat benar» untuk Anda di dalam.6

5. Disiplin kunci — RAII dan scoped_lock

Bahkan setelah mengurangi keadaan berubah bersama, sering Anda tidak bisa menolkannya. Pakai eksklusi untuk yang tetap bersama, tetapi mengunci tanpa disiplin hanya menyembunyikan persaingan.

Pertama, pikirkan satuan penguncian bukan sebagai «rentang kode» melainkan sebagai «data». Tetapkan satu mutex untuk setiap himpunan data berubah yang ingin Anda lindungi (jadikan anggota private, tidak diekspos ke luar), dan ambil mutex yang sama di setiap tempat yang menyentuh data itu — versi rusak dari tabel korespondensi ini adalah apa adanya sebagian besar bug balapan. Dan satu-satunya hal yang boleh Anda lakukan sambil memegang kunci adalah membaca dan menulis data yang dilindunginya. I/O berkas, panggilan jaringan, dan callback (panggilan ke kode eksternal) sambil memegang kunci tidak hanya memperpanjang berapa lama Anda memegangnya — mereka membuka jalur di mana pihak yang dipanggil mencoba mengambil kunci lain lalu deadlock. Siapkan di luar kunci, dan di dalam kunci jangan lakukan apa pun selain menukar adalah bentuk dasarnya.

5.1. Menulis lock()/unlock() secara manual dilarang

Kode yang memanggil lock() / unlock() milik std::mutex secara langsung berakhir gagal melepaskan kunci pada pengecualian atau pengembalian dini. Selalu serahkan perolehan dan pelepasan kunci pada pembungkus RAII.

Pembungkus Pakai
std::lock_guard Memegang satu mutex tepat selama lingkup — bentuk paling dasar
std::scoped_lock (C++17) Memperoleh beberapa mutex sekaligus. Menyelesaikan masalah urutan dengan algoritma hindar-deadlock4
std::unique_lock Untuk ketika Anda ingin membuka dan mengunci kembali di tengah jalan, atau perlu melewatkannya ke condition_variable::wait

Ketika ada dua kunci atau lebih, urutan perolehan yang tertukar tergantung thread adalah pola deadlock klasik (tunggu melingkar di Gambar 2 lahir persis seperti ini). Perbaikannya adalah membuat aturan bahwa «setiap thread memperoleh kunci dalam urutan yang sama», tetapi ketika Anda memperolehnya pada saat yang sama, C++ punya jawaban yang lebih baik: serahkan beberapa mutex ke std::scoped_lock bersama-sama dan pustaka menjamin urutan perolehan bebas deadlock untuk Anda.4 Dalam situasi seperti transfer antara dua objek di mana Anda ingin «keduanya terkunci», jangan pernah mengambilnya satu per satu — selalu ambil bersama.

void Transfer(Account& from, Account& to, int amount)
{
    if (&from == &to) return;                  // jangan lakukan apa pun untuk rekening yang sama (lihat catatan di bawah)
    std::scoped_lock lock(from.mtx, to.mtx);   // keduanya bersama; pustaka menyelesaikan urutan
    from.balance -= amount;
    to.balance   += amount;
}

Pemeriksaan identitas di puncak bukan hiasan. Jika Account yang sama dilewatkan sebagai from dan to, Anda berakhir melewatkan mutex non-rekursif yang sama ke scoped_lock dua kali, yang menyebabkan hang atau perilaku tak terdefinisi. Selalu lampirkan pengecualian objek-yang-sama pada fungsi mana pun yang «mengunci keduanya».

Untuk data yang «sering dibaca, jarang ditulis», Anda bisa memakai std::shared_mutex (C++17) sebagai kunci baca/tulis.12 recursive_mutex adalah tipe yang dirancang agar «perolehan ulang oleh thread yang sama tidak rusak», tetapi desain yang membutuhkan perolehan rekursif sering tanda bahwa batas tanggung jawab kunci telah kabur — pertimbangkan meninjau struktur lebih dulu.

5.2. Peran atomic yang benar

std::atomic menyediakan operasi atomik pada satu variabel, plus pengurutan berdasarkan memory_order.5 Ia pantas pada situasi yang sama dengan Interlocked di edisi .NET: memperbarui satu variabel, seperti penghitung atau bendera. Ia tidak bisa menjaga beberapa variabel konsisten bersama, jadi untuk itu Anda kembali ke std::mutex.

Menukar pointer mentah (std::atomic<T*>) punya jebakan tersendiri. Meskipun tukarnya sendiri atomik, tidak ada yang melindungi masa hidup objek lama setelah diganti. Jika pembaca memuat pointer lama tepat sebelum penulis menukarnya dan delete, Anda mendapat akses ke memori yang sudah dibebaskan. Jika Anda ingin desain «tukar dan bagikan objek tak berubah» di C++, pilih sarana yang datang berpasangan dengan pengelolaan masa hidup — menukar std::shared_ptr<const T> yang dilindungi kunci, atau std::atomic<std::shared_ptr<T>> milik C++20.

Dan untuk diulang: volatile bukan alat sinkronisasi thread. Pemrograman bebas-kunci di mana Anda sendiri menentukan memory_order adalah wilayah ahli, menuntut baik alasan sah untuk merilekskannya dari default (seq_cst) maupun cara memverifikasi bahwa Anda telah melakukannya dengan benar. Dalam aplikasi bisnis, pakai default atau tulis dengan mutex sejak awal.

6. Merancang cara berhenti — stop_token dan penghentian kooperatif

Pertanyaan pertama yang harus diajukan saat meninjau desain multithread adalah «bagaimana ini berhenti?» Dan C++ tidak punya sarana untuk menghentikan thread dengan aman dari luar (seberapa berbahaya TerminateThread Win32 dibahas rinci di Edisi C). Jadi cara thread berhenti harus dibangun dengan perkakas C++ di sekitar penghentian kooperatif — sisi yang menghentikan hanya mengeluarkan permintaan; thread sendiri memutuskan kapan dan bagaimana berakhir, pada titik yang meninggalkan segala sesuatu rapi; dan selesainya join yang dihitung sebagai «berhenti».

Di C++20, std::jthread punya mekanisme berhenti bawaan. Memanggil request_stop() menaikkan permintaan berhenti pada std::stop_token yang diterima fungsi thread, dan loop memeriksanya. wait milik condition_variable_any bisa menerima stop_token secara langsung, jadi «thread yang menunggu pekerjaan tiba» juga bisa dibangunkan seketika oleh permintaan berhenti (BlockingQueue::Pop di bagian 4 tepat berbentuk ini).

class Worker {
public:
    void Start()
    {
        if (thread_.joinable())                       // Tolak Start ganda sementara sudah berjalan.
            throw std::logic_error("already running"); // Jika kita menugaskan alih-alih menolak, thread baru
                                                       // akan mulai berjalan, dan sementara ia menunggu
                                                       // thread lama berhenti, dua worker
                                                       // akan berakhir berjalan berdampingan
        thread_ = std::jthread([this](std::stop_token st) {
            try {
                while (!st.stop_requested()) {
                    if (auto item = queue_.Pop(st)) {   // bangun juga pada permintaan berhenti
                        try {
                            Process(*item, st);          // teruskan st ke pekerjaan yang bisa memblokir di dalam juga
                        } catch (...) {
                            ReportError(std::current_exception());  // catat satu kegagalan dan lanjut
                        }
                    }
                }
            } catch (...) {
                // Garis pertahanan terakhir di batas thread (juga menangkap kegagalan
                // di Pop atau move). Jika pengecualian lolos dari sini, std::terminate
                // meruntuhkan seluruh proses, jadi pastikan ReportError sendiri tidak pernah melempar
                ReportError(std::current_exception());
            }
        });
    }
    // Stop eksplisit tidak diperlukan:
    // destruktor Worker -> destruktor jthread -> request_stop() + join()
private:
    BlockingQueue<WorkItem> queue_{100};   // kapasitas dibatasi (bagian 4)
    std::jthread thread_;
};
permintaan berhentiSisi yang menghentikan- destruktor jthread, atau request_stopstop_tokenLoop hitung:memeriksa stop_requested()Thread yang menunggu:condition_variable_any::wait(lock, st, pred)bangun segeraMembersihkan dan kembali sendirijoin menyelesaikan rendezvousbaru sekarang kita bisa menyebutnya berhenti

Gambar 4: Penghentian kooperatif C++20. Sisi yang menghentikan hanya mengeluarkan permintaan; thread sendiri memutuskan bagaimana berakhir; selesainya join yang dihitung sebagai berhenti

Satu poin lagi: try/catch di dalam worker tidak bisa dihilangkan. Yang dibuat aman terhadap pengecualian oleh jthread adalah join, dan hanya join — jika pengecualian lolos dari fungsi thread, std::terminate meruntuhkan proses, sama seperti std::thread. Putuskan secara eksplisit, di batas thread, bagaimana menangani kegagalan satu potong pekerjaan (catat dan lanjut, atau laporkan ke pemilik lewat saluran kesalahan).

Untuk alasan yang sama, perhatikan bahwa Process juga menerima stop_token. Jika pemrosesan satu potong pekerjaan memblokir di dalam (menunggu jaringan, komputasi panjang, dan sebagainya) dan titik itu tidak bisa mengamati permintaan berhenti, join implisit destruktor akan menunggu selamanya satu item itu selesai. Penghentian kooperatif hanya utuh setelah token mencapai setiap tempat yang menunggu. Jika pekerjaan mencakup panggilan eksternal yang tidak bisa disela, pasang batas waktu dan beri batas atas berapa lama satu item boleh berjalan.

Di lingkungan sebelum C++17, Anda membangun bentuk yang sama secara manual dengan bendera berhenti std::atomic<bool> plus notify_all milik condition_variable. Poin kuncinya di sini adalah memasukkan pemeriksaan bendera berhenti ke predikat condition variable — jika Anda hanya menaikkan bendera dan lupa memberitahu, thread yang menunggu tidak akan pernah bangun.

7. Perhatian khusus Windows — batas dengan API Win32

7.1. Memilih antara pustaka standar dan objek sinkronisasi Win32

Dokumentasi Microsoft merekomendasikan std::mutex / std::shared_mutex untuk kode C++ yang mengutamakan portabilitas, dan menempatkan objek sinkronisasi Win32 sebagai «ketika API tunggu Win32 dibutuhkan» dan «sinkronisasi lintas proses».8

Situasi Pilihan
Eksklusi intra-proses biasa std::mutex + RAII (default)
Banyak baca, jarang tulis std::shared_mutex
Menunggu beberapa objek sekaligus dengan WaitForMultipleObjects Objek kernel Win32 seperti event dan mutex
Eksklusi / pemberitahuan lintas proses Mutex, event, semafor bernama
Penguncian intra-proses memakai API Win32 secara langsung Kunci SRW (CRITICAL_SECTION hanya ketika rekursi dibutuhkan)8

Untuk desain konkret mengecualikan akses ke memori bersama lintas proses, lihat «Jebakan memori bersama dan praktik terbaik di lapangan».

7.2. Jangan sentuh thread di dalam DllMain

Kendala serius saat menulis DLL adalah loader lock. DllMain dipanggil sementara loader lock dipegang, jadi operasi di dalamnya seperti menyinkronkan dengan thread lain, menunggu thread berakhir, atau memanggil LoadLibrary menyebabkan deadlock atau perilaku tak terduga. Pindahkan inisialisasi apa pun yang memulai atau meng-join thread ke luar DllMain, ke fungsi inisialisasi eksplisit.13

7.3. Thread UI dan apartment COM

Aplikasi desktop Windows punya kendala kuat yang berlaku terlepas dari bahasa: hanya thread yang membuat jendela atau kontrol — thread UI — yang boleh menyentuhnya. Windows mengirimkan pesan jendela ke antrean pesan thread yang membuat jendela itu, jadi pembuatan dan manipulasi UI harus dipusatkan pada thread itu. Ketika Anda ingin memperbarui layar dari thread pekerja, jangan sentuh langsung — minta thread UI dengan PostMessage (asinkron), dan tangani di prosedur jendela di sisi thread UI. Memanggil bentuk sinkron, SendMessage, sementara thread UI menunggu pekerja itu selesai menyebabkan deadlock di mana masing-masing menunggu yang lain, jadi jadikan bentuk asinkron sebagai default untuk pemberitahuan dari pekerja. STA/MTA, di mana COM terlibat, dibahas di «Dasar COM STA/MTA - Model threading dan cara menghindari hang». Catat juga bahwa dalam kode C++/CLI yang dikompilasi dengan /clr, header thread standar seperti <thread> dan <mutex> diblokir.14

8. Verifikasi dan debug — bersiap dengan asumsi tidak akan mereproduksi

Anda tidak bisa mengandalkan pengujian untuk menemukan bug balapan, karena pengujian biasa menghitung jalan yang «kebetulan tidak balapan» sebagai lulus. Pikirkan persiapan dalam tiga lapisan.

Garis pertahanan pertama adalah prinsip desain yang dibahas sejauh ini, persis sebagaimana adanya. Dalam tinjauan, konfirmasikan dengan tabel: data berubah mana yang dibagi, mutex mana yang melindungi masing-masing potongan, apakah urutan perolehan beberapa kunci unik (atau diambil bersama dengan scoped_lock), dan di mana jalur berhenti. Desain yang tabel ini tidak bisa Anda tulis belum selesai, seberapa pun baiknya ia berjalan saat ini.

Kedua, buat keadaan abnormal dapat diamati alih-alih menyembunyikannya. Pasang batas waktu dengan try_lock_for milik timed_mutex atau wait_for milik condition_variable pada kunci mana pun yang seharusnya tidak pernah gagal diperoleh, dan catat batas waktu sebagai anomali — itu mengubah hang abadi menjadi kegagalan yang dapat dideteksi. Selalu catat pengecualian yang ditangkap di try/catch batas thread (bagian 6). Ketika hang atau crash terjadi di lapangan, tangkap dump, periksa tumpukan setiap thread, dan cari apakah tunggu kunci mereka membentuk siklus. Menyiapkan dump dan pencatatan dibahas di «Merancang pencatatan dan tangkapan dump untuk crash aplikasi Windows».

Ketiga, goyangkan segala sesuatu di bawah beban. Berjalan lama dengan paralelisme lebih banyak daripada inti yang Anda punya, mengacak urutan pemrosesan, dan menyisipkan tunda buatan adalah teknik uji-tekanan praktis yang memudahkan mengenai «jackpot» balapan di mesin pengembangan. Bug yang hilang di build debug sering mereproduksi dengan mudah di build rilis yang dioptimasi di bawah beban berat.

9. Ringkasan — daftar periksa C++

Tumpuk pemeriksaan khas C++ di atas prinsip yang sama bagi setiap bahasa: jangan membuat thread secara langsung, minimalkan keadaan berubah bersama, korespondensi satu-ke-satu antara kunci dan data, dan penghentian kooperatif.

  1. Apakah std::thread dipakai telanjang (bisakah menjadi jthread? Apakah join dijamin bahkan di jalur pengecualian?)
  2. Apakah detach() tidak dipakai?
  3. Apakah tangkap lambda eksplisit, dan apakah variabel yang ditangkap referensi hidup lebih lama dari thread?
  4. Bisakah Anda katakan dengan yakin bahwa tidak ada satu pun akses berubah bersama yang tidak tersinkronisasi (= perilaku tak terdefinisi) di mana pun?
  5. Apakah tidak ada lock() / unlock() yang ditulis tangan, dan beberapa kunci diambil bersama dengan scoped_lock?
  6. Apakah setiap condition_variable::wait dipakai dengan predikat?
  7. Apakah volatile tidak dipakai untuk bendera bersama (apakah std::atomic sebagai gantinya)?
  8. Apakah jalur berhenti dirancang di sekitar stop_token (atau bendera atomic plus pemberitahuan), dengan selesainya join mengonfirmasi rendezvous?
  9. Apakah future dari std::async tidak dibuang?
  10. Apakah DllMain bebas dari memulai, menyinkronkan, atau meng-join thread?

C++ multithread adalah pekerjaan yang dilakukan sambil berjalan tepat di tepi jurang perilaku tak terdefinisi, tetapi balik itu berarti bahwa mengikuti RAII dan konvensi pustaka standar dengan jujur menempatkan jarak nyata antara Anda dan tepi itu. jthread, scoped_lock, wait berbentuk predikat, atomic — memilih default yang tepat di antara perkakas ini adalah, di C++, praktik prinsip desain itu sendiri.

Artikel terkait

Area konsultasi terkait

KomuraSoft LLC menangani tinjauan desain multithread untuk aplikasi dan DLL C++, investigasi akar masalah (analisis dump) bug kondisi balapan seperti «sesekali crash» atau «hanya berperilaku aneh di build rilis», dan konsultasi memigrasikan kode thread warisan ke C++ modern.

Tautan referensi

  1. ISO C++, C++ Core Guidelines - CP: Concurrency and parallelism. Tentang CP.1 (anggap kode Anda akan berjalan sebagai bagian dari program multithread) dan CP.2 (hindari data race) yang ditetapkan sebagai aturan pembuka bab konkurensi dan paralelisme; tentang tidak ada jaminan yang berlaku sama sekali begitu data race ada; dan tentang aturan desain untuk kode konkuren — lingkup kunci dipegang, pemakaian RAII, dan sebagainya — yang disistematisasi di sana.  2

  2. cppreference.com, std::jthread. Tentang jthread C++20 yang berbeda dari std::thread karena destruktornya secara otomatis memanggil request_stop() lalu join; tentang bisa menerima std::stop_token sebagai argumen terdepan fungsi thread; dan tentang ini yang menjamin baik join maupun permintaan berhenti bahkan ketika pengecualian dilempar.  2

  3. Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. Tentang P0660R10 (<stop_token> dan jthread) dan P1135R6 (pustaka sinkronisasi C++20) yang didukung dari Visual Studio 2019 16.9; dan tentang status dukungan per versi fitur pustaka standar C++.  2 3

  4. Microsoft Learn, scoped_lock Class. Tentang scoped_lock C++17 yang memperoleh satu atau lebih mutex saat konstruksi dan melepaskannya di destruktor; tentang beberapa mutex, ketika dilewatkan bersama, diperoleh dengan algoritma hindar-deadlock setara std::lock; tentang melepaskan dengan andal bahkan jika pengecualian dilempar; dan tentang lock_guard/unique_lock yang juga menjadi pilihan ketika hanya satu mutex terlibat.  2 3

  5. Microsoft Learn, <atomic>. Tentang operasi atomik yang tak terbagi, jadi thread lain hanya bisa mengamati keadaan sebelum atau sesudah operasi; tentang menetapkan, berdasarkan argumen memory_order, persyaratan pengurutan atas visibilitas operasi atomik lain, dan menekan optimasi kompiler yang akan melanggarnya; tentang atomic_flag yang selalu bebas-kunci; dan tentang header ini yang diblokir di bawah /clr:pure.  2

  6. Microsoft Learn, <condition_variable>. Tentang menunggu pada condition variable yang membutuhkan mutex, dengan kunci dilepas selama tunggu; tentang spurious wakeup yang ada — bangun tanpa pemberitahuan — jadi sisi yang menunggu harus memeriksa ulang syarat secara eksplisit saat kembali, dan bentuk predikat wait(lock, pred) menjalankan loop itu atas nama Anda; dan tentang condition_variable_any yang dapat digabung dengan tipe mutex apa pun.  2

  7. Microsoft Learn, <future>. Tentang destruktor future dan shared_future yang pada dasarnya tidak memblokir, dengan satu-satunya pengecualian bahwa future (atau shared_future terakhir) yang terikat pada tugas yang diluncurkan dengan std::async memblokir sampai keadaan bersama menjadi ready jika destruktornya berjalan sementara tugas masih belum lengkap — perilaku yang secara eksplisit dicatat dalam standar.  2

  8. Microsoft Learn, About Synchronization. Tentang panduan memilih primitif sinkronisasi Win32: std::mutex / std::shared_mutex dan RAII direkomendasikan untuk kode C++ yang mengutamakan portabilitas; objek sinkronisasi Win32 dipakai ketika API tunggu Win32 atau sinkronisasi lintas proses dibutuhkan; default untuk kode intra-proses baru adalah kunci SRW, dengan CRITICAL_SECTION disimpan untuk ketika perolehan rekursif dibutuhkan; dan memakai Mutex untuk sinkronisasi intra-proses adalah «kesalahan umum» karena selalu melibatkan transisi kernel.  2 3

  9. cppreference.com, std::thread::~thread. Tentang destruktor std::thread yang memanggil std::terminate jika dipanggil sementara thread masih joinable (belum di-join maupun di-detach) — yaitu, tentang keputusan join atau detach yang harus diselesaikan sebelum objek thread dihancurkan, tanpa kecuali. 

  10. Microsoft Learn, Best Practices in the Parallel Patterns Library. Tentang paralelisme yang idealnya diekspresikan pada tingkat setinggi mungkin (loop luar); tentang overhead penjadwalan fork/join yang bisa mengungguli keuntungan eksekusi paralel di loop paralel di mana pekerjaan setiap iterasi kecil atau tidak seimbang; dan tentang kecenderungan itu yang semakin kuat seiring jumlah prosesor meningkat. 

  11. Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. Tentang pustaka algoritma paralel C++17 yang lengkap, sementara «lengkap» tidak berarti setiap algoritma diparalelkan dalam setiap kasus; tentang kebijakan implementasi memparalelkan algoritma paling penting sambil tetap menyediakan tanda tangan kebijakan eksekusi bagi yang tidak. 

  12. Microsoft Learn, C++ standard library header files. Tentang header standar terkait multithreading yang disusun sebagai <atomic> (C++11), <mutex> (C++11), <shared_mutex> (C++14), <condition_variable> (C++11), <future> (C++11), <stop_token> / <semaphore> / <latch> / <barrier> (C++20), dan <thread> (C++11). 

  13. Microsoft Learn, Dynamic-Link Library Best Practices. Tentang DllMain yang dipanggil sementara loader lock dipegang, memberlakukan kendala serius pada API mana yang boleh dipanggil; tentang menyinkronkan dengan thread lain di dalam DllMain yang bisa deadlock; tentang memanggil LoadLibrary atau menunggu thread berakhir sebagai tindakan terlarang yang khas; tentang inisialisasi yang idealnya ditunda sejauh mungkin dan dipindah ke luar DllMain; dan tentang mendefinisikan hierarki kunci dengan loader lock di puncak. 

  14. Microsoft Learn, <thread>. Tentang header <thread> yang mendefinisikan kelas thread dan fungsi pembantu seperti sleep_for; tentang header ini yang diblokir dalam kode yang dikompilasi dengan /clr; dan tentang makro STDCPP_THREADS yang memungkinkan Anda menentukan apakah dukungan thread ada. 

Artikel terbaru dengan tag yang sama untuk mendalami topik-topik terdekat.

Halaman-halaman ini menempatkan topik dalam konteks layanan dan keputusan yang lebih luas.

Artikel ini berkaitan langsung dengan layanan berikut.

Pertanyaan yang sering diajukan

Pertanyaan yang sering muncul dalam konsultasi tentang topik artikel ini.

Bagaimana memilih antara std::mutex dan CRITICAL_SECTION / kunci SRW milik Win32?
Untuk kode C++ biasa yang mengutamakan portabilitas, std::mutex / std::shared_mutex bersama pembungkus RAII (lock_guard / scoped_lock) adalah pilihan pertama. Pakai objek sinkronisasi Win32 ketika Anda perlu menggabungkannya dengan API tunggu Win32 seperti WaitForMultipleObjects, atau ketika Anda perlu sinkronisasi lintas proses lewat objek bernama. Jika Anda memakai API Win32 secara langsung di dalam proses, default untuk kode baru adalah kunci SRW, dan CRITICAL_SECTION hanya ketika thread yang sama perlu memperolehnya secara rekursif. Memakai Mutex Win32 untuk eksklusi intra-proses adalah kesalahan klasik, karena selalu melibatkan transisi kernel dan dengan demikian lambat.
Bolehkah memakai detach() milik std::thread?
Pada dasarnya, hindari. Thread yang di-detach kehilangan segala cara untuk di-join, dan Anda kehilangan kendali apakah ia masih berjalan saat proses keluar. Ini kecelakaan klasik: thread yang terlepas terus berjalan setelah variabel statis atau heap dihancurkan, lalu menyebabkan crash saat shutdown. Bisa menunggu thread selesai adalah syarat dasar desain thread, jadi pakai jthread (yang join otomatis), atau, jika memakai thread, susun kode agar selalu join sebelum lingkup berakhir. detach hanya boleh dalam situasi sempit: thread boleh sepenanggungan dengan proses, dan Anda bisa menjamin ia sama sekali tidak menyentuh keadaan bersama.
Bisakah volatile dipakai untuk sinkronisasi antar-thread di C++?
Tidak. volatile C++ adalah kualifikasi untuk baca dan tulis yang tidak ingin dioptimasi hilang oleh kompiler — misalnya I/O terpetakan memori — dan tidak menjamin visibilitas atau urutan antar-thread. Jika beberapa thread mengakses variabel yang sama tanpa sinkronisasi, itu data race, dan itu perilaku tak terdefinisi. Pakai std::atomic untuk bendera dan penghitung yang dibagi antar-thread, dan std::mutex ketika Anda perlu melindungi beberapa variabel bersama-sama. std::atomic memberi baik keatomikan operasi maupun pengurutan berdasarkan memory_order.
std::async kelihatan nyaman, tetapi adakah jebakannya?
Jebakan terbesar adalah destruktor future. future (atau shared_future terakhir) yang terikat pada tugas yang diluncurkan dengan std::async memblokir sampai selesai jika destruktornya berjalan sementara tugas masih belum lengkap. Jika Anda membuang future yang dikembalikan tanpa menahannya, itu menjadi setara dengan eksekusi sinkron di tempat itu — kecelakaan di mana Anda bermaksud asinkron tetapi berakhir serial. Selain itu, jika Anda tidak menentukan kebijakan peluncuran, apakah pekerjaan benar-benar berjalan di thread terpisah diserahkan pada diskresi implementasi. Jika memakainya, kelola masa hidup future secara eksplisit, dan tentukan std::launch::async di mana pun Anda perlu menjamin eksekusi konkuren.

Profil penulis

Halaman perkenalan penulis artikel.

Go Komura

Direktur KomuraSoft LLC

Berspesialisasi dalam pengembangan perangkat lunak Windows, konsultasi teknis, dan investigasi bug, terutama pada proyek dengan sistem yang sudah ada dan bug yang sulit direproduksi.

Kembali ke blog