Praktik terbaik multithreading di lapangan: edisi .NET — yang harus diputuskan sebelum menambah thread

· Diperbarui pada: · · Windows, Multithreading, C#, .NET, 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.22175828)

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 .NET — yang harus diputuskan sebelum menambah thread. KomuraSoft LLC. https://comcomponent.com/id/blog/multithreading-best-practices-dotnet/

DOI (arsip terdaftar)
10.5281/zenodo.22175828
DOI (versi terakhir yang didaftarkan)
10.5281/zenodo.22175829

“Pemrosesan lambat, jadi kami menambah thread agar paralel, lalu hasil agregasi sesekali meleset.” “Kami menambah pekerjaan latar, lalu aplikasi macet sebulan sekali.” “Katanya tidak mereproduksi saat di-debug, tetapi di mesin pelanggan itu pasti terjadi.” — Yang menakutkan dari pemrograman multithread adalah kodenya tampak benar segera setelah ditulis. Bug race bergantung pada timing: lolos dari pengujian, lalu baru muncul di produksi.

Di sisi lain, perangkat keras multi-inti sudah biasa. Di aplikasi bisnis pun ada kebutuhan yang tidak bisa menghindari multithreading: menjalankan pemrosesan berat tanpa membekukan UI, atau memproses beberapa perangkat atau file secara bersamaan. Yang penting adalah memutuskan prinsip desain sebelum menambah thread. Bug multithreading bukan sesuatu yang dibasmi lewat debugging; ruang masuknya harus ditutup sejak desain.

Artikel ini adalah edisi .NET dari seri multithreading di lapangan. Ditujukan kepada pengembang yang membangun aplikasi bisnis di Windows dan perlu menambah multithreading, artikel ini merangkum prinsip desain yang berlaku lintas bahasa dan OS, beserta perkakas konkret di C#/.NET, berdasarkan sumber primer per Agustus 2026. Prinsipnya sendiri tidak berubah di Linux atau di C++. Jika menulis kode native, lihat artikel pendamping yang memetakan prinsip yang sama ke perkakas masing-masing bahasa: “edisi C++” dan “edisi C”. Jika menulis Java, lihat “edisi Java”.

1. Intinya dulu

  • Praktik terbaik yang pertama adalah jangan membuat thread sendiri. Bertumpu pada API tingkat lebih tinggi — Task, thread pool, Parallel, dan sejenisnya — alih-alih new Thread, dan serahkan pengelolaan jumlah thread kepada runtime.12
  • Yang pertama dipotong saat memparalelkan adalah “state mutable bersama”. Tempat beberapa thread menulis ke variabel yang sama adalah sumber race. Sebelum melindungi dengan lock, kurangi berbagi itu sendiri lewat partisi data, immutability, dan penyerahan.3
  • Beri disiplin pada penguncian. Putuskan satu lawan satu “lock mana melindungi data mana”, dan jadikan objek lock instans khusus yang tidak terlihat dari luar. lock(this) dan lock(typeof(X)) dilarang. Mulai .NET 9, pakai tipe khusus System.Threading.Lock.4
  • Arahkan penyerahan data antar-thread ke antrean. Susunan produsen/konsumen di atas System.Threading.Channels atau koleksi konkuren lebih sederhana daripada menyebar lock ke mana-mana, dan batasnya pun jelas.56
  • Rancang cara berhenti lebih dulu. Pembatalan kooperatif lewat CancellationToken adalah satu-satunya jawaban yang benar untuk berhenti; Thread.Abort melempar pengecualian runtime di .NET (jalur berbasis Core).78
  • UI milik eksklusif thread UI. 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

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 28, 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

Dipadatkan, masalah yang dibawa multithreading ada dua jenis.4

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 muncul tidak dapat diprediksi.4

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 increment pada penghitung bersama hilang. Jika thread lain menyisip di antara tiga langkah count++, penulisan kembali yang belakangan menimpa yang lain

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

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

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

