Thread Lab - Basics of Thread Programming - Starter Template
10 min read
// =====================================================================// THREAD LAB — Basics of Thread Programming in C#// =====================================================================//// Setup:// dotnet new console -n ThreadLab// -> replace the generated Program.cs with this file//// Run (use Release mode for meaningful timings!):// dotnet run -c Release//// Work through the parts IN ORDER. Each part can be run from the menu,// so test your solution after every TODO before moving on.//// Part 1 Download manager – creating and starting threads// Part 2 Download manager – joining threads// Part 3 Prime hunter – CPU speed-up with threads// Part 4 Shared counter – race conditions and lock// Part 5 Starting gun – semaphore as a start signal// Part 6 Ping-pong – semaphores for turn-taking//// Every TODO is numbered (TODO 1.1, 1.2, ...). The exercise sheet// contains the matching questions you should answer.// =====================================================================using System.Diagnostics;namespace ThreadLab;public static class Program{ // Short id of the thread currently executing — handy for log output. private static string Tid => $"T{Environment.CurrentManagedThreadId,-2}"; public static void Main() { Console.WriteLine("=== Thread Lab ==="); Console.WriteLine($"This machine has {Environment.ProcessorCount} logical processors."); while (true) { Console.WriteLine(); Console.WriteLine("Choose a part:"); Console.WriteLine(" 1 - Download manager: starting threads"); Console.WriteLine(" 2 - Download manager: joining threads"); Console.WriteLine(" 3 - Prime hunter: CPU speed-up"); Console.WriteLine(" 4 - Shared counter: lock"); Console.WriteLine(" 5 - Starting gun: semaphore as a signal"); Console.WriteLine(" 6 - Ping-pong: semaphores for turn-taking"); Console.WriteLine(" 0 - Exit"); Console.Write("> "); switch (Console.ReadLine()?.Trim()) { case "1": Part1_StartingThreads(); break; case "2": Part2_JoiningThreads(); break; case "3": Part3_PrimeHunter(); break; case "4": Part4_SharedCounter(); break; case "5": Part5_StartingGun(); break; case "6": Part6_PingPong(); break; case "0": return; default: Console.WriteLine("Unknown option."); break; } } } private static void Header(string title) { Console.WriteLine(); Console.WriteLine(new string('=', 60)); Console.WriteLine($" {title}"); Console.WriteLine(new string('=', 60)); } // ================================================================= // PART 1 + 2 — DOWNLOAD MANAGER (independent parallel workloads) // ================================================================= // A "download" is simulated with Thread.Sleep. The CPU does almost // nothing while waiting — just like a real download waits for the // network. Threads let us wait for many things at the same time. private static readonly (string Name, int DurationMs)[] Downloads = { ("cat-video.mp4", 2000), ("lecture-notes.pdf", 800), ("game-patch.zip", 1600), ("playlist.mp3", 1200), ("dataset.csv", 1000), }; // Given — do not change. Simulates downloading a file in 4 steps. private static void SimulateDownload(string fileName, int durationMs) { const int steps = 4; for (int i = 1; i <= steps; i++) { Thread.Sleep(durationMs / steps); Console.WriteLine($" [{Tid}] {fileName,-18} {i * 100 / steps,3}%"); } } private static void Part1_StartingThreads() { Header("Part 1 — Starting threads"); // ---- 1.1 Sequential version (given) ------------------------- // Run it first. Note the thread id and the total time. Console.WriteLine("Sequential download:"); var sw = Stopwatch.StartNew(); foreach (var (name, ms) in Downloads) { SimulateDownload(name, ms); } sw.Stop(); Console.WriteLine($"Sequential: all downloads finished after {sw.ElapsedMilliseconds} ms"); Console.WriteLine(); Console.WriteLine("Press Enter to start the parallel version..."); Console.ReadLine(); // ---- 1.2 Parallel version ------------------------------------ sw.Restart(); // TODO 1.2: For EVERY entry in Downloads, create a new Thread that // calls SimulateDownload(name, ms) and start it. // Hint: new Thread(() => ...) with a lambda. // TODO 1.3: Run the program. Look at the time printed below and at // the order of the output lines. Answer the questions on // the exercise sheet. (Do NOT fix anything here — that's // what Part 2 is for.) Console.WriteLine($"Parallel: main thread reached this line after {sw.ElapsedMilliseconds} ms"); } private static void Part2_JoiningThreads() { Header("Part 2 — Joining threads"); int sequentialMs = Downloads.Sum(d => d.DurationMs); var sw = Stopwatch.StartNew(); // TODO 2.1: Start one thread per download again (like in TODO 1.2), // but this time remember every thread in a List<Thread>. // TODO 2.2: Wait until ALL threads have finished (Join). // Only then may the main thread continue and stop the timer. sw.Stop(); Console.WriteLine($"Parallel: all downloads finished after {sw.ElapsedMilliseconds} ms"); Console.WriteLine($"Sequential would take about {sequentialMs} ms " + $"-> speed-up: {(double)sequentialMs / Math.Max(1, sw.ElapsedMilliseconds):F2}x"); // TODO 2.3: Experiment: call Join() directly after Start() inside // your loop (instead of after the loop). Run it, compare // the time, explain the result — then change it back. } // ================================================================= // PART 3 — PRIME HUNTER (CPU-bound speed-up) // ================================================================= // Counting primes keeps the CPU busy. One thread uses one core — // several threads can use several cores at the same time. private const int PrimeLimit = 5_000_000; // increase if it runs too fast // Given — do not change. private static bool IsPrime(int n) { if (n < 2) return false; if (n % 2 == 0) return n == 2; for (int d = 3; (long)d * d <= n; d += 2) { if (n % d == 0) return false; } return true; } // Given — counts primes in [fromInclusive, toExclusive). private static int CountPrimes(int fromInclusive, int toExclusive) { int count = 0; for (int n = fromInclusive; n < toExclusive; n++) { if (IsPrime(n)) count++; } return count; } private static void Part3_PrimeHunter() { Header($"Part 3 — Prime hunter: counting primes below {PrimeLimit:N0}"); var sw = Stopwatch.StartNew(); int expected = CountPrimes(0, PrimeLimit); sw.Stop(); long sequentialMs = sw.ElapsedMilliseconds; Console.WriteLine($"Sequential: {expected:N0} primes in {sequentialMs} ms"); Console.WriteLine(); int[] threadCounts = new[] { 1, 2, 4, Environment.ProcessorCount, Environment.ProcessorCount * 2 } .Distinct() .OrderBy(x => x) .ToArray(); Console.WriteLine("Threads | Time | Speed-up | Result"); Console.WriteLine("--------+----------+----------+------------------"); foreach (int threadCount in threadCounts) { sw.Restart(); int result = CountPrimesParallel(PrimeLimit, threadCount); sw.Stop(); if (result < 0) { Console.WriteLine("CountPrimesParallel is not implemented yet (TODO 3.1)."); return; } string check = result == expected ? "OK" : "WRONG!"; double speedUp = (double)sequentialMs / Math.Max(1, sw.ElapsedMilliseconds); Console.WriteLine($"{threadCount,7} | {sw.ElapsedMilliseconds,5} ms | {speedUp,7:F2}x | {result:N0} {check}"); } // TODO 3.2: Run Part 3 a few times and answer the questions on the // exercise sheet (best thread count, why not more?). } private static int CountPrimesParallel(int limit, int threadCount) { // TODO 3.1: Split the range [0, limit) into threadCount chunks of // (roughly) equal size. Start one thread per chunk that // calls CountPrimes(from, to) for its chunk. // // Every thread stores its partial result in its OWN slot // of an array: int[] results = new int[threadCount]; // -> no two threads write to the same memory location, // so no synchronisation is needed (yet). // // Join all threads, then return the sum of all results. // // Watch out: // - The last chunk must reach exactly up to 'limit' // (limit is not always divisible by threadCount). // - A lambda captures VARIABLES, not values. Copy the loop // variable into a local (int index = i;) before using it // inside the lambda. return -1; // TODO 3.1: remove this line when you are done } // TODO 3.3 (bonus): Thread 0 gets the small numbers, the last thread the // big numbers — which are much more expensive to test. Write a // second version where thread k checks the numbers // k, k + threadCount, k + 2*threadCount, ... // Compare the speed-ups of both versions. // ================================================================= // PART 4 — SHARED COUNTER (race conditions and lock) // ================================================================= private const int CounterThreads = 8; private const int IncrementsPerThread = 1_000_000; private static int _sharedCounter = 0; private static readonly object _counterLock = new(); private static void Part4_SharedCounter() { Header($"Part 4 — Shared counter ({CounterThreads} threads x {IncrementsPerThread:N0} increments)"); RunCounterExperiment("4.1 no synchronisation", IncrementUnsafe); RunCounterExperiment("4.2 lock per increment", IncrementWithLock); RunCounterExperiment("4.3 count locally, lock once", IncrementLocalThenLock); } // Given — runs 'work' on CounterThreads threads and checks the result. private static void RunCounterExperiment(string label, Action work) { _sharedCounter = 0; var threads = new List<Thread>(); var sw = Stopwatch.StartNew(); for (int i = 0; i < CounterThreads; i++) { var t = new Thread(() => work()); threads.Add(t); t.Start(); } foreach (var t in threads) { t.Join(); } sw.Stop(); int expected = CounterThreads * IncrementsPerThread; string verdict = _sharedCounter == expected ? "correct" : $"WRONG, lost {expected - _sharedCounter:N0}"; Console.WriteLine($"{label,-30} {_sharedCounter,11:N0} / {expected:N0} {sw.ElapsedMilliseconds,5} ms ({verdict})"); } // 4.1 Given — run Part 4 several times and look at the first line. private static void IncrementUnsafe() { for (int i = 0; i < IncrementsPerThread; i++) { _sharedCounter++; } } private static void IncrementWithLock() { // TODO 4.2: Same loop as in IncrementUnsafe, but protect every // single increment with lock (_counterLock) { ... }. } private static void IncrementLocalThenLock() { // TODO 4.3: Count in a LOCAL variable first (local variables are // private to each thread!). When the loop is done, add // the local count to _sharedCounter — inside ONE lock. } // TODO 4.4 (bonus): Go back to CountPrimesParallel (Part 3) and replace // the results array by one shared total that every thread // adds its partial result to. Which lock strategy from // 4.2 / 4.3 do you use — and why? // ================================================================= // PART 5 — STARTING GUN (semaphore as a start signal) // ================================================================= private static readonly string[] Runners = { "Ada", "Linus", "Grace", "Bjarne", "Margaret" }; private static SemaphoreSlim _startSignal = new(0); private static readonly List<string> _finishOrder = new(); private static readonly object _finishLock = new(); private static void Part5_StartingGun() { Header("Part 5 — Starting gun"); _startSignal = new SemaphoreSlim(0); // 0 permits -> Wait() blocks _finishOrder.Clear(); // TODO 5.1: Start one thread per runner, each executing Runner(name). // Keep the threads in a list (you need them in TODO 5.5). // Run it now: what happens before the countdown is over? Thread.Sleep(500); // give the runners time to reach the starting line foreach (var s in new[] { "3...", "2...", "1..." }) { Console.WriteLine($" [{Tid}] {s}"); Thread.Sleep(1000); } Console.WriteLine($" [{Tid}] GO!"); // TODO 5.3: Fire the starting gun: release the semaphore so that ALL // runners may start. How many permits do you need? // TODO 5.5: Wait for all runners to finish, then print the ranking // stored in _finishOrder (1. ..., 2. ..., ...). } private static void Runner(string name) { Console.WriteLine($" [{Tid}] {name} is at the starting line."); // TODO 5.2: Wait for the start signal before running. var sw = Stopwatch.StartNew(); Thread.Sleep(Random.Shared.Next(1000, 3000)); // running... Console.WriteLine($" [{Tid}] {name} crosses the finish line after {sw.ElapsedMilliseconds} ms"); // TODO 5.4: Add 'name' to _finishOrder. Several runners may finish // at the same time and List<T> is NOT thread-safe -> // protect the access with _finishLock. } // ================================================================= // PART 6 — PING-PONG (semaphores for turn-taking) // ================================================================= private const int PingPongRounds = 5; private static SemaphoreSlim _pingTurn = new(0); private static SemaphoreSlim _pongTurn = new(0); private static void Part6_PingPong() { Header("Part 6 — Ping-pong"); // TODO 6.2: Initialise the two semaphores so that Ping serves first. // Which one needs 1 permit, which one 0? _pingTurn = new SemaphoreSlim(0); _pongTurn = new SemaphoreSlim(0); var ping = new Thread(PingPlayer); var pong = new Thread(PongPlayer); ping.Start(); pong.Start(); ping.Join(); pong.Join(); Console.WriteLine("Game over."); } // TODO 6.1: Run Part 6 a few times BEFORE changing anything below. // Does the output look like a ping-pong game? private static void PingPlayer() { for (int i = 1; i <= PingPongRounds; i++) { // TODO 6.3: Wait until it is Ping's turn. Thread.Sleep(Random.Shared.Next(50, 300)); // thinking... Console.WriteLine($" [{Tid}] Ping ({i})"); // TODO 6.3: Hand the turn over to Pong. } } private static void PongPlayer() { for (int i = 1; i <= PingPongRounds; i++) { // TODO 6.3: Wait until it is Pong's turn. Thread.Sleep(Random.Shared.Next(50, 300)); // thinking... Console.WriteLine($" [{Tid}] Pong ({i})"); // TODO 6.3: Hand the turn over to Ping. } } // TODO 6.4 (bonus): Add a third player "Pang" so that the order becomes // Ping -> Pong -> Pang -> Ping -> ... How many semaphores // do you need?}