Ray began as a research project at UC Berkeley and grew into what its README now calls “a unified framework for scaling AI and Python applications.” The pitch is simple: the same Python you run on your laptop should run on a cluster with minimal changes. What makes that work is a surprisingly compact core — a driver library that decorates your functions and classes, a set of per-node processes that schedule and execute work, and a distributed object store that moves the results around. In this tour we read the actual Python source of ray-project/ray (the python/ray package on the master branch) and trace a call from ray.init() down to the C++ core worker and back.

The two diagrams below were generated from the repository itself: every node links to a real file on GitHub, and every symbol we mention is one we located with a line-number search in the working tree. The first diagram is a map of the whole system — the public API, the cluster bootstrap machinery, the task and actor paths, and the object layer. The second goes deeper into internals: function export and registration, actor creation, and how ray.put and ray.get interact with the core worker. If you have read our earlier tours of single-machine tools like Dask, this is the natural next step: Ray’s design decisions only become visible at cluster scale.

Ray architecture overview: public API, cluster bootstrap, tasks and actors, objects and libraries

Ray overview — twelve files that define the system's shape. Click any node to open the source on GitHub.

Reading the overview from left to right:

  • ray.init at L1448 in _private/worker.py is the front door. It either attaches to an existing cluster or builds a local one, and the module-level global_worker = Worker() at L1408 means every process has exactly one worker instance.
  • Node at L73 in _private/node.py describes one machine’s configuration, and ray.init constructs it at L1930 for the head-node case.
  • The process launcher in _private/services.py — start_ray_process at L883, with dedicated helpers like start_gcs_server at L1523 and start_raylet at L1610 — actually forks the GCS, raylet, and dashboard processes you see in ps output.
  • ray.remote at L3804 is the decorator entry point. Its helper _make_remote at L3611 checks whether you decorated a function or a class and routes accordingly.
  • RemoteFunction at L41 in remote_function.py is the stateless-task wrapper. Setting self.remote = _remote_proxy at L183 is what gives your function its .remote() method, and calling the wrapper directly raises TypeError at L185.
  • ActorClass at L1553 is the stateful counterpart, and the ActorHandle at L2274 it returns is the proxy your code holds for the actor’s lifetime.
  • ObjectRef at L50 is a Cython class (cdef class ObjectRef(BaseID)) that wraps an object ID plus owner address — it is the future-like handle every task and method call returns.
  • SerializationContext at L145 registers the custom deserializers that make refs and actor handles cross process boundaries intact.
  • Above the core sit the libraries: Tuner at L43 for hyperparameter search and Dataset at L272 for data-parallel loading and transformation. Both are ordinary consumers of the task and actor APIs you see beneath them.

The Task Path: From Decorator to Worker

Follow the task path in the detail diagram. When you write @ray.remote on a function, _make_remote wraps it in a RemoteFunction whose options were validated at decoration time. Nothing runs yet. The first call of f.remote(...) lands in RemoteFunction._remote at L358, and this is where three important things happen.

First, the function is exported. Because the receiving worker is a different process that has never seen your code, Ray pickles the function and ships it through the FunctionActorManager.export at L219 method of function_manager.py. There is an export-once guard: _last_export_cluster_and_job is compared against the worker’s current cluster and job, so the pickle round-trip happens once per function per job rather than on every call. On the execution side, a worker that picks up the task calls fetch_and_register_remote_function at L289 to pull the pickled blob from the cluster’s key-value store and re-import it locally.

Second, arguments are flattened and serialized against the extracted function signature, using the machinery in SerializationContext. If an argument is itself an ObjectRef, Ray passes it by reference and the receiving worker resolves it; only plain values are inlined into the task spec.

Third, the prepared task is handed to the C++ side. The Python driver’s worker.core_worker is a ray._raylet.CoreWorker binding created at L2735 inside connect(). That single binding is the wall between the readable Python layer and the scheduling, ownership, and object-store logic implemented in C++ under src/ray (outside the Python package we toured here). The call returns immediately with an ObjectRef — Ray’s task submission is asynchronous by design, and your script only blocks when it asks for a result.

The Actor Path: Stateful Workers

Actors answer a different question: what if a worker should keep state between calls? ActorClass at L1553 is what @ray.remote produces for a class. Its metaclass-style __init__ at L1562 exists mostly to forbid subclassing an actor class directly, and __call__ raises if you try to instantiate it like a normal Python object — the only legal entry point is .remote().

That call goes to ActorClass._remote at L1883. It validates naming and namespace options, exports the class definition through export_actor_class at L488 in the same manager the task path uses, computes the actor’s resource footprint, and finally invokes core_worker.create_actor at L2193. The result is wrapped into an ActorHandle at L2274 — note the original_handle=True argument at creation, which matters for ownership: when the original handle goes out of scope, the actor itself can be destroyed.

