Start with an exact toolchain.
Flyology is an Alire crate and requires Alire 2.1 or newer. The crate accepts GNAT 13 through 16 as a dependency range, but runtime preparation supports only the host and release pairs listed below.
- macOS / AArch64
- 13.2.2, 14.1.3, 14.2.1, and 16.1.0
- Linux / AArch64
- 16.1.0
- Linux / x86-64
- 13.2.2, 14.1.3, 14.2.1, 15.1.2, 15.3.1, and 16.1.0
Build the public library.
Clone the repository, let Alire select a supported toolchain, and build the crate. Applications must link against a prepared runtime; stock GNARL alone is not sufficient.
git clone https://github.com/flyology-ada/flyology.git
cd flyology
alr build
Prepare a version-matched runtime.
The preparation script copies the active compiler's installed runtime sources into ignored build output, applies the exact patch family, compiles Flyology policy into the runtime, and builds a static RTS.
./scripts/prepare-rts.sh
The default is native. This is the compatibility configuration: undesignated tasks stay on stock pthread-backed execution, and no event machinery starts until a lightweight task is activated.
FLYOLOGY_DEFAULT=lightweight \
FLYOLOGY_LOOP_POOL_SIZE=4 \
./scripts/prepare-rts.sh
FLYOLOGY_RTS_DIR- Writes the generated runtime outside the default
build/rts. Give each materially different configuration its own directory when comparing builds. FLYOLOGY_DEFAULT- Selects
nativeorlightweightfor tasks that do not carry an explicit designation. FLYOLOGY_LOOP_POOL_SIZE- Selects
1 .. 128shared groups for automatic round-robin placement. Groups and their pthreads still start lazily. - Loop placement
FLYOLOGY_LOOP_PLACEMENTandFLYOLOGY_LOOP_PLACEMENT_MAPrequest Linux strict logical-CPU binding or a supported Darwin advisory tag. This places loop pthreads; it does not change task-to-group assignment.
Designate one ordinary Ada task.
Use GNAT Task_Info to select the execution model at task creation. The obsolete-feature warning is deliberate; Flyology projects retain other warnings while using -gnatwJ for this mechanism.
with Flyology;
package Worker is
task Connection is
pragma Task_Info (Flyology.Lightweight_Task);
end Connection;
end Worker;
Explicit Flyology.Native_Task and Flyology.Lightweight_Task values override the prepared project default. Flyology.Project_Default requests that default explicitly, which is useful on discriminated task types.
alr exec -- gprbuild \
--RTS="$PWD/build/rts" \
-P path/to/application.gpr
Size each task stack deliberately.
Ada's Storage_Size is the stack-size control for both native and lightweight tasks. Put the pragma in the task or task-type declaration, use a named constant so the choice is reviewable, and size for the deepest real call path: local objects, recursion, exception propagation, and foreign calls all consume stack.
Worker_Stack_Size : constant := 64 * 1_024;
task type Worker (Kind : Flyology.Execution_Model) is
pragma Task_Info (Kind);
pragma Storage_Size (Worker_Stack_Size);
end Worker;
The requested value is not the task's complete memory footprint or necessarily the final usable mapping size. GNARL adds its conservative alternate-signal-stack allowance, and Flyology rounds a lightweight stack to the host page size. Lightweight stacks are placed in guarded arenas; each slot carries at least 64 KiB of inaccessible guard address space. Native tasks instead retain the host pthread implementation and its platform minimums.
Flyology.Observability.Stack_Pool reports the effective lightweight result. Live_Usable_Bytes is the aggregate usable size after GNARL adjustment and page rounding; Reserved_Bytes is the exact virtual address space held by live arenas, including guards. Reserved address space is not resident memory, so measure process RSS separately.
declare
Pool : constant Flyology.Observability.Stack_Pool_Snapshot :=
Flyology.Observability.Stack_Pool;
begin
Ada.Text_IO.Put_Line
("live=" & Pool.Live_Stacks'Image
& " usable bytes=" & Pool.Live_Usable_Bytes'Image
& " reserved bytes=" & Pool.Reserved_Bytes'Image);
end;
If every live lightweight task in a sample uses the same declaration, divide Live_Usable_Bytes by Live_Stacks to obtain its effective usable size. With mixed task sizes that quotient is only an average. The current structured-server generic owns its handler task type and uses the compiler/runtime default stack size; Storage_Size applies directly to task types declared by the application.
Choose an execution group.
A lightweight task without a specific Ada CPU is placed into the configured shared pool by deterministic round robin. A CPU aspect selects an exact Flyology execution group instead.
task Parser with CPU => 1 is
pragma Task_Info (Flyology.Lightweight_Task);
end Parser;
task Writer with CPU => 2 is
pragma Task_Info (Flyology.Lightweight_Task);
end Writer;
Tasks in one group share a stable loop pthread and schedule cooperatively. Separate groups use separate pthreads and may execute in parallel. Shared group identifiers are 0 .. 127; dedicated group identifiers are 128 .. 255.
Migration is an explicit safe point
A lightweight task can move to another group without changing Ada task identity, stack, locals, or exception state. Use a scoped thread pin while holding thread-affine foreign state, and use a dedicated group when exclusive pthread ownership is also required.
declare
package Groups renames Flyology.Execution_Groups;
Home : constant Groups.Group_Id := Groups.Current;
begin
Groups.Migrate (Groups.For_CPU (2));
Handle_Group_Owned_State;
Groups.Migrate (Home);
end;
Use synchronous I/O calls.
Flyology I/O packages expose normal procedure and function calls from either lane. If a lightweight call would block, only that task waits; the loop thread remains available. A native call may block only its pthread.
Flyology.IO.Timers.Sleep_For (0.050);
Flyology.IO.Sockets.Receive
(Socket, Buffer, Last, Timeout => 1.0);
Flyology.IO.Files.Read_At
(File, Offset => 0, Item => Buffer, Last => Last);
Use the ownership-aware connection packages when descriptor lifetime, cancellation, and close races matter. Raw descriptor waits deliberately leave lifetime serialization to the caller.
Set resource budgets before load testing.
Flyology does not turn one number into a complete capacity plan. Bound the resources the application owns, set latency limits at operation boundaries, and then measure them together under representative peak load.
- Concurrency
- Use a structured server or
Connections.Servercapacity to cap simultaneously owned connections. A structured server creates exactlyCapacityhandler tasks for the duration ofServe, including while idle. - Stack memory
- For homogeneous lightweight tasks, plan usable stack bytes as peak live tasks multiplied by the observed effective bytes per task. Read
Reserved_Bytesfor guarded virtual address space and measure RSS for resident memory. - Parallelism
FLYOLOGY_LOOP_POOL_SIZEbounds automatic shared groups, not task count. Start near the CPU parallelism the workload can use and compare measurements; each created group adds pthread, poller, scheduler, and kernel-queue resources.- Latency
- Use finite I/O deadlines, a bounded shutdown drain, and checkpoints in CPU loops. These are separate limits: cancellation is not a timeout, and a fairness quantum is not a hard execution deadline.
Bound admission, not just the listen backlog
The structured-server capacity is both the maximum active handlers and an eager task-resource budget. Excess work remains in the kernel listen backlog instead of becoming an unbounded user-space task or connection queue.
Handler_Capacity : constant Positive := 256;
Server : aliased HTTP.Server (Capacity => Handler_Capacity);
HTTP.Serve
(Server, Listener, State, Drain_Timeout => 5.0);
Give an operation one meaningful deadline
Receive_Exactly and Send_All apply one timeout across the whole multi-step operation rather than restarting it for every partial transfer. DNS resolution similarly applies its overall timeout across family queries, retry rounds, and transport fallback. Prefer finite deadlines at service boundaries; reserve Infinite for scopes whose shutdown and cancellation path is otherwise explicit.
Add checkpoints to bounded units of CPU work
A CPU-bound lightweight task can monopolize its group until it reaches a runtime suspension, executes delay 0.0, or calls a fairness checkpoint. Priorities choose among ready fibers within a group, but they do not preempt arbitrary lightweight instructions. A new Yield_Budget defaults to a 2 ms quantum; configure it when the service needs a different cooperative slice.
Budget : Flyology.Fairness.Yield_Budget;
Budget.Configure (Ada.Real_Time.Microseconds (250));
while More_Work loop
Process_One_Item;
Budget.Checkpoint;
end loop;
Choose a quantum below the same-group scheduling delay the application can tolerate, then measure it. The value is elapsed wall-clock time between eligible yields, not reserved CPU time. Put Checkpoint after a bounded unit of work: if Process_One_Item itself can run for 50 ms, a 250 µs quantum cannot take effect until that call returns. Very small quanta also increase clock reads and scheduler transfers.
Observe, compare, and adjust one knob.
Take snapshots at idle, representative load, and recovery. Group snapshots are coherent but briefly hold scheduler locks and cost O(group members), so sample periodically rather than per request. Lifetime counters wrap and do not reset; compare two samples instead of treating one cumulative value as a rate.
- Healthy waiting
- A high
Waitingcount accompanied by timer, descriptor, interrupt, or file waits normally means concurrency is parked in the intended kernel-backed path. - Runnable pressure
- A persistently high
Readycount means runnable work is sharing the group. Compare dispatch progress, CPU use, checkpoint placement, and the result of adding a group. - Possible stall
- Ready or running work without changes in dispatch or polling counters is a loop-lag signal.
Stall_Watchdogscan sample this condition from a native monitor task; it diagnoses but does not preempt. - File pressure
Pending_File_Submissionsshows file operations waiting behind the bounded kernel completion queue. It is backpressure, not a hidden worker-thread queue.- Stack pressure
- Track live stacks, effective usable bytes, guarded virtual reservation, process RSS, and task-creation failures together. None of those values alone is a complete memory limit.
./scripts/showcases.sh
# After the showcase build, choose smaller local counts if needed:
./showcases/run_connection_density.sh 1000 10000
./showcases/run_event_loop_pool.sh 1024 20 4
The density run compares lightweight and native tasks with the same requested stack size and reports RSS, virtual address space, thread count, and lightweight stack-pool counters. The pool run compares one event loop with a configured pool under repeated readiness waves. Treat either result as a measurement of that host and workload, not a universal sizing constant.
Run the checks for your changes.
The repository scripts are authoritative for behavioral tests, proofs, documentation, stress campaigns, and showcases.
./scripts/test.sh
./scripts/prove.sh
./scripts/docs.sh
./scripts/showcases.sh
test.shruns both project defaults, runtime-preparation validation, and an external consumer.stress.shruns bounded deterministic concurrency and fault campaigns.prove.shruns the SPARK policy proof suite.docs.shgenerates the public GNATdoc reference with undocumented-entity warnings enabled.
Know when to stay native.
Flyology is experimental. Keep a task native when its correctness depends on kernel real-time scheduling, forced preemption, stock GNARL CPU affinity, unpredictable foreign blocking, or a host and compiler combination outside the verified patch matrix.
- Use lightweight
- High I/O concurrency with cooperative work, explicit task-aware waits, and ordinary Ada task semantics.
- Use native
- Blocking foreign libraries, kernel scheduling policies, independent pthread execution, or unmodified compatibility behavior.
- Use dedicated
- A lightweight continuation must temporarily own a stable loop pthread exclusively.