1

I have read a few stackoverflow threads about multi-threading in a foreach loop, but I am not sure I am understanding and using it right.
I have tried multiple scenarios, but I am not seeing much increase in performance.

Here is what I believe runs Asynchronous tasks, but running synchronously in the loop using a single thread:

Stopwatch stopWatch = new Stopwatch();
stopWatch.Start();

foreach (IExchangeAPI selectedApi in selectedApis)
{
    if (exchangeSymbols.TryGetValue(selectedApi.Name, out symbol))
    {
        ticker = await selectedApi.GetTickerAsync(symbol);
    }               
}    
stopWatch.Stop();

Here is what I hoped to be running Asynchronously (still using a single thread) - I would have expected some speed improvement already here:

List<Task<ExchangeTicker>> exchTkrs = new List<Task<ExchangeTicker>>();
stopWatch.Start();

foreach (IExchangeAPI selectedApi in selectedApis)
{
    if (exchangeSymbols.TryGetValue(selectedApi.Name, out symbol))
    {
        exchTkrs.Add(selectedApi.GetTickerAsync(symbol));
    }
}

ExchangeTicker[] retTickers = await Task.WhenAll(exchTkrs);
stopWatch.Stop();

Here is what I would have hoped to run Asynchronously in Multi-thread:

stopWatch.Start();

Parallel.ForEach(selectedApis, async (IExchangeAPI selectedApi) =>
{
    if (exchangeSymbols.TryGetValue(selectedApi.Name, out symbol))
    {
        ticker = await selectedApi.GetTickerAsync(symbol);
    }
});
stopWatch.Stop();

Stop watch results interpreted as follows:

Console.WriteLine("Time elapsed (ns): {0}", stopWatch.Elapsed.TotalMilliseconds * 1000000);

Console outputs:

Time elapsed (ns): 4183308100
Time elapsed (ns): 4183946299.9999995
Time elapsed (ns): 4188032599.9999995

Now, the speed improvement looks minuscule. Am I doing something wrong or is that more or less what I should be expecting? I suppose writing to files would be a better to check that.
Would you mind also confirming I am interpreting the different use cases correctly?

Finally, using a foreach loop in order to get the ticker from multiple platforms in parallel may not be the best approach. Suggestions on how to improve this would be welcome.

EDIT

Note that I am using the ExchangeSharp code base that you can find here

Here is what the GerTickerAsync() method looks like:

public virtual async Task<ExchangeTicker> GetTickerAsync(string marketSymbol)
{
    marketSymbol = NormalizeMarketSymbol(marketSymbol);
    return await Cache.CacheMethod(MethodCachePolicy, async () => await OnGetTickerAsync(marketSymbol), nameof(GetTickerAsync), nameof(marketSymbol), marketSymbol);
}

For the Kraken API, you then have:

protected override async Task<ExchangeTicker> OnGetTickerAsync(string marketSymbol)
{
    JToken apiTickers = await MakeJsonRequestAsync<JToken>("/0/public/Ticker", null, new Dictionary<string, object> { { "pair", NormalizeMarketSymbol(marketSymbol) } });
    JToken ticker = apiTickers[marketSymbol];
    return await ConvertToExchangeTickerAsync(marketSymbol, ticker);
}

And the Caching method:

public static async Task<T> CacheMethod<T>(this ICache cache, Dictionary<string, TimeSpan> methodCachePolicy, Func<Task<T>> method, params object?[] arguments) where T : class
{
    await new SynchronizationContextRemover();
    methodCachePolicy.ThrowIfNull(nameof(methodCachePolicy));
    if (arguments.Length % 2 == 0)
    {
        throw new ArgumentException("Must pass function name and then name and value of each argument");
    }
    string methodName = (arguments[0] ?? string.Empty).ToStringInvariant();
    string cacheKey = methodName;
    for (int i = 1; i < arguments.Length;)
    {
        cacheKey += "|" + (arguments[i++] ?? string.Empty).ToStringInvariant() + "=" + (arguments[i++] ?? string.Empty).ToStringInvariant("(null)");
    }
    if (methodCachePolicy.TryGetValue(methodName, out TimeSpan cacheTime))
    {
        return (await cache.Get<T>(cacheKey, async () =>
        {
            T innerResult = await method();
            return new CachedItem<T>(innerResult, CryptoUtility.UtcNow.Add(cacheTime));
        })).Value;
    }
    else
    {
        return await method();
    }
}
stackMeUp
  • 522
  • 4
  • 16
  • This is a very common misconception, but asynchronous method calls in C# are not a magic ticket to parallelism. In all your examples above, [one and only one thread is ever being used](https://blog.stephencleary.com/2013/11/there-is-no-thread.html): the main thread from which you invoked the methods. – Patrick Tucci Apr 30 '20 at 10:28
  • @PatrickTucci, are you saying the "Parallel.ForEach" does not run my tasks in parallel? – stackMeUp Apr 30 '20 at 10:30
  • I'm sorry, I did not see that example. That's my mistake. `Parallel.ForEach` and other PLINQ calls can automatically run queries on multiple threadpool threads. Traditional asynchronous method calls do not automatically create parallelism, and most programmers new to the TPL and `async`/`await` keywords believe they do. Which was the reason for my original comment. But you're right, `Parallel.ForEach` does use multiple threads. – Patrick Tucci Apr 30 '20 at 10:34
  • @PatrickTucci, ok great, so nothing wrong about my 3 approaches and interpretation of them? – stackMeUp Apr 30 '20 at 10:37
  • 1
    Your interpretation appears to be correct for all three approaches. I'm assuming, like TomTom mentioned, that you're not seeing a decrease in execution time because the API is limiting your calls. – Patrick Tucci Apr 30 '20 at 10:41
  • @PatrickTucci, great, I will dig into it. They should though, strange :-) Thanks again – stackMeUp Apr 30 '20 at 10:43
  • Can you include in your question the code of the `GetTickerAsync` method? – Theodor Zoulias Apr 30 '20 at 10:51
  • @PatrickTucci you claim that **one and only one** thread is used, linking to [an article](https://blog.stephencleary.com/2013/11/there-is-no-thread.html) that claims that **no thread** is used. Both claims can't be true at the same time. Either your claim or the article's claim must be wrong. – Theodor Zoulias Apr 30 '20 at 10:55
  • @TheodorZoulias The article doesn't say that asynchronous programming uses no threads, it says that no additional threads are used when calling asynchronous methods, which is what many programmers new to `async`/`await` believe. Which is what my original comment said, one and only one thread is used when calling an asynchronous method. Unless the asynchronous method creates threads and waits asynchronously, which is not consistent with asynchronous API etiquette in C# and is outside the scope of these comments. – Patrick Tucci Apr 30 '20 at 10:59
  • @PatrickTucci [the article](https://blog.stephencleary.com/2013/11/there-is-no-thread.html) tries to debunk the fallacy that: *"if I am awaiting an operation, there must be a thread that is doing the wait!"* Which is exactly what you seem to believe. – Theodor Zoulias Apr 30 '20 at 11:07
  • @TheodorZoulias, I have added an Edit showing the GetTickerAsync method and a couple more related ones. – stackMeUp Apr 30 '20 at 12:23
  • @TheodorZoulias I've never said or implied that. The distinction I am making is that a single thread is used for your `Main` method or UI message loop. Aside from that, **no additional threads are used when awaiting an asynchronous method call**. – Patrick Tucci May 01 '20 at 12:20
  • @PatrickTucci your initial statement was *"one and only one thread is ever being used"*, which is most probably incorrect, since the continuation after each `await` could run in a different `ThreadPool` thread. Your next statement was that Stephen Cleary's article *"doesn't say that asynchronous programming uses no threads"*, although this is exactly what is says. The article has the title [**There Is No Thread**](https://blog.stephencleary.com/2013/11/there-is-no-thread.html) ffs! – Theodor Zoulias May 01 '20 at 12:44
  • @TheodorZoulias Why are you quoting specific excerpts from my comments and ignoring the context? I never said that `await`ing asynchronous method calls uses a thread, period. – Patrick Tucci May 01 '20 at 18:54
  • @PatrickTucci if you think that your [initial comment](https://stackoverflow.com/questions/61520919/multi-threading-in-a-foreach-loop?noredirect=1#comment108826313_61520919) is correct in the right context, and helpful for the OP, you can post it as an answer. Comments are for asking more information or for suggesting improvements. Answering questions in comments should be avoided. (You can hover your mouse over the "add a comment" link to see the instructions) – Theodor Zoulias May 02 '20 at 04:01

2 Answers2

2

At first it should be pointed out that what you are trying to achieve is performance, not asynchrony. And you are trying to achieve it by running multiple operations concurrently, not in parallel. To keep the explanation simple I'll use a simplified version of your code, and I'll assume that each operation is a direct web request, without an intermediate caching layer, and with no dependencies to values existing in dictionaries.

foreach (var symbol in selectedSymbols)
{
    var ticker = await selectedApi.GetTickerAsync(symbol);
}

The above code runs the operations sequentially. Each operation starts after the completion of the previous one.

var tasks = new List<Task<ExchangeTicker>>();
foreach (var symbol in selectedSymbols)
{
    tasks.Add(selectedApi.GetTickerAsync(symbol));
}
var tickers = await Task.WhenAll(tasks);

The above code runs the operations concurrently. All operations start at once. The total duration is expected to be the duration of the longest running operation.

Parallel.ForEach(selectedSymbols, async symbol =>
{
    var ticker = await selectedApi.GetTickerAsync(symbol);
});

The above code runs the operations concurrently, like the previous version with Task.WhenAll. It offers no advantage, while having the huge disadvantage that you no longer have a way to await the operations to complete. The Parallel.ForEach method will return immediately after launching the operations, because the Parallel class doesn't understand async delegates (it does not accept Func<Task> lambdas). Essentially there are a bunch of async void lambdas in there, that are running out of control, and in case of an exception they will bring down the process.

So the correct way to run the operations concurrently is the second way, using a list of tasks and the Task.WhenAll. Since you've already measured this method and haven't observed any performance improvements, I am assuming that there something else that serializes the concurrent operations. It could be something like a SemaphoreSlim hidden somewhere in your code, or some mechanism on the server side that throttles your requests. You'll have to investigate further to find where and why the throttling happens.

Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104
  • 1
    Ouha, thanks for this great explanation. I do see a SemaphoreSlim used there indeed. I don't know anything about that, but will check it out. – stackMeUp Apr 30 '20 at 16:10
  • So I believe the reason why my benchmarking always gave similar numbers was because I ran all my tests successively and the one that did not do multi-threading must have affected all the numbers or something of the sort. I have also increase the max number of gates allowed by the SemaphoreSlim(s) – stackMeUp May 01 '20 at 16:40
  • Now that I have numbers that seem to make some sense, I notice that using Parallel.ForEach() is by far the fastest. About 30 times faster than the approach using the list. You are however not recommending using the Parallel.ForEach(). Did you ever notice such a difference in benchmarking? Or could it be that the timer is lying as tasks may still be running in the background when printing the result to Console (probably what you meant by "the method will return immediately")? - Although no threads appear still running in the debugger. – stackMeUp May 01 '20 at 16:53
  • 1
    @stackMeUp yeap, the `Parallel.ForEach` does not `await` the `async void` delegates to finish. It just starts them and then returns. – Theodor Zoulias May 01 '20 at 16:54
  • Interesting! Is there a way to get real benchmark in that case? – stackMeUp May 01 '20 at 16:55
  • 1
    @stackMeUp yeap. You can put stopwatches inside the lambdas. But it's a waste of time. The `Parallel` class is not async-friendly. – Theodor Zoulias May 01 '20 at 16:58
  • 1
    Ok, I'll follow your advice and won't bother with that one! Thank you again for your precious help :-) – stackMeUp May 01 '20 at 17:00
  • Sorry got another detail to ask. When filling up a list with tasks, I suppose we are essentially just pushing in a bunch of pointers, but not actually kicking off the tasks yet. It is only when calling "Task.WhenAll" that all the tasks get launched and waited upon. Is that more or less right? Meaning that I could execute several times the same lot of tasks by repeatedly calling "Task.WhenAll" (ie. only needing to create the list of tasks once at initialisation only)? – stackMeUp May 01 '20 at 17:32
  • 1
    @stackMeUp nope, tasks are almost always created **hot**, meaning they have already started. The `Task.WhenAll` is just used to await all of them to complete. The `await` has no effect to the task itself. It affects the caller, not the callee. – Theodor Zoulias May 01 '20 at 17:43
  • Damn it! Thought I had understood something smart here! :-) – stackMeUp May 01 '20 at 18:02
  • Something I am wondering about. In your last answer, you said "tasks are almost always created hot". But you said in your main answer "The above code runs the operations concurrently. All operations start at once." (That might be the "almost") Does that mean that when doing "tasks.Add(selectedApi.GetTickerAsync(symbol));" in a foreach loop, all the tasks get stated altogether right after exiting the loop or one by one as they get added to the list of tasks? Thanks – stackMeUp May 12 '20 at 09:55
  • 1
    @stackMeUp each task returned by the call `selectedApi.GetTickerAsync(symbol)` is already started. So it is started before being added to the list. The "almost" covers the case of creating a task using its [constructor](https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.task.-ctor) `new Task(...`. In that case the task is created cold (not started). Using the task constructor is rare and is considered unorthodox. AFAIK there is no built-in API that returns a task in a cold state. – Theodor Zoulias May 12 '20 at 10:30
  • Awesome, I get it, thanks. So I suppose the List container could be replaced by any type of container then (since the task is started before being added to the list). Nothing prevents me from doing something of the sort: taskDictionary.Add(symbol, selectedApi.GetTickerAsync(symbol)); – stackMeUp May 12 '20 at 11:44
  • Actually, does not seem to work with the Dictionary. The Task.WhenAll() does not like it as it needs an IEnumerable. I guess the only way to do that is to have the Tasks themselves returning the extra information (eg. symbol). – stackMeUp May 12 '20 at 12:00
  • 1
    @stackMeUp yes, the standard way of getting the result of a task is from the result of the `await` operation. Assuming that you want both the `marketSymbol` and the `ExchangeTicker` when a task completes, then the result of the task should contain both of these values. You can put them in a custom class, or simply use a [`Tuple`](https://learn.microsoft.com/en-us/dotnet/csharp/tuples) (or `ValueTuple`) as the type of the task's result. – Theodor Zoulias May 12 '20 at 12:23
  • 1
    Yeah, I modified the Task, thank you for the confirmation :-) – stackMeUp May 12 '20 at 20:09
1

In general, when you do not see an increase by multi threading, it is because your task is not CPU limited or large enough to offset the overhead.

In your example, i.e.:

selectedApi.GetTickerAsync(symbol);

This can hae 2 reasons:

1: Looking up the ticker is brutally fast and it should not be an async to start with. I.e. when you look it up in a dictionary.

2: This is running via a http connection where the runtime is LIMITING THE NUMBER OF CONCURRENT CALLS. Regardless how many tasks you open, it will not use more than 4 at the same time.

Oh, and 3: you think async is using threads. It is not. It is particularly not the case in a codel ike this:

await selectedApi.GetTickerAsync(symbol);

Where you basically IMMEDIATELY WAIT FOR THE RESULT. There is no multi threading involved here at all.

foreach (IExchangeAPI selectedApi in selectedApis) { if (exchangeSymbols.TryGetValue(selectedApi.Name, out symbol)) { ticker = await selectedApi.GetTickerAsync(symbol); } }

This is linear non threaded code using an async interface to not block the current thread while the (likely expensive IO) operation is in place. It starts one, THEN WAITS FOR THE RESULT. No 2 queries ever start at the same time.

If you want a possible (just as example) more scalable way:

  • In the foreach, do not await but add the task to a list of tasks.
  • Then start await once all the tasks have started. Likein a 2nd loop.

WAY not perfect, but at least the runtime has a CHANCE to do multiple lookups at the same time. Your await makes sure that you essentially run single threaded code, except async, so your thread goes back into the pool (and is not waiting for results), increasing your scalability - an item possibly not relevant in this case and definitely not measured in your test.

TomTom
  • 61,059
  • 10
  • 88
  • 148
  • Thanks. You seem to confirm what I said in my question. You did not comment on the Parallel.ForEach though, which is the key point and is doing parallel tasking I believe :-) – stackMeUp Apr 30 '20 at 10:40
  • 1
    Actually I did. Parallel.Foreach will run ino the problem of #2 EXTREMELY fast - you start a lot of parallel operations, but in many scenarios (http) the number of parallel requests processed is limited. So, they bascially queue up. – TomTom Apr 30 '20 at 11:01