Introduction to Concurrent Programming with GPUs
Tasks
Assignments & Tests
Concurrent Programming Problems
Dining Philosophers Problem
Premise
- 5 philosophers and 5 forks, 1 between them.
- Each philosopher has a fork on left and right, they want to eat eggs and side dish.
- To eat, each philosopher needs both, i.e. pick up left, pick up right, eat, put down left, put down right.
- If all tried to eat at same time, then there will be resource contention with fork as resource.
- If philosophers do not drop the fork at all, then there is a deadlock.
- If philosophers drop their fork when another picks up a fork it is a livelock.
Solution
import random
import threading
import time
class Philosopher(threading.Thread):
running = True
def __init__(self,
xname: str,
fork_on_left: threading.Lock,
fork_on_right: threading.Lock):
threading.Thread.__init__(self)
self.name: str = xname
self.fork_on_left: threading.Lock = fork_on_left
self.fork_on_right: threading.Lock = fork_on_right
def run(self):
while self.running:
# Philosopher is thinking (but really is sleeping).
time.sleep(random.uniform(3, 13))
print(f'{self.name} is hungry.')
self.dine()
def dine(self):
fork1, fork2 = self.fork_on_left, self.fork_on_right
while self.running:
fork1.acquire(True)
locked = fork2.acquire(False)
if locked:
break
fork1.release()
print(f'{self.name} swaps forks')
fork1, fork2 = fork2, fork1
else:
return
self.dining()
fork2.release()
fork1.release()
def dining(self):
print(f'{self.name} starts eating')
time.sleep(random.uniform(1, 10))
print(f'{self.name} finishes eating and leaves to think.')
def dining_philosophers():
forks = [threading.Lock() for n in range(5)]
philosopher_names = ('Aristotle',
'Kant',
'Buddha',
'Marx',
'Russel')
philosophers = [Philosopher(philosopher_names[i],
forks[i % 5],
forks[(i+1) % 5]) for i in range(5)]
random.seed(507129)
Philosopher.running = True
for p in philosophers:
p.start()
time.sleep(100)
Philosopher.running = False
print("Now we're finishing.")
dining_philosophers()
Explanation
Philosopher(threading.Thread)Class:- Each instance represents a philosopher.
__init__(self, name, fork_on_left, fork_on_right): Initializes with a name and its designated left/rightthreading.Lockobjects (forks).run(self): The main loop for a philosopher.- Simulates thinking (
time.sleep). - Becomes hungry and calls
self.dine().
- Simulates thinking (
dine(self): The critical section for acquiring forks to eat.- Attempts to acquire
fork1(blocking call - waits until available). - Attempts to acquire
fork2(non-blocking call - checks if immediately available). - If
fork2is acquired:- Calls
self.dining()to eat.
- Calls
- If
fork2is NOT acquired (to avoid deadlock):- Releases
fork1. - Swaps the internal order of
fork1andfork2. - Retries acquiring forks.
- Releases
- Finally, releases both forks.
- Attempts to acquire
dining(self): Simulates the eating process with atime.sleep.
dining_philosophers()Function:- Creates 5
threading.Lockobjects (the forks). - Creates 5
Philosopherobjects, assigning each a pair of adjacent forks. - Sets
Philosopher.running = Trueto start the simulation. - Starts each philosopher's thread (
p.start()). - Lets the simulation run for a fixed duration (
time.sleep(100)). - Sets
Philosopher.running = Falseto signal philosophers to stop.
- Creates 5
Deadlock Avoidance Strategy
The key to avoiding deadlock is in the dine method:
- A philosopher will never hold one fork while waiting indefinitely for the second.
- If the second fork isn't immediately available, the philosopher releases the first fork they picked up, changes their preferred first fork (swaps), and then retries the entire process. This breaks the circular wait condition that leads to deadlock.
Producer-Consumer Problem
Premise
- One or more consumers need to read data in order and without duplication.
- One or more producers add data in the order that it needs to be processed.
Solution
#include <iostream>
#include <thread>
#include <deque>
#include <mutex>
#include <chrono>
#include <condition_variable>
using std::deque;
std::mutex mu,cout_mu;
std::condition_variable cond;
class Buffer
{
public:
void add(int num) {
while (true) {
std::unique_lock<std::mutex> locker(mu);
cond.wait(locker, [this](){return buffer_.size() < size_;});
buffer_.push_back(num);
locker.unlock();
cond.notify_all();
return;
}
}
int remove() {
while (true)
{
std::unique_lock<std::mutex> locker(mu);
cond.wait(locker, [this](){return buffer_.size() > 0;});
int back = buffer_.back();
buffer_.pop_back();
locker.unlock();
cond.notify_all();
return back;
}
}
Buffer() {}
private:
deque<int> buffer_;
const unsigned int size_ = 10;
};
class Producer
{
public:
Producer(Buffer* buffer, std::string name)
{
this->buffer_ = buffer;
this->name_ = name;
}
void run() {
while (true) {
int num = std::rand() % 100;
buffer_->add(num);
cout_mu.lock();
int sleep_time = rand() % 100;
std::cout << "Name: " << name_ << " Produced: " << num << " Sleep time: " << sleep_time << std::endl;
std::this_thread::sleep_formilliseconds(sleep_time);
cout_mu.unlock();
}
}
private:
Buffer *buffer_;
std::string name_;
};
class Consumer
{
public:
Consumer(Buffer* buffer, std::string name)
{
this->buffer_ = buffer;
this->name_ = name;
}
void run() {
while (true) {
int num = buffer_->remove();
cout_mu.lock();
int sleep_time = rand() % 100;
std::cout << "Name: " << name_ << " Consumed: " << num << " Sleep time: " << sleep_time << std::endl;
std::this_thread::sleep_formilliseconds(sleep_time);
cout_mu.unlock();
}
}
private:
Buffer *buffer_;
std::string name_;
};
int main() {
Buffer b;
Producer p1(&b, "producer1");
Producer p2(&b, "producer2");
Producer p3(&b, "producer3");
Consumer c1(&b, "consumer1");
Consumer c2(&b, "consumer2");
Consumer c3(&b, "consumer3");
std::thread producer_thread1run, &p1;
std::thread producer_thread2run, &p2;
std::thread producer_thread3run, &p3;
std::thread consumer_thread1run, &c1;
std::thread consumer_thread2run, &c2;
std::thread consumer_thread3run, &c3;
producer_thread1.join();
producer_thread2.join();
producer_thread3.join();
consumer_thread1.join();
consumer_thread2.join();
consumer_thread3.join();
getchar();
return 0;
}
This C++ code demonstrates a classic Producer-Consumer pattern using multithreading.
Explanation
- Global Variables:
std::mutex mu: A mutex to protect shared access to theBuffer.std::mutex cout_mu: A mutex to protectstd::cout, ensuring console output from different threads doesn't get mixed up.std::condition_variable cond: Used to signal between threads. Producers wait on it if the buffer is full, and consumers wait if it's empty.
BufferClass:- Manages a shared
std::deque<int> buffer_(a double-ended queue) with a fixed maximumsize_of 10. void add(int num)(Producer's method):- Acquires a
std::unique_lock<std::mutex> locker(mu)on the buffer's mutex. cond.wait(locker, [this](){return buffer_.size() < size_;});:- The thread waits (releases the lock
muand sleeps) until the conditionbuffer_.size() < size_(buffer is not full) is true. - The lock is reacquired automatically before checking the condition and upon waking up.
- The thread waits (releases the lock
- Adds
numto thebuffer_. locker.unlock(): Releases the lock explicitly.cond.notify_all(): Notifies all waiting threads (both producers and consumers) that the buffer state has changed.- The outer
while(true)loop andreturnensure the method completes after one successful addition.
- Acquires a
int remove()(Consumer's method):- Acquires a
std::unique_lock<std::mutex> locker(mu). cond.wait(locker, [this](){return buffer_.size() > 0;});:- The thread waits until
buffer_.size() > 0(buffer is not empty).
- The thread waits until
- Retrieves and removes an item from the
buffer_. locker.unlock(): Releases the lock.cond.notify_all(): Notifies all waiting threads.- Returns the removed item.
- The outer
while(true)loop andreturnensure the method completes after one successful removal.
- Acquires a
- Manages a shared
ProducerClass:- Stores a pointer to the shared
Bufferand aname_. void run():- Enters an infinite loop.
- Generates a random number (
num). - Calls
buffer_->add(num)to put the item into the buffer. - Locks
cout_muto print producer information and a random sleep duration. - Simulates work by sleeping for a random time.
- Unlocks
cout_mu.
- Stores a pointer to the shared
ConsumerClass:- Stores a pointer to the shared
Bufferand aname_. void run():- Enters an infinite loop.
- Calls
buffer_->remove()to get an item from the buffer. - Locks
cout_muto print consumer information and a random sleep duration. - Simulates work by sleeping for a random time.
- Unlocks
cout_mu.
- Stores a pointer to the shared
main()Function:- Creates a single
Bufferinstanceb. - Creates three
Producerinstances (p1,p2,p3) and threeConsumerinstances (c1,c2,c3), all sharing the same bufferb. - Launches six threads: one for each producer's
run()method and one for each consumer'srun()method. producer_threadX.join()andconsumer_threadX.join(): Themainthread waits for these threads to finish. However, because therun()methods contain infinitewhile(true)loops, these threads will never actually finish on their own. The program will run indefinitely until manually terminated (e.g., Ctrl+C).getchar(): This line is effectively unreachable due to the indefinite joins.
- Creates a single
Synchronization Explained
- Mutex (
mu): Ensures that only one thread (either a producer adding or a consumer removing) can access and modify thebuffer_at any given time, preventing data corruption. - Condition Variable (
cond):- Allows threads to wait efficiently for a specific condition to become true without busy-waiting (repeatedly checking).
- When a producer finds the buffer full, it waits on
cond. - When a consumer finds the buffer empty, it waits on
cond. - When a producer adds an item (potentially making a non-empty buffer for a waiting consumer) or a consumer removes an item (potentially making space for a waiting producer), it calls
cond.notify_all()to wake up any threads waiting oncond. The woken threads then re-check their condition.
cout_mu: Ensures that thestd::coutstatements from different threads are printed atomically, preventing garbled output.
This code provides a functional, albeit indefinite, simulation of the producer-consumer problem, highlighting the use of C++ concurrency primitives.
Sleeping Barber Problem
Premise
- N customers can sit in the waiting room.
- There is only one barber.
- When inactive the barber sleeps.
- If the barber is sleeping, a customer should wake him up.
- If there is no space in the waiting room, a customer leaves.
Solution
# Based on code from https://github.com/Nohclu/Sleeping-Barber-Python-3.6-/blob/master/barber.py
import time
import random
import threading
from queue import Queue
CUSTOMERS_SEATS = 15 # Number of seats in BarberShop
BARBERS = 3 # Number of Barbers working
EVENT = threading.Event() # Event flag, keeps track of Barber/Customer interactions
Earnings = 0
SHOP_OPEN = False
class Customer(threading.Thread): # Producer Thread
def __init__(self, queue): # Constructor passes Global Queue (all_customers) to Class
threading.Thread.__init__(self)
self.queue = queue
self.rate = self.what_customer()
@staticmethod
def what_customer():
customer_types = ["adult", "senior", "student", "child"]
customer_rates = {"adult": 16,
"senior": 7,
"student": 10,
"child": 7}
t = random.choice(customer_types)
print(t + " rate.")
return customer_rates[t]
def run(self):
if not self.queue.full(): # Check queue size
EVENT.set() # Sets EVENT flag to True i.e. Customer available in the Queue
EVENT.clear() # A lerts Barber that their is a Customer available in the Queue
else:
# If Queue is full, Customer leaves.
print("Queue full, customer has left.")
def trim(self):
global Earnings
print("Customer haircut started.")
a = 3 * random.random() # Retrieves random number.
time.sleep(a)
payment = self.rate
# Barber finished haircut.
print("Haircut finished. Haircut took {}".format(a))
Earnings += payment
class Barber(threading.Thread): # Consumer Thread
def __init__(self, queue): # Constructor passes Global Queue (all_customers) to Class
threading.Thread.__init__(self)
# TODO set this class's queue property to the passed value
self.queue = queue
self.sleep = True # No Customers in Queue therefore Barber sleeps by default
def is_empty(self): # Simple function that checks if there is a customer in the Queue and if so
if self.queue.empty():
self.sleep = True # If nobody in the Queue Barber sleeps.
else:
self.sleep = False # Else he wakes up.
print("------------------\nBarber sleep {}\n------------------".format(self.sleep))
def run(self):
global SHOP_OPEN
while SHOP_OPEN:
while self.queue.empty():
# Waits for the Event flag to be set, Can be seen as the Barber Actually sleeping.
EVENT.wait()
print("Barber is sleeping...")
print("Barber is awake.")
customer = self.queue
self.is_empty()
# FIFO Queue So first customer added is gotten.
customer = customer.get()
customer.trim() # Customers Hair is being cut
customer = self.queue
customer.task_done()
print(self.name) # Which Barber served the Customer
def wait():
time.sleep(1 * random.random())
if __name__ == '__main__':
Earnings = 0
SHOP_OPEN = True
barbers = []
all_customers = Queue(CUSTOMERS_SEATS) # A queue of size Customer Seats
for b in range(BARBERS):
# TODO Pass the all_customers Queue to the Barber constructor
b = Barber(all_customers)
# Makes the Thread a super low priority thread allowing it to be terminated easier
b.daemon = True
b.start() # Invokes the run method in the Barber Class
# Adding the Barber Thread to an array for easy referencing later on.
barbers.append(b)
for c in range(10): # Loop that creates infinite Customers
print("----")
# Simple Tracker too see the qsize (NOT RELIABLE!)
print(all_customers.qsize())
wait()
c = Customer(all_customers) # Passing Queue object to Customer class
all_customers.put(c) # Puts the Customer Thread in the Queue
c.start()
all_customers.join() # Terminates all Customer Threads
print("Barbers payment total:" + str(Earnings))
SHOP_OPEN = False
for i in barbers:
i.join(timeout=4) # Terminates all Barbers
# Program hangs due to infinite loop in Barber Class, use ctrl-z to exit.
Explanation
- Global Variables & Constants:
CUSTOMERS_SEATS = 15: Maximum number of customers that can wait in the shop.BARBERS = 3: Number of barbers working.EVENT = threading.Event(): A synchronization primitive. Customers set it to signal their arrival (waking up a potentially sleeping barber). Barbers wait on this event.Earnings = 0: Tracks the total money collected.SHOP_OPEN = False(initially, set toTrueinmain): Controls the main loop of the barber threads.
Customer(threading.Thread)Class (Producer-like):- Represents a customer arriving at the barbershop.
__init__(self, queue):- Takes the shared
all_customersqueue. self.rate: Randomly determines the price for the customer's haircut usingwhat_customer().
- Takes the shared
what_customer()(static method):- Randomly assigns a customer type ("adult", "senior", "student", "child") and returns the corresponding price.
run(self):- This method is called when a customer thread starts.
- Checks if the
self.queue(waiting room) is not full.- If not full: It calls
EVENT.set()(to signal availability) and then immediatelyEVENT.clear(). This is a very brief signal meant to wake up a barber.
- If not full: It calls
- If the queue is full: Prints that the customer has left.
- Note: The customer object itself is added to the
all_customersqueue in the main part of the script, not within its ownrunmethod.
trim(self):- Simulates the haircut process: prints messages, sleeps for a random duration (
3 * random.random()), and addsself.rateto globalEarnings.
- Simulates the haircut process: prints messages, sleeps for a random duration (
Barber(threading.Thread)Class (Consumer-like):- Represents a barber who serves customers.
__init__(self, queue):- Takes the shared
all_customersqueue. self.sleep = True: An attribute indicating if the barber is sleeping (thoughEVENT.wait()primarily controls this).
- Takes the shared
is_empty(self):- Checks if the customer queue is empty and updates
self.sleepfor printing status.
- Checks if the customer queue is empty and updates
run(self):- The main loop for the barber, continues as long as
SHOP_OPENisTrue. - Waiting for Customer:
while self.queue.empty(): Enters a loop if no customers are in the queue.EVENT.wait(): The barber "sleeps" here, waiting until another thread (a customer) callsEVENT.set().
- Once woken (or if the queue wasn't empty):
customer = self.queue.get(): Retrieves aCustomerobject from the queue (FIFO). This call will block if the queue is empty (acting as a secondary wait if the event was set but another barber got the customer).customer.trim(): Calls the customer'strimmethod to simulate the haircut.self.queue.task_done(): Signals to the queue that the processed item is complete (used withqueue.join()).
- The main loop for the barber, continues as long as
wait()function:- A simple helper function to pause for a short, random duration.
if __name__ == '__main__':(Main Execution Block):- Initializes
Earningsto 0 andSHOP_OPENtoTrue. - Creates
all_customers = Queue(CUSTOMERS_SEATS), a thread-safe queue to hold waiting customers. - Barber Creation:
- Creates
BARBERSnumber ofBarberthreads, passingall_customersto each. - Sets each barber thread as a
daemonthread (so they exit when the main program exits). - Starts each barber thread (
b.start()).
- Creates
- Customer Generation Loop:
- A loop runs 10 times (not infinite as the comment suggests in that line, but the overall pattern can be made infinite).
c = Customer(all_customers): Creates a new customer.all_customers.put(c): The main thread adds the customer object to the waiting queue.c.start(): Starts the customer thread (itsrunmethod executes, which signals theEVENT).
all_customers.join(): The main thread waits until every customer put into the queue has hadtask_done()called for it (i.e., all 10 customers are served).- Prints final
Earnings. SHOP_OPEN = False: Signals barber threads to stop theirwhile SHOP_OPEN:loops.- Attempts to
join()barber threads with a timeout. The comment# Program hangs due to infinite loop in Barber Class, use ctrl-z to exit.indicates a potential issue where barbers might get stuck onEVENT.wait()ifSHOP_OPENbecomes false while they are waiting and no more customer events are triggered to wake them.
- Initializes
Synchronization and Flow
- Barbers start and, if the
all_customersqueue is empty, they wait onEVENT.wait(). - The main loop creates a
Customerobject, adds this object to theall_customersqueue, and then starts theCustomerthread. - The
Customerthread'srun()method executes. If there's space, it callsEVENT.set(), briefly waking up a barber waiting onEVENT.wait(). It then immediately callsEVENT.clear(). - The woken barber (or one that wasn't sleeping) attempts
self.queue.get(). This retrieves theCustomerobject that the main thread put on the queue. - The barber calls the
trim()method on the retrievedCustomerobject. - After the haircut, the barber calls
self.queue.task_done(). - This continues until 10 customers are processed. Then
SHOP_OPENis set toFalse, and the program attempts to shut down.
Data and Code Synchronisation
- Semaphores
- Locks
Concurrent Programming Patterns
Divide and Conquer
- Concept: This pattern involves breaking down a large, complex problem into smaller, independent sub-problems. These sub-problems are then solved concurrently (at the same time). Finally, the solutions to the sub-problems are combined to form the solution to the original large problem.
- Concurrency: The independence of the sub-problems is key to enabling concurrency, as each can be processed by a separate thread or process without interfering with others.
- Example: Sorting a large list of numbers. The list can be divided into smaller sub-lists, each sorted concurrently, and then the sorted sub-lists are merged back together. Merge Sort and Quick Sort algorithms are classic examples that can be parallelized using this pattern.
Map and Reduce
- Concept: This pattern is designed for processing and generating large datasets in parallel. It consists of two main phases:
- Map Phase: An input dataset is split into smaller chunks. A "mapper" function is applied to each chunk independently to filter and transform the data into intermediate key/value pairs.
- Reduce Phase: The intermediate key/value pairs from the map phase are shuffled and grouped by key. A "reducer" function is then applied to each group of values associated with the same key to produce the final output.
- Concurrency: The map operations on different data chunks can occur in parallel, and similarly, multiple reduce operations (for different keys) can also run concurrently.
- Example: Counting word frequencies in a large collection of documents. Mappers could process individual documents, outputting (word, 1) for each word. Reducers would then sum the counts for each identical word. Frameworks like Hadoop MapReduce are built on this pattern.
Pipelines/Workflows
- Concept: This pattern organizes a sequence of computational stages, where the output of one stage becomes the input for the next. Data flows through these stages, with each stage performing a specific task.
- Concurrency: Concurrency can be achieved in a pipeline in a couple of ways:
- Task Parallelism: Different stages can operate on different data items simultaneously. As one data item moves to stage 2, stage 1 can start processing the next data item.
- Parallel Stages: A single stage itself might be parallelized if its task can be broken down further.
- Example: Video processing. One stage might decode a video frame, the next might apply a filter, a subsequent stage could encode it, and another could write it to disk. Each stage can work on a different frame concurrently. Data streaming and ETL (Extract, Transform, Load) processes often use this pattern.
Recursion
- Concept: Recursion is a programming technique where a function calls itself to solve smaller instances of the same problem.1 In concurrent programming, recursion is often a natural way to implement patterns like Divide and Conquer.
- Concurrency: When a recursive function breaks a problem into multiple independent sub-problems, these sub-problems can often be solved concurrently. Each recursive call that leads to an independent sub-problem can potentially be executed in a separate thread or task. This is sometimes referred to as "recursive splitting" or "recursive parallelism."
- Example: Calculating Fibonacci numbers where
fib(n) = fib(n-1) + fib(n-2). The two recursive callsfib(n-1)andfib(n-2)can be computed concurrently. Tree traversals are another common example where different subtrees can be processed recursively and concurrently. The fork-join pattern is often used to manage recursive concurrent tasks.
Repository Pattern
- Concept: The Repository pattern is primarily an architectural pattern that mediates between the domain (business logic) and data mapping layers. It provides an abstraction layer over data persistence, making it seem like an in-memory collection of objects.
- Concurrency Relevance: While not a concurrent programming pattern in itself, the Repository pattern is crucial in concurrent applications for managing data access. When multiple threads or processes need to read or write data:
- Data Consistency: The repository, often in conjunction with a Unit of Work pattern, helps manage transactions and ensure data consistency.
- Concurrency Control: It can be a place to implement concurrency control mechanisms (like optimistic or pessimistic locking) to prevent data corruption when multiple operations happen simultaneously on the same data.
- Abstraction: It shields the concurrent business logic from the complexities of how data is actually stored and accessed concurrently by the underlying database or data store.
- Example: In a web application handling multiple user requests concurrently, each request might interact with repositories to fetch or save user data. The repository ensures that these concurrent operations are handled safely and consistently with respect to the database.
Flynn's Taxonomy
Definition
Flynn's Taxonomy is a classification system for parallel computer architectures, proposed by Michael J. Flynn in 1966 and extended in 1972. It categorizes architectures based on the number of concurrent instruction streams and data streams they can process.

Categories
SISD (Single Instruction, Single Data Stream)
- Concept: This is the traditional sequential computer architecture. It has one control unit that fetches a single stream of instructions, and a single processing unit that operates on a single stream of data. Only one instruction is executed at a time.
- Concurrency: No parallelism in either instruction or data streams.
- Examples: Early personal computers, simple microcontrollers, and older mainframe computers.
SIMD (Single Instruction, Multiple Data Streams)
- Concept: A single instruction is executed simultaneously by multiple processing elements, but each processing element operates on a different data stream. This is well-suited for tasks that require the same operation to be performed on large sets of data.
- Concurrency: Achieved by applying the same instruction to multiple data elements at the same time (data parallelism).
- Examples: Vector processors, Graphics Processing Units (GPUs), and modern CPUs with vector extensions (like Intel's AVX or ARM's NEON). Image processing and scientific simulations often use SIMD architectures.
- Subcategories:
- Array Processor: Each parallel processing unit has its own separate memory and register file. The modern term "Single Instruction, Multiple Threads" (SIMT), used by Nvidia for its GPUs, is a form of this.
- Pipelined Processor: Processing units read data from a central resource, process fragments of that data, and write results back. Modern CPUs often use register files as this central resource ("packed SIMD").
- Associative Processor: Each processing unit independently decides whether to execute an instruction based on local data (also known as "predicated" or "masked" SIMD).
MISD (Multiple Instruction, Single Data Stream)
- Concept: Multiple instruction streams operate on a single data stream. This means different operations are performed on the same data concurrently.
- Concurrency: Achieved by having multiple instructions process the same data simultaneously.
- Examples: This category is considered largely theoretical, with few practical, real-world examples. Some potential, though not widespread, applications include fault-tolerant systems where multiple processors execute different instructions on the same data for redundancy or specialized signal processing tasks (like multiple filters on one signal).
MIMD (Multiple Instruction, Multiple Data Streams)
- Concept: This is the most common type of parallel computer architecture. It involves multiple processing units, each capable of executing different instruction streams independently on different data streams.
- Concurrency: Achieved by having multiple processors simultaneously execute different instructions on different pieces of data (task parallelism and data parallelism).
- Examples: Multi-core processors found in modern PCs and servers, supercomputers, clusters of computers, and distributed systems. MIMD architectures can be further divided into:
- Shared Memory MIMD: All processors share access to a common memory space.
- Distributed Memory MIMD: Each processor has its own local memory, and communication between processors typically occurs via message passing over a network.
Significance
- Framework for Understanding: It provides a simple and fundamental framework for understanding and categorizing different approaches to parallel processing in computer architectures.
- Design Guidance: It helps in analyzing the potential parallelism in computer architectures and guides the design of parallel algorithms and programming models.
- Communication Tool: It serves as a common language for discussing parallel systems.
- Historical Context: While modern architectures can be complex and sometimes combine elements of these categories (e.g., a MIMD system where each core also has SIMD capabilities), Flynn's Taxonomy remains a foundational concept for introducing parallel computing.
Python Parallel Programming
Options for Concurrency
threading
- Concurrency Unit: Threads (within the same process).
- Parallelism: Concurrent, not truly parallel for CPU-bound tasks in CPython due to the Global Interpreter Lock (GIL). The GIL allows only one thread to execute Python bytecode at a time.
- Memory: Threads share the same memory space, making data sharing easy but requiring careful synchronization (e.g., using
Locks) to prevent race conditions. - Multitasking: Preemptive (the OS decides when to switch threads).
- Best For: I/O-bound tasks (e.g., network requests, file operations). Threads can release the GIL during I/O waits, allowing other threads to run and improving responsiveness.
- Overhead: Lower than
multiprocessing. - Complexity: Managing shared state and avoiding deadlocks can be challenging.
multiprocessing
- Concurrency Unit: Processes (independent Python interpreters).
- Parallelism: True parallelism. Each process has its own GIL and can run on a separate CPU core.
- Memory: Each process has its own separate memory space. Data sharing requires Inter-Process Communication (IPC) mechanisms (e.g.,
Queue,Pipe, shared memory). - Multitasking: Preemptive (the OS manages processes).
- Best For: CPU-bound tasks (e.g., heavy computations, data analysis) where utilizing multiple cores significantly improves performance.
- Overhead: Higher than
threading(more memory, slower to start). - Complexity: IPC can be more complex to set up and manage than shared memory in threads.
asyncio
- Concurrency Unit: Coroutines (managed by an event loop within a single thread).
- Parallelism: Concurrent, not parallel. Operates on a single thread.
- Memory: Shares memory within its single process.
- Multitasking: Cooperative (tasks explicitly
awaitand yield control to the event loop during blocking operations). - GIL Impact: Operates within the GIL of its single thread, but designed to maximize that thread's efficiency by switching tasks during I/O waits.
- Best For: High-volume I/O-bound tasks (e.g., many concurrent network connections, web servers, database interactions). Very efficient for handling many simultaneous operations with low overhead.
- Overhead: Generally lower than
threadingfor managing many I/O-bound tasks due to efficient context switching between coroutines. - Complexity: Requires
async/awaitsyntax, which is a different programming paradigm. Integrating synchronous (blocking) code can be challenging.
Quick Comparison Table
| Feature | threading | multiprocessing | asyncio |
|---|---|---|---|
| Mechanism | Threads (shared memory) | Processes (separate memory) | Coroutines (single thread, event loop) |
| Parallelism | Concurrent (GIL limits CPU-bound) | True Parallel (multi-core) | Concurrent (single-core) |
| Use Case | I/O-bound | CPU-bound | High-volume I/O-bound |
| GIL | Limits true parallelism | Bypassed (per process) | Operates within it (single thread) |
| Data Sharing | Easy (direct, needs sync) | Harder (IPC) | Easy (within its process) |
| Overhead | Moderate | High | Low (for I/O) |
- Choose
threadingfor I/O-bound tasks where simplicity in data sharing (with care) is desired. - Choose
multiprocessingfor CPU bound tasks to leverage multiple CPU cores for true parallelism. - Choose
asynciofor high-performance I/O-bound applications, especially those with many concurrent connections, if you're comfortable with theasync/awaitparadigm.