On my talk on spotting unsafe concurrent ruby patterns I documented a few common Ruby code patterns that may trigger issues when executed concurrently across multiple threads.

I just bumped into another such example! This specific one seems not to affect MRI Ruby (aka the default Ruby implementation), but I was able to clearly trigger it on both JRuby and TruffleRuby using

some_hash[key] = value

The trigger above is very simple — having different threads writing to the same Hash concurrently. Just writing, they don’t even need to be reading.

Here’s the full example:

puts RUBY_DESCRIPTION

PER_THREAD_LIMIT = 1000000
THREADS = 8

THE_HASH = {}

(1..THREADS).to_a.map do |thread_id|
  Thread.new do
    PER_THREAD_LIMIT.times do
      THE_HASH[rand(PER_THREAD_LIMIT)] = thread_id
    end
    puts "Thread #{thread_id} done!"
  end
end.map(&:join)

puts "All done!"

and here’s the result of running it on JRuby:

jruby 9.2.8.0 (2.5.3) 2019-08-12 a1ac7ff OpenJDK 64-Bit Server VM 11.0.3+7-LTS on 11.0.3+7-LTS +jit [linux-x86_64]
warning: thread "Ruby-0-Thread-3: threads.rb:1" terminated with exception (report_on_exception is true):
java.lang.ArrayIndexOutOfBoundsException: Index 18581 out of bounds for length 16427
        at org.jruby.dist/org.jruby.RubyHash.internalPutNoResize(RubyHash.java:561)
        at org.jruby.dist/org.jruby.RubyHash.internalPut(RubyHash.java:535)
        at org.jruby.dist/org.jruby.RubyHash.internalPut(RubyHash.java:525)
        at org.jruby.dist/org.jruby.RubyHash.fastASetCheckString(RubyHash.java:1020)
        at org.jruby.dist/org.jruby.RubyHash.op_aset(RubyHash.java:1055)
        at org.jruby.dist/org.jruby.RubyHash$INVOKER$i$2$0$op_aset.call(RubyHash$INVOKER$i$2$0$op_aset.gen)
        at org.jruby.dist/org.jruby.runtime.callsite.CachingCallSite.call(CachingCallSite.java:203)
        at threads.invokeOther1:\=\{\}=(threads.rb:11)
        at threads.RUBY$block$\=threads\,rb$2(threads.rb:11)
        at org.jruby.dist/org.jruby.runtime.CompiledIRBlockBody.yieldDirect(CompiledIRBlockBody.java:146)
        at org.jruby.dist/org.jruby.runtime.IRBlockBody.yieldSpecific(IRBlockBody.java:85)
        at org.jruby.dist/org.jruby.runtime.Block.yieldSpecific(Block.java:139)
        at org.jruby.dist/org.jruby.RubyFixnum.times(RubyFixnum.java:279)
        at org.jruby.dist/org.jruby.RubyInteger$INVOKER$i$0$0$times.call(RubyInteger$INVOKER$i$0$0$times.gen)
        at org.jruby.dist/org.jruby.runtime.callsite.CachingCallSite.call(CachingCallSite.java:151)
        at org.jruby.dist/org.jruby.runtime.callsite.CachingCallSite.callIter(CachingCallSite.java:160)
        at threads.invokeOther3:times(threads.rb:10)
        at threads.RUBY$block$\=threads\,rb$1(threads.rb:10)
        at org.jruby.dist/org.jruby.runtime.CompiledIRBlockBody.callDirect(CompiledIRBlockBody.java:136)
        at org.jruby.dist/org.jruby.runtime.IRBlockBody.call(IRBlockBody.java:77)
        at org.jruby.dist/org.jruby.runtime.Block.call(Block.java:129)
        at org.jruby.dist/org.jruby.RubyProc.call(RubyProc.java:295)
        at org.jruby.dist/org.jruby.RubyProc.call(RubyProc.java:274)
        at org.jruby.dist/org.jruby.RubyProc.call(RubyProc.java:270)
        at org.jruby.dist/org.jruby.internal.runtime.RubyRunnable.run(RubyRunnable.java:105)
        at java.base/java.lang.Thread.run(Thread.java:834)

Thread 5 done!
Thread 8 done!
Thread 4 done!
Thread 6 done!
Thread 2 done!
Thread 1 done!
Unhandled Java exception: java.lang.ArrayIndexOutOfBoundsException: Index 18581 out of bounds for length 16427
java.lang.ArrayIndexOutOfBoundsException: Index 18581 out of bounds for length 16427
   internalPutNoResize at org/jruby/RubyHash.java:561
           internalPut at org/jruby/RubyHash.java:535
           internalPut at org/jruby/RubyHash.java:525
   fastASetCheckString at org/jruby/RubyHash.java:1020
               op_aset at org/jruby/RubyHash.java:1055
                  call at org/jruby/RubyHash$INVOKER$i$2$0$op_aset.gen:-1
                  call at org/jruby/runtime/callsite/CachingCallSite.java:203
  invokeOther1:\=\{\}= at threads.rb:11
            threads.rb at threads.rb:11
           yieldDirect at org/jruby/runtime/CompiledIRBlockBody.java:146
         yieldSpecific at org/jruby/runtime/IRBlockBody.java:85
         yieldSpecific at org/jruby/runtime/Block.java:139
                 times at org/jruby/RubyFixnum.java:279
                  call at org/jruby/RubyInteger$INVOKER$i$0$0$times.gen:-1
                  call at org/jruby/runtime/callsite/CachingCallSite.java:151
              callIter at org/jruby/runtime/callsite/CachingCallSite.java:160
    invokeOther3:times at threads.rb:10
            threads.rb at threads.rb:10
            callDirect at org/jruby/runtime/CompiledIRBlockBody.java:136
                  call at org/jruby/runtime/IRBlockBody.java:77
                  call at org/jruby/runtime/Block.java:129
                  call at org/jruby/RubyProc.java:295
                  call at org/jruby/RubyProc.java:274
                  call at org/jruby/RubyProc.java:270
                   run at org/jruby/internal/runtime/RubyRunnable.java:105
                   run at java/lang/Thread.java:834

as well as on TruffleRuby:

truffleruby 19.1.1, like ruby 2.6.2, GraalVM CE Native [x86_64-linux]
Thread 1 done!
threads.rb:11:in `[]=': <no message> (NullPointerException) (RuntimeError)
        from org.truffleruby.core.hash.PackedArrayStrategy.getHashed(PackedArrayStrategy.java:41)
        from org.truffleruby.core.hash.SetNode.setPackedArray(SetNode.java:80)
        from org.truffleruby.core.hash.SetNodeGen.executeSet(SetNodeGen.java:37)
        from org.truffleruby.core.hash.HashNodes$SetIndexNode.set(HashNodes.java:239)
        from org.truffleruby.core.hash.HashNodesFactory$SetIndexNodeFactory$SetIndexNodeGen.execute(HashNodesFactory.java:632)
        from org.truffleruby.language.control.SequenceNode.execute(SequenceNode.java:34)
        from org.truffleruby.language.methods.ExceptionTranslatingNode.execute(ExceptionTranslatingNode.java:51)
        from org.truffleruby.language.RubyRootNode.execute(RubyRootNode.java:54)
        from org.graalvm.compiler.truffle.runtime.OptimizedCallTarget.callProxy(OptimizedCallTarget.java:328)
        from org.graalvm.compiler.truffle.runtime.OptimizedCallTarget.callRoot(OptimizedCallTarget.java:318)
Translated to internal error
        from threads.rb:11:in `block (3 levels) in <main>'
        from threads.rb:10:in `times'
        from threads.rb:10:in `block (2 levels) in <main>'

This happens because neither Ruby has synchronization when changing the internal structure of the Hash (which happens when for instance the hash needs to grow), and thus we can run into this issue.

Note also that this won’t happen every time, as this is a race condition, so your results may vary :)

How to fix it? Consider replacing the hash with a Concurrent::Map or wrapping it with a lock.