We talk about different concurrency models and although there are several, two of them standout for being easy to reason about and relatively bug free. Those are CSP(Communicating Sequential Processes) and Actor Model. In this article, we will talk about CSP and how it handles concurrency.
Thread-based Concurrency
Thread-based concurrency is the model that all programming languages usually implement. At its core it uses threads which are similar to OS level processes but share the memory, file and network resources of the parent process. Operating system schedules the threads and handles the multiplexing between the different threads. In this model, programming languages usually do not handle the scheduling of the threads.
How does it work?
Thread model. Made with excalidraw.
The thread is a stripped down process which shares the memory space of the parent process. It still shares the memory from the parent process and the scheduling is done by OS. Programming languages use the shared memory to signalling and synchronizing the threads. As mutliple threads can access the shared memory without explicit control from the parent process, this is highly error prone and difficult to debug as the race conditions(happens when there are multiple code paths executing at the same time). The main issue here is the shared state and non deterministic nature of thread execution.
import threading
import time
# Shared resource
counter = 0
counter_lock = threading.Lock()
def increment_counter(amount, repeats):
global counter
for _ in range(repeats):
with counter_lock: # Acquire lock before modifying shared state
counter += amount
time.sleep(0.01) # Small delay to simulate work
# Create and start multiple threads
threads = []
for i in range(5):
t = threading.Thread(target=increment_counter, args=(i+1, 10))
threads.append(t)
t.start()
# Wait for all threads to complete
for t in threads:
t.join()
print(f"Final counter value: {counter}")
Notice the use of locks for the correctness. A simple mistake of not wrapping that critical section(the code segment accessed by multiple threads) with a lock can completely mess up the final result and debugging this would be really difficult when you have several of those and don’t know where to look for.
This brings us to the other models of concurrency.
CSP (Communicating Sequential Processes)
“Don’t communicate by sharing memory; share memory by communicating”
Communicating Sequential Processes is a formal language for describing patterns of interaction in concurrent systems.
Core principles of CSP
- Processes as building blocks: Systems should be composed of sequential processes that execute independently and communicate with each other.
- Message passing and synchronization: Processes communicate by passing messages and need to participate at the same time for the message passing to happen, thus leading to synchronization without the use of locks and semaphores.
- Channels: Channels are the message conduits that processes talk through.
The above principles allow us to break down our problems into sequential processes that have their own internal state and pass messages to the other processes through channels synchronizing automatically whenever needed without locks and semaphores.
“I thought of objects being like biological cells and/or individual computers on a network, only able to communicate with messages. - Alan Kay”
This is what we expected Object Oriented Programming to be but that turned out to be a whole other thing.
A good example of how a problem can be split into sequential processes can be found at the below video by Rop Pike.
The lexer and the parser runs in two different sequential processes that communicate with channels. There is nothing that we did to make it parellel but by inherent synchronization and parellel execution of these processes the overall program has become more performant. Doing so in other languages would require much more effort and some locks to achieve this. And the added benefit we got was that the internal state and the internal workings of the processes are completely hidden and irrelevant for the other processes.
Below is the go
implementation for the Python code we showcased above.
package main
import (
"fmt"
"sync"
)
func incrementCounter(amount int, repeats int, results chan < -int) {
localSum: = 0
for i: = 0;i < repeats;i++{
localSum += amount
time.Sleep(10 * time.Millisecond)
}
// Send the result through the channel
results < -localSum
}
func main() {
// Create a channel for results
results: = make(chan int)
var wg sync.WaitGroup
// Launch multiple goroutines
for i: = 0;i < 5;i++{
wg.Add(1)
go func(n int) {
defer wg.Done()
incrementCounter(n + 1, 10, results)
}(i)
}
// Start a goroutine to close the results channel when all work is done
go func() {
wg.Wait()
close(results)
}()
// Collect results as they come in
counter: = 0
for result: = range results {
counter += result
}
fmt.Printf("Final counter value: %d\n", counter)
}
Notice how there are no locks. This is because the goroutines are not trying to update a shared state and are using
message passing to send the final local counter result back to the main process. This takes away the chances of race
conditions and synchronization problems. Not saying we would never have shared state in go
but CSP and Go make it
easier to reduce the amount shared state needed to make the program concurrent and performant. This is even before we
discuss how easy it is to create a concurrent goroutine in Go. I think enough has already been said about that and I
would never forget that. So that doesn’t make the cut for this blog.
We will talk about another pattern of concurrency, Actor Pattern, in a subsequent article which although different gives similar benefits and some really good concurrency primitives.