ruby-concurrency / concurrent-ruby

Modern concurrency tools including agents, futures, promises, thread pools, supervisors, and more. Inspired by Erlang, Clojure, Scala, Go, Java, JavaScript, and classic concurrency patterns.
https://ruby-concurrency.github.io/concurrent-ruby/
Other
5.68k stars 418 forks source link

Fix `ReentrantReadWriteLock` implementation when Mutex is per-fiber. #983

Closed ioquatix closed 1 year ago

ioquatix commented 1 year ago

Ruby 3.0+ is per-fiber Mutex. Currently, it's possible to get errors when using ReentrantReadWriteLock on different fibers. Let's fix that.

Fixes https://github.com/ruby-concurrency/concurrent-ruby/issues/962

ioquatix commented 1 year ago

This PR also enables the tests under JRuby.

eregon commented 1 year ago

Use Thread.current.local_variable... for implementing thread locals.

Unfortunately I think we cannot just do that, and this is the reason that Concurrent::ThreadLocalVar exists (AFAIK):

Maybe the first point can be addressed with some ObjectSpace.define_finalizer on the ThreadLocalVar. But we would also need a weak set of Thread instances or something. That could be an array/set of Threads + a finalizer per Thread, a bit similar to what RubyThreadLocalVar does. Maybe we could just use Thread.list for that, but 1) that doesn't exist for Fibers (that's the big problem), 2) this might iterate far more threads than threads which ever had a ThreadLocalVar value set (maybe no big deal).

Some pointers I could find about Concurrent::ThreadLocalVar:

There should definitely be better docs on Concurrent::ThreadLocalVar to explain how it differs to Ruby's thread_variable_get. I'll try to add that at some point.

eregon commented 1 year ago

Something interesting is JavaThreadLocalVar is already per-Fiber, since it's per java.lang.Thread and JRuby uses java.lang.Thread (or java.lang.VirtualThread) to implement Fibers.

I think we should probably create a FiberLocalVar, based on JavaThreadLocalVar and RubyThreadLocalVar, and sharing the logic with RubyThreadLocalVar to avoid code duplication (ideally it should only be Fiber.current vs Thread.current changed).

eregon commented 1 year ago

The rest of the PR looks good from a quick look.

To give an example issue with the current PR: each ReentrantReadWriteLock instance leaks a number of bytes per thread (i.e. a Ruby core thread-local/fiber-local variable, so at least the key which is a Symbol, the value and the internal hash entry), even after the ReentrantReadWriteLock is GC'd. That leak only goes away when the Thread instance is GC'd, which might never happen (with thread pools, etc).

ioquatix commented 1 year ago

That makes sense. I don’t think there is any way in Ruby to delete a thread local or fiber local variable In general. Maybe we can nullify it using a finalizer.

eregon commented 1 year ago

That makes sense. I don’t think there is any way in Ruby to delete a thread local or fiber local variable In general. Maybe we can nullify it using a finalizer.

Indeed, there is no such thing as thread_variable_remove or for fiber locals in Ruby (currently at least). So we cannot even use one Ruby thread-local (i.e., thread_variable_get) per ThreadLocalVar instance, because that would leak the internal Hash entry on the Thread and the key Symbol (and per Thread which used that ThreadLocalVar), even if we would set the value to nil via a finalizer. That's probably the main reason why the current implementation uses a single Ruby thread-local for all ThreadLocalVar instances, to avoid that leak.

Aside: I wonder if this is partly why I didn't mind the :"foo_#{object_id}" workaround for Fiber storage variables, because anyway those also don't support removal and keep a strong reference to their key + value until the Fiber is gone, regardless of whether the key would become unreachable (at least currently, maybe something that could be improved in Ruby, but unlikely because incompatible and would likely have a big perf trade-off). Hence if being able to GC a thread/fiber-local is wanted, then one needs concurrent-ruby's ThreadLocalVar/FiberLocalVar.

ioquatix commented 1 year ago

One way to deal with this would be to assume assigning nil deletes the value - i.e. nil is indistinguishable from unset. This seems like a problem we should solve in Ruby and avoid hacking around here (even if we need a short term fix).

ioquatix commented 1 year ago

I've reverted back to the shared array implementation, and reworked it into class AbstractLocals, class ThreadLocals and class FiberLocals. We could perhaps make the name a little more specific, e.g. AbstractLocalsArray or something but I honestly don't have a strong opinion about it.

Generally, it seems okay. I'm a little concerned about the O(number of ECs) cost of deleting a local. If we are expecting people to have 100s or 100,000s of threads/fibers etc, this is a non-trivial cost.

A better solution might be to use a linked list for the @all_locals. Each EC would use a linked list node which is removed when it goes out of scope. With a little cunning, it might be possible to retain the similar synchronisation free approach.

ioquatix commented 1 year ago

@eregon I've done another pass over this, squashed all the commits, etc. Let me know if you want any further changes made.

eregon commented 1 year ago

@ioquatix Could you rebase? (feel free to squash your changes to make the rebase easier) Then I'll review.

ioquatix commented 1 year ago

Okay, rebase done.