Hacker News new | past | comments | ask | show | jobs | submit login
Ruby 3.0 and the new FiberScheduler interface (wjwh.eu)
216 points by WJW on Dec 29, 2020 | hide | past | favorite | 94 comments



> all relevant standard library methods have been patched to yield to the scheduler whenever they encounter a situation where they will block the current fiber

This is huge. They solved the function-coloring problem by deciding that all functions are now potentially async. It makes it more likely that the ecosystem as a whole actually becomes async-compatible. I wish Python had taken this approach, though I understand why they didn't.


(Author here)

I kinda agree but yielding only on I/O is not enough IMO. Sooner or later we'll want to do CPU intensive work in fibers as well, which in the current implementation will block all other fibers on the same thread. Like I mentioned in the article, I'd like to see work stealing between different threads as that would allow fibers to migrate away from threads stuck in CPU intensive work. An alternative way could be to adopt a model similar to Haskells lightweight threads, where the runtime forces a `yield` after N milliseconds (configurable). That would make sure that CPU intensive work would not block other fibers "too" much.


Go's journey here has been interesting. Early on it was possible (though rarely seen in practice) to end up with a cpu bound thread not yielding because it didn't hit yield point like I/O.

Then they added a guarantee that if your loop called a function, the scheduler would be able to make the goroutine yield. https://golang.org/doc/go1.2#preemption

This mostly worked although you could still have a CPU-bound thread not making any function calls. I also personally ran into a pathological issue where the scheduler was being invoked, but a heuristic kept the current goroutine running so others were still starved.

Finally they added true pre-emption (not yielding) in 1.14 https://golang.org/doc/go1.14#runtime it looks like it just sends signals and saves state.

Once nice thing is that if I understand the go runtime correctly, work stealing by scheduling goroutines on different threads has been a thing for a long time.


This is not only the way Haskell's scheduler work but also Erlang VM's. Not completely the same because the BEAM scheduler uses reductions instead of time but both scheduling schemes can be classified as preemptive. Since fibers are cooperatively scheduled I don't believe the core developers of Ruby would just agree to switch the scheduling scheme.


> Sooner or later we'll want to do CPU intensive work in fibers as well, which in the current implementation will block all other fibers on the same thread.

This limitation is present in every modern asynchronous programming system I've ever seen. They simply can't deal with long function calls. There's a loop under the hood which constantly executes queued functions. Any long-running function will stall the execution pipeline and increase latency. It's just like cooperative multitasking...


Golang, .Net, and BEM all use preemptive scheduling IIRC. Don't know the specifics of each implementation, but you can use timers to signal the process and "wake" the scheduler.


Beam uses reduction, basically instructions count. I believe native functions can still lock up a scheduler (and must provide an estimate of their icount) which is one of the reasons why they’re tricky.


That depends. A long-running function is usually just a semantically long running function which consists of multiple (usually suspendable) parts. Of course there are exceptions, such as long running cpu heavy calculations for which a single atomic call takes longer than just milliseconds. But I think this is rather rare.

And for just semantically long running functions, it is not difficult to write a scheduler that tries to be "fair", however you want to interpret that. But of course it requires to fully embrace asynchronous programming, something that will probably difficult in Ruby.


Ruby already handles this for regular threads by setting a timer for how long one thread can hold the GVL, doesn’t it? Could the same thing work for fibers? I.e. could the ruby vm inject a yield into a running fiber after every 100ms?


This is essentially the approach Java is taking with the upcoming loom project.


> They solved the function-coloring problem

Not really, they've just made it super implicit. Any FFI calls are now implicitly coloured, same with anything CPU-heavy.

Like their approach to type annotations, I think this will be a mistake in hindsight.


This is gonna sound pedantic but I think it's not a coloring problem. Coloring is when syntactically a function has to change its own signature just because it started calling an inner function with the new color.

That said, you're right in that one may have to make some changes to code to get around some new problems. The problem with FFI/CPU-heavy functions is that they prevent _other_ fibers from being scheduled while they run.

If it becomes possible to implement work-stealing, then that would mitigate the problem. It would also be solvable by sprinkling "yield to scheduler" calls throughout such functions. Annoying and not always possible, but, since in these cases the signature would not change, technically not coloring.


