HN user

kprotty

180 karma
Posts0
Comments59
View on HN
No posts found.

Zig gives things we really dont have yet: C + generics + good const eval + good build system + easy cross compilation + modern niceties (optionals, errors, sum types, slices, vectors, arbitrary bit packing, expression freedom).

Are there any other languages that provide this? Would genuinely consider the switch for some stuff if so.

Yes, I've written a few unsafe-focused crates [0], some of which have been modified & merged into the stdlib [1] [2] exposing them to the fringe edge-cases of Rust like strict provenance.

IMO, Rust is good for modeling static constraints - ideal when there's multiple teams of varying skill trying to work on the same codebase, as the contracts for components are a lot clearer. Zig is good for expressing system-level constructs efficiently: doing stuff like self-referential/intrusive data structures, cross-platform simd, and memory transformations is a lot easier in Zig than Rust.

Personally, I like Zig more.

[0] https://crates.io/users/kprotty

[1] https://github.com/rust-lang/rust/pull/95801

[2] https://github.com/rust-lang/rust/blob/a63150b9cb14896fc22f9...

I've worked on two "production" zig codebases: tigerbeetle [0] and sig [1].

These larger zig projects will stick to a tagged release (which doesn't change), and upgrade to newly tagged releases, usually a few days or months after they come out. The upgrade itself takes like a week, depending on the amount of changes to be done. These projects also tend to not use other zig dependencies.

[0] https://github.com/tigerbeetle/tigerbeetle/pulls?q=is%3Apr+a...

[1] https://github.com/Syndica/sig/pulls?q=is%3Apr+author%3Akpro...

so you have no clue if the shared data might be incompletely modified or otherwise logically corrupted.

One can make a panic wrapper type if they cared: It's what the stdlib Mutex currently does:

MutexGuard checks if its panicking during drop using `std::thread::panicking()`, and if so, sets a bool on the Mutex. The next acquirer checks for that bool & knows state may be corrupted. No need to bake this into the Mutex itself.

Tokio's focus is on low tail-latencies for networking applications (as mentioned). But it doesn't employs yield_now for waiting on a concurrent condition to occur, even as a backoff strategy, as that fundamentally kills tail-latency under the average OS scheduler.

Was Rust Worth It? 3 years ago

Green threads give you all the advantages of async

They require more memory over stackless coroutines as it stores the callstack instead of changing a single state. They also allow for recursion, but its undelimited meaning you either 1) overrun the guard page and potentially write to another Green thread's stack by just declaring a large local variable 2) enable some form of stack-probing to address that (?) or 3) Support growable stacks which requires a GC to fixup pointes (isn't available in a systems lang).

green threads should run faster, as storing state on a stack is generally faster than malloc.

Stackless coroutines explicit don't malloc on each call. You only allocate the intial state machine (stack in GreenThread terms).

The primary objection seems to be speed

It's compatibility. No way to properly set the stack-size at compile time for various platforms. No way to setup guard pages in a construct that's language-level so should support being used without an OS (i.e. embedded, wasm, kernel). The current async using stackless coroutines 1) knows the size upfront due to being a compiler-generated StateMachine 2) disallows recursion (as that's a recursive StateMachine type, so users must dynamically allocate those however appropriate) which works for all targets.

This would imply a single/global runtime along with an unrealistic API surface;

For 1) It's common enough to have multiple runtimes in the same process, each setup possibly differently and running independently of each other. Often known as a "thread-per-core" architecture, this is the scheme used in apps focused on high IO perf like nginx, glommio, actix, etc.

For 2) runtime (libasync.so) implementations would have to cover a lot of aspects they may not need (async compute-focused runtimes like bevy don't need timers, priorities, or even IO) and expose a restrictive API (what's a good generic model for a runtime IO interface? something like io_uring, dpdk, or epoll? what about userspace networking as seen in seastar?). A pluggable runtime mainly works when the language has a smaller scope than "systems programming" like Ponylang or Golang.

