Skip to content
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

Add EventLoop APIs for simpler scheduling of callbacks #2759

Merged
merged 43 commits into from
Oct 3, 2024
Merged
Show file tree
Hide file tree
Changes from 37 commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
5f7065f
benchmarks: Add benchmark for MTELG.scheduleTask(in:_:)
simonjbeaumont Jun 28, 2024
33e82e0
api: Add NIOTimer, NIOTimerHandler, and EventLoop.setTimer(for:_:)
simonjbeaumont Jun 28, 2024
b59e31d
benchmarks: Add benchmark for MTELG.setTimer(for:_:)
simonjbeaumont Jun 28, 2024
17cf105
internal: Add NIOCustomTimerImplementation conformance to SelectableE…
simonjbeaumont Jun 28, 2024
0afb3a2
test: Add Linux pre-5.9.2 backport for fulfillment(of:timeout:enforce…
simonjbeaumont Jul 1, 2024
df3cdb1
test: Increase timer used in shutdown test
simonjbeaumont Jul 1, 2024
b0d3f66
feedback: Make NIOTimer Sendable
simonjbeaumont Jul 5, 2024
907c087
feedback: Rename timerFired(loop:) to timerFired(eventLoop:)
simonjbeaumont Jul 8, 2024
b3e4903
feedback(attempted): Store a closure instead of UInt64
simonjbeaumont Jul 8, 2024
fb5e835
feedback(reverted, allocates): Store a closure instead of UInt64
simonjbeaumont Jul 8, 2024
89c57ec
feedback(unsure): Replace extra protocol with runtime checks
simonjbeaumont Jul 8, 2024
59a04de
feedback(unsure): Generic timerFired protocol witness
simonjbeaumont Jul 8, 2024
06a4ce7
feedback(unsure): Make setTimer generic over the handler
simonjbeaumont Jul 8, 2024
950db0c
feedback: Use labelled parameter for handler
simonjbeaumont Jul 8, 2024
d6ae472
feedback: Use separate prepositions for TimeAmount and NIODeadline APIs
simonjbeaumont Jul 8, 2024
4439ac6
Remove DocC disambiguation for now until API is decided
simonjbeaumont Jul 8, 2024
abd4b28
feedback: Add documentation to NIOTimerHandler.timerFired protocol re…
simonjbeaumont Jul 8, 2024
ceabc7b
feedback: Change API terms from setTimer to scheduleCallback
simonjbeaumont Jul 8, 2024
80d91f6
feedback: Local variable rename: taskId -> taskID
simonjbeaumont Jul 10, 2024
2ec5543
feedback: Reanme NIOScheduledCallbackHandler.onSchedule to handleSche…
simonjbeaumont Jul 10, 2024
5d6ac17
feedback: Update protocol requirement documentation comments to use D…
simonjbeaumont Jul 10, 2024
bbf8f9f
feedback: Remove explicit benchmark.stopMeasurement calls
simonjbeaumont Jul 11, 2024
21fd1cc
feedback: Remove TODO following discussion about internal inits
simonjbeaumont Jul 11, 2024
9bf65f6
feedback: Make scheduleCallback throwing
simonjbeaumont Jul 11, 2024
dbba9e3
feedback: Add a missing explicit self
simonjbeaumont Jul 11, 2024
86b8ac3
Merge remote-tracking branch 'upstream/main' into sb/timer-api
simonjbeaumont Jul 12, 2024
b16a575
Remove broken DocC disambiguatoin and fix test calls
simonjbeaumont Jul 12, 2024
4e71ffb
Update implementation comments in EmbeddedEventLoop and AsyncTestingE…
simonjbeaumont Jul 15, 2024
b909365
feedback: Fix preconditionFailure message
simonjbeaumont Jul 15, 2024
5559772
feedback: Move SheduledTask.Kind and kind above other properties
simonjbeaumont Jul 15, 2024
7f159be
feedback: Measure .instructions instead of .cpuTotal
simonjbeaumont Jul 15, 2024
3ad5647
feedback: Remove vestigial references to setTimer in impl comments
simonjbeaumont Jul 15, 2024
bd8fa73
Merge remote-tracking branch 'upstream/main' into HEAD
simonjbeaumont Jul 25, 2024
6af561d
Add cancellation callback and implicitly cancel on shutdown
simonjbeaumont Jul 19, 2024
81cc415
format: Update for new format and lint rules
simonjbeaumont Jul 25, 2024
37ebfde
Merge remote-tracking branch 'upstream/main' into sb/timer-api
simonjbeaumont Jul 30, 2024
4467728
Remove use of Task.sleep(for:) in tests
simonjbeaumont Jul 30, 2024
6544461
Merge remote-tracking branch 'upstream/main' into sb/timer-api
simonjbeaumont Aug 15, 2024
c48b7aa
Update benchmark to use same config as other benchmarks
simonjbeaumont Aug 15, 2024
151c2c8
Rename onCancelScheduledCallback to didCancelScheduledCallback
simonjbeaumont Aug 15, 2024
5e61930
Merge branch 'main' into sb/timer-api
simonjbeaumont Sep 30, 2024
0a2baf3
Merge branch 'main' into sb/timer-api
simonjbeaumont Oct 3, 2024
ba63fda
Make AsyncStream.makeStream backfill internal
simonjbeaumont Oct 3, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions Benchmarks/Benchmarks/NIOPosixBenchmarks/Benchmarks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
//===----------------------------------------------------------------------===//

import Benchmark
import NIOCore
import NIOPosix

private let eventLoop = MultiThreadedEventLoopGroup.singleton.next()
Expand Down Expand Up @@ -64,4 +65,43 @@ let benchmarks = {
)
}
#endif

Benchmark(
"MTELG.scheduleTask(in:_:)",
configuration: Benchmark.Configuration(
metrics: [.mallocCountTotal, .instructions],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use our defaultMetrics here? We don't have instruction based benchmarks yet

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ISTR using .instructions because you asked me too, in place of wall clock time. I can use defaultMetrics though, sure.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

scalingFactor: .kilo
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we align the maximum duration/iterations with #2839

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can I gently push back on that ask.

You're asking me to align with a new precedent in an un-merged PR that was opened well after this one, instead of this PR keeping the conventions of the branch it is targeting.

This PR has been subject to a lot of scope creep already.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see it's been merged already. So I've now merged this PR with main and updated the benchmarks to use the same configuration as the rest.

)
) { benchmark in
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
defer { try! group.syncShutdownGracefully() }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should do this setup inside the benchmark.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not? IIUC, that's what startMeasurement is for.

If you're suggesting that I use the setup and teardown closures of the Benchmark() function, then I cannot since I need to actually use these variables in the benchmark. The only alternative would be to make them implicitly unwrapped optionals at global scope, which was pretty gross.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can reuse ELs across benchmarks so I was thinking it might just be better if we create one EL that we share. I would just define a let eventLoop = MultithreadedEventLoopGroup.singleton.any() at the top.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure that's great, I think I'd rather each benchmark do it's own setup and teardown to ensure its unpolluted by the side effects of running other benchmarks.

Where we can, I'm happy to try and use the setup/teardown closure style, if you prefer it, and where we cannot, use this.

It also has the benefit for local reasoning of what's being benchmarked. It's all contained in the call to Benchmark { ... }.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I disagree and I just looked at the code in the benchmark again and we are already defining an EL for exactly this purpose. There is a static EL defined in this file called eventLoop.

The reason why I think it's not important to keep is that it should be irrelevant for the benchmark here. The important part of the benchmark is the scheduling and not how the EL is constructed and shut down so it keeps the benchmark more concise.

IMO we should definitely only have one style here and not mix static EL with one EL per benchmark.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While I still disagree with this feedback and think we should change the other benchmarks too for local reasoning reasons, I'm more interested in converging this PR, so I've updated to accommodate this feedback.

let loop = group.next()

benchmark.startMeasurement()
for _ in benchmark.scaledIterations {
loop.scheduleTask(in: .hours(1), {})
}
}

Benchmark(
"MTELG.scheduleCallback(in:_:)",
configuration: Benchmark.Configuration(
metrics: [.mallocCountTotal, .instructions],
scalingFactor: .kilo
)
) { benchmark in
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
defer { try! group.syncShutdownGracefully() }
let loop = group.next()

final class Timer: NIOScheduledCallbackHandler {
func handleScheduledCallback(eventLoop: some EventLoop) {}
}
let timer = Timer()

benchmark.startMeasurement()
for _ in benchmark.scaledIterations {
let handle = try! loop.scheduleCallback(in: .hours(1), handler: timer)
}
}
}
25 changes: 25 additions & 0 deletions Sources/NIOCore/EventLoop.swift
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,31 @@ public protocol EventLoop: EventLoopGroup {
/// It is valid for an `EventLoop` not to implement any of the two `_promise` functions. If either of them are implemented,
/// however, both of them should be implemented.
func _promiseCompleted(futureIdentifier: _NIOEventLoopFutureIdentifier) -> (file: StaticString, line: UInt)?

/// Schedule a callback at a given time.
///
/// - NOTE: Event loops that provide a custom scheduled callback implementation **must** also implement
/// `cancelScheduledCallback`. Failure to do so will result in a runtime error.
@discardableResult
func scheduleCallback(
at deadline: NIODeadline,
handler: some NIOScheduledCallbackHandler
) throws -> NIOScheduledCallback

/// Schedule a callback after given time.
///
/// - NOTE: Event loops that provide a custom scheduled callback implementation **must** also implement
/// `cancelScheduledCallback`. Failure to do so will result in a runtime error.
@discardableResult
func scheduleCallback(
in amount: TimeAmount,
handler: some NIOScheduledCallbackHandler
) throws -> NIOScheduledCallback

/// Cancel a scheduled callback.
///
/// - NOTE: Event loops only need to implemented this if they provide a custom scheduled callback implementation.
func cancelScheduledCallback(_ scheduledCallback: NIOScheduledCallback)
}

extension EventLoop {
Expand Down
166 changes: 166 additions & 0 deletions Sources/NIOCore/NIOScheduledCallback.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the SwiftNIO open source project
//
// Copyright (c) 2024 Apple Inc. and the SwiftNIO project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of SwiftNIO project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

/// A type that handles callbacks scheduled with `EventLoop.scheduleCallback(at:handler:)`.
///
/// - Seealso: `EventLoop.scheduleCallback(at:handler:)`.
public protocol NIOScheduledCallbackHandler {
/// This function is called at the scheduled time, unless the scheduled callback is cancelled.
///
/// - Parameter eventLoop: The event loop on which the callback was scheduled.
func handleScheduledCallback(eventLoop: some EventLoop)
simonjbeaumont marked this conversation as resolved.
Show resolved Hide resolved
FranzBusch marked this conversation as resolved.
Show resolved Hide resolved

/// This function is called if the scheduled callback is cancelled.
///
/// The callback could be cancelled explictily, by the user calling ``NIOScheduledCallback/cancel()``, or
/// implicitly, if it was still pending when the event loop was shut down.
///
/// - Parameter eventLoop: The event loop on which the callback was scheduled.
func onCancelScheduledCallback(eventLoop: some EventLoop)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ultra naming nit: Should this be didCancelScheduledCallback? This follows how Swift APIs are often called when something did happen. on is more used when it is about to happen or in the process of happening. e.g. viewDidLoad in UIKit vs onAppear in SwiftUI

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that on is also used for when something has happened but is more ambiguous about whether it's after. IIRC it's will that implies before. E.g. willSet and didSet.

A quick git-grep in the NIO repo shows a use of both on and did quite a bit.

However, it's fair that the only public API uses did:

Sources/NIOCore/AsyncSequences/NIOAsyncSequenceProducerStrategies.swift:37:        public mutating func didYield(bufferDepth: Int) -> Bool {
Sources/NIOCore/AsyncSequences/NIOAsyncSequenceProducerStrategies.swift:42:        public mutating func didConsume(bufferDepth: Int) -> Bool {
Sources/NIOCore/AsyncSequences/NIOAsyncWriter.swift:70:    public func didYield(_ element: Element) {
Sources/NIOPosix/PendingDatagramWritesManager.swift:249:    public mutating func didWrite(
Sources/NIOPosix/PendingWritesManager.swift:201:    public mutating func didWrite(

I can change to using did 😅

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

}

extension NIOScheduledCallbackHandler {
/// Default implementation of `onCancelScheduledCallback(eventLoop:)`: does nothing.
public func onCancelScheduledCallback(eventLoop: some EventLoop) {}
}

/// An opaque handle that can be used to cancel a scheduled callback.
///
/// Users should not create an instance of this type; it is returned by `EventLoop.scheduleCallback(at:handler:)`.
///
/// - Seealso: `EventLoop.scheduleCallback(at:handler:)`.
public struct NIOScheduledCallback: Sendable {
@usableFromInline
enum Backing: Sendable {
/// A task created using `EventLoop.scheduleTask(deadline:_:)` by the default implementation.
case `default`(_ task: Scheduled<Void>)
/// A custom callback identifier, used by event loops that provide a custom implementation.
case custom(id: UInt64)
}

@usableFromInline
var eventLoop: any EventLoop

@usableFromInline
var backing: Backing

/// This initializer is only for the default implementation and is fileprivate to avoid use in EL implementations.
fileprivate init(_ eventLoop: any EventLoop, _ task: Scheduled<Void>) {
self.eventLoop = eventLoop
self.backing = .default(task)
}

/// Create a handle for the scheduled callback with an opaque identifier managed by the event loop.
///
/// - NOTE: This initializer is for event loop implementors only, end users should use `EventLoop.scheduleCallback`.
///
/// - Seealso: `EventLoop.scheduleCallback(at:handler:)`.
@inlinable
public init(_ eventLoop: any EventLoop, id: UInt64) {
self.eventLoop = eventLoop
self.backing = .custom(id: id)
}

/// Cancel the scheduled callback associated with this handle.
@inlinable
public func cancel() {
self.eventLoop.cancelScheduledCallback(self)
}

/// The callback identifier, if the event loop uses a custom scheduled callback implementation; nil otherwise.
///
/// - NOTE: This property is for event loop implementors only.
@inlinable
public var customCallbackID: UInt64? {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: Do we need the custom here in the naming?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO it adds value when glancing at it as the property name implies that it's only relevant for custom implementations. How strongly do you feel about it. It's public API so if there's a consensus that this needs a different name I'll suck it up 😄

guard case .custom(let id) = self.backing else { return nil }
return id
}
}

extension EventLoop {
// This could be package once we drop Swift 5.8.
public func _scheduleCallback(
at deadline: NIODeadline,
handler: some NIOScheduledCallbackHandler
) -> NIOScheduledCallback {
let task = self.scheduleTask(deadline: deadline) { handler.handleScheduledCallback(eventLoop: self) }
task.futureResult.whenFailure { error in
if case .cancelled = error as? EventLoopError {
handler.onCancelScheduledCallback(eventLoop: self)
}
}
return NIOScheduledCallback(self, task)
}

/// Default implementation of `scheduleCallback(at deadline:handler:)`: backed by `EventLoop.scheduleTask`.
///
/// Ideally the scheduled callback handler should be called exactly once for each call to `scheduleCallback`:
/// either the callback handler, or the cancellation handler.
///
/// In order to support cancellation in the default implementation, we hook the future of the scheduled task
/// backing the scheduled callback. This requires two calls to the event loop: `EventLoop.scheduleTask`, and
/// `EventLoopFuture.whenFailure`, both of which queue onto the event loop if called from off the event loop.
///
/// This can present a challenge during event loop shutdown, where typically:
/// 1. Scheduled work that is past its deadline gets run.
/// 2. Scheduled future work gets cancelled.
/// 3. New work resulting from (1) and (2) gets handled differently depending on the EL:
/// a. `SelectableEventLoop` runs new work recursively and crashes if not quiesced in some number of ticks.
/// b. `EmbeddedEventLoop` and `NIOAsyncTestingEventLoop` will fail incoming work.
///
/// `SelectableEventLoop` has a custom implementation for scheduled callbacks so warrants no further discussion.
///
/// As a practical matter, the `EmbeddedEventLoop` is OK because it shares the thread of the caller, but for
/// other event loops (including any outside this repo), it's possible that the call to shutdown interleaves
/// with the call to create the scheduled task and the call to hook the task future.
///
/// Because this API is synchronous and we cannot block the calling thread, users of event loops with this
/// default implementation will have cancellation callbacks delivered on a best-effort basis when the event loop
/// is shutdown and depends on how the event loop deals with newly scheduled tasks during shutdown.
///
/// The implementation of this default conformance has been further factored out so we can use it in
/// `NIOAsyncTestingEventLoop`, where the use of `wait()` is _less bad_.
@discardableResult
public func scheduleCallback(
at deadline: NIODeadline,
handler: some NIOScheduledCallbackHandler
) -> NIOScheduledCallback {
self._scheduleCallback(at: deadline, handler: handler)
}

/// Default implementation of `scheduleCallback(in amount:handler:)`: calls `scheduleCallback(at deadline:handler:)`.
@discardableResult
@inlinable
public func scheduleCallback(
in amount: TimeAmount,
handler: some NIOScheduledCallbackHandler
) throws -> NIOScheduledCallback {
try self.scheduleCallback(at: .now() + amount, handler: handler)
}

/// Default implementation of `cancelScheduledCallback(_:)`: only cancels callbacks scheduled by the default implementation of `scheduleCallback`.
///
/// - NOTE: Event loops that provide a custom scheduled callback implementation **must** implement _both_
/// `sheduleCallback(at deadline:handler:)` _and_ `cancelScheduledCallback(_:)`. Failure to do so will
/// result in a runtime error.
@inlinable
public func cancelScheduledCallback(_ scheduledCallback: NIOScheduledCallback) {
switch scheduledCallback.backing {
case .default(let task):
task.cancel()
case .custom:
preconditionFailure("EventLoop missing custom implementation of cancelScheduledCallback(_:)")
}
}
}
32 changes: 32 additions & 0 deletions Sources/NIOEmbedded/AsyncTestingEventLoop.swift
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,38 @@ public final class NIOAsyncTestingEventLoop: EventLoop, @unchecked Sendable {
self.scheduleTask(deadline: self.now + `in`, task)
}

public func scheduleCallback(
at deadline: NIODeadline,
handler: some NIOScheduledCallbackHandler
) throws -> NIOScheduledCallback {
/// The default implementation of `scheduledCallback(at:handler)` makes two calls to the event loop because it
/// needs to hook the future of the backing scheduled task, which can lead to lost cancellation callbacks when
/// callbacks are scheduled close to event loop shutdown.
///
/// We work around this here by using a blocking `wait()` if we are not on the event loop, which would be very
/// bad in areal event loop, but _less bad_ for testing.
///
/// For more details, see the documentation attached to the default implementation.
if self.inEventLoop {
return self._scheduleCallback(at: deadline, handler: handler)
} else {
return try self.submit {
self._scheduleCallback(at: deadline, handler: handler)
}.wait()
}
}

@discardableResult
public func scheduleCallback(
in amount: TimeAmount,
handler: some NIOScheduledCallbackHandler
) throws -> NIOScheduledCallback {
/// Even though this type does not implement a custom `scheduleCallback(at:handler)`, it uses a manual clock so
/// it cannot rely on the default implementation of `scheduleCallback(in:handler:)`, which computes the deadline
/// as an offset from `NIODeadline.now`. This event loop needs the deadline to be offset from `self.now`.
try self.scheduleCallback(at: self.now + amount, handler: handler)
}

/// On an `NIOAsyncTestingEventLoop`, `execute` will simply use `scheduleTask` with a deadline of _now_. Unlike with the other operations, this will
/// immediately execute, to eliminate a common class of bugs.
public func execute(_ task: @escaping () -> Void) {
Expand Down
11 changes: 11 additions & 0 deletions Sources/NIOEmbedded/Embedded.swift
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,17 @@ public final class EmbeddedEventLoop: EventLoop {
scheduleTask(deadline: self._now + `in`, task)
}

@discardableResult
public func scheduleCallback(
in amount: TimeAmount,
handler: some NIOScheduledCallbackHandler
) -> NIOScheduledCallback {
/// Even though this type does not implement a custom `scheduleCallback(at:handler)`, it uses a manual clock so
/// it cannot rely on the default implementation of `scheduleCallback(in:handler:)`, which computes the deadline
/// as an offset from `NIODeadline.now`. This event loop needs the deadline to be offset from `self._now`.
self.scheduleCallback(at: self._now + amount, handler: handler)
}

/// On an `EmbeddedEventLoop`, `execute` will simply use `scheduleTask` with a deadline of _now_. This means that
/// `task` will be run the next time you call `EmbeddedEventLoop.run`.
public func execute(_ task: @escaping () -> Void) {
Expand Down
22 changes: 16 additions & 6 deletions Sources/NIOPosix/MultiThreadedEventLoopGroup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -508,28 +508,38 @@ struct ErasedUnownedJob {

@usableFromInline
internal struct ScheduledTask {
@usableFromInline
enum Kind {
case task(task: () -> Void, failFn: (Error) -> Void)
case callback(any NIOScheduledCallbackHandler)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One performance thought here. Currently we are storing an existential callback handler. However, we could just store the two closures itself which we can get while we are in the generic method. This way we would avoid calling through an existential on every scheduled callback task.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The requirements for protocol NIOScheduledCallbackHandler are generic functions. Is there a way I can store these as closures in the enum associated value, without making the Kind generic?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I forgot to add the second half of this. Yes this is why I think we should go back to any EventLoop. I assume the perf benefit outweighs this. This is an assumption but the scheduling and running of tasks is probably hotter than whatever we do in their callback with the passed EL. @Lukasa WDYT?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The functions being generic don't really matter here: we can store generic closures in the Kind type without promoting the generic to the Kind type itself. We won't get specialization, but that's fine.

So TL;DR: yes, changing ScheduledEventLoop's representation to a pair of closures is probably the right thing to do.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I spent some time working this and couldn't come up with a representation of this form that resulted in the amortized zero allocations we were looking for. Shall we shelve the suggestion on this thread? IIUC this is all internal anyway so we're not painting ourselves into a corner AFAICT?

}

@usableFromInline
let kind: Kind

/// The id of the scheduled task.
///
/// - Important: This id has two purposes. First, it is used to give this struct an identity so that we can implement ``Equatable``
/// Second, it is used to give the tasks an order which we use to execute them.
/// This means, the ids need to be unique for a given ``SelectableEventLoop`` and they need to be in ascending order.
@usableFromInline
let id: UInt64
let task: () -> Void
private let failFn: (Error) -> Void

@usableFromInline
internal let readyTime: NIODeadline

@usableFromInline
init(id: UInt64, _ task: @escaping () -> Void, _ failFn: @escaping (Error) -> Void, _ time: NIODeadline) {
self.id = id
self.task = task
self.failFn = failFn
self.readyTime = time
self.kind = .task(task: task, failFn: failFn)
}

func fail(_ error: Error) {
failFn(error)
@usableFromInline
init(id: UInt64, _ handler: any NIOScheduledCallbackHandler, _ time: NIODeadline) {
self.id = id
self.readyTime = time
self.kind = .callback(handler)
}
}

Expand Down
Loading
Loading