It is not pedantic at all, you are completely right; if this implementation were to be classified as coloroing, then everything would be, including classic threads.


Ruby FFI has been experimenting with provided support for kicking external functions to background threads with a single keyword argument.


In a high level dynamic language with exceptions, there's already so many implicit "colors" to a function that I think this is still the right choice.


This "implicit coloring" is what's talked about in _Unyielding_ [1], which is the "explicit color is good" counterpoint to _What Color is Your Function?_ (though Unyielding predates WCIYF by about a year).

[1]: https://glyph.twistedmatrix.com/2014/02/unyielding.html


I don't see how this solves the "coloring" problem. "Colored" functions have a different return type; a future of some sort. If you want to return a future to a caller, or have a method that blocks until the work is done, you still need a way to differentiate that.

Golang does essentially the same thing and uses channels instead of "futures". However, some APIs will have functions that return a channel that you receive a message on later.. Which is a lot like a future. And if you provide a more simple non-channel API along side(instead of making the consumer wrap your calls in a Go routine or always use a channel), you now have "colored" functions.

I believe this is just a solution to a scheduling problem(pre-emptive vs cooperative).


> "Colored" functions have a different return type; a future of some sort.

This is an aside and possibly pedantic, but everyone seems to have forgotten that the whole idea of futures was originally that they weren't some different type. They were the same type, but blocked transparently on demand and had no `get` operation.

The construct that came before futures, called eventual-values, had the different-type thing. The only difference of futures, the big idea, was they said 'maybe we can get rid of that part'.

> These “futures” greatly resemble the “eventual values” of Hibbard’s Algol 68 [39]. The principal difference is that eventual values are declared as a separate data type, distinct from the type of the value they take on, whereas futures cannot be distinguished from the type of the value they take on.

(Halstead, 1985)

But now people write types like `Future[T]`... which completely misses the point! If you start to write `Future[...` then think to yourself... this isn't a future. And that's how you get this colouring problem you describe... it was already fixed and people have forgotten all about it.


This seems a lot like the “remote calls are invisibly transparent” which most people decided was a bad idea. (IIRC)


I'm not debating if it's a bad idea or not - I'm just saying that's the definition of future as the authors intended it. If you don't want that that, then maybe call it an eventual value instead as you don't have the single essential part of what makes a future.


I think the general meaning of this term has changed. Future is now considered a different type and I think we just have to accept that the older definition isn't really used anymore.


Yes and to be honest I've written Future<... myself.


I don’t understand your final point. Are you saying that generics on futures are bad? I don’t see the alternative.


> Are you saying that generics on futures are bad?

Yes - because it means existing code that expects a T cannot accept a Future<T>, which is the colouring problem being discussed.

You end up with half your code accepting futures and the other legacy half not accepting them and there's a divide. If you want to use the legacy code you have to force the future potentially before you really needed it.

> I don’t see the alternative.

The alternative is wherever a function accepts a concrete value T t, also let it accept a future value T t. When t is actually used, then block as if get was called.


> Yes - because it means existing code that expects a T cannot accept a Future<T>

First of all, this is a programming language specific problem, not a general one. A good language allows you to write:

    someFuture.map(someFunction)
Where "someFunction" accepts the plain value of type T that is stored within "someFuture".

Yes, you cannot write someFunction(someFuture), but that is a good thing, since asynchronous execution can very well change the behaviour of the program. Hence, I want the compiler (if there is one) to warn me if a variable that I pass to a function is now a Future instead of a plain value. I then map over the Future and I'm good. Or, maybe I want to wait for completion of the future and I'm good.

In both cases, there is no reason to write a second version of "someFunction".

Therefore...

> If you want to use the legacy code you have to force the future potentially before you really needed it.

... this is only true in incapable programming languages.


Isn't this the point I was making?

You need support in the language to do this. If you're writing a library routine from scratch as Future<... with the compiler knowing nothing about how to handle it specially then you aren't getting a future as they were originally envisioned and you're missing out.


> Isn't this the point I was making?

I don't think so - let me try to make it more clear, hold on.

> You need support in the language to do this. If you're writing a library routine from scratch as Future<... with the compiler knowing nothing about how to handle it specially then you aren't getting a future as they were originally envisioned and you're missing out.

