Praktik terbaik multithreading di lapangan: edisi C — menulis aman mengikuti cara Win32 API

· Diperbarui pada: · · Windows, Multithreading, C, Win32 API, 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.22175897)

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 — menulis aman mengikuti cara Win32 API. KomuraSoft LLC. https://comcomponent.com/id/blog/multithreading-best-practices-c/

DOI (arsip terdaftar)
10.5281/zenodo.22175897
DOI (versi terakhir yang didaftarkan)
10.5281/zenodo.22175898

“Proses resident untuk kendali peralatan ditulis dalam C.” “Akhirnya thread harus ditambahkan ke aplikasi C berumur dua puluh tahun.” “TerminateThread dipakai untuk menghentikan thread, tetapi sesekali seluruh proses macet.” — Multithreading di C adalah dunia di mana bahasa memberi bantuan paling sedikit. Tidak ada pengecualian, tidak ada RAII, tidak ada template; kebenaran sinkronisasi sepenuhnya bergantung pada pemilihan API dan disiplin pemanggilan.

Artikel ini adalah edisi C dari seri multithreading di lapangan. Ditujukan kepada pengembang yang menulis C terhadap Win32 API, artikel ini menurunkan prinsip desain multithreading — berhenti membuat thread secara massal, kurangi state mutable bersama, terapkan disiplin kunci, rancang cara berhenti sejak awal — ke perkakas Win32: cara membuat thread (_beginthreadex), cara memilih objek sinkronisasi, desain berhenti tanpa TerminateThread, dan batasan DllMain, 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

  • Buat thread dengan _beginthreadex, bukan CreateThread. Jika thread yang memanggil CRT dibuat dengan CreateThread, CRT dapat mengakhiri proses saat memori menipis.12
  • Default kunci di dalam proses adalah kunci SRW; pakai CRITICAL_SECTION hanya ketika perolehan rekursif dibutuhkan. Memakai Mutex untuk eksklusi intra-proses adalah “kesalahan umum” yang selalu melibatkan transisi kernel.3
  • Perbarui variabel tunggal dengan keluarga fungsi Interlocked. volatile tidak menjamin keatomikan maupun urutan. Sebagian besar fungsi Interlocked membawa penghalang memori penuh.4
  • Tunggu dengan variabel kondisi (keluarga SleepConditionVariableCS) atau peristiwa plus fungsi tunggu. Loop polling berbasis Sleep membuang CPU dan daya tanggap.5
  • Jangan memakai TerminateThread. Itu fungsi berbahaya yang merusak kunci, heap, dan state DLL, dan menjadi sasaran peringatan analisis kode C6258. Rancang berhenti sebagai penghentian kooperatif: peristiwa berhenti plus WaitForMultipleObjects.67
  • Serahkan pekerjaan pendek ke thread pool Windows (CreateThreadpoolWork), bukan ke thread buatan sendiri. Jangan mengakhiri thread pool dengan ExitThread / TerminateThread.89
  • Jangan membuat thread, menyinkronkan, atau menunggu thread berakhir di dalam DllMain. Fungsi itu dipanggil saat loader lock dipegang, sehingga menjadi lahan subur deadlock.10
  • <threads.h> C11 dapat dipakai dari VS 2022 17.8 ke atas, tetapi <stdatomic.h> masih experimental. Untuk basis kode khusus Windows, cara Win32 lebih realistis.11

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 23, 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 — race condition dan deadlock

Masalah yang dibawa multithreading, terlepas dari bahasanya, pada dasarnya dua jenis.

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 ini bersamaan, penambahan satu thread tertimpa dan hilang oleh tulis-kembali thread lain.4 Hasil berubah setiap kali dijalankan, dan hasil mana yang muncul tidak dapat diprediksi.

Thread BVariabel bersama countThread AThread BVariabel bersama countThread Acount = 10Dua kali ditambah, tetapi count = 11Penambahan thread A hilangBaca (10)Baca (10)Tambah di lokal (11)Tambah di lokal (11)Tulis kembali (11)Tulis kembali (11)

Gambar 1: Race condition klasik di mana penghitung bersama kehilangan satu penambahan. Jika thread lain menyelip di antara tiga langkah count++, tulis-kembali yang belakangan menimpa yang lain

Deadlock adalah keadaan di mana dua thread saling menunggu kunci yang dipegang pihak lain, sehingga keduanya tidak bisa maju. Thread A memegang kunci 1 dan menunggu kunci 2; thread B memegang kunci 2 dan menunggu kunci 1 — hanya itu, keduanya berhenti selamanya.

menunggu pelepasan kunci 2menunggu pelepasan kunci 1Thread Asedang memegang kunci 1Thread Bsedang memegang kunci 2

Gambar 2: Tunggu sirkular pada deadlock. Begitu panah tunggu membentuk lingkaran, semua thread di dalam lingkaran berhenti selamanya

Keduanya bergantung pada timing. Kombinasi urutan yang di mesin pengembangan hanya mengenai sekali dalam puluhan ribu kali, di mesin pelanggan dengan jumlah inti dan timing berbeda bisa terjadi setiap hari. “Tidak mereproduksi begitu debugger dipasang” juga karena observasi mengubah timing — itu perilaku khas bug race. Karena itu, semua prinsip di artikel ini mengarah ke mengurangi tempat yang perlu disinkronkan, sebelum “menyinkronkan dengan benar”.

2.1. Premis khas C — bahasa tidak menjaga apa pun

Kelompok prinsip ini, di C, harus dinyatakan sebagai disiplin, karena bahasa tidak punya mekanisme yang memaksa mereka.