Yang merepotkan: keduanya bergantung pada timing. Interleaving (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 timing berbeda. “Tidak mereproduksi saat debugger terpasang” dan “hilang setelah log ditambah” terjadi karena observasi sendiri mengubah timing — itu perilaku klasik bug race.

Itulah sebabnya semua prinsip berikut mengarah ke satu arah. Sebelum “menyinkronkan dengan benar”, kurangi tempat yang perlu disinkronkan — ini prinsip dasar 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) — keluarga API yang berpusat pada Task. TPL menyesuaikan derajat paralelisme secara dinamis dengan prosesor yang tersedia, dan mengambil alih pekerjaan tingkat rendah: membagi kerja, menjadwalkannya ke thread pool, menangani pembatalan, dan mengelola state.1

Thread pool adalah infrastruktur yang dipakai .NET sendiri secara luas — menjalankan Task, menyelesaikan I/O asinkron, callback timer, dan lainnya. Selama yang dilempar adalah potongan kerja pendek, pengembang tidak perlu mengelola siklus hidup thread.2

// Komputasi berat yang memakai CPU, di latar
var result = await Task.Run(() => HeavyCalculation(input));

// Jalankan beberapa pemrosesan independen secara bersamaan, lalu tunggu semuanya (ketika jumlahnya kecil)
// * Bentuk ini untuk kasus ProcessAsync adalah metode asinkron yang didominasi I/O.
//   WhenAll hanya "menunggu Task yang sudah berjalan", jadi jika 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 poin: sambungkan token pemanggil ke ParallelOptions
// (lupa ini, dan ct di tubuh metode selalu None), dan teruskan ct
// yang sama ke tubuh juga (jangan buang)

Ada satu peringatan. Task.WhenAll(items.Select(...)) memulai pemrosesan setiap elemen sekaligus pada saat dienumerasi. Itu tidak masalah untuk beberapa hingga puluhan item yang sudah ditentukan, tetapi memakainya pada koleksi besar akan menghabiskan soket, koneksi DB, dan memori sekaligus. Untuk pekerjaan yang volumenya tidak bisa diprediksi, batasi derajat konkurensi seperti Parallel.ForEachAsync di atas, atau kendalikan aliran dengan channel bounded yang dibahas nanti.

Membuat thread sendiri hampir hanya dibenarkan ketika sifat thread itu sendiri yang menjadi syarat: “perlu message loop khusus”, “perlu menentukan apartment (STA)”, atau “harus terus berjalan sepanjang masa hidup aplikasi”.

3.2. Paralelisme data: Parallel.For / ForEach

Untuk paralelisme data — “terapkan pemrosesan yang sama pada setiap elemen koleksi agar keseluruhannya lebih cepat” — pakai Parallel.For / Parallel.ForEach, jangan membagi loop ke thread sendiri. TPL mengurus pemecahan sumber data (partisi) dan penyeimbangan ulang beban. Loop dasar bahkan tidak perlu lock.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.For benar-benar berjalan secara paralel. Kode di mana satu iterasi menunggu event yang disetel iterasi lain dapat deadlock, bergantung pada penjadwalan.

3.3. Pekerjaan “menunggu” ke I/O asinkron, bukan ke thread

Pekerjaan yang didominasi tunggu I/O — file, jaringan, basis data — bukan kandidat untuk menambah thread. Mengikat satu thread penuh 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.

Didominasi tunggu I/Ofile, jaringan, DBKomputasi yang memakai CPUMenerapkan pemrosesan yang samapada setiap elemen koleksiSatu blok pekerjaan lataryang independenMessage loop, persyaratan STA, dll.sifat thread itu sendiri yang menjadi syaratAda pekerjaan yang ingin dijalankan secara paralelApa yang mendominasi pekerjaan?I/O asinkron dengan async/awaitjangan menambah threadBagaimana bentuk pekerjaannya?Parallel.For / ForEachTask.Run / Task.WhenAllnew Thread(jalan terakhir yang bersifat pengecualian)

Gambar 3: Percabangan 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”. Bagaimana thread pool dan I/O asinkron terhubung di bawahnya dibahas secara rinci di “IOCP dan thread pool .NET”.

