Skip to content

Support 1:1 Scheduling #10493

Closed
Closed
@alexcrichton

Description

@alexcrichton

Rust currently supports M:N scheduling through the runtime's Scheduler type. The standard library should also support 1:1 scheduling. This is obviously a lofty goal, and not quite as easy as just changing a few lines of code here and there.

In my opinion, this ticket can be closed when this code runs:

use std::comm::stream;

#[start]
fn main(_: int, _: **u8) -> int {
  let (port1, chan1) = stream();
  let (port2, chan2) = stream();
  do spawn {
    println!("{}", port1.recv());
    chan2.send(0);
  }
  chan1.send(2);
  port2.recv()
}

The main point of this code is that we have lots of basic runtime services working without "booting the runtime."

I believe that the best way to achieve this goal is to sort out what exactly needs to be changed and start moving everything in the right direction. At its core, a 1:1 scheduling mode means that there is no Scheduler interface. Once we remove this, there are a few implications for runtime:

  • All libuv-based I/O does not work
  • No comm can be used without the scheduler
  • Task spawning is very closely tied to the scheduler

For each of these items, however, there are clear correspondences in a 1:1 world:

  • Thread-blocking I/O (just use the normal posix apis)
  • communication pthreads cvar/mutex for blocking
  • Task spawning corresponds to thread spawning

In addition to having differences, I believe that the to scheduling modes share a core idea which is that of a local Task. This task encapsulates information such as garbage collection, its name, stdio/logger handles, etc.

Here are my personal thoughts about going about doing this.

I/O

This story is actually pretty much set as-is. You can use println! without booting the runtime today, and everything works just fine. The reason for this is that I/O is multiplexed over libuv/native implementation via the EventLoop and IoFactory traits. The major workhorse is the IoFactory trait, but the idea is that by default all rust code uses the "native I/O" factory which issues thread-blocking posix-like calls by default.

I believe that this component of 1:1 scheduling can be considered done. The reason for this is that the std::io primitives all "just work" in both a libuv-backed and native-backed environment (assuming they both have filled-out implementations). I'm sure that there are remaining rough edges around the IoFactory and such, but the ideas are all there and running today.

Communication

Currently all of the std::comm primitives, implemented in std::rt::comm, are very tightly integrated to the Scheduler. This does not allow them to be used at all when the runtime is not present.

Most of their implementation would be the same between a 1:1 and M:N model, except for the two concepts of "I would like to block" and "wake up this task". One could imagine code along the lines of:

if have_local_scheduler() {
  scheduler.block_on(&mutex);
} else {
  cond_wait(&my_cond, &mutex);
}

This would work, but I believe that there is a better solution. In the solution for I/O, there is one "central dispatch" point which has the "if local_scheduler" check, and I like how that has turned out, so it seems like something could also be used for communication primitives.

I believe that the only way to do this today would be to use trait objects. The Chan and Port types would have an underlying ~RtioChan and ~RtioPort object (stealing names from I/O). There are two downsides to this approach:

  1. This forces an allocation. I don't think that this is really that much of a problem b/c you're already allocating other things for the channel/port, I don't think that they're ever going to be 0-allocations except for this trait object boundary.
  2. This forces virtual dispatch. I'm comfortable saying that this is perfectly reasonable for I/O because I/O is the bottleneck, not a jmp to a register. For communication primitives, however, this may not be the case. My gut tells me that one virtual jmp is nothing compared to the number of atomic instructions which need to happen, but I do not have numbers to back up that claim.

Regardless, let's say that we're not going to litter all methods with if in_scheduler() { ... }. There is then the question of where does this decision go? For I/O, this is currently solved at Scheduler-creation time. The I/O implementation is selected by a factory located in the crate map. This may also be a good place for a CommunicationFactory and it's related set of functions? I would be wary of putting too many "factories" all over the place, however. To me, though, this seems to be a sufficiently configurable location for where to place the channel/port factory type.

Tasks

Task spawning is a similar problem to the communication types. There is currently a nice interface defined by the std::task module for spawning new tasks. I'm not intimately familiar with the interface, but I believe that it's mostly building up configuration, then hitting the go button. This means that the abstraction's api is basically firing off some configuration along with a proc and letting it run.

This sounds to me a lot like another factory, and a lot like another slot in the crate map. I don't want to go too overboard with these factories, though. I believe that the scheduler is also very tightly coupled to the "task factory" because 1:1 would just call Thread::start while M:N would have to start dealing with the local scheduler. I'll talk a little more about booting the runtime later, but I want to control the explosion of "trait factories" that we have if we decide to pursue this change.

A local task

I believe that it's necessary to always in all rust code have the concept of a local task. This task contains common functionality to all scheduling systems which allows many other components of libstd to work. This includes things like std::local_data, efficient println, pretty failure, etc.

I think that this task will also always be stored in OS-level TLS, so I don't think that there's much to worry about here. This would just require a refactoring of the current Task type stored in TLS (perhaps).

Intermingling M:N and 1:1

I can imagine intermingling these two scheduling modes could become very interesting. For example, let's say that I create a (port, chan) pair. If the runtime were super smart, it would not block the thread when the port called recv() in an M:N situation, but it would block the thread in a 1:1 situation. Basically communication across boundaries would "do the right thing".

I don't like the sound of that, and I think it may be just too much of a burden to maintain. I think that the idea of a thread pool is still a useful thing to have, but perhaps you should be forced to resort to custom communication between yourself and the thread pool. I could be wrong about the utility of this mode, however, and we may want to design around it as well.

Booting the runtime

In 1:1 situations, there's no need to boot the runtime. Everything is always available to you at all times. In an M:N situation, however, we must boot the runtime because the threadpool has to be started at some point. I'm willing to chalk this up to "well, that's M:N for you" in the sense that we don't need to specially accommodate use cases beyond a nice mton::boot function (or something like that)

Conclusion

There's still not a whole lot that's concrete in this proposal, and that's partly intentional. I want to discuss this strategy and how things are working out, and then the concrete proposal can come next. I hope to have enough actionable content here to move forward to a proposal after some discussion, however.

Metadata

Metadata

Assignees

No one assigned

    Labels

    A-runtimeArea: std's runtime and "pre-main" init for handling backtraces, unwinds, stack overflowsmetabugIssues about issues themselves ("bugs about bugs")

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions