1

I am coding a long running operation in FSharp's taks CE as follows

let longRunningTask = Task.Run(...)


// Now let's do the rest of the multi-tasking program

task {
  DO SOMETHING
  let! result = longRunningTask
  DO SOMETHING ELSE
}

The problem is DO SOMETHING ELSE appears to be running on an arbitrary thread (as observed also by printing the current thread id), whereas I absolutely need it to run on the same thread as DO SOMETHING, as I don't want any other form of concurrency except for the longRunningTask.

I've tried in many ways to set the current synchronization context, creating first a unique value of that type, but that doesn't seem to affect the result.

vincenzoml
  • 413
  • 1
  • 3
  • 10
  • Why there's need to execute continuation on exactly same thread? Is it wpf/winforms/avalonia/other_ui_framework, if not - it's not necessary in most cases. Using things like `lock` or underlying `Monitor` is generally bad idea, because of synchronous locking, while it's possible to do async locking with `SemaphoreSlim` – JL0PD Jul 30 '22 at 06:04
  • @JL0PD the design of most of my application can very well run sequentially, but benefits from an asynchronous (cooperative) design: I have plenty of non-thread-safe resource pools that I want to manage cooperatively. I want to schedule just the long-running operations in parallel with a main thread, and when they terminate, I want to read the replies, again, cooperatively. – vincenzoml Jul 30 '22 at 06:09
  • One simple way is to use `System.Threading.Tasks.Parallel.For` – JL0PD Jul 30 '22 at 06:16
  • Parallel for is not my use case as far as I can tell, I'm building a pipeline; but would be happy to be contradicted. – vincenzoml Jul 30 '22 at 08:11
  • Could you share some minimal code (ie with Sleep or something) that illustrates that the task CE is switching to another thread? I may be missing something from the design of tasks, but it’s my understanding that such shouldn’t magically happen. It may even be a bug. – Abel Jul 30 '22 at 12:02
  • Hmm, this may be a feature of tasks in general, not the CE per se. See this, it may help, it’s the same issue in C#: https://stackoverflow.com/questions/40498763/c-sharp-awaitable-task-staying-in-the-same-context#40498799 – Abel Jul 30 '22 at 12:06
  • @Abel thanks for the pointer!!! If I create a small task that awaits the long-running one, and then returns Task.FromResult of its result (or unit if appropriate) then I get back to the same thread. However, reading the result is now synchronous, whereas I want it to be asynchronous (that is, permit the other tasks to continue execution) but on the same task (that is, not switching thread, so that it's asynchronous, but not concurrent). It's a big step forward the solution actually, so I'll keep working a bit more on this. – vincenzoml Jul 31 '22 at 09:21
  • @vincenzoml before adding a ton of unnecessary code, what kind of application are you building and why do you want to run on the original thread? `task` should return on the original synchronization context by itself. You [have to use `backgroundTask`](https://docs.microsoft.com/en-us/dotnet/fsharp/language-reference/task-expressions#background-tasks) to avoid this. In a desktop app this works out of the box. If there's no sync context, eg in a web app, the original thread will be busy processing another request when your task finishes. You shouldn't expect to get back to that thread. – Panagiotis Kanavos Aug 06 '22 at 12:04
  • That's not true. If in a task ce you wait for a background tasks you may end up in another thread. Try that, printing the thread id. – vincenzoml Aug 07 '22 at 14:54

2 Answers2

2

It might be an overkill, but SynchronizationContext may help you. It's used to dispatch delegates to some threads. There's a lot of explanations on how it's working (search for ConfigureAwait(false)), so I'll focus on implementation

type ThreadOwningSyncCtx() =
    inherit SynchronizationContext()

    let _queue = new BlockingCollection<(SendOrPostCallback * obj)>()

    member _.DoWork(cancellationToken: CancellationToken) =
        while not cancellationToken.IsCancellationRequested do
            let (callback, state) = _queue.Take()
            callback.Invoke state
        ()

    override _.Post(callback, state) =
        _queue.Add((callback, state))
        ()

    override _.Send(callback, state) =
        let tcs = TaskCompletionSource()
        let cb s =
            callback.Invoke s
            tcs.SetResult()
        _queue.Add((cb, state))
        tcs.Task.Wait()
        ()

Notes on methods:

  • Post: Method which is executed on async path. This method is called from infrastructure of Task when C# await or F# let! do! completes asynchronously. Callback is queued to be completed sometime.

  • Send: Method which is executed on sync path. It's expected that callback will be executed before this method returns. For example when someone calls a CancellationTokenSource.Cancel or WPF's Dispatcher.Invoke or WinForms Control.Invoke

  • DoWork: Method which blocks current thread to execute all pending callback, because we can't just interrupt thread to perform some task, it must be waiting for it.

Usage:

let syncCtx = ThreadOwningSyncCtx()
// set current sync ctx, so every continuation is queued back to main thread.
// comment this line and `printThreadId` will return different numbers
SynchronizationContext.SetSynchronizationContext syncCtx

let printThreadId() =
    printfn "%d" Thread.CurrentThread.ManagedThreadId

// create cancellation token, so app won't run indefinitely
let cts = new CancellationTokenSource()

// task to simulate some meaningful work
task {
    printThreadId()
    do! Task.Yield() // this action always completes asynchronously
    printThreadId()

    cts.Cancel() // cancel token, so main thread can continue it's work
} |> ignore

// process all pending continuations
syncCtx.DoWork(cts.Token)
JL0PD
  • 3,698
  • 2
  • 15
  • 23
  • Wouldn't it be easier to just call `Task.Result` to block the calling thread, instead of using a computation expression? See my answer for more details. – Brian Berns Aug 01 '22 at 00:42
  • @BrianBerns, yeah, it will be much easier – JL0PD Aug 01 '22 at 02:51
  • @JL0PD can you explain the last line? When does one need to call syncCtx.DoWork? Once per program? – vincenzoml Aug 01 '22 at 13:17
  • @vincenzoml call it when you have some pending continuations. Either call `DoWork` after creating task, like in answer, or in the end of program and make all everything inside task. Last option is closer to how wpf/winforms work – JL0PD Aug 01 '22 at 14:12
  • @BrianBerns if one uses Task.Result the problem is solved locally, for one specific situation, but however there may be other ways to create more threads. For instance, calling repeatedly a function creating a task. Each function may run concurrently to the other ones even on the same thread. What I want is truly cooperative multitasking except for the background jobs. Agreed that if the job is only one it could be run in the main thread. But I can spawn many background jobs and wait for all of them with "WhenAny". – vincenzoml Aug 01 '22 at 16:37
  • @JL0PD I still need clarification. The idea seems to work but so far has required a lot of trial and error. Two key points: 1) By putting some asserts here and there, to check if the synchronization context would stay constant, I found out I have to set it in each "task {...}" invocation. Is this true or my mistake? 2) I did not find any meaningful way to cancel the token after all tasks finish in the main program. I need to set wait for all tasks explicitly. Is there a way to cancel the token automatically when there are no longer tasks after DoWork()? – vincenzoml Aug 01 '22 at 17:24
  • @JL0PD continuing my journey: sometimes, after cancelling, the program hangs. By printf debugging I see the DoWork function is stuck on one last piece of work. – vincenzoml Aug 01 '22 at 17:55
  • `task` returns to the original synchronization context by itself. There shouldn't be any need for this code. The `ThreadOwningSyncCtx` is trying to create a sync context when there isn't one, because ... there just isn't one, eg in a console or web application. In this case it would be far better to fix the code so it doesn't require a specific thread instead of trying to get back to a thread that may be serving other requests – Panagiotis Kanavos Aug 06 '22 at 12:06
1

If you really need to make sure that the main computation occurs on a single thread, you can just avoid the computation expression entirely:

printfn "Do something (on thread %A)" Thread.CurrentThread.ManagedThreadId
let task = startLongRunningTask ()
let result = task.Result
printfn "Do something else (on the same thread %A)" Thread.CurrentThread.ManagedThreadId

Note that Result blocks the calling thread until the task is complete, which is the behavior you seem to want. (Even simpler: You could just run the long-running task on the main thread as well, but I assume there's some reason that's not desirable.)

Brian Berns
  • 15,499
  • 2
  • 30
  • 40
  • This solution works nicely; in the computation expression, instead of writing "do! myThread" I write "myThread.Result". That simple. Indeed, I do need to run long-running computations on other processors, which is what happens now in my program. What is the difference between do! and just calling result and how to avoid risking unwanted thread switches? What other situations could trigger them? – vincenzoml Aug 01 '22 at 13:31
  • Contradicting my previous comment (see other comment) one really needs a synchronization context for truly cooperative tasks. – vincenzoml Aug 01 '22 at 16:38
  • @vincenzoml `one really needs a synchronization context for truly cooperative tasks` not really. You only need that in desktop applications, where the sync context is the UI thread. In every other case you can have different tasks working through a pub/sub mechanism. What are you trying to do? – Panagiotis Kanavos Aug 06 '22 at 12:08
  • I am coding a pet scheduler that manages resources, and I don't want to lock the data structures. However I want to run several background tasks that take resources and return them when finished. The problem is with no synchronisation context the code that waits for tasks to finish becomes parallel to the main scheduler and screws the non concurrent data structures. – vincenzoml Aug 07 '22 at 14:51