Pertama, jaminan pelepasan harus dibuat lewat struktur. Tidak ada padanan RAII di C++, jadi pelepasan kunci dan CloseHandle pada handle dijaga dengan pola goto cleanup yang menyatukan pintu keluar fungsi, atau dengan konvensi pengodean yang menulis perolehan dan pelepasan berpasangan. Kecelakaan khas C: menambahkan return dini, lalu kunci bocor.

Kedua, perlakuan data race sama dengan C++. Baca atau tulis sederhana pada variabel 32-bit yang disejajarkan dengan benar memang atomik di Windows, tetapi di luar itu — variabel 64-bit (Windows 32-bit), operasi majemuk, konsistensi beberapa variabel — tidak ada jaminan.12 Kode yang “kebetulan jalan” rusak ketika kompiler atau tingkat optimasi berubah.

Ketiga, tentukan kepemilikan. Budaya menuliskan di komentar fungsi “buffer ini ditulis thread mana, dan mulai kapan menjadi milik siapa” di multithreading C sama berbobotnya dengan pemilihan primitif sinkronisasi.

3. Cara membuat thread — hanya _beginthreadex

3.1. Mengapa CreateThread tidak memadai

API native Win32 adalah CreateThread, tetapi thread yang memanggil fungsi CRT (runtime C) harus dibuat dengan _beginthreadex — itu petunjuk resmi. _beginthreadex menginisialisasi data internal per-thread yang dipakai CRT, lalu memulai thread. Jika thread yang dibuat dengan CreateThread memanggil fungsi CRT, CRT dapat mengakhiri proses dalam kondisi memori menipis.12 printf, malloc, dan strtok semuanya CRT, jadi dalam praktik “thread yang ditulis dalam C selalu _beginthreadex”.

Hindari juga _beginthread (tanpa ex). Ada jebakan: jika thread yang dibuat selesai terlalu cepat, handle yang dikembalikan sudah tidak valid (bisa menunjuk thread lain). _beginthreadex, yang handlenya dapat dilewatkan ke API sinkronisasi, lebih aman. Handle yang dikembalikan _beginthreadex ditutup pemanggil dengan CloseHandle.13

#include <process.h>

static unsigned __stdcall WorkerMain(void* arg)
{
    WorkerContext* ctx = (WorkerContext*)arg;
    /* ... loop tunggu di bab 5 dan 6 ... */
    return 0;
}

HANDLE hThread = (HANDLE)_beginthreadex(
    NULL, 0, WorkerMain, &ctx, 0, NULL);
if (hThread == NULL) { /* penanganan kegagalan */ }
/* ...setelah permintaan berhenti... */
WaitForSingleObject(hThread, INFINITE);  /* join */
CloseHandle(hThread);

3.2. Pekerjaan pendek ke thread pool Windows

Jika tujuannya “melempar banyak pekerjaan kecil” atau “thread berumur pendek dibuat dan dihancurkan berulang kali”, jangan buat thread sendiri — pakai thread pool Windows (API thread pool sejak Vista). Objek pekerjaan yang dibuat dengan CreateThreadpoolWork dikirim lewat SubmitThreadpoolWork, lalu thread pekerja di pool menjalankan callback secara paralel.8 Pengelolaan jumlah thread diserahkan ke OS, dan biaya pembuatan serta penghancuran thread hilang. Itulah jawaban C untuk prinsip “jangan membuat thread sendiri”.

Disiplin saat memakai pool juga dinyatakan resmi. Jangan mengakhiri thread pool dengan TerminateThread / ExitThread; state yang dibuat di dalam callback (TLS, prioritas thread, dan semacamnya) dipulihkan sebelum kembali; biarkan wait handle hidup sampai pool selesai memakainya; dan seterusnya.9 Satu lagi catatan praktis: yang dibatasi pool hanya jumlah thread pekerja. Callback yang sudah dikirim dengan SubmitThreadpoolWork tetapi belum dijalankan dapat menumpuk tanpa batas. Pada konfigurasi resident di mana masukan terus mengungguli pemrosesan, pasang pembatas masuk seperti semafor atau antrean berkapasitas di sisi aplikasi, lalu biarkan sisi pengirim menunggu atau menolak saat penuh (backpressure agar kelebihan beban tidak dipindah ke memori — prinsip yang sama dengan desain antrean di bab 5).

4. Minimalkan state mutable bersama — partisi, hanya-baca, penyerahan

Race hanya terjadi ketika “beberapa thread” dan “data mutable bersama” bertemu. Sebelum memilih primitif sinkronisasi (bab berikutnya), tanyakan dulu apakah berbagi itu sendiri bisa dikurangi. Caranya tiga jalur.

Partisi. Pada agregasi paralel, jangan biarkan setiap thread menulis ke penghitung bersama. Buat subtotal di variabel lokal per-thread (atau buffer yang dialokasikan per-thread), lalu gabungkan sekali di akhir dengan InterlockedAdd atau semacamnya. Tulis ke state bersama turun dari “setiap iterasi” menjadi “sekali per thread”, sehingga biaya sinkronisasi dan jendela race mengecil jauh. Penegasan kepemilikan di 2.1 — “buffer ini milik thread mana” — langsung menjadi cetak biru partisi.

Jadikan hanya-baca. Pengaturan dan tabel yang dibangun saat start-up lalu tidak ditulis lagi aman dibaca dari berapa pun thread setelah inisialisasi selesai. Selesaikan inisialisasi sebelum semua thread dimulai, atau jika inisialisasi tertunda dibutuhkan, pakai one-time initialization Win32 (InitOnceExecuteOnce) dan buat batas “mulai kapan menjadi hanya-baca” eksplisit di kode.3

