EP 197 · Async Composable Architecture · Jul 18, 2022 ·Members

Video #197: Async Composable Architecture: Schedulers

smart_display

Loading stream…

Video #197: Async Composable Architecture: Schedulers

Episode: Video #197 Date: Jul 18, 2022 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep197-async-composable-architecture-schedulers

Episode thumbnail

Description

We can now run async work directly in a reducer’s effects, but time-based asynchrony, like Task.sleep, will wreak havoc in our tests. Let’s explore the problem in a new feature, and see how to recover the nice syntax of modern timing tools using a protocol from the past: Combine schedulers.

Video

Cloudflare Stream video ID: 17d01d23289413816f8950a78d8f33f2 Local file: video_197_async-composable-architecture-schedulers.mp4 *(download with --video 197)*

References

Transcript

0:05

So things are looking pretty good. We are now free to use Swift’s new async/await features directly in the effects returned by reducers. This means we could execute multiple await s to chain together multiple asynchronous operations, and we could even use async let and task groups to run a multiple units of work in parallel, gathering up their output to feed back into the reducer.

0:24

But there are still more types of asynchrony we use in everyday applications, such as time based asynchrony. What do we do if we want to delay for some time in our effects, or throttle or debounce the emissions of effects? Such operations are covered by the Combine framework, and in fact are kinda the whole point of Combine.

0:44

However, Swift now has tools that are built on the foundation of async/await that provide a lot of the same functionality that Combine gives us, except in some cases even simpler. Instead of things like the Publisher protocol in Combine for expressing streams of values over time, we now have the AsyncSequence protocol. And instead of things like the Scheduler protocol for expressing how work is scheduled in the future, we now have the Clock protocol. Even better, Apple also has a Swift package called “ Async Algorithms ” that has all types of interesting operators to use with async sequences, and we will even be able to make use of those in our application.

1:19

We’d like to be able to make use of these tools in the library while still maintaining testability. That will be possible, but it will require a bit more time since the tools are still beta and a little buggy as of the most recent Xcode beta (14 beta 3).

1:33

So instead, we are going to first introduce new tools to make Combine’s API look more similar to the newer async tools, which will be handy for migrating existing applications to async/await, and then someday in the future we will be able to start using the newer tools, like clocks.

1:49

Let’s explore this by adding a new feature to our case study that makes use of time. Timing effects

1:56

So, it turns out that the number fact API server we use doesn’t have anything interesting to say about negative numbers. If we count down and request a fact we get: Fact -1 is a boring number.

2:08

It’s a bit harsh, but I guess we get what we pay for.

2:11

Let’s add a really silly feature to our demo where whenever we count below zero we will automatically count back up. But, to make it cute we’ll make it wait for one second before doing so.

2:26

To accomplish this we can add a new action to our enum that represents the action sent when the delay finishes after tapping the decrement button: enum EffectsBasicsAction: Equatable { case decrementButtonTapped case decrementDelayResponse … }

2:40

Then we can implement the delay logic in the .decrementButtonTapped action. Let’s start in the simplest way possible by just leveraging the tools that Swift gives, in particular Task.sleep : case .decrementButtonTapped: state.count -= 1 state.numberFact = nil return state.count >= 0 ? .none : .task { try? await Task.sleep(nanoseconds: NSEC_PER_SEC) return .decrementDelayResponse } We can check if the count goes below zero after decrementing, and if it does we can open up an Effect.task , perform a sleep, and then send the delay action into the system.

3:15

And then in the .decrementDelayResponse action we can implement the logic so that we re-increment the count, but only if the count is still below zero: case .decrementDelayResponse: if state.count < 0 { state.count += 1 } return .none

3:27

This way if by the time the delay is finished if we have already counted back up to a non-negative number we don’t have to do any re-incrementing.

3:36

With just those few changes the feature is working as we expect. If we decrement a bunch of times and then wait a bit, the counter will eventually count back up. And if we count down and then immediately count back to a non-negative number, nothing happens.

