Cancellable Tasks And Beyond
Cancellable Tasks (IcedTasks)
Introduction
F# has traditionally supported Asynchronous programming with the Async computation expression. However, Tasks in the .NET ecosystem have exploded in usage within the Core library. There has been an interop story by using Async.AwaitTask
but for many reasons, F# 6.0 released with native task computation expression support. There are a few differences between Async and Tasks. The big one for me was the loss of implicit CancellationToken passing.
For instance:
let doSlowWorkAsync (conn : IDbConnection) = async {
let! ct = Async.CancellationToken
let cmdDef = CommandDefinition("select pg_sleep(10)",cancellationToken = ct)
let! _ = conn.QueryAsync(cmdDef) |> Async.AwaitTask
()
}
// vs
// The Async.CancellationToken moved to a parameter here
let doSlowWorkTask (ct : CancellationToken) (conn : IDbConnection) = task {
let cmdDef = CommandDefinition("select pg_sleep(10)",cancellationToken = ct)
let! _ = conn.QueryAsync(cmdDef)
()
}
This may not look like much of a difference in this short, but this can explode with complexity as you’re now passing CancellationTokens
around to functions that may not even require them. For a more complex example, having to pass CancellationTokens
everywhere really pollutes your api example:
module BusinessLogic2 =
open System.Threading
open System.Threading.Tasks
open Microsoft.Extensions.Caching.Distributed
let preserveEndocrins (logger: ILogger) (ct: CancellationToken) () =
task {
do! Task.Delay(100, ct)
}
let monotonectallyImplementErrorFreeConvergence (ct: CancellationToken) (logger: ILogger) id =
task {
let! _ = preserveEndocrins logger ct ()
return id
}
let fungiblyFacilitateTechnicallySoundResults id (ct: CancellationToken) conn =
task {
let! _ = Database.doSlowWork2 ct conn
()
}
let completelyTransitionBackendRelationships (ct: CancellationToken) () =
task {
//send an email?
()
}
let continuallyActualizeImperatives (logger: ILogger) (ct: CancellationToken) (conn) =
task {
for i = 0 to 100000 do
let! r1 = monotonectallyImplementErrorFreeConvergence ct logger i
let! _ = fungiblyFacilitateTechnicallySoundResults r1 ct conn
()
}
let reticulatingSplines
(logger: ILogger)
(caching: IDistributedCache)
(ct: CancellationToken)
(conn)
=
task {
logger.LogDebug("Started reticulatingSplines")
let! _ = preserveEndocrins logger ct ()
logger.LogInformation("preserveEndocrins might be doing something strange?")
let! _ = Database.doSlowWork2 ct conn
let! _ = continuallyActualizeImperatives logger ct conn
let! _ = completelyTransitionBackendRelationships ct ()
()
}
There’s got to be a better way
Introducing IcedTasks which provides a few different task based computation expressions. The one we’re going to focus on today is CancellableTask<'T>
- Alias for CancellationToken -> Task<'T>
. If we look at our first example, we can replace it to be:
let doSlowWork3 (conn : IDbConnection) = cancellableTask {
let! ct = CancellableTask.getCancellationToken ()
let cmdDef = CommandDefinition("select pg_sleep(10)",cancellationToken = ct)
let! _ = conn.QueryAsync(cmdDef)
()
}
This looks more similar to the Async version! But how does our BusinessLogic look?
module BusinessLogic3 =
open System.Threading
open System.Threading.Tasks
open Microsoft.Extensions.Caching.Distributed
open IcedTasks
let preserveEndocrins (logger: ILogger) () = cancellableTask {
// You can bind against `CancellationToken -> Task<'T>` calls
// no need for `CancellableTask.getCancellationToken()`.
do! fun ct -> Task.Delay(100, ct)
}
let monotonectallyImplementErrorFreeConvergence (logger: ILogger) id =
cancellableTask {
let! _ = preserveEndocrins logger ()
return id
}
let fungiblyFacilitateTechnicallySoundResults id conn =
cancellableTask {
let! _ = Database.doSlowWork3 conn
()
}
let completelyTransitionBackendRelationships () =
cancellableTask {
//send an email?
()
}
let continuallyActualizeImperatives (logger: ILogger) (conn) =
cancellableTask {
for i = 0 to 100000 do
let! r1 = monotonectallyImplementErrorFreeConvergence logger i
let! _ = fungiblyFacilitateTechnicallySoundResults r1 conn
()
}
let reticulatingSplines (logger: ILogger) (caching: IDistributedCache) (conn) =
cancellableTask {
logger.LogDebug("Started reticulatingSplines")
let! _ = preserveEndocrins logger ()
logger.LogInformation("preserveEndocrins might be doing something strange?")
let! _ = Database.doSlowWork3 conn
let! _ = continuallyActualizeImperatives logger conn
let! _ = completelyTransitionBackendRelationships ()
()
}
All the calls have removed the need for CancellationToken
! I want to point out are two ways to get at the CancellationToken
- Binding against
CancellationToken -> Task<'T>
- Binding against
let! ct = CancellableTask.getCancellationToken ()
.
Cool story but why would I want to use CancellationTokens in the first place?
CancellationTokens
allow you to stop use of expensive resources. For example, you may have a WebApi that takes to a database. This database query could take a bit of time, however your user may navigate away from the page. Without a CancellationToken
being passed along to the database callsite, it could keep processing in the background and waste resources.
An example of this code on github is here.
Ok I’m sold… but I’m already using TaskResult
everywhere.
Awesome! FsToolkit.ErrorHandling.IcedTasks is available and provides a CancellableTaskResult<'T>
which is an alias CancellationToken -> Task<Result<'T, 'TError>>
.