Serahkan. Aliran data antar-thread jangan disentuh dari kedua sisi lewat variabel bersama; kumpulkan ke antrean produsen/konsumen. Implementasi C-nya adalah variabel kondisi di bab 5 (buffer sirkular berbatas plus SleepConditionVariableCS) — itu juga contoh implementasi resmi. Buffer berkapasitas terbatas sekaligus menjadi backpressure alami: sisi produksi menunggu ketika produksi menyalip konsumsi.5

5. Memilih objek sinkronisasi dan disiplin kunci

Primitif sinkronisasi Win32 banyak jenisnya; salah pilih merusak kinerja maupun kebenaran. Petunjuk resmi diringkas dalam satu lembar.3

yaeksklusimembatasi jumlah akses bersamaanpemberitahuan peristiwatidak (di dalam proses)yatidakyatidakSinkronisasi lintasproses?Kegunaan?Mutex bernamaSemafor bernamaPeristiwa bernamaPerolehan rekursifoleh thread yang sama dibutuhkan?CRITICAL_SECTIONKode C++ yangmengutamakan portabilitas?std::mutex /std::shared_mutexKunci SRW (pilihan default)

Gambar 3: Cara memilih primitif sinkronisasi Win32. Percabangan pertama adalah “apakah lintas proses”; intinya jangan memilih objek kernel (Mutex) jika tidak lintas proses

Primitif Lingkup Ciri Tempat dipakai
Kunci SRW Di dalam proses Cepat (biasanya selesai di mode pengguna), ukuran pointer, tidak rekursif Default kode baru. Berbagi baca juga mungkin dengan AcquireSRWLockShared
CRITICAL_SECTION Di dalam proses Cepat (spin lalu tunggu kernel), rekursif Ketika thread yang sama perlu memperoleh kunci secara rekursif
Mutex Di dalam proses / lintas proses Selalu objek kernel, lambat Eksklusi lintas proses (bernama), kombinasikan dengan WaitForMultipleObjects
Semafor Di dalam proses / lintas proses Objek kernel Membatasi jumlah akses bersamaan ke kumpulan sumber daya
Peristiwa Di dalam proses / lintas proses Objek kernel Pemberitahuan “sesuatu terjadi” (jangan dipakai untuk melindungi data)
Fungsi Interlocked Di dalam proses (juga lintas proses jika memori bersama) Operasi atomik tanpa kunci Penghitung, bendera, penggantian pointer4

Satu catatan pelengkap untuk tabel dan flowchart. Objek kernel seperti peristiwa, semafor, dan Mutex bisa dipakai biasa untuk sinkronisasi di dalam proses jika dibuat tanpa nama (peristiwa berhenti di bab 6 justru peristiwa tanpa nama). Objek kernel bukan khusus lintas proses. “Tanpa nama = mutlak terbatas di dalam proses” juga tidak tepat: jika handle diwariskan ke proses anak atau diduplikasi ke proses lain dengan DuplicateHandle, objek kernel yang sama dapat dipakai dari beberapa proses meskipun tanpa nama. Penamaan adalah salah satu cara khas agar proses lain dapat membuka kembali objek yang sama — itu pemahaman yang tepat. Percabangan di gambar 3 menunjukkan inti pilihan “jangan pilih objek kernel sebagai kunci di dalam proses”; untuk pemberitahuan intra-proses (peristiwa) dan pembatas jumlah bersamaan (semafor), objek kernel tanpa nama tetap jawaban yang benar.

Keluarga Interlocked setara dengan kelas Interlocked di edisi .NET dan std::atomic di edisi C++. InterlockedIncrement / InterlockedExchange / InterlockedCompareExchange membuat operasi pada variabel tunggal tak terbagi, dan sebagian besar fungsi membawa penghalang memori penuh sehingga jaminan urutan ikut didapat.4 “Sudah aman karena diberi volatile” adalah kesalahpahaman: keatomikan maupun urutan tidak dijamin (lihat FAQ). Premis lain adalah alignment. Variabel sasaran fungsi Interlocked harus disejajarkan pada batas alami (nilai 32-bit pada batas 4 byte, nilai 64-bit pada batas 8 byte); jika tidak, perilaku tidak dapat diprediksi.12 Jangan jadikan sasaran Interlocked field di struct yang di-#pragma pack, atau field pada buffer yang dipetakan apa adanya dari format komunikasi. Batasi penghitung dan bendera pada variabel yang dideklarasikan biasa (yang alignment-nya diatur kompiler). Penggantian pointer dengan InterlockedExchangePointer dan semacamnya punya catatan tersendiri. Yang tak terbagi hanyalah penggantian itu sendiri; masa hidup blok lama setelah diganti tidak dijaga siapa pun. Jika pembaca memuat pointer lama tepat sebelum penulis mengganti lalu free, itu akses ke memori yang sudah dibebaskan. Desain yang memperbarui data bersama lewat penggantian pointer hanya berdiri bersama protokol pengumpulan seperti kunci atau hitungan referensi (jika ragu, melindungi dengan kunci SRW lebih aman).

