|
| 1 | +# EventLoops, EventLoopFutures, and Swift Concurrency |
| 2 | + |
| 3 | +This article aims to communicate how NIO's ``EventLoop``s and ``EventLoopFuture``s interact with the Swift 6 |
| 4 | +concurrency model, particularly regarding data-race safety. It aims to be a reference for writing correct |
| 5 | +concurrent code in the NIO model. |
| 6 | + |
| 7 | +NIO predates the Swift concurrency model. As a result, several of NIO's concepts are not perfect matches to |
| 8 | +the concepts that Swift uses, or have overlapping responsibilities. |
| 9 | + |
| 10 | +## Isolation domains and executors |
| 11 | + |
| 12 | +First, a quick recap. The core of Swift 6's data-race safety protection is the concept of an "isolation |
| 13 | +domain". Some valuable reading regarding the concept can be found in |
| 14 | +[SE-0414 (Region based isolation)](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0414-region-based-isolation.md) |
| 15 | +but at a high level an isolation domain can be understood to be a collection of state and methods within which there cannot be |
| 16 | +multiple executors executing code at the same time. |
| 17 | + |
| 18 | +In standard Swift Concurrency, the main boundaries of isolation domains are actors and tasks. Each actor, |
| 19 | +including global actors, defines an isolation domain. Additionally, for functions and methods that are |
| 20 | +not isolated to an actor, the `Task` within which that code executes defines an isolation domain. Passing |
| 21 | +values between these isolation domains requires that these values are either `Sendable` (safe to hold in |
| 22 | +multiple domains), or that the `sending` keyword is used to force the value to be passed from one domain |
| 23 | +to another. |
| 24 | + |
| 25 | +A related concept to an "isolation domain" is an "executor". Again, useful reading can be found in |
| 26 | +[SE-0392 (Custom actor executors)](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0392-custom-actor-executors.md). |
| 27 | +At a high level, an executor is simply an object that is capable of executing Swift `Task`s. Executors can be |
| 28 | +concurrent, or they can be serial. Serial executors are the most common, as they can be used to back an |
| 29 | +actor. |
| 30 | + |
| 31 | +## Event Loops |
| 32 | + |
| 33 | +NIO's core execution primitive is the ``EventLoop``. An ``EventLoop`` is fundamentally nothing more than |
| 34 | +a Swift Concurrency Serial Executor that can also perform I/O operations directly. Indeed, NIO's |
| 35 | +``EventLoop``s can be exposed as serial executors, using ``EventLoop/executor``. This provides a mechanism |
| 36 | +to protect actor-isolated state using a NIO event-loop. With [the introduction of task executors](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0417-task-executor-preference.md), |
| 37 | +future versions of SwiftNIO will also be able to offer their event loops for individual `Task`s to execute |
| 38 | +on as well. |
| 39 | + |
| 40 | +In a Swift 6 world, it is possible that these would be the API that NIO offered to execute tasks on the |
| 41 | +loop. However, as NIO predates Swift 6, it also offers its own set of APIs to enqueue work. This includes |
| 42 | +(but is not limited to): |
| 43 | + |
| 44 | +- ``EventLoop/execute(_:)`` |
| 45 | +- ``EventLoop/submit(_:)`` |
| 46 | +- ``EventLoop/scheduleTask(in:_:)`` |
| 47 | +- ``EventLoop/scheduleRepeatedTask(initialDelay:delay:notifying:_:)`` |
| 48 | +- ``EventLoop/scheduleCallback(at:handler:)-2xm6l`` |
| 49 | + |
| 50 | +The existence of these APIs requires us to also ask the question of where the submitted code executes. The |
| 51 | +answer is that the submitted code executes on the event loop (or, in Swift Concurrency terms, on the |
| 52 | +executor provided by the event loop). |
| 53 | + |
| 54 | +As the event loop only ever executes a single item of work (either an `async` function or one of the |
| 55 | +closures above) at a time, it is a _serial_ executor. It also provides an _isolation domain_: code |
| 56 | +submitted to a given `EventLoop` never runs in parallel with other code submitted to the same loop. |
| 57 | + |
| 58 | +The result here is that a all closures passed into the event loop to do work must be transferred |
| 59 | +in: they may not be kept hold of outside of the event loop. That means they must be sent using |
| 60 | +the `sending` keyword. |
| 61 | + |
| 62 | +> Note: As of the current 2.75.0 release, NIO enforces the stricter requirement that these closures |
| 63 | + are `@Sendable`. This is not a long-term position, but reflects the need to continue |
| 64 | + to support Swift 5 code which requires this stricter standard. In a future release of |
| 65 | + SwiftNIO we expect to relax this constraint: if you need this relaxed constraint |
| 66 | + then please file an issue. |
| 67 | + |
| 68 | +## Event loop futures |
| 69 | + |
| 70 | +In Swift NIO the most common mechanism to arrange a series of asynchronous work items is |
| 71 | +_not_ to queue up a series of ``EventLoop/execute(_:)`` calls. Instead, users typically |
| 72 | +use ``EventLoopFuture``. |
| 73 | + |
| 74 | +``EventLoopFuture`` has some extensive semantics documented in its API documentation. The |
| 75 | +most important principal for this discussion is that all callbacks added to an |
| 76 | +``EventLoopFuture`` will execute on the ``EventLoop`` to which that ``EventLoopFuture`` is |
| 77 | +bound. By extension, then, all callbacks added to an ``EventLoopFuture`` execute on the same |
| 78 | +executor (the ``EventLoop``) in the same isolation domain. |
| 79 | + |
| 80 | +The analogy to an actor here is hopefully fairly clear. Conceptually, an ``EventLoopFuture`` |
| 81 | +could be modelled as an actor. That means all the callbacks have the same logical semantics: |
| 82 | +the ``EventLoopFuture`` uses the isolation domain of its associated ``EventLoop``, and all |
| 83 | +the callbacks are `sent` into the isolation domain. To that end, all the callback-taking APIs |
| 84 | +require that the callback is sent using `sending` into the ``EventLoopFuture``. |
| 85 | + |
| 86 | +> Note: As of the current 2.75.0 release, NIO enforces the stricter requirement that these callbacks |
| 87 | + are `@Sendable`. This is not a long-term position, but reflects the need to continue |
| 88 | + to support Swift 5 code which requires this stricter standard. In a future release of |
| 89 | + SwiftNIO we expect to relax this constraint: if you need this relaxed constraint |
| 90 | + then please file an issue. |
| 91 | + |
| 92 | +Unlike ``EventLoop``s, however, ``EventLoopFuture``s also have value-receiving and value-taking |
| 93 | +APIs. This is because ``EventLoopFuture``s pass a value along to their various callbacks, and |
| 94 | +so need to be both given an initial value (via an ``EventLoopPromise``) and in some cases to |
| 95 | +extract that value from the ``EventLoopFuture`` wrapper. |
| 96 | + |
| 97 | +This implies that ``EventLoopPromise``'s various success functions |
| 98 | +(_and_ ``EventLoop/makeSucceededFuture(_:)``) need to take their value as `sending`. The value |
| 99 | +is potentially sent from its current isolation domain into the ``EventLoop``, which will require |
| 100 | +that the value is safe to move. |
| 101 | + |
| 102 | +> Note: As of the current 2.75.0 release, NIO enforces the stricter requirement that these values |
| 103 | + are `Sendable`. This is not a long-term position, but reflects the need to continue |
| 104 | + to support Swift 5 code which requires this stricter standard. In a future release of |
| 105 | + SwiftNIO we expect to relax this constraint: if you need this relaxed constraint |
| 106 | + then please file an issue. |
| 107 | + |
| 108 | +There are also a few ways to extract a value, such as ``EventLoopFuture/wait(file:line:)`` |
| 109 | +and ``EventLoopFuture/get()``. These APIs can only safely be called when the ``EventLoopFuture`` |
| 110 | +is carrying a `Sendable` value. This is because ``EventLoopFuture``s hold on to their value and |
| 111 | +can give it to other closures or other callers of `get` and `wait`. Thus, `sending` is not |
| 112 | +sufficient. |
| 113 | + |
| 114 | +## Combining Futures |
| 115 | + |
| 116 | +NIO provides a number of APIs for combining futures, such as ``EventLoopFuture/and(_:)``. |
| 117 | +This potentially represents an issue, as two futures may not share the same isolation domain. |
| 118 | +As a result, we can only safely call these combining functions when the ``EventLoopFuture`` |
| 119 | +values are `Sendable`. |
| 120 | + |
| 121 | +> Note: We can conceptually relax this constraint somewhat by offering equivalent |
| 122 | + functions that can only safely be called when all the combined futures share the |
| 123 | + same bound event loop: that is, when they are all within the same isolation domain. |
| 124 | + |
| 125 | + This can be enforced with runtime isolation checks. If you have a need for these |
| 126 | + functions, please reach out to the NIO team. |
| 127 | + |
| 128 | +## Interacting with Futures on the Event Loop |
| 129 | + |
| 130 | +In a number of contexts (such as in ``ChannelHandler``s), the programmer has static knowledge |
| 131 | +that they are within an isolation domain. That isolation domain may well be shared with the |
| 132 | +isolation domain of many futures and promises with which they interact. For example, |
| 133 | +futures that are provided from ``ChannelHandlerContext/write(_:promise:)`` will be bound to |
| 134 | +the event loop on which the ``ChannelHandler`` resides. |
| 135 | + |
| 136 | +In this context, the `sending` constraint is unnecessarily strict. The future callbacks are |
| 137 | +guaranteed to fire on the same isolation domain as the ``ChannelHandlerContext``: no risk |
| 138 | +of data race is present. However, Swift Concurrency cannot guarantee this at compile time, |
| 139 | +as the specific isolation domain is determined only at runtime. |
| 140 | + |
| 141 | +In these contexts, today users can make their callbacks safe using ``NIOLoopBound`` and |
| 142 | +``NIOLoopBoundBox``. These values can only be constructed on the event loop, and only allow |
| 143 | +access to their values on the same event loop. These constraints are enforced at runtime, |
| 144 | +so at compile time these are unconditionally `Sendable`. |
| 145 | + |
| 146 | +> Warning: ``NIOLoopBound`` and ``NIOLoopBoundBox`` replace compile-time isolation checks |
| 147 | + with runtime ones. This makes it possible to introduce crashes in your code. Please |
| 148 | + ensure that you are 100% confident that the isolation domains align. If you are not |
| 149 | + sure that the ``EventLoopFuture`` you wish to attach a callback to is bound to your |
| 150 | + ``EventLoop``, use ``EventLoopFuture/hop(to:)`` to move it to your isolation domain |
| 151 | + before using these types. |
| 152 | + |
| 153 | +> Note: In a future NIO release we intend to improve the ergonomics of this common problem |
| 154 | + by offering a related type that can only be created from an ``EventLoopFuture`` on a |
| 155 | + given ``EventLoop``. This minimises the number of runtime checks, and will make it |
| 156 | + easier and more pleasant to write this kind of code. |
| 157 | + |
| 158 | +## Interacting with Event Loops on the Event Loop |
| 159 | + |
| 160 | +As with Futures, there are occasionally times where it is necessary to schedule |
| 161 | +``EventLoop`` operations on the ``EventLoop`` where your code is currently executing. |
| 162 | + |
| 163 | +Much like with ``EventLoopFuture``, you can use ``NIOLoopBound`` and ``NIOLoopBoundBox`` |
| 164 | +to make these callbacks safe. |
| 165 | + |
| 166 | +> Warning: ``NIOLoopBound`` and ``NIOLoopBoundBox`` replace compile-time isolation checks |
| 167 | + with runtime ones. This makes it possible to introduce crashes in your code. Please |
| 168 | + ensure that you are 100% confident that the isolation domains align. If you are not |
| 169 | + sure that the ``EventLoopFuture`` you wish to attach a callback to is bound to your |
| 170 | + ``EventLoop``, use ``EventLoopFuture/hop(to:)`` to move it to your isolation domain |
| 171 | + before using these types. |
| 172 | + |
| 173 | +> Note: In a future NIO release we intend to improve the ergonomics of this common problem |
| 174 | + by offering a related type that can only be created from an ``EventLoopFuture`` on a |
| 175 | + given ``EventLoop``. This minimises the number of runtime checks, and will make it |
| 176 | + easier and more pleasant to write this kind of code. |
0 commit comments