You seem to assume that the programming language needs to know of the existence of Futures to help. But that is not the case and probably the reason for the misunderstanding.

It is sufficient that the language supports generics/type-parameters as well as a lightweight syntax (such as in the example I gave) and optimally higher kinded types or a similar mechanism.

If that is given, the language can be totally agnostic of Futures or async programming in general and it is easily possible to define a Future type as a library-type and use it in the way I described. Scala is a good example of such a language.


Do you mean the simple construct

    someFuture.map(someFunction)
My understanding is that this only runs someFunction when the future has completed?

This blocks the entire of someFunction until all the values are ready. You haven't adapted someFunction to work with futures at all - you're simply not running it until all the futures are gone and only normal values remain which you then pass into it!

Imagine someFunction comes from a library so cannot be modified trivially, takes three values, and can run successfully with the first two completed values while the third completes. What would you write with map to do that?


Your understanding is correct! It is inevitable that someFunction can only be called once someFuture has completed successfully. No language or technology can change that, but it is as much of a problem as the fact that in most languages lines are executed in sequence: pretty much no one thinks that this is a problem and I think we both agree on that.

So then...

> Imagine someFunction comes from a library takes three values, and can run successfully with the first two completed values while the third completes. What would you write in someFuture.map(...) to do that?

That is a good example actually. I believe that the user (at the callsite) should both _know_ if the library operates in an async way and/or runs code in parallel _and_ the user should be in control of choosing his desired behaviour.

Hence, with a library function that just expects 3 values, this is not possible and I would say the design isn't great.

Here is how I would write it in scala-ish pseudocode:

    // library - two separate functions instead of one
    def calcX(a, b): X
    def calcY(x, c): Y
    // One "combined" synchronous function can be created easily if desired:
    combinedCalc = calcX.andThen(calcY)
    
    // all given values are (lazy) futures
    aFuture = future(...)
    aFuture = future(...)
    aFuture = future(...)
    
    // callsite - async but _no parallel execution_
    xFuture = (aFuture, bFuture).mapN(calcX) // This is actually valid Scala syntax. Returns a future
    yFuture = (xFuture, cFuture).mapN(calcY)

    // alternative Scala syntax
    yFuture = for {
      a <- aFuture
      b <- bFuture
      x <- calcX(a, b)
      c <- cFuture
    } yield calcY(x, c)
As you can see, I could also change the order:

    yFuture = for {
      a <- aFuture
      c <- cFuture
      b <- bFuture
      x <- calcX(a, b)
    } yield calcY(x, c)
This is relevant because I might want to control when things get executed - but I can b sure they run one after each other and nothing runs while something else has already failed or was already started.

How to run it in parallel?

    xFuture = (aFuture, bFuture).parMapN(calcX)
    yFuture = (xFuture, cFuture).parMapN(calcY)
Now everything runs in Parallel.

Sorry the wall of code, but it shows that the code can look rather short even without future or async specific syntax support from the language.

The important part here is that it allows me to have fine-grained control over the execution of my code. There are a lot of situations where I probably don't care and it might be nice to have a way to say "Hey compiler, I don't care, just run that code in the way you think it's best", similar to what an SQL planner does with my queries.

But often I want control. For instance, in your example: what happens if the library decides to always evaluate "c" eagerly even though "a" and "b" often fail and the whole calculation is aborted? That would be a waste of CPU. But worse: the library might change its behaviour any time and keep the same function signature.

While that might be not so bad for mere calculations, what if we start to think about actions? Such as database or file or network IO - or even in-memory transactions that should be atomic. How about _streams_ of these actions, such as running an action every second and then combining this with other streams of actions? Losing control over the fain grained execution will result in terrible hard to debug and fix bugs due to concurrency.


> It is inevitable that someFunction can only be called once someFuture has completed successfully. No language or technology can change that

This is just not a true statement - and that was the point of the original thread - the original paper on futures had them implemented transparently and able to be passed down into library code not expecting futures. It's also how Haskell's par works today. So it demonstrably can be implemented.

> I believe that the user (at the callsite) should both _know_

Ok but this is just a 'don't do that' argument. If a user tells us they have a good reason to do it, I'm interested in how we can enable them to do that.

> with a library function that just expects 3 values, this is not possible

But it is in Haskell using par, and other languages with more traditional futures.

> Here is how I would write it

Great... but you've rewritten the library code with 'two separate functions instead of one.' My original point was 'because it means existing code that expects a T cannot accept a Future<T>' and you're proving that back to me so hard that you've accepted you need to completely rewrite the code to do it!

Languages with traditional futures would let you just call the original, unmodified library code, with future values instead of actual values, and it would just work.

If you had library code like this, you could pass in future values of T for a, b, and c, and it would execute the expensive_operation on a and b as soon as it could, while c completes.

    def foo(T a, T b, T c)
        expensive_operation(a, b) + expensive_operation(b, c)
    end
> For instance, in your example: what happens if the library decides to always evaluate "c" eagerly even though "a" and "b" often fail and the whole calculation is aborted?

Yes this is a problem with transparent futures, as is the ordering issue you raised. But I wasn't arguing they didn't have their own issues. You're saying it's impossible and that is demonstrably false, because languages do do it.

(I did half of my PhD on these topics, and the other half on Ruby.)


> This is just not a true statement - and that was the point of the original thread - the original paper on futures had them implemented transparently and able to be passed down into library code not expecting futures. It's also how Haskell's par works today. So it demonstrably can be implemented.

Okay, technically you are right - however it changes the semantics of the program. I admit that I didn't mention this constraint though.

> Great... but you've rewritten the library code with 'two separate functions instead of one.' My original point was 'because it means existing code that expects a T cannot accept a Future<T>' and you're proving that back to me so hard that you've accepted you need to completely rewrite the code to do it!

> Yes this is a problem with transparent futures, as is the ordering issue you raised. But I wasn't arguing they didn't have their own issues.

I don't disagree with any of that. However, what you originally said:

> > Are you saying that generics on futures are bad?

> Yes - because it means existing code that expects a T cannot accept a Future<T>, which is the colouring problem being discussed.

And you imply that transparent futures are better than the ones with generics. So where we disagree is on what is worse. I am convinced that the problems (and there are certainly a lot of unknowns) you introduce with transparent futures is much worse than the performance you might gain in certain cases and the easier syntax in many cases.


For what it's worth, I believe hiding the semantics of futures is not the way for most languages I use with concurrency primitives either.. And it would seem the language designers agree.

I believe it's important to expose the fact that an asynchronous(potentially parallel) task is doing something for you. The runtime semantics of an assignment would appear the same in code by be very different behind the scenes otherwise. Also, a lot of code is written to manage those tasks in use-case specific ways. How would you race or cancel transparent futures?


Does this mean that Rust's Future trait is incorrectly named? Or at least not named according to the academic use of the word?

Of course because Rust's runtime or lack thereof aims to be zero overhead a Future could never be without the poll function.. right? You need a runtime to make regular functions accept these magic opaque future values.


Yes not named according to the original paper.

Yes it requires some kind of special compiler or runtime support.


Isn't Future a functor?


That's not the intention of the original authors, no.


It solves the coloring problem by not having a coloring problem in the first place. Maybe I should have said "avoids the function-coloring problem".


I believe the example in the article "avoids the function-coloring problem" by completely avoiding its problem space of continuing the execution of an imperative block of code after starting an asynchronous task to produce some value, and then synchronizing at the production of that value. In the example each code block run in a fiber blocks at Net::HTTP.get and only continues after it returns a value.

Looking at the API for Fiber.. It looks like "resume" returns the yield(generator) or fiber return value? So Fibers can be treated as a sort of Promise? As soon as you start returning fibers from methods you have the coloring problem..


Python has gevent.


I'm excited to see where this goes, but I must admit I'm conflicted on the idea of seeing async Ruby code. Async is great technically but one of the things I love about Ruby is the elegance and clarity. That tends to go out the window when async is introduced in many languages. async/await has helped a ton though, so hopefully Ruby will get something like that as we go.

I would just suggest that sometimes performance isn't worth sacrificing clarity. Obviously sometimes you have to (Big O can be an unforgiving beast) but not always.


Time will tell, but this could be beneficial for existing libraries like concurrent-rb.

And, maybe it becomes more useful for using ruby in smaller services outside of the Rails context, especially when the go-to solution for a bunch of problems is to run a separate copy of your server in 'worker mode'. Even with Rails, not relying on Delayed Job or Sidekiq by default would be nice. Of course, I'm thinking about Ractor there too.


It worth remembering that ruby code already handles async, and threads. For example ActiveRecord database requests have been non-blocking for many years.

This new scheduler interface just gives us a nicer and lighter weight abstraction for handling it rather than, for example, using OS thread.


> ActiveRecord database requests have been non-blocking for many years.

That's blocking IO with multi-threading. The executing thread stops and waits.


Yes, poor choice of words on my part. The IO calls themselves still block but each is in its own OS thread and GVL releases when the thread blocks.

If you can only do parallel IO in a thread why not use a light weight fibre (green thread) instead of an OS thread? Hence this new scheduler and fibres.


> each is in its own OS thread and GVL releases when the thread blocks.

The GVL isn't automatically released when a thread blocks on IO. Each bit of native code performing IO has to explicitly release it like in the pg gem here: https://github.com/ged/ruby-pg/blob/fb465855ce1dd12cf7eb69c9...

If you don't need high concurrency then using threads is simpler.


> For example ActiveRecord database requests have been non-blocking for many years.

Right, but it still blocks the entire calling thread, no? Been a few years since I've been deep into Rails.


Ah, I see the confusion in what I’ve said. Yes the IO calls themselves are blocking but you can easily wrap them in separate OS threads.

The GVL will release when the IO call blocks allowing another OS thread, including the parent, to continue.

That allows you to make blocking calls in parallel but is heavier weight than using the new scheduler and fibres for those parallel IO calls.

In the case of ActiveRecord I think it’s down to the database driver implementation, anyone who knows care to comment?


Before I learned Rails I used EventMachine, which was very "async". Everything was a chained callback. So Ruby has had the option of this complexity for a while.


Using FiberScheduler allows you to use async IO without the additional complexity of callbacks.


As an outsider, why doesn't/can't Ruby implement a full actor system like Erlang/BEAM? Ruby is already based on message passing so it seems like it should be possible. Granted it'd likely induce a large performance hit since presumably every object would need to be locked or have a message queue.


Well... the big reason almost no runtime/vm provides what erlang does is because BEAM will preempt a running actor if it takes too long (uses up its operation budget more or less). Ruby only provides mechanisms for cooperatively scheduled execution. That is to say, and this is the big problem with async most everywhere except Erlang/BEAM, a task will block everything else from executing until its finished and yields to another fiber/thread.

Shrug Having tried like every possible method of async I/O with Ruby to eek out moar good perfz back in the day... including various actor implementations (sup, JRuby and Akka)... nothing compares to just using BEAM if you want the actor paradigm. The cooperative scheduling problem is a really enormous pain in the ass most of the time.


We can do the same model for Ruby (preemption of event driven fibers) and may choose to do so in the future. We are just taking one step at a time.


That's part of my question (to me at least) is why they couldn't implement preemptive actors? At least at the granularity of individuals methods which in Ruby is pretty much any action, right? Async and the whole red/blue function problem seems like a pain.


Ractors, also introduced in ruby 3.0, are basically this. Ractors work by passing messages or immutable objects. https://github.com/ruby/ruby/blob/master/doc/ractor.md


> As an outsider, why doesn't/can't Ruby implement a full actor system like Erlang/BEAM?

Do you really want an actor system?

Actors are non-determinsitic, extremely prone to difficult-to-debug race conditions, and inherently stateful. They're a classic concurrency foot-gun from the dark old ways of doing things.

Don't we want to be moving away from these models that we know trip people up? Can't we do better than this for Ruby?


You shouldn't use actor systems as a code organization pattern, only in places where async is necessary and in those cases you're going to have to debug race conditions anyways. When that happens you will want an actor system because it will make it easier to understand the data flowing through your system, and especially, effortlessly (0 loc) clean up dangling resources from error conditions and prevent leaks. Moreover Elixir provides you fantastic tools to drive unit and integration testing around your async systems.


> They're a classic concurrency foot-gun from the dark old ways of doing things.

Sure you're not thinking of threads? Actor's have only really been done in Erlang, and more recenetly in Akka and Orlean's. AFAIK, there aren't any other paradigms other than CSP or threads for concurrency.

