-
Notifications
You must be signed in to change notification settings - Fork 28
Introduce AsyncIO for Windows and Linux #117
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Introduce AsyncIO for Windows and Linux #117
Conversation
Resolves: #110 |
Talked to @iCharlesHu offline and he confirmed that if we merge this one, we won't need #64 |
internal init(closeWhenDone: Bool, purpose: Purpose) throws { | ||
#if canImport(WinSDK) | ||
// On Windows, we need to create a named pipe | ||
let pipeName = "\\\\.\\pipe\\subprocess-\(purpose)-\(Int.random(in: .min ..< .max))" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would be a bit worried about running into collisions here, there is a good chance this could repeat and lead to subtle bugs. Instead of making it random, what about using a monotonically increasing unsigned 64-bit integer stored in a static variable? Then we can guarantee this is never a problem.
Something like this maybe?
func nextPipeNumber() -> UInt64 {
struct Static: ~Copyable {
static let pipeNumber = Atomic<UInt64>(0)
}
return Static.pipeNumber.add(1, ordering: .sequentiallyConsistent).newValue
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How about UUID? It feels heavy to introduce lock and atomics just for a random value.
Also: is this really a concern? I would be really worried if a random number between .min
and .max
starts to repeat fairly soon.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Never mind UUID is not available in Subprocess. I still feel like the chances of us running into collisions are fairly low, and introducing atomic just for this purpose doesn't seem to justify the additional safety it brings.
@cthielen do you have any thoughts on this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe random number collisions follows the Birthday Paradox rule, which according to (multiple) ChatGPT/Claude calculations puts us around 3.5 ^ 10 ^9 range for full 64 bit signed int. This means we have to run 3.5 * 10^9 times for it to have more than 50% chance repeating. To me that's statistically negligible.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is using an Atomic for this "heavy"? (Also, not a lock and atomics -- just an atomic. No need for a lock as well) How do you know Int.random is not actually more expensive? I did a quick benchmark and atomics seem to outperform Int.random by an order of magnitude...
import Synchronization
let pipeNumber = Atomic<UInt64>(0)
func main() {
let clock = ContinuousClock()
let count = 100_000
let random = clock.measure {
for _ in 0..<count {
Int.random(in: .min ..< .max)
}
}
let atomic = clock.measure {
for _ in 0..<count {
pipeNumber.add(1, ordering: .sequentiallyConsistent).newValue
}
}
print("random = \(random)")
print("atomic = \(atomic)")
}
main()
random = 0.0332772 seconds (33.2 ms)
atomic = 0.0006226 seconds (0.6 ms)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Atomics aren't sufficient here. Named pipe names are system-wide.
Why are we using a named pipe here anyway? What's wrong with CreatePipe()
? Using a Windows named pipe is an odd thing to do unless you want to open it from some other process (or across the network).
If we absolutely must use a named pipe for some reason, we should be trying to create it in a loop, with the CreateNamedPipe
function's dwOpenMode
set to include FILE_FLAG_FIRST_PIPE_INSTANCE
, and then adjust the name on each pass through the loop so that we get a unique name. An additional gotcha here is that in some circumstances named pipes are restricted to the same container and must start their names with "\\.\pipe\LOCAL\
". I think it's very likely the wrong choice for this code…
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Named pipe names are system-wide
I'm glad you looked over this. Had no idea this is a named pipe :P.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@al45tair we have to use named pipe here because Microsoft's documentation states that:
Asynchronous (overlapped) read and write operations are not supported by anonymous pipes.
See https://learn.microsoft.com/en-us/windows/win32/ipc/anonymous-pipe-operations
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, good point, though the actual problem is really just that CreatePipe()
doesn't have a flags argument for you to specify FILE_FLAG_OVERLAPPED
. Interestingly, Windows apparently used to use a named pipe anyway to implement anonymous pipes (see https://stackoverflow.com/questions/60645/overlapped-i-o-on-anonymous-pipe).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@al45tair thanks for linking that! Thanks to @jakepetroules's counter suggestion our current implementation of CreatedPipe
is (almost) the same as the one listed above.
Oh, one more suggestion, could AsyncIO.swift be split up into one file per backend? IOCP, epoll, Dispatch. That would make it easier to add others in future, like kqueue (BSD), poll (non-Linux non-BSD Unix), without the one file getting too big. |
- Darwin: based on DispatchIO - Linux: based on epoll - Windows (not included in this commit): based on IOCP with OVERLAPPED
- Darwin: based on DispatchIO - Linux: based on epoll - Windows (not included in this commit): based on IOCP with OVERLAPPED
d2d172e
to
d3f69c3
Compare
d3f69c3
to
22e4b38
Compare
continuation.finish(throwing: error) | ||
return | ||
} | ||
let completionKey = UInt64(UInt(bitPattern: handle)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there an advantage to this over UInt64(biPattern:)
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
UInt64(biPattern:)
requires Int64 so I had to convert twice. Otherwise I get the error message error: cannot convert value of type 'HANDLE' (aka 'UnsafeMutableRawPointer') to expected argument type 'Int64'
// Wake up the thread for shutdown | ||
_ = _SubprocessCShims.write(currentState.shutdownFileDescriptor, &one, MemoryLayout<UInt64>.stride) | ||
// Cleanup the monitor thread | ||
pthread_join(currentState.monitorThread, nil) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this will go badly wrong if we do this more than once. shutdown
should protect against that. After pthread_join
the pthread_t
is a dangling pointer
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
shutdown
is only called in atexit
, which by definition should only run once.
_ bytes: Bytes, | ||
to diskIO: borrowing IOChannel | ||
) async throws -> Int { | ||
let fileDescriptor = diskIO.channel |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
do we have a guarantee that fileDescriptor
is not an actual file? Because on actual files, epoll
will always return writable even if you can't write them and write
will block (e.g. on NFS or other network FSes).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes. The only scenario that FileDescriptor
might be a file is when user pass in FD directly. In those cases we bind them to child process directly and we don't read from / write to them.
[PlatformFileDescriptor : SignalStream.Continuation] | ||
> = Mutex([:]) | ||
|
||
final class AsyncIO: Sendable { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this whole file has very high complexity code that seems fairly adhoc, do we have a rigorous test suite for this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fairly adhoc
How so? Is there a more canonical way of doing AsyncIO on Linux? This new implementation passes all existing tests and I'll be adding more later: #97
We need to finish up the implementation first.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using epoll/kqueue correctly is notoriously difficult, so typically you would create an abstraction for it and rigorously test that. For example, NIO has a Selector
(https://github.com/apple/swift-nio/blob/main/Sources/NIOPosix/SelectorGeneric.swift) implementation which abstracts a lot of the difficult stuff.
Not abstracting that usually leads to very-hard-to-debug stalls or CPU spins etc.
5d379e3
to
779a51d
Compare
…s on DispatchIO and we can just use DispatchData.Region on Darwin
779a51d
to
9c6703f
Compare
Removed |
This is coming along really nicely! It looks pretty good to me at this point, and the last thing I'd like to see addressed before I'd feel comfortable giving a formal ✅ is the @sendable issues with DispatchIO detailed in #117 (comment) |
Resolves: #105 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sticking the @unchecked @Sendable
on the DisptatchIO handler as discussed would be a nice final bit of polish, but I think this is ready to land either way from my perspective.
Unfortunately, you can't use |
Appears to also resolve: #119 |
) { pipeNameW in | ||
// Use OVERLAPPED for async IO | ||
var openMode: DWORD = DWORD(FILE_FLAG_OVERLAPPED | FILE_FLAG_FIRST_PIPE_INSTANCE) | ||
switch purpose { | ||
case .input: | ||
openMode |= DWORD(PIPE_ACCESS_OUTBOUND) | ||
case .output: | ||
openMode |= DWORD(PIPE_ACCESS_INBOUND) | ||
} | ||
|
||
return CreateNamedPipeW( | ||
pipeNameW, | ||
openMode, | ||
DWORD(PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT), | ||
1, // Max instance, | ||
DWORD(readBufferSize), | ||
DWORD(readBufferSize), | ||
0, | ||
&saAttributes | ||
) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK, one final thing about this code (sorry). I think we should at least add PIPE_REJECT_REMOTE_CLIENTS
to the flags.
Ideally I think we'd specify an ACL for the named pipe, to make it harder to interfere with this code by having some other process call CreateFile
between the CreateNamedPipeW
and CreateFileW
calls here, but the window for mischief is quite small and PIPE_REJECT_REMOTE_CLIENTS
would at least restrict it to things already running on the current machine.
This PR finishes
AsyncIO
by introducing Windows IO Completion Port based implementation.This PR is staged on top of #64 and should be considered ready to be review as soon as that one lands.
Resolves: #105