Praktik terbaik multithreading di lapangan: edisi C++ — menghapus kecelakaan secara struktural dengan RAII dan jthread
· Go Komura · 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::threadtelanjang. Jika destruktorstd::threadberjalan sementara thread masih joinable,std::terminatemembunuh proses seketika.std::jthreadC++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; pakailock_guard/scoped_lock. Destruktor melepaskan kunci dengan andal bahkan jika pengecualian dilempar. Ketika memperoleh beberapa kunci sekaligus,scoped_lockmengurusnya dengan algoritma hindar-deadlock.4 volatilebukan alat sinkronisasi. Pakaistd::atomicuntuk bendera dan penghitung bersama, danstd::mutexuntuk melindungi beberapa variabel bersama-sama.std::atomicmemberi baik keatomikan maupun pengurutan berdasarkanmemory_order.5- Lakukan tunggu rendezvous dengan
waitberbentuk predikat milikcondition_variable. Condition variable rentan spurious wakeup (bangun tanpa diberitahu), jadi memanggilwaittanpa 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 denganstd::atomic<bool>plus condition variable. Anggap penghentian paksa thread sebagai sesuatu yang di dunia C++ sama sekali tidak ada.3- Ketahui bahwa destruktor
futurebisa memblokir sebelum memakaistd::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.
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 secara lokal (11)
B->>B: Tambah secara lokal (11)
A->>M: Tulis kembali (11)
B->>M: Tulis kembali (11)
Note over M: Dua increment terjadi,<br/>tetapi count = 11 — penambahan Thread A hilang
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.
flowchart LR
A["Thread A<br/>memegang kunci 1"] -->|"menunggu memperoleh kunci 2"| B["Thread B<br/>memegang kunci 2"]
B -->|"menunggu memperoleh kunci 1"| A
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
flowchart TB
T["Thread telah dimulai"] --> Q{"Apa yang terjadi<br/>ketika lingkup keluar?"}
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 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(ataushared_futureterakhir) yang terikat pada tugas yang diluncurkan lewatstd::asyncmemblokir sampai selesai jika destruktornya berjalan sementara tugas masih belum lengkap.7 Untuk pekerjaan yang benar-benar diluncurkan denganstd::launch::async, membuang future yang dikembalikan setara dengan eksekusi sinkron di titik itu. Lebih buruk, jika Anda tidak menentukan kebijakan peluncuran, implementasi bebas memilihdeferred(eksekusi malas) secara default; dalam hal itu, jika tidak ada yang memanggilget()/wait(), pekerjaan tidak pernah dijalankan sama sekali dan hilang diam-diam. Jika Anda ingin menjamin eksekusi konkuren, tentukanstd::launch::asyncsecara 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::terminatedipanggil. 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_;
};
flowchart TB
OWNER["Sisi yang menghentikan<br/>- destruktor jthread, atau request_stop"] -->|"permintaan berhenti"| ST["stop_token"]
ST --> P["Loop hitung:<br/>memeriksa stop_requested()"]
ST --> W["Thread yang menunggu:<br/>condition_variable_any::wait(lock, st, pred)<br/>bangun segera"]
P --> E["Membersihkan dan kembali sendiri"]
W --> E
E --> J["join menyelesaikan rendezvous<br/>baru 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.
- Apakah
std::threaddipakai telanjang (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 Anda katakan dengan yakin bahwa tidak ada satu pun akses berubah bersama yang tidak tersinkronisasi (= perilaku tak terdefinisi) di mana pun?
- Apakah tidak ada
lock()/unlock()yang ditulis tangan, dan beberapa kunci diambil bersama denganscoped_lock? - Apakah setiap
condition_variable::waitdipakai dengan predikat? - Apakah
volatiletidak dipakai untuk bendera bersama (apakahstd::atomicsebagai gantinya)? - Apakah jalur berhenti dirancang di sekitar
stop_token(atau bendera atomic plus pemberitahuan), dengan selesainya join mengonfirmasi rendezvous? - Apakah
futuredaristd::asynctidak dibuang? - Apakah
DllMainbebas 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
- Praktik terbaik multithreading di lapangan: edisi .NET — Apa yang diputuskan sebelum menambah thread
- Praktik terbaik multithreading di lapangan: edisi C — menulis dengan aman cara API Win32
- Praktik terbaik multithreading di lapangan: edisi Java — konvensi era thread virtual
- Memanggil DLL native dari C#: pembungkus C++/CLI vs P/Invoke
- Jebakan memori bersama dan praktik terbaik di lapangan
- Dasar COM STA/MTA - Model threading 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 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.
- Konsultasi teknis dan tinjauan desain
- Investigasi bug dan akar masalah
- Pengembangan aplikasi Windows
- Hubungi kami
Tautan referensi
-
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
-
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
-
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
-
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
-
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
-
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
-
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
-
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
-
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. ↩
-
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. ↩
-
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, 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. ↩
-
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 terkait
Artikel terbaru dengan tag yang sama untuk mendalami topik-topik terdekat.
Praktik terbaik multithreading praktis: edisi C — menulis dengan aman cara Win32 API
Pendekatan mapan untuk multithreading di C dengan Win32 adalah pembuatan thread lewat _beginthreadex, kunci SRW dan variabel kondisi, fun...
Praktik terbaik multithreading di lapangan: edisi Java — konvensi era virtual thread
Di Java, praktik yang mapan untuk multithreading adalah tidak pernah membuat thread secara langsung, melainkan bertumpu pada ExecutorServ...
Win32 Thread Pool API — konkurensi tanpa membuat thread, lewat CreateThreadpoolWork
Apakah Anda menebar panggilan CreateThread di seluruh kode native? Artikel ini menjelaskan Win32 thread pool API yang didesain ulang di V...
DllMain dan loader lock — alasan sebenarnya Anda diminta "jangan lakukan apa pun di inisialisasi DLL"
Mengapa Anda tidak boleh memanggil LoadLibrary atau menyinkronkan dengan thread lain dari DllMain. Berdasarkan sumber primer, artikel ini...
Spurious wakeup — mengapa condition variable bangun "tanpa diberitahu" dan cara menunggu dengan benar di Windows
Tunggu condition variable dapat kembali bahkan ketika tidak ada notifikasi yang datang (spurious wakeup). Artikel ini menjelaskan, dari i...
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 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.