0

I have an API that needs to be called from the thread that initialized it. I want to make a wrapper that would create a thread for running all calls to the API and then allow me to await those calls from the UI. What would be an elegant way to do this in C#, without resorting to 3rd party libraries?

I am imagining the result looking something like this:

static class SingleThreadedApi
{
    public static void Init();

    // Has to be called from the same thread as init.
    public static double LongRunningCall();
}

class ApiWrapper
{
    // ???
}

class MyWindow
{
    ApiWrapper api = new();

    async void OnLoad()
    {
        await api.InitAsync();
    }

    async void OnButtonClick()
    {
        var result = await api.LongRunningCallAsync();
        answer.Text = "result: {result}";
    }
}

This question is similar to Best way in .NET to manage queue of tasks on a separate (single) thread, except there tasks only had to run serially, not necessarily on the same thread.

How do I create a custom SynchronizationContext so that all continuations can be processed by my own single-threaded event loop? might be one solution, but I hope there is something better than: "not the easiest thing in the world. I have an open-source [...] implementation."

Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104
Don Reba
  • 13,814
  • 3
  • 48
  • 61
  • Can you give a practical use-case code example? – gunr2171 Apr 10 '23 at 20:04
  • @TheodorZoulias Given that the questions are not only identical, but even the OP thinks the solution in the linked duplicate would solve their problem, what's your basis for reopening the question? – Servy Apr 10 '23 at 20:14
  • @Servy, the linked solution is not elegant and proposes using a 3rd party library. – Don Reba Apr 10 '23 at 20:15
  • @DonReba You thinking it's not elegant doesn't make it not a duplicate. For a question to merit reopening you would need to explain why it fails to solve the problem. "I don't like it" doesn't meet that criteria. – Servy Apr 10 '23 at 20:16
  • 1
    @Servy, why are you so eager to shut down this discussion? – Don Reba Apr 10 '23 at 20:18
  • @DonReba I'm not. I'm giving you the answer to your question. That you don't like the answer to your question doesn't mean anyone is "shutting it down" If you actually have a problem not solved by the existing answers, by all means, explain it. – Servy Apr 10 '23 at 20:22
  • @Servy, I am not asking how to create a `SynchronizationContext`. The way to propose a solution is to post an answer, not mark the question a duplicate. – Don Reba Apr 10 '23 at 20:25
  • 1
    @Servy the [linked question](https://stackoverflow.com/questions/39271492/how-do-i-create-a-custom-synchronizationcontext-so-that-all-continuations-can-be) is specificaly about creating a custom `SynchronizationContext`. This question is about a problem that *can* be solved by a custom `SynchronizationContext`, but it's neither the only nor the best solution. So IMHO the two questions are related, but they are not even close to being identical. – Theodor Zoulias Apr 10 '23 at 20:26
  • @DonReba You are in fact asking literally that. The way to provide an answer for a question that has been answered before it to close it as a duplicate, not to duplicate the answers to repeated questions. That's exactly why the feature exists. – Servy Apr 10 '23 at 20:27
  • @TheodorZoulias The question asks how to define the contexts code runs in when awaiting. That's a synchronization context. That is uses different words to ask for the same thing doesn't make it not a duplicate. A solution that doesn't set a synchronization context that has the specified behavior is not an answer to the question. If you think you can write a better solution *to the same problem* the post your answer to the duplicate. That's the whole point of closing questions as duplicates, so that the answers are all in the same place, and finding the best answers is easier. – Servy Apr 10 '23 at 20:30
  • 1
    @Servy are you suggesting me to post an answer to the [linked question](https://stackoverflow.com/questions/39271492/how-do-i-create-a-custom-synchronizationcontext-so-that-all-continuations-can-be), which is about custom `SynchronizationContext`s, and show how to achieve the same effect with a custom `TaskScheduler` instead of a custom `SynchronizationContext`? Here is a different suggestion: post such an answer yourself there, and then count the downvotes for your off-topic answer. – Theodor Zoulias Apr 10 '23 at 20:36
  • Don Reba is the API-dedicated thread special in some way, like being a STA thread, or it's just a normal thread? – Theodor Zoulias Apr 10 '23 at 20:43
  • 1
    @TheodorZoulias It is just a normal thread, thank you. – Don Reba Apr 10 '23 at 20:55
  • 1
    Related: [Forcing certain code to always run on the same thread](https://stackoverflow.com/questions/61530632/forcing-certain-code-to-always-run-on-the-same-thread). – Theodor Zoulias Apr 11 '23 at 04:29

2 Answers2

3

So far, the best solution I found was to use a BlockingCollection<Action> with TaskCompletionSource. Simplified, it looks like this:

static class SingleThreadedAPi
{
    public static void Init();

    // Has to be called from the same thread as init.
    public static double LongRunningCall();
}

class ApiWrapper
{
    BlockingCollection<Action> actionQueue = new();

    public ApiWrapper()
    {
        new Thread(Run).Start();
    }

    public Task InitAsync()
    {
        var completion = new TaskCompletionSource();
        actionQueue.Add(() =>
        {
            try
            {
                SingleThreadedAPi.Init();
                completion.SetResult();
            }
            catch (Exception e)
            {
                completion.SetException(e);
            }
        });
        return completion.Task;
    }

    public Task<double> LongRunningCallAsync()
    {
        var completion = new TaskCompletionSource<double>();
        actionQueue.Add(() =>
        {
            try
            {
                
                completion.SetResult(SingleThreadedAPi.LongRunningCall());
            }
            catch (Exception e)
            {
                completion.SetException(e);
            }
        });
        return completion.Task;
    }

    public void Finish()
    {
        actionQueue.CompleteAdding();
    }

    void Run()
    {
        foreach (var action in actionQueue.GetConsumingEnumerable())
            action();
    }
}

class MyWindow
{
    ApiWrapper api;

    async void OnLoad()
    {
        await api.InitAsync();
    }

    async void OnButtonClick()
    {
        var result = await api.LongRunningCallAsync();
        answer.Text = "result: {result}";
    }
}
Don Reba
  • 13,814
  • 3
  • 48
  • 61
1

Pretty much any solution to this problem will be based on a BlockingCollection<T> one way or another, and the question becomes how to make the interaction with the BlockingCollection<T> more convenient. My suggestion is to wrap it in a custom TaskScheduler, that uses a single dedicated thread, and executes all the tasks on this thread. You can find material about how to write custom TaskSchedulers on this old article (the source code of the article can be found here), or you can just use the SingleThreadTaskScheduler implementation that I've posted here. It can be used like this:

class MyWindow
{
    private SingleThreadTaskScheduler _apiTaskScheduler;
    private TaskFactory _apiTaskFactory;

    async void OnLoad()
    {
        _apiTaskScheduler = new();
        _apiTaskFactory = new(_apiTaskScheduler);
        await _apiTaskFactory.StartNew(() => SingleThreadedApi.Init());
    }

    async void OnButtonClick()
    {
        double result = await _apiTaskFactory
            .StartNew(() => SingleThreadedApi.LongRunningCall());
        answer.Text = "result: {result}";
    }

    void OnClosed()
    {
        _apiTaskScheduler.Dispose();
    }
}

The Dispose is a blocking call. It will block the UI thread until all tasks that have been scheduled for execution are completed, and the dedicated thread is terminated. This is not ideal, but allowing the window to close before all operations are completed is probably even worse.

Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104