authorgravatar for andrew@ziglang.orgAndrew Kelley <andrew@ziglang.org> 2025-11-21 12:02:59-08:00
committergravatar for andrew@ziglang.orgAndrew Kelley <andrew@ziglang.org> 2025-11-21 19:54:41-08:00
logcf744aa182cf3a7c4738c43f5b1fcf6c280aa74f
treed8703b12d47b93216a8128c846a8cb33810bea8a
parent13b537d77c07101a9dcb41e0839df1213ca8dd1d

std.Io.Threaded: slightly different semantics

while still preserving the guarantee about async() being assigned a unit of concurrency (or immediately running the task), this change: * retains the error from calling getCpuCount() * spawns all threads in detached mode, using WaitGroup to join them * treats all workers the same regardless of whether they are processing concurrent or async tasks. one thread pool does all the work, while respecting async and concurrent limits.

3 files changed, 90 insertions(+), 100 deletions(-)

lib/std/Io/Threaded.zig+84-98
......@@ -22,12 +22,30 @@ mutex: std.Thread.Mutex = .{},
2222cond: std.Thread.Condition = .{},
2323run_queue: std.SinglyLinkedList = .{},
2424join_requested: bool = false,
25threads: std.ArrayList(std.Thread),
2625stack_size: usize,
27cpu_count: usize, // 0 means no limit
28concurrency_limit: usize, // 0 means no limit
29available_thread_count: usize = 0,
30one_shot_thread_count: usize = 0,
26/// All threads are spawned detached; this is how we wait until they all exit.
27wait_group: std.Thread.WaitGroup = .{},
28/// Maximum thread pool size (excluding main thread) when dispatching async
29/// tasks. Until this limit, calls to `Io.async` when all threads are busy will
30/// cause a new thread to be spawned and permanently added to the pool. After
31/// this limit, calls to `Io.async` when all threads are busy run the task
32/// immediately.
33///
34/// Defaults to a number equal to logical CPU cores.
35async_limit: Io.Limit,
36/// Maximum thread pool size (excluding main thread) for dispatching concurrent
37/// tasks. Until this limit, calls to `Io.concurrent` will increase the thread
38/// pool size.
39///
40/// concurrent tasks. After this number, calls to `Io.concurrent` return
41/// `error.ConcurrencyUnavailable`.
42concurrent_limit: Io.Limit = .unlimited,
43/// Error from calling `std.Thread.getCpuCount` in `init`.
44cpu_count_error: ?std.Thread.CpuCountError,
45/// Number of threads that are unavailable to take tasks. To calculate
46/// available count, subtract this from either `async_limit` or
47/// `concurrent_limit`.
48busy_count: usize = 0,
3149
3250wsa: if (is_windows) Wsa else struct {} = .{},
3351
......@@ -103,19 +121,18 @@ pub fn init(
103121) Threaded {
104122 if (builtin.single_threaded) return .init_single_threaded;
105123
124 const cpu_count = std.Thread.getCpuCount();
125
106126 var t: Threaded = .{
107127 .allocator = gpa,
108 .threads = .empty,
109128 .stack_size = std.Thread.SpawnConfig.default_stack_size,
110 .cpu_count = std.Thread.getCpuCount() catch 0,
111 .concurrency_limit = 0,
129 .async_limit = if (cpu_count) |n| .limited(n - 1) else |_| .nothing,
130 .cpu_count_error = if (cpu_count) |_| null else |e| e,
112131 .old_sig_io = undefined,
113132 .old_sig_pipe = undefined,
114133 .have_signal_handler = false,
115134 };
116135
117 t.threads.ensureTotalCapacity(gpa, t.cpu_count) catch {};
118
119136 if (posix.Sigaction != void) {
120137 // This causes sending `posix.SIG.IO` to thread to interrupt blocking
121138 // syscalls, returning `posix.E.INTR`.
......@@ -140,19 +157,17 @@ pub fn init(
140157/// * `deinit` is safe, but unnecessary to call.
141158pub const init_single_threaded: Threaded = .{
142159 .allocator = .failing,
143 .threads = .empty,
144160 .stack_size = std.Thread.SpawnConfig.default_stack_size,
145 .cpu_count = 1,
146 .concurrency_limit = 0,
161 .async_limit = .nothing,
162 .cpu_count_error = null,
163 .concurrent_limit = .nothing,
147164 .old_sig_io = undefined,
148165 .old_sig_pipe = undefined,
149166 .have_signal_handler = false,
150167};
151168
152169pub fn deinit(t: *Threaded) void {
153 const gpa = t.allocator;
154170 t.join();
155 t.threads.deinit(gpa);
156171 if (is_windows and t.wsa.status == .initialized) {
157172 if (ws2_32.WSACleanup() != 0) recoverableOsBugDetected();
158173 }
......@@ -171,10 +186,12 @@ fn join(t: *Threaded) void {
171186 t.join_requested = true;
172187 }
173188 t.cond.broadcast();
174 for (t.threads.items) |thread| thread.join();
189 t.wait_group.wait();
175190}
176191
177192fn worker(t: *Threaded) void {
193 defer t.wait_group.finish();
194
178195 t.mutex.lock();
179196 defer t.mutex.unlock();
180197
......@@ -184,20 +201,13 @@ fn worker(t: *Threaded) void {
184201 const closure: *Closure = @fieldParentPtr("node", closure_node);
185202 closure.start(closure);
186203 t.mutex.lock();
187 t.available_thread_count += 1;
204 t.busy_count -= 1;
188205 }
189206 if (t.join_requested) break;
190207 t.cond.wait(&t.mutex);
191208 }
192209}
193210
194fn oneShotWorker(t: *Threaded, closure: *Closure) void {
195 closure.start(closure);
196 t.mutex.lock();
197 defer t.mutex.unlock();
198 t.one_shot_thread_count -= 1;
199}
200
201211pub fn io(t: *Threaded) Io {
202212 return .{
203213 .userdata = t,
......@@ -488,7 +498,7 @@ fn async(
488498 start: *const fn (context: *const anyopaque, result: *anyopaque) void,
489499) ?*Io.AnyFuture {
490500 const t: *Threaded = @ptrCast(@alignCast(userdata));
491 if (t.cpu_count == 1 or builtin.single_threaded) {
501 if (builtin.single_threaded or t.async_limit == .nothing) {
492502 start(context.ptr, result.ptr);
493503 return null;
494504 }
......@@ -500,35 +510,29 @@ fn async(
500510
501511 t.mutex.lock();
502512
503 if (t.available_thread_count == 0) {
504 if (t.cpu_count != 0 and t.threads.items.len >= t.cpu_count) {
505 t.mutex.unlock();
506 ac.deinit(gpa);
507 start(context.ptr, result.ptr);
508 return null;
509 }
513 const busy_count = t.busy_count;
510514
511 t.threads.ensureUnusedCapacity(gpa, 1) catch {
512 t.mutex.unlock();
513 ac.deinit(gpa);
514 start(context.ptr, result.ptr);
515 return null;
516 };
515 if (busy_count >= @intFromEnum(t.async_limit)) {
516 t.mutex.unlock();
517 ac.deinit(gpa);
518 start(context.ptr, result.ptr);
519 return null;
520 }
517521
518 const thread = std.Thread.spawn(
519 .{ .stack_size = t.stack_size },
520 worker,
521 .{t},
522 ) catch {
522 t.busy_count = busy_count + 1;
523
524 const pool_size = t.wait_group.value();
525 if (pool_size - busy_count == 0) {
526 t.wait_group.start();
527 const thread = std.Thread.spawn(.{ .stack_size = t.stack_size }, worker, .{t}) catch {
528 t.wait_group.finish();
529 t.busy_count = busy_count;
523530 t.mutex.unlock();
524531 ac.deinit(gpa);
525532 start(context.ptr, result.ptr);
526533 return null;
527534 };
528
529 t.threads.appendAssumeCapacity(thread);
530 } else {
531 t.available_thread_count -= 1;
535 thread.detach();
532536 }
533537
534538 t.run_queue.prepend(&ac.closure.node);
......@@ -550,47 +554,33 @@ fn concurrent(
550554 const t: *Threaded = @ptrCast(@alignCast(userdata));
551555
552556 const gpa = t.allocator;
553 const ac = AsyncClosure.init(gpa, result_len, result_alignment, context, context_alignment, start) catch {
557 const ac = AsyncClosure.init(gpa, result_len, result_alignment, context, context_alignment, start) catch
554558 return error.ConcurrencyUnavailable;
555 };
556559 errdefer ac.deinit(gpa);
557560
558561 t.mutex.lock();
559562 defer t.mutex.unlock();
560563
561 // If there's an avilable thread, use it.
562 if (t.available_thread_count > 0) {
563 t.available_thread_count -= 1;
564 t.run_queue.prepend(&ac.closure.node);
565 t.cond.signal();
566 return @ptrCast(ac);
567 }
564 const busy_count = t.busy_count;
568565
569 // If we can spawn a normal worker, spawn it and use it.
570 if (t.cpu_count == 0 or t.threads.items.len < t.cpu_count) {
571 t.threads.ensureUnusedCapacity(gpa, 1) catch return error.ConcurrencyUnavailable;
566 if (busy_count >= @intFromEnum(t.concurrent_limit))
567 return error.ConcurrencyUnavailable;
568
569 t.busy_count = busy_count + 1;
570 errdefer t.busy_count = busy_count;
571
572 const pool_size = t.wait_group.value();
573 if (pool_size - busy_count == 0) {
574 t.wait_group.start();
575 errdefer t.wait_group.finish();
572576
573577 const thread = std.Thread.spawn(.{ .stack_size = t.stack_size }, worker, .{t}) catch
574578 return error.ConcurrencyUnavailable;
575
576 t.threads.appendAssumeCapacity(thread);
577 t.run_queue.prepend(&ac.closure.node);
578 t.cond.signal();
579 return @ptrCast(ac);
579 thread.detach();
580580 }
581581
582 // If we have a concurrencty limit and we havent' hit it yet,
583 // spawn a new one-shot thread.
584 if (t.concurrency_limit != 0 and t.one_shot_thread_count >= t.concurrency_limit)
585 return error.ConcurrencyUnavailable;
586
587 t.one_shot_thread_count += 1;
588 errdefer t.one_shot_thread_count -= 1;
589
590 const thread = std.Thread.spawn(.{ .stack_size = t.stack_size }, oneShotWorker, .{ t, &ac.closure }) catch
591 return error.ConcurrencyUnavailable;
592 thread.detach();
593
582 t.run_queue.prepend(&ac.closure.node);
583 t.cond.signal();
594584 return @ptrCast(ac);
595585}
596586
......@@ -684,41 +674,37 @@ fn groupAsync(
684674 context_alignment: std.mem.Alignment,
685675 start: *const fn (*Io.Group, context: *const anyopaque) void,
686676) void {
687 if (builtin.single_threaded) return start(group, context.ptr);
688
689677 const t: *Threaded = @ptrCast(@alignCast(userdata));
678 if (builtin.single_threaded or t.async_limit == .nothing)
679 return start(group, context.ptr);
680
690681 const gpa = t.allocator;
691682 const gc = GroupClosure.init(gpa, t, group, context, context_alignment, start) catch
692683 return start(group, context.ptr);
693684
694685 t.mutex.lock();
695686
696 if (t.available_thread_count == 0) {
697 if (t.cpu_count != 0 and t.threads.items.len >= t.cpu_count) {
698 t.mutex.unlock();
699 gc.deinit(gpa);
700 return start(group, context.ptr);
701 }
687 const busy_count = t.busy_count;
702688
703 t.threads.ensureUnusedCapacity(gpa, 1) catch {
704 t.mutex.unlock();
705 gc.deinit(gpa);
706 return start(group, context.ptr);
707 };
689 if (busy_count >= @intFromEnum(t.async_limit)) {
690 t.mutex.unlock();
691 gc.deinit(gpa);
692 return start(group, context.ptr);
693 }
708694
709 const thread = std.Thread.spawn(
710 .{ .stack_size = t.stack_size },
711 worker,
712 .{t},
713 ) catch {
695 t.busy_count = busy_count + 1;
696
697 const pool_size = t.wait_group.value();
698 if (pool_size - busy_count == 0) {
699 t.wait_group.start();
700 const thread = std.Thread.spawn(.{ .stack_size = t.stack_size }, worker, .{t}) catch {
701 t.wait_group.finish();
702 t.busy_count = busy_count;
714703 t.mutex.unlock();
715704 gc.deinit(gpa);
716705 return start(group, context.ptr);
717706 };
718
719 t.threads.appendAssumeCapacity(thread);
720 } else {
721 t.available_thread_count -= 1;
707 thread.detach();
722708 }
723709
724710 // Append to the group linked list inside the mutex to make `Io.Group.async` thread-safe.
lib/std/Io/Threaded/test.zig+2-2
......@@ -10,7 +10,7 @@ test "concurrent vs main prevents deadlock via oversubscription" {
1010 defer threaded.deinit();
1111 const io = threaded.io();
1212
13 threaded.cpu_count = 1;
13 threaded.async_limit = .nothing;
1414
1515 var queue: Io.Queue(u8) = .init(&.{});
1616
......@@ -38,7 +38,7 @@ test "concurrent vs concurrent prevents deadlock via oversubscription" {
3838 defer threaded.deinit();
3939 const io = threaded.io();
4040
41 threaded.cpu_count = 1;
41 threaded.async_limit = .nothing;
4242
4343 var queue: Io.Queue(u8) = .init(&.{});
4444
lib/std/Thread/WaitGroup.zig+4
......@@ -60,6 +60,10 @@ pub fn isDone(wg: *WaitGroup) bool {
6060 return (state / one_pending) == 0;
6161}
6262
63pub fn value(wg: *WaitGroup) usize {
64 return wg.state.load(.monotonic) / one_pending;
65}
66
6367// Spawns a new thread for the task. This is appropriate when the callee
6468// delegates all work.
6569pub fn spawnManager(