Untuk menunggu, ada variabel kondisi. Buat dengan InitializeConditionVariable; sisi konsumsi tidur dengan SleepConditionVariableCS (dipasangkan dengan CRITICAL_SECTION); sisi produksi membangunkan dengan WakeConditionVariable — contoh implementasi resmi antrean produsen/konsumen berbuffer terbatas berbentuk seperti ini.5 Tata cara penting: setelah bangun, selalu periksa ulang syarat (apakah antrean tidak kosong) di dalam kunci, dan jika palsu kembali ke wait dalam loop. Variabel kondisi punya spurious wakeup — bangun tanpa pemberitahuan — dan saat bangun, konsumen lain mungkin sudah mengambil elemen, jadi “dibangunkan = syarat terpenuhi” tidak selalu benar. Ini perkakas C untuk susunan yang sama dengan channel di edisi .NET 4.3 dan BlockingQueue di edisi C++ bab 4. Jika dipasangkan dengan kunci SRW, pakai SleepConditionVariableSRW.

5.1. Disiplin kunci — tiga prinsip yang tidak berubah, apa pun yang dipilih

Memilih primitif dengan benar pun tidak mencegah race jika disiplin pemakaian absen.

  • Tentukan satu-lawan-satu “data mana dilindungi kunci mana”. Pasangkan satu kunci (kunci SRW atau CRITICAL_SECTION) ke setiap kumpulan data mutable yang ingin dilindungi, dan ambil kunci yang sama di semua tempat yang menyentuh data itu. Di C, menuliskan di komentar header “struct ini dilindungi g_lockFoo” sangat efektif.
  • Jangan melakukan hal yang memakan waktu atau hal eksternal saat memegang kunci. Yang boleh dilakukan sambil memegang kunci hanyalah baca-tulis data yang dilindungi. I/O berkas, jaringan, atau pemanggilan callback sambil memegang kunci memperpanjang waktu pegang, dan pihak yang dipanggil mungkin mengambil kunci lain lalu membentuk tunggu sirkular di gambar 2.
  • Tetapkan urutan perolehan beberapa kunci. Di tempat yang mengambil dua kunci atau lebih, buat aturan bahwa semua thread mengambil dalam urutan yang sama (hierarki kunci). Dokumen praktik terbaik DLL menyatakan secara eksplisit bahwa pembalikan urutan (lock order inversion) menghasilkan deadlock yang sulit di-debug, dan hierarki harus didefinisikan lalu diikuti secara konsisten.10

6. Merancang cara berhenti — jangan memakai TerminateThread

6.1. Apa yang dirusak TerminateThread

TerminateThread menghapus thread sasaran tanpa membiarkannya menjalankan kode user-mode sama sekali. Akibat yang diuraikan dokumentasi resmi serius. Jika thread sasaran memegang critical section, kunci tidak pernah dilepas; jika sedang memanipulasi heap, kunci heap tetap dipegang (semua thread yang kemudian memanggil malloc hang); jika sedang memanipulasi state global suatu DLL, state DLL rusak. Posisi resminya “fungsi berbahaya yang hanya boleh dipakai dalam kasus paling ekstrem”, dan analisis kode mendeteksinya sebagai peringatan C6258.67

Dalam investigasi penyebab aplikasi yang “sesekali macet seluruh proses”, menemukan TerminateThread adalah pemandangan yang benar-benar sering di lapangan. Jika ketemu, itu sasaran perbaikan.

6.2. Bentuk yang benar: peristiwa berhenti + WaitForMultipleObjects

Pola mapan penghentian kooperatif di C adalah membuat satu peristiwa berhenti reset-manual, lalu setiap thread pekerja menunggu bersamaan “sinyal kerja” dan “sinyal berhenti”. Dokumentasi peringatan C6258 sendiri menunjuk bentuk ini (buat peristiwa, setiap thread mengawasi peristiwa dengan WaitForSingleObject dan berakhir sendiri) sebagai cara berakhir yang benar.7

HANDLE hStopEvent;   /* CreateEvent(NULL, TRUE, FALSE, NULL): reset manual */
HANDLE hWorkEvent;   /* CreateEvent(NULL, FALSE, FALSE, NULL): reset otomatis.
                        Setelah diterima, otomatis kembali non-sinyal (jika reset
                        manual, setelah sekali sinyal tunggu dilewati begitu saja,
                        lalu menjadi busy loop yang mengitari antrean kosong) */

static unsigned __stdcall WorkerMain(void* arg)
{
    HANDLE waits[2] = { hStopEvent, hWorkEvent };
    for (;;) {
        DWORD r = WaitForMultipleObjects(2, waits, FALSE, INFINITE);
        if (r == WAIT_FAILED) {          /* handle tidak valid, dll. Jika dibiarkan, putaran kosong penuh tenaga */
            LogLastError();              /* catat GetLastError() lalu keluar */
            break;
        }
        if (r == WAIT_OBJECT_0)          /* permintaan berhenti */
            break;
        if (r == WAIT_OBJECT_0 + 1) {    /* ada pekerjaan */
            /* Teruskan juga peristiwa berhenti ke ProcessNextItem: jika menunggu
               lama di dalam satu item, shutdown disandera satu item bila berhenti
               tidak teramati di situ */
            while (ProcessNextItem(hStopEvent)) {  /* proses 1 item dari antrean. FALSE jika kosong */
                /* Periksa permintaan berhenti juga saat menguras. Jika dilalaikan,
                   berhenti mustahil selama pekerjaan terus menumpuk (kelaparan berhenti) */
                if (WaitForSingleObject(hStopEvent, 0) == WAIT_OBJECT_0)
                    break;
            }
        }
    }
    Cleanup();                           /* rapikan milik sendiri sendiri */
    return 0;                            /* berakhir sendiri */
}

