CodingBison

One way to avoid the limitation provided by Python's Global Interpreter Lock (GIL) is to use the multiprocessing module that enables multiple processes to run in parallel. Since GIL is per-process, each process can acquire its own GIL and can run concurrently.

With multiprocessing module, we can use multiple processes instead of threads. This comes with its own constraints. Switching between different processes (aka scheduling) has a bigger overhead than switching between threads within one process. After all, that is why we use threads! One advantage of using multiprocessing is that for the case when we have multiple cores, we can make each process run on different cores. This is something we cannot do with the threading module, since the GIL allows only one thread to be run at a given time.

In addition, communication between different processes also require some form of inter process communication (IPC). Python provides constructs like Value and Array that help us achieve IPC using shared memory. So, this adds an extra layer of complexity.

Before we go any further, let us take a look at methods and attributes provided by the multiprocessing module.

 [user@codingbison]$ python3
 Python 3.2.3 (default, Jun  8 2012, 05:40:06) 
 [GCC 4.6.3 20120306 (Red Hat 4.6.3-2)] on linux2
 Type "help", "copyright", "credits" or "license" for more information.
 >>> 
 >>> import multiprocessing
 >>> 
 >>> dir(multiprocessing)
     ['Array', 'AuthenticationError', 'BoundedSemaphore', 'BufferTooShort', 
     'Condition', 'Event', 'JoinableQueue', 'Lock', 'Manager', 'Pipe', 'Pool', 
     'Process', 'ProcessError', 'Queue', 'RLock', 'RawArray', 'RawValue', 
     'SUBDEBUG', 'SUBWARNING', 'Semaphore', 'TimeoutError', 'Value', '__all__', 
     '__author__', '__builtins__', '__cached__', '__doc__', '__file__', '__name__', 
     '__package__', '__path__', '__version__', '_multiprocessing', 
     'active_children', 'allow_connection_pickling', 'cpu_count', 
     'current_process', 'freeze_support', 'get_logger', 'log_to_stderr', 'os', 
     'process', 'sys', 'util']
 >>> 

Some of the basic methods in the above output are: Process (to create a process), Lock (to create a lock), Value (to share a value stored in a shared memory common to multiple processes), and Array (to share a list of values stored in a shared memory common to multiple processes).

A Case of One Process

If we have to create a process, then the essential pieces are: (a) create a process (using Process()) and assign a target function to it along with function arguments (target and args parameters for the Process() call), (b) start the process (using start()), and (c) wait for the child process to finish (using join()). Here is a pseudo-code that starts a process p, and makes it run the target function, foo(). In a way, these steps are analogous to those of the threading module.

 import multiprocessing 

 def foo(n):
     ...
     ...

 p = multiprocessing.Process(target = foo, args = (n,))
 p.start()
 p.join()

The start() and the join() methods are owned by each process and not by the multiprocessing module. To confirm that, let us print methods and attributes owned by a process.

 >>> import multiprocessing
 >>> 
 >>> def foo():
 ...     print("Hello")
 ... 
 >>> p = multiprocessing.Process(target=foo, args=())
 >>> 
 >>> dir(p)
     ['_Popen', '__class__', '__delattr__', '__dict__', '__doc__', '__eq__', 
     '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', 
     '__init__', '__le__', '__lt__', '__module__', '__ne__', '__new__', 
     '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', 
     '__str__', '__subclasshook__', '__weakref__', '_args', '_authkey', 
     '_bootstrap', '_daemonic', '_identity', '_kwargs', '_name', '_parent_pid', 
     '_popen', '_target', '_tempdir', 'authkey', 'daemon', 'exitcode', 'ident', 
     'is_alive', 'join', 'name', 'pid', 'run', 'start', 'terminate']
 >>> 

The process object owns various other useful methods and data members: pid returns the PID of the current process, name returns the name of the current process, etc.

Let us now write a simple example that creates a process. The example (provided below) uses a process p and provides sleepAndRun() function as the target function. The process also passes a number (n) to this function; the number n specifies the number of iterations in the sleepAndRun(). The process mimics a case of an animal (let us say, a turtle) that is participating in a race (why not!) and when it gets tired, it takes a small nap.