Actor's can have race conditions, same as any other concurrent system but I've personally never actually had any given the design patterns in OTP and Elixir. It's really helpful (to me) that each actor is always deterministic in itself and doesn't share data, only messages. It also maps nicely onto multicore.


> Sure you're not thinking of threads?

No I'm thinking of actors - stateful, non-determinstic, racey actors. A minefield of classic concurrency bugs!

> Actor's can have race conditions, same as any other concurrent system

Deterministic systems like fork-join don't ever have race-conditions.

> is always deterministic in itself and doesn't share data

But the global state of all the actors is shared. If an actor you send a message to responds differently due to its state then let's be honest you're implicitly sharing that state.

> It also maps nicely onto multicore.

It maps directly to multicore, right. Don't we want something higher level and safer than directly mapping to our hardware?


it's pretty trivial to implement a deterministic fork/join[0] in actors, mostly by using the strategy "ignore messages". Elixir does this with the Task.async/1 function.

https://hexdocs.pm/elixir/Task.html#module-async-and-await

[0] determinism can go out the window once there's an error. Can you safely say you know what happens in ruby if you are running async and one of your concurrently running async functions throws?

The nice thing in elixir there is zero ambiguity as to what happens if you have (N) async functions running and one of them crashes -- it brings down all of the other threads and triggers their respective resource cleanups in exactly the way that they do as actors, since Task is a fairly trivial wrapping of erlang's actors. (Or you have choices!!, you can async_nolink if you want your async function to plow forward even if its parent dies due to the crash of one of its siblings)


I think when GP said "full actor system" they meant distributed actors. Of course this will mean the actors have state and message passing is nondeterministic. It's a network, afterall.

I think there is still some confusion since you suggest fork/join but that's only for threads and won't work over a network.


> but that's only for threads and won't work over a network

Map-reduce is fork-join and runs just fine over a network.


> No I'm thinking of actors - stateful, non-determinstic, racey actors. A minefield of classic concurrency bugs!

Well yes systems of actors contain race conditions. However, so does Rust's or Go's concurrency primitives. The only way to completely avoid race conditions I know of would be formal methods via TLA+ or similar. From my brief looks into TLA+ it'd appear to match actor implementation models pretty well (messages to actors yield new states).

> But the global state of all the actors is shared. If an actor you send a message to responds differently due to its state then let's be honest you're implicitly sharing that state.

It's not really whether a program has state, but how that state is accessed and modified. In the actor model, only an actor itself can modify it's own data which makes it easier to reason about many classes of concurrency problems. Especially actions within an actor itself are deterministic (generally speaking). This makes a lot of problems become very straightforward to model without needing locks everywhere. The concurrency problem is shifted to modeling the behavior of a group of actors, which helps enforce separation of concurrency concerns from the lower level implementation details (with a good framework at least).

Since I was asking and thinking about Ruby getting (preemptive) actors initially, this frame of thought seems odd to me. The entire foundation of Ruby is built on objects that contain implicit state and on those objects behaving differently based on their state even to the point of checking for unhandled "messages" via `#method_missing`.

Th fork/join model can be emulated rather well as @dnautics described in a sibling comment. It's close to the model used in Elixir's Phoenix web server where each request just forks an Erlang process to handle it.

At the end of the day, issues with races in the actor paradigm are little different than communicating with remote programs as either fork'ed processes or network programs. Just fork'ing a program may avoid some concurrency issues by not doing any useful work by itself. If you do use fork/join to service http requests via sockets you're now relying on implicit state from the OS to do the heavy lifting.

If anything Linux's new `io_uring` works by treating a process like an actor where the process & kernel pass messages via a "messsage queue" which means your forks will just be acting as pseudo-actors anyway. Side note, on Linux doing this saves a lot of context switches by not preempting the OS process' call stack via a direct syscall every time it has something to communicate to the kernel. That gives a big speed bump to IO heavy programs. There can be significant performance benefits to making programming models work more like the hardware. I expect that actor models will grow to be a better performance match as core counts continue rising.

> Don't we want something higher level and safer than directly mapping to our hardware?

