-1

Sync-over-async is bad. I know. But is there a sync-over-async problem when calling TaskCompletionSource.Task.Wait()? Does the answer changes if TaskCompletionSource was created with TaskCreationOptions.RunContinuationsAsynchronously?

Update
To answer the questions from the comments. Not all Task's are equal. The Task object was introduced long before async/await and was used for parallel programming. For example, there is nothing wrong with the following code as it doesn't do any async work.

var task = Task.Run(() => Thread.Sleep(10_000));
task.Wait();

For the context: The Kafka client has a sync method to produce messages which accepts an action to report delivery status asynchronously

void Produce(
      TopicPartition topicPartition,
      Message<TKey, TValue> message,
      Action<DeliveryReport<TKey, TValue>> deliveryHandler = null);

In a few scenarios, I need to wait for the delivery report before continuing the work, which can be in sync or async context. For that, I have the following class:

internal class DeliveryReportAwaiter<TKey, TValue> : IDisposable
{
    private const int WaitForDeliveryGracePeriodFactor = 2;
    private readonly int _waitDeliveryReportTimeoutMs;
    private readonly ILogger _logger;
    private readonly CancellationTokenSource _cancellationTokenSource;
    private readonly TaskCompletionSource _taskCompletionSource;

    private bool _disposed;

    public DeliveryReportAwaiter(int waitDeliveryReportTimeoutMs, ILogger logger)
    {
        _logger = logger;
        _waitDeliveryReportTimeoutMs = waitDeliveryReportTimeoutMs *
            WaitForDeliveryGracePeriodFactor;

        _taskCompletionSource = new TaskCompletionSource(
            TaskCreationOptions.RunContinuationsAsynchronously);
        _cancellationTokenSource = new CancellationTokenSource();
        // in case OnDeliveryReportReceived was never called
        _cancellationTokenSource.Token.Register(SetTaskTimeoutException);
    }

    public void WaitForDeliveryReport(CancellationToken token)
    {
        token.ThrowIfCancellationRequested();
        _cancellationTokenSource.CancelAfter(_waitDeliveryReportTimeoutMs);
        
        // Is this considered sync-over-async?
        _taskCompletionSource.Task.Wait(token);
    }

    public Task WaitForDeliveryReportAsync(CancellationToken token)
    {
        token.ThrowIfCancellationRequested();
        _cancellationTokenSource.CancelAfter(_waitDeliveryReportTimeoutMs);
        return _taskCompletionSource.Task.WaitAsync(token);
    }

    public void OnDeliveryReportReceived(DeliveryReport<TKey, TValue> deliveryReport,
        Action<DeliveryReport<TKey, TValue>> handleReportAction)
    {
        if (_disposed)
        {
            _logger.LogWarning(
                "The delivery report for the message {Key} on topic {Topic} arrived " +
                    "after the awaiter was disposed due to timeout or cancellation. " +
                    "The delivery status is {Status}",
                deliveryReport.Key,
                deliveryReport.Topic,
                deliveryReport.Status);

            return;
        }

        if (!_cancellationTokenSource.TryReset())
        {
            SetTaskTimeoutException();
        }
        else
        {
            handleReportAction?.Invoke(deliveryReport);
            _taskCompletionSource.TrySetResult();
        }
    }

    public void Dispose()
    {
        if (_disposed)
        {
            return;
        }

        _disposed = true;
        _cancellationTokenSource.Dispose();
    }

    private void SetTaskTimeoutException()
    {
        var errorMessage = $"Producer timed out while waiting for publish " +
            $"confirm for {_waitDeliveryReportTimeoutMs}ms!";
        _taskCompletionSource.TrySetException(new KafkaTimeoutException(errorMessage));
    }
}

See the WaitForDeliveryReport method implementation.
Now the question is much longer but I hope it will help people to understand the reason behind it.

Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104
Artur
  • 4,595
  • 25
  • 38
  • Ask yourself why you are asking this and how this can be a useful question without context. Why would you want to call `TaskCompletionSource.Task.Wait()` in the first place? What's the purpose of your question? P.S.: Didn't vote on this (yet) – Julian Mar 05 '23 at 12:51
  • 2
    I didn't vote to close, but I also find that the question needs details or clarity. What is the significance of `TaskCompletionSource.Task` if the `.Task` is a `Task` like any other `Task`? – GSerg Mar 05 '23 at 12:52
  • 1
    If the thread which is waiting is supposed to be the one which should call `SetResult()` or `SetException()` then this is surely a deadlock. Even without that, this sounds like a bad idea in general. – Tanveer Badar Mar 05 '23 at 13:01
  • Please see the updates. – Artur Mar 05 '23 at 13:49
  • 1
    I'm not a native speaker, so maybe I'm missing things. For me, it looks clear that `sync-over-async` means synchronous blocking on async flow. I will change it, though. – Artur Mar 05 '23 at 14:57

2 Answers2

1

