Assignment: Concurrent Programming, Part I
Table of Contents
Objectives
In this assignment you will be using the semaphore synchronization mechanism as implemented by Python's standard library threading API to solve some synchronization problems. One of the nice things about using a "real" API is that you can actually run your solutions and take some basic measurements (e.g., timing). Running a concurrent program does not, of course, necessarily prove that it is correct, but it can help to reason about what may or may not be going on.
Your solutions to this assignment will consist of code and accompanying writeup (the latter for only some of the problems). Please combine all your writeups into a single PDF document and compress & archive all your source files together such that when extracted they will be placed in separate directories named according to their associated problems (e.g., 1/golfing.py, 2/mixer.py, 3/philosophers.py).
You need not over-document your code, but if your implementation is particularly opaque or clever, we ask that you include a few well-placed comments to help explain things. An interview may be requested if we simply can't decipher your work. Non-runnable code will, regardless of how well it is documented, receive no credit.
Tutorial: concurrency in Python (and misc. APIs)
Threading
Every problem in this assignment requires concurrency. To write concurrent
programs using semaphores, you'll use Python's standard library threading
API. The easiest way to create a thread is to simply pass a Thread
object the
"target" function in which to start its execution. The following program shows
how to create five separate threads that execute the method say_hello
(with a
different argument per thread).
from threading import Thread def say_hello(id): print('hello from child', id) for i in range(5): t = Thread(target=say_hello, args=[i]) t.start() print('started thread', t.ident)
Output follows:
hello from child 0 started thread 4420612096 hello from child 1 started thread 4420612096 hello from child 2 started thread 4420612096 hello from child 3 started thread 4420612096 hello from child 4 started thread 4420612096
Note that the program exits only when all threads have terminated. This can be changed by configuring daemon threads, but you won't need them for this assignment.
It's also important to recognize that, due to the way in which threads are implemented in CPython (the "official" implementation of Python) threads will not actually exhibit parallelism, and instead be limited to running one at a time in the Python interpreter.
Using global variables
Due to the well known problems that can be caused by the accidental (or poorly
planned) modification of global variables, Python doesn't allow assignment to
global variables in non-global scopes without using the global
keyword. Consider the following program:
count = 0 def foo(): count += 1 foo()
When run, the following error is produced:
Traceback (most recent call last): File "./demo.py", line 6, in <module> foo() File "./demo.py", line 4, in foo count += 1 UnboundLocalError: local variable 'count' referenced before assignment
To get this to work, we would need to change foo
as follows:
def foo(): global count count += 1
But of course if foo
were to be called from separate threads we would then
need to protect it using a synchronization mechanism (e.g., a semaphore).
Using semaphores
There are a number of synchronization mechanisms provided in the Python
threading module, but we'll use only Semaphore objects. Creating a semaphore is
performed in exactly the same way as demonstrated in the lecture
slides. Unfortunately, the method names adopted by the Python library are
acquire
and release
instead of wait
and signal
(we could create aliases
for the latter, but won't bother). The following program demonstrates the use of
semaphores to implement two-thread rendezvous, which was one of the first
patterns we covered.
from threading import Thread, Semaphore from time import sleep aArrived = Semaphore(0) bArrived = Semaphore(0) def aBody(): aArrived.release() print('a at rendezvous') bArrived.acquire() print('a past rendezvous') def bBody(): bArrived.release() print('b at rendezvous') aArrived.acquire() print('b past rendezvous') tA = Thread(target=aBody) tA.start() sleep(1) # force thread A to block tB = Thread(target=bBody) tB.start()
Output follows:
a at rendezvous b at rendezvous b past rendezvous a past rendezvous
Note that the program also demonstrates the use of sleep
(in the time
module) to insert some forced downtime in the main thread.
Timing
Some of the problems ask that you time your implementations for comparison. This
is simple to do with the timeit module. The following program demonstrates how
to use it to time the execution of a function that creates 10 threads and waits
for them all to complete using Thread
's join
method.
from threading import Thread from time import sleep from timeit import Timer def totime(): ts = [Thread(target=thread_body, args=[0.1]) for i in range(10)] for t in ts: t.start() for t in ts: t.join() def thread_body(n): sleep(n) if __name__ == '__main__': timer = Timer(totime) print("Time: {:0.3f}s".format(timer.timeit(100)/100))
The output when run on my machine is:
Time: 0.104s
Note the following:
- the
Timer
constructor takes a (zero-argument) function to time - the
timeit
method actually performs the timing, and takes as an argument the number of times to run the function to be timed; consequently, to come up with the time per run, it is necessary to divide by the same value (e.g.,timer.timeit(100)/100
) - in order to measure how long it takes for all the threads spawned by the
totime
function to complete, it is necessary to calljoin
on all the threads before returning
Generating pseudo-random numbers
To make run-throughs (and corresponding output traces) of your concurrent programs more interesting, you will likely be injecting random sleeps at various points in your code. This will force threads to voluntarily yield control to each other at interesting places (where we might normally have to hope that the scheduler steps in and preempts them). The problem with using unpredictable random values, however, is that they make it harder for us to compare the results of one run to the next. If by chance a sequence of random numbers causes our threads to deadlock in one run, for instance, it's unlikely the same sequence would occur at the same time in the next run.
What we'd like is to be able to generate predictable sequences of random-ish numbers over and over again, to be replayed across runs. If you've already taken a course where basic number theory was covered, then you know this is a job for a pseudo-random number generator (PRNG). If this is a foreign concept to you, then do pay attention.
A PRNG generates values for us using a generator algorithm (which we won't discuss) and some initial state, typically known as a seed. A PRNG is necessarily a state machine, as each invocation returns a new pseudorandom value and requires that it track the new state (so to compute the next value). If a PRNG maintains k bits of state, then it is effectively capable of producing a sequence of \(2^k\) pseudorandom values, after which it will cycle back around to the beginning of the sequence.
Here's how we create and seed a PRNG in Python, using the random
module.
import random rng = random.Random() rng.seed(100)
We can now obtain pseudorandom values:
print("Random float in range [0.0, 1.0):", rng.random()) print("Random int in range [0, 1000): ", int(rng.random()*1000)) print("Random int in range [20, 30]: ", rng.randint(20, 30))
Sample output follows:
Random float in range [0.0, 1.0): 0.1456692551041303 Random int in range [0, 1000): 454 Random int in range [20, 30]: 22
See http://docs.python.org/3/library/random.html for other methods supported
by Random
.
Perhaps more interestingly, we can produce the same sequence of pseudorandom values by re-seeding the PRNG:
rng = random.Random() for i in range(5): rng.seed(100) print("Pass", i, ":", [rng.random() for _ in range(3)])
Output follows:
Pass 0 : [0.1456692551041303, 0.45492700451402135, 0.7707838056590222] Pass 1 : [0.1456692551041303, 0.45492700451402135, 0.7707838056590222] Pass 2 : [0.1456692551041303, 0.45492700451402135, 0.7707838056590222] Pass 3 : [0.1456692551041303, 0.45492700451402135, 0.7707838056590222] Pass 4 : [0.1456692551041303, 0.45492700451402135, 0.7707838056590222]
You'll use this pattern in your programs, typically by creating and seeding a
PRNG, then proceeding to extract pseudorandom values from it, say, for use as
arguments to sleep
.
Take care that you only seed the PRNG once in your program (or once per thread – with differing seed values)! I've come across many a submission in the past where a PRNG was re-seeded with the same value on every iteration of a thread body/loop, effectively making it so the values produced are not at all random.
Synchronization and Concurrent Programming in Python
Each of the following problems describes a concurrent system to be simulated
using threads, which in turn requires the help of one or more semaphores for
proper synchronization. Your solution to each problem should be a single Python
script that, when run with python scriptname.py
should instantiate all
objects/threads and invoke any methods needed to start up the simulation and
produce the required output. Python 3.x is recommended, though if your scripts
require Python 2.x, just let us know (see also Python2orPython3).
At the IIT Driving range (12 points)
Down at the new IIT driving range, there are numerous tees at which golfers can be found practicing their swings. Each golfer will call for a bucket of N balls at a time, then proceed to swing at them one by one until empty, at which point he'll call for another bucket. When there are insufficient balls left in the central stash to satisfy a call for a bucket, all practice ceases and the retriever cart takes to the field to gather balls to replenish the supply.
Without synchronization in place, each golfer thread executes the following code:
# golfer while True: stash -= N for i in range(0,N): balls_on_field += 1 # simulate "swinging" here with random sleep
And the single retriever cart thread runs the following:
# cart while True: stash += balls_on_field
Here are the synchronization rules:
- Golfers are allowed to swing concurrently.
- Golfers may not swing whilst the cart is out on the field.
- Golf balls are never lost (or gained)! They are either in the stash, on the field, or with a golfer.
- When the stash needs to be replenished, the cart thread has priority over golfers. I.e., golfers that are currently practicing will be interrupted after their current swing so the cart can head out.
Here is properly synchronized sample output for 3 golfers with an initial stash of 20 balls, and 5 balls per bucket:
$ python golfer.py 3 20 5 Golfer 0 calling for bucket Golfer 0 got 5 balls; Stash = 15 Golfer 1 calling for bucket Golfer 1 got 5 balls; Stash = 10 Golfer 1 hit ball 0 Golfer 2 calling for bucket Golfer 2 got 5 balls; Stash = 5 Golfer 0 hit ball 0 Golfer 2 hit ball 0 Golfer 2 hit ball 1 Golfer 2 hit ball 2 Golfer 0 hit ball 1 Golfer 2 hit ball 3 Golfer 1 hit ball 1 Golfer 1 hit ball 2 Golfer 1 hit ball 3 Golfer 0 hit ball 2 Golfer 2 hit ball 4 Golfer 2 calling for bucket Golfer 2 got 5 balls; Stash = 0 Golfer 2 hit ball 0 Golfer 1 hit ball 4 Golfer 1 calling for bucket ################################################################################ Stash = 0; Cart entering field Cart done, gathered 14 balls; Stash = 14 ################################################################################ Golfer 1 got 5 balls; Stash = 9 Golfer 1 hit ball 0 Golfer 2 hit ball 1 Golfer 0 hit ball 3 Golfer 2 hit ball 2 Golfer 0 hit ball 4 Golfer 2 hit ball 3 Golfer 0 calling for bucket Golfer 0 got 5 balls; Stash = 4 Golfer 0 hit ball 0 Golfer 1 hit ball 1 Golfer 2 hit ball 4 Golfer 1 hit ball 2 Golfer 0 hit ball 1 Golfer 0 hit ball 2 Golfer 2 calling for bucket ################################################################################ Stash = 4; Cart entering field Cart done, gathered 12 balls; Stash = 16 ################################################################################ Golfer 2 got 5 balls; Stash = 11 Golfer 2 hit ball 0 Golfer 0 hit ball 3 Golfer 1 hit ball 3 Golfer 2 hit ball 1 Golfer 0 hit ball 4 ...
Your simulation should allow the following items to be easily configurable:
- the initial size of the stash
- the number of balls per bucket (which determines (a) how many balls a golfer can hit before calling for another bucket and (b) when the cart needs to head out to the field)
- the number of golfer threads
You can assume the initial stash size is enough to satisfy all golfers concurrently.
Dance mixer (12 points)
At the Willowbrook ballroom they regularly run a "dance mixer" event, wherein N leaders line up on one side of the ballroom and M followers line up on the other. They pair up one by one then proceed to dance around the floor – after which they return to the end of their respective lines. This continues until the band leader decides it is time to switch dances and the pairing up of dancers is tapered off. When all dancers have made it off the dance floor and back to their respective lines, the music changes and the dancing begins again.
The system is to be simulated with the following threads:
- The (single) band leader thread, which will execute the following
unsynchronized code:
for music in cycle(['waltz', 'tango', 'foxtrot']): start_music(music) end_music(music)
Note that the band leader continually cycles through an arbitrary number of music styles. After the
start_music
call, couples may start forming and dancing on the floor. Theend_music
call may only be invoked when there are no couples on the floor (i.e., they have all lined back up). The code above usescycle
fromitertools
. - Leader threads, which will execute the following unsynchronized code:
while True: enter_floor() dance() line_up()
- Follower threads, which will execute the following unsynchronized code:
while True: enter_floor() dance() line_up()
A leader is "paired up" with a follower by ensuring that they must both finish
executing enter_floor
before dancing. Upon dancing, the output should reflect
which threads are actually paired up (see below). You may add parameters to the
function calls above to help you achieve this. After dancing (concurrently),
leaders and followers may line back up at their leisure.
Your code should allow for easy configuration of the number of leaders and followers, be starvation free, and allow for the maximum amount of concurrency. If necessary, you may wish to use the deque data structure employed previously in the MLFQ lab for communicating data between threads.
Sample output follows for a dance mixer with 2 leaders and 5 followers (the band leader is configured to change songs after every 5 seconds):
$ python mixer.py 2 5 ** Band leader started playing waltz ** Follower 0 entering floor. Leader 0 entering floor. Leader 0 and Follower 0 are dancing. Follower 1 entering floor. Leader 1 entering floor. Leader 1 and Follower 1 are dancing. Leader 0 getting back in line. Follower 2 entering floor. Leader 0 entering floor. Leader 0 and Follower 2 are dancing. Leader 0 getting back in line. Leader 0 entering floor. Follower 3 entering floor. Leader 0 and Follower 3 are dancing. Follower 0 getting back in line. Follower 1 getting back in line. Leader 1 getting back in line. Follower 2 getting back in line. Follower 3 getting back in line. Leader 0 getting back in line. ** Band leader stopped playing waltz ** ** Band leader started playing tango ** Follower 4 entering floor. Leader 1 entering floor. Leader 1 and Follower 4 are dancing. Leader 0 entering floor. Follower 0 entering floor. Leader 0 and Follower 0 are dancing. Follower 4 getting back in line. Leader 0 getting back in line. Follower 1 entering floor. Leader 0 entering floor. Leader 0 and Follower 1 are dancing. Leader 0 getting back in line. Follower 0 getting back in line. Leader 1 getting back in line. Follower 1 getting back in line. ** Band leader stopped playing tango ** ** Band leader started playing foxtrot ** Follower 2 entering floor. Leader 0 entering floor. Leader 0 and Follower 2 are dancing. Leader 1 entering floor. Follower 3 entering floor. Leader 1 and Follower 3 are dancing. Follower 3 getting back in line. Leader 1 getting back in line. Leader 0 getting back in line. Follower 2 getting back in line. ** Band leader stopped playing foxtrot ** ** Band leader started playing waltz ** ...
Dining philosophers (12 points)
For this problem you will implement and time different solutions to the dining philosophers problem. The solutions we discussed include:
- The "footman" solution: where a single semaphore (a multiplex) limits the number of philosophers that can simultaneously "try" to dine.
- The "left-handed philosopher" solution: where one of the philosophers attempts to pick up his left fork first (whilst all other philosophers start with their right).
- The Tanenbaum solution: which has philosophers re-checking forks for neighboring, hungry philosophers.
The details/pseudo-code for each solution are in the lecture slides.
You are to craft a program that will apply each solution, in succession, to a specified number of philosophers and rounds of dining, then print out the amount of time to complete each simulation.
For instance, specifying 20 philosophers, with a requisite 1000 meals eaten by each, might produce the following results (times are made up):
$ python philosophers.py 20 1000 Running dining philosophers simulation: 20 philosophers, 1000 meals each 1. Footman solution, time elapsed: 32.1992s 2. Left-handed solution, time elapsed: 42.2290s 3. Tanenbaum's solution, time elapsed: 28.4939s
Each philosopher should eat/sleep for random amounts of time, but to be fair these durations should be consistent (i.e., generated using PRNGs with the same seed) across each algorithm.
After testing your solutions, discuss your results in a separate writeup. Are they what you expect? What accounts for the disparity in time elapsed for the different solutions?