Certainly! ... if it makes acceptable tradeoff's in other areas. Though Actor's are really more of a base system to build higher level constructs that are easier/safer to use. Most programming shouldn't be done using your own concurrency primitives, but building on well thought out and tested libraries.

Mainly though, the idea of a Ruby with preemptive actors seems fun to me. Perhaps something you could map to lego robot's for kids.


> The only way to completely avoid race conditions I know of would be formal methods via TLA+ or similar. From my brief looks into TLA+ it'd appear to match actor implementation models pretty well (messages to actors yield new states).

The fork-join model does allow you to completely avoid race conditions, because tasks are always created in a deterministic order, each can only work with the immutable input they get, and tasks join also in a deterministic order.

(I think when I say fork-join you're thinking processes fork and join - I mean the abstract concept of fork-join. Map-reduce is an example of a fork-join model and is deterministic and race-free by construction with no proof system needed.)


Ah yes, you’re right. Started thinking that we’re talking about different aspect. I’ve been thinking about models as they’re used in end-user systems. Particularly how it’d fit in with Ruby and Rails, or similar. Actors without a good framework like OTP are indeed a pain. The fork-join model is great for parallelism and computation. Problem is that it’s harder to use for general purpose concurrency where your inputs aren’t immutable or fit into the model. Not that I’ve seen fork-join model really used other than map reduce or some MPI constructs.


There are different fully concurrent ruby implementations: truffleruby, rubinius, jruby.. they all suck at what ruby is most used for, running rails. Last time i tried cruby was 10x faster still.

So in practice we glady throw more memory and processes at the problem.


I wonder what percentage of Ruby is rails... I have been using Ruby pretty much every day for work since 2005, and haven't done any rails since 2009. Wonder if I am that much of an anomaly.


What are you using Ruby for?

I have used ruby since 2013 and started on rails but quit using rails in 2015. I still use ruby all the time though, mostly for scripts/automation that violate my rule on bash v. other lang for scripts (basically do I need arrays, maps, or to parse json beyond simple extractions that jq is great at). I do have a couple sinatra services now though that I maintain. Sinatra is wonderful with simple needs like mine.

I don't know how much of an anomaly I am though, interested in hearing from others as well.

Edit: not looking to debate bash vs other langs here. Of course you're free to do such below, but I won't be engaging (got to focus on work and have had the debate many times and I don't think any of us will ever convince the other. It's become religion at this point).

Also I use Elixir/Phoenix these days for use cases that used to be rails


Anything you miss from Rails now that you're primarily using Phoenix for those use cases? Elixir/Phoenix does look pretty intriguing to me, though the support universe is smaller.

What held me back so far is that a lot of simple B2B, low volume stuff doesn't seem to benefit from the high parallelism that BEAM brings.

But I'm wondering if it brings enough other advantages that I shouldn't view it that simplistically.


Not the previous commenter. But enthusiastic :)

I would recommend trying it and seeing how you like it. I've basically dropped Python which was my daily language in favor of Elixir. I find I get a higher top bound to what I can do, high-level expressive but less magical code and a bunch of capabilities that aren't typically feasible with other runtimes (state handling, resiliency and stuff). The parallelism I get for free with Phoenix whether I try or not.

I'd watch Sasa Jurics talk on the heart of elixir and erlang to get more of the technical advantages laid out quite well.


> But I'm wondering if it brings enough other advantages that I shouldn't view it that simplistically.

So to start with, Phoenix LiveView really is a game changer, you should have a look at Elixir just for that. This is the talk which really bluffed me when I first saw it: https://www.youtube.com/watch?v=MZvmYaFkNJI.

For the other upsides, I like a little bit better the way everything is architectured in Phoenix, there's much less magic and it's easier to follow the data flow and what is going on.


A lot of services written with Sinatra, some developer tooling, scripts to maintain our systems.


What are you doing that cruby is faster?

I've run both Rubinius and jruby (with rails), both gave me significant performance gains.


FYI, this domain is flagged as "dangerous" by mcafee's malicious site detection right now. Centurylink's nameservers are blocking it. You might want to talk with them about that.


Interesting! I'm happy to see Ruby isn't going the Python route ... It seems to look a lot how https://www.gevent.org/ achieves async on Python, which I can only applaud ...


Hey WJW, nice blog you have there! Have been following along, good stuff.


Hey dochtman, thanks! Long time no see, hope everything is good in Rust-land. Gelukkig nieuwjaar!


Are there any good libraries for fiber pools, so that not to many fibers run at once? I am interested in something like the concurrent-ruby threadpools.



Only one fiber per thread runs at a time, so that would naturally limit the concurrency of fibers to the number of threads?


As I understand it will yield back once it hits IO. But if you have something like a web server with a timeout of 60s you won't be able to open 10'000 connections at once to it. Some or all will be probably run into the timeout before the ruby could process the whole request. Therefore I would try to use some form of rate limiting via a FiberPool with a backlog.


Use a semaphore instead. That's what it is built for.

That way you don't need to make a complex fiber scheme just for resource management. Spin up as many as you need an the runtime will do the task pool for you.

https://ruby-concurrency.github.io/concurrent-ruby/1.1.4/Con...


Could someone explain if FiberScheduler can use all the available CPU cores?


Yes, you run one scheduler per core.


will this fiber scheduler + ractor will probably make ruby faster?


Both of those changes increase parallelism and concurrency, they do not decrease latency by themselves.


Not directly, but they will facilitate using more performant paradigms.


Could somebody tell me what's wrong with threads, e.g.

    require "open-uri"
    require "json"

    results = []

    (0..100).each_slice(10) do |slice|
      slice.map do |i|
        Thread.new do
          results << JSON.parse(URI.open("https://httpbin.org/get?i=#{i}").read)["args"]
        end
      end.each(&:join)
    end

    p results


There’s nothing wrong with threads.

But the promise of Fibers + a fiber scheduler is that you won’t have to think about the performance impact of blocking IO as much as you might normally.

For example, in a rails app (or any ruby web server) I am very scared to make slow external http requests because that might tie up a thread for a while, and request threads are a pretty limited resource in typical configurations. (16 threads/process) * (1 process/cpu core) might be a reasonable default, giving you at most 128 concurrent requests in flight on an 8 core server.

Suppose you make a really slow 3rd party api call that takes 10 seconds to service a request. Your server can handle no more than 128reqs/10s = 12.8 of these requests per second, even though it will spend most of its time doing nothing but waiting.

If we bring fibers in to the mix and do one fiber per request, then they can yield to another fiber while waiting on the api response - you can then easily have thousands of requests in flight instead of 128.

Granted, if you are entirely IO bound then you can probably get away with a much larger thread pool, or do thread-per-request. But if you have some other endpoints that are more compute intensive then bumping up your thread count might be dangerous... then what do you do? Fibers might be the answer.


Correct me if I'm wrong, but this code would block if (for example) one single request in a batch of 10 takes significantly longer to complete. The 9 other threads are then doing nothing while they wait.

Of course, you could implement a more robust algorithm for scheduling threads, but it sounds like an implementation of FiberScheduler would make the best use of a single thread's resources (and you could scale that to multiple threads as needed using something like a shared queue). Since fibers are lighter-weight, it's presumably more efficient to run parallel IO operations in a single thread, and only use more threads if your work requires it.


The article example waits for all the fibers to complete as well.

I'm not sure about Ruby's specific implementation, but the threads need not wait around. When the thread entry returns pthread_exit is called. I believe the OS thread would go away regardless of how you are waiting on them.


Well the idea is that thread creation itself is not really cheap. For most software I would say it is not really a problem, but if the "work" to be done is short enough, that creation of thread becomes the most costly factor. True, that can be solved by thread pool usually.

So I guess as with most things in software engineering, if there is something wrong with threads or not depends on your use case.


With 10 threads at a time - not much, but threads are pretty heavyweight when all you need is waiting on IO. Systems where waiting IO doesn't map 1:1 to threads scale to more concurrent users better (Erlang, Node, Go all do this). With evented IO you can service hundreds of thousands of connections (if you have a good way to round-robin between them or some other way to schedule them well). Having the same number of threads will be difficult.


You can’t have a thread per http request.


Of course you can. In the days of CGI scripts, we forked a full process per request, never mind a thread.

I'd wager that the great majority of Web apps (i.e., the not-terribly-busy ones) would scale reasonably well on thread-per-request.




Join us for AI Startup School this June 16-17 in San Francisco!

Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

Search: