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

Open
wants to merge 40 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
40 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
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
42 changes: 42 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,45 @@ let benchmarks = {
)
}
#endif

Benchmark(
"MTELG.scheduleTask(in:_:)",
configuration: Benchmark.Configuration(
metrics: [.mallocCountTotal, .cpuTotal],
simonjbeaumont marked this conversation as resolved.
Show resolved Hide resolved
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.stopMeasurement()
simonjbeaumont marked this conversation as resolved.
Show resolved Hide resolved
}

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

final class Timer: NIOTimerHandler {
func timerFired(loop: any EventLoop) {}
}
let timer = Timer()

benchmark.startMeasurement()
for _ in benchmark.scaledIterations {
let handle = loop.setTimer(for: .hours(1), timer)
simonjbeaumont marked this conversation as resolved.
Show resolved Hide resolved
}
benchmark.stopMeasurement()
}
}
8 changes: 8 additions & 0 deletions Sources/NIOCore/EventLoop.swift
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 356,14 @@ 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)?

/// Set a timer that will call a handler at the given time.
@discardableResult
func setTimer(for deadline: NIODeadline, _ handler: any NIOTimerHandler) -> NIOTimer
simonjbeaumont marked this conversation as resolved.
Show resolved Hide resolved
simonjbeaumont marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's add another bikeshed: is there any reason not to make this method generic?

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 in 06a4ce7. Although I didn't see much improvement from it.


/// Set a timer that will call a handler after a given amount of time.
@discardableResult
func setTimer(for duration: TimeAmount, _ handler: any NIOTimerHandler) -> NIOTimer
glbrntt marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Contributor

Choose a reason for hiding this comment

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

While I'm here, can I suggest that we should use different labels instead of for in both? That will help with tab-completion a bit.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hm, it felt OK to me. Would you prefer the following?

  • setTimer(for: TimeAmount, ...)
  • setTimer(forDeadline: NIODeadline, ...)

I'm thinking of how I set timers verbally on my phone I normally always use "set a timer for" regardless of whether I then say a duration or an absolute time.

Open to suggestions here, though :)

Copy link
Contributor

Choose a reason for hiding this comment

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

setTimer(for:) and setTimer(at:) is probably the easiest spelling distinction.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

https://github.com/apple/swift-nio/pull/2759/files/df3cdb1c1f1f3ce8fb66fd81cdfa0cef3318c984#r1664313726 resulted in new API spellings. But these do use different prepositions for the NIODeadline and TimeAmount variants: scheduleCallback(at:handler:) and scheduleCallback(in:handler:), respectively.

}

extension EventLoop {
Expand Down
94 changes: 94 additions & 0 deletions Sources/NIOCore/NIOTimer.swift
Original file line number Diff line number Diff line change
@@ -0,0 1,94 @@
//===----------------------------------------------------------------------===//
//
// 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 timer callbacks scheduled with ``EventLoop/setTimer(for:_:)-5e37g``.
///
/// - Seealso: ``EventLoop/setTimer(for:_:)-5e37g``.
public protocol NIOTimerHandler {
func timerFired(loop: any EventLoop)
simonjbeaumont marked this conversation as resolved.
Show resolved Hide resolved
Lukasa marked this conversation as resolved.
Show resolved Hide resolved
}

/// An opaque handle that can be used to cancel a timer.
///
/// Users cannot create an instance of this type; it is returned by ``EventLoop/setTimer(for:_:)-5e37g``.
///
/// - Seealso: ``EventLoop/setTimer(for:_:)-5e37g``.
public struct NIOTimer {
simonjbeaumont marked this conversation as resolved.
Show resolved Hide resolved
Lukasa marked this conversation as resolved.
Show resolved Hide resolved
@usableFromInline
enum Backing {
/// A task created using `EventLoop.scheduleTask(deadline:_:)`, used by default for `EventLoop` implementations.
case scheduledTask(Scheduled<Void>)
/// An identifier for a timer, used by `EventLoop` implementations that conform to `CustomTimerImplementation`.
case custom(eventLoop: any NIOCustomTimerImplementation, id: UInt64)
simonjbeaumont marked this conversation as resolved.
Show resolved Hide resolved
}

@usableFromInline
var backing: Backing

fileprivate init(_ scheduled: Scheduled<Void>) {
self.backing = .scheduledTask(scheduled)
}

@inlinable
init(_ eventLoop: any NIOCustomTimerImplementation, id: UInt64) {
self.backing = .custom(eventLoop: eventLoop, id: id)
}

/// Cancel the timer associated with this handle.
@inlinable
public func cancel() {
simonjbeaumont marked this conversation as resolved.
Show resolved Hide resolved
switch self.backing {
case .scheduledTask(let scheduled):
scheduled.cancel()
case .custom(let eventLoop, let id):
eventLoop.cancelTimer(id)
}
}
}

/// Default implementation of `setSimpleTimer(for deadline:_:)`, backed by `EventLoop.scheduleTask`.
extension EventLoop {
@discardableResult
public func setTimer(for deadline: NIODeadline, _ handler: any NIOTimerHandler) -> NIOTimer {
NIOTimer(self.scheduleTask(deadline: deadline) { handler.timerFired(loop: self) })
}
}

/// Default implementation of `setSimpleTimer(for duration:_:)`, delegating to `setSimpleTimer(for deadline:_:)`.
extension EventLoop {
@discardableResult
@inlinable
public func setTimer(for duration: TimeAmount, _ handler: any NIOTimerHandler) -> NIOTimer {
self.setTimer(for: .now() duration, handler)
}
}

/// Extension point for `EventLoop` implementations implement a custom timer.
public protocol NIOCustomTimerImplementation: EventLoop {
simonjbeaumont marked this conversation as resolved.
Show resolved Hide resolved
/// Set a timer that calls handler at the given time.
///
/// Implementations must return an integer identifier that uniquely identifies the timer.
func setTimer(for deadline: NIODeadline, _ handler: any NIOTimerHandler) -> UInt64

/// Cancel a timer with a given timer identifier.
func cancelTimer(_ id: UInt64)
}

/// Default implementation of `setSimpleTimer(for deadline:_:)` for `EventLoop` types that opted in to `CustomeTimerImplementation`.
extension EventLoop where Self: NIOCustomTimerImplementation {
@inlinable
public func setTimer(for deadline: NIODeadline, _ handler: any NIOTimerHandler) -> NIOTimer {
NIOTimer(self, id: self.setTimer(for: deadline, handler))
}
}
8 changes: 8 additions & 0 deletions Sources/NIOEmbedded/AsyncTestingEventLoop.swift
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 171,14 @@ public final class NIOAsyncTestingEventLoop: EventLoop, @unchecked Sendable {
return self.scheduleTask(deadline: self.now `in`, task)
}

@discardableResult
public func setTimer(for duration: TimeAmount, _ handler: any NIOTimerHandler) -> NIOTimer {
/// Even though this type does not conform to `CustomTimerImplemenation`, it has a manual clock so we cannot
simonjbeaumont marked this conversation as resolved.
Show resolved Hide resolved
/// rely on the default implemntation of `setTimer(for duration:_:)`, which computes the deadline for
/// `setTimer(for deadline:_:)` naively using `NIODeadline.now`, but we must use `self.now`.
self.setTimer(for: self.now duration, 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
8 changes: 8 additions & 0 deletions Sources/NIOEmbedded/Embedded.swift
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 127,14 @@ public final class EmbeddedEventLoop: EventLoop {
return scheduleTask(deadline: self._now `in`, task)
}

@discardableResult
public func setTimer(for duration: TimeAmount, _ handler: any NIOTimerHandler) -> NIOTimer {
/// Even though this type does not conform to `CustomTimerImplemenation`, it has a manual clock so we cannot
/// rely on the default implemntation of `setTimer(for duration:_:)`, which computes the deadline for
/// `setTimer(for deadline:_:)` naively using `NIODeadline.now`, but we must use `self._now`.
self.setTimer(for: self._now duration, 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
23 changes: 17 additions & 6 deletions Sources/NIOPosix/MultiThreadedEventLoopGroup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -460,21 460,32 @@ internal struct ScheduledTask {
/// 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
enum Kind {
simonjbeaumont marked this conversation as resolved.
Show resolved Hide resolved
case task(task: () -> Void, failFn: (Error) -> Void)
case timer(any NIOTimerHandler)
}

@usableFromInline
let kind: Kind

// TODO: Should these be .init() or should they be static functions?
simonjbeaumont marked this conversation as resolved.
Show resolved Hide resolved
simonjbeaumont marked this conversation as resolved.
Show resolved Hide resolved
@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 NIOTimerHandler, _ time: NIODeadline) {
self.id = id
self.readyTime = time
self.kind = .timer(handler)
}
}

Expand Down
34 changes: 31 additions & 3 deletions Sources/NIOPosix/SelectableEventLoop.swift
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 474,8 @@ Further information:
fatalError("Tried to run an UnownedJob without runtime support")
}
#endif
case .timer(let handler):
handler.timerFired(loop: self)
}
}
}
Expand Down Expand Up @@ -565,7 567,10 @@ Further information:
if moreScheduledTasksToConsider, tasksCopy.count < tasksCopyBatchSize, let task = scheduledTasks.peek() {
if task.readyTime.readyIn(now) <= .nanoseconds(0) {
scheduledTasks.pop()
tasksCopy.append(.function(task.task))
switch task.kind {
case .task(let task, _): tasksCopy.append(.function(task))
case .timer(let handler): tasksCopy.append(.timer(handler))
}
} else {
nextScheduledTaskDeadline = task.readyTime
moreScheduledTasksToConsider = false
Expand Down Expand Up @@ -658,9 663,14 @@ Further information:
for task in immediateTasksCopy {
self.run(task)
}
// Fail all the scheduled tasks.
// Fail all the scheduled tasks (timers have no failFn and can just be dropped).
for task in scheduledTasksCopy {
task.fail(EventLoopError.shutdown)
switch task.kind {
case .task(_, let failFn):
failFn(EventLoopError.shutdown)
case .timer:
break
}
}

iterations = 1
Expand Down Expand Up @@ -869,6 879,7 @@ enum UnderlyingTask {
#if compiler(>=5.9)
case unownedJob(ErasedUnownedJob)
#endif
case timer(any NIOTimerHandler)
}

@usableFromInline
Expand All @@ -883,3 894,20 @@ internal func assertExpression(_ body: () -> Bool) {
return body()
}())
}

extension SelectableEventLoop: NIOCustomTimerImplementation {
@inlinable
internal func setTimer(for deadline: NIODeadline, _ handler: any NIOTimerHandler) -> UInt64 {
let taskId = self.scheduledTaskCounter.loadThenWrappingIncrement(ordering: .relaxed)
simonjbeaumont marked this conversation as resolved.
Show resolved Hide resolved
simonjbeaumont marked this conversation as resolved.
Show resolved Hide resolved
let task = ScheduledTask(id: taskId, handler, deadline)
try! self._schedule0(.scheduled(task))
simonjbeaumont marked this conversation as resolved.
Show resolved Hide resolved
return taskId
}

@inlinable
internal func cancelTimer(_ id: UInt64) {
self._tasksLock.withLock {
self._scheduledTasks.removeFirst(where: { $0.id == id })
}
simonjbeaumont marked this conversation as resolved.
Show resolved Hide resolved
}
}
Loading