As a side note; Rust tries to decouple the scheduling of Futures/tasks using its concept of Waker. This enables async implementations which only concern themselves with scheduling like synchronization primitives or basic sequencers/orchestration to be runtime-agnostic.

Preemption simulates localized concurrency (running multiple distinct things logically at the same time) not parallelism (running them physically at the same time). You can have concurrency outside continuations. OS threads for example are not continuations, but still express concurrency to the kernel so that it can (not guaranteed) express concurrency to the physical CPU cores which hopefully execute the concurrent code in parallel (not guaranteed again, due to hyperthreading).

getaddrinfo() is a synchronous function that can do network requests to resolve DNS. The network property isn't reflected in its function signature becoming async. You can have an async_getaddrinfo() which does, but the former is just a practical example of network calls in particular being unrelated to function coloring.

The main problem is that tokio futures are 'static

An important distinction to make is that tokio Futures aren't 'static, you can instead only spawn (take advantage of the runtime's concurrency) 'static Futures.

This implies that you can't statically guarantee that a future is cleaned up properly.

Futures need to be Pin'd to be poll()'d. Any `T: !Unpin` that's pinned must eventually call Drop on it [0]. A type is `!Unpin` if it transitively contains a `PhantomPinned`. Futures generated by the compiler's `async` feature are such, and you can stick this in your own manually defined Futures. This lets you assume `mem::forget` shenanigans are UB once poll()'d and is what allows for intrusive/self-referential Future libraries [1]. The future can still be leaked from being kept alive by an Arc/Rc, but as a library developer I don't think you can/would-care-to reasonably distinguish that from normal use.

[0] https://doc.rust-lang.org/std/pin/#drop-guarantee

[1] https://docs.rs/futures-intrusive/latest/futures_intrusive/

Tokio and glommio using interrupts is ironically another misconception. They're cooperatively scheduled so yes, a misbehaving blocking task can stall the scheduler. They can't really interrupt an arbitrary stackless coroutine like a Future due to having nowhere to store the OS thread context in a way that can be resumed (Each thread has its own stack, but now it's stackful with all the concerns of sizing and growing. Or you copy the stack to the task but now have somehow to fixup stack pointers in places the runtime is unaware).

https://tokio.rs/blog/2020-04-preemption#a-note-on-blocking

Tokio does not, and will not attempt to detect blocking tasks and automatically compensate

The cost of switching goroutines, rust Futures, Zig async Frames, or fibers/userspace-tasks in general is on the other of a few nano-seconds whereas it's in the micro-second range for OS threads. This allows you to spawn tons of tasks and have them all communicate with each other very quickly (write to queue; push receiver to scheduler runqueue; switch out sender; switch to receiver) whereas doing so with OS threads would never scale (write to queue; syscall to wake receiver; syscall to switch out sender). Any highly concurrent application (think games, simulations, net services) uses userspace/custom task scheduling for similar reasons.

1) That's no longer "running async code in Drop" as it's spawned/detached and semantically/can run outside the Drop. This distinction is important for something like `select` which assumes all cancellation finishes in Drop. 2) This doesn't address the efficiency concern of using borrowed memory in the Future. You have to either reference count or own the memory used by the Future for the "spawn in Drop" scheme to work for cleanup. 3) Even if you define an explicit/custom async destructor, Rust doesn't have a way to syntactically defer its execution like Go and Zig do so you'd end up having to call it on all exit points which is error prone like C (would result in a panic instead of a leak/UB, but that can be equally undesirable). 4) Is there anywhere one can read up on the work being done for async Drop in Rust? Was only able to find this official link but it seems to still have some unanswered questions (https://rust-lang.github.io/async-fundamentals-initiative/ro...)

what prevents it from ensuring that a runtime is present when it does?

The runtime being a library instead of a language/compiler level feature. Custom runtimes is necessary for systems languages as they can have specialized constraints.

