Powered by AppSignal & Oban Pro

Module 4 – Scheduling & Concurrency - Exercises

livebooks/module-4-exercises.livemd

Module 4 – Scheduling & Concurrency - Exercises

Mix.install([{:kino, "~> 0.17.0"}])
Code.require_file("quiz.ex", __DIR__)
Code.require_file("process_viz.ex", __DIR__)

Introduction

Welcome to the hands-on exercises for Module 4 – Scheduling & Concurrency!

Each section has runnable code cells. Execute them, experiment, and observe what happens!

Scheduler Configuration and Utilization

Exercise 1: Scheduler Setup Analysis

Goal: Understand your system’s scheduler configuration and baseline utilization

Task 1.1: Inspect Scheduler Configuration
% Get basic scheduler info
Schedulers = erlang:system_info(schedulers),
Online = erlang:system_info(schedulers_online),
LogicalProcs = erlang:system_info(logical_processors),

io:format("Schedulers: ~p, Online: ~p, Logical CPUs: ~p~n",
          [Schedulers, Online, LogicalProcs]),

% Check dirty schedulers
DirtyCPU = erlang:system_info(dirty_cpu_schedulers),
DirtyIO = erlang:system_info(dirty_io_schedulers),
io:format("Dirty CPU: ~p, Dirty I/O: ~p~n", [DirtyCPU, DirtyIO]).
Task 1.2: Measure Baseline Scheduler Utilization
% Enable wall time tracking
erlang:system_flag(scheduler_wall_time, true),
erlang:statistics(scheduler_wall_time),  % Reset baseline

timer:sleep(5000),  % Wait 5 seconds

% Get utilization
WallTime = erlang:statistics(scheduler_wall_time),

lists:foreach(fun({SchedId, Active, Total}) ->
    Util = case Total of
        0 -> 0.0;
        _ -> (Active / Total) * 100
    end,
    io:format("Scheduler ~p: ~.1f% utilized~n", [SchedId, Util])
end, WallTime).

Observe: Baseline utilization when system is idle vs under load.

Discussion: Why does BEAM use one scheduler per core? What happens if you have more schedulers than cores?

Process Priority and Run Queues

Exercise 2: Priority Scheduling Behavior

Goal: Observe how process priority affects scheduling order

Task 2.1: Check Current Run Queue State
% Get run queue info
TotalRunnable = erlang:statistics(run_queue),
PerScheduler = erlang:statistics(run_queues),

io:format("Total runnable processes: ~p~n", [TotalRunnable]),
io:format("Per-scheduler queues: ~p~n", [PerScheduler]).
Task 2.2: Create Competing Processes with Different Priorities
% Low priority process (with termination)
LowPid = spawn(fun() ->
    process_flag(priority, low),
    Loop = fun LoopFun(Count) ->
        lists:sum(lists:seq(1, 1000)),
        io:format("LOW process iteration~n"),
        timer:sleep(100),
        receive
            stop -> ok
        after 0 ->
            if Count < 30 -> LoopFun(Count + 1);  % Stop after 30 iterations
               true -> ok
            end
        end
    end,
    Loop(0)
end),

% Normal priority process (with termination)
NormalPid = spawn(fun() ->
    process_flag(priority, normal),
    Loop = fun LoopFun(Count) ->
        lists:sum(lists:seq(1, 1000)),
        io:format("NORMAL process iteration~n"),
        timer:sleep(100),
        receive
            stop -> ok
        after 0 ->
            if Count < 30 -> LoopFun(Count + 1);  % Stop after 30 iterations
               true -> ok
            end
        end
    end,
    Loop(0)
end),

% High priority process (with termination)
HighPid = spawn(fun() ->
    process_flag(priority, high),
    Loop = fun LoopFun(Count) ->
        lists:sum(lists:seq(1, 1000)),
        io:format("HIGH process iteration~n"),
        timer:sleep(100),
        receive
            stop -> ok
        after 0 ->
            if Count < 30 -> LoopFun(Count + 1);  % Stop after 30 iterations
               true -> ok
            end
        end
    end,
    Loop(0)
end),

