Advanced Python: Sharing Data In Parallel And Concurrent Python Applications
By: Farhad Malik
Tags: Programming, Fintech, Python, Technology, Multiprocessing

Advanced Python: Sharing Data In Parallel And Concurrent Python Applications

Learn How To Use Python Synchronisation Primitives

Most of the enterprise-level high-performant computationally-intensive applications are concurrent and parallel in nature. The advanced features of concurrency and parallelism are usually used to enhance the performance of an application.

One of the biggest challenges in the concurrent and parallel applications is to be able to share the data between threads, asyncio routines, and/or processes.

This is where we can use the synchronization primitives. This article will aim to explain what they are and when to use them.

Synchronisation Primitives

Article Aim

This article will help us understand what Python Synchronisation Primitives are which can be used to share the data between processes/threads/tasks.

I will start by providing a brief overview of concurrency and parallelism concepts in general. I will then provide an overview of the basic synchronisation primitives along with the information on when to use them.

This is an advanced level topic for Python developers and I recommend it to everyone who is/or intends in using the Python programming language.

If you want to understand the Python programming language from the beginner to an advanced level then I highly recommend the article below:

1. Understanding Parallelism And Concurrency

Let’s start by understanding the key terms first:

1. Parallelism

Parallelism is a must-know concept. Parallelism involves creating multiple processes. Parallel execution means executing multiple sets of instructions at the same time in a completely different process. Within each process, we can choose to execute the steps synchronously or asynchronously.

Creating a new process each time is an expensive task. Hence we have to be very careful and ensure that we are attempting to execute a long-running CPU bound (and not IO bound) operation.

2. Concurrency

Each process can run multiple threads within its context. A thread is a set of coded computer instructions. Each thread has its own memory space. It also has access to the process memory and context information. We can create threads using the threading library. Due to GIL, only one thread can execute Python code at a time. Yet we can use threads to run multiple I/O bound tasks simultaneously. We often use multiple processes to obtain local and remove concurrency.

Concurrency is a slightly different concept. Concurrency is also used to enhance the overall performance of an application but it is used to solve a different problem.

Concurrency is great for IO-bound operations such as for reading a file or communication over a network.

When we run a process, such as Python.exe, it executes the code within its Main thread. The main thread can start up multiple threads. Subsequently, a process can start up multiple subprocesses.

Python offers the asyncio library which lets us perform actions while a task is in an I/O wait state. The library enables Python coders to write concurrent code using the async/await syntax whilst having full control over the execution of the concurrent coroutines.

The foundation of Async IO sits on the event loop object. We can think of the event loop as a stack of functions. The event loop is essentially the core of every asyncio operation.

It maintains the functions that are going to run and ensures that the functions are not blocking each other.

If you want to understand how parallelism and concurrency work in Python then I highly recommend this article:

2. Synchronisation Primitives Introduction

We can create pools, threads, or asyncio routines to enhance the performance of an application. The processes/threads/async routines, at times, need to communicate with each other and pass data to each other.

This is where the synchronisation primitives come in.

For threads, the synchronisation primitives are located in the threading library. Subsequently, for asyncio tasks, the synchronisation primitives are located within the asyncio library. The process-safe primitives are located in the multiprocessing library.

The overall concept of the synchronisation primitives is the same therefore I will explain them together in this section.

Let’s start by understanding the basic synchronisation primitives.

1. Lock

Locks are used to providing exclusive access to a shared resource. Locks are essentially used to block the callers such as coroutines or threads or processes from accessing a shared resource.

This shared resource could be a file, database, another module, class, function, or some lines of the code.

The locks block the callers from executing parts of the code as an instance.

Locks implement mutex lock. There are Lock objects within multiprocessing , asyncio and threading libraries that can be created, acquired, and released.

We can also use a multiprocessing.Lock to ensure only one process can access a shared resource at a time.

The locks are acquired first. Once a lock is acquired then any subsequent attempt to access the code that the lock is blocking will be blocked until the lock is released. The acquire() and release() methods are called to acquire and release the locks.

Within the threading library, we also have access to the RLock objects. These lock objects are reentrant and can be acquired multiple times by the same thread without blocking.

The acquired thread must release it once it has acquired it.

Now the key information to remember is that the asyncio.Lock is not thread-safe.

We can execute the locked() function to check if the threading.Lock is locked. If we try to release an unlocked thread, it raises a ThreadError exception.

2. Event

What if we want to the threads or asyncio routines or processes within our application to communicate with each other.

We could use the threading.Event object to signal other threads or the asyncio.Event object to notify multiple asyncio tasks about an event.

The way it works is that it has an internal flag which is set to False initially. Hence, we can set it to True or False by calling the set() or clear() methods respectively.

Essentially, the event blocks asyncio tasks or threads (dependent on the object type we are using) until the flag is set to True . The set method is used to set() the internal flag of an Event to True and the clear() method resets the value of the flag to False .

The wait() method is used to block a task until the flag is set to True .

As soon as the value of the flag is False , it will allow the coroutine to execute further.

3. Condition

Think of a condition object as an Event that uses a Lock.

Multiple conditions can use one lock. Conditions are used to coordinate exclusive access to a shared resource between different tasks or threads that want to gain access to a shared resource.

Therefore the condition blocks the threads/tasks until a condition is met.

Again, the asyncio Condition object can be created by calling asyncio.Condition() and the threading Condition object can be created by calling threading.Condition() and we can have multiple conditions sharing one lock. For multiple process scenarios, I will explain how Manager() object can be used.

The notify() method can be used to wake up tasks that are waiting on the condition. It notifies only one task by default. notify_all() can wake up all tasks that are waiting on this condition. We should lock before notify() is called and release after it.

We can use wait_for(predicate) to wait until the predicate condition becomes true.

Furthermore, the threading.condition object is used by a thread to notify other threads to access a shared resource. It can be passed with a Lock or RLock. We can also use wait(timeout) to wait until notified or until a timeout has occurred. If we want to wake up all of the threads that are waiting on the thread then use the notify_all() function.

4. Semaphore

The semaphore ( threading.semaphore or asyncio.semaphore ) can be used to permit only a given number of threads/tasks to access a shared resource.

A semaphore object has an internal counter. The counter is used to keep track of the number of threads or asyncio tasks. Each time the acquire() method is called, the value of the counter is incremented. The release() method is used to decrement the counter value.

The counter never decrements below zero.

5. BoundedSemaphore

We can also use bounded semaphore. It is a modification of the Semaphore object. Bounded semaphores are initialized with an initial value. If the release() the function is called and the internal value is increased above the initial value then it raises a ValueError exception.

6. Timer

The Timer class is essentially a subclass of the Thread class. The Timer objects are used to represent an action that should run after a specified amount of time. We use the start() method to start the timer and cancel() to cancel the timer.

Remember any object that has to acquire and release method can be used with the with statement .

7. Sending Objects Between Processes

If we want to exchange objects between processes, we can use a Queue or Pipe.

A pipe is used for a connection between two processes whereas a queue is used for producer/consumer pattern.

The queue is First-in First-out in nature.

The way a queue works is that one process can put a message in the queue and the other process can get the same message out from the queue.

a message in the queue and the other process can the same message out from the queue. A queue instance will have to be created and then passed as an argument to the function of the processes. The shared queue object can be used for multiple-process communication.

The pipe works in a slightly different manner. Pipe() returns a pair of connection objects. These objects are 2-way by default . We can pass on of the connection objects as an argument to a function when we start the process and use the other connection object. We can then execute its send() function to send a message between the two connection objects. The respective connection object will get the message once we execute the receive() method.

8. Sharing Memory Between Processes

We can share the data using Value or Array objects. The Value or Array is essentially a shared memory map which can store the data.

It’s best to avoid using shared state as much as possible.

The Value and Array shared objects are process-safe and thread-safe. However, they cannot support arbitrary object types. We can use the Manager objects instead.

Manager objects are more flexible than Value or Array objects.

A manager object controls a server process that holds Python objects. It allows other processes to manipulate the shared objects using proxies.

The manager object can return a dictionary, namespace, lock, read-only lock, semaphore, bounded semaphore, queue, value, array, condition, and event which I explained above.

We can then pass these manager objects to multiple processes and share the data.

These server process managers are more flexible than using shared memory objects. A single manager can also be shared by processes on different computers over a network.

We can also use multiprocessing.connection module. It allows us to obtain a high-level message-oriented API for dealing with sockets/windows-named pipes. It supports digest authentication using the hmax module. We can use it to check that the connection is authenticated via the deliver_challange() function.

8. Important Notes To Remember

Always best to avoid sharing data in an application if it is possible.

There are two important points to remember with asyncio library.

1. The primitives in asyncio are not thread-safe. As a result, one must not use it for thread synchronisation.

2. These primitives do not accept a timeout argument.

With processes,

A pipe is to support one publish-subscriber. A queue is to support multiple publishers-subscribers. A pipe is used for a connection between two processes and a queue allows multiple producers and consumers. The object that is put to a queue must be pickled. A background thread flushes the pickled data. It is then sent to the underlying pipe. This can result in objects being received out of order on the other end and the queue remains empty until a kill. When a process puts an item in the queue, the feeder thread is started. This thread transfers objects from a buffer into the pipe.

2. For more information

Please visit the official documentation of the primitives here:

Summary

This article provided an overview of the Python Synchronisation Primitives that are required to grant access to a shared resource amongst multiple processes/threads/tasks.

It also provided a brief overview of concurrency and parallelism concepts that should help us with designing concurrent and parallel applications.