EDIT: Note that it's the presence of a supported runtime for the async operation (e.g. it relies on runtime-specific state like non-blocking IO, timers, priorities, etc.), not only the presence of any runtime.

What prevents the runtime from preempting a task?

Memory efficient runtimes use stackless coroutines (think state machines) instead of stackful (think green threads / fibers). The latter comes with inefficiencies like trying to guess stack sizes and growing them on demand (either fixing pointers to them elsewhere or implementing a GC) so it's not always desirable.

To preempt the OS thread of a stackful coroutine (i.e. to catch synchronously blocking on something) you need to have a way to save its stack/registers in addition to its normal state machine context which is the worst of both worlds: double the state + the pointer stability issues from before.

This is why most stackful coroutine runtimes are cooperatively scheduled instead, requiring blocking opportunities to be annotated so the runtime can workaround that to still make progress.

To run async in Drop in rust, you need to use block_on() as you can't natively await (unlike in Go). This is the "blocking on Drop" mentioned and can result in deadlocks if the async logic is waiting on the runtime to advance, but the block_on() is preventing the runtime thread from advancing. Something like `async fn drop(&mut self)` is one way to avoid this if Rust supported it.

Not sure I follow; the cancellation logic is on both threads/tasks 1) the operation itself waiting for either the result or a cancel notification and 2) the cancellation thread sending that notification.

The cancellation thread is generally the one doing the `select` so it spawns the operation thread(s) and waits for (one of) their results (i.e. through a channel/event). The others which lose the race are sent the cancellation signal and optionally joined if they need to be (i.e. they use intrusive memory).

That would be useful, but I wouldn't call the lack of it "half-baked", since no other mainstream language has it either. It's just a nice-to-have.

Golang supports running asynchronous code in defers, similar with Zig when it still had async.

Async-drop gets upgraded from a nice-to-have into an efficiency concern as the current scheme of "finish your cancellation in Drop" doesn't support borrowed memory in completion-based APIs like Windows IOCP, Linux io_uring, etc. You have to resort to managed/owned memory to make it work in safe Rust which adds unnecessary inefficiency. The other alternatives are blocking in Drop or some language feature to statically guarantee a Future isn't cancelled once started/initially polled.

If a function calls something that does something async, that can't be evaluated synchronously due to 1) no setup; could be async IO and require being called in the context of an async runtime (library feature, not language feature) and 2) blocking synchronously on an async task in an async runtime can result in deadlocks from task waiting on runtime IO polling but the waiting preventing the runtime from being polled.

A new futex API 3 years ago

A spinlock can be conditionally faster than a lock with uses futex, but it doesn't scale. Futex is really meant to provide an efficient way to block and unblock threads. One could use FUTEX_LOCK_PI to have it actually implement a lock, but that's a separate argument/edge-case.

A new futex API 3 years ago

futex APIs are both prevalent and somewhat unfortunate: Since the task enqueue happens in the kernel, you have no control over how it does it. This makes something like "wait if not equal" or requeue either not portable (few non-linux api's support it) or inefficient to adopt (too many syscalls from intermediate locks or eager wakes when using). It also means custom queueing (i.e. readers in one list, writers in another, same addr key) isn't possible.

NetBSD and Windows seemed to have made a more perf-flexible API here: Each thread has an implicit AutoResetEvent which it can wait on while any other thread can set it. All task queueing can then be done in userspace, in a lock-free fashion too for Windows. This avoids that extra syscall at the end for futex based locks/rwlocks and employs natural/efficient requeue. NetBSD has a slight edge in that you can set the AutoResetEvent of multiple threads using a single syscall.

Zig 0.11 3 years ago

This is partially why allocation is left to the caller (concurrent data structures are often intrusive), have single consumers (made lock free using separate synchronization) or only have lockless guarantees not obstruction freedom.

I think the obnoxious part in Rust is doing intrusive and shared mutability parts of data structures. Having to go between NonNulls, Options, Pin, and Cell/UnsafeCell is not a pleasant experience.