Before I started a project, I wrote a simple test to compare the performance of ConcurrentBag from (System.Collections.Concurrent) relative to locking & lists. I am extremely surprised that ConcurrentBag is over 10 times slower than locking with a simple List. From what I understand, the ConcurrentBag works best when the reader and writer is the same thread. However, I hadn't thought it's performance would be so much worse than traditional locks.

I have run a test with two Parallel for loops writing to and reading from a list/bag. However, the write by itself shows a huge difference:

As I mentioned, doing concurrent reads and writes doesn't help the concurrent bag test. Am I doing something wrong or is this data structure just really slow?

[EDIT] - I removed the Tasks because I don't need them here (Full code had another task reading)

[EDIT]
Thanks a lot for the answers. I am having a hard time picking "the right answer" since it seems to be a mix of a few answers.

As Michael Goldshteyn pointed out, the speed really depends on the data.
Darin pointed out that there should be more contention for ConcurrentBag to be faster, and Parallel.For doesn't necessarily start the same number of threads. One point to take away is to not do anything you don't have to inside a lock. In the above case, I don't see myself doing anything inside the lock except may be assigning the value to a temp variable.

Additionally, sixlettervariables pointed out that the number of threads that happen to be running may also affect results, although I tried running the original test in reverse order and ConcurrentBag was still slower.

I ran some tests with starting 15 Tasks and the results depended on the collection size among other things. However, ConcurrentBag performed almost as well as or better than locking a list, for up to 1 million insertions. Above 1 million, locking seemed to be much faster sometimes, but I'll probably never have a larger datastructure for my project.
Here's the code I ran:

You really don't need to wrap the Parallel.For() in a Task inserter in this benchmark.
– Henk HoltermanJan 24 '11 at 18:35

@Henk, you're correct. I wrapped it in a task because in the full code, there is another task that reads while that task writes. ------------ @Rauhotz, I have a Dual Core box
– TachyJan 24 '11 at 18:47

11 Answers
11

Let me ask you this: how realistic is it that you'd have an application which is constantly adding to a collection and never reading from it? What's the use of such a collection? (This is not a purely rhetorical question. I could imagine there being uses where, e.g., you only read from the collection on shutdown (for logging) or when requested by the user. I believe these scenarios are fairly rare, though.)

This is what your code is simulating. Calling List<T>.Add is going to be lightning-fast in all but the occasional case where the list has to resize its internal array; but this is smoothed out by all the other adds that happen quite quickly. So you're not likely to see a significant amount of contention in this context, especially testing on a personal PC with, e.g., even 8 cores (as you stated you have in a comment somewhere). Maybe you might see more contention on something like a 24-core machine, where many cores can be trying to add to the list literally at the same time.

Contention is much more likely to creep in where you read from your collection, esp. in foreach loops (or LINQ queries which amount to foreach loops under the hood) which require locking the entire operation so that you aren't modifying your collection while iterating over it.

If you can realistically reproduce this scenario, I believe you will see ConcurrentBag<T> scale much better than your current test is showing.

Update: Here is a program I wrote to compare these collections in the scenario I described above (multiple writers, many readers). Running 25 trials with a collection size of 10000 and 8 reader threads, I got the following results:

Took 529.0095 ms to add 10000 elements to a List<double> with 8 reader threads.
Took 39.5237 ms to add 10000 elements to a ConcurrentBag<double> with 8 reader threads.
Took 309.4475 ms to add 10000 elements to a List<double> with 8 reader threads.
Took 81.1967 ms to add 10000 elements to a ConcurrentBag<double> with 8 reader threads.
Took 228.7669 ms to add 10000 elements to a List<double> with 8 reader threads.
Took 164.8376 ms to add 10000 elements to a ConcurrentBag<double> with 8 reader threads.
[ ... ]
Average list time: 176.072456 ms.Average bag time: 59.603656 ms.

So clearly it depends on exactly what you're doing with these collections.

I do read from it but as I mentioned in the original post, the amount of time it took was proportionate. However, it may not have been a valid test because of how Parallel.For creates new threads differently for the two cases
– TachyJan 24 '11 at 21:40

