Mengelola concurrency di Haskell: membuat thread ringan dengan forkIO, sinkronisasi sederhana dengan MVar, shared state yang aman dengan Software Transactional Memory lewat TVar dan atomically, komposisi tugas paralel dengan package async, serta perbedaan lazy dan strict pada concurrent pipeline.

Setelah di episode 11 kalian menguasai monad IO, Maybe, Either, dan State, sekarang kita masuk ke topik yang membuat banyak bahasa "stress": concurrency. Kabar baiknya, dasar-dasar purity yang kalian pelajari di episode 7 menjadi keuntungan besar di sini — tanpa shared mutable state di kode pure, sebagian besar masalah concurrency klasik (race condition, deadlock) tidak akan pernah muncul sejak awal.
Mengapa bab ini penting? Karena server Haskell di produksi — dari API servant sampai worker pemrosesan data — mengandalkan thread ringan (green thread) dan shared state yang aman. Episode ini memberi kalian dua alat: MVar untuk sinkronisasi sederhana dan STM (Software Transactional Memory) untuk shared state yang aman dan komposisional.
Haskell threads adalah user-space threads yang sangat murah — jutaan thread bisa hidup dalam satu proses. Membuat thread semudah:
import Control.Concurrent (forkIO, threadDelay)
main :: IO ()
main = do
forkIO $ do
threadDelay 1000000
putStrLn "dari thread"
putStrLn "dari main"
threadDelay 2000000forkIO menjalankan aksi IO di thread baru, lalu main berlanjut. Output di atas bisa muncul dalam urutan apa pun (biasanya "dari main" dulu, lalu "dari thread") — inilah non-determinisme interleaving yang harus dikelola dengan sinkronisasi.
Note
Perhatikan bahwa urutan pencetakan tidak dijamin. Di kode pure, determinisme dijamin; begitu thread masuk, urutan eksekusi ditentukan scheduler. Prinsip yang harus dipegang: isolasi logika pure, sinkronisasi hanya di tepian IO.
MVar adalah kotak yang menyimpan satu nilai, dan kotak ini mengunci saat penuh atau kosong:
takeMVar m mengosongkan kotak dan mengembalikan nilainya — memblokir jika kotak kosong.putMVar m v mengisi kotak — memblokir jika kotak penuh.import Control.Concurrent (MVar, newEmptyMVar, takeMVar, putMVar)
main :: IO ()
main = do
m <- newEmptyMVar
forkIO $ putMVar m "hasil dari worker"
hasil <- takeMVar m -- menunggu worker selesai
putStrLn hasiltakeMVar di main memblokir sampai worker mengisi kotak — ini pola wait/notify yang sederhana. MVar juga sering dipakai sebagai mutex: hanya satu thread yang bisa takeMVar pada satu waktu, sehingga kode di antara take dan put aman dari interleaving.
Untuk shared state yang lebih kompleks, MVar mulai terasa kaku — lock manual mudah salah, dan komposisi antar lock memicu deadlock. STM (Software Transactional Memory) memberikan model yang jauh lebih baik: transaksi atomik.
Bayangkan database: transaksi dijalankan, dan jika ada konflik, di-rollback dan dicoba lagi. STM melakukan hal yang sama untuk memori:
import Control.Concurrent.STM
import Control.Concurrent (forkIO)
import Control.Monad (replicateM_)
main :: IO ()
main = do
counter <- newTVarIO 0
replicateM_ 100 $ forkIO $
atomically $ modifyTVar' counter (+1)
threadDelay 1000000
final <- atomically (readTVar counter)
putStrLn ("counter akhir: " ++ show final)Lima thread menambah counter secara transaksional. Karena tiap atomically adalah transaksi penuh, hasil akhirnya dijamin 100 — tidak ada counter yang "tertimpa" karena interleaving. Kunci penting: kode di dalam atomically hanya boleh memakai variabel STM (TVar, TMVar, TChan) — kalian tidak bisa memanggil I/O sembarangan di dalam transaksi.
Kekuatan STM yang tidak dimiliki lock manual: menunggu kondisi dengan retry, dan menggabungkan kondisi dengan orElse:
transfer :: TVar Int -> TVar Int -> Int -> STM ()
transfer from to amt = do
balance <- readTVar from
if balance < amt
then retry -- tunggu sampai saldo cukup
else writeTVar from (balance - amt)
>> modifyTVar' to (+amt)
main :: IO ()
main = do
a <- newTVarIO 100
b <- newTVarIO 0
atomically (transfer a b 50) -- langsung sukses
-- atomically (transfer a b 999) -- retry sampai saldo terpenuhi
putStrLn "transfer selesai"Saat saldo tidak cukup, retry membatalkan transaksi dan memblokir sampai salah satu TVar yang dibaca berubah — lalu mencoba lagi. Tidak ada busy-wait, tidak ada polling manual. Kombinasi retry + orElse ini adalah salah satu alasan utama STM dipakai untuk antrian dan workflow state di sistem Haskell.
Package async membungkus forkIO dengan ergonomi yang jauh lebih baik — menangkap exception dan mengembalikan hasil:
import Control.Concurrent.Async (async, concurrently)
fetch :: String -> IO Int
fetch url = pure (length url) -- contoh: pekerjaan I/O
main :: IO ()
main = do
(a, b) <- concurrently (fetch "service-a") (fetch "service-b")
print (a + b)concurrently menjalankan dua aksi sekaligus dan menunggu keduanya. Untuk daftar tugas:
import Control.Concurrent.Async (mapConcurrently)
main :: IO ()
main = do
hasil <- mapConcurrently fetch ["a", "b", "c"]
print hasilmapConcurrently mem-parallel-kan fetch untuk seluruh list input dan mengumpulkan hasilnya dalam urutan yang sama — pola ini dipakai luas untuk pipeline request paralel di backend.
Tip
Aturan memilih sinkronisasi: MVar untuk sinyal one-shot dan mutex sederhana; STM untuk shared state kompleks yang perlu komposisi kondisi; async untuk menjalankan tugas independen secara paralel dan menunggu hasilnya. Jangan memulai dengan STM untuk hal-hal sepele — mulailah dari yang paling sederhana.
Kehati-hatian: data lazy yang dibagikan antar thread bisa menyebabkan space leak karena thunk yang dievaluasi di thread yang salah. Contoh klasik adalah memparalelkan evaluasi list tak terduga.
Praktik yang disarankan:
Data.Map.Strict, Data.Text dibanding String) untuk data yang dibagikan antar thread.Control.Parallel.Strategies (paket parallel) mengevaluasi struktur data secara eagerly di thread pool:import Control.Parallel.Strategies
import Control.Parallel (par, pseq)
parMax :: [Int] -> Int
parMax (x:xs) = let m = parMax xs in m `par` (x `max` m) `pseq` (x `max` m)
parMax [] = error "list kosong"par menjadwalkan evaluasi di thread lain, pseq memastikan urutan evaluasi. Ini hanya permukaan — episode 21 akan membahas strategi paralel dan profiler untuk melihat hasilnya.
modifyTVar (lazy) vs modifyTVar' (strict) — gunakan versi strict (') untuk counter/akumulator agar thunk tidak menumpuk di dalam transaksi.atomically — tidak diperbolehkan; transaksi harus pure (hanya STM).MVar yang tidak pernah dikosongkan — takeMVar selamanya memblokir; pastikan setiap put punya take yang pasti jalan.IORef tanpa atomicModifyIORef — kalau memakai IORef, gunakan versi atomik atau beralih ke STM.mapConcurrently dengan chunk atau thread pool.Inti yang harus dibawa pulang:
forkIO membuat thread ringan; urutan eksekusi antar thread tidak dijamin.MVar adalah kotak penyimpanan yang memblokir saat penuh/kosong — cocok untuk sinyal dan mutex.atomically, TVar, retry, orElse — aman dan komposisional.async (concurrently, mapConcurrently) mem-parallel-kan tugas dengan ergonomi tinggi.Di episode 13 selanjutnya kita akan membahas backend services: Servant / Scotty — mendefinisikan API di level tipe dengan Servant, memulai cepat dengan Scotty yang ringan, serialisasi JSON dengan Aeson, dan menaruh seluruhnya di belakang pola pure core, IO boundary. Sampai jumpa di episode 13!