iClaustron training for Rust developers · module 5 of 9 · 16 min
Key operations, asynchronous
Define the work, send it, carry on, and collect the results as they come: many transactions in flight on one thread.
The calls · methods of ApidConnection
Send, poll and flush: the three calls of asynchronous work
impl ApidConnection {
pub fn send_queries(&mut self, force: bool) -> Result<(), IcError>
pub fn poll(&mut self, wait_ms: u32) -> usize
pub fn flush(&mut self, wait_ms: u32, force: bool)
-> Result<usize, IcError>
}
iclaustron: crates/ic_apid/src/transaction.rs (ApidConnection::send_queries, flush); crates/ic_apid/src/apid_conn.rs (ApidConnection::poll); doc/rust/04-api-design.md (Sending, polling and completion); doc/user-guide.md §5
The example · examples/rust/pipeline.rs · 32 in flight by default
The pipeline example: statics for what main and callbacks share
/// The table, set once before the first write. static TABLE: OnceLock<T1> = OnceLock::new(); /// The next id to write, and one past the last. static NEXT: AtomicU32 = AtomicU32::new(0); static END: AtomicU32 = AtomicU32::new(0); /// Writes completed, and how many of them failed, with the first error. static DONE: AtomicU32 = AtomicU32::new(0); static FAILED: AtomicU32 = AtomicU32::new(0); static FIRST_ERROR: AtomicI32 = AtomicI32::new(0);
let _ = TABLE.set(bound);
// ...
while i < width {
let qid = conn.create_query(&t1.table, &t1.rec, &t1.rec)?;
queries.push(qid);
let id = NEXT.fetch_add(1, Ordering::Relaxed);
start_write(&mut conn, qid, id)?;
i += 1;
}
while DONE.load(Ordering::Relaxed) < rows {
conn.flush(10, false)?;
}
A callback is a plain function, so what it shares with the main loop is in statics. run starts a write per query, then only flushes.
iclaustron: examples/rust/pipeline.rs (module doc, the statics, run); examples/README.md (pipeline); doc/user-guide.md §5, §8
Step 1 · define · start_write() in the example
Step 1, define: the request is packed for its node, not sent
match conn.query_mut(qid) {
Some(query) => {
t1.fill(&t1.rec, query.key_row_mut(), &values)?;
t1.fill(&t1.rec, query.attr_row_mut(), &values)?;
}
// ...
}
// ... hint = conn.hint_for_key(&t1.table, &t1.rec, query.key_row())
let trans = conn.start_transaction(hint, Some(&t1.table))?;
let args = WriteKeyArgs {
kind: WriteKind::Write,
callback: Some(on_written),
..WriteKeyArgs::default()
};
conn.write_key(qid, trans, &args)?;
conn.commit_transaction(trans)
iclaustron: examples/rust/pipeline.rs (start_write); crates/ic_apid/src/query.rs (ApidQuery::key_row_mut, WriteKeyArgs); crates/ic_apid/src/transaction.rs (start_transaction, write_key, define, commit_transaction); crates/ic_apid/src/apid_conn.rs (coord_record)
Step 2 · send · the first half of conn.flush
Step 2, send: all the requests, one write per data node
// Send what is defined and take the replies; the callbacks, run
// inside, keep the pipeline full. Without force, a send may wait a
// moment for company, never longer than the adaptive send allows.
while DONE.load(Ordering::Relaxed) < rows {
conn.flush(10, false)?;
}
Each round of this loop sends, in one go, every write the callbacks of the round before defined.
iclaustron: examples/rust/pipeline.rs (run); crates/ic_apid/src/transaction.rs (flush, send_queries, send_transaction, send_query); crates/ic_apid/src/apid_global.rs (NodeShared::send_words_for_generation)
Step 3 · poll · the second half of conn.flush
Step 3, poll: the receive thread routes, the poll completes
conn.flush(10, false)?; // send, then poll up to 10 ms
Replies come from two places, in either order: KEY_OP_CONF from the coordinator, ROW_DATA_REP from the node that read the row.
The receive thread reads nothing but the block number, and posts one page per user thread per round, under one short lock.
The poll takes every posted page at once. A confirmation finds its transaction by one array index, a row its query by its id.
A completed query leaves its transaction's lists; then its callback runs, or it goes on the executed list.
iclaustron: crates/ic_apid/src/thread_conn.rs (ThreadConnection::post, exchange_with_requests); crates/ic_apid/src/apid_conn.rs (poll_with_requests); crates/ic_apid/src/transaction.rs (take_key_conf, take_row, finish_query); doc/rust/05-ndb-protocol.md §6.5
In flight · the threads over time
Many transactions in flight: the thread works while the nodes do
Three single-write transactions are defined, then sent: one write per data node, by this thread itself, straight to the socket.
The thread does not wait for them. It defines three more and sends those too: six transactions in flight on one connection.
A receive thread only routes the replies to this thread's inbox. The poll completes T1–T3 and runs their callbacks, which define T7–T9.
The next send carries T7–T9, one write per node, while T4–T6's replies wait in the inbox for the next poll: two batches always out.
iclaustron: doc/rust/architecture.html §3 (pipelines on one thread); crates/ic_apid/src/transaction.rs (send_queries, finish_query); crates/ic_apid/src/thread_conn.rs (post); doc/user-guide.md §5
Completions · the executed list or a callback
Two ways to collect completions: the executed list or a callback
pub type QueryCallback = fn(&mut ApidConnection, QueryId, usize);
pub struct ReadKeyArgs { // and WriteKeyArgs
// ... kind, abort_option
pub user_ref: usize, // given back with the completed query
pub callback: Option<QueryCallback>, // None: the executed list
// ... binding
}
// ApidConnection, and ApidQuery from conn.query(id):
pub fn get_next_executed_query(&mut self) -> Option<QueryId>
pub fn user_ref(&self) -> usize
iclaustron: crates/ic_apid/src/query.rs (QueryCallback, ReadKeyArgs, WriteKeyArgs, ApidQuery::user_ref); crates/ic_apid/src/transaction.rs (get_next_executed_query, finish_query)
Callbacks in Rust · QueryCallback, in query.rs
A Rust callback is a plain function, not a closure
It reaches
- the connection, and through it every query
- its query's id: its error, rows and transaction
user_ref: a usize of yours, 0 unless set- statics: what it shares with the rest
Not a closure
- its type is a
fnpointer: a closure that captures cannot be one - one that captures nothing coerces to it, and gains nothing
- the form of C's callback: a function and a word
The pipeline example sets no user_ref: the query's id is its slot. Its statics are a OnceLock for the table and atomics for the counters: no unsafe.
iclaustron: crates/ic_apid/src/query.rs (QueryCallback, ReadKeyArgs, WriteKeyArgs); crates/ic_capi/src/lib.rs (trampoline); examples/rust/pipeline.rs (module doc, the statics, on_written); doc/rust/07-style-guide.md (Closures); doc/user-guide.md §8
Callbacks · the rules
Inside a callback: what it sees, what it may do, what it must not
It may
- read the query's error and its rows
- define queries, this one included
- start, commit, roll back and close transactions
- send
It runs
- on the polling thread, inside the poll
- with its query complete, the transaction settled as far as this query decides it
- where a poll does nothing, and a flush only sends
- with the connection borrowed, which it cannot drop
A committed one-query transaction is done in its callback: close it there. On return the query is idle, unless defined anew.
iclaustron: crates/ic_apid/src/transaction.rs (finish_query, take_key_conf, flush); crates/ic_apid/src/apid_conn.rs (poll_with_requests: in_callback); crates/ic_apid/src/query.rs (QueryCallback); doc/user-guide.md §8
The example · on_written() in pipeline.rs
The pipeline's callback closes its transaction and starts the next
fn on_written(conn: &mut ApidConnection, qid: QueryId, _user_ref: usize) {
let (error, trans) = match conn.query(qid) {
Some(query) => (query.error(), query.transaction()),
None => return,
};
// ... counted in DONE, and a failure in FAILED, FIRST_ERROR
if let Some(trans) = trans {
let _ = conn.close_transaction(trans);
}
loop {
let id = NEXT.fetch_add(1, Ordering::Relaxed);
// ... none left past END: return
match start_write(conn, qid, id) {
Ok(()) => return,
// ...
}
}
}
iclaustron: examples/rust/pipeline.rs (on_written, start_write); crates/ic_apid/src/query.rs (ApidQuery::error, transaction); crates/ic_apid/src/transaction.rs (take_key_conf: the commit recorded before the callbacks; finish_query); doc/user-guide.md §8
force · adaptive send and the send pool
Without force, a send may wait a moment for company on its link
Without force, A's requests are appended to the node's buffer, and adaptive send may hold them back for company: 200 µs at most today.
B's requests join them, and B writes both in one write: the data node pays one receive, one execution round, one reply packet.
Had none come, the send pool writes them at the deadline. A thread that sends alone teaches the algorithm not to hold at all.
With force the write happens now. A thread that goes on to poll loses nothing by leaving it off, as the pipeline example does.
iclaustron: crates/ic_apid/src/adaptive_send.rs (AdaptiveSend::decide, deadline, IC_DEFAULT_MAX_SEND_WAIT_NANOS); crates/ic_apid/src/apid_global.rs (send_words_for_generation, send_due); crates/ic_apid/src/send_pool.rs; transaction.rs (send_queries)
Cost · chapter 12's model of a batch
Why many in flight pays: a batch's fixed costs are shared
Cost per operation
d = d_op + d_batch / b
- d_op ≈ 300 ns: define, complete, route
- d_batch, several µs: a write and a read per node, a round, a wake-up
- past b ≈ 200 the batch term is small
Little's law
X = depth · b / R
- depth batches of b in flight, each R from send to done
- deeper raises X until R grows: until a server is full
Depth adds throughput, not cost: measured client CPU per read stays near 545 ns from depth 1 to depth 4.
iclaustron: doc/rust/12-performance-model.md (The path of a batch; Operational laws: Little's law, Cost per operation); doc/rust/architecture.html §3 (the executor's loop: a write, a read and a round per node)
Measured · ic_bench runs this model
Measured asynchronous reads: 2.6 million a second on one thread
| 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 |
ic_bench, primary key reads of t9: two release data nodes on the same machine, batches of 200 or 300, two in flight per thread unless stated.
iclaustron: doc/rust/architecture.html §1 (What it does, measured); doc/rust/12-performance-model.md (Calibration); tools/ic_bench/src/main.rs (run_thread: depth batches in flight)
Errors · in the asynchronous model
Errors in flight: on the query and its transaction, retried whole
// committed(): the transaction's error, else the query's, else 4012
match committed(&mut conn, trans, updater) {
// ... Ok(()): committed
Err(e) if e.is_temporary() && attempt < ATTEMPTS => {
// ... a new transaction, after a pause
}
Err(e) => return Err(e),
}
A node failure reaches the callbacks too: a poll ends what it aborted with a temporary error, and what had committed comes back committed (module 02).
iclaustron: examples/rust/retry.rs (run); examples/rust/lib.rs (committed, finish); crates/ic_port/src/err.rs (IcError::is_temporary); crates/ic_apid/src/transaction.rs (end_transaction, fail_lost_transactions); doc/user-guide.md §9, §10; doc/rust/05-ndb-protocol.md §6.5
The limit · one thread's work
The limit: new work comes only from this thread or its callbacks
poll knows of no hand-off from another thread. Work that arrives on other threads goes through an executor instead, handed over by Submitter::submit, with a callback for the result: module 06.
iclaustron: doc/rust/architecture.html §3 (the limitation; an executor with two inputs); crates/ic_apid/src/apid_conn.rs (ApidConnection: one thread uses it); crates/ic_apid/src/executor.rs (Submitter::submit, Done::Callback); doc/user-guide.md §5
Compared · the three models on a connection
Three models on a connection: what each costs and when to use it
| Synchronous | Batched synchronous | Asynchronous | |
|---|---|---|---|
| The caller writes | one call per query, which waits | queries on transactions, commit, flush until each is done | queries with a callback or for the executed list; send; poll |
| In flight on a thread | one query | one batch, drained before the next | several batches, refilled as they come back |
| Reads a second | 198 000 from 64 threads | 1.17 – 1.35 M, one thread, depth 1 | 2.55 – 2.61 M, one thread, depth 4 |
| Client CPU per read | 18.3 µs | about 545 ns | about 545 ns |
| Use it for | tools, tests, low-rate services | a thread that needs every result before it goes on | loaders, benchmarks, servers built for it |
Measured on a Mac (M5 Pro), two release data nodes on the same machine, t9 reads by primary key, batches of 200; synchronous: one call at a time per thread.
iclaustron: doc/rust/12-performance-model.md (Calibration; Executors: 64 threads, a connection each); doc/rust/architecture.html §4 (the comparison); tools/ic_bench/src/main.rs (--depth 1, the plain form)
Recap · module 05
Asynchronous key operations: what to take away
- Define is local; send writes it all, one write per data node; poll completes it.
- Receive threads only route; the user thread decodes, completes and calls back.
- Completions go to the executed list, or to a callback inside the poll.
- A callback may define, commit, close and send: the pipeline refills itself.
- Many in flight share each batch's costs: 2.6 M reads a second on one thread.
- New work comes only from this thread; other threads' work needs an executor.
iClaustron training for Rust developers · 9 modules · you are at 05 · this module for C developers