-
Notifications
You must be signed in to change notification settings - Fork 419
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Concurrent::Hash
default initialization is not fully thread-safe
#970
Comments
Concurrent::Hash
default initialization is not thread-saeConcurrent::Hash
default initialization is not fully thread-safe
Oh, wow. This is interesting. |
Same applies to the require 'concurrent-ruby'
1000.times do
h = Concurrent::Map.new do |hash, key|
hash[key] = Concurrent::Array.new
end
100.times.map do
Thread.new do
h[:na] << true
end
end.each(&:join)
raise if h[:na].count != 100
end |
This change makes the initialization of the hash upon missing key fully thread-safe. Before this change, initialization that would occur in two threads could overwrite each other, as illustrated here: ruby-concurrency/concurrent-ruby#970
Behavior upon missing prefix partial name may cause a key to overwrite when executed in multiple threads at the same time. ref ruby-concurrency/concurrent-ruby#970
Fixes issue described here: ruby-concurrency/concurrent-ruby#970
Initialization upon miss can lead to hard to debug scenarios where potentially a concurrent array will leak out but the value in the `@events` will be different. as described here: ruby-concurrency/concurrent-ruby#970
Without this change potential incrementation can "go away" and can actually mismatch by 1 if run in multiple threads the same time. This can lead to super weird errors where counter is not as expected (been there, took me ages to debug). ref: ruby-concurrency/concurrent-ruby#970
Under intense threading usage, the `@@users` buffer will not be thread-safe fully. Details here: ref ruby-concurrency/concurrent-ruby#970
In case of extensive concurrent usage, the mutex handed over to two threads under same key may differ as illustrated below: ```ruby require 'concurrent' 10000.times do kind_fetcher_locks = Concurrent::Hash.new { |hash, key| hash[key] = Mutex.new } refs = Set.new 100.times.map do |i| Thread.new { refs << kind_fetcher_locks[i % 50].object_id } end.each(&:join) raise "Not 50 but #{refs.count}" unless refs.size == 50 end ``` this can lead to really weird issues. Works when fixed as above: ```ruby require 'concurrent' 10000.times do mutex = Mutex.new # kind_fetcher_locks = Concurrent::Hash.new { |hash, key| hash[key] = Mutex.new } kind_fetcher_locks = Concurrent::Hash.new do |hash, key| mutex.synchronize do break hash[key] if hash.key?(key) hash[key] = Mutex.new end end refs = Set.new 100.times.map do |i| Thread.new { refs << kind_fetcher_locks[i % 50].object_id } end.each(&:join) raise "Not 50 but #{refs.count}" unless refs.size == 50 end ``` ref ruby-concurrency/concurrent-ruby#970
Not locking the default initialization can lead to race-conditions. Note: not sure if I should use one or two mutexes as I am not familiar with this code enough to make the judgment. ref: ruby-concurrency/concurrent-ruby#970
Under intense threading usage, the `@@users` buffer will not be thread-safe fully. Details here: ref ruby-concurrency/concurrent-ruby#970
This pattern is widely prevalent in open source code and it's very very clear that developers assume that this works. I think it's very important to wrap this initializer block in a mutex and not just update the docs |
It's now slightly complicated, as some (as above) have a fix that assumes the initializer is not in the mutex and so call Also, it's suboptimal to use a mutex that's separate from the hash, as above; it would help for Concurrent::Hash to have some of the quality-of-life improvements of Concurrent::Map or at least a way to use the same lock. |
@granthusbands if not fixable or brings weird problems to the table, maybe we could expand rubocop to notify on common mistakes, etc. |
Thank you for the issue report. I generally agree we should fix this if we can. The question is how. (1) We could (try to) use a lock around the whole initializer, but that is also a typical anti-pattern to hold a lock so long, and that can lead to deadlock (e.g., if 2 Concurrent::Hash initializer blocks refer to one another, like #627 which uses the block of (2) We could do what I suggested in my PhD thesis to solve basically the same issue but on It's a classic "pick 1 or 2 but all 3 seems impossible":
|
I've filed an issue on the CRuby tracker to see what they think about the same problem for core |
FWIW CRuby closed that ticket and added documentation that Hash is not thread-safe for that case: https://bugs.ruby-lang.org/issues/19237#note-2 and ruby/ruby@ffd5241. Using the lock approach would also fix #929, but makes it prone to deadlocks. For example we'll already need Monitor and not Mutex to let existing usages of Using an object forwarding Another option would be to pass a special object to the block, which warns on |
Fixes issue described here: ruby-concurrency/concurrent-ruby#970
Fixes issue described here: ruby-concurrency/concurrent-ruby#970
Fixes issue described here: ruby-concurrency/concurrent-ruby#970
Fixes issue described here: ruby-concurrency/concurrent-ruby#970
Not locking the default initialization can lead to race-conditions. ref: ruby-concurrency/concurrent-ruby#970 Co-authored-by: Maciej Mensfeld <[email protected]> resolves puppetlabs#8951
Not locking the default initialization can lead to race-conditions. I don't think we can switch to Concurrent::Map, and it's compute_if_absent method, because insertion order won't be maintained. So synchronize the long way. ref: ruby-concurrency/concurrent-ruby#970 Co-authored-by: Maciej Mensfeld <[email protected]> resolves puppetlabs#8951
Based on the docs:
Given this code:
the initialization is not thread-safe.
Note from @eregon, the thread-safe variant of this code is:
Obviously the latter part of the doc indicates that:
but the initial part makes it confusing:
It can be demoed by running this code:
I would expect to either:
Works like so:
The text was updated successfully, but these errors were encountered: