Menguasai Konkurensi & Paralelisme
Setiap sistem backend yang akan Anda bangun berbagi satu persyaratan yang tidak bisa ditawar: ia harus menangani banyak hal sekaligus. Web server yang hanya bisa memproses satu request per waktu memaksa setiap pengguna lain menunggu, dan itu tidak akan berhasil di produksi. Panduan ini membangun model mental lengkap dari dasar, dari siklus CPU mentah dan operasi IO, lewat thread OS dan event loop, sampai primitif konkurensi modern seperti goroutine dan async/await. Semuanya berpijak pada kode backend praktis memakai Go dan Python.
Kenapa Konkurensi Penting
Saat kita bicara "aplikasi backend" dalam konteks ini, maksudnya web server berbasis HTTP, proses yang berjalan di mesin, mendengarkan di port, menerima request dari browser dan klien lain, dan mengirim respons kembali.
Jika server Anda hanya bisa memproses satu request per waktu, maka setiap pengguna lain, berpotensi ribuan, harus menunggu dalam antrean atau menerima error. Situasi mustahil untuk aplikasi produksi nyata mana pun.
Memahami bagaimana server Anda melakukan banyak hal sekaligus bukan sekadar "bagus untuk diketahui", ia membentuk cara Anda men-debug masalah, menyusun aplikasi, dan membuat keputusan arsitektural.
Kebanyakan kita mempelajari kata kunci seperti async, await, dan threading dari dokumentasi bahasa pemrograman. Kita tahu mereka "membuat hal menjadi konkuren." Tapi kita jarang memahami apa yang sebenarnya dilakukan kata kunci ini di balik layar, di level mekanis, apa yang terjadi di CPU dan sistem operasi.
Biaya Duduk Menganggur
Mari mulai dengan skenario konkret. Anda punya browser membuat request ke backend. Backend perlu meng-query database, menunggu respons, dan mengembalikan data ke klien. Itu siklus hidup khas panggilan API.
Berapa lama query database?
Waktu tunggu tergantung di mana database Anda tinggal relatif terhadap server Anda:
| Skenario | Latensi | Deskripsi |
|---|---|---|
| Localhost | 1-2 ms | Server dan database di mesin yang sama |
| Region sama, AZ berbeda | 20-30 ms | Setup produksi khas (mis. AWS us-east-1a -> us-east-1b) |
| Lintas region | 90-100 ms | Database di region yang jauh secara geografis |
Selama waktu tunggu itu, entah 2 ms atau 100 ms, jika server Anda memproses secara sinkron (satu request per waktu, baris demi baris), CPU Anda melakukan sama sekali tidak ada apa pun. Ia sepenuhnya idle.
Mengukur pemborosan
CPU modern mengeksekusi ±3 miliar instruksi per detik, sekitar 3 juta instruksi per milidetik. Jika server Anda duduk idle 100 ms menunggu respons database, ia seharusnya bisa mengeksekusi 300 juta instruksi: tapi malah mengeksekusi nol.
Uraian panggilan API yang realistis
Panggilan API menengah-ke-kompleks khas melibatkan 3-5 query database plus 1-2 panggilan layanan eksternal (email, cache seperti Redis, dll.). Jika masing-masing dari lima operasi jaringan itu rata-rata 50 ms, total waktu menunggu IO ±250 ms. Tapi komputasi CPU aktual, validasi, parsing JSON, serialisasi, hanya ±10 ms. Artinya perangkat keras server Anda idle 95% dari waktu. Persis inilah masalah yang dipecahkan konkurensi.
IO-Bound vs CPU-Bound
Setiap operasi yang dilakukan backend Anda jatuh ke salah satu dari dua kategori. Perbedaan ini konsep terpenting di seluruh panduan ini, ia menggerakkan setiap keputusan tentang model konkurensi mana yang dipakai.
Operasi IO-Bound
IO singkatan Input/Output: operasi apa pun tempat CPU tidak bisa melakukan hal berguna karena menunggu sesuatu yang eksternal. "I" merujuk data yang datang ke dalam proses Anda; "O" merujuk data yang keluar. Menurut halaman manual Linux read(2), read pada file descriptor (socket, file, pipe) akan memblokir thread pemanggil sampai data tiba.
Operasi IO-bound umum di sistem backend:
- Query database: mengirim query SQL lewat TCP, menunggu baris kembali
- Panggilan API eksternal: request HTTP ke layanan pihak ketiga (payment gateway, penyedia email)
- Operasi file system: membaca file config, menulis upload, penanganan file sementara
- Interaksi cache: operasi
GET/SETRedis lewat jaringan - I/O standar: logging ke
stdout, membaca daristdin - Message queue: publish ke atau consume dari Kafka, RabbitMQ, dll.
Operasi CPU-Bound
Kerja CPU-bound saat prosesor aktif mengunyah data, ia mengeksekusi instruksi secepat mungkin, dan bottleneck-nya CPU itu sendiri, bukan resource eksternal apa pun. Kebanyakan kerja CPU-bound di backend khas ringan (1-2 ms untuk parsing JSON atau validasi), tapi sebagian operasi sangat CPU-bound:
- Pemrosesan gambar/video: perkalian matriks, konvolusi, transformasi ruang warna
- Enkripsi / hashing: verifikasi JWT, hashing kata sandi bcrypt, handshake TLS
- Kompresi: encoding gzip, zstd, Brotli dari body respons
- Serialisasi: marshal/unmarshal JSON/Protobuf skala besar
- Inferensi ML: menjalankan forward pass model tanpa offload GPU
Lebih dari 70% waktu di aplikasi backend khas dihabiskan untuk IO. Untuk kerja IO-bound, konkurensi wajib. Untuk kerja CPU-bound, paralelisme (banyak core) memberi percepatan.
Konkurensi vs Paralelisme
Kedua istilah ini sering dikacaukan, dan dengan alasan yang baik, karena keduanya melibatkan "melakukan banyak hal." Tapi perbedaannya kritis.
Paralelisme: melakukan banyak hal pada waktu yang sama
Sebuah program paralel saat ia mengeksekusi banyak instruksi di momen yang sama. Ini butuh dukungan perangkat keras: minimal dua core CPU, karena satu core hanya bisa mengeksekusi satu instruksi dalam satu instan. Jika Anda punya 4 core, Anda bisa menjalankan 4 instruksi benar-benar serentak.
Konkurensi: menangani banyak hal sekaligus
Konkurensi soal menyusun program Anda supaya banyak tugas bisa dimulai, dijeda, dan dilanjutkan, bahkan di satu core. Dari luar, tampak semuanya terjadi sekaligus. Tapi perbesar ke momen tunggal mana pun, dan hanya satu hal yang sebenarnya mengeksekusi di core tertentu. Kuncinya: saat satu tugas terblokir (menunggu IO), tugas lain mendapat CPU.
Paralelisme soal melakukan banyak hal sekaligus. Konkurensi soal menangani banyak hal sekaligus.
Dikreditkan ke Rob Pike, salah satu pencipta Go
Thread OS
Mekanisme fundamental pertama untuk melakukan banyak hal sekaligus adalah thread. Thread unit eksekusi independen yang dikelola sistem operasi. Ia bukan fitur bahasa pemrograman Anda, ia fitur kernel OS. Saat Anda "membuat thread", Anda membuat system call (seperti clone() di Linux atau pthread_create() via POSIX) untuk meminta kernel menyiapkan konteks eksekusi baru.
Apa yang OS buat untuk tiap thread
Saat thread baru dibuat, kernel mengalokasikan beberapa hal untuknya:
- Stack: dipakai melacak panggilan fungsi (call frame), variabel lokal, dan alamat kembali. Di Linux, ukuran stack default 8 MB memori virtual (meski halaman fisik hanya dialokasikan sesuai permintaan via page fault).
- Instruction pointer (IP): melacak persis di mana thread berada di kodenya, supaya kernel bisa melanjutkannya setelah context switch.
- Struktur data kernel: metadata untuk scheduler: state thread (running / sleeping / blocked), prioritas, CPU affinity, signal mask, dll.
Scheduling preemptive
Scheduler OS memutuskan thread mana yang mendapat waktu CPU dan berapa lama. Ia menetapkan tiap thread time slice (biasanya beberapa milidetik). Setelah slice itu kedaluwarsa, scheduler secara paksa menjeda thread, entah selesai atau tidak, menyimpan state-nya, dan beralih ke thread lain. Ini disebut preemptive scheduling karena thread di-preempt (diinterupsi) tanpa kerja sama mereka.
Memori bersama antar thread
Sifat penting: thread dalam proses yang sama berbagi ruang memori yang sama (heap, variabel global, file descriptor). Jika thread 1 mengalokasikan objek di heap, thread 2 bisa mengaksesnya via pointer. Ini membuat komunikasi antar-thread cepat (tanpa salinan, tanpa serialisasi), tapi juga berbahaya, seperti yang akan kita lihat di bagian race condition.
Thread lintas proses berbeda, bagaimanapun, terisolasi penuh, mereka tidak bisa melihat memori satu sama lain. Isolasi ini ditegakkan sistem memori virtual kernel demi keamanan dan stabilitas.
Thread-per-request di Python
# Python, threading model (simplified)
import threading
def handle_request(conn):
# 1. Parse the HTTP request (CPU work)
request = parse_http(conn)
# 2. Database query, this BLOCKS the thread
# The OS scheduler will switch to another thread
user = db.execute(
"SELECT * FROM users WHERE id = %s",
(request.user_id,)
)
# 3. Thread resumes here after DB responds
response = json.dumps(user)
conn.send(response)
# Create a new OS thread for each incoming connection
while True:
conn = server_socket.accept()
t = threading.Thread(target=handle_request, args=(conn,))
t.start()Python
Perhatikan tidak ada async atau await di mana pun. Blocking terjadi transparan, di level OS, saat db.execute() membuat panggilan jaringan. Thread ditandai "blocked", OS beralih ke thread lain, dan belakangan membangunkan thread ini saat respons database tiba di socket.
Overhead Thread & Context Switching
Thread kuat, tapi mereka datang dengan tiga kategori overhead yang membatasi berapa banyak yang praktis bisa Anda jalankan.
1. Overhead memori
Tiap thread mendapat stack sendiri. Di Linux default 8 MB ruang alamat virtual (meski hanya halaman yang disentuh yang mengonsumsi RAM fisik). Bahkan dengan konservatif 500 KB-1 MB memori fisik aktual per thread, 10.000 koneksi serentak akan mengonsumsi 5-10 GB RAM hanya untuk stack thread, sebelum aplikasi Anda bahkan mengalokasikan satu struktur data pun.
2. Overhead pembuatan
Membuat thread butuh system call ke kernel. Kernel harus mengalokasikan stack, menyiapkan struktur data internal (deskriptor thread, state sinyal, metadata scheduler), dan mendaftarkan thread ke scheduler. Ini makan dari beberapa mikrodetik sampai beberapa milidetik: cepat menurut standar manusia, tapi signifikan di skala saat Anda membuat dan menghancurkan ribuan thread.
3. Overhead context switch
Ini yang besar. Setiap kali scheduler beralih dari satu thread ke yang lain, ia harus:
- Simpan register CPU thread saat ini (serbaguna, floating-point, SIMD)
- Perbarui struktur pembukuan (state thread, pencatatan waktu)
- Pilih thread berikutnya untuk dijalankan (melibatkan algoritma scheduler)
- Pulihkan register tersimpan thread baru
- Berpotensi membuang cache CPU (baris cache L1/L2 jadi "dingin")
Setiap context switch makan 1-10 mikrodetik di perangkat keras modern. Dengan 4 core dan 1.000 thread, scheduler harus terus menerus mengatur di antara mereka, dan overhead ini bisa menjadi sumber latensi yang terukur. Waktu context switch murni tidak produktif, ia kerja pemeliharaan, bukan komputasi nyata.
Persis inilah kenapa model "satu thread per request" hancur di bawah konkurensi tinggi. 10.000 koneksi serentak = 10.000 thread = gigabyte memori stack + overhead context switching konstan. Masalah C10k adalah realisasi industri bahwa model ini tidak berskala.
Model Event Loop
Event loop mengambil pendekatan radikal berbeda untuk konkurensi: alih-alih banyak thread, ia memakai satu thread dan loop yang terus memeriksa operasi IO yang selesai, lalu menjalankan callback yang sesuai. Ini model yang menggerakkan Node.js, asyncio Python, Nginx, dan banyak sistem berperforma tinggi lain.
Mekanisme inti
Saat tugas (seperti melayani Request A) perlu membuat query database, alih-alih memblokir dan menunggu, ia:
- Memulai IO: mengirim byte query lewat socket TCP ke database.
- Mendaftarkan callback: memberi tahu event loop: "Saat database merespons di socket ini, jalankan fungsi ini."
- Melepaskan kendali: kembali seketika, membebaskan thread tunggal untuk mengambil tugas berikutnya.
Event loop itu sendiri pada dasarnya loop tak hingga. Di tiap iterasi, ia memakai mekanisme level OS untuk memeriksa socket mana yang datanya siap, lalu menjalankan callback untuk operasi selesai itu dan kembali memeriksa.
Multiplexing IO level OS
Event loop tidak mengimplementasikan pemantauan IO sendiri, ia mengandalkan sistem operasi. OS menyediakan system call khusus yang bisa memantau secara efisien ribuan file descriptor (socket, file, pipe) sekaligus:
| OS | Mekanisme | Catatan |
|---|---|---|
| Linux | epoll |
O(1) per peristiwa siap. Berskala ke jutaan koneksi. Lihat epoll(7) |
| macOS / BSD | kqueue |
Mirip epoll tapi API berbeda. Lihat kqueue(2) |
| Windows | IOCP |
IO Completion Ports, padanan Windows |
Di balik layar, Node.js memakai libuv yang mengabstraksikan mekanisme spesifik platform ini. asyncio Python memakai modul selectors yang membungkus epoll/kqueue.
Kenapa event loop efisien untuk IO
Karena hanya ada satu thread, tanpa overhead context switching, tanpa alokasi stack multi-megabyte, dan tanpa juggling scheduler. CPU menghabiskan waktunya entah menjalankan kode aktual Anda (callback) atau menunggu efisien di dalam epoll_wait(). Inilah kenapa Node.js bisa menangani puluhan ribu koneksi serentak di perangkat keras sederhana.
Kompromi kritis: jangan pernah memblokir loop
Jika Anda menjalankan operasi intensif CPU, katakanlah tugas pemrosesan gambar 100 ms, langsung di thread event loop, semuanya berhenti. Tidak ada request lain yang bisa dilayani, tidak ada callback yang bisa menyala, tidak ada penyelesaian IO yang bisa diproses. Seluruh server membeku sampai kerja CPU itu selesai. Inilah kenapa bahasa berbasis event loop seperti JavaScript menuntut kode non-blocking dan menggeser komputasi berat ke worker thread atau proses terpisah.
Callback -> Promise -> async/await: evolusinya
Event loop bekerja pada callback, tapi callback mentah mengarah ke kode bersarang dalam ("callback hell" yang terkenal). Evolusinya di JavaScript:
// Stage 1: Raw callbacks (pre-ES6)
db.query("SELECT * FROM users WHERE id = ?", [userId],
function(err, result) {
if (err) return sendError(res, err);
// Nested callback for second query
db.query("SELECT * FROM orders WHERE user_id = ?", [userId],
function(err, orders) {
if (err) return sendError(res, err);
// Another level of nesting...
sendResponse(res, { user: result, orders: orders });
}
);
}
);JavaScript / callback hell
// Stage 2: async/await (modern, syntactic sugar over callbacks)
async function handleRequest(req, res) {
const user = await db.query("SELECT * FROM users WHERE id = ?", [req.userId]);
const orders = await db.query("SELECT * FROM orders WHERE user_id = ?", [req.userId]);
sendResponse(res, { user, orders });
}JavaScript / async/await
Kata kunci await gula sintaksis. Saat runtime menjumpai await, ia berkata: "Aku melepaskan kendali CPU ke event loop. Semua setelah baris ini efektifnya callback yang akan berjalan saat operasi IO ini selesai."
asyncio Python: gagasan yang sama
import asyncio
import aiohttp
async def handle_request(user_id: int) -> dict:
# await = "yield control to the event loop until this IO completes"
user = await db.fetchone(
"SELECT * FROM users WHERE id = $1", user_id
)
orders = await db.fetch(
"SELECT * FROM orders WHERE user_id = $1", user_id
)
return {"user": user, "orders": orders}
# Run with an event loop (uvloop for performance)
asyncio.run(handle_request(123))Python
Async/Await sebagai State Machine
Untuk benar-benar memahami async/await, membantu melihat apa yang sebenarnya kompiler atau runtime ubah dari kode Anda. Fungsi async pada dasarnya dikonversi menjadi state machine yang berinteraksi dengan event loop. Setiap await titik transisi antar state.
Fungsi async aslinya
async def fetch_user_data(user_id):
user = await db.get_user(user_id) # State 0 -> 1
orders = await db.get_orders(user_id) # State 1 -> 2
return {"user": user, "orders": orders} # State 2 -> donePython
Apa yang dilihat runtime (konseptual)
# Conceptual state machine representation
def fetch_user_data_machine(user_id):
state = 0
user = None
orders = None
def step():
nonlocal state, user, orders
if state == 0:
state = 1
# Start the IO, register callback for when it completes
return db.get_user(user_id).then(
lambda result: (user := result, step())
)
elif state == 1:
state = 2
return db.get_orders(user_id).then(
lambda result: (orders := result, step())
)
elif state == 2:
return {"user": user, "orders": orders}
return stepPython / conceptual
await is a state transition; the event loop runs other work in betweenDua hal yang dijelaskan ini
1. Kenapa await hanya bisa muncul di dalam fungsi async. Fungsi harus ditandai async karena ia perlu ditransformasi menjadi state machine. Fungsi biasa tidak punya infrastruktur state machine untuk mendukung jeda dan lanjut.
2. Kenapa memblokir event loop katastrofal. Jika Anda melakukan operasi intensif CPU di dalam state (mis. komputasi 500 ms), state machine tidak pernah bisa bertransisi. Tidak ada state machine lain (request lain) yang bisa maju. Seluruh server menggantung.
Goroutine Go & Virtual Thread
Go mengambil pendekatan ketiga yang bukan thread OS mentah maupun event loop satu thread. Ia memakai goroutine: thread ruang-pengguna yang ringan, dikelola scheduler runtime Go sendiri. Istilah "virtual thread" menangkap gagasannya: mereka berperilaku seperti thread (kode blocking, logika sekuensial), tapi jauh lebih murah karena bukan konstruk level OS.
Kenapa goroutine murah
| Sifat | Thread OS | Goroutine |
|---|---|---|
| Ukuran stack | ±8 MB (virtual), ±KB fisik | ±2-8 KB awal (tumbuh dinamis) |
| Waktu pembuatan | ±us sampai ms (system call) | ±ns sampai us (alokasi ruang-pengguna) |
| Context switch | 1-10 us (mode kernel) | ±200 ns (tukar pointer ruang-pengguna) |
| Batas praktis | ±10.000 (bottleneck memori) | ±1.000.000+ (tergantung RAM) |
| Dijadwalkan oleh | Kernel OS | Scheduler runtime Go |
Server HTTP Go: satu goroutine per request
Library standar Go net/http membuat goroutine baru untuk setiap request masuk. Ini terdengar seperti model thread-per-request yang barusan kita bilang tidak berskala, tapi goroutine begitu ringan sehingga bekerja dengan indah. Ini pola source code aktual dari paket net/http Go:
// From Go's standard library net/http/server.go (simplified)
func (srv *Server) Serve(l net.Listener) error {
for {
conn, err := l.Accept() // Accept new TCP connection
if err != nil {
return err
}
// Create a NEW goroutine for each connection
go srv.handleConn(conn)
}
}Go
Kata kunci go men-spawn goroutine baru. Ini padanan membuat thread, tapi di sebagian kecil biayanya (nanodetik vs. mikrodetik, kilobyte vs. megabyte).
Handler Go khas
func handleGetUser(w http.ResponseWriter, r *http.Request) {
userID := r.URL.Query().Get("id")
// This BLOCKS the goroutine, but not the OS thread.
// The Go scheduler parks this goroutine and runs another.
user, err := db.QueryRow(
"SELECT name, email FROM users WHERE id = $1",
userID,
)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
// Goroutine resumes here after DB responds
json.NewEncoder(w).Encode(user)
}Go
Perhatikan: tanpa async, tanpa await, tanpa callback. Kodenya terbaca seperti kode sinkron, blocking, karena goroutine memang memblokir. Tapi scheduler runtime Go secara transparan memarkirnya dan mengambil goroutine lain. Anda mendapat ergonomi kode blocking dengan efisiensi IO non-blocking.
Scheduler M:N
Scheduler Go memakai model M:N: ia memultipleks M goroutine ke N thread OS. Jumlah thread OS dikendalikan environment variable GOMAXPROCS (default jumlah core CPU). Tiap thread OS menjalankan antrean lokal goroutine.
Saat goroutine memblokir pada IO (mis. baca database), network poller internal runtime Go (dibangun di atas epoll/kqueue di balik layar) mendeteksi penyelesaian IO dan menaruh goroutine kembali ke antrean run. Ini yang terbaik dari kedua dunia: kode gaya-blocking dengan efisiensi level event loop.
Race Condition & State Bersama
Konkurensi memperkenalkan seluruh kategori bug yang tidak ada di kode sekuensial. Hampir semuanya menelusuri kembali ke satu akar: state mutable bersama. Saat dua tugas konkuren membaca dan menulis variabel yang sama, dan operasinya saling menyisip dengan cara tak terduga, Anda mendapat race condition.
Contoh counter klasik
Dua thread sama-sama mencoba menaikkan counter bersama dari 0 ke 1. Menaikkan butuh tiga langkah CPU: baca nilai saat ini, tambah satu, tulis kembali. Jika thread saling menyisip:
Race condition di async/await juga
Anda mungkin berpikir: "Aku memakai JavaScript / Python dengan async/await, hanya ada satu thread, jadi tidak ada race condition." Salah. Race condition masih bisa terjadi di antara titik await. Anggap contoh Python ini:
balance = 100
async def withdraw(amount: int):
global balance
if balance >= amount: # Check at time T1
await process_withdrawal(amount) # <- yields control here!
balance -= amount # Deduct at time T2
# Both coroutines see balance=100, both pass the check,
# both deduct 100 -> balance = -100 (invalid!)
await asyncio.gather(
withdraw(100),
withdraw(100),
)Python
Masalahnya: pemeriksaan if dan pengurangan dipisahkan oleh await. Di antara dua titik itu, event loop menjalankan panggilan withdraw kedua, yang juga melihat balance == 100 dan lolos pemeriksaan. Keduanya mengurangi, dan saldo jadi -100.
Race condition terjadi kapan pun ada pola check-then-act yang dipisahkan titik yield (await) atau context switch. Kode async satu thread tidak kebal terhadap race condition.
Lock, Mutex & Channel
Solusi untuk race condition telah dipelajari puluhan tahun. Ini pendekatan utamanya.
Mutex (Mutual Exclusion)
Mutex memastikan hanya satu thread/goroutine yang bisa mengeksekusi critical section pada satu waktu. Semua yang lain harus menunggu sampai lock dilepaskan. Ini primitif sinkronisasi paling umum di semua bahasa.
Python: threading.Lock
import threading
counter = 0
lock = threading.Lock()
def increment():
global counter
with lock: # Acquire lock, blocks other threads
counter += 1 # Only one thread executes this at a time
# Lock is released automatically at end of `with`
# Create 1000 threads all incrementing the same counter
threads = [threading.Thread(target=increment) for _ in range(1000)]
for t in threads: t.start()
for t in threads: t.join()
print(counter) # Always 1000, no lost updatesPython
Go: sync.Mutex
package main
import (
"fmt"
"sync"
)
func main() {
var counter int
var mu sync.Mutex
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock() // Acquire, other goroutines wait here
counter++
mu.Unlock() // Release, next goroutine can proceed
}()
}
wg.Wait()
fmt.Println(counter) // Always 1000
}Go
Channel: pendekatan pilihan Go
Go punya peribahasa terkenal: "Don't communicate by sharing memory; share memory by communicating." Alih-alih banyak goroutine menulis ke variabel bersama (yang butuh mutex), Anda bisa memakai channel: pipa bertipe lewat mana goroutine mengirim dan menerima nilai. Hanya goroutine yang memiliki data yang memodifikasinya; yang lain mengirim pesan untuk meminta perubahan.
package main
import "fmt"
func counterService(ch chan int, done chan bool) {
counter := 0
for {
select {
case delta := <-ch:
counter += delta // Only this goroutine modifies counter
case done <- counter:
return
}
}
}
func main() {
ch := make(chan int, 100) // Buffered channel
done := make(chan bool)
go counterService(ch, done)
// 1000 goroutines send +1 through the channel
for i := 0; i < 1000; i++ {
ch <- 1
}
result := <-done
fmt.Println(result) // 1000, no race condition, no mutex needed
}Go
Python asyncio.Lock untuk kode async
import asyncio
balance = 100
lock = asyncio.Lock()
async def withdraw(amount: int):
global balance
async with lock: # Only one coroutine at a time
if balance >= amount:
await process_withdrawal(amount)
balance -= amount
# Now safe, second withdraw waits for the lock
await asyncio.gather(
withdraw(100),
withdraw(100),
)
print(balance) # 0, not -100Python
Memilih Model yang Tepat
Ini ringkasan praktisnya, kapan memakai apa, dan kenapa.
| Beban kerja | Model Terbaik | Kenapa | Contoh |
|---|---|---|---|
| IO-Bound, konkurensi tinggi | Event loop / async-await / goroutine | Overhead minimal, tanpa context switching, menangani 100K+ koneksi | Web server, API gateway, proxy microservice, server chat |
| CPU-Bound, bisa diparalelkan | Thread OS dengan paralelisme (banyak core) | Eksekusi serentak sejati; lebih banyak core = proporsional lebih cepat | Pemrosesan gambar, encoding video, inferensi ML, enkripsi |
| Campuran IO + CPU | Goroutine (Go) atau worker pool + async (Python/JS) | IO ditangani konkuren; kerja CPU digeser ke pool thread/proses | Kebanyakan backend dunia nyata, panggilan API + sedikit komputasi |
Konkurensi menjaga program Anda produktif, ia menghentikan CPU menganggur sambil menunggu IO. Pakai async/await, goroutine, atau virtual thread untuk ini.
Paralelisme membuat Anda memakai banyak core CPU untuk melakukan banyak komputasi di instan yang sama. Pakai thread OS atau process pool untuk ini.
Kebanyakan aplikasi backend sangat IO-bound: bottleneck-nya hampir selalu jaringan, bukan CPU. Tapi saat Anda memang memukul kerja CPU-bound, paralelisme tool yang membuka lebih banyak throughput.
Primitif konkurensi spesifik bahasa
| Bahasa | Konkurensi IO | Paralelisme CPU | Mekanisme Dasar |
|---|---|---|---|
| Go | go func() (goroutine) |
Goroutine lintas core (otomatis) | Scheduler M:N + epoll/kqueue |
| Python | asyncio + async/await |
multiprocessing / concurrent.futures |
Event loop + process pool (GIL membatasi thread) |
| JavaScript | async/await / Promise |
worker_threads / cluster |
Event loop libuv |
| Java (21+) | Virtual Thread (Thread.ofVirtual()) |
Platform Thread + ForkJoinPool | Scheduler M:N berbasis continuation |
| Rust | async/await + runtime Tokio |
std::thread / Rayon |
Runtime async multi-threaded work-stealing |
Bacaan Lanjutan
Buku
- Operating Systems: Three Easy Pieces oleh Remzi & Andrea Arpaci-Dusseau, standar emas untuk memahami thread, scheduling, dan konkurensi di level OS. Gratis online.
- Concurrency in Go oleh Katherine Cox-Buday, selami dalam goroutine, channel, pola, dan jebakan.
- Fluent Python, Bab 19-21 oleh Luciano Ramalho, konkurensi dengan thread, proses, dan asyncio.