BOOL StopWorkers(HANDLE* threads, DWORD count)
{
    BOOL ok = TRUE;
    if (!SetEvent(hStopEvent)) {             /* jika permintaan berhenti tidak sampai, */
        LogLastError();                      /* jangan masuk join tanpa batas waktu */
        return FALSE;
    }
    /* Permintaan berhenti berdiri serentak bagi semua */
    for (DWORD i = 0; i < count; i++) {
        if (WaitForSingleObject(threads[i], INFINITE) == WAIT_OBJECT_0) {
            CloseHandle(threads[i]);         /* tutup hanya yang join-nya terkonfirmasi */
        } else {
            LogLastError();                  /* WAIT_FAILED: handle tidak valid, dll. */
            ok = FALSE;                      /* jangan laporkan "semua sudah berhenti" */
        }
    }
    return ok;   /* jika FALSE, jangan lanjut melepas sumber daya bersama */
}

Ada alasan sisi penghenti melakukan join satu per satu dengan WaitForSingleObject. Handle yang dapat ditunggu WaitForMultipleObjects sekaligus paling banyak MAXIMUM_WAIT_OBJECTS (64). Melewatkan array di atas itu membuat tunggu itu sendiri gagal dengan WAIT_FAILED, lalu handle ditutup dalam keadaan “mengira sudah menunggu semua padahal tidak menunggu siapa pun”. Jika yang dibutuhkan hanya menunggu semua berakhir, loop satu per satu tanpa batas atas lebih aman.

SetEvent(hStopEvent)Sisi yang menghentikanPeristiwa berhenti(reset manual: terlihat semua)Pekerja 1:WaitForMultipleObjects menungguberhenti dan pekerjaan bersamaanPekerja 2:WaitForMultipleObjects menungguberhenti dan pekerjaan bersamaanRapikan lalu return sendiriRapikan lalu return sendiriSisi yang menghentikan menunggu handle thread lalu joinBaru di sini boleh dikatakan 'sudah berhenti'

Gambar 4: Pola peristiwa berhenti. Peristiwa reset-manual untuk berhenti membuat satu SetEvent membangunkan semua pekerja yang sedang menunggu sekaligus. Cara berakhir diputuskan masing-masing thread, dan berhenti dianggap selesai setelah join selesai

Ada tiga poin. Jadikan peristiwa berhenti reset-manual (satu SetEvent terlihat semua pekerja), letakkan peristiwa berhenti di awal array tunggu (saat sinyal bersamaan, berhenti diutamakan), dan sisi yang menghentikan wajib menunggu join handle thread sebelum menutup handle.

Dua catatan lingkup penerapan. Pertama, bentuk “peristiwa plus menguras semua item” ini untuk konfigurasi satu pekerja. Peristiwa reset-otomatis, berapa pun kali SetEvent dipanggil, hanya menyatakan “ada satu keadaan sinyal” (sinyal beruntun menyatu). Jika pekerja lebih dari satu, hanya satu yang bangun dan memproses burst secara serial. Jika beberapa pekerja berbagi antrean, ganti sinyal kerja menjadi semafor, dan naikkan hitungan dengan ReleaseSemaphore(hSem, 1, NULL) setiap kali satu item ditambahkan. Keberhasilan tunggu semafor mengonsumsi hitungan 1, sehingga korespondensinya benar: “sebanyak item yang ditumpuk, pekerja yang menunggu bangun satu per satu” (kegunaan ini, seperti “membatasi jumlah akses bersamaan ke kumpulan sumber daya” di tabel gambar 3, adalah wilayah semafor). Namun saat diganti ke semafor, sisi konsumsi juga harus diubah menjadi “satu keberhasilan tunggu = proses hanya satu item dari antrean”. Jika loop menguras semua item di sampel di atas dibiarkan, satu tunggu hanya mengonsumsi satu izin tetapi mengosongkan antrean, lalu pekerja lain bangun terhadap antrean kosong karena sisa izin, atau ReleaseSemaphore di sisi produksi gagal karena melebihi batas — hitungannya kacau. Menjaga korespondensi “satu izin = satu pekerjaan” adalah premis cara semafor. Kedua, alirkan jalur berhenti juga ke dalam pemrosesan satu item. Jika ProcessNextItem menunggu blocking lama di dalamnya, teruskan peristiwa berhenti ke situ dan tunggu secara tumpang-tindih, atau pasang batas waktu terbatas. Pemeriksaan hanya di antara item menyisakan lubang: “satu item tidak selesai, sehingga shutdown menunggu selamanya”. Yang dikatakan sama dengan StopAsync di edisi .NET dan jthread plus join di edisi C++.

Thread yang menunggu di I/O blocking (pipa, soket, port serial) tidak bisa datang memeriksa peristiwa, jadi sisi I/O juga perlu menunggu tumpang-tindih dengan OVERLAPPED plus peristiwa, atau membangunkan I/O dengan CancelIoEx (contoh konkret komunikasi serial ada di “Jebakan aplikasi komunikasi serial”).

7. DllMain dan loader lock — ladang ranjau saat menulis DLL

Komponen bersama yang ditulis dalam C sering menjadi DLL, dan di situ ada batasan khas bernama loader lock. DllMain dipanggil OS loader sambil memegang loader lock, jadi melakukan hal berikut di dalamnya menjadi penyebab deadlock atau crash.10

  • Sinkronisasi dengan thread lain (memperoleh kunci, menunggu thread berakhir)
  • Pemanggilan LoadLibrary / FreeLibrary (langsung maupun tidak langsung)
  • Pembuatan thread (berbahaya jika melibatkan sinkronisasi) atau ExitThread