4. Prinsip 2: Minimalkan state mutable bersama

Race hanya terjadi ketika “beberapa thread” dan “data mutable bersama” keduanya ada. Jumlah thread ditentukan persyaratan, jadi yang bisa dipotong desain adalah berbagi. Ada tiga cara.

4.1. Partisi — setiap thread hanya menyentuh datanya sendiri

Pendekatan paling sederhana dan paling kuat: bagi data per thread. Untuk agregasi dalam loop paralel, jangan menulis ke variabel total bersama pada setiap iterasi. Pakai overload Parallel.For yang menerima state thread-local agar setiap thread membangun subtotalnya sendiri di lokal, lalu gabungkan sekali di akhir. Penulisan ke state bersama turun dari “setiap iterasi” menjadi “sekali per thread”. Biaya sinkronisasi dan jendela race menyusut berlipat ganda.3

long total = 0;
Parallel.For(0, items.Length,
    () => 0L,                                  // Nilai awal thread-local
    (i, state, local) => local + Weigh(items[i]), // Setiap iterasi hanya menambah ke local miliknya
    local => Interlocked.Add(ref total, local));  // Penggabungan sekali per thread
Array data (yang diproses)Thread 1memproses bagiannya danhanya menambah ke subtotal lokalThread 2memproses bagiannya danhanya menambah ke subtotal lokalThread 3memproses bagiannya danhanya menambah ke subtotal lokalGabung: Interlocked.Addmencerminkan ke total, sekali per thread

Gambar 4: Agregasi thread-local. Karena setiap thread hanya menyentuh datanya sendiri selama pemrosesan, tidak ada ruang untuk race, dan penulisan ke state bersama terjadi hanya sekali per thread, pada saat penggabungan

4.2. Immutable — yang tidak ditulis ulang boleh dibagi

Data yang hanya pernah dibaca aman dibaca sekaligus dari berapa pun banyak thread. Nilai konfigurasi, data master, masukan komputasi, dan sejenisnya dapat dibagi bebas tanpa sinkronisasi jika dibuat immutable — tidak pernah ditulis ulang setelah konstruksi. Di C#, tipe record dan properti init mendukung desain ini. Cukup memutuskan “ketika perubahan dibutuhkan, bangun instans baru dan tukar, jangan tulis ulang yang sudah ada” sudah menghapus satu potong state mutable yang harus dilindungi.

Tetapi “tampak hanya-baca” dan “immutable” adalah dua hal berbeda. Antarmuka hanya-baca seperti IReadOnlyList<T> hanya berarti “tidak dapat ditulis ulang lewat antarmuka itu” — ia tidak mencegah List<T> di bawahnya ditulis ulang lewat referensi lain. Jaminan record / init pun dangkal: objek yang ditunjuk properti tidak ikut terlindungi. Untuk data yang benar-benar ingin dibagi aman antar-thread, pakai koleksi immutable dari System.Collections.Immutable, misalnya ImmutableArray<T>, atau teruskan salinan pada saat berbagi, sehingga jalur penulisan ulang terputus. Syaratnya: tipe elemen T itu sendiri juga harus immutable. Koleksi immutable hanya melindungi “urutan”. Referensi ke objek elemen yang mutable tetap dibagi apa adanya, jadi jika isi elemen bisa ditulis ulang lewat jalur lain, race tetap ada. Buat graf objek immutable sampai daunnya, atau teruskan salinan dalam.

4.3. Serahkan — kirim lewat antrean, jangan berbagi

Meski begitu, data tetap perlu dipindahkan antar-thread. Ketika itu, jangan “kedua sisi menyentuh variabel bersama”. Pakai susunan produsen/konsumen: satu sisi menulis, sisi lain membaca, dengan antrean di tengah.

Pilihan pertama di .NET adalah System.Threading.Channels. Ini FIFO tempat produsen menulis data secara asinkron dan konsumen membacanya secara asinkron. Channel itu sendiri mengelola semua pekerjaan sinkronisasi.5

