Skip to content

Commit 9afaf80

Browse files
authored
Don't retain a task when all we want is a time (#2373)
Motivation: To know when we next need to wake up, we keep track of what the next deadline will be. This works great, but in order to keep track of this UInt64 we save off an entire ScheduledTask. This object is quite wide (6 pointers wide), and two of those pointers require ARC traffic, so doing this saving produces unnecessary overhead. Worse, saving this task plays poorly with task cancellation. If the saved task is cancelled, this has the effect of "retaining" that task until the next event loop tick. This is unlikely to produce catastrophic bugs in real programs, where the loop does tick, but it violates our tests which rigorously assume that we will always drop a task when it is cancelled. In specific manufactured cases it's possible to produce leaks of non-trivial duration. Modifications: - Wrote a weirdly complex test. - Moved the implementation of Task.readyIn to a method on NIODeadline - Saved a NIODeadline instead of a ScheduledTask Result: Minor performance improvement in the core event loop processing, minor correctness improvement.
1 parent 5db1dfa commit 9afaf80

File tree

4 files changed

+97
-23
lines changed

4 files changed

+97
-23
lines changed

Sources/NIOPosix/MultiThreadedEventLoopGroup.swift

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -378,21 +378,14 @@ internal struct ScheduledTask {
378378
let task: () -> Void
379379
private let failFn: (Error) ->()
380380
@usableFromInline
381-
internal let _readyTime: NIODeadline
381+
internal let readyTime: NIODeadline
382382

383383
@usableFromInline
384384
init(id: UInt64, _ task: @escaping () -> Void, _ failFn: @escaping (Error) -> Void, _ time: NIODeadline) {
385385
self.id = id
386386
self.task = task
387387
self.failFn = failFn
388-
self._readyTime = time
389-
}
390-
391-
func readyIn(_ t: NIODeadline) -> TimeAmount {
392-
if _readyTime < t {
393-
return .nanoseconds(0)
394-
}
395-
return _readyTime - t
388+
self.readyTime = time
396389
}
397390

398391
func fail(_ error: Error) {
@@ -403,17 +396,17 @@ internal struct ScheduledTask {
403396
extension ScheduledTask: CustomStringConvertible {
404397
@usableFromInline
405398
var description: String {
406-
return "ScheduledTask(readyTime: \(self._readyTime))"
399+
return "ScheduledTask(readyTime: \(self.readyTime))"
407400
}
408401
}
409402

410403
extension ScheduledTask: Comparable {
411404
@usableFromInline
412405
static func < (lhs: ScheduledTask, rhs: ScheduledTask) -> Bool {
413-
if lhs._readyTime == rhs._readyTime {
406+
if lhs.readyTime == rhs.readyTime {
414407
return lhs.id < rhs.id
415408
} else {
416-
return lhs._readyTime < rhs._readyTime
409+
return lhs.readyTime < rhs.readyTime
417410
}
418411
}
419412

@@ -422,3 +415,12 @@ extension ScheduledTask: Comparable {
422415
return lhs.id == rhs.id
423416
}
424417
}
418+
419+
extension NIODeadline {
420+
func readyIn(_ target: NIODeadline) -> TimeAmount {
421+
if self < target {
422+
return .nanoseconds(0)
423+
}
424+
return self - target
425+
}
426+
}

Sources/NIOPosix/SelectableEventLoop.swift

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -402,14 +402,14 @@ Further information:
402402
}
403403
}
404404

405-
private func currentSelectorStrategy(nextReadyTask: ScheduledTask?) -> SelectorStrategy {
406-
guard let sched = nextReadyTask else {
405+
private func currentSelectorStrategy(nextReadyDeadline: NIODeadline?) -> SelectorStrategy {
406+
guard let deadline = nextReadyDeadline else {
407407
// No tasks to handle so just block. If any tasks were added in the meantime wakeup(...) was called and so this
408408
// will directly unblock.
409409
return .block
410410
}
411411

412-
let nextReady = sched.readyIn(.now())
412+
let nextReady = deadline.readyIn(.now())
413413
if nextReady <= .nanoseconds(0) {
414414
// Something is ready to be processed just do a non-blocking select of events.
415415
return .now
@@ -449,23 +449,23 @@ Further information:
449449
assert(self.internalState == .noLongerRunning, "illegal state: \(self.internalState)")
450450
self.internalState = .exitingThread
451451
}
452-
var nextReadyTask: ScheduledTask? = nil
452+
var nextReadyDeadline: NIODeadline? = nil
453453
self._tasksLock.withLock {
454454
if let firstTask = self._scheduledTasks.peek() {
455455
// The reason this is necessary is a very interesting race:
456456
// In theory (and with `makeEventLoopFromCallingThread` even in practise), we could publish an
457457
// `EventLoop` reference _before_ the EL thread has entered the `run` function.
458458
// If that is the case, we need to schedule the first wakeup at the ready time for this task that was
459459
// enqueued really early on, so let's do that :).
460-
nextReadyTask = firstTask
460+
nextReadyDeadline = firstTask.readyTime
461461
}
462462
}
463463
while self.internalState != .noLongerRunning && self.internalState != .exitingThread {
464464
// Block until there are events to handle or the selector was woken up
465465
/* for macOS: in case any calls we make to Foundation put objects into an autoreleasepool */
466466
try withAutoReleasePool {
467467
try self._selector.whenReady(
468-
strategy: currentSelectorStrategy(nextReadyTask: nextReadyTask),
468+
strategy: currentSelectorStrategy(nextReadyDeadline: nextReadyDeadline),
469469
onLoopBegin: { self._tasksLock.withLock { () -> Void in self._pendingTaskPop = true } }
470470
) { ev in
471471
switch ev.registration.channel {
@@ -498,17 +498,17 @@ Further information:
498498

499499
// Make a copy of the tasks so we can execute these while not holding the lock anymore
500500
while tasksCopy.count < tasksCopy.capacity, let task = self._scheduledTasks.peek() {
501-
if task.readyIn(now) <= .nanoseconds(0) {
501+
if task.readyTime.readyIn(now) <= .nanoseconds(0) {
502502
self._scheduledTasks.pop()
503503
self.tasksCopy.append(task)
504504
} else {
505-
nextReadyTask = task
505+
nextReadyDeadline = task.readyTime
506506
break
507507
}
508508
}
509509
} else {
510-
// Reset nextReadyTask to nil which means we will do a blocking select.
511-
nextReadyTask = nil
510+
// Reset nextreadyDeadline to nil which means we will do a blocking select.
511+
nextReadyDeadline = nil
512512
}
513513

514514
if self.tasksCopy.isEmpty {

Tests/NIOPosixTests/EventLoopTest+XCTest.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
//
33
// This source file is part of the SwiftNIO open source project
44
//
5-
// Copyright (c) 2017-2022 Apple Inc. and the SwiftNIO project authors
5+
// Copyright (c) 2017-2023 Apple Inc. and the SwiftNIO project authors
66
// Licensed under Apache License v2.0
77
//
88
// See LICENSE.txt for license information
@@ -56,6 +56,7 @@ extension EventLoopTest {
5656
("testRepeatedTaskThatIsCancelledAfterRunningAtLeastTwiceNotifies", testRepeatedTaskThatIsCancelledAfterRunningAtLeastTwiceNotifies),
5757
("testRepeatedTaskThatCancelsItselfNotifiesOnlyWhenFinished", testRepeatedTaskThatCancelsItselfNotifiesOnlyWhenFinished),
5858
("testCancelledScheduledTasksDoNotHoldOnToRunClosure", testCancelledScheduledTasksDoNotHoldOnToRunClosure),
59+
("testCancelledScheduledTasksDoNotHoldOnToRunClosureEvenIfTheyWereTheNextTaskToExecute", testCancelledScheduledTasksDoNotHoldOnToRunClosureEvenIfTheyWereTheNextTaskToExecute),
5960
("testIllegalCloseOfEventLoopFails", testIllegalCloseOfEventLoopFails),
6061
("testSubtractingDeadlineFromPastAndFuturesDeadlinesWorks", testSubtractingDeadlineFromPastAndFuturesDeadlinesWorks),
6162
("testCallingSyncShutdownGracefullyMultipleTimesShouldNotHang", testCallingSyncShutdownGracefullyMultipleTimesShouldNotHang),

Tests/NIOPosixTests/EventLoopTest.swift

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -830,6 +830,77 @@ public final class EventLoopTest : XCTestCase {
830830
}
831831
}
832832

833+
func testCancelledScheduledTasksDoNotHoldOnToRunClosureEvenIfTheyWereTheNextTaskToExecute() {
834+
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
835+
defer {
836+
XCTAssertNoThrow(try group.syncShutdownGracefully())
837+
}
838+
839+
class Thing {
840+
private let deallocated: ConditionLock<Int>
841+
842+
init(_ deallocated: ConditionLock<Int>) {
843+
self.deallocated = deallocated
844+
}
845+
846+
deinit {
847+
self.deallocated.lock()
848+
self.deallocated.unlock(withValue: 1)
849+
}
850+
}
851+
852+
func make(deallocated: ConditionLock<Int>) -> Scheduled<Never> {
853+
let aThing = Thing(deallocated)
854+
return group.next().scheduleTask(in: .hours(1)) {
855+
preconditionFailure("this should definitely not run: \(aThing)")
856+
}
857+
}
858+
859+
// What the heck are we doing here?
860+
//
861+
// Our goal is to arrange for our scheduled task to become "nextReadyTask" in SelectableEventLoop, so that
862+
// when we cancel it there is still a copy aliasing it. This reproduces a subtle correctness bug that
863+
// existed in NIO 2.48.0 and earlier.
864+
//
865+
// This will happen if:
866+
//
867+
// 1. We schedule a task for the future
868+
// 2. The event loop begins a tick.
869+
// 3. The event loop finds our scheduled task in the future.
870+
//
871+
// We can make that happen by scheduling our task and then waiting for a tick to pass, which we can
872+
// achieve using `submit`.
873+
//
874+
// However, if there are no _other_, _even later_ tasks, we'll free the reference. This is
875+
// because the nextReadyTask is cleared if the list of scheduled tasks ends up empty, so we don't want that to happen.
876+
//
877+
// So the order of operations is:
878+
//
879+
// 1. Schedule the task for the future.
880+
// 2. Schedule another, even later, task.
881+
// 3. Wait for a tick to pass.
882+
// 4. Cancel our scheduled.
883+
//
884+
// In the correct code, this should invoke deinit. In the buggy code, it does not.
885+
//
886+
// Unfortunately, this window is very hard to hit. Cancelling the scheduled task wakes the loop up, and if it is
887+
// still awake by the time we run the cancellation handler it'll notice the change. So we have to tolerate
888+
// a somewhat flaky test.
889+
let deallocated = ConditionLock(value: 0)
890+
let scheduled = make(deallocated: deallocated)
891+
scheduled.futureResult.eventLoop.scheduleTask(in: .hours(2)) { }
892+
try! scheduled.futureResult.eventLoop.submit { }.wait()
893+
scheduled.cancel()
894+
if deallocated.lock(whenValue: 1, timeoutSeconds: 60) {
895+
deallocated.unlock()
896+
} else {
897+
XCTFail("Timed out waiting for lock")
898+
}
899+
XCTAssertThrowsError(try scheduled.futureResult.wait()) { error in
900+
XCTAssertEqual(EventLoopError.cancelled, error as? EventLoopError)
901+
}
902+
}
903+
833904
func testIllegalCloseOfEventLoopFails() {
834905
// Vapor 3 closes EventLoops directly which is illegal and makes the `shutdownGracefully` of the owning
835906
// MultiThreadedEventLoopGroup never succeed.

0 commit comments

Comments
 (0)