“Menunggu thread pekerja berakhir di DllMain saat DLL di-unload” kelihatan benar, tetapi itu deadlock khas (thread yang berakhir mencoba mengambil loader lock untuk pengiriman DLL_THREAD_DETACH, lalu saling menunggu). DLL yang punya thread harus mengekspor fungsi inisialisasi dan penghentian eksplisit seperti MyLib_Init / MyLib_Shutdown, dan menjalankan start serta join thread di situ. Ideal DllMain adalah stub yang hampir kosong.10

8. Pilihan thread C11 — ringkasan status

Pilihan ketika ingin “menulis C portabel tanpa bergantung pada Win32” adalah <threads.h> C11 (thrd_create / mtx_lock / cnd_wait) dan <stdatomic.h>. Menurut tabel kesesuaian resmi MSVC, <threads.h> didukung di Visual Studio 2022 17.8 (perlu /std:c11 dan SDK yang sesuai), sementara <stdatomic.h> masih experimental dan membutuhkan opsi /experimental:c11atomics.11

Jika berbagi kode dengan Linux menjadi syarat, thread C11 (atau pembungkus pthread) punya nilai, tetapi untuk basis kode khusus Windows, cara Win32 di artikel ini unggul dari sisi volume informasi, rekam jejak, dan kemudahan debug. Apa pun yang dipilih, prinsip desain sejauh ini (kurangi berbagi, korespondensi kunci dan data, penghentian kooperatif) tidak berubah.

9. Verifikasi dan debug — bersiap dengan asumsi “tidak mereproduksi”

Bug race tidak dapat diharapkan ketemu di pengujian. Pengujian biasa menghitung jalan yang “kebetulan tidak race” sebagai lulus. Persiapan dipikirkan dalam tiga lapis.

Garis pertahanan pertama adalah desain. Dalam tinjauan, konfirmasikan dengan tabel: “data mutable mana yang dibagi”, “kunci mana melindungi masing-masing (tabel korespondensi 5.1)”, “apakah urutan perolehan kunci unik”, “apakah peristiwa berhenti sampai ke semua pekerja”. Desain yang tabel ini tidak dapat ditulis belum selesai, seberapa pun baiknya ia berjalan saat ini.

Kedua, buat anomali dapat diamati. Jangan menunggu tanpa syarat dengan INFINITE; pasang batas waktu di titik penting dan catat kehabisan waktu di log — itu mengubah hang abadi menjadi kegagalan yang dapat dideteksi. Di lapangan hang, ambil dump, periksa stack semua thread, dan lihat apakah tunggu kunci mereka membentuk siklus. Untuk kesalahan seputar DLL, pemeriksaan dengan Application Verifier direkomendasikan secara resmi.10 Penyiapan dump dan log diuraikan di “Merancang log dan dump saat aplikasi Windows crash”.

Ketiga, goyangkan di bawah beban. Uji stres — berjalan lama dengan thread lebih banyak daripada jumlah inti, mengacak urutan pemrosesan, menyisipkan tunda buatan — adalah sarana praktis agar “jackpot” race lebih mudah mengenai di mesin pengembangan. Jangan lupa uji reproduksi pada build rilis yang dioptimasi plus beban tinggi.

10. Ringkasan — daftar periksa edisi C

  1. Apakah semua pembuatan thread memakai _beginthreadex (tidak tercampur CreateThread / _beginthread)?
  2. Apakah handle thread di-CloseHandle setelah join (WaitForSingleObject)?
  3. Apakah thread buatan sendiri tidak diproduksi massal untuk pekerjaan berumur pendek (bisakah dilempar ke API thread pool)?
  4. Apakah eksklusi intra-proses memakai kunci SRW / CRITICAL_SECTION (Mutex tidak disalahgunakan)?
  5. Apakah penghitung dan bendera bersama memakai keluarga Interlocked, bukan mengandalkan volatile?
  6. Apakah polling Sleep tidak tersisa (sudah diganti variabel kondisi atau tunggu peristiwa)?
  7. Apakah tidak ada satu pun TerminateThread (penghentian paksa thread lain)? Apakah pekerja berakhir dengan return dari fungsi thread, bukan pemanggilan ExitThread (pembersihan CRT berjalan benar lewat _endthreadex)?
  8. Apakah jalur berhenti peristiwa berhenti plus WaitForMultipleObjects ada di semua pekerja, dan thread yang sedang I/O blocking juga dapat dibangunkan?
  9. Apakah pelepasan kunci dan handle dijamin di semua jalur return (disiplin goto cleanup)?
  10. Apakah DllMain tidak membuat thread, menyinkronkan, atau menunggu thread berakhir?

Tanpa dukungan bahasa, kualitas multithreading C langsung menjadi pemilihan API dan disiplin. Jika keempat default ini — _beginthreadex, kunci SRW, Interlocked, peristiwa berhenti — dijadikan baku, desain C pun bisa menjauh dari “sesekali macet”.

Artikel terkait

Area konsultasi terkait

KomuraSoft LLC menangani tinjauan desain multithread untuk proses resident C, aplikasi kendali peralatan, dan DLL; investigasi penyebab hang dan crash yang berasal dari TerminateThread atau kebocoran kunci (analisis dump); serta konsultasi teknis menambah thread ke kode C warisan.