Initializing the TaskCompletionSource with the TaskCreationOptions.RunContinuationsAsynchronously option means that the Task will be completed on the ThreadPool, and not on the thread that called the TrySetException method. This is useful in case that the Task is awaited, so that the completion thread is not hijacked by the continuation(s) after the await. Having your thread "stolen" after calling SetResult is a common cause of bugs in asynchronous programming. It is something highly unexpected by most programmers, before they experience it for the first time (usually at the finale a long and unpleasant debugging session).

But in case that the Task is Waited synchronously, completing it on the ThreadPool offers no advantage. The continuation after the Wait will run on the waiting thread, and not on the completing thread. All that the completing thread will have to do is to signal an internal ManualResetEventSlim, which is a negligible amount of work.

So you should avoid initializing the TaskCompletionSource with this option, if you intend to Wait it synchronously. Doing so not only adds overhead for no reason, but also risks the delayed completion of the task, in case the ThreadPool is saturated. In extreme cases it's even possible for a complete deadlock to occur, in case the ThreadPool is saturated and has reached its maximum size.

Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104
  • Thank you for your answer! So, if I understand it correctly, there is no sync-over-async problem while **not** using `RunContinuationsAsynchronously`. When using `RunContinuationsAsynchronously`, two threads are required to accomplish the task, similar to blocking on async code. Am I correct? – Artur Mar 05 '23 at 16:44
  • Sync-over-async has also the risk of deadlock in `SynchronizationContext`-aware environments like classic Asp.Net, WinForms, and WPF. And it happens because the continuation is scheduled on the same thread which blocks the async call. But is there a continuation in the case of `TaskCompletionSource`? In other words is there a risk of deadlock? – Artur Mar 05 '23 at 16:48
  • 1
    @Artur you are probably referring to the classic deadlock illustrated in Stephen Cleary's article [Don't block on async code](https://blog.stephencleary.com/2012/07/dont-block-on-async-code.html). That article is about asynchronous method implemented with the `async` keyword. It's not about asynchronous methods in general, i.e. methods that return tasks. In your case the `Task` is completed on a `ThreadPool` thread, by the .NET `TimerQueue` infrastructure. The only way to deadlock is for the `ThreadPool` to run out of threads. – Theodor Zoulias Mar 05 '23 at 17:25
  • @Artur some people, not most. :-) They might have been burn by async deadlocks in the past, or might have seen coworkers and others been burn, and so they want to warn inexperienced programmers about the dangers lurking in this technology. IMHO the dangers are exaggerated. It's very unlikely that a sync over async deadlock will survive the debugging and testing, and reach the production environment. – Theodor Zoulias Mar 05 '23 at 17:45
-2

As best as I can tell, your extremely long-winded question revolves to just this:

public void WaitForDeliveryReport(CancellationToken token)
{
    token.ThrowIfCancellationRequested();
    _cancellationTokenSource.CancelAfter(_waitDeliveryReportTimeoutMs);
    
    // Is this considered sync-over-async?
    _taskCompletionSource.Task.Wait(token);
}

The answer is yes, of course it is. You have a sane API and you turn it into a random thread (probably the GUI, if you code it like you do this) blocker for absolutely no reason.

Edit:

In a few scenarios, I need to wait for the delivery report before continuing the work

There is a built-in keyword in C# for exactly that: await. You don't need your class or sync-over-async fakery.

Blindy
  • 65,249
  • 10
  • 91
  • 131
  • Please read the full sentence: "In a few scenarios, I need to wait for the delivery report before continuing the work, **which can be in sync or async context.**". I can't use `await` in sync context. Also, read the comments to the question to understand why the question is so `extremely long-winded`. – Artur Mar 05 '23 at 15:14
  • Can you prove the `Task` behind the `TaskCompletionSource` has async semantics? See my example with `Task.Run` to understand why I'm asking. – Artur Mar 05 '23 at 15:39
  • I think there's a fundamental disconnect between tasks, the async mechanism in C#/VB.NET (not .Net!) and your general understanding of threads. Your example of what you call "async semantics" shows the obvious problem with your code, but you seem to be missing it: you're blocking your own thread for as long as another thread is running. That is literally the only reason why tasks and the TPL was designed in the first place. – Blindy Mar 05 '23 at 20:28
  • You're using phrases like "async semantics" as something that have intrinsic meaning, while they mean nothing. It's buzzwords you hear from HR recruiters at best. And you make no case why you can't simply await your task and continue from it, or even use `Task.ContinueWith`, you just allude at some greater issue, but the same with the "async semantics" phrase you think it has intrinsic meaning to everyone and don't spell it out. I can assure you that whatever you're imagining is wrong, and that `await` is the answer to your problem. – Blindy Mar 05 '23 at 20:30
  • 1
    Stop talking with a feeling of superiority and read the question again. If you are not intending to help, don't answer. – Artur Mar 05 '23 at 21:45
  • I could say that if you don't intend to listen to an answer you're not already dead-set on (like calling `Wait` on a task), you shouldn't ask the question, but you do you. – Blindy Mar 06 '23 at 02:30