Praktik terbaik multithreading di lapangan: edisi .NET — apa yang diputuskan sebelum menambah thread
· Go Komura · Windows, Multithreading, C#, .NET, Aplikasi bisnis, Investigasi bug, Desain
«Pemrosesan lambat, jadi kami menambah thread untuk memparalelkannya, lalu hasil agregasi sesekali meleset.» «Kami menambah pekerjaan latar, lalu aplikasi macet sebulan sekali.» «Mereka bilang tidak mereproduksi di debugger, tetapi di situs pelanggan itu pasti terjadi.» — Yang menakutkan dari pemrograman multithread adalah ia tampak benar saat baru selesai ditulis. Bug kondisi balapan bergantung pada waktu: mereka lolos dari pengujian dan hanya menampakkan diri di produksi.
Di sisi lain, sekarang perangkat keras multi-inti sudah biasa, ada situasi di aplikasi bisnis yang memang tidak bisa menghindari multithreading — misalnya «jalankan pemrosesan berat tanpa membekukan UI» atau «proses beberapa perangkat atau berkas secara bersamaan». Yang penting adalah memutuskan prinsip desain sebelum menambah thread. Bug multithreading bukan sesuatu yang dilenyapkan lewat debugging; ia sesuatu yang Anda desain agar tidak ada ruang baginya untuk masuk sejak awal.
Artikel ini adalah edisi .NET dari seri multithreading di lapangan. Ditujukan kepada pengembang yang membangun aplikasi bisnis di Windows dan mendapati diri perlu menambah multithreading, artikel ini merangkum prinsip desain yang berlaku terlepas dari bahasa atau OS, bersama perkakas konkret di C#/.NET, berdasarkan sumber primer per Agustus 2026. Prinsipnya sendiri tidak berubah di Linux atau di C++. Jika Anda menulis kode native, lihat artikel pendamping yang memetakan prinsip yang sama ke perkakas masing-masing bahasa — «edisi C++» dan «edisi C» — dan jika Anda menulis Java, lihat «edisi Java».
1. Kesimpulan dulu
- Praktik terbaik yang pertama adalah jangan membuat thread sendiri. Bertumpu pada API tingkat lebih tinggi — Task, thread pool, kelas
Parallel— alih-alihnew Thread, dan serahkan pengelolaan jumlah thread kepada runtime.12 - Yang pertama dipotong saat memparalelkan adalah «keadaan berubah bersama». Tempat beberapa thread menulis ke variabel yang sama adalah sumber kondisi balapan; sebelum meraih kunci untuk melindunginya, kurangi berbagi itu sendiri lewat partisi data, ketakberubahan, dan penyerahan.3
- Beri disiplin pada penguncian. Putuskan, satu lawan satu, «kunci mana melindungi data mana», dan jadikan objek kunci instans khusus yang tidak terlihat dari luar.
lock(this)danlock(typeof(X))dilarang. Mulai .NET 9, pakai tipe khususSystem.Threading.Lock.4 - Arahkan penyerahan data antar-thread lewat antrean. Susunan produsen/konsumen yang dibangun di atas
System.Threading.Channelsatau koleksi konkuren lebih sederhana dirancang daripada menyebar kunci ke mana-mana, dan memberi batas yang jelas juga.56 - Rancang cara berhenti, lebih dulu. Pembatalan kooperatif lewat
CancellationTokenadalah satu-satunya jawaban yang benar untuk berhenti;Thread.Abortmelempar pengecualian runtime di .NET (jalur berbasis Core).78 - UI milik eksklusif thread UI. Baik kontrol WinForms maupun elemen WPF tidak boleh disentuh dari thread selain yang membuatnya. Dari thread lain, minta lewat
Control.Invoke/Dispatcher.910 - «Paralel berarti lebih cepat» tidak selalu berlaku. Loop yang pekerjaan per iterasinya kecil bisa justru lebih lambat karena overhead paralelisasi. Selalu ukur sebelum mengadopsinya.3
2. Mengapa multithreading sulit — kondisi balapan dan deadlock
Dipadatkan, masalah yang dibawa multithreading ada dua jenis.4
Kondisi balapan (race condition) adalah bug di mana hasil berubah bergantung pada urutan beberapa thread mencapai potongan kode tertentu. Contoh klasiknya adalah increment penghitung bersama: satu baris count++ sebenarnya terurai menjadi tiga langkah — «baca → tambah → tulis kembali». Jika dua thread menjalankan tiga langkah ini pada saat yang sama, penulisan kembali satu thread menimpa penambahan yang lain, dan increment hilang. Hasil berubah setiap kali dijalankan, dan hasil mana yang Anda dapat tidak dapat diprediksi.4
sequenceDiagram
accTitle: Kondisi balapan pada penghitung bersama
accDescr: 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
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, tetapi count = 11<br/>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.4
flowchart LR
accTitle: Tunggu melingkar sebuah deadlock
accDescr: Tunggu melingkar sebuah deadlock. Begitu panah menunggu membentuk cincin, setiap thread di dalam cincin itu berhenti selamanya
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, setiap thread di dalam cincin itu berhenti selamanya
Yang membuat keduanya merepotkan adalah ketergantungan pada waktu. Sebuah jalinan (kombinasi urutan eksekusi) 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 dari sini mengarah ke satu arah: sebelum «menyinkronkan dengan benar», kurangi tempat yang perlu disinkronkan — ini tulang punggung desain multithread.
3. Prinsip 1: Jangan membuat thread sendiri
3.1. Bertumpu pada Task dan thread pool
Membuat thread secara langsung dengan new Thread(...) adalah, di .NET hari ini, jalan terakhir yang bersifat pengecualian. Sejak .NET Framework 4, sarana yang direkomendasikan untuk kode multithread dan paralel adalah TPL (Task Parallel Library) — yaitu keluarga API yang berpusat pada Task. TPL menyesuaikan derajat paralelisme secara dinamis dengan prosesor yang tersedia, dan mengambil alih semua pekerjaan tingkat rendah membagi kerja, menjadwalkannya ke thread pool, menangani pembatalan, dan mengelola keadaan.1
Thread pool adalah infrastruktur yang dipakai .NET sendiri secara luas — untuk menjalankan Task, menyelesaikan I/O asinkron, callback timer, dan lainnya — dan selama Anda melemparkan potongan kerja pendek, pengembang tidak perlu mengelola siklus hidup thread sendiri.2
// Jalankan komputasi berat yang memakai CPU di latar
var result = await Task.Run(() => HeavyCalculation(input));
// Jalankan beberapa pemrosesan independen secara bersamaan dan tunggu semuanya (ketika jumlahnya kecil)
// * Bentuk ini mengandaikan ProcessAsync adalah metode asinkron yang didominasi I/O.
// WhenAll hanya "menunggu Task yang sudah berjalan", jadi jika Anda ingin
// menjalankan komputasi CPU secara bersamaan, bungkus setiap pekerjaan
// dengan Task.Run(() => Calc(x)) agar masuk ke thread pool
var results = await Task.WhenAll(items.Select(x => ProcessAsync(x)));
// Jika jumlah item banyak, batasi derajat konkurensi
await Parallel.ForEachAsync(items,
new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = callerCt },
async (x, ct) => await ProcessAsync(x, ct));
// Dua hal penting: sambungkan token pemanggil ke ParallelOptions
// (lupa ini dan ct di dalam tubuh selalu None), dan teruskan ct
// yang sama ke tubuh juga (jangan buang)
Ada satu peringatan. Task.WhenAll(items.Select(...)) memulai pemrosesan setiap elemen sekaligus, begitu dienumerasi. Itu tidak masalah untuk segelintir hingga beberapa puluh item yang sudah ditentukan, tetapi memakainya pada koleksi besar akan menghabiskan soket, koneksi DB, dan memori sekaligus. Untuk pekerjaan yang volumenya tidak dapat diprediksi, batasi derajat konkurensi seperti Parallel.ForEachAsync di atas, atau kendalikan aliran dengan saluran bounded yang dibahas nanti.
Membuat thread sendiri hampir hanya dibenarkan ketika sifat thread itu sendiri yang menjadi syarat — misalnya «perlu message loop khusus sendiri», «perlu menentukan apartment threading (STA)», atau «perlu terus berjalan sepanjang masa hidup aplikasi».
3.2. Untuk paralelisme data, pakai Parallel.For / ForEach
Untuk paralelisme data — «terapkan pemrosesan yang sama pada setiap elemen koleksi agar keseluruhannya lebih cepat» — pakai Parallel.For / Parallel.ForEach alih-alih membagi loop ke thread sendiri. TPL mengurus pemecahan sumber data (partisi) dan penyeimbangan ulang beban, dan untuk loop dasar Anda bahkan tidak perlu kunci.11
Namun ada dua jebakan yang dinyatakan secara eksplisit dalam dokumentasi resmi.3
- Jangan menganggap paralel selalu lebih cepat. Loop dengan sedikit iterasi, atau yang pekerjaan per iterasinya ringan, bisa justru lebih lambat karena overhead paralelisasi mengungguli tubuh pekerjaan. Kinerja bergantung pada banyak faktor, jadi selalu ukur lalu putuskan dari situ.
- Jangan biarkan iterasi menunggu satu sama lain. Tidak ada jaminan bahwa setiap iterasi
Parallel.Forbenar-benar berjalan secara paralel. Kode di mana satu iterasi menunggu peristiwa yang ditetapkan iterasi lain dapat deadlock, bergantung pada penjadwalan.
3.3. Arahkan pekerjaan «menunggu» ke I/O asinkron, bukan ke thread
Pekerjaan yang sebagian besar menunggu I/O — berkas, jaringan, basis data — bukan kandidat untuk menambah thread. Mengikat seluruh thread sementara ia menunggu hanyalah pemborosan; I/O asinkron lewat async/await tidak mengonsumsi thread selama menunggu. Pembedaan ini — paralelkan pekerjaan CPU-bound, buat pekerjaan I/O-bound asinkron — adalah garis pertama yang harus ditarik di pintu masuk desain multithread.
flowchart TB
accTitle: Percabangan sebelum mendirikan thread
accDescr: Percabangan yang harus dilalui sebelum «mendirikan thread». Sebagian besar pemrosesan bisnis jatuh ke salah satu dari tiga jalur keluar di atas, dan mencapai new Thread hanyalah kasus pengecualian
S["Ada pekerjaan yang ingin dijalankan secara paralel"] --> Q1{"Apa yang mendominasi pekerjaan?"}
Q1 -->|"Didominasi menunggu I/O<br/>berkas, jaringan, DB"| ASYNC["I/O asinkron dengan async/await<br/>jangan menambah thread"]
Q1 -->|"Komputasi yang memakai CPU"| Q2{"Bagaimana bentuk pekerjaannya?"}
Q2 -->|"Menerapkan pemrosesan yang sama<br/>pada setiap elemen koleksi"| PAR["Parallel.For / ForEach"]
Q2 -->|"Satu blok pekerjaan latar<br/>yang independen"| TASK["Task.Run / Task.WhenAll"]
Q2 -->|"Message loop, persyaratan STA, dll.<br/>sifat thread itu sendiri yang menjadi syarat"| TH["new Thread<br/>(jalan terakhir yang bersifat pengecualian)"]
Gambar 3: Percabangan yang harus dilalui sebelum «mendirikan thread». Sebagian besar pemrosesan bisnis jatuh ke salah satu dari tiga jalur keluar di atas, dan mencapai new Thread hanyalah kasus pengecualian
Pengambilan keputusan praktis untuk async/await dibahas di «Tabel keputusan praktis C# async/await - Task.Run dan ConfigureAwait», dan bagaimana thread pool serta I/O asinkron terhubung di bawahnya dibahas secara rinci di «Kedalaman I/O Windows (bagian 3) — I/O Completion Port (IOCP) dan thread pool .NET».
4. Prinsip 2: Minimalkan keadaan berubah bersama
Kondisi balapan hanya terjadi ketika «beberapa thread» dan «data berubah bersama» keduanya ada. Jumlah thread ditentukan persyaratan, jadi yang bisa dipotong desain adalah berbagi. Ada tiga cara.
4.1. Membagi — biarkan setiap thread hanya menyentuh datanya sendiri
Pendekatan paling sederhana dan paling kuat adalah membagi data per thread. Untuk agregasi dalam loop paralel, alih-alih menulis ke variabel total bersama pada setiap iterasi, pakai overload Parallel.For yang menerima keadaan thread-lokal agar setiap thread membangun subtotalnya sendiri secara lokal, lalu gabungkan sekali di akhir. Penulisan ke keadaan bersama turun dari «setiap iterasi» menjadi «sekali per thread», dan baik biaya sinkronisasi maupun jendela kondisi balapan menyusut berlipat ganda.3
long total = 0;
Parallel.For(0, items.Length,
() => 0L, // Nilai awal thread-lokal
(i, state, local) => local + Weigh(items[i]), // Setiap iterasi hanya menambah ke local miliknya
local => Interlocked.Add(ref total, local)); // Penggabungan terjadi sekali per thread
flowchart TB
accTitle: Agregasi thread-lokal
accDescr: Agregasi thread-lokal. Karena setiap thread hanya menyentuh datanya sendiri selama pemrosesan, tidak ada ruang untuk kondisi balapan, dan penulisan ke keadaan bersama terjadi hanya sekali per thread, pada saat penggabungan
SRC["Larikan data (yang diproses)"] --> T1["Thread 1<br/>memproses bagiannya dan<br/>hanya menambah ke subtotal lokal"]
SRC --> T2["Thread 2<br/>memproses bagiannya dan<br/>hanya menambah ke subtotal lokal"]
SRC --> T3["Thread 3<br/>memproses bagiannya dan<br/>hanya menambah ke subtotal lokal"]
T1 --> M["Gabung: Interlocked.Add mencerminkan<br/>subtotal ke total, sekali per thread"]
T2 --> M
T3 --> M
Gambar 4: Agregasi thread-lokal. Karena setiap thread hanya menyentuh datanya sendiri selama pemrosesan, tidak ada ruang untuk kondisi balapan, dan penulisan ke keadaan bersama terjadi hanya sekali per thread, pada saat penggabungan
4.2. Membuatnya tak berubah — yang tidak ditulis ulang boleh dibagi bebas
Data yang hanya pernah dibaca aman dibaca sekaligus dari berapa pun banyak thread. Nilai konfigurasi, data induk, masukan komputasi, dan sejenisnya dapat dibagi bebas tanpa sinkronisasi jika Anda membuatnya tak berubah — tidak pernah ditulis ulang setelah konstruksi. Di C#, tipe record dan properti init mendukung desain ini. Sekadar memutuskan bahwa «ketika perubahan dibutuhkan, bangun instans baru dan tukar, alih-alih menulis ulang yang sudah ada» menghapus satu potong keadaan berubah yang harus Anda lindungi.
Tetapi «tampak hanya-baca» dan «tak berubah» adalah dua hal berbeda. Antarmuka hanya-baca seperti IReadOnlyList<T> hanya berarti «Anda tidak dapat menulis ulang lewat antarmuka itu» — ia tidak mencegah List<T> di bawahnya ditulis ulang lewat referensi lain. Jaminan record / init pun dangkal: ia tidak melindungi objek yang ditunjuk properti. Untuk data yang benar-benar ingin Anda bagi dengan aman antar-thread, pakai koleksi tak berubah dari System.Collections.Immutable, misalnya ImmutableArray<T>, atau teruskan salinan pada saat berbagi, sehingga jalur penulisan ulang terputus sama sekali. Dalam hal itu, syaratnya adalah tipe elemen T itu sendiri juga harus tak berubah. Koleksi tak berubah hanya melindungi «urutan» — referensi ke objek elemen yang dapat berubah tetap dibagi apa adanya, jadi jika isi elemen dapat ditulis ulang lewat jalur lain, kondisi balapan tetap ada. Buat graf objek tak berubah sampai daunnya, atau teruskan salinan dalam.
4.3. Menyerahkan — kirim lewat antrean alih-alih berbagi
Meski begitu, Anda tetap perlu memindahkan data antar-thread. Ketika itu, alih-alih «kedua sisi menyentuh variabel bersama», pakai susunan produsen/konsumen di mana satu sisi menulis dan yang lain membaca, dengan antrean di antaranya.
Pilihan pertama di .NET adalah System.Threading.Channels. Ia adalah FIFO tempat produsen menulis data secara asinkron dan konsumen membacanya secara asinkron, dan saluran itu sendiri mengelola semua pekerjaan sinkronisasi.5
var channel = Channel.CreateBounded<WorkItem>(100); // Kapasitas 100 — menerapkan tekanan balik
// Sisi produsen
await channel.Writer.WriteAsync(item, ct); // Jika penuh, menunggu sampai ada ruang
// …setelah setiap produsen selesai menulis:
channel.Writer.Complete(); // Menyatakan "tidak ada lagi yang datang". Tanpa ini loop pembaca tidak pernah berakhir
// Sisi konsumen
await foreach (var item in channel.Reader.ReadAllAsync(ct))
{
Process(item);
}
flowchart LR
accTitle: Produsen/konsumen melalui saluran
accDescr: Susunan produsen/konsumen dengan saluran di tengah. Kedua sisi tidak menyentuh variabel bersama secara langsung; baik tunggu maupun pengendalian kapasitas diserahkan kepada saluran
P1["Produsen 1<br/>WriteAsync"] --> CH["Saluran bounded (kapasitas 100)<br/>Antrean FIFO<br/>sinkronisasi dikelola saluran"]
P2["Produsen 2<br/>WriteAsync"] --> CH
CH --> C1["Konsumen 1<br/>ReadAllAsync"]
CH --> C2["Konsumen 2<br/>ReadAllAsync"]
CH -.->|"Jika penuh, buat penulisan menunggu<br/>(tekanan balik)"| P1
CH -.->|"Jika kosong, buat pembacaan menunggu"| C1
Gambar 5: Susunan produsen/konsumen dengan saluran di tengah. Kedua sisi tidak menyentuh variabel bersama secara langsung; baik tunggu maupun pengendalian kapasitas diserahkan kepada saluran
Yang penting di lapangan adalah memilih saluran berkapasitas terbatas (bounded). Perilaku bawaan saat batas tercapai adalah «sisi penulis menunggu ruang», dan ini menjadi tekanan balik (backpressure) yang alami. Pakai antrean tanpa batas dalam susunan di mana produksi mengungguli konsumsi, dan Anda mendapat bom waktu yang tetap berjalan sementara memori terus tumbuh.5
Di dunia sinkron, peran yang dimainkan saluran bounded diisi oleh BlockingCollection<T> dengan kapasitas yang ditentukan. Ia menggabungkan pemblokiran dengan pengendalian kapasitas: batas kapasitas mencegah produsen terlalu jauh mendahului konsumen, dan memblokir serta membuat konsumen menunggu ketika kosong.12 ConcurrentQueue<T> / ConcurrentStack<T>, di sisi lain, adalah koleksi cepat yang mencapai keamanan thread hanya dengan operasi Interlocked alih-alih kunci6, tetapi mereka antrean thread-safe polos tanpa batas kapasitas maupun mekanisme «tunggu ketika kosong». Anggap mereka sebagai komponen, bukan pemeran utama penyerahan kerja. Catat juga bahwa BlockingCollection<T> tidak dirancang dengan akses asinkron dalam pikiran, jadi jika Anda memasangkannya dengan async/await, pilih Channel<T> sebagai gantinya.12
Satu peringatan: waspadai anggapan bahwa «mengganti kamus menjadi ConcurrentDictionary membuatnya thread-safe». Bahkan ketika operasi individual thread-safe, operasi majemuk seperti «periksa apakah ada, lalu tambah» tetap bersaing (pakai metode yang dibangun untuk operasi majemuk, misalnya GetOrAdd). Dan GetOrAdd sendiri punya catatan: meskipun nilai yang akhirnya tersimpan dijamin satu, fungsi pabrik yang membangun nilai dapat dipanggil lebih dari sekali di bawah persaingan. Masukkan efek samping ke pabrik — membuka koneksi, membuat berkas, dan semacamnya — dan eksekusi ganda membocorkannya, jadi buat pabrik bebas efek samping, atau, untuk inisialisasi yang harus terjadi tepat sekali, simpan Lazy<T> sebagai nilai. Menukar tipe koleksi bukan pengganti mengurangi keadaan berubah bersama.
5. Prinsip 3: Beri disiplin pada penguncian
Bahkan setelah mengurangi keadaan berubah bersama, sering Anda tidak bisa membuatnya nol. Pakai pengendalian eksklusif (kunci) untuk yang tetap bersama, tetapi kunci bukan alat untuk «membungkus lock di sekitar mana pun yang kelihatan mencurigakan, berjaga-jaga». Ada empat poin disiplin.
5.1. Putuskan «apa yang dilindungi», dan kunci dengan objek khusus
Pikirkan satuan penguncian sebagai «data», bukan «rentang kode». Tetapkan satu objek kunci untuk setiap himpunan data berubah yang ingin Anda lindungi, dan ambil kunci yang sama di setiap tempat yang menyentuh data itu — bug kondisi balapan, pada praktiknya, adalah apa yang Anda dapat ketika tabel korespondensi ini sudah runtuh.
Jadikan objek kunci instans khusus yang tidak diekspos ke luar. lock(this) berbagi kunci dengan kode eksternal mana pun yang dapat mereferensikan instans Anda, dan lock(typeof(X)) berbagi kunci dengan seluruh application domain — keduanya lahan subur untuk deadlock. Mulai .NET 9 / C# 13, memakai instans tipe khusus System.Threading.Lock sebagai objek kunci direkomendasikan.4
public class OrderBook
{
private readonly Lock _gate = new(); // .NET 9+ (readonly object sebelumnya)
private readonly List<Order> _orders = []; // Data yang dilindungi _gate
public void Add(Order order)
{
lock (_gate) { _orders.Add(order); }
}
}
Pernyataan lock C# menjamin kunci dilepas bahkan ketika pengecualian terjadi. Cara ia diperluas bergantung pada tipe objek kunci: untuk objek biasa ia menjadi panggilan Monitor.Exit di blok finally, dan untuk tipe Lock ia menjadi panggilan EnterScope() dan pembuangannya.413 Dengan kata lain, field bertipe Lock adalah mekanisme berbeda dari Monitor, dan jika hanya sebagian kode Anda yang menulis tangan Monitor.Enter(_gate), eksklusi timbal balik dengan lock (_gate) tidak berlaku. Dengan tipe mana pun, lebih aman berhenti menulis tangan Monitor.Enter / Exit dan menyeragamkan sepenuhnya pada sintaksis lock.4
5.2. Jangan lakukan hal yang lambat atau eksternal sambil memegang kunci
Semakin singkat Anda memegang kunci, semakin baik; satu-satunya hal yang boleh dilakukan sambil memegangnya adalah membaca dan menulis data yang dilindunginya. Menulis kode yang melakukan I/O sambil memegang kunci, atau yang memanggil kode eksternal lewat peristiwa atau callback, tidak hanya memperpanjang berapa lama Anda memegangnya — ia membuka jalur di mana kode yang dipanggil mencoba mengambil kunci lain lalu deadlock. Siapkan di luar kunci, dan di dalam kunci jangan lakukan apa pun selain menukar — itu bentuk dasarnya.
Catat bahwa Anda tidak dapat await di dalam lock (itu kesalahan kompilasi). Ini perlindungan, bukan sekadar pembatasan: Monitor punya afinitas thread — thread yang mengambil kunci harus yang melepaskannya — yang tidak kompatibel dengan kode asinkron di mana thread yang mengeksekusi dapat berubah di seberang await. Untuk eksklusi dalam kode asinkron, pakai SemaphoreSlim dengan hitungan awal 1.14
private readonly SemaphoreSlim _asyncGate = new(1, 1);
public async Task SaveAsync(Data data, CancellationToken ct)
{
await _asyncGate.WaitAsync(ct);
try { await WriteToFileAsync(data, ct); }
finally { _asyncGate.Release(); }
}
5.3. Selalu ambil beberapa kunci dalam urutan yang sama
Ketika Anda punya dua kunci atau lebih, pola deadlock klasik adalah urutan perolehan terbalik dari satu thread ke thread lain. Perbaikannya sederhana: jadikan aturan bahwa setiap thread mengambil kunci dalam urutan yang sama. Di tempat Anda tidak dapat menjamin urutan, pakai overload berbatas waktu Monitor.TryEnter, dan jika kunci tidak didapat, mundur dan coba lagi (atau catat keanehan itu) — itu mengubah hang yang akan berlangsung selamanya menjadi kegagalan yang dapat dideteksi.4
5.4. Pakai Interlocked untuk pembaruan sederhana, ReaderWriterLockSlim ketika baca mendominasi
Untuk pembaruan atomik satu variabel — menaikkan atau menurunkan penghitung, menukar bendera — kelas Interlocked (Increment / Add / CompareExchange) lebih cepat daripada lock. Tanpa persaingan, biayanya bisa serendah satu prefiks instruksi CPU.4 Sebaliknya, sejauh itu Interlocked berhenti; ia tidak dapat menjaga beberapa variabel tetap konsisten bersama. Struktur bebas-kunci buatan sendiri yang digabung dengan volatile adalah alat ahli yang menuntut pemahaman dalam model memori, dan bukan sesuatu yang seharusnya Anda tulis di aplikasi bisnis.
Untuk data bersama di mana «baca sering tetapi tulis jarang», ada juga pilihan ReaderWriterLockSlim, yang membuat tulis eksklusif sambil membiarkan baca lewat secara bersamaan.13
6. Prinsip 4: Rancang cara berhenti, lebih dulu
Pertanyaan pertama yang harus diajukan dalam tinjauan desain multithreading adalah «bagaimana ini berhenti». Anda dapat menulis kode yang mulai berjalan tanpa memikirkannya, tetapi kode yang berhenti dengan aman tidak lahir kecuali Anda merancangnya.
6.1. Pembatalan kooperatif (CancellationToken) adalah satu-satunya jawaban yang benar
Model berhenti .NET disatukan di sekitar pembatalan kooperatif. Pihak yang ingin menghentikan sesuatu membuat CancellationTokenSource dan meneruskan Token-nya ke setiap potongan pemrosesan. Ketika ingin berhenti, ia memanggil Cancel(). Sisi pemrosesan mengawasi token dan, pada titik yang nyaman menurut pilihannya sendiri, membersihkan lalu selesai — karena ini kerja sama bukan paksaan, sisi pemrosesan dapat berakhir sambil menjaga keadaannya konsisten sepanjang waktu.7
private CancellationTokenSource? _cts;
private Task? _worker;
public void Start()
{
if (_worker is { IsCompleted: false }) // Tolak Start ganda selama masih berjalan
throw new InvalidOperationException("Pekerja sudah sedang berjalan.");
if (_worker is { IsFaulted: true }) // Jangan membangun ulang di atas kegagalan sebelumnya yang ditelan
throw new InvalidOperationException("Pekerja sebelumnya gagal.", _worker.Exception);
_cts = new CancellationTokenSource();
var token = _cts.Token; // Tangkap ke lokal dulu, agar tidak bersaing dengan Start ulang setelah berhenti
_worker = Task.Run(() => WorkLoop(token), token);
}
private void WorkLoop(CancellationToken ct)
{
while (!ct.IsCancellationRequested) // Awasi dengan polling
{
ProcessNextItem(ct); // Teruskan ct ke pemanggilan yang memblokir agar bisa dihentikan segera
}
}
public async Task StopAsync()
{
var cts = _cts; // Pin ke lokal agar bahkan jika field diganti
var worker = _worker; // sementara kita menunggu, kita tidak menghentikan sasaran yang salah
if (cts is null || worker is null) return;
Exception? cancelFailure = null;
try { cts.Cancel(); } // Callback yang terdaftar pada token dapat melempar
catch (Exception ex) { cancelFailure = ex; } // Tahan dulu, laporkan setelah join
try
{
try { await worker; } // Selalu join terlepas dari apakah Cancel berhasil, dan amati kegagalan di tengah jalan
catch (OperationCanceledException ex) when (ex.CancellationToken == cts.Token)
{ } // Hanya perlakukan pembatalan yang kita sendiri minta sebagai "normal"
catch (Exception ex) when (cancelFailure is not null)
{
throw new AggregateException(cancelFailure, ex); // Jangan kehilangan salah satu kegagalan
}
}
finally
{
cts.Dispose(); // Buang sumber setelah join (melepas sumber daya OS seperti WaitHandle-nya).
if (ReferenceEquals(_cts, cts))
{
_cts = null; // Jangan biarkan StopAsync berikutnya memakai sumber yang sudah dibuang
_worker = null;
}
}
if (cancelFailure is not null)
throw new AggregateException(cancelFailure);
}
Catat bahwa Start / StopAsync ini adalah konstruksi minimal yang mengandaikan dipanggil berurutan dari satu thread (thread UI, misalnya). Jika beberapa thread mungkin mengoperasikan siklus hidup secara bersamaan, serialkan Start / StopAsync itu sendiri dengan sesuatu seperti SemaphoreSlim — akan sia-sia jika operasi pengelolaan siklus hidup sendiri bersaing, sebelum Anda sempat melindungi pekerja.
Sampel kecil ini juga punya beberapa penyesuaian yang membuahkan hasil di lapangan. Pertama, Start menolak panggilan ganda selama masih berjalan. Menimpa _cts dan _worker tanpa syarat akan kehilangan referensi ke pekerja sebelumnya, meninggalkan «thread liar» yang berjalan berdampingan — yang tidak dapat Anda hentikan maupun join. Sudah standar bagi API siklus hidup (Start/Stop) untuk menegakkan «hanya satu pada satu waktu» pada dirinya sendiri. Di luar itu, tiga poin lagi. Pertama, API berhenti menunggu penyelesaian. Cancel() hanya «meminta» pembatalan; saat ia kembali, pekerja mungkin masih di tengah ProcessNextItem. Jadikan ia Stop() yang hanya meminta lalu kembali, dan Anda menciptakan kondisi balapan baru di mana pemanggil mulai membersihkannya sendiri sementara pekerja masih berjalan. Kedua, tahan Task alih-alih membuangnya. Buang dengan _ = Task.Run(...) dan tidak ada yang akan sadar jika pekerja mati karena pengecualian. Ketiga, tangkap token ke variabel lokal sebelum meneruskannya, alih-alih mereferensikan _cts.Token di dalam lambda. Mereferensikannya di dalam lambda berarti ia dievaluasi pada waktu eksekusi, dan jika Start ulang terjadi tepat setelah berhenti, Anda mendapat kekeliruan di mana pekerja lama meraih token baru. Meneruskan token yang sama sebagai argumen kedua Task.Run juga berarti bahwa ketika sisi pemrosesan berakhir lewat ThrowIfCancellationRequested atau OperationCanceledException dari API yang sadar pembatalan, Task diklasifikasikan sebagai «Canceled» alih-alih «Faulted» (dalam contoh ini, keluar secara normal lewat kondisi loop, seperti ditunjukkan, tetap dihitung sebagai selesai dengan sukses). Satu hal lagi: catch di StopAsync memakai filter when untuk menjebak hanya pembatalan yang berasal dari token miliknya sendiri. Menelan OperationCanceledException tanpa syarat akan membuat bahkan kegagalan sungguhan yang dilempar token berbeda di dalam pemrosesan — timeout per elemen, misalnya — terlihat seperti «ia berhenti, jadi tidak apa-apa». Catat bahwa identifikasi lewat kecocokan token ini runtuh jika WorkLoop secara internal memakai token tertaut (komposisi tautan dari bagian 6.1), karena pengecualian yang terbang keluar membawa token sisi tautan. Dalam konfigurasi itu, pilih secara eksplisit, sebagai keputusan desain, entah memanggil ct.ThrowIfCancellationRequested() di pintu keluar WorkLoop untuk «menerjemahkan» kembali ke token luar sebelum pergi, atau longgarkan filter menjadi when (cts.IsCancellationRequested) dan terima «pembatalan sementara permintaan berhenti sedang berlangsung dihitung sebagai normal».
flowchart TB
accTitle: Bentuk pembatalan kooperatif
accDescr: Bentuk pembatalan kooperatif. Pihak yang menghentikan hanya memanggil Cancel(); setiap potongan pemrosesan memutuskan sendiri «kapan dan bagaimana» ia berakhir. Itulah mengapa ia dapat berhenti sambil menjaga keadaannya konsisten
OWNER["Pihak yang menghentikan"] -->|"Memanggil Cancel() sekali"| CTS["CancellationTokenSource"]
CTS -->|"Menyerahkan Token"| W1["Pekerjaan worker 1"]
CTS -->|"Menyerahkan Token"| W2["Pekerjaan worker 2"]
CTS -->|"Menyerahkan Token"| W3["API pustaka yang<br/>mendukung pembatalan"]
W1 -->|"Memeriksa IsCancellationRequested<br/>membersihkan lalu berakhir sendiri"| E1["Selesai normal"]
W2 -->|"ThrowIfCancellationRequested"| E2["OperationCanceledException<br/>= diperlakukan sebagai pembatalan selesai"]
W3 -->|"Menginterupsi segera bahkan di tengah tunggu"| E3["Pembatalan selesai"]
Gambar 6: Bentuk pembatalan kooperatif. Pihak yang menghentikan hanya memanggil Cancel(); setiap potongan pemrosesan memutuskan sendiri «kapan dan bagaimana» ia berakhir. Itulah mengapa ia dapat berhenti sambil menjaga keadaannya konsisten
Ada konvensi di sisi pustaka juga. Operasi yang dapat dibatalkan harus menyediakan metode publik yang menerima CancellationToken, dan di dalam loop komputasi, periksa IsCancellationRequested secara berkala atau panggil ThrowIfCancellationRequested(). Yang terakhir melempar OperationCanceledException, yang diperlakukan Task sebagai «pembatalan selesai» alih-alih «kegagalan». Ketika Anda ingin berhenti baik pada token yang dipasok dari luar maupun keperluan internal (timeout, misalnya), gabungkan mereka dengan token tertaut.7
6.2. Anggap Thread.Abort tidak ada
Thread.Abort — «bunuh dari luar thread yang tidak mau menurut» — semata-mata melempar PlatformNotSupportedException di .NET Core / .NET 5 dan setelahnya; ia tidak lagi dapat dipakai sama sekali. Melempar pengecualian ke dalam thread tanpa mengetahui di mana ia sedang mengeksekusi berisiko menginterupsi pembersihan sumber daya dan merusak keadaan. Jika Anda perlu memaksa mengakhiri kode pihak ketiga yang tidak merespons pembatalan kooperatif (atau yang tidak dapat Anda tulis ulang agar merespons), panduan resmi adalah menjalankannya di proses terpisah dan menghentikannya dengan Process.Kill.8
6.3. Ketika menunggu, pakai wait handle, bukan polling
Menulis «tunggu dalam loop Sleep(100) sampai bendera terpasang» membuang baik CPU maupun daya tanggap. Ada primitif sinkronisasi seperti ManualResetEventSlim dan SemaphoreSlim untuk sinyal antar-thread, yang dengan benar menidurkan thread sampai diberi sinyal.13 Pilihan antara ketepatan timer dan tunggu peristiwa di Windows dibahas secara rinci di «Mengapa di Windows sebaiknya mengutamakan tunggu peristiwa daripada Sleep(1)».
7. Keistimewaan thread UI — aturan aplikasi desktop Windows
Aplikasi desktop Windows punya satu kendala kuat lagi di atas prinsip umum. Aturan bahwa hanya thread yang membuat UI (thread UI) yang boleh menyentuhnya.
Kontrol WinForms tidak thread-safe; mengoperasikannya dari beberapa thread mendorong kontrol ke keadaan tidak konsisten dan menyebabkan persaingan, deadlock, dan macet. Windows mensyaratkan aplikasi memiliki satu thread khusus yang menerima pesan sistem, dan pembuatan serta manipulasi UI harus dipusatkan pada thread itu.9 WPF punya struktur yang persis sama: hanya thread UI yang dapat mengubah elemen UI.10
Ketika Anda ingin memperbarui UI dari thread lain, jangan sentuh secara langsung — ubah menjadi «permintaan ke thread UI».
flowchart LR
accTitle: Pembaruan UI sebagai permintaan
accDescr: Ubah pembaruan UI menjadi «permintaan». Tugas thread latar hanya sampai menaruh pekerjaannya ke antrean pesan — selalu thread UI sendiri yang menyentuh kontrol
OS["Windows<br/>mouse, keyboard, penggambaran ulang"] --> Q["Antrean pesan<br/>thread UI"]
BG["Thread latar<br/>(pekerjaan berat, komunikasi)"] -->|"Meminta lewat Control.Invoke /<br/>Dispatcher.InvokeAsync"| Q
Q --> UI["Thread UI<br/>satu-satunya thread yang boleh menyentuh kontrol"]
BG -.->|"Menyentuh kontrol secara langsung"| NG["Dilarang<br/>penyebab persaingan, deadlock, dan macet"]
Gambar 7: Ubah pembaruan UI menjadi «permintaan». Tugas thread latar hanya sampai menaruh pekerjaannya ke antrean pesan — selalu thread UI sendiri yang menyentuh kontrol
| Kerangka | Cara meminta |
|---|---|
| WinForms | Control.Invoke (sinkron) / Control.BeginInvoke (asinkron) / mulai .NET 9, Control.InvokeAsync9 |
| WPF | Dispatcher.Invoke (sinkron) / Dispatcher.InvokeAsync / Dispatcher.BeginInvoke (asinkron)10 |
Di antara ini, bentuk sinkron (Control.Invoke / Dispatcher.Invoke) perlu kehati-hatian. Jika thread UI sedang menunggu secara sinkron pekerja itu selesai, dan pekerja memanggil Invoke, Anda mendapat deadlock di mana masing-masing menunggu yang lain (tepat tunggu melingkar dari bagian 2). Jadikan bentuk asinkron (BeginInvoke / InvokeAsync) bawaan untuk pemberitahuan dan laporan kemajuan dari thread latar, dan batasi bentuk sinkron pada situasi di mana Anda yakin thread UI tidak sedang menunggu Anda.
Di lapangan ada jawaban yang lebih baik lagi. Tulis pemrosesan yang dimulai di thread UI dengan async/await, dan await menangkap SynchronizationContext thread UI lalu secara otomatis melanjutkan kelanjutan di thread UI, yang sangat mengurangi jumlah tempat Anda perlu menulis tangan Invoke sama sekali. Ini bukan, bagaimanapun, sifat tanpa syarat. Kode yang dimasuki dari callback latar, atau kelanjutan setelah ConfigureAwait(false), tidak kembali ke thread UI, jadi dispatch eksplisit tetap dibutuhkan jika Anda menyentuh UI di jalur itu. Menetap pada bentuk «pekerjaan berat pergi ke Task.Run atau I/O asinkron, dan mencerminkan hasil di layar terjadi di kelanjutan setelah await» adalah bentuk dasar aplikasi Windows modern. Hubungan antara thread UI dan async/await dirangkum dalam satu diagram di «Async WPF/WinForms dan thread UI dalam satu lembar».
Selain itu, ketika COM terlibat — integrasi Office, komponen warisan, dan semacamnya — lapisan lain ditambahkan: model threading COM sendiri (STA/MTA). Insiden seperti «kami membuat objek COM di thread UI tetapi memanggilnya dari thread lain dan ia macet» termasuk lapisan ini, dan dijelaskan di «Dasar COM STA/MTA - model threading dan cara menghindari hang».
8. Jika menulis dalam kode native (C++/C)
Prinsip sampai di sini — jangan membuat thread secara langsung, kurangi keadaan berubah bersama, disiplin penguncian, merancang cara berhenti — berlaku langsung juga pada kode native. Yang berubah adalah perkakas. Di C++, padanannya adalah RAII bersama std::jthread / std::mutex / std::atomic; di C, mereka adalah _beginthreadex API Win32, kunci SRW, variabel kondisi, dan pola peristiwa berhenti. Masing-masing dibahas, termasuk jebakan khas bahasa (destruktor std::thread, bahaya TerminateThread, DllMain dan loader lock, dan lainnya), di «edisi C++» dan «edisi C» seri ini.
9. Verifikasi dan debugging — bersiap dengan asumsi «tidak mereproduksi»
Anda tidak dapat mengharapkan bug multithreading ditemukan oleh pengujian. Unit test biasa menghitung jalan di mana kondisi balapan kebetulan tidak terjadi sebagai keberhasilan. Pikirkan persiapan Anda dalam tiga lapisan.
Garis pertahanan pertama adalah prinsip desain yang dibahas sejauh ini, itu sendiri. Antara aplikasi dengan lima potong keadaan berubah bersama dan yang dengan lima puluh, jumlah tempat yang harus Anda curigai berbeda sepuluh kali lipat. Dalam tinjauan, periksa dengan tabel: «data berubah mana yang dibagi», «kunci mana melindungi masing-masing», «apakah urutan perolehan kunci unik», dan «di mana jalur berhenti». Desain yang tabel ini tidak dapat Anda tulis belum selesai, bahkan jika ia berjalan.
Kedua, buat keanehan dapat diamati alih-alih menyembunyikannya. Deteksi keanehan tunggu kunci dengan timeout Monitor.TryEnter dan catat,4 rekam alih-alih menelan pengecualian yang tidak teramati dari pekerjaan yang dilempar ke thread pool, dan siapkan untuk mengambil dump penuh saat hang agar Anda dapat memeriksa tumpukan setiap thread — perang melawan bug yang «hanya terjadi sesekali» diputuskan oleh seberapa banyak informasi yang dapat Anda ekstrak dari satu kali ia terjadi. Menyiapkan dump dan pencatatan dibahas di «Merancang aplikasi Windows agar meninggalkan log dan dump saat crash».
Ketiga, goyangkan di bawah beban. Pengujian stres yang memudahkan mengenai jalinan sial di mesin pengembangan — berjalan dengan paralelisme lebih banyak daripada jumlah inti untuk waktu yang panjang, mengacak urutan pemrosesan, menyisipkan tunda buatan, dan semacamnya — adalah cara realistis untuk mengusir kondisi balapan sebelum dikirim. Bug yang lenyap di bawah debugger sering mereproduksi di build rilis plus beban tinggi.
10. Ringkasan — daftar periksa sebelum menambah thread
Dipadatkan, praktik terbaik pemrograman multithread bukan «keterampilan menulis sinkronisasi dengan benar» melainkan «desain yang memungkinkan Anda menghindari menulis sinkronisasi sama sekali». Jika Anda dapat menjawab delapan pertanyaan berikut sebelum mulai, hampir setiap insiden besar dapat dicegah.
- Apakah pekerjaan ini CPU-bound atau I/O-bound (jika yang terakhir, jawabannya async/await, bukan thread)?
- Apakah Anda hampir menulis
new Thread(dapatkah ia diekspresikan dengan Task,Parallel, atau thread pool sebagai gantinya)? - Data berubah mana yang dibagi antar-thread — dapatkah Anda mendaftarkannya?
- Dapatkah berbagi itu dihilangkan lewat partisi, ketakberubahan, atau penyerahan lewat antrean?
- Untuk setiap potong data bersama yang tersisa, apakah ada tepat satu kunci yang sesuai yang sudah diputuskan?
- Apakah urutan perolehan kunci unik di setiap thread, dan apakah Anda menghindari panggilan eksternal sambil memegang kunci?
- Apakah
CancellationTokenditeruskan ke setiap operasi berjalan lama, dan dapatkah Anda menjelaskan jalur berhenti? - Apakah kode yang menyentuh UI dipusatkan pada thread UI?
Bug multithreading tidak muncul pada hari Anda menulis kode — mereka menunjukkan taringnya di situs pelanggan lama setelah Anda sudah lupa. Dibalik, jika Anda menjalankan daftar periksa ini pada tahap desain, Anda dapat mencabut jenis kegagalan paling mahal — «sesekali crash», «macet sebulan sekali» — sebelum menulis sebaris kode.
Artikel terkait
- Praktik terbaik multithreading di lapangan: edisi C++ — menghapus kecelakaan secara struktural dengan RAII dan jthread
- Praktik terbaik multithreading praktis: edisi C — menulis dengan aman cara Win32 API
- Praktik terbaik multithreading di lapangan: edisi Java — konvensi era virtual thread
- Tabel keputusan praktis C# async/await - Task.Run dan ConfigureAwait
- Async WPF/WinForms dan thread UI dalam satu lembar
- Kedalaman I/O Windows (bagian 3) — I/O Completion Port (IOCP) dan thread pool .NET
- Dasar COM STA/MTA - model threading dan cara menghindari hang
- Mengapa di Windows sebaiknya mengutamakan tunggu peristiwa daripada Sleep(1)
- Jebakan memori bersama dan praktik terbaik di lapangan
Area konsultasi terkait
KomuraSoft LLC menangani tinjauan desain aplikasi bisnis yang mencakup multithreading, investigasi akar masalah cacat yang sulit direproduksi seperti «sesekali crash atau macet» — analisis dump dan identifikasi lokasi persaingan — serta konsultasi teknis tentang memparalelkan atau mengasinkronkan aplikasi yang sudah ada. Kami siap dilibatkan sejak tahap «tolong periksa apakah desain ini bisa menimbulkan persaingan».
- Konsultasi teknis dan tinjauan desain
- Investigasi bug dan akar masalah
- Pengembangan aplikasi Windows
- Hubungi kami
Tautan referensi
-
Microsoft Learn, Task Parallel Library (TPL). Tentang TPL sebagai sarana yang direkomendasikan untuk kode multithread dan paralel sejak .NET Framework 4; tentang menyesuaikan derajat paralelisme secara dinamis dengan prosesor yang tersedia; tentang mengambil alih pembagian kerja, penjadwalan ke thread pool, penanganan pembatalan, dan pengelolaan keadaan; tentang loop yang pekerjaan per iterasinya kecil dapat menjadi lebih lambat karena overhead paralelisasi; dan tentang pemahaman dasar kunci, deadlock, dan kondisi balapan yang tetap direkomendasikan bahkan ketika memakai TPL. ↩ ↩2
-
Microsoft Learn, The managed thread pool. Tentang kelas ThreadPool yang menyediakan kumpulan thread pekerja yang dikelola sistem, membiarkan pengembang berfokus pada tugas aplikasi alih-alih pengelolaan thread; dan tentang .NET yang memakai thread pool secara luas untuk operasi TPL, penyelesaian I/O asinkron, callback timer, tunggu terdaftar, koneksi soket, dan lainnya. ↩ ↩2
-
Microsoft Learn, Potential Pitfalls in Data and Task Parallelism. Tentang loop paralel yang kadang lebih lambat daripada yang berurutan dan selalu perlu diukur; tentang menghindari penulisan ke memori bersama di dalam loop paralel, dengan overload yang menerima keadaan thread-lokal direkomendasikan; dan tentang tidak ada jaminan bahwa setiap iterasi For/ForEach benar-benar berjalan secara paralel, sehingga kode yang menunggu antar-iterasi dapat deadlock. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Managed threading best practices. Tentang definisi kondisi balapan (contoh di mana increment penghitung terurai menjadi baca, tambah, dan tulis kembali, lalu ditimpa dan hilang) dan deadlock; tentang memakai pembatalan kooperatif alih-alih Thread.Abort; tentang tipe atau this yang tidak boleh dipakai sebagai objek kunci, dan bahwa .NET 9 / C# 13 ke atas harus memakai instans System.Threading.Lock khusus; tentang pernyataan lock C# yang menjamin Monitor.Exit di blok finally; tentang mendeteksi deadlock dengan timeout Monitor.TryEnter; tentang kelas Interlocked yang lebih cepat untuk perubahan keadaan sederhana; dan tentang panduan desain bahwa data statis harus thread-safe secara bawaan dan data instans tidak thread-safe secara bawaan. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10
-
Microsoft Learn, System.Threading.Channels library. Tentang saluran sebagai FIFO untuk model produsen/konsumen yang mengelola sinkronisasi secara internal; tentang dapat membuat saluran dengan batas kapasitas memakai CreateBounded; tentang perilaku bawaan saat batas tercapai adalah penulis menunggu, dengan FullMode lain seperti DropOldest juga dapat dipilih; dan tentang tekanan balik yang diterapkan ketika penulisan mengungguli pembacaan. ↩ ↩2 ↩3
-
Microsoft Learn, Thread-safe collections. Tentang koleksi di bawah System.Collections.Concurrent yang mencapai keamanan thread lewat penguncian berbutir halus atau mekanisme bebas-kunci; dan tentang ConcurrentQueue serta ConcurrentStack yang diimplementasikan tanpa kunci, memakai operasi Interlocked, sehingga mereka bertahan di bawah penambahan dan penghapusan sering dari beberapa thread. ↩ ↩2
-
Microsoft Learn, Cancellation in Managed Threads. Tentang prosedur pembatalan kooperatif memakai CancellationTokenSource dan CancellationToken; tentang pembatalan yang kooperatif alih-alih dipaksa, dengan pendengar yang memutuskan cara berhenti; tentang tiga pendekatan pengawasan: polling, pendaftaran callback, dan wait handle; tentang ThrowIfCancellationRequested yang melempar OperationCanceledException, yang diperlakukan Task sebagai pembatalan selesai; tentang menggabungkan beberapa token dengan token tertaut; dan tentang pustaka yang perlu menyediakan metode publik yang menerima CancellationToken. ↩ ↩2 ↩3
-
Microsoft Learn, Using threads and threading. Tentang CancellationToken sebagai cara yang benar untuk menghentikan thread; tentang Thread.Abort yang melempar PlatformNotSupportedException di .NET Core dan .NET 5 ke atas, dengan peringatan depresiasi waktu kompilasi (SYSLIB0006) dari .NET 5 ke atas juga; dan tentang memaksa mengakhiri kode pihak ketiga yang tidak merespons pembatalan kooperatif yang mensyaratkan dijalankan di proses terpisah dan dihentikan dengan Process.Kill. ↩ ↩2
-
Microsoft Learn, How to handle cross-thread operations with controls. Tentang akses ke kontrol WinForms yang tidak thread-safe, dengan operasi dari beberapa thread yang berujung keadaan tidak konsisten, persaingan, deadlock, dan macet; tentang setiap kontrol yang perlu dibuat dan diakses di thread yang sama, dengan Windows yang mensyaratkan thread UI khusus untuk mengantar pesan sistem; dan tentang memanggil dengan aman dari thread lain memakai Control.Invoke, Control.InvokeAsync dari .NET 9 ke atas, atau BackgroundWorker. ↩ ↩2 ↩3
-
Microsoft Learn, Threading model (WPF). Tentang perubahan UI di WPF yang dibatasi pada satu thread, dengan thread latar yang mendaftarkan item kerja ke Dispatcher thread UI untuk memintanya; tentang Dispatcher.Invoke yang sinkron sementara InvokeAsync dan BeginInvoke asinkron; dan tentang Dispatcher yang memproses kerja sebagai antrean berprioritas. ↩ ↩2 ↩3
-
Microsoft Learn, Data Parallelism (Task Parallel Library). Tentang Parallel.For / Parallel.ForEach yang menyediakan paralelisme data dengan rasa hampir sama seperti menulis loop for; tentang tidak perlu membuat thread atau mengantrekan item kerja, dan tidak perlu kunci dalam loop dasar; dan tentang TPL yang memecah sumber data ke beberapa thread dan menyeimbangkan ulang beban jika menjadi timpang. ↩
-
Microsoft Learn, BlockingCollection<T> Class. Tentang BlockingCollection sebagai implementasi produsen/konsumen dengan pemblokiran dan batas kapasitas; tentang batas kapasitas yang mencegah produsen terlalu jauh mendahului konsumen; dan tentang ia tidak dirancang untuk akses asinkron, dengan Channel<T> direkomendasikan untuk produsen/konsumen asinkron. ↩ ↩2
-
Microsoft Learn, Overview of synchronization primitives. Tentang Monitor yang menyediakan eksklusi timbal balik lewat objek kunci dan punya afinitas thread; tentang kode C# yang diharapkan memakai pernyataan lock alih-alih Monitor secara langsung; tentang ReaderWriterLockSlim yang membuat tulis eksklusif sambil membiarkan baca bersamaan; dan tentang SemaphoreSlim sebagai semafor ringan untuk dipakai di dalam satu proses, sementara Semaphore bernama dan dapat dipakai untuk sinkronisasi lintas proses. ↩ ↩2 ↩3
-
Microsoft Learn, Async semaphores, locks, and reader/writer coordination. Tentang pernyataan lock C# dan tipe Lock yang punya afinitas thread sehingga tidak dapat dipakai di seberang await (karena thread yang mengeksekusi kelanjutan dapat berubah sebelum dan sesudah await); tentang memakai SemaphoreSlim dengan hitungan 1, lewat WaitAsync dan Release di finally, untuk eksklusi timbal balik dalam kode asinkron; dan tentang Channel bounded sebagai alternatif untuk keperluan throttling. ↩
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 C++ — menghapus kecelakaan secara struktural dengan RAII dan jthread
Di C++, multithreading adalah dunia di mana data race menjadi perilaku tak terdefinisi. Artikel ini menelusuri jebakan destruktor std::th...
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...
Memakai WMI/CIM dari C# dan PowerShell — panduan praktis pengambilan info perangkat keras, pemantauan proses, dan kueri jarak jauh
Cara standar mengambil nomor seri PC, memantau ruang disk, dan mendeteksi proses yang start adalah WMI/CIM. Artikel ini menjelaskan pemak...
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.
- Mengapa lock(this) atau lock(typeof(MyClass)) harus dihindari?
- Karena objek yang dikunci terlihat dari kode di luar milik Anda. this adalah instans itu sendiri, jadi kode eksternal yang dapat mereferensikan instans itu bisa mengunci objek yang sama, lalu menimbulkan persaingan atau deadlock yang tidak dimaksudkan. typeof(MyClass) bahkan lebih berbahaya: objek Type hanya ada satu per application domain, sehingga Anda berbagi kunci dengan kode yang sama sekali tidak terkait. Pakai objek khusus yang tidak pernah diekspos ke luar sebagai sasaran kunci. Mulai .NET 9 / C# 13, rekomendasinya adalah memakai instans tipe khusus System.Threading.Lock sebagai objek kunci.
- Berapa banyak thread yang boleh dibuat? Berapa jumlah thread yang optimal?
- Jawaban modern adalah jangan menentukan jumlah thread sendiri. Pakai Task dan kelas Parallel, dan thread pool akan menyesuaikan derajat paralelisme secara otomatis dengan jumlah inti CPU dan beban saat ini. Desain yang berulang-ulang memanggil new Thread cenderung kelebihan atau kekurangan pasokan di mesin pelanggan dengan jumlah inti berbeda. Yang perlu diperhatikan bukan angka, melainkan jenis pekerjaan: komputasi yang menghabiskan CPU tidak menjadi lebih cepat jika diparalelkan melebihi jumlah inti, dan pemrosesan yang sebagian besar menunggu I/O seharusnya tidak mendapat thread tambahan sama sekali — langkah yang benar di situ adalah I/O asinkron dengan async/await.
- Apakah menambahkan volatile membuat sesuatu menjadi thread-safe?
- Tidak. Yang dijamin volatile adalah pengurutan — bahwa akses ke field itu tidak diurut ulang terhadap operasi memori di sekitarnya (semantik acquire/release) — bukan keatomikan operasi majemuk seperti baca, hitung, tulis kembali. Misalnya, bahkan jika beberapa thread melakukan ++ pada penghitung volatile int, increment tetap hilang. Pakai kelas Interlocked untuk menaikkan atau menurunkan penghitung atau untuk compare-and-swap, dan pakai lock ketika beberapa variabel perlu dilindungi bersama sebagai satu kelompok. volatile hampir hanya layak dipertimbangkan pada kasus sederhana seperti bendera berhenti, di mana satu thread menulis dan yang lain hanya membaca — dan bendera itu pun kini standar diekspresikan sebagai CancellationToken.
- Bagaimana membedakan apakah bug yang hanya terjadi sesekali disebabkan multithreading?
- Tiga tanda yang harus dicurigai: operasi yang sama kadang mereproduksi, kadang tidak; gejala berhenti mereproduksi begitu debugger dipasang atau pencatatan ditambah; dan ia hanya terjadi di bawah beban tinggi atau tepat setelah startup. Bug yang bergantung pada waktu dicirikan hasil yang berubah setiap kali dijalankan — itu definisi kondisi balapan itu sendiri. Untuk mempersempitnya, pertama-tama daftarkan setiap data berubah yang Anda bagi, dan buat tabel, untuk masing-masing, kunci mana yang melindunginya. Satu akses yang tidak terlindungi pun adalah tersangka. Untuk hang, ambil tumpukan setiap thread dan periksa apakah mereka membentuk siklus menunggu kunci satu sama lain. Jeda di debugger Visual Studio dan lihat Parallel Stacks, atau, di produksi, ambil dump lalu analisis.
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.