Tautan referensi

  1. Microsoft Learn, CreateThread function. Tentang thread di dalam executable yang memanggil CRT yang seharusnya dikelola dengan _beginthreadex / _endthreadex, bukan CreateThread / ExitThread; dan tentang thread yang dibuat dengan CreateThread, jika memanggil CRT, dapat membuat CRT mengakhiri proses dalam kondisi memori rendah. ↩ ↩2

  2. Microsoft Learn, Multithreading with C and Win32. Tentang program yang memanggil pustaka CRT yang seharusnya memulai thread dengan _beginthread / _beginthreadex dan tidak memakai CreateThread / ExitThread Win32; tentang keluarga _beginthread yang menginisialisasi variabel per-thread CRT; dan tentang SuspendThread yang dapat menghentikan thread di tengah akses ke struktur data internal CRT lalu memicu deadlock. ↩ ↩2

  3. Microsoft Learn, About Synchronization. Tentang petunjuk memilih primitif sinkronisasi Win32: kunci SRW adalah default kode baru, berukuran pointer, dan biasanya selesai di mode pengguna; CRITICAL_SECTION dipakai ketika perolehan rekursif dibutuhkan; Mutex selalu objek kernel dan dipakai untuk sinkronisasi bernama lintas proses serta kombinasinya dengan WaitForMultipleObjects; memakai Mutex untuk sinkronisasi intra-proses adalah “kesalahan umum” yang jauh lebih lambat pada operasi berfrekuensi tinggi; semafor dipakai untuk membatasi jumlah akses bersamaan ke kumpulan sumber daya, dan peristiwa untuk pemberitahuan. ↩ ↩2 ↩3

  4. Microsoft Learn, Interlocked Variable Access. Tentang fungsi Interlocked yang menyinkronkan akses ke variabel yang dibagi beberapa thread dan membuat operasi tak terbagi; tentang InterlockedIncrement / Decrement yang mengikat baca, tambah, dan tulis kembali menjadi satu operasi atomik, sehingga tanpa sinkronisasi increment bersamaan dua thread dapat kehilangan satu kali; tentang keluarga fungsi seperti InterlockedExchange / InterlockedCompareExchange; tentang variabel di memori bersama yang dapat dipakai juga antar thread di proses berbeda; dan tentang sebagian besar fungsi Interlocked yang menyediakan penghalang memori penuh, dengan versi Acquire / Release untuk memilih semantik urutan. ↩ ↩2 ↩3 ↩4

  5. Microsoft Learn, Using Condition Variables. Tentang contoh implementasi antrean produsen/konsumen dengan buffer sirkular berbatas yang dilindungi CRITICAL_SECTION; tentang membuat variabel kondisi dengan InitializeConditionVariable, konsumen menunggu dengan SleepConditionVariableCS, dan membangunkan pihak lain dengan WakeConditionVariable; dan tentang dukungan variabel kondisi sejak Windows Vista. ↩ ↩2 ↩3

  6. Microsoft Learn, TerminateThread function. Tentang TerminateThread yang mengakhiri thread sasaran tanpa membiarkannya menjalankan kode user-mode sama sekali; tentang kunci yang tidak dilepas jika sasaran memegang critical section; tentang kunci heap yang tidak dilepas jika sedang mengalokasikan memori dari heap; tentang state kernel32 atau state global DLL yang dapat rusak; dan tentang posisinya sebagai “fungsi berbahaya yang hanya boleh dipakai dalam kasus paling ekstrem”, yang tidak boleh dipanggil kecuali kode yang dapat dijalankan thread sasaran diketahui dan dikendalikan sepenuhnya. ↩ ↩2

  7. Microsoft Learn, Warning C6258. Tentang peringatan analisis kode C6258 yang mendeteksi pemakaian TerminateThread; tentang TerminateThread yang tidak memungkinkan pembersihan thread yang layak; dan tentang cara berakhir yang benar: buat peristiwa dengan CreateEvent, setiap thread mengawasi keadaan peristiwa dengan WaitForSingleObject, lalu mengakhiri eksekusi sendiri ketika menjadi sinyal. ↩ ↩2 ↩3

  8. Microsoft Learn, CreateThreadpoolWork function. Tentang membuat objek pekerjaan dengan CreateThreadpoolWork, lalu setiap pemanggilan SubmitThreadpoolWork membuat thread pekerja di pool menjalankan callback; tentang lingkungan eksekusi yang dapat ditentukan lewat lingkungan callback (TP_CALLBACK_ENVIRON); dan tentang ketersediaan sejak Windows Vista. ↩ ↩2

  9. Microsoft Learn, Thread Pools. Tentang thread pool yang cocok untuk aplikasi yang menjalankan banyak pekerjaan pendek secara asinkron atau yang sering membuat thread berumur pendek; tentang komponen API thread pool baru yang didesain ulang di Vista; dan tentang praktik terbaik: jangan mengakhiri thread pool dengan TerminateThread atau memanggil ExitThread dari callback, rapikan state yang dibuat di callback sebelum kembali, dan biarkan wait handle hidup sampai pool selesai memakainya. ↩ ↩2

  10. Microsoft Learn, Dynamic-Link Library Best Practices. Tentang DllMain yang dipanggil saat loader lock dipegang, sehingga API yang boleh dipanggil sangat dibatasi; tentang sinkronisasi dengan thread lain di dalam DllMain yang memicu deadlock; tentang pemanggilan LoadLibrary sebagai larangan; tentang menunggu thread berakhir di dalam DllMain saat unload DLL yang membentuk deadlock karena saling menunggu pengiriman DLL_THREAD_DETACH thread yang berakhir; tentang ideal DllMain sebagai stub yang hampir kosong dan inisialisasi yang sebaiknya ditunda sejauh mungkin; dan tentang mendefinisikan hierarki kunci dengan loader lock di puncak. ↩ ↩2 ↩3 ↩4 ↩5

  11. Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. Tentang tabel dukungan fitur pustaka standar C: thread C11 (threads.h) didukung di Visual Studio 2022 17.8; stdatomic.h diperlakukan experimental (opsi /experimental:c11atomics); dan dukungan kompiler C11 / C17 membutuhkan Visual Studio 2019 16.8 atau lebih baru plus Windows SDK yang sesuai. ↩ ↩2

  12. Microsoft Learn, Interlocked Variable Access. Tentang baca atau tulis sederhana pada variabel 32-bit yang disejajarkan dengan benar yang atomik, tetapi sinkronisasi akses (urutan) tidak dijamin; tentang baca atau tulis sederhana pada variabel 64-bit yang atomik di Windows 64-bit tetapi tidak dijamin di Windows 32-bit; dan tentang variabel ukuran lain yang keatomikannya tidak dijamin di platform mana pun. ↩ ↩2

  13. Microsoft Learn, _beginthread, _beginthreadex. Tentang alasan _beginthreadex lebih aman daripada _beginthread: thread yang dibuat dengan _beginthread, jika selesai terlalu cepat, membuat handle yang sudah dikembalikan menjadi tidak valid dan dapat menunjuk thread lain; handle _beginthreadex harus ditutup pemanggil dengan CloseHandle dan validitasnya dijamin; handle _beginthreadex dapat dilewatkan ke API sinkronisasi; fungsi thread memakai konvensi __stdcall dan mengembalikan kode berakhir thread; serta perlunya menaut ke CRT versi multithread. ↩

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.

