I appreciate it's not easy.
That I can't quite follow. Why wouldn't I be able to do something like this?
@noncopyable
struct FD {
var underlying: CInt
consuming func startCloseHopingItWillFinishSoon() {
defer { forget self }
fireAndForgetUnregisterAndClose(...)
}
consuming func close() async throws {
defer { forget self }
try await unregisterAndClose(...)
}
}
actor ManagedFD {
let fd: FD? // yeah, I know, currently no Generic<@noncopyable>, could use -1 or some other sentinel for now.
static func withManagedFD<R>(_ body: (ManagedFD) async throws -> R) -> R {
let managedFD = ManagedFD(...)
defer {
try! await managedFD.close() // yeah I know, currently I need to spell that out...
}
return try await body(managedFD)
}
func close() async throws {
var fd: FD? = nil
swap(&fd, &self.fd)
try await fd.close()
}
deinit {
if self.fd != nil {
if strictMode { error("ManagedFD died without all resources deconstructed, tight file descriptor control lost for a while") }
var fd: FD? = nil
swap(&fd, &self.fd)
fd?.startCloseHopingItWillFinishSoon()
}
}
}
Right, yes. You can always push responsibility one layer up. If you have file descriptors you can define that there is a FileDescriptorManager which owns all the file descriptors. And FileDescriptor's deinit
merely asks its manager to do the destruction when convenient.
That however has at least two profound consequences:
- At a given point in our program we can no longer know the exact state of our resources. As in, an underlying file descriptor may or may not be closed when my type got
deinit
ed. - At some point we reached the top level, who manages that? Apple's SDKs often use global singletons (Dispatch's thread pools, Swift Concurrency's cooperative pool, the main thread/queue/runloop, URLSession's connection pools, ...) and Rust's Tokio/mio just blocks the calling thread in
Drop for Runtime
and theshutdown*
methods until everything's done.
Much like myself, Tokio/mio for example don't seem to be happy with non-deterministic file descriptor closes (the "just push it up one layer to a system that'll close at its convenience"). But much like deinit
, Drop
requires a synchronous operation too and at the same time it needs to deregister & close that fd. So how can that work? It seems that each runtime (even if multi-threaded) actually shares just one kqueue()
that just gets dup
'd to allow multi-threading without having to take a mutex in userland to access the selector. That way, Tokio/mio uses the kernel as the "mutex" and all registrations/deregistrations of file descriptors are shared across all threads which means that any Tokio worker thread can deregister a file descriptor using it's clone of the one kqueue()
.
The repercussions here are that the kernel now has to synchronise this one shared kqueue()
which likely leads to sub-linear scaling across cores. Also, mio
has to issue more syscalls and ignore errors because it "cannot know" what the actual registration is (as they can be changes concurrently by multiple threads). It just deletes all possible registrations and hopes that the underlying system is okay with that.
And more so, it will assume that you're running on a Runtime
thread. If not, you're bust. For Rust, I think this checks out because for async fn
s you likely pick one Runtime
and fly with it, so async fn
s aren't run on some other runtime or the "wrong thread".
So overall, I'm not fully convinced that the tradeoffs that Tokio/mio made will just carry over to Swift. Swift Concurrency doesn't have an I/O capable runtime that everything can just use so it doesn't really seem feasible to require synchronous resource destruction like Rust does. And even in Rust, this comes with profound consequences (one shared kqueue
) and also requirements on the underlying kernel system (kqueue, epoll, io_uring, ...).
Summing up, I don't want to say anybody is wrong here but I don't think the argument "Rust can't do it, we won't need it" fully works. In Rust, "end-user types" are what Swift will call @noncopyable
and they usually guarantee resource destruction by the time they got dropped. This works (but as we've seen comes with real implementation restrictions)! In Swift, I'd argue that most "end-user types" will not be @noncopyable
so resource management will remain really hard unless you withFunction
everything or require the user to manually call close() async throws
which is super easy to forget.