You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Driver::new does this for handles: Table<Resource> and again for TimerQueue::new(config.max_handles); the IOCP backend adds resources, workers and watch::entries, each a Vec of max_handlesOptions. So the cost of a loop is proportional to the largest number of handles it might ever hold, paid whether or not it holds any.
Why this is now load-bearing
Two consequences, both measured on a real consumer (Perry):
It forces the ceiling to stay small, and the ceiling is a hard limit. Perry's net_config() uses max_handles: 4096; a connection costs two handles, so a turnloop-backed HTTP server refuses the 2,049th concurrent connection. Sequential connects stop at exactly 2,048, so it is not a backlog effect. On the same machine the pre-migration tokio path reached 10,000 without difficulty. Filed as turnloop servers stop accepting at 2,048 concurrent connections (net_config max_handles is hardcoded) perry#10351.
It makes a loop per agent expensive. Perry is moving to one loop per JS agent, which is what this crate's own DESIGN describes (§7 per-agent timers, detach/attach between loops). perry/thread's parallelMap/parallelFilter start threads per call, so a 64-way map over data that touches no I/O at all would preallocate 64 full tables. Perry's baseline RSS on an HTTP fixture is already 41 MB against tokio's 20 MB.
Raising max_handles fixes (1) and makes (2) worse by the same factor. That is the whole difficulty.
Proposal: page the table
Make max_handles mean what it says — a ceiling — and allocate slots in pages on demand.
Slots are addressed by index (key as u32 as usize), and generations already guard reuse, so adding a page never moves an existing entry and invalidates no handle. That is what makes this safe to do without touching the public API.
Allocate page 0 at construction; add a page when the free list empties and the ceiling allows. Never shrink below one page (or shrink only on an explicit call, if it is wanted at all).
An idle loop then costs one page regardless of its ceiling, so a large default ceiling becomes affordable and a per-agent loop becomes cheap.
The same treatment is wanted for TimerQueue and for the three IOCP Vecs, which are sized the same way.
There is prior art for exactly this shape in the consumer: Perry replaced a flat shape table with a paged id-indexed slab (32-byte records) for the same reason, and kept ids stable across growth.
Two things that belong with it
Reaching the ceiling should be backpressure, not refusal. Today the handle after the last one is a flat error, which a server surfaces as a connection refusal. A full table should behave like a full accept queue.
The zero-steady-state-allocation gates must still hold. Page allocation happens on growth, not in the steady state, so a test should pin that a loop at its high-water mark allocates nothing per turn.
#43 is "a loop's Config cannot grow in place", which today forces a host to size a loop correctly at construction or recreate it and lose in-flight completions. Paging the tables removes the practical half of that: the number a host has to guess stops mattering, because the ceiling costs nothing until it is used. #43 stays open for the rest of Config (pooled_buffers, events_per_turn), which are separate questions.
Config::max_handlesreads like a ceiling and behaves like a preallocation.Table::newbuilds every slot and the whole free list up front:Driver::newdoes this forhandles: Table<Resource>and again forTimerQueue::new(config.max_handles); the IOCP backend addsresources,workersandwatch::entries, each aVecofmax_handlesOptions. So the cost of a loop is proportional to the largest number of handles it might ever hold, paid whether or not it holds any.Why this is now load-bearing
Two consequences, both measured on a real consumer (Perry):
net_config()usesmax_handles: 4096; a connection costs two handles, so a turnloop-backed HTTP server refuses the 2,049th concurrent connection. Sequential connects stop at exactly 2,048, so it is not a backlog effect. On the same machine the pre-migration tokio path reached 10,000 without difficulty. Filed as turnloop servers stop accepting at 2,048 concurrent connections (net_config max_handles is hardcoded) perry#10351.detach/attachbetween loops).perry/thread'sparallelMap/parallelFilterstart threads per call, so a 64-way map over data that touches no I/O at all would preallocate 64 full tables. Perry's baseline RSS on an HTTP fixture is already 41 MB against tokio's 20 MB.Raising
max_handlesfixes (1) and makes (2) worse by the same factor. That is the whole difficulty.Proposal: page the table
Make
max_handlesmean what it says — a ceiling — and allocate slots in pages on demand.key as u32 as usize), and generations already guard reuse, so adding a page never moves an existing entry and invalidates no handle. That is what makes this safe to do without touching the public API.TimerQueueand for the three IOCPVecs, which are sized the same way.There is prior art for exactly this shape in the consumer: Perry replaced a flat shape table with a paged id-indexed slab (32-byte records) for the same reason, and kept ids stable across growth.
Two things that belong with it
Relationship to #43
#43 is "a loop's
Configcannot grow in place", which today forces a host to size a loop correctly at construction or recreate it and lose in-flight completions. Paging the tables removes the practical half of that: the number a host has to guess stops mattering, because the ceiling costs nothing until it is used. #43 stays open for the rest ofConfig(pooled_buffers,events_per_turn), which are separate questions.