The example also prints the name and PID of the two process: the main process and the child p process. For getting a handle of the current process, the example call current_process() method to get the multiprocessing module.

 from multiprocessing import Process, current_process
 import time
 import random

 def sleepAndRun(n):
     pName = current_process().name
     pid = current_process().pid
     print("\t[%s (%d)] Starting the process" % (pName, pid))
     i = 0
     while i < n:
         x = 5 * random.random()
         print("\t[%s (%d)] Let us sleep for %f seconds" % (pName, pid, x))
         time.sleep(x)

         x = 5 * random.random()
         print("\t[%s (%d)] Let us run for %f seconds" % (pName, pid, x))
         i += 1
     print("\t[" + pName + "] Finishing the process")

 if __name__ == '__main__':
     pName = current_process().name
     pid = current_process().pid
     print("[%s (%d)] Starting the process" % (pName, pid))

     p = Process(target=sleepAndRun, args=(5,))
     p.start()

     print("[%s (%d)] Wait for process: %s" % (pName, pid, p.name))
     p.join()
     print("[%s (%d)] Finishing the process" % (pName, pid))
 [MainProcess (29774)] Starting the process
 [MainProcess (29774)] Wait for process: Process-1
         [Process-1 (29775)] Starting the process
         [Process-1 (29775)] Let us sleep for 1.834272 seconds
         [Process-1 (29775)] Let us run for 1.107033 seconds
         [Process-1 (29775)] Let us sleep for 4.047518 seconds
         [Process-1 (29775)] Let us run for 4.055574 seconds
         [Process-1 (29775)] Let us sleep for 2.756004 seconds
         [Process-1 (29775)] Let us run for 0.801133 seconds
         [Process-1 (29775)] Let us sleep for 1.585777 seconds
         [Process-1 (29775)] Let us run for 4.497533 seconds
         [Process-1 (29775)] Let us sleep for 3.906793 seconds
         [Process-1 (29775)] Let us run for 3.072426 seconds
         [Process-1] Finishing the process
 [MainProcess (29774)] Finishing the process

We can get an additional proof that multiprocessing creates another process by taking a look at the ps command. We run this command while the above program is still running. The output shows that there are actually two processes: one main and the other child process created by the main. The main process has a PID of 29774 and the child process has a PID of 29775; these are the same values as seen in the above output.

 [user@codingbison]$ ps -aef  | egrep "python|PID"
 UID        PID  PPID  C STIME TTY          TIME CMD
 1000     29234 24847  0 22:41 pts/2    00:00:00 python3
 1000     29774   795  2 23:12 pts/6    00:00:00 python3 single-process.py
 1000     29775 29774  0 23:12 pts/6    00:00:00 python3 single-process.py
 1000     29778 26750  0 23:12 pts/3    00:00:00 egrep --color=auto python|PID
 [user@codingbison]$ 

A Case of Two Processes

With multiple processes, we need to ensure that they can share data among themselves. With threads, it is a trivial task, since all the threads belonging to a process share the memory owned by the process. But, when we have multiple processes, we no longer have this luxury. Hence, to have a common data shared across processes, we need to have some form of inter process communication (IPC). Python provides constructs like Value and Array that help us achieve IPC using shared memory.

Like the case of multiple threads, when multiple processes work on a common shared data, it is possible that they would overwrite the data. This is because the scheduler can move a process out of CPU, while it is still working on the common data and is not yet finished. Then the scheduler may switch to another process that may also update the common data. When the earlier process is rescheduled, it would continue to use the older value of the common data and this can lead to loss of data integrity. Hence, in addition to provide a common form of IPC, we also need to synchronize processes such that out of many processes, only one process can access the common data at any given time.

The solution once again is to use a lock. Like the threading module, the multiprocessing module also provide a lock method. Each process that intends to update the common data, must first acquire the lock by calling the acquire() method of the lock object. If the lock is already acquired by some other process, then the process will block till the other thread releases the lock. Once done, the process can call release() method to unlock the acquired lock.

Since acquire() method can potentially block the current process, sometimes a process can use a handy shortcut to check if the lock is available or not. For that, the process can pass an optional boolean parameter to this method and set it to False; if no parameter is specified, then the default value is True. Thus, lock.acquire(False) would acquire the lock if it is available, else, it would return immediately with a value of False.

With that, let us rewrite our second example that uses multiple processes. For common data, it uses the Value() method provided by the multiprocessing module. For process-synchronization, it uses the Lock() method provided by the multiprocessing module.

This example illustrates how we can use multiple processes to run tasks in parallel. The example is a simple abstraction of what is known as a salmon run. In this event, salmon fish migrate from ocean to the shallow regions of the river by swimming upstream. To their disadvantage, bears wait for them and as they swim upstream.

