Praktik terbaik multithreading di lapangan: edisi C++ — menghilangkan kecelakaan secara struktural dengan RAII dan jthread
· Diperbarui pada: · Go Komura · Windows, Multithreading, C++, Visual Studio, Aplikasi bisnis, Investigasi bug, Desain
Riwayat revisi (1 pembaruan, terakhir pada 31 Aug 2026)
Catatan perubahan yang dilakukan pada artikel ini. Jika versi sebelumnya telah diarsipkan, versi itu tetap dapat dibaca melalui tautan permanen dengan DOI.
- Diterjemahkan ulang sebagai terjemahan lengkap dari naskah Jepang. Versi bahasa Indonesia sebelumnya adalah ringkasan yang hanya memindahkan sebagian naskah, sehingga bagian, tabel, gambar Mermaid, keterangan gambar, dan FAQ tidak ada. Semuanya dipulihkan sesuai naskah Jepang, dan klaim teknisnya sama dengan versi Jepang.
- Publikasi pertama
Mengutip artikel ini(DOI (arsip terdaftar): 10.5281/zenodo.22175862)
DOI di bawah mengarah ke versi yang telah diarsipkan sebelumnya dan mungkin berbeda dari teks saat ini. Gunakan URL halaman ini untuk merujuk teks saat ini.
Go Komura (2026). Praktik terbaik multithreading di lapangan: edisi C++ — menghilangkan kecelakaan secara struktural dengan RAII dan jthread. KomuraSoft LLC. https://comcomponent.com/id/blog/multithreading-best-practices-cpp/
- DOI (arsip terdaftar)
- 10.5281/zenodo.22175862
- DOI (versi terakhir yang didaftarkan)
- 10.5281/zenodo.22175863
“Desain yang jalan di C# mulai sesekali crash setelah dibawa ke C++.” “Kami memakai std::thread, dan ketika pengecualian dilempar seluruh aplikasi mati seketika lewat terminate.” “Kami menghentikan pekerjaan dengan bendera volatile bool, tetapi hanya di build rilis ia tidak berhenti.” — Multithreading di C++ punya ketakutan yang tidak dimiliki bahasa terkelola. Data race, apa adanya, adalah perilaku tak terdefinisi (undefined behavior). Bukan hanya nilai rusak yang mungkin terbaca; premis optimasi kompiler runtuh, dan keadaan menjadi “apa pun boleh terjadi”.
Artikel ini adalah edisi C++ dari seri multithreading di lapangan. Ditujukan kepada pengembang yang menulis aplikasi bisnis, kendali peralatan, dan DLL dalam C++ modern (C++17/20), artikel ini menurunkan prinsip desain multithread — jangan menambah thread secara langsung, kurangi state mutable bersama, terapkan disiplin kunci, rancang cara berhenti sejak awal — ke perkakas C++ dan Windows, bersama jebakan khas C++, berdasarkan sumber primer per Agustus 2026. Ditulis agar dapat dibaca sendiri. Prinsip yang sama, dijabarkan untuk bahasa lain, ada di “edisi .NET”, “edisi C”, dan “edisi Java”.
1. Kesimpulan dulu
- Di C++, data race bukan “nilai rusak yang mungkin terbaca” — itu perilaku tak terdefinisi. Tidak menyisakan satu pun akses mutable bersama yang tidak tersinkronisasi adalah syarat mutlak, lebih dari di bahasa lain.1
- Jangan memakai
std::threadsecara mentah. Jika destruktorstd::threadberjalan sementara thread masih joinable,std::terminatemembunuh proses seketika.std::jthreadC++20 otomatis join di destruktornya dan membawa mekanisme permintaan berhenti (stop_token).23 - Kunci selalu dipegang lewat RAII. Berhenti menulis
mtx.lock()secara manual; pakailock_guard/scoped_lock. Destruktor melepaskan kunci dengan andal meski pengecualian dilempar. Ketika beberapa kunci diperoleh sekaligus,scoped_lockmengurusnya dengan algoritma hindar-deadlock.4 volatilebukan alat sinkronisasi. Bendera dan penghitung bersama memakaistd::atomic; perlindungan beberapa variabel memakaistd::mutex.std::atomicmenyediakan keatomikan dan pengurutan berdasarkanmemory_order.5- Tunggu dengan
waitberpredikat milikcondition_variable. Condition variable punya spurious wakeup (bangun tanpa pemberitahuan), jadiwaittanpa predikat adalah sarang kesalahan.6 - Bentuk dasar cara berhenti adalah
jthread+stop_token(C++20). Di lingkungan sebelumnya, rakit penghentian kooperatif denganstd::atomic<bool>plus condition variable. Anggap penghentian paksa thread sebagai sesuatu yang tidak ada di dunia C++.3 - Ketahui bahwa destruktor
futurememblokir sebelum memakaistd::async. Membuang nilai kembali setara dengan eksekusi serial.7 - Objek sinkronisasi Win32 hanya dipakai untuk “kerja sama dengan API tunggu Win32” dan “lintas proses”. Di luar itu, menulis ke pustaka standar lebih menguntungkan untuk portabilitas dan pemeliharaan.8
Pada diagram, garis utuh menunjukkan relasi yang selalu berlaku dan garis putus-putus menunjukkan relasi bersyarat (syaratnya ada pada penjelasan masing-masing relasi di halaman rincian). Daftar lengkap relasi (total 27, beserta bukti dan tingkat kepastian) serta definisi konsep utama dikumpulkan di halaman rincian peta pengetahuan (dalam bahasa Jepang). Data: JSON-LD / Turtle
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 berubah tergantung urutan beberapa thread mencapai potongan kode tertentu. Contoh klasiknya adalah penghitung bersama: satu ekspresi ++count terurai 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 tertimpa dan hilang oleh penulisan kembali yang lain. Hasil berubah setiap kali dijalankan, dan hasil mana yang muncul tidak dapat diprediksi.
sequenceDiagram
participant A as Thread A
participant M as Variabel bersama count
participant B as Thread B
Note over M: count = 10
A->>M: Baca (10)
B->>M: Baca (10)
A->>A: Tambah di lokal (11)
B->>B: Tambah di lokal (11)
A->>M: Tulis kembali (11)
B->>M: Tulis kembali (11)
Note over M: Dua increment terjadi,<br/>tetapi count = 11<br/>Increment thread A hilang
Gambar 1: Kondisi balapan klasik di mana increment pada penghitung bersama hilang. Jika thread lain menyisip di antara tiga langkah ++count, pihak yang menulis kembali belakangan menimpa yang lain
Deadlock adalah keadaan di mana dua thread saling menunggu kunci yang dipegang pihak 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.
flowchart LR
A["Thread A<br/>sedang memegang kunci 1"] -->|"menunggu pelepasan kunci 2"| B["Thread B<br/>sedang memegang kunci 2"]
B -->|"menunggu pelepasan kunci 1"| A
Gambar 2: Tunggu melingkar sebuah deadlock. Begitu panah menunggu membentuk cincin, semua thread di dalam cincin itu berhenti selamanya
Yang merepotkan adalah keduanya bergantung pada waktu. Jalinan yang di mesin pengembangan hanya mengenai sekali dalam puluhan ribu jalan bisa terjadi setiap hari di mesin pelanggan, dengan jumlah inti dan waktu yang berbeda. “Tidak mereproduksi saat debugger terpasang” dan “hilang setelah log ditambah” terjadi karena observasi sendiri mengubah waktu — itu perilaku klasik bug balapan. Itulah sebabnya setiap prinsip dalam artikel ini mengarah ke satu arah: kurangi tempat yang perlu disinkronkan, sebelum “menyinkronkan dengan benar”.
2.1. Di C++, data race langsung menjadi perilaku tak terdefinisi
Di atas itu, C++ punya lapisan yang lebih dalam daripada bahasa lain. Menurut standar C++, jika beberapa thread mengakses suatu lokasi memori tanpa sinkronisasi dan setidaknya satu menulis, itu data race, dan itu perilaku tak terdefinisi. Bab konkurensi C++ Core Guidelines (CP.2, “Avoid data races”) menempatkan ini sebagai aturan mutlak yang pertama.1 Perilaku tak terdefinisi bukan cerita lunak “nilai lama atau nilai baru yang terbaca”. Kompiler mengoptimasi dengan premis data race tidak ada, jadi perilaku yang tidak terbayangkan dari kode sumber — pemeriksaan syarat yang hilang dari loop, penulisan yang diurut ulang atau digabung — terjadi secara sah. Kecelakaan klasik “bendera berhenti volatile bool hanya gagal di build rilis” adalah kasus buku teks ini.
2.2. RAII adalah fondasinya
Premis khas C++ yang lain adalah pengecualian dan pengelolaan sumber daya. C++ tidak punya finally; sebagai gantinya ada RAII (pelepasan otomatis lewat destruktor), dan perkakas multithreading dirancang dengan asumsi RAII. “Kelola kunci lewat masa hidup objek”; “jamin join thread lewat masa hidup objek juga” — mengikuti aliran itu adalah fondasi untuk menulis multithread dengan aman di C++.
3. Cara memulai thread — jebakan thread dan jthread
3.1. Destruktor std::thread adalah “spesifikasi yang mencelakakan”
std::thread punya jebakan yang dikenal luas. Jika destruktor 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 tercapai; destruktor worker memanggil terminate
}
Agar aman terhadap pengecualian, join harus dijamin dengan try/catch — keadaan yang menyimpang: bahasa RAII, tetapi justru thread 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 diganti ke std::jthread.2 Di MSVC, <stop_token> dan jthread tersedia dari Visual Studio 2019 16.9 ke atas.3
flowchart TB
T["Thread telah dimulai"] --> Q{"Apa yang terjadi<br/>saat lingkup berakhir?"}
Q -->|"std::thread<br/>belum di-join maupun di-detach"| X["std::terminate<br/>proses mati seketika"]
Q -->|"std::thread<br/>sudah di-join"| OK1["Join dengan aman"]
Q -->|"std::jthread (C++20)"| OK2["request_stop + join otomatis<br/>aman meski pengecualian dilempar"]
Gambar 3: Masa hidup objek thread dan cara berakhirnya. std::thread ditetapkan mati seketika jika join terlupa, jadi dari C++20 ke atas jadikan jthread sebagai default
Pada dasarnya, jangan memakai detach(). Thread yang kehilangan sarana join menjadi penyebab klasik crash saat shutdown, berlomba dengan penghancuran variabel statis dan heap ketika proses berakhir.
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: tugas asinkron sekali pakai dan penerimaan hasilnya. Namun ada spesifikasi penting:future(ataushared_futureterakhir) yang terikat pada tugas yang diluncurkan lewatstd::asyncmemblokir sampai selesai jika destruktornya berjalan sementara tugas belum lengkap.7 Untuk pekerjaan yang benar-benar diluncurkan denganstd::launch::async, membuang future hasil dikembalikan setara dengan eksekusi sinkron di titik itu. Lebih buruk, jika kebijakan peluncuran tidak ditentukan, implementasi boleh memilihdeferred(eksekusi tertunda) sebagai default; dalam hal itu, jika tidak ada yang memanggilget()/wait(), pekerjaan tidak pernah dijalankan sama sekali dan hilang diam-diam. Jika eksekusi konkuren harus dijamin, tentukanstd::launch::asyncsecara eksplisit, dan biarkan pemilik mengelola masa hidup future.- PPL (Parallel Patterns Library)
concurrency::parallel_for/parallel_for_each: penerapan paralel pada setiap elemen koleksi. Namun, jika pekerjaan 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 (bukan semuanya).11 Catat bahwa jika pengecualian lolos dari pemrosesan elemen di bawah kebijakan eksekusi,std::terminatedipanggil. Menempatkan batas pengecualian sendiri (try/catch) di dalam callback mengikuti pemikiran yang sama dengan batas thread di bab 6.
Garis “menunggu I/O bukan alasan menambah thread” tetap berlaku. Untuk kode native Windows, I/O OVERLAPPED dan IOCP adalah wadahnya (mekanismenya ada di “Kedalaman I/O Windows, bagian 2”).
4. Mengurangi state mutable bersama — membagi, melewatkan menurut nilai, const, dan antrean
Persaingan hanya muncul ketika “beberapa thread” dan “data mutable bersama” keduanya ada. Jumlah thread ditentukan persyaratan, jadi yang bisa dipotong desain adalah berbagi. Caranya jatuh ke tiga rumpun — membagi, membuat tak berubah, dan menyerahkan data — dan berikut cara menulisnya di C++.
Membagi. Pada 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”, sehingga biaya sinkronisasi dan jendela race menjadi jauh lebih kecil — beda orde besaran. Langkah gabungan tunggal itu boleh memakai std::mutex atau fetch_add pada std::atomic.
Melewatkan menurut nilai. Jika data yang dibutuhkan thread diserahkan 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 memakai tangkap eksplisit, menurut salinan atau move sebagai aturan. Namun “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 yang ditunjuk 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 dapat dibagi tanpa sinkronisasi jika dijadikan berbagi const yang tidak ditulis ulang setelah konstruksi (std::shared_ptr<const Config>, misalnya). Catatan: yang dilarang shared_ptr<const T> hanya mutasi lewat handle itu. Jika alias non-const tersisa di tempat lain, atau anggota mutable ditulis ulang, persaingan tetap ada — jadi rancang sampai “setelah konstruksi selesai, lepaskan referensi non-const dan jangan biarkan siapa pun menulis sesudahnya”. Sekadar memutuskan “ketika perubahan dibutuhkan, bangun objek baru dan tukar, alih-alih menulis ulang di tempat” menghapus satu potong state mutable yang harus dijaga (pengelolaan masa hidup tukar itu sendiri, lihat peringatan di 5.2).
Menyerahkan lewat antrean. Alirkan data antar-thread lewat antrean produsen/konsumen, bukan variabel bersama. Standar C++ tidak punya 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: setiap Push menunggu selamanya
throw std::invalid_argument("capacity must be positive");
}
// Jika penuh, menunggu sampai ada ruang (atau permintaan berhenti). 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 masukkan setelah berhenti dimulai
queue_.push(std::move(item));
}
not_empty_.notify_one(); // beritahu di luar kunci
return true;
}
// Menunggu permintaan berhenti (stop_token) atau kedatangan elemen. nullopt saat 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 elemen 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_; // pilih any agar wait yang mendukung stop_token dapat dipakai
std::condition_variable_any not_full_;
std::queue<T> queue_;
};
Ada dua poin desain. Pertama, beri batas kapasitas dan buat sisi produsen menunggu ketika penuh. Antrean tanpa batas atas menjadi bom waktu pada susunan di mana produksi lebih cepat daripada konsumsi: “tetap berjalan”, tetapi memori terus tumbuh. Push yang memblokir saat penuh bekerja sebagai tekanan balik (backpressure) alami, menyampaikan kelebihan beban ke hulu secara mekanis. Kedua, karena condition variable punya spurious wakeup (bangun tanpa pemberitahuan), selalu panggil wait dengan predikat. Bentuk berpredikat wait menjalankan “loop sampai syarat benar” di dalamnya untuk Anda.6
5. Disiplin kunci — RAII dan scoped_lock
Bahkan setelah state mutable bersama dikurangi, sering tidak bisa nol. Pakai eksklusi untuk yang tetap bersama, tetapi kunci tanpa disiplin hanya menyembunyikan persaingan.
Pertama, pikirkan satuan penguncian bukan sebagai “rentang kode” melainkan sebagai “data”. Pasangkan satu mutex pada setiap himpunan data mutable yang ingin dilindungi (jadikan anggota private, tidak diekspos ke luar), dan ambil mutex yang sama di setiap tempat yang menyentuh data itu — tabel korespondensi yang rusak itulah wujud bug balapan. Dan satu-satunya yang boleh dilakukan sambil memegang kunci adalah membaca dan menulis data yang dilindunginya. I/O berkas, panggilan jaringan, dan callback (pemanggilan ke kode eksternal) sambil memegang kunci tidak hanya memperpanjang waktu pegang — mereka membuka jalur di mana pihak yang dipanggil mencoba mengambil kunci lain lalu deadlock. Siapkan di luar kunci; 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 gagal melepaskan kunci pada pengecualian atau pengembalian dini. Perolehan dan pelepasan kunci selalu diserahkan pada pembungkus RAII.
| Pembungkus | Kegunaan |
|---|---|
std::lock_guard |
Memegang satu mutex selama lingkup — bentuk paling dasar |
std::scoped_lock (C++17) |
Memperoleh beberapa mutex sekaligus. Menyelesaikan masalah urutan dengan algoritma hindar-deadlock4 |
std::unique_lock |
Ketika ingin melepaskan dan memperoleh kembali di tengah jalan, atau melewatkannya ke condition_variable::wait |
Ketika ada dua kunci atau lebih, urutan perolehan yang tertukar antar-thread adalah pola deadlock klasik (tunggu melingkar di Gambar 2 lahir seperti ini). Perbaikannya adalah membuat aturan “semua thread mengambil dalam urutan yang sama”, tetapi ketika diperoleh 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.4 Pada situasi seperti transfer antar dua objek di mana “keduanya ingin dikunci”, jangan mengambilnya satu per satu — selalu ambil bersama.
void Transfer(Account& from, Account& to, int amount)
{
if (&from == &to) return; // rekening yang sama: tidak melakukan apa pun (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, mutex non-rekursif yang sama dilewatkan ke scoped_lock dua kali, yang menyebabkan hang atau perilaku tak terdefinisi. Fungsi yang “mengunci keduanya” harus selalu menolak kasus kedua argumen merujuk objek yang sama.
Untuk data yang “sering dibaca, jarang ditulis”, std::shared_mutex (C++17) dapat dipakai sebagai kunci baca/tulis.12 recursive_mutex adalah tipe agar “perolehan ulang oleh thread yang sama tidak rusak”, tetapi desain yang membutuhkan perolehan rekursif sering tanda bahwa batas tanggung jawab kunci sudah kabur — tinjau strukturnya lebih dulu.
5.2. Posisi atomic yang benar
std::atomic menyediakan operasi tak terbagi pada satu variabel, plus pengurutan berdasarkan memory_order.5 Perannya sama dengan Interlocked di edisi .NET: pembaruan satu variabel, seperti penghitung atau bendera. Ia tidak dapat menjaga beberapa variabel tetap konsisten bersama, jadi untuk itu kembali ke std::mutex.
Menukar pointer mentah (std::atomic<T*>) punya jebakan tersendiri. Tukarnya sendiri tak terbagi, tetapi tidak ada yang menjaga masa hidup objek lama setelah diganti. Jika pembaca memuat pointer lama tepat sebelum penulis menukarnya dan delete, itu akses ke memori yang sudah dibebaskan. Jika desain “tukar dan bagikan objek tak berubah” ingin dilakukan 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 diulang: volatile bukan alat sinkronisasi thread. Pemrograman bebas-kunci di mana memory_order ditentukan sendiri adalah wilayah ahli, yang punya alasan sah untuk merilekskannya dari default (seq_cst) plus sarana verifikasi. Di 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 diuraikan di edisi C). Jadi cara berhenti dirakit dengan perkakas C++ sebagai penghentian kooperatif — pihak yang menghentikan hanya mengeluarkan permintaan; kapan dan bagaimana berakhir diputuskan thread sendiri pada titik yang rapi untuk dibersihkan; selesainya join yang dihitung sebagai “berhenti”.
Di C++20, std::jthread membawa mekanisme berhenti bawaan. Memanggil request_stop() menandai permintaan berhenti pada std::stop_token yang diterima fungsi thread, dan loop memeriksanya. wait milik condition_variable_any dapat menerima stop_token secara langsung, jadi “thread yang menunggu pekerjaan tiba” juga dapat dibangunkan seketika oleh permintaan berhenti (BlockingQueue::Pop di bab 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 menugaskan alih-alih menolak, thread baru
// mulai berjalan, lalu sementara menunggu
// thread lama berhenti, dua worker
// 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
} catch (...) {
ReportError(std::current_exception()); // catat satu kegagalan dan lanjut
}
}
}
} catch (...) {
// Garis pertahanan terakhir di batas thread (kegagalan Pop atau move juga ditangkap di sini).
// Jika pengecualian lolos dari sini, std::terminate meruntuhkan seluruh proses,
// jadi pastikan ReportError sendiri tidak melempar
ReportError(std::current_exception());
}
});
}
// Stop eksplisit tidak diperlukan:
// destruktor Worker → destruktor jthread → request_stop() + join()
private:
BlockingQueue<WorkItem> queue_{100}; // kapasitas dibatasi (bab 4)
std::jthread thread_;
};
flowchart TB
OWNER["Pihak yang menghentikan<br/>(destruktor jthread atau request_stop)"] -->|"permintaan berhenti"| ST["stop_token"]
ST --> P["Loop komputasi:<br/>memeriksa stop_requested()"]
ST --> W["Thread yang menunggu:<br/>condition_variable_any::wait(lock, st, pred)<br/>bangun seketika"]
P --> E["Membersihkan lalu return sendiri"]
W --> E
E --> J["join menyelesaikan pertemuan<br/>baru di sini bisa disebut berhenti"]
Gambar 4: Penghentian kooperatif C++20. Pihak yang menghentikan hanya mengeluarkan permintaan; cara berakhir diputuskan thread sendiri; selesainya join yang dihitung sebagai berhenti
Satu poin lagi: try/catch di dalam worker tidak boleh 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 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 pekerjaan memblokir di dalam (tunggu jaringan, komputasi panjang, dan sebagainya) dan titik itu tidak dapat mengamati permintaan berhenti, join implisit destruktor akan menunggu selamanya satu item itu selesai. Penghentian kooperatif baru utuh setelah token sampai ke setiap tempat yang menunggu. Jika pekerjaan mencakup panggilan eksternal yang tidak dapat disela, pasang batas waktu dan beri batas atas berapa lama satu item boleh berjalan.
Di lingkungan C++17 dan sebelumnya, bentuk yang sama dirakit secara manual dengan bendera berhenti std::atomic<bool> plus notify_all milik condition_variable. Poin kuncinya adalah memasukkan pemeriksaan bendera berhenti ke predikat condition variable (jika hanya menandai bendera dan lupa memberitahu, thread yang menunggu tidak pernah bangun).
7. Perhatian khusus Windows — garis batas dengan API Win32
7.1. Memilah pustaka standar dan objek sinkronisasi Win32
Dokumentasi Microsoft merekomendasikan std::mutex / std::shared_mutex untuk kode C++ yang mengutamakan portabilitas, dan membatasi pemakaian objek sinkronisasi Win32 pada “ketika API tunggu Win32 dibutuhkan” dan “sinkronisasi lintas proses”.8
| Situasi | Pilihan |
|---|---|
| Eksklusi biasa di dalam proses | 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 |
| Kunci di dalam proses yang memakai API Win32 secara langsung | Kunci SRW (CRITICAL_SECTION hanya ketika rekursi dibutuhkan)8 |
Desain konkret mengecualikan shared memory lintas proses ada di “Jebakan shared memory 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 menjadi penyebab deadlock atau perilaku tak menentu. Inisialisasi yang memulai atau meng-join thread keluarkan dari DllMain (ke fungsi inisialisasi eksplisit).13
7.3. Thread UI dan apartment COM
Aplikasi desktop Windows punya kendala kuat yang berlaku terlepas dari bahasa. Jendela dan kontrol hanya boleh disentuh thread yang membuatnya (thread UI). Windows mengirimkan pesan jendela ke antrean pesan thread yang membuat jendela itu, jadi pembuatan dan operasi UI harus dipusatkan pada thread itu. Ketika layar ingin diperbarui 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, ketika COM terlibat, diuraikan di “Dasar COM STA/MTA - model thread dan cara menghindari hang”. Catat juga bahwa pada kode C++/CLI yang dikompilasi dengan /clr, header thread standar seperti <thread> dan <mutex> diblokir.14
8. Verifikasi dan debug — bersiap dengan asumsi “tidak mereproduksi”
Bug balapan tidak dapat diharapkan ketemu di pengujian. Pengujian biasa menghitung jalan yang “kebetulan tidak balapan” sebagai lulus. Persiapan dipikirkan dalam tiga lapis.
Garis pertahanan pertama adalah prinsip desain sejauh ini, apa adanya. Dalam tinjauan, konfirmasikan dengan tabel: data mutable mana yang dibagi, mutex mana yang melindungi masing-masing, apakah urutan perolehan beberapa kunci unik (atau diambil bersama dengan scoped_lock), dan di mana jalur berhenti. Desain yang tabel ini tidak dapat ditulis belum selesai, seberapa pun baiknya ia berjalan saat ini.
Kedua, buat keadaan abnormal dapat diamati, jangan disembunyikan. Pasang batas waktu dengan try_lock_for milik timed_mutex atau wait_for milik condition_variable pada kunci yang seharusnya tidak pernah gagal diperoleh, dan catat batas waktu sebagai anomali — itu mengubah hang abadi menjadi kegagalan yang dapat dideteksi. Pengecualian yang ditangkap di try/catch batas thread (bab 6) selalu dicatat. Ketika hang atau crash terjadi di lapangan, ambil dump, periksa tumpukan setiap thread, dan lihat apakah tunggu kunci mereka membentuk siklus. Penyiapan dump dan log diuraikan di “Merancang log dan dump saat aplikasi Windows crash”.
Ketiga, goyangkan di bawah beban. Uji stres — berjalan lama dengan paralelisme lebih banyak daripada jumlah inti, mengacak urutan pemrosesan, menyisipkan tunda buatan — adalah sarana praktis agar “jackpot” balapan lebih mudah mengenai di mesin pengembangan. Bug yang hilang di build debug sering mereproduksi pada build rilis yang dioptimasi plus beban tinggi.
9. Ringkasan — daftar periksa edisi C++
Tumpuk pemeriksaan khas C++ di atas prinsip yang sama bagi setiap bahasa (jangan membuat thread secara langsung, minimalkan state mutable bersama, korespondensi satu-ke-satu antara kunci dan data, penghentian kooperatif).
- Apakah
std::threaddipakai secara mentah (bisakah menjadijthread? Apakah join dijamin bahkan di jalur pengecualian?) - Apakah
detach()tidak dipakai? - Apakah tangkap lambda eksplisit, dan apakah variabel yang ditangkap referensi hidup lebih lama dari thread?
- Bisakah dikatakan dengan yakin bahwa tidak ada satu pun akses mutable bersama yang tidak tersinkronisasi (= perilaku tak terdefinisi)?
- Apakah tidak ada
lock()/unlock()yang ditulis tangan, dan beberapa kunci diambil bersama denganscoped_lock? - Apakah setiap
condition_variable::waitmemakai predikat? - Apakah
volatiletidak dipakai untuk bendera bersama (sudahstd::atomic)? - Apakah jalur berhenti dirancang di sekitar
stop_token(atau bendera atomic plus pemberitahuan), dengan selesainya join mengonfirmasi pertemuan? - Apakah
futuredaristd::asynctidak dibuang? - Apakah
DllMaintidak memulai, menyinkronkan, atau meng-join thread?
Multithreading C++ adalah pekerjaan di tepi jurang “perilaku tak terdefinisi”, tetapi balik itu berarti mengikuti RAII dan aliran pustaka standar dengan jujur sudah menjauhkan dari tepi itu. jthread, scoped_lock, wait berpredikat, atomic — memilih default perkakas yang benar, di C++, adalah praktik prinsip desain itu sendiri.
Artikel terkait
- Praktik terbaik multithreading di lapangan: edisi .NET
- Praktik terbaik multithreading di lapangan: edisi C
- Praktik terbaik multithreading di lapangan: edisi Java
- Praktik membungkus DLL native dengan C++/CLI
- Jebakan shared memory dan praktik terbaik di lapangan
- Dasar COM STA/MTA - model thread dan cara menghindari hang
- Kedalaman I/O Windows (bagian 2) — I/O sinkron dan asinkron: arti OVERLAPPED yang sesungguhnya
Area konsultasi terkait
KomuraSoft LLC menangani tinjauan desain multithread untuk aplikasi dan DLL C++, investigasi bug yang berasal dari persaingan seperti “sesekali crash” atau “hanya aneh di build rilis” (analisis dump), dan konsultasi migrasi kode thread warisan ke C++ modern.
- Konsultasi teknis dan tinjauan desain
- Investigasi bug dan analisis penyebab
- Pengembangan aplikasi Windows
- Hubungi kami
Tautan referensi
-
ISO C++, C++ Core Guidelines - CP: Concurrency and parallelism. Tentang CP.1 (anggap kode Anda akan berjalan multithread) dan CP.2 (hindari data race) yang ditetapkan sebagai aturan pembuka bab konkurensi dan paralelisme; tentang tidak ada jaminan yang berlaku begitu data race ada; dan tentang aturan desain kode konkuren — rentang pegang kunci, pemakaian RAII, dan sebagainya — yang disistematisasi di sana. ↩ ↩2
-
cppreference.com, std::jthread. Tentang jthread C++20 yang berbeda dari std::thread karena destruktornya secara otomatis memanggil request_stop() lalu join; tentang dapat menerima std::stop_token sebagai argumen terdepan fungsi thread; dan tentang ini yang menjamin join maupun permintaan berhenti bahkan ketika pengecualian dilempar. ↩ ↩2
-
Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. Tentang P0660R10 (<stop_token> dan jthread) serta 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
-
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 pelepasan yang andal meski pengecualian dilempar; dan tentang lock_guard/unique_lock yang juga menjadi pilihan ketika hanya satu mutex terlibat. ↩ ↩2 ↩3
-
Microsoft Learn, <atomic>. Tentang operasi atomik yang tak terbagi, sehingga thread lain hanya dapat 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
-
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 — sehingga sisi yang menunggu harus memeriksa syarat secara eksplisit saat kembali, dan wait(lock, pred) berpredikat menjalankan loop itu atas nama pemanggil; dan tentang condition_variable_any yang dapat digabung dengan tipe mutex apa pun. ↩ ↩2
-
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 shared state menjadi ready jika destruktornya berjalan sementara tugas belum lengkap — perilaku yang secara eksplisit dicatat dalam catatan standar. ↩ ↩2
-
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 kode baru di dalam proses adalah kunci SRW, dengan CRITICAL_SECTION disimpan untuk ketika perolehan rekursif dibutuhkan; dan memakai Mutex untuk sinkronisasi di dalam proses adalah “kesalahan umum” karena selalu melibatkan transisi kernel. ↩ ↩2 ↩3
-
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 selesai sebelum objek thread dihancurkan. ↩
-
Microsoft Learn, Best Practices in the Parallel Patterns Library. Tentang paralelisme yang sebaiknya diekspresikan pada tingkat setinggi mungkin (loop luar); tentang overhead penjadwalan fork/join yang dapat mengungguli keuntungan eksekusi paralel pada loop paralel di mana pekerjaan setiap iterasi kecil atau tidak seimbang; dan tentang kecenderungan itu yang semakin kuat seiring jumlah prosesor meningkat. ↩
-
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. ↩
-
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). ↩
-
Microsoft Learn, Dynamic-Link Library Best Practices. Tentang DllMain yang dipanggil sementara loader lock dipegang, sehingga API yang boleh dipanggil sangat dibatasi; tentang menyinkronkan dengan thread lain di dalam DllMain yang dapat deadlock; tentang pemanggilan LoadLibrary dan menunggu thread berakhir sebagai larangan khas; tentang inisialisasi yang sebaiknya ditunda sejauh mungkin dan dikeluarkan dari DllMain; dan tentang mendefinisikan hierarki kunci dengan loader lock di puncak. ↩
-
Microsoft Learn, <thread>. Tentang header <thread> yang mendefinisikan kelas thread dan fungsi pembantu seperti sleep_for; tentang header ini yang diblokir pada kode yang dikompilasi dengan /clr; dan tentang makro STDCPP_THREADS yang memungkinkan menentukan apakah dukungan thread ada. ↩
Artikel terkait
Artikel terbaru dengan tag yang sama untuk mendalami topik-topik terdekat.
Praktik terbaik multithreading di lapangan: edisi C — menulis aman mengikuti cara Win32 API
Pola mapan multithreading C × Win32 adalah pembuatan thread dengan _beginthreadex, kunci SRW dan variabel kondisi, Interlocked, serta des...
Praktik terbaik multithreading di lapangan: edisi .NET — yang harus diputuskan sebelum menambah thread
Merangkum pola desain .NET/C# agar aplikasi multithread tidak sesekali crash atau macet: jangan membuat thread sendiri, bertumpu pada Tas...
Spurious wakeup — mengapa condition variable "bangun tanpa notifikasi" dan cara menunggu yang benar di Windows
wait pada condition variable dapat bangun meskipun notifikasi tidak datang (spurious wakeup). Artikel ini mengurai alasan spesifikasi men...
Praktik terbaik multithreading di lapangan: edisi Java — pola mapan di era virtual thread
Di Java, pola mapan multithreading adalah tidak membuat thread secara langsung, melainkan bertumpu pada ExecutorService dan virtual threa...
API thread pool Win32 — konkurensi tanpa membuat thread, lewat CreateThreadpoolWork
Apakah kode native masih menumpuk CreateThread? Artikel ini menjelaskan API thread pool Win32 yang didesain ulang di Vista: empat objek w...
Topik terkait
Halaman-halaman ini menempatkan topik dalam konteks layanan dan keputusan yang lebih luas.
Topik teknis Windows
Portal tentang pengembangan Windows, investigasi bug, dan pemanfaatan aset yang ada.
Layanan yang terkait dengan topik ini
Artikel ini berkaitan langsung dengan layanan berikut.
Pengembangan aplikasi Windows
Aplikasi bisnis, integrasi perangkat, dan alat komunikasi, dari kebutuhan hingga pengembangan.
Pertanyaan yang sering diajukan
Pertanyaan yang sering muncul dalam konsultasi tentang topik artikel ini.
- Bagaimana memilah std::mutex dengan CRITICAL_SECTION dan kunci SRW milik Win32?
- Pada kode C++ biasa yang mengutamakan portabilitas, kandidat pertama adalah std::mutex / std::shared_mutex bersama pembungkus RAII (lock_guard / scoped_lock). Objek sinkronisasi Win32 dipilih ketika ingin digabung dengan API tunggu Win32 seperti WaitForMultipleObjects, atau ketika sinkronisasi lintas proses lewat objek bernama diperlukan. Jika API Win32 dipakai langsung di dalam proses, default kode baru adalah kunci SRW, dan CRITICAL_SECTION hanya ketika thread yang sama perlu memperoleh kunci secara rekursif. Memakai Mutex Win32 untuk eksklusi di dalam proses adalah kesalahan klasik: selalu melibatkan transisi kernel, sehingga lambat.
- Bolehkah memakai detach() milik std::thread?
- Pada dasarnya, hindari. Thread yang di-detach kehilangan sarana untuk di-join, dan tidak lagi bisa dikendalikan apakah masih berjalan saat proses berakhir. Kecelakaan klasiknya: thread yang sudah di-detach terus berjalan setelah variabel statis atau heap dihancurkan, lalu crash saat shutdown. Mampu menunggu thread selesai adalah syarat dasar desain thread, jadi pakai jthread (join otomatis), atau jika memakai thread, susun kode agar selalu join sebelum lingkup berakhir. detach hanya boleh dalam situasi sempit: nasib thread boleh sama dengan proses, dan dapat dijamin bahwa thread itu sama sekali tidak menyentuh state bersama.
- Bisakah volatile dipakai untuk sinkronisasi antar-thread di C++?
- Tidak. volatile di C++ adalah kualifikasi untuk baca-tulis yang tidak ingin dioptimasi kompiler — misalnya I/O terpetakan memori — dan tidak menjamin visibilitas atau pengurutan 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 jika beberapa variabel perlu dilindungi bersama. std::atomic menyediakan baik keatomikan operasi maupun pengurutan berdasarkan memory_order.
- std::async kelihatan praktis, 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 belum lengkap. Jika future hasil dikembalikan dibuang tanpa ditahan, eksekusi di tempat itu setara dengan eksekusi sinkron — kecelakaan di mana yang dimaksud asinkron justru menjadi serial. Selain itu, jika kebijakan peluncuran tidak ditentukan, 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 tempat yang harus dijamin berjalan secara 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.