timer:sleep(3000),

% Check their progress (reduction counts)
{_, LowRed} = process_info(LowPid, reductions),
{_, NormalRed} = process_info(NormalPid, reductions),
{_, HighRed} = process_info(HighPid, reductions),

io:format("Reductions - Low: ~p, Normal: ~p, High: ~p~n",
          [LowRed, NormalRed, HighRed]),

% Clean up - send stop messages
LowPid ! stop,
NormalPid ! stop,
HighPid ! stop.

Observe: High priority process gets more CPU time. Output order favors high priority.

Discussion: When should you use non-normal priorities? What are the risks of too many high-priority processes?

Reduction Counting and Preemption

Exercise 3: Measuring Reduction Costs

Goal: Understand which operations consume reductions and trigger preemption

Task 3.1: Measure Operation Costs
MeasureReductions = fun(Name, Operation) ->
    {_, RedBefore} = process_info(self(), reductions),
    Operation(),
    {_, RedAfter} = process_info(self(), reductions),
    Cost = RedAfter - RedBefore,
    io:format("~s: ~p reductions~n", [Name, Cost])
end,

MeasureReductions("Function call", fun() -> erlang:system_time() end),
MeasureReductions("Message send", fun() -> self() ! test end),
MeasureReductions("List creation", fun() -> lists:seq(1, 100) end),
MeasureReductions("List length", fun() -> length(lists:seq(1, 100)) end),
MeasureReductions("Map lookup", fun() -> maps:get(a, #{a => 1}) end),
MeasureReductions("Process spawn", fun() -> spawn(fun() -> ok end) end).

Observe: Different operations have vastly different reduction costs. length/1 is proportional to list size.

Task 3.2: Observe Preemption in Action
% Process that will use its full reduction budget
PreemptionTest = spawn(fun() ->
    Loop = fun LoopFun(Count) ->
        % Check for stop message
        receive
            stop ->
                io:format("Stopping after ~p iterations~n", [Count]),
                ok
        after 0 ->
            {_, RedBefore} = process_info(self(), reductions),

            % Do work until preempted
            lists:sum(lists:seq(1, 10000)),

            {_, RedAfter} = process_info(self(), reductions),
            Used = RedAfter - RedBefore,

            if Used > 2000 ->
                io:format("Used ~p reductions (over budget, was preempted)~n", [Used]),
                LoopFun(Count + 1);
            true ->
                io:format("Used ~p reductions (still within budget)~n", [Used]),
                LoopFun(Count + 1)
            end
        end
    end,
    Loop(0)
end),

timer:sleep(2000),
PreemptionTest ! stop.

Observe: Processes get preempted after consuming their reduction budget (typically 2000-4000).

Discussion: Why reductions instead of time-based preemption? How does this affect responsiveness?

Process State Transitions

Exercise 4: Observing Process States

Goal: Watch processes transition between RUNNING, WAITING, and SUSPENDED states

Task 4.1: Create Process That Shows State Transitions
% Queue monitoring process (renamed to avoid conflicts)
QueueMonitor = spawn(fun() ->
    Loop = fun LoopFun() ->
        try
            RunQueues = erlang:statistics(run_queues),
            TotalRunnable = erlang:statistics(run_queue),

            case TotalRunnable of
                0 -> ok;
                _ -> io:format("Run queues: ~p (total: ~p)~n", [RunQueues, TotalRunnable])
            end
        catch
            _:_ -> ok  % Ignore statistics errors
        end,

        receive
            stop -> ok
        after 50 -> LoopFun()
        end
    end,
    Loop()
end),

% Demo process showing states
DemoPid = spawn(fun() ->
    io:format("Process RUNNING initially~n"),

    % Go to SUSPENDED (waiting for message)
    io:format("Process entering receive (SUSPENDED)~n"),
    receive
        wakeup ->
            io:format("Process RUNNING again (message received)~n"),
            timer:sleep(100)
    end,

    % Another suspend with timeout
    io:format("Process SUSPENDED with timeout~n"),
    receive
        never_comes -> ok
    after 200 ->
        io:format("Process RUNNING (timeout expired)~n")
    end
end),

timer:sleep(50),
DemoPid ! wakeup,

timer:sleep(500),
QueueMonitor ! stop,

% Return ok to avoid pattern match issues
ok.

Observe: Processes only consume scheduler time when RUNNING. Suspended processes wait outside run queues.

Discussion: What makes a process transition from SUSPENDED to RUNNABLE? How does this affect system responsiveness?

Load Balancing and Work Stealing

Exercise 5: Scheduler Load Distribution

Goal: Observe work stealing and load balancing across schedulers

Task 5.1: Create Load Imbalance

Note: If running in LiveBook, this should be in a separate cell from Task 4.1.

% Ultra-lightweight version to avoid LiveBook crashes
% Check initial balance
InitialQueues = erlang:statistics(run_queues),
io:format("Initial queues: ~p~n", [InitialQueues]),

% Create just 5 very lightweight processes
% Use a reference to track completion
Ref = make_ref(),
Parent = self(),

% Spawn minimal workers
WorkerPids = [spawn(fun() ->
    % Absolutely minimal work
    _ = 1 + 1,

    % Report completion
    Parent ! {Ref, done, self()}
end) || _ <- lists:seq(1, 5)],

% Collect completions
[receive
    {Ref, done, _Pid} -> ok
after 100 ->
    ok
end || _ <- WorkerPids],

% Final check
FinalQueues = erlang:statistics(run_queues),
io:format("Final queues: ~p~n", [FinalQueues]),

ok.

Observe: Initial burst may create imbalance, but work stealing redistributes load over time.

Task 5.2: Track Scheduler ID for Process Migration
% Process that reports which scheduler it runs on
MigrationTest = spawn(fun() ->
    Loop = fun LoopFun(Count) ->
        SchedId = erlang:system_info(scheduler_id),
        io:format("Iteration ~p on scheduler ~p~n", [Count, SchedId]),

        % Do some work
        lists:sum(lists:seq(1, 5000)),
        timer:sleep(200),

        if Count < 10 ->
            LoopFun(Count + 1);
        true ->
            io:format("Migration test completed~n"),
            ok
        end
    end,
    Loop(1)
end).

Observe: Processes may migrate to different schedulers as load balancing occurs.

Discussion: When does work stealing happen? What are the trade-offs of process migration vs cache locality?

Voluntary Yielding

Exercise 6: Manual Yield Control

Goal: Understand when and how to voluntarily yield control

Task 6.1: Compare Yielding vs Non-Yielding
% Process that never yields manually
NeverYield = spawn(fun() ->
    Loop = fun LoopFun(Iterations) ->
        if Iterations < 1000 ->
            _ = lists:sum(lists:seq(1, 100)),
            LoopFun(Iterations + 1);
        true ->
            io:format("NeverYield completed~n")
        end
    end,
    Loop(0)
end),

% Process that yields periodically
FrequentYield = spawn(fun() ->
    Loop = fun LoopFun(Iterations) ->
        if Iterations < 1000 ->
            _ = lists:sum(lists:seq(1, 100)),
            case Iterations rem 100 of
                0 -> timer:sleep(0);  % Yield every 100 iterations
                _ -> ok
            end,
            LoopFun(Iterations + 1);
        true ->
            io:format("FrequentYield completed~n")
        end
    end,
    Loop(0)
end),

timer:sleep(1000),

% Check their progress
case process_info(NeverYield, reductions) of
    {_, NeverRed} ->
        io:format("NeverYield: ~p reductions~n", [NeverRed]);
    undefined ->
        io:format("NeverYield: completed~n")
end,

case process_info(FrequentYield, reductions) of
    {_, FreqRed} ->
        io:format("FrequentYield: ~p reductions~n", [FreqRed]);
    undefined ->
        io:format("FrequentYield: completed~n")
end.

Observe: Yielding process may appear slower but allows other processes to run.

Discussion: When should you manually yield? What’s the cost? How does this affect system responsiveness?

Module 4 Review

Quiz.render_from_file(__DIR__ <> "/module-4-exercises.livemd", quiz: 1)