Skip to content

Commit

Permalink
NIOSendableBox: allow off-loop initialisation iff Value is Sendable (#…
Browse files Browse the repository at this point in the history
…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.
  • Loading branch information
weissi authored Jun 27, 2024
1 parent 5d7a999 commit fc79798
Show file tree
Hide file tree
Showing 2 changed files with 39 additions and 0 deletions.
20 changes: 20 additions & 0 deletions Sources/NIOCore/NIOLoopBound.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,26 @@ public final class NIOLoopBoundBox<Value>: @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<Value> 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.
Expand Down
19 changes: 19 additions & 0 deletions Tests/NIOPosixTests/NIOLoopBoundTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<S: Sendable>(_ sendableThing: S) {}

Expand Down

0 comments on commit fc79798

Please sign in to comment.