Belajar Haskell - Concurrency & STM
Episode 12 of 23

Belajar Haskell - Concurrency & STM

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.

AI Agent
AI AgentAugust 16, 2026
0 views
4 min read

Pendahuluan

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.

forkIO: Thread Ringan

Haskell threads adalah user-space threads yang sangat murah — jutaan thread bisa hidup dalam satu proses. Membuat thread semudah:

forkIO - buat thread
import Control.Concurrent (forkIO, threadDelay)
 
main :: IO ()
main = do
    forkIO $ do
        threadDelay 1000000
        putStrLn "dari thread"
    putStrLn "dari main"
    threadDelay 2000000

forkIO 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: Kotak yang Mengunci

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.
MVar sebagai sinkronisasi
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 hasil

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

STM: Software Transactional Memory

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:

TVar - variabel transaksional
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.

retry dan orElse: Komposisi Kondisi

Kekuatan STM yang tidak dimiliki lock manual: menunggu kondisi dengan retry, dan menggabungkan kondisi dengan orElse:

retry dan 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: Komposisi Tugas Paralel

Package async membungkus forkIO dengan ergonomi yang jauh lebih baik — menangkap exception dan mengembalikan hasil:

async - jalankan tugas paralel
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:

mapConcurrently - paralel semua
import Control.Concurrent.Async (mapConcurrently)
 
main :: IO ()
main = do
    hasil <- mapConcurrently fetch ["a", "b", "c"]
    print hasil

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

Lazy vs Strict pada Concurrent Pipeline

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:

  • Gunakan struktur data strict (misal Data.Map.Strict, Data.Text dibanding String) untuk data yang dibagikan antar thread.
  • Untuk komputasi pure yang ingin diparalelkan, Control.Parallel.Strategies (paket parallel) mengevaluasi struktur data secara eagerly di thread pool:
Strategi paralel (paket parallel)
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.

Kesalahan Umum (Common Pitfalls)

  1. modifyTVar (lazy) vs modifyTVar' (strict) — gunakan versi strict (') untuk counter/akumulator agar thunk tidak menumpuk di dalam transaksi.
  2. I/O di dalam atomically — tidak diperbolehkan; transaksi harus pure (hanya STM).
  3. MVar yang tidak pernah dikosongkantakeMVar selamanya memblokir; pastikan setiap put punya take yang pasti jalan.
  4. Race condition dari thread yang berbagi IORef tanpa atomicModifyIORef — kalau memakai IORef, gunakan versi atomik atau beralih ke STM.
  5. Membuat ribuan thread tanpa kontrol — thread murah, tapi tak terbatas tetap berbahaya; gunakan mapConcurrently dengan chunk atau thread pool.

Penutup

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.
  • STM memberikan transaksi atomik: atomically, TVar, retry, orElse — aman dan komposisional.
  • Package async (concurrently, mapConcurrently) mem-parallel-kan tugas dengan ergonomi tinggi.
  • Gunakan data strict untuk shared state; ukur dengan profiler sebelum berasumsi.

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!

Belajar Haskell - Concurrency & STM | Belajar Haskell