3:49

Now, of course using Task.sleep directly in our reducer is going to wreak havoc on our tests. But before looking at that let’s improve this feature a bit using a powerful tool of the Composable Architecture.

4:01

Right now, if you count down a bunch of times and then count back to 0, the inflight delayed effects will continue to run and deliver their actions into the system even though we know we don’t need to re-increment anymore.

4:14

We can see this by tacking a .debug() to the end of our reducer, running the app in the simulator, counting down a few times and then immediately counting up to 0. We can see that the two decrementDelayResponse actions were still delivered to the store even though they did not need to be, and as a result no state change occurred: received action: EffectsBasicsAction.decrementDelayResponse (No state changes) received action: EffectsBasicsAction.decrementDelayResponse (No state changes)

4:42

What if we could cancel all of those inflight effects once we detect they are no longer needed?

4:47

We can do this by marking our delayed task as “cancellable”: .task { try? await Task.sleep(nanoseconds: NSEC_PER_SEC) return .decrementDelayResponse } .cancellable(id: <#AnyHashable#>)

4:54

To do this you provide an identifier to uniquely identify the effect at a later time. The id can be any hashable value, such as a string, but a nice way to make the id static and only locally constructible is to introduce a whole new type: enum DelayID {}

5:15

The contents of the type are not important, that’s why we used an empty enum. All that is important is that it’s a static identifier that cannot be replicated anywhere else in our application, so it truly is unique. We can even scope it directly in the reducer: let effectsBasicsReducer = Reducer< EffectsBasicsState, EffectsBasicsAction, EffectsBasicsEnvironment > { state, action, environment in enum DelayID {} … }

5:37

So, let’s use that id for the cancellable identifier: .task { try? await Task.sleep(nanoseconds: NSEC_PER_SEC) return .decrementDelayResponse } .cancellable(id: DelayID.self)

5:42

Next we can cancel all of the effects tagged with this identifier once we detect that their work is no longer needed. In particular, if we count back up to 0 or higher we can cancel. This can be done using the .cancel effect: case .incrementButtonTapped: state.count += 1 state.numberFact = nil return state.count >= 0 ? .cancel(id: DelayID.self) : .none

6:11

That’s all it takes to clean up those delayed effects when they are no longer needed, and the application works exactly as it did before, but we’ll see in the console that the decrementDelayResponse actions are no long fed into the system when we increment back up to a positive number.

6:39

Now let’s look at tests.

6:42

If we run tests we see that they are still passing, which is interesting because we have introduced some pretty significant logic to the feature. It seems that none of our tests exercise the flow of what happens when you count down to a negative number and leave it there.

7:00

So, let’s get some coverage on that. Let’s get a stub into place by constructing a store with all failing dependencies just to get things started: func testDecrement() async { let store = TestStore( initialState: EffectsBasicsState(), reducer: effectsBasicsReducer, environment: .unimplemented ) }

7:35

Then we can emulate the user decrementing by sending the .decrementButtonTapped action: store.send(.decrementButtonTapped) { $0.count = -1 }

7:53

If we run the test right now without asserting on anything else we will of course get a failure because there is now an inflight effect that we haven’t asserted on: testDecrement(): An effect returned for this action is still running. It must complete before the end of the test. …

8:10

So, what do we expect to happen? Well, an effect should be kicked off and one second later it should feed an action back into the system that re-increments the count back up to 0. Thanks to our new async receive this is as simple as awaiting until the action is received: func testDecrement() async { … await store.receive(.decrementDelayResponse) { $0.count = 0 } }

8:44

Well, this is still failing. This is only happening because the delay in the reducer takes a second, and our timeout for receive is also a second. We can increase the timeout for a moment just to give enough time for the effect to finish: await store.receive( .decrementDelayResponse, timeout: NSEC_PER_SEC * 2 ) { … }

9:12

It is of course not ideal to have to be waiting around for a second for the effect to finish, and to have to tweak timeout parameters. We will be making all of this a lot nicer momentarily.

9:25

But, with that done, we do have a passing test! We are now testing how a complex, time-based effect is executed and feeds its data back into the system. It’s pretty amazing.

9:36

Let’s test something more complicated. Let’s see what happens when we decrement to a negative number, but increment back up before the delayed effect finishes: func testDecrementCancellation() async { let store = TestStore( initialState: EffectsBasicsState(), reducer: effectsBasicsReducer, environment: .unimplemented ) store.send(.decrementButtonTapped) { $0.count = -1 } store.send(.incrementButtonTapped) { $0.count = 0 } }

10:19

This test passes, which means we have definitive proof that the delayed effect was canceled. For if it was not we would get a test failure letting us know that there were unfinished inflight effects still out there.

10:32

In fact, let’s comment out the cancellation logic to see what this looks like: case .incrementButtonTapped: state.count += 1 state.numberFact = nil // return state.count >= 0 // ? .cancel(id: DelayID.self) // : .none return .none

10:42

Now when we run tests we get a failure: testDecrementCancellation(): An effect returned for this action is still running. It must complete before the end of the test. …

10:47

So indeed our logic for cancelling and cleaning up effects seems to be working. Let’s undo to get back to a passing state.

11:01

So things are seeming pretty amazing, but not everything is amazing. The decrement test we wrote takes a second to complete: Test Case '-[SwiftUICaseStudiesTests.EffectsBasicsTests testDecrement]' passed (1.086 seconds).

11:13

This is not the best way to test time-based effects. We shouldn’t have to literally wait for time to pass in order to get this effect to emit data. This is going to make our test suite incredible slow, even for small durations, but also what happens if someday we want to test an effect that delays its output by 10 seconds, or minutes, or more? This approach just doesn’t scale well.

11:40

That’s why in the Composable Architecture we recommend controlling time using Combine schedulers . Luckily we already have a scheduler in our environment, so we can rewrite the effect using Combine like this: return state.count >= 0 ? .none : Effect(value: .decrementDelayResponse) .delay(for: 1, scheduler: environment.mainQueue) .eraseToEffect() .cancellable(id: DelayID.self)

12:38

If we run tests now we get a bunch of failures because we are using a failing scheduler in the test, which means anytime you access any of its endpoints you get a test failure. For example, in the testDecrement test we get: testDecrement(): DispatchQueue - An unimplemented scheduler scheduled an action to run later.

13:09

One way to get this passing is to use an “immediate” scheduler, which is a scheduler that immediately executes work scheduled on it rather than waiting any time at all: store.environment.mainQueue = .immediate

13:30

Now tests pass, and do so immediately: Test Case '-[SwiftUICaseStudiesTests.EffectsBasicsTests testDecrement]' passed (0.033 seconds).

13:55

Or, if you wanted to truly test that the delay lasted for one second, you could use a test scheduler: func testDecrement() async { let mainQueue = DispatchQueue.test … store.environment.mainQueue = mainQueue .eraseToAnyScheduler() … }

14:13

This kind of scheduler does not execute any work scheduled on it until you explicitly advance its internal clock forward. So if we run tests now they will fail: testDecrement(): Expected to receive an action, but received none.

14:30

This is happening because we need to advance the test scheduler: mainQueue.advance(by: .seconds(1))

14:43

Now tests pass again, and they pass instantly: Test Case '-[SwiftUICaseStudiesTests.EffectsBasicsTests testDecrement]' passed (0.035 seconds).

14:49

The testDecrementCancellation test is also broken, because the environment’s scheduler is unimplemented. We can’t use an “immediate” scheduler here because it will instantly execute its scheduled work before we have a chance to cancel. Instead, we can set up another test scheduler that prevents the work from executing to get a passing test. Async scheduling

15:56

So this is how we can introduce some time-based asynchrony into a composable architecture application. by using Combine schedulers, and making use of immediate and test schedulers, we can do so in a completely testable manner.

16:10

Which is great, but what’s not so great is having to express something so simple with a Combine chain of operators: Effect(value: .decrementDelayResponse) .delay(for: 1, scheduler: environment.mainQueue) .eraseToEffect() .cancellable(id: DelayID.self)

16:30

It also just kinda reads backwards. We are upfront saying we are constructing an effect that immediately and synchronously returns an action, and then we delay the emission of that action by a second, then we have to erase it to the Effect type, and then finally make it cancellable.

16:44

When we were using Effect.task things read much better from top-to-bottom: .task { try? await Task.sleep(nanoseconds: NSEC_PER_SEC) return .decrementDelayResponse } .cancellable(id: DelayID.self)

16:51

First we sleep for a bit, then we return the action, and then we make the whole thing cancellable.

16:59

The problem with Task.sleep is that it is not controllable at all. It just sleeps for a real life 1 second, and we have no way of speeding that up or making it instantaneous.

17:08

There is an overload of Task.sleep that takes a concept known as a Clock : Task.sleep(until: <#InstantProtocol#>, clock: <#Clock#>)

17:14

Clocks are analogous to schedulers from Combine, but tuned specifically for Swift’s new concurrency tools. Where schedulers allow you to schedule a unit of work for a later time, clocks allow you to use an asynchronous context to sleep until a later time. It’s expressed as a protocol, just like Scheduler , with a pretty simple interface: protocol Clock: Sendable { associatedtype Instant: InstantProtocol var now: Instant { get } var minimumResolution: Instant.Duration { get } func sleep( until deadline: Instant, tolerance: Instant.Duration? ) async throws }

17:33

Its main piece of functionality is the async sleep method. Swift even ships with two concrete conformances to Clock , known as “continuous” and “suspending”. Continuous clocks keep incrementing even when the system is asleep where as suspending clocks do not.

17:47

We will be getting deep into clocks in future Point-Free episodes, but currently they are still in beta and a little buggy.

17:54

So, until then let’s see if we can make the schedulers look a little more clock-like. Perhaps we can implement an async sleep function on schedulers that simply suspends for an amount of time. That would allow to use a nice syntax that reads from top-to-bottom: .task { try? await environment.mainQueue .sleep(nanoseconds: NSEC_PER_SEC) return .decrementDelayResponse } .cancellable(id: DelayID.self)

18:20

…while still using schedulers, which means hopefully this code can be testing without literally waiting for time to pass. It also means we can adopt this syntax in our current Composable Architecture applications even before we are ready to go all into clocks, and then hopefully in the future when we are ready to use clocks the migration will be very simple.

18:38

Let’s see what it takes to implement this method. First let’s get a stub of a signature into place: extension Scheduler { func sleep( for duration: SchedulerTimeType.Stride ) async throws { } }

18:54

It throws, like Task.sleep , so that it can be somewhat cooperative with cancellation errors.

18:57

In the function we can easily call out to the schedule(after:) endpoint on Scheduler in order to execute some work after the duration: extension Scheduler { func sleep( for duration: SchedulerTimeType.Stride ) async throws { self.schedule(after: self.now.advanced(by: duration)) { <#???#> } } }

19:21

But, the question is, what work do we want to perform?

19:23

If we step back a moment, what we really want to do is suspend this function for the duration that the scheduler is sleeping. However, we have a bit of an impedance mismatch here. We want to suspend in the async/await world, but schedulers work in the non-async/await, escaping-closure world.

19:40

Luckily Swift has a tool for bridging these worlds together. Actually there’s two tools: one is called an “unsafe continuation” and the other is called a “checked continuation”. Let’s use the unsafe version for now and we will explain the difference between the two in a bit.

19:53

It starts by calling the global function withUnsafeContinuation : await withUnsafeContinuation { continuation in }

20:05

Then in this closure you perform your old-style asynchronous work that cannot use async/await, such as scheduling some work: await withUnsafeContinuation { continuation in self.schedule(after: self.now.advanced(by: duration)) { } }

20:11

And then once the scheduler’s work executes we can tell the continuation to resume: await withUnsafeContinuation { continuation in self.schedule(after: self.now.advanced(by: duration)) { continuation.resume() } }

20:16

Once the continuation is resumed it will cause the await to un-suspend, allowing execution to continue.

20:22

The continuation must be resumed exactly one single time. If you resume it multiple times it will result in undefined behavior. The other style of continuation, “checked continuation”, has the same restriction in that you must resume it exactly one time. However, it performs runtime checks to see if you use it incorrectly so that it can warn you. Those checks have some overhead, and so if you are sure you are using it correctly, you can use the “unsafe” version.

20:46

One last thing we should do once the continuation completes is check if the task was canceled while we were waiting for the scheduler to execute work: await withUnsafeContinuation { continuation in self.schedule(after: self.now.advanced(by: duration)) { continuation.resume() } } try Task.checkCancellation()

20:58

This completes the implementation, and our code is now compiling: try? await environment.mainQueue.sleep(for: .seconds(1))

21:05

We can run it in the simulator or Xcode previews, and everything should work exactly as it did before, but now we have a fighting chance to write a test that does not take 1 second to execute.

21:13

If we run tests now we will see they fail. It seems that the effect is still in flight and has not yet finished and fed its output back into the system: testDecrement(): An effect returned for this action is still running. It must complete before the end of the test. … Failed: testDecrement(): Expected to receive an action, but received none.

21:26

This is happening even though we are clearly advancing the scheduler by one second: mainQueue.advance(by: .seconds(1))

21:30

And even waiting around for 2 seconds to see if an action comes into the system: await store.receive( .decrementDelayResponse, timeout: NSEC_PER_SEC * 2 ) { … }

21:34

The problem is that in the async/await world we just have no guarantees when our code is going to be executed. The advance method works, roughly, by moving the internal time of the schedule forward, finding all units of work whose schedule date is before the new time, and executing those units of work.

21:51

However, we have no guarantee that the scheduler job was enqueued by the time we advance the scheduler. Sure, when the decrementButtonTapped action is sent we do fire up the effect task, but it may take a few moments before Swift’s concurrency scheduling system starts the work on a thread. And so if that span of time is long enough, we may advance the scheduler when there isn’t even any work on the scheduler’s queue.

22:13

We can see this concretely by putting a print statement just before we advance the scheduler: print("Just before mainQueue.advance") mainQueue.advance(by: .seconds(1))

22:21

And a print statement right when the task starts: .task { print("Task started") try? await environment.mainQueue.sleep(for: .seconds(1)) return .decrementDelayResponse } .cancellable(id: DelayID.self)

22:26

If we run this we will indeed see that the schedule is advanced before the task even starts: Just before mainQueue.advance Task started

22:37

So it’s not surprising that when we advance the schedule it doesn’t actually cause the sleep to finish.

22:44

One thing we might try is to insert a Task.yield before advancing so that we could suspend for a bit of time and hopefully that gives the effect enough time to start it’s task: await Task.yield() print("Just before mainQueue.advance") mainQueue.advance(by: .seconds(1))

22:54

However this still fails.

22:59

Perhaps we need to double yield in order to give a little bit more time: await Task.yield() await Task.yield() print("Just before mainQueue.advance") mainQueue.advance(by: .seconds(1))

23:04

This passes sometimes, but sometimes fails.

23:12

Maybe we need a triple yield: await Task.yield() await Task.yield() await Task.yield() print("Just before mainQueue.advance") mainQueue.advance(by: .seconds(1))

23:14

Unfortunately the results are the same. It seems to pass sometimes but not other times.

23:22

Maybe we just need a whole bunch of yields: for _ in 1...10 { await Task.yield() }

23:34

OK, this does seem to be passing a bunch of times, but eventually we actually get a crash in our new async receive method: Thread 13: “-[__NSTaggedDate count]: unrecognized selector sent to instance 0x8000000000000000”

23:47

Turns out the receive method we wrote last episode isn’t exactly safe. It’s marked as async, which means it can be executed from multiple threads, and we have non-isolated mutable data in the test store, such as the inFlightsEffect array.

24:02

The easiest fix is to make the receive method @MainActor so that it is serialized to the main thread: @MainActor public func receive(…) { … }

24:22

And now tests pass consistently without crashing Test Suite 'Selected tests' passed. Executed 1000 tests, with 0 failures (0 unexpected) in 2.021 (2.315) seconds In the future we may want to make TestStore and Store into actors in order to more fully protect their mutable state.

24:26

So, it’s seeming ok, but also how can we really be sure?

24:30

Well, unfortunately, we don’t think it’s currently possible to be 100% sure. In fact, there have been some performance regressions in Swift 5.7 that force us to yield more often than was necessary in Swift 5.6. We used to see consistently passing tests with just 3 yields, and now it requires a lot more.

24:45

The fact is that asynchronous and concurrent code is highly unpredictable and non-deterministic, and so it’s difficult to get any hard guarantees of how the code will be executed.

24:55

However, we feel that this is true of production code, not test code. We feel that Swift could provide tools that could be handy for testing that allows us to control how and when tasks start up. If we could guarantee that the effect task would be started before store.send returns, then this test would be very easy to write.

25:13

We will continue exploring these avenues, but unfortunately the tools aren’t quite right yet. So, for now, sadly I think we are going to need to sprinkle in some of these repeated yields into our code in order to make things work the way we want.

25:25

Luckily for us we can hide these details. Since these yields are sprinkled in specifically for scheduling, why don’t we create an async version of advance that does the yielding for us? We can even define it directly in our test file right now, and someday the Combine Schedulers library can supply it.

25:41

Let’s start by familiarizing ourselves with how the current, non-async test scheduler works because it’s surprisingly tricky.

25:48

We can take a look at the schedule methods, where are apart of the Scheduler protocol’s interface. In a real life scheduler these endpoints would do the real work of scheduling something with the operating system to execute the work after a delay. But the test schedule doesn’t eager schedule or execute anything. Instead, it just keeps track of a queue of currently scheduled work so that we can later execute it when the schedule’s internal time is advanced: public func schedule( after date: SchedulerTimeType, tolerance _: SchedulerTimeType.Stride, options _: SchedulerOptions?, _ action: @escaping () -> Void ) { self.scheduled.append( (self.nextSequence(), date, action) ) } public func schedule( options _: SchedulerOptions?, _ action: @escaping () -> Void ) { self.scheduled.append( (self.nextSequence(), self.now, action) ) }

26:16

The queue keeps track of the date the item should be executed, the unit of work to execute, as well as a tie-breaking sequence integer that is used whenever two units of work are scheduled for the exact same time.

26:28

The real meat of the test scheduler is in the advance method. It’s the method responsible for moving the scheduler’s time forward and executing any units of work along the way. It starts by getting the final time we want to advance the scheduler to: public func advance( by stride: SchedulerTimeType.Stride = .zero ) { let finalDate = self.now.advanced(by: stride) … }

26:44

We don’t immediately advance the scheduler to that time, and instead we do it step-by-step depending on what work is currently in the scheduler’s queue. The reason we do this is that the act of executing a unit of work could enqueue another unit of work before the finalDate , and so we won’t want to miss out on that work.

27:00

This step-by-step process begins by looping while our current time is less than the final date: while self.now <= finalDate { … }

27:07

Then we sort the scheduled work by date and that tie-breaking “sequence” for units of work that are scheduled at the same time: self.scheduled .sort { ($0.date, $0.sequence) < ($1.date, $1.sequence) }

27:11

Then we check if there’s even any work to execute by seeing if the first unit has a date less than the final date, and if it doesn’t we can just immediately move now to the final date and be done: guard let next = self.scheduled.first, finalDate >= next.date else { self.now = finalDate return }

27:24

However, if there is a unit work that is ready to be executed we will move now to its time: self.now = next.date

27:28

And then finally we remove that unit of work from the scheduled queue and execute it: self.scheduled.removeFirst() next.action()

27:32

After that control flow will be returned to the while loop and we repeat the process all over again.

27:38

So, it’s tricky, but it gets the job done. Handling all of that nuance allows us to be sure that advancing time for delayed work, timer work, and even recursive, reentrant work, all does what we expect in tests.

27:50

Now, we don’t want to get rid of this synchronous advance method because there are still going to be people out there that need to use it. We just want to introduce an additional async version like we did for the TestStore.receive method.

28:01

We can’t edit the Combine Schedulers dependency directly, but we can copy-and-paste the test scheduler directly into our test file and then edit the local copy.

28:17

Then we can copy and paste the advance method, make it async, and let’s ago ahead and mark it as @MainActor , because like test store’s receive it is called from multiple threads and manages mutable state on the inside, so it’d be nice to serialize things: @MainActor public func advance( by stride: SchedulerTimeType.Stride = .zero ) async { … }

28:38

Now we just need to insert a yield in the right place. As we said a moment ago, the problem we are running into with our test is that we advance the scheduler before there’s any work in the scheduler’s queue.

28:48

So, perhaps we can use the asynchronous context provided to us by virtue of the fact that we are in an async function to perform a yield before we sort the scheduled work and take the first item to execute: while self.now <= finalDate { for _ in 1...10 { await Task.yield() } self.scheduled .sort { ($0.date, $0.sequence) < ($1.date, $1.sequence) }

29:08

Then we can drop the yields from the test and instead await advancing the scheduler: await mainQueue.advance(by: .seconds(1)) await store.receive(.decrementDelayResponse) { $0.count = 0 }

29:27

And tests still pass, even when repeatedly run. Next time: streams

29:51

So things are starting to look pretty good. Not only can we use Effect.task to open up an asynchronous context for our effects, which allows us to perform as many awaits as we want and use a familiar coding style to compute the data that we want to feed back into the system, but we can now also leverage async sleeping in order to introduce delays into our effects.

30:09

And best of all, it’s still all testable. Sure we had to insert a little bit of hackiness into our test scheduler, but hopefully Swift will someday introduce tools that allow us to have more control over how work is scheduled in the concurrency runtime.

30:24

But there is still a pretty big blind spot in our asynchronous effects story for the Composable Architecture. And that’s effects that need to send multiple actions back into the system. Effect.task works great for when we have a single action, but what if we need to send many?

30:41

It’s totally possible to support this, and in doing so we will be able to greatly simplify many complex effects, and further reduce our reliance on Combine. So let’s take a look at that…next time! References Collection: Concurrency Brandon Williams & Stephen Celis Note Swift has many tools for concurrency, including threads, operation queues, dispatch queues, Combine and now first class tools built directly into the language. We start from the beginning to understand what the past tools excelled at and where they faultered in order to see why the new tools are so incredible. http://pointfree.co/collections/concurrency Downloads Sample code 0197-tca-concurrency-pt3 Point-Free A hub for advanced Swift programming. Brought to you by Brandon Williams and Stephen Celis . Content Become a member The Point-Free Way Beta previews Gifts Videos Collections Free clips Blog More About Us Community Slack Mastodon Twitter BlueSky GitHub Contact Us Privacy Policy © 2026 Point-Free, Inc. All rights are reserved for the videos and transcripts on this site. All other content is licensed under CC BY-NC-SA 4.0 , and the underlying source code to run this site is licensed under the MIT License .