Accordingly, the example creates two processes: p_bear and p_salmon. The process, p_bear, mimics a bear catching a salmon by decreasing a common variable, salmonCount. The process, p_salmon, mimics a salmon swimming upstream by increasing this variable. So, while one process consumes salmon, the other actually adds it. Hence, this becomes a producer-consumer problem.

 import time
 import random
 from multiprocessing import Process, current_process, Lock, Value

 lock = Lock()

 def catchSalmon(n, salmonCount):
     global lock
     i = 0
     pName = current_process().name
     pid = current_process().pid
     print("\t[%s (%d)] Starting the process" % (pName, pid))
     while i < n:
         print("\t[%s (%d)] Total Salmon: %d" % (pName, pid, salmonCount.value))
         if (salmonCount.value <= 0):
             x = 5 * random.random()
             print("\t[%s (%d)] No Salmon yet. Let us wait again" % (pName, pid))
             time.sleep(x)
         else:
             print("\t[%s (%d)] Woohoo.. Caught a Salmon" % (pName, pid))
             lock.acquire()
             salmonCount.value -= 1
             lock.release()
             i += 1
     print("\t[%s (%d)] Finishing the process" % (pName, pid))

 def jumpUpstream(n, salmonCount):
     global lock
     i = 0

     pName = current_process().name
     pid = current_process().pid
     print("\t[%s (%d)] Starting the process" % (pName, pid))
     while i < n:
         x = 5 * random.random()
         time.sleep(x)
         lock.acquire()
         salmonCount.value += 1
         lock.release()
         print("\t[%s (%d)] One Salmon jumped upstream" % (pName, pid))
         print("\t[%s (%d)] Total Salmon: %d" % (pName, pid, salmonCount.value))
         i += 1
     print("\t[%s (%d)] Finishing the process" % (pName, pid))

 if __name__ == '__main__':
     pName = current_process().name
     pid = current_process().pid
     print("[%s (%d)] Starting the Main process" % (pName, pid))

     salmonCount = Value('i', 0)
     p_bear = Process(target=catchSalmon, args=(3, salmonCount))
     p_bear.start()
     p_salmon = Process(target=jumpUpstream, args=(3, salmonCount))
     p_salmon.start()

     print("[%s (%d)] Wait for process: %s" % (pName, pid, p_bear.name))
     p_bear.join()
     print("[%s (%d)] Wait for process: %s" % (pName, pid, p_salmon.name))
     p_salmon.join()
     print("[%s (%d)] Finishing the Main process" % (pName, pid))

Please note that we pass an initial value of 0 to the salmonCount along with a type code 'i'; this type code indicates that it is a singed int. Other supported type are: characters ('c'), ints ('b', 'B', 'h', 'H', 'l'), longs ('I' and 'L'), and floats ('f', 'd').

The output (provided below) shows that when there is no salmon, the p_bear process simply waits for it (by calling time.sleep()). When the process wakes up and if there is salmonCount is non-zero, then it reduces it by one. On the contrary, when the p_salmon process wakes up, it increases the salmonCount variable. After the completion of n such iterations for both processes, they finish and the join() call returns in the main process.

 [MainProcess (29216)] Starting the Main process
 [MainProcess (29216)] Wait for process: Process-1
         [Process-1 (29217)] Starting the process
         [Process-1 (29217)] Total Salmon: 0
         [Process-1 (29217)] No Salmon yet. Let us wait again
         [Process-2 (29218)] Starting the process
         [Process-1 (29217)] Total Salmon: 0
         [Process-1 (29217)] No Salmon yet. Let us wait again
         [Process-1 (29217)] Total Salmon: 0
         [Process-1 (29217)] No Salmon yet. Let us wait again
         [Process-1 (29217)] Total Salmon: 0
         [Process-1 (29217)] No Salmon yet. Let us wait again
         [Process-2 (29218)] One Salmon jumped upstream
         [Process-2 (29218)] Total Salmon: 1
         [Process-2 (29218)] One Salmon jumped upstream
         [Process-2 (29218)] Total Salmon: 2
         [Process-2 (29218)] One Salmon jumped upstream
         [Process-2 (29218)] Total Salmon: 3
         [Process-2 (29218)] Finishing the process
         [Process-1 (29217)] Total Salmon: 3
         [Process-1 (29217)] Woohoo.. Caught a Salmon
         [Process-1 (29217)] Total Salmon: 2
         [Process-1 (29217)] Woohoo.. Caught a Salmon
         [Process-1 (29217)] Total Salmon: 1
         [Process-1 (29217)] Woohoo.. Caught a Salmon
         [Process-1 (29217)] Finishing the process
 [MainProcess (29216)] Wait for process: Process-2
 [MainProcess (29216)] Finishing the Main process

Once again, we can use ps command to see the child processes created by the main process. The output of ps command shows that now there are three processes: one main and the other two created by the main. The main process has a PID of 29216 and the two child processes have PIDs of 29217 and 29218. As expected, these are the same values as seen in the above output.

 [user@codingbison]$ ps -aef  | egrep "python|PID"
 UID        PID  PPID  C STIME TTY          TIME CMD
 1000     29216   795 17 22:34 pts/6    00:00:00 python3 multiprocessing.py
 1000     29217 29216  0 22:34 pts/6    00:00:00 python3 multiprocessing.py
 1000     29218 29216  0 22:34 pts/6    00:00:00 python3 multiprocessing.py
 1000     29220 26750  0 22:34 pts/3    00:00:00 egrep --color=auto python|PID
 [user@codingbison]$ 




comments powered by Disqus