@TriArc: Sorry, I was going from the code you posted rather than the words you wrote (I do that a lot). I now see that you say you tested concurrent reads/writes but without seeing the code it's hard to say what's going on there. Did you test with 1 reader and 1 writer? All I can tell you is that my understanding of the collections in System.Collections.Concurrent is that they're designed to be scalable, so the best tests to measure their benefits will be ones that involve large numbers of readers and/or writers.
– Dan TaoJan 24 '11 at 21:43

@TriArc: But anyway, I would recommend at least taking a look at the program I posted on pastebin. Maybe comparing that to what you're doing and seeing what makes our tests different will shed some light on the subject.
– Dan TaoJan 24 '11 at 21:44

thanks for the code. You do a pretty thorough comparison.
– TachyJan 25 '11 at 1:55

4

Heh, just found this while researching ConcurrentBag for a problem, where, interestingly enough, I never read from the collection (until after the threads finish writing to it and re-join the parent). The particular case involves partitioning data into multiple sets -- so, perhaps not as rare as you might think ;-)
– CameronDec 10 '11 at 4:51

Looking at the program using MS's contention visualizer shows that ConcurrentBag<T> has a much higher cost associated with parallel insertion than simply locking on a List<T>. One thing I noticed is there appears to be a cost associated with spinning up the 6 threads (used on my machine) to begin the first ConcurrentBag<T> run (cold run). 5 or 6 threads are then used with the List<T> code, which is faster (warm run). Adding another ConcurrentBag<T> run after the list shows it takes less time than the first (warm run).

From what I'm seeing in the contention, a lot of time is spent in the ConcurrentBag<T> implementation allocating memory. Removing the explicit allocation of size from the List<T> code slows it down, but not enough to make a difference.

EDIT: it appears to be that the ConcurrentBag<T> internally keeps a list per Thread.CurrentThread, locks 2-4 times depending on if it is running on a new thread, and performs at least one Interlocked.Exchange. As noted in MSDN: "optimized for scenarios where the same thread will be both producing and consuming data stored in the bag." This is the most likely explanation for your performance decrease versus a raw list.

Concurrent collections that use locking can be very fast if there is little or no contention for their data (i.e., locks). This is due to the fact that such collection classes are often built using very inexpensive locking primitives, especially when uncontented.

Lockless collections can be slower, because of tricks used to avoid locks and due to other bottlenecks such as false sharing, complexity required to implement their lockless nature leading to cache misses, etc...

To summarize, the decision of which way is faster is highly dependant on the data structures employed and the amount of contention for the locks among other issues (e.g., num readers vs. writers in a shared/exclusive type arrangement).

Your particular example has a very high degree of contention, so I must say I am surprised by the behavior. On the other hand, the amount of work done while the lock is kept is very small, so maybe there is little contention for the lock itself, after all. There could also be deficiencies in the implementation of ConcurrentBag's concurrency handling which makes your particular example (with frequent inserts and no reads) a bad use case for it.

Not me, but may be because its a duplicate answer?
– nawfalApr 3 '13 at 11:13

3

That article is terrible; the author is testing creating large numbers of ConcurrentBag<int>s and putting them in a list of objects, not actually testing making 1 bag and putting tons of objects into the bag itself.
– bcrJul 11 '14 at 15:06

As @Darin-Dimitrov said, I suspect that your Parallel.For isn't actually spawning the same number of threads in each of the two results. Try manually creating N threads to ensure that you are actually seeing thread contention in both cases.

They do use the same # of threads. To verify, replace lst1.Add(i); with lst1.Add(ThreadId); and do a .Distinct() on the result.
– Henk HoltermanJan 24 '11 at 18:58

I had thought about this possibility, but hadn't yet tested it(except giving it a cursory glance with a debugger). I tried specifically starting 40 Tasks for each case. The ConcurrentBag was faster for up to a Million , but it's speed slows down for anything larger. It's speed was actually reasonable.
– TachyJan 24 '11 at 19:29

My guess is that locks don't experience much contention. I would recommend reading following article: Java theory and practice: Anatomy of a flawed microbenchmark. The article discusses a lock microbenchmark. As stated in the article there are a lot of things to take into consideration in this kind of situations.