Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Expanded test coverage: cross-platform FP control-state preservation, nested
fibers, deep-stack yields, many yields, local-variable integrity, interleaved
independent fibers, and allocation-failure cleanup.
- `Options` for `Fiber.create` (`stack_size`, `data`), a public `data`
(`?*anyopaque`) field for per-fiber payloads, and `Fiber.reset` to re-arm a
finished fiber for pool reuse without reallocating.

### Changed

- `Fiber.create` now takes an `Options` argument; pass `.{}` for the previous
defaults.

### Fixed

Expand Down
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ fn worker(_: *Fiber) void {
pub fn main() !void {
const allocator = std.heap.page_allocator;

const f = try Fiber.create(allocator, &worker);
const f = try Fiber.create(allocator, &worker, .{});
defer f.destroy();

std.debug.print("main: start\n", .{});
Expand All @@ -92,15 +92,18 @@ The `fiber` module exposes a single stackful fiber type.

| Symbol | Signature | Description |
| --- | --- | --- |
| `Fiber.create` | `(allocator, entry: *const fn (*Fiber) void) !*Fiber` | Allocate a fiber and its stack, ready to run `entry`. Does not start it. |
| `Options` | `struct { stack_size: usize = 64*1024, data: ?*anyopaque = null }` | Options for `create` — stack size and an initial `data` payload. |
| `Fiber.create` | `(allocator, entry, options: Options) !*Fiber` | Allocate a fiber and its stack. `options` sets the stack size and initial `data`. Pass `.{}` for defaults. Does not start it. |
| `Fiber.reset` | `(*Fiber, entry, data: ?*anyopaque) void` | Re-arm a finished (or never-started) fiber with a new entry and data, reusing its stack. The pooling primitive. |
| `data` | `?*anyopaque` field | Per-fiber user payload; set via `Options` or directly, read in the entry via `fiber.data`. |
| `Fiber.destroy` | `(*Fiber) void` | Free the fiber and its stack. |
| `Fiber.resumeFiber` | `(*Fiber) void` | Switch into the fiber. Returns when the fiber yields or finishes. |
| `Fiber.yield` | `() void` | Suspend the current fiber and switch back to its caller. Panics if called outside a fiber. |
| `State` | `enum { ready, running, suspended, done }` | The fiber's lifecycle state, readable via `fiber.state`. |

`yield` is also re-exported at the module root as `@import("fiber").yield`.

Each fiber allocates a fixed **64 KiB** stack at `create` time. `entry` receives its
Each fiber allocates a stack — **64 KiB by default**, configurable via `Options.stack_size` — at `create` time. `entry` receives its
own `*Fiber` so it can reach fiber-local data; returning from `entry` moves the fiber
to `.done`, after which it must not be resumed again.

Expand Down
2 changes: 1 addition & 1 deletion examples/basic.zig
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ fn counter(_: *Fiber) void {
pub fn main(init: std.process.Init) !void {
const gpa = init.gpa;

const f = try Fiber.create(gpa, &counter);
const f = try Fiber.create(gpa, &counter, .{});
defer f.destroy();

std.debug.print("main: created fiber (state={s})\n", .{@tagName(f.state)});
Expand Down
2 changes: 1 addition & 1 deletion examples/scheduler.zig
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ pub fn main(init: std.process.Init) !void {
var created: usize = 0;
errdefer for (fibers[0..created]) |f| f.destroy();
for (&fibers, entries) |*slot, entry| {
slot.* = try Fiber.create(gpa, entry);
slot.* = try Fiber.create(gpa, entry, .{});
created += 1;
}
defer for (fibers) |f| f.destroy();
Expand Down
163 changes: 146 additions & 17 deletions src/fiber.zig
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ const Context = context.Context;

pub const State = enum { ready, running, suspended, done };

/// Default fiber stack size (64 KiB), used when `Options.stack_size` is unset.
pub const default_stack_size = 64 * 1024;

/// Options for `Fiber.create`.
pub const Options = struct {
/// Size in bytes of the stack allocated for the fiber.
stack_size: usize = default_stack_size,
/// Arbitrary user payload stored on the fiber and readable via `fiber.data`.
data: ?*anyopaque = null,
};

threadlocal var current: ?*Fiber = null;
threadlocal var root_context: Context = .{};

Expand All @@ -14,25 +25,42 @@ pub const Fiber = struct {
state: State = .ready,
caller: *Context = undefined,
allocator: std.mem.Allocator,
data: ?*anyopaque = null,

const default_stack_size = 64 * 1024; // 64 KiB

pub fn create(allocator: std.mem.Allocator, entry: *const fn (*Fiber) void) !*Fiber {
pub fn create(
allocator: std.mem.Allocator,
entry: *const fn (*Fiber) void,
options: Options,
) !*Fiber {
const self = try allocator.create(Fiber);
errdefer allocator.destroy(self);

const stack = try allocator.alloc(u8, default_stack_size);
const stack = try allocator.alloc(u8, options.stack_size);
errdefer allocator.free(stack);

self.* = .{
.stack = stack,
.entry = entry,
.allocator = allocator,
.data = options.data,
};
self.setupStack();
return self;
}

/// Re-arm a finished (or never-started) fiber with a new entry and data,
/// reusing the existing stack allocation. This is the pooling primitive:
/// create a pool of fibers once, then `reset` each between jobs instead of
/// reallocating a stack per job. Must not be called on a running or
/// suspended fiber — that would orphan its in-progress frame.
pub fn reset(self: *Fiber, entry: *const fn (*Fiber) void, data: ?*anyopaque) void {
std.debug.assert(self.state == .done or self.state == .ready);
self.entry = entry;
self.data = data;
self.state = .ready;
self.setupStack();
}

pub fn destroy(self: *Fiber) void {
const allocator = self.allocator;
allocator.free(self.stack);
Expand Down Expand Up @@ -92,7 +120,7 @@ test "fiber runs, yields, and completes" {
};
S.ticks = 0;

const f = try Fiber.create(allocator, &S.work);
const f = try Fiber.create(allocator, &S.work, .{});
defer f.destroy();

var resumes: usize = 0;
Expand Down Expand Up @@ -130,7 +158,7 @@ test "xmm6 is preserved across fiber switches" {
};
S.ok = false;

const f = try Fiber.create(allocator, &S.work);
const f = try Fiber.create(allocator, &S.work, .{});
defer f.destroy();

while (f.state != .done) {
Expand Down Expand Up @@ -235,7 +263,7 @@ test "FP control state is preserved across fiber switches" {
const saved_mxcsr = Fp.getMxcsr();
const saved_cw = Fp.getCw();

const f = try Fiber.create(allocator, &S.work);
const f = try Fiber.create(allocator, &S.work, .{});
defer f.destroy();

while (f.state != .done) {
Expand Down Expand Up @@ -281,11 +309,11 @@ test "a fiber can resume another fiber (nesting)" {
};
S.n = 0;

const inner = try Fiber.create(allocator, &S.inner);
const inner = try Fiber.create(allocator, &S.inner, .{});
defer inner.destroy();
S.inner_fiber = inner;

const outer = try Fiber.create(allocator, &S.outer);
const outer = try Fiber.create(allocator, &S.outer, .{});
defer outer.destroy();

while (outer.state != .done) outer.resumeFiber();
Expand Down Expand Up @@ -318,7 +346,7 @@ test "yield works from deep in the call stack" {
S.reached = 0;
S.resumed = 0;

const f = try Fiber.create(allocator, &S.work);
const f = try Fiber.create(allocator, &S.work, .{});
defer f.destroy();

f.resumeFiber(); // descends 100 frames, then yields
Expand Down Expand Up @@ -346,7 +374,7 @@ test "a fiber can yield many times" {
};
S.count = 0;

const f = try Fiber.create(allocator, &S.work);
const f = try Fiber.create(allocator, &S.work, .{});
defer f.destroy();

var resumes: usize = 0;
Expand Down Expand Up @@ -378,7 +406,7 @@ test "local variables survive across yields" {
};
S.result = 0;

const f = try Fiber.create(allocator, &S.work);
const f = try Fiber.create(allocator, &S.work, .{});
defer f.destroy();

while (f.state != .done) f.resumeFiber();
Expand Down Expand Up @@ -420,9 +448,9 @@ test "independent fibers keep separate state when interleaved" {
S.counters = .{ 0, 0, 0 };

const fibers = [_]*Fiber{
try Fiber.create(allocator, &S.w0),
try Fiber.create(allocator, &S.w1),
try Fiber.create(allocator, &S.w2),
try Fiber.create(allocator, &S.w0, .{}),
try Fiber.create(allocator, &S.w1, .{}),
try Fiber.create(allocator, &S.w2, .{}),
};
defer for (fibers) |f| f.destroy();

Expand Down Expand Up @@ -463,7 +491,7 @@ test "create reports OutOfMemory and leaks nothing on allocation failure" {
);
try std.testing.expectError(
error.OutOfMemory,
Fiber.create(failing.allocator(), &S.work),
Fiber.create(failing.allocator(), &S.work, .{}),
);
}

Expand All @@ -476,7 +504,108 @@ test "create reports OutOfMemory and leaks nothing on allocation failure" {
);
try std.testing.expectError(
error.OutOfMemory,
Fiber.create(failing.allocator(), &S.work),
Fiber.create(failing.allocator(), &S.work, .{}),
);
}
}

test "fiber data payload is passed through and readable by the entry" {
const allocator = std.testing.allocator;

const S = struct {
const Ctx = struct { value: u32 };
fn work(f: *Fiber) void {
const ctx: *Ctx = @ptrCast(@alignCast(f.data.?));
ctx.value += 100;
}
};

var ctx = S.Ctx{ .value = 1 };
const f = try Fiber.create(allocator, &S.work, .{ .data = &ctx });
defer f.destroy();

while (f.state != .done) f.resumeFiber();

try std.testing.expectEqual(@as(u32, 101), ctx.value);
}

test "fiber data can be set on the handle after create" {
const allocator = std.testing.allocator;

const S = struct {
const Ctx = struct { value: u32 };
fn work(f: *Fiber) void {
const ctx: *Ctx = @ptrCast(@alignCast(f.data.?));
ctx.value = 42;
}
};

var ctx = S.Ctx{ .value = 0 };
const f = try Fiber.create(allocator, &S.work, .{});
defer f.destroy();
f.data = &ctx; // set after create, before first resume

while (f.state != .done) f.resumeFiber();

try std.testing.expectEqual(@as(u32, 42), ctx.value);
}

test "custom stack size is honored" {
const allocator = std.testing.allocator;

const S = struct {
var ran: bool = false;
fn work(_: *Fiber) void {
Fiber.yield();
ran = true;
}
};
S.ran = false;

const custom = 128 * 1024; // differs from the 64 KiB default
const f = try Fiber.create(allocator, &S.work, .{ .stack_size = custom });
defer f.destroy();

try std.testing.expectEqual(@as(usize, custom), f.stack.len);

while (f.state != .done) f.resumeFiber();
try std.testing.expect(S.ran);
}

test "reset re-arms a finished fiber for reuse without reallocating" {
const allocator = std.testing.allocator;

const S = struct {
var first_ran: u32 = 0;
fn first(_: *Fiber) void {
first_ran += 1;
}
fn second(f: *Fiber) void {
const n: *u32 = @ptrCast(@alignCast(f.data.?));
n.* += 10;
Fiber.yield(); // suspend/resume on the re-armed (reused) stack
n.* += 100;
}
};
S.first_ran = 0;

const f = try Fiber.create(allocator, &S.first, .{});
defer f.destroy();

const stack_ptr = f.stack.ptr;
const stack_len = f.stack.len;

while (f.state != .done) f.resumeFiber();
try std.testing.expectEqual(@as(u32, 1), S.first_ran);
try std.testing.expectEqual(State.done, f.state);

// Re-arm the same fiber with a new entry + data; same stack buffer.
var counter: u32 = 5;
f.reset(&S.second, &counter);
try std.testing.expectEqual(State.ready, f.state);
try std.testing.expectEqual(stack_ptr, f.stack.ptr); // no realloc
try std.testing.expectEqual(stack_len, f.stack.len);

while (f.state != .done) f.resumeFiber();
try std.testing.expectEqual(@as(u32, 115), counter);
}
Loading