var channel = Channel.CreateBounded<WorkItem>(100); // Kapasitas 100 — menerapkan backpressure

// Sisi produsen
await channel.Writer.WriteAsync(item, ct); // Jika penuh, menunggu sampai ada ruang
// …setelah semua 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);
}
Jika penuh, buat penulisan menunggu(backpressure)Jika kosong, buat pembacaan menungguProdusen 1WriteAsyncChannel bounded (kapasitas 100)Antrean FIFOsinkronisasi dikelola channelProdusen 2WriteAsyncKonsumen 1ReadAllAsyncKonsumen 2ReadAllAsync

Gambar 5: Susunan produsen/konsumen dengan channel di tengah. Kedua sisi tidak menyentuh variabel bersama secara langsung; baik tunggu maupun pengendalian kapasitas diserahkan kepada channel

Yang penting di lapangan adalah memilih channel berkapasitas terbatas (bounded). Perilaku bawaan saat batas tercapai adalah “sisi penulis menunggu ruang”, dan ini menjadi backpressure yang alami. Pakai antrean tanpa batas dalam susunan di mana produksi mengungguli konsumsi, dan yang didapat adalah bom waktu: tetap berjalan sementara memori terus tumbuh.5

Di dunia sinkron, peran channel 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 lock6, 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 untuk akses asinkron, jadi jika dipasangkan dengan async/await, pilih Channel<T>.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 bisa race (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 file, 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 state mutable bersama.

5. Prinsip 3: Beri disiplin pada penguncian

Bahkan setelah mengurangi state mutable bersama, sering kali tidak bisa dibuat nol. Pakai pengendalian eksklusif (lock) untuk yang tetap bersama, tetapi lock bukan alat untuk “membungkus lock di sekitar mana pun yang kelihatan mencurigakan”. 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 lock untuk setiap himpunan data mutable yang ingin dilindungi, dan ambil lock yang sama di setiap tempat yang menyentuh data itu. Bug race, pada praktiknya, adalah apa yang terjadi ketika tabel korespondensi ini sudah runtuh.

Jadikan objek lock instans khusus yang tidak diekspos ke luar. lock(this) berbagi lock dengan kode eksternal mana pun yang dapat mereferensikan instans Anda, dan lock(typeof(X)) berbagi lock dengan seluruh application domain — keduanya lahan subur untuk deadlock. Mulai .NET 9 / C# 13, memakai instans tipe khusus System.Threading.Lock sebagai objek lock direkomendasikan.4

public class OrderBook
{
    private readonly Lock _gate = new();          // .NET 9+ (sebelumnya readonly object)
    private readonly List<Order> _orders = [];    // Data yang dilindungi _gate

    public void Add(Order order)
    {
        lock (_gate) { _orders.Add(order); }
    }
}

Pernyataan lock C# menjamin lock dilepas bahkan ketika pengecualian terjadi. Cara ia diperluas bergantung pada tipe objek lock: 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. Jika hanya sebagian kode 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 lock

Semakin singkat lock dipegang, semakin baik. Satu-satunya hal yang boleh dilakukan sambil memegangnya adalah membaca dan menulis data yang dilindunginya. Kode yang melakukan I/O sambil memegang lock, atau yang memanggil kode eksternal lewat event atau callback, tidak hanya memperpanjang waktu pegang — ia membuka jalur di mana kode yang dipanggil mencoba mengambil lock lain lalu deadlock. Siapkan di luar lock, dan di dalam lock jangan lakukan apa pun selain menukar — itu bentuk dasarnya.

Catat bahwa await di dalam lock tidak bisa (itu kesalahan kompilasi). Ini perlindungan, bukan sekadar pembatasan: Monitor punya afinitas thread — thread yang mengambil lock 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 lock dalam urutan yang sama

Ketika ada dua lock atau lebih, pola deadlock klasik adalah urutan perolehan terbalik dari satu thread ke thread lain. Perbaikannya sederhana: jadikan aturan bahwa setiap thread mengambil lock dalam urutan yang sama. Di tempat urutan tidak bisa dijamin, pakai overload berbatas waktu Monitor.TryEnter, dan jika lock 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 lock-free buatan sendiri yang digabung dengan volatile adalah alat ahli yang menuntut pemahaman dalam model memori, dan bukan sesuatu yang seharusnya ditulis 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”. Kode yang mulai berjalan bisa ditulis tanpa memikirkannya, tetapi kode yang berhenti dengan aman tidak lahir kecuali dirancang.

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 state-nya konsisten.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 — sia-sia melindungi pekerja jika operasi pengelolaan siklus hidup sendiri sudah saling race.

Sampel kecil ini juga memuat 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 yatim” yang berjalan berdampingan — yang tidak dapat dihentikan maupun di-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 yang tercipta adalah race baru: pemanggil mulai membersihkan 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, 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 — tampak sebagai “berhenti, jadi normal”. Identifikasi lewat kecocokan token ini runtuh jika WorkLoop memakai token tertaut (komposisi tautan di 6.1). Pengecualian yang datang membawa token sisi tautan. Dalam susunan itu, pilih secara eksplisit sebagai desain: panggil ct.ThrowIfCancellationRequested() di pintu keluar WorkLoop untuk “menerjemahkan” ke token luar sebelum keluar, atau longgarkan filter menjadi when (cts.IsCancellationRequested) dan terima bahwa “pembatalan selama permintaan berhenti dianggap normal”.

Memanggil Cancel() sekaliMenyerahkan TokenMenyerahkan TokenMenyerahkan TokenMemeriksa IsCancellationRequestedmembersihkan lalu berakhir sendiriThrowIfCancellationRequestedMenginterupsi segera bahkan di tengah tungguPihak yang menghentikanCancellationTokenSourcePekerjaan worker 1Pekerjaan worker 2API pustaka yangmendukung pembatalanSelesai normalOperationCanceledException= diperlakukan sebagai pembatalan selesaiPembatalan 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 state-nya 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 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. Melempar pengecualian ke dalam thread tanpa mengetahui di mana ia sedang mengeksekusi berisiko menginterupsi pelepasan sumber daya dan merusak state. Jika perlu memaksa mengakhiri kode pihak ketiga yang tidak merespons pembatalan kooperatif (atau yang tidak dapat ditulis 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 event di Windows dibahas secara rinci di “Mengapa di Windows sebaiknya mengutamakan tunggu event daripada Sleep(1)”.

7. Kekhususan 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 state yang tidak konsisten dan menyebabkan race, deadlock, dan freeze. 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 sama: hanya thread UI yang dapat mengubah elemen UI.10

Ketika ingin memperbarui UI dari thread lain, jangan sentuh secara langsung — ubah menjadi “permintaan ke thread UI”.

Meminta lewat Control.Invoke /Dispatcher.InvokeAsyncMenyentuh kontrol secara langsungWindowsmouse, keyboard, penggambaran ulangAntrean pesanthread UIThread latar(pekerjaan berat, komunikasi)Thread UIsatu-satunya thread yang boleh menyentuh kontrolDilarangpenyebab race, deadlock, dan freeze

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, yang didapat adalah 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 bisa dipastikan thread UI tidak sedang menunggu.

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. Jumlah tempat yang perlu menulis tangan Invoke pun jauh berkurang. 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 UI disentuh 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”.

8. Jika menulis native (C++/C)

Prinsip sampai di sini — jangan membuat thread secara langsung, kurangi state mutable 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, lock SRW, variabel kondisi, dan pola event 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 — siap-siap dengan asumsi “tidak mereproduksi”

Bug multithreading tidak bisa diharapkan ditemukan oleh pengujian. Unit test biasa menghitung jalan di mana race kebetulan tidak terjadi sebagai keberhasilan. Pikirkan persiapan dalam tiga lapisan.

Garis pertahanan pertama adalah prinsip desain yang dibahas sejauh ini, itu sendiri. Antara aplikasi dengan lima potong state mutable bersama dan yang dengan lima puluh, jumlah tempat yang harus dicurigai berbeda sepuluh kali lipat. Dalam tinjauan, periksa dengan tabel: “data mutable mana yang dibagi”, “lock mana melindungi masing-masing”, “apakah urutan perolehan lock unik”, dan “di mana jalur berhenti”. Desain yang tabel ini tidak bisa ditulis belum selesai, bahkan jika ia berjalan.

Kedua, buat keanehan dapat diamati alih-alih menyembunyikannya. Deteksi keanehan tunggu lock 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 stack setiap thread bisa diperiksa — perang melawan bug yang “hanya terjadi sesekali” diputuskan oleh seberapa banyak informasi yang bisa diambil dari satu kali ia terjadi. Menyiapkan dump dan log dibahas di “Merancang aplikasi Windows agar meninggalkan log dan dump saat crash”.

Ketiga, goyangkan di bawah beban. Pengujian stres yang memudahkan mengenai interleaving 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 race 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 delapan pertanyaan berikut bisa dijawab sebelum mulai, hampir setiap insiden besar dapat dicegah.

  1. Apakah pekerjaan ini CPU-bound atau I/O-bound (jika yang terakhir, jawabannya async/await, bukan thread)?
  2. Apakah Anda hampir menulis new Thread (dapatkah ia diekspresikan dengan Task, Parallel, atau thread pool sebagai gantinya)?
  3. Data mutable mana yang dibagi antar-thread — dapatkah didaftarkan?
  4. Dapatkah berbagi itu dihilangkan lewat partisi, immutability, atau penyerahan lewat antrean?
  5. Untuk setiap potong data bersama yang tersisa, apakah ada tepat satu lock yang sesuai yang sudah diputuskan?
  6. Apakah urutan perolehan lock unik di setiap thread, dan apakah panggilan eksternal dihindari sambil memegang lock?
  7. Apakah CancellationToken diteruskan ke setiap operasi berjalan lama, dan dapatkah jalur berhenti dijelaskan?
  8. Apakah kode yang menyentuh UI dipusatkan pada thread UI?

Bug multithreading tidak muncul pada hari kode ditulis. Ia baru muncul di mesin pelanggan lama setelah sudah dilupakan. Sebaliknya, jika daftar periksa ini dijalankan pada tahap desain, jenis kegagalan paling mahal — “sesekali crash”, “macet sebulan sekali” — bisa dicabut sebelum menulis sebaris kode.

Artikel terkait

Area konsultasi terkait

KomuraSoft LLC menangani tinjauan desain aplikasi bisnis yang mencakup multithreading, investigasi akar masalah pada bug yang sulit direproduksi seperti “sesekali crash atau macet” (analisis dump dan identifikasi lokasi race), serta konsultasi teknis untuk memparalelkan atau mengasinkronkan aplikasi yang sudah ada. Tidak masalah jika permintaan masih pada tahap “tolong periksa apakah desain ini bisa menimbulkan race”.

Tautan referensi

  1. 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 state; tentang loop yang pekerjaan per iterasinya kecil dapat menjadi lebih lambat karena overhead paralelisasi; dan tentang pemahaman dasar lock, deadlock, dan race condition yang tetap direkomendasikan bahkan ketika memakai TPL. ↩ ↩2

  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

  3. 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 state thread-local 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

  4. Microsoft Learn, Managed threading best practices. Tentang definisi race condition (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 lock, 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 state 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

  5. Microsoft Learn, System.Threading.Channels library. Tentang channel sebagai FIFO untuk model produsen/konsumen yang mengelola sinkronisasi secara internal; tentang dapat membuat channel dengan batas kapasitas memakai CreateBounded; tentang perilaku bawaan saat batas tercapai adalah penulis menunggu (Wait), dengan FullMode lain seperti DropOldest juga dapat dipilih; dan tentang backpressure yang diterapkan ketika penulisan mengungguli pembacaan. ↩ ↩2 ↩3

  6. Microsoft Learn, Thread-safe collections. Tentang koleksi di bawah System.Collections.Concurrent yang mencapai keamanan thread lewat penguncian berbutir halus atau mekanisme lock-free; dan tentang ConcurrentQueue serta ConcurrentStack yang diimplementasikan tanpa lock, memakai operasi Interlocked, sehingga mereka bertahan di bawah penambahan dan penghapusan sering dari beberapa thread. ↩ ↩2

  7. 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

  8. 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

  9. 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 state tidak konsisten, race, deadlock, dan freeze; 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

  10. 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

  11. 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 lock dalam loop dasar; dan tentang TPL yang memecah sumber data ke beberapa thread dan menyeimbangkan ulang beban jika menjadi timpang. ↩

  12. 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

  13. Microsoft Learn, Overview of synchronization primitives. Tentang Monitor yang menyediakan eksklusi timbal balik lewat objek lock 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

  14. 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 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.

Mengapa lock(this) atau lock(typeof(MyClass)) harus dihindari?
Karena objek yang dikunci terlihat juga dari kode di luar milik Anda. this adalah instans itu sendiri, jadi kode eksternal yang bisa mereferensikan instans itu dapat mengunci objek yang sama, lalu memicu race atau deadlock yang tidak dimaksudkan. typeof(MyClass) lebih berbahaya lagi: objek Type hanya ada satu di dalam application domain, sehingga lock ikut terbagi dengan kode yang sama sekali tidak terkait. Pakai objek khusus yang tidak diekspos ke luar sebagai sasaran lock. Mulai .NET 9 / C# 13, yang direkomendasikan adalah memakai instans tipe khusus System.Threading.Lock sebagai objek lock.
Berapa banyak thread yang boleh dibuat? Berapa jumlah thread yang optimal?
Jawaban modern: jangan menentukan jumlah thread sendiri. Pakai Task atau kelas Parallel, dan thread pool akan menyesuaikan derajat paralelisme secara otomatis menurut jumlah inti CPU dan beban. Desain yang berulang-ulang memanggil new Thread cenderung kelebihan atau kekurangan pasokan di mesin pelanggan yang jumlah intinya berbeda. Yang perlu diperhatikan bukan angka, melainkan jenis pekerjaan. Komputasi yang menghabiskan CPU tidak menjadi lebih cepat jika diparalelkan melebihi jumlah inti. Pemrosesan yang didominasi tunggu I/O seharusnya tidak mendapat thread tambahan — yang benar di situ adalah I/O asinkron dengan async/await.
Apakah menambahkan volatile membuat kode menjadi thread-safe?
Tidak. Yang dijamin volatile hanyalah pengurutan: akses ke field itu tidak diurut ulang terhadap operasi memori di sekitarnya (semantik acquire/release). Itu bukan keatomikan operasi majemuk seperti baca, hitung, lalu tulis kembali. Misalnya, meskipun beberapa thread melakukan ++ pada penghitung volatile int, increment tetap bisa hilang. Pakai kelas Interlocked untuk menaikkan atau menurunkan penghitung, atau untuk compare-and-swap. Pakai lock jika beberapa variabel harus dilindungi bersama sebagai satu kelompok. volatile hampir hanya relevan pada kasus sederhana seperti bendera berhenti, di mana satu thread menulis dan yang lain hanya membaca — dan bendera itu pun sekarang standar diekspresikan sebagai CancellationToken.
Bagaimana membedakan apakah bug yang hanya muncul sesekali disebabkan multithreading?
Tiga tanda yang perlu dicurigai: operasi yang sama kadang mereproduksi, kadang tidak; gejala hilang begitu debugger dipasang atau log ditambah; dan ia hanya terjadi saat beban tinggi atau tepat setelah startup. Bug yang bergantung pada timing dicirikan hasil yang berubah setiap kali dijalankan — itu definisi race condition itu sendiri. Untuk mengisolasinya, daftarkan dulu setiap data mutable yang dibagi, lalu buat tabel: lock mana yang melindungi masing-masing. Satu akses yang tidak terlindungi pun sudah tersangka. Untuk hang, ambil stack setiap thread dan periksa apakah mereka membentuk siklus menunggu lock 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.

Kembali ke blog