Road to Haskeller #23 - Concurrency

Last Edited: 8/21/2024

The blog post introduces how we can achieve concurrency in Haskell.

Haskell & Concurrency

You might notice that it has been almost 3 weeks since I published an article on Haskell. The reason for that gap is not because I lost interest in Haskell. It is becuase I wanted to cover the basics concurrency and parallelism in C so that I can focus on their implementations in Haskell. Hence, if you want to learn the concept more conceptually, I would recommend checking the article, "Road to C Programmer #13 - Multithreading".

Threads

To achieve concurrency or parallelism, we need to implement multithreading. To create a new thread in Haskell, you can use the following:

main.hs
import Control.Concurrent
 
-- Create new thread
-- forkIO :: IO() -> IO ThreadId
 
f :: Int -> Int -> IO ()
f a b = do
  let x = a + b
  putStrLn $! show x
 
main :: IO ()
main = do
  ...
  forkIO $ f 1 2
  ...

By using forkIO function from Control.Concurrent module, you can create a new thread. The forkIO function can take an IO action as an input and return an IO action with ThreadId, which can be used to identify the thread. The above uses $! in f function as it might return a thunk and fail to utilize processors without it due to default lazy evaluation in Haskell.

Also, Haskell is not multithreaded and only utilizes single processor by default, so you will need to compile the code with -threaded tag like ghc -threaded main.hs to enable multithreading, and you will need to execute the executable with -N like ./main +RTS -N to access multiple processors in your machine.

Mutex Variables

To have an access to the result of a function in another thread, MVar. It works exactly like a mutex lock in C, but with storage capability. A value can be put to and taken from a mutex variable only one at a time.

import Control.Concurrent
 
f :: Int -> Int -> MVar Int -> IO ()
f a b mVar = do
  putMVar mVar $! (a + b) -- put value in MVar
 
main :: IO ()
main = do
  mVar <- newEmptyMVar -- initialize MVar
  forkIO $ f 1 2 mVar
  result <- takeMVar mVar -- take value of MVar
  putStrLn $ show result

The takeMVar function can be triggered only after a value is put in the mVar with the putMVar function.

Channels

Channels, Chan, is another way of sharing the results of functions in other threads. Unlike MVar, threads can put as many values as possible in a channel, but taking them requires that there is a value stored in the channel. The value is taken from the values that are put first, making it a FIFO (first in first out) queue.

import Control.Concurrent
 
f :: Int -> Int -> Chan Int -> IO ()
f a b chan = do
  writeChan chan $! (a + b) -- write value in Chan
 
main :: IO ()
main = do
  chan <- newChan -- initialize Chan
  forkIO $ f 1 2 chan
  forkIO $ f 3 4 chan
  result1 <- readChan chan -- read value in Chan
  result2 <- readChan chan -- read value in Chan
  putStrLn $ show result1 -- => (3 or 7)
  putStrLn $ show result2 -- => (3 or 7)

When the order of reading the result do not matter, it might be more effective to use channels as it can easily facilitate parallel execution. The readChan will only read when there is a value already stored in a channel using writeChan.

MVar & Chan

Let's see them more in action. The below code tries to create 10 new threads that prints out their thead ids.

getGreeting :: IO String
getGreeting = do
  tid <- myThreadId -- thread id can be retrieved like this
  let greeting = "Hello from" ++ show tid
  return $! greeting
 
hello :: IO ()
hello = do
  greeting <- getGreeting
  putStrLn greeting
 
main :: IO()
main = do
  let n = 10
  mapM_ (const $ forkIO $ hello) [1..n]
  return ()

However, the above has a critical flaw that it sometimes only prints some ids but not all of them. It is because return () does not wait until all the threads finish their execution and terminate the entire program. To make sure that we can wait for all of the threads to finish their execution, we can create a channel that stores the empty tuples and read the channel ten times before running return ().

getGreeting :: IO String
getGreeting = do
  tid <- myThreadId
  let greeting = "Hello from" ++ show tid
  return $! greeting
 
hello :: Chan () -> IO ()
hello endFlags = do
  greeting <- getGreeting
  putStrLn greeting
  writeChan endFlags ()
 
main :: IO()
main = do
  endFlags <- newChan
  let n = 10
  mapM_ (const $ forkIO $ hello endFlags) [1..n]
  mapM_ (const $ readChan endFlags) [1..n]

The above code waits until readChan reads 10 empty tuples from endFlags channel before exiting the program. Hence, you can see all the threads executing every time you run the above. However, the above is executing fine because Haskell protects standard output with a buffer by default.

When you disable the buffer by importing import System.IO and putting hSetBuffering stdout NoBuffering inside of the main IO action, you would see that the characters are put immediately during the executions of putStrLn in multiple threads in parallel, which results in standard output looking like:

HeHHHHHHHHHleeeeeeeeellllllllllolllllllll ooooooooof         rffffffffforrrrrrrrrmooooooooommTmmmmmmmTThTTTTTTThhrhhhhhhhrrerrrrrrreeaeeeeeeeaadaaaaaaaddIdddddddIIdIIIIIIIdd ddddddd   1      1115111122296
478310

To fix this problem, we can use MVar and lock the putStrLn operation exactly like a mutex lock.

getGreeting :: IO String
getGreeting = do
  tid <- myThreadId
  let greeting = "Hello from" ++ show tid
  return $! greeting
 
hello :: MVar () -> Chan () -> IO ()
hello mutexLock endFlags = do
  greeting <- getGreeting
  takeMVar mutexLock
  putStrLn greeting
  putMVar mutexLock ()
  writeChan endFlags ()
 
main :: IO()
main = do
  hSetBuffering stdout NoBuffering
  mutexLock <- newEmptyMVar
  endFlags <- newChan
  let n = 10
  mapM_ (const $ forkIO $ hello mutexLock endFlags) [1..n]
  putMVar mutexLock ()
  mapM_ (const $ readChan endFlags) [1..n]

Now, when you run the above, you should see a sane output like the below:

Hello fromThreadId 14
Hello fromThreadId 13
Hello fromThreadId 16
Hello fromThreadId 12
Hello fromThreadId 15
Hello fromThreadId 18
Hello fromThreadId 19
Hello fromThreadId 21
Hello fromThreadId 20
Hello fromThreadId 17

You need to be careful where you create a lock, as you might not be able to take advantage of parallelism.

Exercises

This is an exercise section where you can test your understanding of the material introduced in the article. I highly recommend solving these questions by yourself after reading the main part of the article. You can click on each question to see its answer.

Resources