CreateThread atau _beginthreadex, mana yang harus dipakai?
Pakai _beginthreadex untuk thread yang memanggil fungsi pustaka runtime C (CRT). _beginthreadex menginisialisasi data internal per-thread yang dibutuhkan CRT, lalu baru memulai thread. Dokumentasi resmi menyatakan secara eksplisit bahwa jika thread yang dibuat dengan CreateThread memanggil fungsi CRT, CRT dapat mengakhiri proses saat memori menipis. Dalam praktik, thread di aplikasi C hampir pasti memanggil fungsi CRT di suatu tempat (printf, malloc, strtok, dan semacamnya), jadi aman diingat sebagai "selalu _beginthreadex". _beginthread (tanpa ex) punya jebakan: jika thread selesai terlalu cepat, handle yang dikembalikan bisa menjadi tidak valid, jadi pilih _beginthreadex juga untuk alasan itu.
Tidak boleh menghentikan thread dengan TerminateThread?
Tidak boleh. TerminateThread menghapus thread sasaran tanpa membiarkannya menjalankan kode user-mode sama sekali. Jika thread itu memegang critical section, kunci tidak pernah dilepas; jika sedang mengalokasikan memori dari heap, kunci heap tetap dipegang; jika sedang memanipulasi state global suatu DLL, state DLL rusak. Dokumentasi resmi menyebutnya "fungsi berbahaya yang hanya boleh dipakai dalam kasus paling ekstrem", dan analisis kode menandainya sebagai peringatan C6258. Cara berhenti yang benar adalah penghentian kooperatif: buat peristiwa berhenti, biarkan setiap thread mengawasinya dengan WaitForSingleObject / WaitForMultipleObjects, lalu biarkan masing-masing merapikan dirinya sendiri dan berakhir sendiri.
Mutex dipakai untuk eksklusi di dalam proses. Apa yang salah?
Berfungsi, tetapi kinerja turun jauh. Mutex Win32 selalu objek kernel, jadi setiap perolehan dan pelepasan memicu transisi ke mode kernel. Untuk eksklusi di dalam satu proses, kunci SRW atau CRITICAL_SECTION — yang selesai di mode pengguna dan hanya jatuh ke tunggu kernel saat ada persaingan — jauh lebih cepat, dan dokumentasi resmi secara eksplisit menyebut memakai Mutex untuk sinkronisasi intra-proses sebagai "kesalahan umum". Giliran Mutex adalah ketika eksklusi lintas proses dibutuhkan sebagai objek bernama, atau ketika ingin menunggunya bersama objek kernel lain dengan WaitForMultipleObjects.
Apakah threads.h dan stdatomic.h dari C11 bisa dipakai di Windows?
Di MSVC, thread C11 (threads.h) didukung sejak Visual Studio 2022 17.8 (perlu /std:c11 dan Windows SDK yang sesuai). stdatomic.h masih experimental dan membutuhkan opsi /experimental:c11atomics (menurut tabel kesesuaian resmi per Agustus 2026). Itu opsi yang masuk akal jika portabilitas menjadi prioritas utama, tetapi untuk basis kode khusus Windows, menulis dengan Win32 API (_beginthreadex, kunci SRW, variabel kondisi, Interlocked) lebih realistis dari sisi rekam jejak dan volume informasi.
Apakah menambahkan volatile membuat bendera bersama menjadi aman?
Tidak. volatile di C hanya menekan optimasi kompiler (misalnya menyimpan nilai di register). Ia tidak menjamin keatomikan operasi maupun urutan memori antar prosesor. Baca atau tulis sederhana pada variabel 32-bit yang disejajarkan dengan benar memang atomik di Windows, tetapi "baca, tambah, tulis kembali" terpecah, dan urutan terhadap operasi memori di sekitarnya tidak ditentukan. Pakai keluarga fungsi Interlocked untuk memperbarui penghitung atau bendera bersama. Sebagian besar fungsi Interlocked membawa penghalang memori penuh, jadi jaminan urutan ikut didapat. Jika beberapa variabel harus dilindungi bersama, pakai kunci SRW atau CRITICAL_SECTION.

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