Slide 1

Slide 1 text

Threads, callbacks, and execution context in Ruby Andrey Novikov, Evil Martians RubyConf AU 2024 12 April 2024

Slide 2

Slide 2 text

About me Back-end engineer at Evil Martians Writing Ruby, Go, and whatever SQL, Dockerfiles, TypeScript, bash… Love open-source software Created a few little Ruby gems Living in Japan for 1 year already Driving a moped Hi, I’m Andrey

Slide 3

Slide 3 text

No content

Slide 4

Slide 4 text

No content

Slide 5

Slide 5 text

evilmartians.com

Slide 6

Slide 6 text

Martian Open Source Ruby Next makes modern Ruby code run in older versions and alternative implementations Yabeda: Ruby application instrumentation framework Lefthook: git hooks manager AnyCable: Polyglot replacement for ActionCable server PostCSS: A tool for transforming CSS with JavaScript Imgproxy: Fast and secure standalone server for resizing and converting remote images Overmind: Process manager for Procfile-based applications and tmux Even more at evilmartians.com/oss

Slide 7

Slide 7 text

Threads, callbacks, and execution context in Ruby

Slide 8

Slide 8 text

Let’s talk about callbacks… What callbacks? No, not Rails callbacks, no-no-no! class User < ApplicationRecord after_create :send_welcome_email end

Slide 9

Slide 9 text

Let’s talk about blocks as callbacks It feels like these two code samples are identical, right? WRONG! 3.times do puts "Hello RubyConf AU!" end i = 0 while i < 3 puts "Hello RubyConf AU!" i += 1 end

Slide 10

Slide 10 text

Blocks are separate pieces of code Block is separate entity, that is passed to times method as an argument and got called by it. 3.times { |i| greet.call(i) } # or 3.times(&greet) greet = proc do puts "Hello, RubyConf Australia!" end

Slide 11

Slide 11 text

Blocks are called by methods Illustration from the Ruby under a microscope book, chapter 2. Ruby under a microscope

Slide 12

Slide 12 text

And there is difference in performance Empty while loop is twice as faster than times with a block. However, difference is negligible with real workloads. And it is not a point of this talk… Benchmark.ips do |x| x.report("blocks") { 1_000_000.times { |i| i } } x.report("while") { i = 0; while i < 1_000_000; i += 1; end } end Warming up -------------------------------------- blocks 4.000 i/100ms while 9.000 i/100ms Calculating ------------------------------------- blocks 40.186 (± 0.0%) i/s - 204.000 in 5.076671s while 89.914 (± 1.1%) i/s - 450.000 in 5.005051s

Slide 13

Slide 13 text

Blocks ARE callbacks We often use blocks as callbacks to hook our own behavior for someone’s else code.

Slide 14

Slide 14 text

Blocks are closures as well A Ruby code to execute Environment to be executed in: Blocks can access local variables and self at the place where block was created …blocks have in some sense a dual personality. On the one hand, they behave like separate functions: you can call them and pass them arguments just as you would with any function. On the other hand, they are part of the surrounding function or method. Ruby Under a Microscope greeter.call # => Hello RubyConf AU 2024 def greeter conf = "RubyConf AU 2024" proc do puts "Hello #{conf}" end end # LAMBDA CALCULUS FTW!!!!1

Slide 15

Slide 15 text

Blocks are closures as well A Ruby code to execute Environment to be executed in: Blocks can access local variables, self, etc at the place where block was created BUT Some environments can be changed unexpectedly

Slide 16

Slide 16 text

Environment changes: self instance_exec and class_exec class Conference def title "RubyConf AU 2024" end def say(&block) puts instance_exec(&block) end end attr_reader :name, :conf "Hello #{conf.title}, I'm #{name}" class Speaker def greet conf.say do end end end undefined local variable or method `conf' for an instance of Conference

Slide 17

Slide 17 text

Environment changes: local vars Someone can change variables enclosed in the block’s environment… # later… conf = "RubyConf AU 2025" greeter.call # => Hello RubyConf AU 2025 conf = "RubyConf AU 2024" greeter = proc do puts "Hello #{conf}" end greeter.call # => Hello RubyConf AU 2024

Slide 18

Slide 18 text

Environment changes: local vars It is done to allow multiple blocks to work with a single enclosed variable. Illistration and code: Ruby under microscope i = 0 increment_function = lambda do puts "Incrementing from #{i} to #{i+1}" i += 1 end decrement_function = lambda do i -= 1 puts "Decrementing from #{i+1} to #{i}" end

Slide 19

Slide 19 text

Blocks can be called from other threads Can you feel how thread-safety problems are coming? work.call "new thread" work.call "main thread" result = [] work = proc do |arg| # Can you tell which thread is executing me? result << arg # I'm closure, I can do that! end Thread.new do end # And guess what's inside result? 🫠

Slide 20

Slide 20 text

Different threads What if we have some units of work that relies on thread local state… work2 = proc do Thread.current[:state] ||= 'work2' raise "Unexpected!" if Thread.current[:state] != 'work2' SecureRandom.hex(8) end work1 = proc do Thread.current[:state] ||= 'work1' raise "Unexpected!" if Thread.current[:state] != 'work1' SecureRandom.hex(4) end

Slide 21

Slide 21 text

Different threads And then try to execute them using concurrent-ruby Promise that utilizes thread pools to execute blocks… promises = 100.times.flat_map do Concurrent::Promise.execute(&work1) Concurrent::Promise.execute(&work2) end Concurrent::Promise.zip(*promises).value! #=> Unexpected! (RuntimeError) # But it also might be okay (chances are low though)

Slide 22

Slide 22 text

Example: NATS client NATS is a modern, simple, secure and performant message communications system for microservice world. In earlier versions every subscription was executed in its own separate thread. nats = NATS.connect("demo.nats.io") nats.subscribe("service") do |msg| msg.respond("pong") end nats-pure gem

Slide 23

Slide 23 text

Example: NATS client Before: After: def subscribe(topic, &callback) # Sent subscribe request to NATS Thread.new do while msg = message_queues[topic].pop callback.call(msg) end end end while msg = incoming_messages.pop do message_queues[msg.topic].push(msg) end def subscribe(topic, &callback) # Sent subscribe request to NATS callbacks[topic] = callback end while msg = incoming_messages.pop Concurrent::Promise.execute do callbacks[topic].call(msg) end end

Slide 24

Slide 24 text

No content

Slide 25

Slide 25 text

Can I use Thread.current in NATS callbacks? Q: So, can I? A: It depends on gem version! 🤯 (you could before version 2.3.0) Hint: better not to anyway! nats = NATS.connect("demo.nats.io") nats.subscribe("service") do |msg| Thread.current[:handled] ||= 0 Thread.current[:handled] += 1 end

Slide 26

Slide 26 text

Where you can find thread pools? Puma Sidekiq ActiveRecord load_async NATS client …and many more Good thing is that you don’t have to care about them most of the time. Pro Tip: In Rails use ActiveSupport::CurrentAttributes instead of Thread.current !

Slide 27

Slide 27 text

How to understand? No easy way to know! 😭 Only by reading API documentation and source code. When and where and how a block will be called?

Slide 28

Slide 28 text

Recap Blocks are primarily used as callbacks Blocks can be executed in a different threads And this thread can be different each time! Think twice before using Thread.current Blocks can be executed with a different receiver Even captured local variables can be changed And you have to remember that!

Slide 29

Slide 29 text

Thank you! @Envek @Envek @Envek @Envek github.com/Envek @evilmartians @evilmartians @evil-martians @evil.martians evilmartians.com Our awesome blog: evilmartians.com/chronicles! See these slides at envek.github.io/rubyconfau-threads-callbacks