Thread Lab - Basics of Thread Programming in C#
Learning goals
After this exercise you can
- create and start threads with
new Thread(...)andStart(), - wait for threads to finish with
Join(), - explain and fix race conditions with the
lockstatement, - use
SemaphoreSlimto send simple signals between threads, - measure and explain the speed-up threads give you.
Background: Parallel Programming - Processes and Parallel Programming - Threads.
Setup
Create a new console application in your favourite IDE, or use the CLI:
dotnet new console -n ThreadLab
cd ThreadLab
# replace Program.cs with the provided starter file
dotnet run -c ReleaseStarter file: Thread Lab - Basics of Thread Programming - Starter Template
Always measure in Release mode — Debug builds are slower and distort the timings.
The program shows a menu. Every part can be started on its own. Work through the parts in order and run the program after every TODO. The TODO numbers in Program.cs match the numbers on this sheet.
Every log line starts with the id of the thread that printed it, e.g. [T1 ]. Keep an eye on it.
Write your answers to the questions (❓) into a short text file or as comments in your code.
Part 1 - Download manager: starting threads
Downloading a file means mostly waiting for the network. The starter code simulates downloads with Thread.Sleep.
1.1 Run Part 1 and watch the sequential download.
❓ Which thread executes all downloads? How long does it take in total, and how does that number relate to the individual download durations?
1.2 Implement the parallel version: create one thread per download and start it.
1.3 Run Part 1 again and look at the time the main thread prints.
❓ The main thread claims to be finished after only a few milliseconds. Why? Are the downloads finished at that moment?
❓ The menu re-appears while downloads are still printing. Why doesn’t the program wait for the threads?
❓ Run it several times. Is the order of the output lines always the same? Why (not)?
✅ Checkpoint: all five downloads print their progress interleaved, with different thread ids.
Part 2 - Download manager: joining threads
2.1 Start one thread per download again, but store them in a List<Thread>.
2.2 Wait for all threads with Join() before the stopwatch is stopped.
❓ How long do all downloads take now? Which download determines the total time?
❓ Compute the speed-up yourself: sequential time ÷ parallel time.
2.3 Experiment: call Join() directly after Start() inside the loop.
❓ What happens to the total time? Explain why — then change it back.
✅ Checkpoint: the total time is roughly the duration of the longest download (≈ 2 s instead of ≈ 6.6 s).
Part 3 - Prime hunter: CPU speed-up
Downloads only wait. Counting primes really keeps the CPU busy. A single thread can only use one CPU core — this is where threads give real computing power.
3.1 Implement CountPrimesParallel(limit, threadCount):
- Split
[0, limit)intothreadCountchunks. - Start one thread per chunk; each thread calls
CountPrimes(from, to). - Each thread writes its result into its own slot of an
int[] results. Joinall threads and return the sum.
💡 Hints
- The last chunk must end exactly at
limit. - Results are wrong or you get an
IndexOutOfRangeException? A lambda captures the variablei, not its value at that moment. When the thread finally runs, the loop may already have changedi. Copy it first:int index = i;
✅ Checkpoint: every row in the table shows OK, and the time goes down with more threads.
3.2 Run Part 3 several times.
❓ The program printed the number of logical processors at startup. Which thread count gives the best speed-up on your machine?
❓ Why does using twice as many threads as processors not make it twice as fast again?
❓ Why do we not need a
lockfor theresultsarray?
3.3 (Bonus) Large numbers are much more expensive to test than small ones, so the thread with the last chunk has the most work. Write a version where thread k checks k, k + threadCount, k + 2·threadCount, …
❓ Compare the speed-ups of both versions. Explain the difference.
Part 4 - Shared counter: race conditions and lock
Eight threads each increment one shared counter one million times. The expected result is 8,000,000.
4.1 Run Part 4 several times and look at the first line (no synchronisation).
❓ Why are increments lost? Hint:
_sharedCounter++is not one step. Which steps does it consist of?
4.2 Implement IncrementWithLock: protect every single increment with lock (_counterLock).
4.3 Implement IncrementLocalThenLock: count in a local variable, then add the result to _sharedCounter inside one lock.
❓ Compare the times of 4.2 and 4.3. Why is 4.3 so much faster although both are correct?
❓ Why does the local variable in 4.3 not need a lock?
✅ Checkpoint: lines 4.2 and 4.3 are correct, line 4.1 (usually) is not.
4.4 (Bonus) Change CountPrimesParallel from Part 3: instead of the results array, every thread adds its partial result to one shared total. Which lock strategy do you choose?
Part 5 - Starting gun: a semaphore as a signal
Five runners wait at the starting line. Nobody may start before the gun is fired.
A SemaphoreSlim(0) starts with 0 permits: every Wait() blocks until somebody calls Release().
5.1 Start one thread per runner and keep the threads in a list. Run it.
❓ What happens before the countdown is over?
5.2 In Runner: wait for the start signal before running.
❓ Run it now. What happens after
GO!? Why?
5.3 Fire the starting gun: release the semaphore.
❓ How many permits must you release? What happens if you release only one?
5.4 Record the finishing order in _finishOrder. Protect the access with _finishLock — several runners might finish at the same time and List<T> is not thread-safe.
5.5 Join all runner threads and print the ranking.
✅ Checkpoint: all runners wait during the countdown, start together at GO!, and a ranking is printed at the end.
Part 6 - Ping-pong: semaphores for turn-taking
Two players should strictly alternate: Ping, Pong, Ping, Pong, …
6.1 Run Part 6 before changing anything.
❓ Does it look like a ping-pong game? Run it several times.
6.2 Initialise the two semaphores so that Ping serves first.
6.3 In both players: wait for your own turn, play, then hand the turn to the other player.
❓ What happens if both semaphores start with 0 permits? Try it! What is this situation called?
❓ What happens if both start with 1?
✅ Checkpoint: the output always alternates Ping (1), Pong (1), Ping (2), … — no matter how long the players “think”.
6.4 (Bonus) Add a third player Pang: Ping → Pong → Pang → Ping → …
❓ How many semaphores do you need? Can you find a general rule for n players?
Summary questions
- Part 2 and Part 3 both got faster with threads, but for different reasons. Explain the difference between waiting workloads (downloads) and computing workloads (primes).
- When do threads need a
lock— and when don’t they? Use examples from Parts 3 and 4. - What is the difference between
lockand a semaphore in what they are used for in this exercise?