iClaustron training for Rust developers · module 2 of 9 · 20 min
Thread architecture
Which threads an iClaustron API node runs, what each one owns, and the path a key read takes through them.
The rule · fixed for the life of the project
The one rule: receive threads receive and route, user threads execute
The reference NDB API
- Its receive thread decodes each signal and updates the transaction.
- Then it wakes the user thread waiting for it.
- With many user threads, that receive thread is the bottleneck.
iClaustron
- A receive thread's work per signal: a table lookup and a copy into a page.
- The user thread that owns the query decodes rows, updates the transaction, runs callbacks.
- That work grows with the application's threads, not with a fixed pool.
One exception, by design: signals about the link itself, such as HEARTBEAT_CONF, run on the receive thread that owns the node.
iclaustron: doc/rust/architecture.html §1 (Threading architecture); doc/rust/02-architecture.md (Threading model); crates/ic_apid/src/thread_conn.rs (module note); rec_thread.rs (Receiver::execute)
The thread map · learn it once, it comes back
The thread map: who sends, who receives, who keeps the links up
Every thread that talks to RonDB holds a connection: its own block number, its own inbox, state no other thread touches.
Requests leave through one send buffer per data node: the user thread writes the socket, the send pool only what came meanwhile.
Replies arrive on the receive thread that owns the node. It reads the block number only and posts one page per inbox.
The heartbeat thread and a connect thread per data node keep the links up. Neither carries a transaction's signals.
An executor is a user thread that runs requests for application threads with no connection of their own.
iclaustron: doc/rust/architecture.html §1 (The threads today); crates/ic_apid/src/apid_global.rs (ApidGlobal::start_with_options); thread_conn.rs (ThreadTable, Router); executor.rs (Executor::start)
On the map · one primary key read
A primary key read, step by step through the threads
User thread 0 reads one row by primary key. Watch which thread does each part of the work.
Define: read_key packs KEY_OP_REQ into the connection's staged buffer for the coordinator's node. No lock, nothing sent.
Send: flags are set in place; thread 0 appends to the node's send buffer and, with no one else writing, writes the socket.
Data node: the coordinator answers KEY_OP_CONF, the node that read the row sends ROW_DATA_REP; both to block 0x8000.
Route: the receive thread that owns the link reads only the block number, 0x8000 + 0, and puts both in thread 0's page.
Post: once a round, that page goes into thread 0's inbox under one short lock. Thread 0 is woken only if it sleeps.
Poll: thread 0 joins any fragments, finds transaction and query by each reply's first word, decodes the row, runs the callback.
iclaustron: crates/ic_apid/src/transaction.rs (read_key, define, send_queries, route_reply); apid_global.rs (NodeShared::send_words); rec_thread.rs (route_signals); thread_conn.rs (post); apid_conn.rs (poll_with_requests)
Thread types · as the global starts them
Thread types: how many of each, what each does and touches
| Thread | How many | What it does | What it touches |
|---|---|---|---|
| User thread | one per connection, up to 4 711 | packs and sends requests, polls its inbox, decodes rows, runs callbacks | its connection, with no lock; a node's send buffer, to append |
| Executor | a user thread; as many as started | runs application threads' requests, many to a send | its connection; the requests in its inbox |
| Receive thread | 1 by default, at most one per data node | reads its nodes' sockets, routes by block number; runs HEARTBEAT_CONF and the node-loss reports | its sockets; each inbox's lock once a round; its nodes' state, as only writer |
| Send pool | 1 | writes what came during another thread's write; ends adaptive-send waits | the nodes' send buffers |
| Heartbeat | 1 | HEARTBEAT_REQ to every node that is up; asks for a silent link to be dropped | node state, read only |
| Connect | one per data node | gets the port, connects, handshakes, hands the link over | the membership word, by compare-and-swap |
iclaustron: doc/rust/architecture.html §1 (thread table); crates/ic_apid/src/apid_global.rs (start_with_options); rec_thread.rs (Receiver::execute); send_pool.rs; heartbeat.rs; connect_thread.rs; executor.rs; crates/ic_port/src/consts.rs (IC_MAX_THREAD_CONNECTIONS)
Starting · from the Rust examples
Connecting starts the library's threads; a connection is one user thread's
let global = ApidGlobal::connect(
connect_string(args),
Some(name),
&GlobalOptions::default(),
)?;
if global.wait_for_all_started(EX_START_WAIT_MS) == 0 {
return Err(IcError::new(err::IC_ERROR_NO_STARTED_DATA_NODE));
}
let conn = global.create_connection()?;
Ok((global, conn))
ApidGlobal::connectstarts the receive threads, the send pool, the heartbeat thread and a connect thread per data node, then returns. Dropping the global stops them all and waits for each.receive_threadsin the options: 1 by default, never more than there are data nodes.- Each connection is one user thread's: block 0x8000 plus its id, at most 4 711 in a process.
iclaustron: examples/rust/lib.rs (connect); crates/ic_apid/src/apid_global.rs (GlobalOptions, ApidGlobal and its Drop, ApidGlobal::connect, start_with_options, create_connection); crates/ic_util/src/threadpool.rs (ThreadPool::stop, Drop); crates/ic_signals/src/blocks.rs (api_block_of_thread)
PART 1
Sending and receiving
One write per data node out, one page per user thread back.
Send path · the thread that sends does the write
The send path: one buffer per data node, and the sender writes it
User thread 1 appends its packed requests to data node 2's send buffer, under the node's mutex.
No one is writing: it sets the writer flag, swaps the waiting buffer for the spare, lets go of the mutex and writes the socket.
User thread 2 appends meanwhile, sees the flag and goes back to its own work: its signals wait for the thread that writes.
Write done, more waiting: thread 1 does not write for others. It hands the node to the send pool, which writes the rest.
iclaustron: crates/ic_apid/src/apid_global.rs (NodeShared::send_words_for_generation, write_out, take_waiting); send_pool.rs (module note, SendPool::ask, run_send_thread); doc/rust/architecture.html §1 (The thread that sends does the write)
Send path · what is held back, and for how long
Adaptive send and acknowledgements: writes that wait for company
Adaptive send
- Without
force, a node's write may wait for the next thread's signals, if no write is under way. - Bounded: 200 µs as built, and only as many sends as it has learned to hold, at most 8.
- The send pool writes them when the wait ends. With
forcethe write happens now.
Commit acknowledgements
- The acknowledgement a commit calls for, COMMIT_MARKER_FREE_ORD, is queued in the connection.
- It leaves with the next send, in the same write as new requests.
- Written alone only after 1 ms, or when nothing is in flight.
A thread sending alone in lock step never sees the count rise. pipeline.rs flushes with force false, finish in lib.rs with true.
iclaustron: crates/ic_apid/src/adaptive_send.rs (AdaptiveSend::decide, adjust); apid_global.rs (send_words); apid_conn.rs (poll_with_requests); transaction.rs (send_commit_ack, flush); examples/rust/pipeline.rs; lib.rs (finish)
Receive path · pages that go round
The receive path: receive pages in, one signal page per user thread
A receive thread reads its nodes' sockets into receive pages of its own, reading again while each read fills the room it had.
The router splits the bytes into signals, unpacks BUNDLE and sorts each by block number, with no lock held.
Small signals are copied into the thread's page; a large ROW_DATA_REP stays in its receive page, sealed until it comes back.
poll gives the emptied pages back and takes the posted ones. Each page is allocated and freed on the thread that owns it.
iclaustron: crates/ic_apid/src/signal_reader.rs (SignalReader, IC_RECEIVE_PAGE_WORDS); rec_thread.rs (read_node, route_signals); signal_page.rs (module note); thread_conn.rs (IC_SPARE_PAGES); apid_global.rs (IC_LARGE_SIGNAL_WORDS, IC_EXTRA_READS); apid_conn.rs (receive_view)
Receive path · posting to an inbox
Inbox posts: one lock per round, a wake-up only for a sleeping thread
One lock a round
The router sorts a round's signals per user thread with no lock held, then posts each page under one short lock: a hundred signals cost one lock.
Woken only asleep
Before it sleeps, the user thread sets the inbox's waiting flag under the lock. A post to a busy thread makes no system call.
Woken after unlock
The wake-up goes after the lock is let go, so the thread never wakes into a held mutex. A wake-up for signals already taken is harmless.
Several receive threads post to one inbox, a ThreadConnection behind an Arc, each under the same lock. conn.poll gives emptied pages back and takes the posted ones in one lock.
iclaustron: crates/ic_apid/src/thread_conn.rs (module note; ThreadConnection::post, exchange_with_requests; Router::post_all); rec_thread.rs (Receiver::round); apid_conn.rs (ApidConnection, poll); doc/rust/architecture.html §1 (The rules that keep it fast)
PART 2
Node state and failure
One writer per node, and a lost link kept apart from a lost node.
Node state · no mutex, one writer
Node state has one writer: the receive thread that owns the node
A node is dealt to one receive thread at start and stays there. It sees the socket close and executes HEARTBEAT_CONF: the only writer of its PublishedNodeState, atomics others read with no lock.
The heartbeat thread reads when the last HEARTBEAT_CONF came. After 4 check intervals without one, it asks the owner to drop the link.
Membership has several writers, so every change is a compare-and-swap: the first evidence wins. A failure report outranks the rest.
iclaustron: crates/ic_apid/src/node_state.rs (module note); apid_global.rs (NodeShared, cas_membership, node_failure_reported, claim_link); heartbeat.rs (keep_time); rec_thread.rs (install, link_lost); doc/rust/02-architecture.md (Node state has one writer)
Node failure · from the threads' side
Link lost or node lost: what a waiting transaction sees
Link lost: our own evidence
- The socket closes, a write fails, or 4 check intervals pass without HEARTBEAT_CONF.
- The owner publishes the link down and bumps a link-event counter; a poll checks it with one load.
- Transactions coordinated over that link end at the next poll with 7135, the outcome unknown.
Node lost: NODE_LOST_REP
- A data node reports the node failed; the link is dropped if it still stood.
- Its transactions wait for the takeover: TAKEOVER_CONF says committed, TAKEOVER_REF aborted (4031).
- At NODE_LOST_DONE_REP, whatever is still open is rolled back with 4010.
A lost link says nothing about the node, which may be up and serving everyone else. Neither case is reported as the cluster failing.
iclaustron: crates/ic_apid/src/rec_thread.rs (read_node, link_lost, execute); apid_global.rs (note_link_event, node_failure_reported, takeover_reported); transaction.rs (module note, lost_transactions, fail_lost_transactions); doc/user-guide.md §10
Node failure · when a node is connected to again
Membership decides when a connect thread connects to a node again
With no data node connected, every wait ends, cached table definitions are dropped, and a node id the management server chose is claimed again first.
iclaustron: crates/ic_apid/src/apid_global.rs (LinkStatus, retry_delay_ms, claim_link, node_failure_reported, takeover_reported, note_link_lost); connect_thread.rs (run_connect_thread); doc/rust/02-architecture.md (Node failure flow)
PART 3
Executors, locks and numbers
A user thread that works for others, the order locks are taken in, and what it all does today.
On the map · executor threads
An executor is a user thread whose inbox also takes requests
An executor is a user thread like the others, started by the library: a connection, a block number, an inbox.
Application threads with no connection submit requests through a submitter, into a second list in the executor's inbox.
Replies arrive in the same inbox, posted by the receive threads. Scan readers leave nudges on a list of their own.
One mutex, one condition, one waiting flag: the executor sleeps in one place and wakes for whichever comes first.
What it admits in one pass goes out in one write per data node, carrying every caller's requests. Module 06 goes deeper.
iclaustron: crates/ic_apid/src/executor.rs (module note, Executor::start, Submitter); thread_conn.rs (Requests, submit, nudge, exchange_with_requests); doc/rust/architecture.html §3 (An executor with two inputs; One wait point)
Executors · one pass of the loop
The executor's loop: take, execute, deliver, admit, send
- Take, in one lock: posted pages, requests, scan readers' nudges. Sleep only with nothing to do.
- Execute the replies: they complete operations and free coordinator records.
- Deliver each finished request to its slot, waking its caller, or to its callback.
- Admit waiting requests while fewer than the in-flight limit, 400 by default, are out.
- Send what this pass admitted: one write per data node.
Replies before requests: room in the pipeline is freed first. At its in-flight limit the executor sleeps for replies alone.
iclaustron: crates/ic_apid/src/executor.rs (Runner::run, admit_waiting, IC_EXECUTOR_IN_FLIGHT); thread_conn.rs (exchange_with_requests); doc/rust/architecture.html §3 (The executor's loop)
Locks · ic_port::sync levels · inside the crates: module 09
Mutex levels: rising order only
| Level | Mutex | Protects |
|---|---|---|
| 1 | a table's AUTO_INCREMENT range | its values, across a refill's round trip |
| 2 | the counter connection | the connection the AUTO_INCREMENT counters are updated on |
| 3 | dictionary cache | table and index definitions, shared by every thread |
| 4 | global | the thread table; our node id and configuration |
| 5 | a user thread's inbox | posted pages; an executor's requests and nudges |
| 8 | a data node | its send buffer and writer flag; the link a connect thread hands over |
| 10 | send pool | its queue of nodes to write for |
| 11 | completion | a slot an executor leaves a result in; a scan reader's feed |
Each IcMutex is made with its level, and a debug build aborts on a lock taken out of order. Levels 6, 7 and 9 are not taken: no node changes receive thread, receive threads keep their own pages, the heartbeat keeps no list.
iclaustron: crates/ic_port/src/sync.rs (IC_MUTEX_LEVEL_*, check_order); IcMutex::new in crates/ic_apid/src; doc/rust/02-architecture.md (mutex levels)
Measured · primary key reads with ic_bench
Measured: 2.6 M reads a second on one user thread, up to 8.9 M on eight
| Setup | Reads a second | Client CPU per read | What limits it |
|---|---|---|---|
| Mac, 1 user thread, depth 4 | 2.6 M | 545 ns | the user thread: about 350 ns of user time per read |
| Mac, 4 user threads, batch 300 | 5.8 M | 495 ns | cores shared with the data nodes' threads |
| Mac, 8 user threads, batch 300 | 7.0 – 8.9 M | 512 – 565 ns | one coordinator thread of a data node at 96% |
| Linux, 32 CPUs, 8 user threads, 2 receive threads | up to 7.1 M | — | the data node's receive thread |
Reads of table t9; two release data nodes on the same machine; batches of 200 or 300, two in flight per thread unless stated. The limits today are on the data node side.
iclaustron: doc/rust/architecture.html §1 (What it does, measured); doc/rust/12-performance-model.md (Calibration: the Mac is an M5 Pro)
Planned · not built yet
How the threads can evolve: three planned steps
Planned
Poll set wake-up
A receive thread waits up to 20 ms in its poll set: a new link can go that long unwatched. A self-pipe or eventfd would wake it at once.
Planned
Wake-up helpers
Helper threads would take the wake-ups when a round wakes hundreds of threads, or an executor its callers. The send pool could grow.
Planned
Placement by load
Nodes are dealt to receive threads in turn at start. Placing them by measured load needs a hand-over that keeps one writer per node.
Each keeps the one rule: receive threads only route. Executors and the 4 711-connection limit were on this list and are built.
iclaustron: doc/rust/architecture.html §1 (How it can evolve); doc/rust/02-architecture.md (Building it in steps, step 7); crates/ic_apid/src/rec_thread.rs (module note); send_pool.rs (module note); apid_global.rs (IC_RECEIVE_POLL_MS)
Recap · module 02
Thread architecture: what to take away
- Receive threads only route; user threads decode, update and call back.
- A user thread writes its own requests: one write per data node a send.
- Replies come through the inbox: one page and one lock per round.
- A node's state has one writer, the receive thread that owns the node.
- A lost link fails with 7135 and the node is connected to again; a lost node waits for takeover.
- An executor is a user thread whose inbox also takes requests.
Next · 03 · Object architecture
iclaustron: doc/rust/architecture.html §1 and §3; doc/rust/02-architecture.md; crates/ic_apid/src (apid_global.rs, rec_thread.rs, thread_conn.rs, send_pool.rs, executor.rs)
iClaustron training for Rust developers · 9 modules · you are at 02 · this module for C developers