A common component of many applications, from banking software to resource-based games like Cookie Clicker, is a wallet that holds a collection of resources. Users can interact the wallet by adding or subtracting resources from it, but no resource can ever go negative – debt is not allowed!
You’ll implement such a wallet in this MP, along with a multi-threaded server that uses a simple protocol to interact with your wallet over the network.
mp6.zip contains initial files. You will modify
wallet.py
wallet-server.py
In wallet.py
, you may only import threading
, no other libraries; and you may not use any global variables.
For the first two parts of this MP, you will be working in the file wallet.py
. You will implement the wallet library
, which has only a few basic requirements:
wallet_change
and supply a delta
to change your resource by a certain delta
. You may assume that every delta
is an int
.wallet_change
until you can satisfy the request (another thread must add to your wallet).class Wallet
, not in global variables or the like.The starter code has a simple class Wallet
for you to extend with empty method bodies for you to implement.
change
must block if there are not enough resources to be immediately satisfied. This must move the thread to a blocked state using one or more of the synchronization tools from the threading library, such as locks and conditional variables.
Two prohibited alternatives to blocking
Your wallet must NOT busy wait and must NOT sleep wait. Both busy and sleep waiting causes your CPU to make unnecessary checks and, in the case of sleeping, possibly waits an unnecessary extra amount of time.
# Example of "busy waiting" (wasting CPU cycles), which is NOT allowed:
while resource_count < delta:
pass # do nothing
# Example of "sleep waiting" (wasting CPU cycles or time, or a bit of both), which is NOT allowed:
while resource_count < delta:
0.001) time.sleep(
try_change
must not block, but also must not make resources negative. If change
would block, try_change
will instead immediately return False
.
Sometimes we want to perform a multi-resource transaction as a single atomic operation. For example, if a group of friends agree to split the bill at a restaurant we only want to perform the transaction if every friend has enough money for their part of the bill; otherwise, we shouldn’t get any food.
The transaction
method takes multiple keyword arguments, each with a resource name as a the keyword and the delta change to that resource as the value. It performs all of these changes at once, blocking if needed until they can be done in one action. You may assume that every delta is an int
.
For example, if Luther, Wade, and Carl agreed to split a $45 restaurant bill we might run wallet.transaction(Luther=-15, Wade=-15, Carl=-15, Restaurant=+45)
; this would block until all three of Luther, Wade, and Carl have at least 15 in their accounts and then reduce all three by 15 and increase the restaurant by 45 all at once, not allowing any other threads to do anything else to these four accounts in between the various -15 and +45 operations.
Synchronize each resource or each wallet?
You can design your wallet so that each resource has its own synchronization primitive to use in blocking, or so that there is just one single synchronization primitive shared by all resources in the wallet. There are pros and cons to each.
Being able to synchronize each resource individually provides fine-grained control and scales up well to large numbers of threads and resources accessing different resources. However, it means that a multi-resource transaction must handle all the involved synchronization primitives correctly, which is difficult to do correctly without introducing deadlocks.
Having a shared synchronization for all resources in the wallet makes multi-resource transactions straightforward: once the synchronization primitive is acquired, all of the resources can be changed together. However, it reduces opportunities for parallelism and does not scale very efficiently to large numbers of threads and resources.
You are welcome to use either approach in this MP, but synchronizing each wallet, not each resource, is much easier to do.
You will implement a server for your wallet in the create_wallet_server
function of wallet-server.py
, which has the following requirements:
Accept any number of concurrent connections.
We have tests that sends as many as 100 connections at one time, so you’ll want to use a number at least that large as the argument of your server socket’s .listen
method call or you might lose some of the connections.
Listen incoming connections on the provided port
, with each incoming connection must be then handled by its own thread.
Accept messages over connected sockets and send send a message back for each one. All messages (incoming and outgoing) are terminated with a newline (\n
). The messages are:
GET resource\n
invokes the .get(resource)
method of the underlying wallet and sends back its return value, converted to a str
.
MOD resource delta\n
invokes the .change(resource, delta)
method of the underlying wallet and sends back its return value, converted to a str
. You may assume that delta
is an integer.
TRY resource delta\n
invokes the .try_change(resource, delta)
method of the underlying wallet and sends back its return value, converted to a str
. You may assume that delta
is an integer.
TRAN resource1 delta1 resource2 delta2 [...]\n
for any number of resources (0 or more) invokes the .transaction(resource1=delta1, resource2=delta2, ...)
method and sends back its return value, converted to a str
. You may assume that each delta
is an integer.
Note that if d = {'arg1':2, 'another':3}
then foo(**d)
is equivalent to foo(arg1=2, another=3)
EXIT\n
closes the socket connection, sending nothing and terminating the thread that was handling this connection.
The only libraries you may import are threading
and socket
, as well as the wallet
you created in parts 1–3. The command-line argument parser also imports getopt
and sys
, but you should not use those libraries in the code that you write. You should also not add any global variables that are not already part of the file.
Your server should ignore all \r
characters. Just skip them. (This will help you debug, but is not a hard requirement and is not graded. However, telnet will add \r
characters as part of the newline sequence, and the server will be much easier to test with telnet than without it.)
We recommend testing your implementation in three stages.
The best way to see if your code works and fix it when it does not is do your own tests.
For parts 1–3, writing and running a tester file is the best way to make small tests.
Just implemented change
and want to see if it works? Write a program like
import wallet
= wallet.Wallet()
w print(w.change('thingy',4))
print(w.change('thingy',-2))
and check its output against what you think it should do. This will be much easier to debug than any of the tests we provide.
For part 4, you can use telnet
to test your code. This will involve having two (or more) terminals.
telnet
in Docker
The Dockerfile provided at the beginning of the semester does not include telnet
. You can install it for a single docker session (or on your course VM) by running
apt update
apt install telnet
To keep it installed for all future Docker session, edit the Dockerfile
to have telnet
on one of the installation lines (for example, right after make
) and then rebuild the container.
In one you’ll start the server as python3 wallet-server.py -p 34000
In the other you’ll connect to it as telnet localhost 34000
. After a few lines of connection information you’ll see something like
Connected to localhost.
Escape character is '^]'.
In the escape character string, ^
means the control key, so if you see the above to exit telnet you’d type Ctrl+]
.
You can then type messages in the telnet session, such as GET baz
and the response will appear afterwards. For example, an entire telnet session might be
Connected to localhost.
Escape character is '^]'.
GET baz
0
MOD baz 6
6
MOD baz 4
10
GET baz
10
EXIT
Connection closed by foreign host.
You should be able to open multiple terminals at once, connect them all to the same server using telnet, and have them all interact with the wallet at once, including one telnet making a blocking request and being awakened by another telnet supplying the needed resources.
The MP comes with several files that interact with the wallet in longer runs, verifying that your code works even in long-running sessions. These are:
hedgehog-simple.py
uses change
and get
Two threads; one generates 🐛, the other consumes 🐛 to generate 🦔.
hedgehog-rat.py
uses change
, get
, and try_change
Four threads; one generates 🐛, one generates 🌽, one other consumes 🐛 to generate 🦔, and one tries to consume 🐛, but falls back on 🌽 if there are no 🐛, to generate 🐀.
Because thread scheduling is unpredictable, the number of times that 🐀s eat all the 🐛s before a 🦔 can will vary by run. There’s a very small chance that the program will freeze because it ran out of 🐛 and is still trying to make more 🦔.
degree.py
uses change
, get
, and transaction
Many threads.
Some threads just produce things: one generates ☘️ (and an occasional 🍀); another 🧰 (and an occasional 💎); another 🍏; another 🧬.
Some threads consume some things to generate other things, such as making a 📗 out of 1×🍏, 1×🍀, 10×☘️, and 5×🧬.
Eventually, these result in a 🎓.
gacha.py
uses change
, get
, and transaction
Many threads; like degree.py
, some produce things and others convert them into other things.
This file mimics 90 draws of Genshin Impact’s 3-, 4-, and 5-star system with a pity system that guarantees an item of a certain star-level.
ping-pong.py
uses change
Two resources and two threads. One thread consumes one resource and produces the other, the other thread is the other way around. The result is each thread does one action then waits for the other, bouncing resources back and forth. This has proven useful for debugging some edge cases.
ping-pong-transaction.py
uses change
and transaction
Like ping-pong.py
, but using transactions instead of pairs of changes. Randomly decides which thread has the needed resource to start the exchanges.
We provide a suite of automated tests designed to be run with the pytest
library, which is preinstalled on your Docker image.
python3 -m pytest
You can also run just one part’s tests with python3 -m pytest test_part_1.py
.
You can also run just a single test with python3 -m pytest test_part_1.py -k initially_empty
.
These tests use several odd constructs to ensure they terminate even if your code blocks and are not likely to be easy to use in debugging directly. Some of them also require that your wallet.py
does not print anything. If you fail one or more of these tests, you are best-off copying the relevant parts of the test into your own test program rather than trying to debug the test itself.
pytest
outside of Docker
pytest
is not part of the default Python installation. To install it, run
python3 -m pip install pytest
If pip
isn’t found, one of the following should work:
sudo apt-get install python3-pip
(the VM will need this)python3 -m ensurepip
pip
or pip3
or py -m pip
instead of python3 -m pip
Once everything is working you can try running all the tests in summary form through make test
or python3 scoring.py
(both do the same thing). This is also what is run when you submit your code.
No tests will be performed and no points awarded if you violate the various constraints on imports and global variables given above.