Method calls are the elegant part. ActorHandle defines a __getattr__ at L2610 that returns an ActorMethod (the class lives at L848) for any attribute you touch. Calling it lands in _actor_method_call at L2486, which serializes the arguments and reaches core_worker.submit_actor_task at L2578. The return value is again one or more ObjectRefs. Concurrency semantics depend on configuration: an async actor runs tasks on an event loop, while threaded actors default to out-of-order execution — the code around L2167 shows allow_out_of_order_execution being defaulted from is_asyncio or max_concurrency > 1.

For coordination across a fixed set of actors, the standard library utility is ActorPool at L13 in util/actor_pool.py, which distributes work items over your handles and yields results as they complete. It is a small file and one of the best places to see actor handles being used the way Ray’s own authors intended.

Objects: ObjectRef, ray.put and ray.get

The object layer is the third pillar. ray.put at L3053 stores a value once and hands you a ref you can pass to any task; Worker.put_object at L810 does the work, retrying automatically when the store is full. ray.get at L2902 blocks until the value is available locally — if it lives on another node, the system ships it over first — and it will re-raise a task’s exception in your process, which is why errors from remote code look local. ray.wait at L3109 splits a list of refs into ready and not-ready without blocking on all of them, and is the standard tool for bounding how many tasks are in flight.

The ObjectRef itself, defined in Cython at includes/object_ref.pxi L50, is more than a token: it registers itself with the core worker on construction and deregisters on deallocation (L68 and the __dealloc__ that follows), which is how Ray’s distributed reference counting knows when a value can be evicted. It also supports await via as_future, so the same ref works in asyncio code. Inside a worker, get_runtime_context at L641 in runtime_context.py exposes the current job, task, and actor IDs so library code can introspect where it is running. One more detail worth knowing: in __init__.py, AUTO_INIT_APIS at L207 lists the public functions (get, put, wait, cancel, and friends) that implicitly call ray.init() for you — remote deliberately is not in that set, because decorating should never start a cluster.

Ray internals detail: task path, actor path, objects and serialization, cluster metadata

Ray internals — the task path from decorator to core worker, the actor path with handles and method calls, and the object layer they both share.

From Install to Your First Model

Everything above is verifiable from the quick-start path in the README and the remote() docstring, which contains the exact example we adapt here.

Step 1 — install:

pip install ray

Step 2 — turn a plain function into a remote task with the decorator:

import ray
ray.init()

@ray.remote
def train_chunk(seed: int):
    # pretend this trains a small model and returns its score
    total = sum(i * i for i in range(seed * 1_000))
    return seed, total

Step 3 — submit all chunks at once and collect with ray.get. The calls return immediately; the loop only blocks at the single ray.get:

refs = [train_chunk.remote(s) for s in range(8)]
results = ray.get(refs)          # blocks until all eight finish
print(results[:2])

Step 4 — make it stateful. A model server is naturally an actor, and the docstring for ray.remote shows the class form verbatim:

@ray.remote
class Scorer:
    def __init__(self):
        self.calls = 0
    def score(self, x):
        self.calls += 1
        return x * 2

scorer = Scorer.remote()
ref = scorer.score.remote(21)
print(ray.get(ref))              # 42

Step 5 — scale out. The same script runs unchanged on a real cluster: start nodes with ray start --head / ray start --address=<head> and point ray.init(address=...) at the cluster. That is the whole migration story, and it is the reason the core API surface is so small — tasks, actors, and objects cover what the AI libraries need.

Where the Libraries Plug In

The overview diagram’s right edge shows two representative consumers. Ray Tune’s Tuner expresses hyperparameter search as many parallel trials, each an actor or task, and borrows the scheduling and fault-tolerance of the core rather than reimplementing it. Ray Data’s Dataset decomposes a large dataset into blocks and processes them with streaming tasks, which is the same task/actor substrate again. This layering is why Ray’s README lists its AI libraries — Data, Train, Tune, RLlib, Serve — as separate products while the runtime underneath stays shared. If you are comparing distributed-dataframe options, our Modin tour (here) covers a library that can use Ray as an execution engine, and our Dask tour (here) covers its closest architectural cousin in task-graph land.

Honest Limits

Three limitations deserve plain language. First, the C++ heart is out of scope here: scheduling policy, the ownership model described in the “Ownership” paper referenced in the README, and the Plasma-derived object store live in src/ray, so this tour stops at the CoreWorker binding — the diagrams describe the Python side truthfully, but the most interesting scheduling code is on the far side of that wall. Second, the Python package is enormous (over ten thousand files in the sparse checkout we searched), and much of its bulk is tests, autoscaler cloud providers, and protocol-generated code; the handful of files we anchor on are the ones the public API actually routes through. Third, we deliberately quote no performance numbers in this post: any claim about throughput or latency would need a benchmark we did not run, and the README itself makes no such claims. What we can say is structural — Ray trades a small, stable API for a large amount of process machinery, and reading the source makes that trade visible: ray.init starts real operating-system processes, decoration creates real wrappers with real validation, and every .remote() call crosses a language boundary. Understanding those costs is exactly what a source tour is for. For inference-scale serving built on similar distributed runtimes, see our vLLM (tour) and SGLang (tour).

Watch PyShine on YouTube

Contents