From fc79798d5a150d61361a27ce0c51169b889e23de Mon Sep 17 00:00:00 2001 From: Johannes Weiss Date: Thu, 27 Jun 2024 15:51:24 +0100 Subject: [PATCH] NIOSendableBox: allow off-loop initialisation iff Value is Sendable (#2753) ### Motivation: `NIOLoopBound` and `NIOLoopBoundBox` are important tools for making mutable types `Sendable`. The power of `NIOLoopBoundBox` is that it allows correct mutation, whilst remaining `Sendable` without locks and without having to use `@unchecked` if the mutable class makes sure to have all accesses (reading & writing) run on one particular `EventLoop`. This is safe as `EventLoop`s guarantee sequentially consistent memory order across executions of different work items/events. Typically `EventLoop`s achieve this by just being thread-bound. These types are well used already but there's a small and common pattern which is safe but unsupported as of yet: Initialise a `NIOLoopBoundBox` with a `Sendable` value _off_ the loop. All further accesses (reads & writes) however happen _on_ the loop. That's safe because of Swift's Definitive Initialisation (DI) but `NIOLoopBoundBox` didn't support this pattern (apart from `makeEmptyBox` which always initialises with `nil`). ### Modifications: - Allow `Sendable` values to be provided whilst creating the type off loop. ### Result: - Even more types can be safely & correctly made `Sendable` without using `@unchecked. --- Sources/NIOCore/NIOLoopBound.swift | 20 ++++++++++++++++++++ Tests/NIOPosixTests/NIOLoopBoundTests.swift | 19 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/Sources/NIOCore/NIOLoopBound.swift b/Sources/NIOCore/NIOLoopBound.swift index c105f361d1..a3631f5b2f 100644 --- a/Sources/NIOCore/NIOLoopBound.swift +++ b/Sources/NIOCore/NIOLoopBound.swift @@ -107,6 +107,26 @@ public final class NIOLoopBoundBox: @unchecked Sendable { return .init(_value: nil, uncheckedEventLoop: eventLoop) } + /// Initialise a ``NIOLoopBoundBox`` by sending a `Sendable` value, validly callable off `eventLoop`. + /// + /// Contrary to ``init(_:eventLoop:)``, this method can be called off `eventLoop` because we know that `value` is `Sendable`. + /// So we don't need to protect `value` itself, we just need to protect the ``NIOLoopBoundBox`` against mutations which we do because the ``value`` + /// accessors are checking that we're on `eventLoop`. + public static func makeBoxSendingValue( + _ value: Value, + as: Value.Type = Value.self, + eventLoop: EventLoop + ) -> NIOLoopBoundBox where Value: Sendable { + // Here, we -- possibly surprisingly -- do not precondition being on the EventLoop. This is okay for a few + // reasons: + // - This function only works with `Sendable` values, so we don't need to worry about somebody + // still holding a reference to this. + // - Because of Swift's Definitive Initialisation (DI), we know that we did write `self._value` before `init` + // returns. + // - The only way to ever write (or read indeed) `self._value` is by proving to be inside the `EventLoop`. + return .init(_value: value, uncheckedEventLoop: eventLoop) + } + /// Access the `value` with the precondition that the code is running on `eventLoop`. /// /// - note: ``NIOLoopBoundBox`` itself is reference-typed, so any writes will affect anybody sharing this reference. diff --git a/Tests/NIOPosixTests/NIOLoopBoundTests.swift b/Tests/NIOPosixTests/NIOLoopBoundTests.swift index 4785c5d4dd..0c525125ad 100644 --- a/Tests/NIOPosixTests/NIOLoopBoundTests.swift +++ b/Tests/NIOPosixTests/NIOLoopBoundTests.swift @@ -49,6 +49,25 @@ final class NIOLoopBoundTests: XCTestCase { }.wait()) } + func testLoopBoundBoxCanBeInitialisedWithSendableValueOffLoopAndLaterSetToValue() { + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + defer { + XCTAssertNoThrow(try group.syncShutdownGracefully()) + } + + let loop = group.any() + + let sendableBox = NIOLoopBoundBox.makeBoxSendingValue(15, as: Int.self, eventLoop: loop) + for _ in 0..<(100 - 15) { + loop.execute { + sendableBox.value += 1 + } + } + XCTAssertEqual(100, try loop.submit { + sendableBox.value + }.wait()) + } + // MARK: - Helpers func sendableBlackhole(_ sendableThing: S) {}