☰
Ray分布式计算框架入门:核心原理解析与Python并行实践
2026/10/18 6:02:16 网站建设 项目流程

1. 认识Ray之前,先聊聊单机Python的烦恼

第一次接触Ray的时候,我正在处理一个让人头大的问题:单机跑一个超参数搜索脚本,每天夜里挂着训练,第二天早上看结果,发现才跑了十几个组合,而目标是要穷举几百组参数。那时候的常规做法无非是手动写多进程、用并行库,或者干脆把任务切碎丢给多台机器跑,但无论哪种方式,都要付出大量的工程成本和心智负担。

后来在一次社区技术分享里听到有人提到Ray,说它是一个Python生态里的分布式计算框架,能把原本跑在单机上的Python代码,几乎不改变代码结构地扩展到集群上。当时将信将疑:Python这门GIL统治的语言,真的能这么轻松地在多核、多机上并行吗?带着这个疑问入坑之后,发现Ray确实有自己的独到之处。

Ray本质上是一个面向Python的通用分布式执行引擎,核心目标就是降低分布式编程的门槛。它解决的痛点很直接:单机Python程序只能用一个进程跑满一个核;多进程、多机并行往往要处理队列、状态同步、失败重试等一大堆琐碎细节;而Ray把这些问题封装成了最简单的几个原语,让普通Python开发者也能写出分布式应用。

这篇教程作为Ray系列的第一篇,不讲太深,先把Ray是什么、它解决了什么问题、核心抽象有哪些、怎么快速跑起来,一次讲清楚。无论是做机器学习训练、数据处理,还是写一个需要并发处理的后端服务,了解Ray都会让技术选型多一个靠谱选项。

提示:Ray的适用人群很宽,但最适合的仍然是那些写Python、并且明显感到单机资源不够用的开发者。如果你只是写写脚本、串行处理少量数据,Ray帮你省不了多少事,甚至还会引入额外复杂度。

2. Ray的三板斧:Task、Actor、Object

2.1 核心抽象之一:Task

Ray最基础的抽象是Task,也就是远程函数。用法和Python普通函数几乎一样,唯一的区别是加了一个装饰器@ray.remote,调用的时候变成.remote(),函数就会提交到Ray集群里异步执行,立即返回一个ObjectRef(对象引用)。

import ray import time ray.init() @ray.remote def compute(x): time.sleep(1) return x * x # 异步提交4个任务 results = [compute.remote(i) for i in range(4)] # 阻塞等待结果 print(ray.get(results))

这段代码一看就懂,但背后的含义很丰富。compute.remote(i)执行的时候,调用方并不会等待函数体跑完,而是立刻得到一个ObjectRef。真实计算被调度到了本地或者远端Worker进程里,并行度取决于集群里的CPU资源和num_cpus参数设置。ray.get()负责阻塞等待并取回真实结果。

我第一次跑这个例子的时候,直观感受是:这就像给函数加了个“异步化”开关,普通函数和分布式函数之间的鸿沟一下子被抹平了。对于写惯了concurrent.futures的人来说,Ray的Task还多了几层优势,比如自动跨机器调度、失败重试、对象存储共享中间结果,这些都内建在框架里,不需要额外实现。

2.2 核心抽象之二:Actor

Task是无状态的一次性计算,但很多场景需要“有状态的分布式对象”,比如多个客户端要同时读写一个共享计数器,或者一个模型推理服务需要常驻内存加载模型参数。Ray用Actor来解决这类需求。

@ray.remote class Counter: def __init__(self): self.value = 0 def increment(self): self.value += 1 return self.value counter = Counter.remote() print(ray.get(counter.increment.remote())) print(ray.get(counter.increment.remote()))

Actor相当于一个常驻在某个节点上的对象,它的方法通过.remote()被远程调用,多个调用之间共享对象内部状态。由于状态存在于进程内部,Actor天然避开了多进程环境里共享内存的痛苦:每个Actor有单一归属地,所有方法调用都被路由到那个进程上执行,不存在并发写入同一块内存的竞争问题。

做机器学习推理服务时,Actor也特别好用。常见的做法是每个Actor加载一个模型副本,多个Actor并行推理,吞吐量可以线性扩展。加一个@ray.remote装饰器,用num_replicas参数还能直接指定创建多少个Actor副本,负载均衡由Ray自动处理。

2.3 核心抽象之三:ObjectRef

ObjectRef与Task、Actor配合得很紧密。.remote()调用返回值不是真实数据,而是一个ObjectRef,可以类比为“一个指向未来结果的分布式指针”。数据本身存放在Ray的分布式对象存储里,ray.get()负责从存储中取回数据,ray.wait()可以等待部分任务完成。

ref = compute.remote(10) print(ref) # ObjectRef,立即返回 result = ray.get(ref) print(result) # 100,阻塞取回结果

理解ObjectRef的关键在于:它让Task之间可以进行数据依赖,而不需要把中间结果反复存盘。一个Task的输出可以作为另一个Task的输入,Ray会自动在对象存储中传递数据。这其实构成了一个简单的任务图,Ray在底层负责跟踪这些依赖关系,并尽量在数据本地执行后续任务,减少网络传输。

这个设计逻辑有点像盖楼的时候先搭钢筋骨架,数据就是一块块预制板,ObjectRef就是吊装挂钩。钢筋骨架搭好之后,预制板往哪里吊都由调度器算好了,不需要每个工人都知道整个施工方案。

3. Ray解决的问题:从GIL到集群扩展

3.1 GIL是并行路上的坑,Ray帮你绕过去了

Python开发者普遍遇到的第一个性能瓶颈就是GIL(全局解释器锁)。GIL确保同一时刻只有一个线程执行Python字节码,所以多线程代码在计算密集场景下往往跑不出多核效果,甚至因为线程切换开销更慢。

Ray的方法是不用线程,直接用进程。每个Task默认被调度到一个Worker进程中执行,每个进程都有独立的Python解释器,互不争抢GIL。所以在Ray里,并行任务数量提升,消耗的是CPU核心数,而不是线程切换时间。和multiprocessing相比,Ray的优势在于这些跨进程的功能不只在单机上有效,换到集群环境同样成立,代码不用改。

3.2 从单机到集群的平滑升级

在没有Ray的时候,单机脚本迁移到分布式集群通常要经历一次大重构:要选消息中间件、设计任务队列、处理节点间通信、写失败重试逻辑。这些内容每一个都能耗掉一两天工时,更别提把数据切成适合并行处理的小块还涉及分片策略。

Ray想做的事情,是让迁移成本低到“改一行代码”。单机脚本里ray.init()不传参数,就是本地模式;同一套代码,在集群入口把它替换成连接集群地址,比如ray.init(address="ray://head-node:10001"),你的Task和Actor就会自动被调度到集群节点上执行。

这种平滑升级背后依赖的是Ray的GCS(Global Control Service),它统一管理节点发现、任务调度、对象元数据。写应用的人只需要面向最上层的API,资源如何分配、数据往哪里传,由Ray内部处理。

3.3 生态组件:RLlib、Tune、Serve、Datasets

Ray的基础核心是分布式执行引擎,但它更进一步,围绕引擎长出了一整套机器学习和在线服务生态。这里提几个大家最常接触的:

  • RLlib:强化学习算法库,内置大量算法,并且天然支持分布式训练环境。做强化学习实验的人通常写死一套训练循环,想在多机上跑就要自己折腾,RLlib把这些都封装掉了。
  • Tune:分布式超参数调优工具。开头提到的搜索几百组超参数的场景,Tune可以做异步搜索、自动调度、提前停止,配合Ray的调度能力,可以乱序跑满所有空闲资源。
  • Serve:模型服务框架。它把训练好的模型封装成HTTP接口,内置了请求批处理、自动扩容和负载均衡,做在线推理部署的时候很省心。
  • Datasets:分布式数据集处理方案。支持在多个节点上并行加载、转换和Shuffle数据,并且可以对接主流存储系统和格式。

这套生态意味着Ray不只是一个“分布式进程池”,而是一整套覆盖训练、调优、部署、推理链路的工具栈。对于我个人经验来说,最实用的组合是“Tune做调参,Serve做上线,底层核心正好统一管理资源”。

4. 快速上手:安装Ray并跑通第一个Demo

4.1 安装Ray

Ray是用Python写的,最省事的安装方式就是pip。需要注意版本,现在主力版本是2.x,1.x已经比较老了,官方文档也以2.x为主。安装核心库:

pip install ray

如果做机器学习相关开发,建议连默认的依赖一起装:

pip install ray[default]

default这个依赖标签会补齐一些常用库,包括Dashboard依赖、存储后端插件等。我踩过一次坑:只装最简版,结果启动Dashboard的时候缺依赖,报错不明显,排查花了不少时间。所以除非你明确不做机器学习,否则直接装ray[default]最省心。

装好后可以验证版本:

import ray print(ray.__version__)

4.2 单机模式第一个示例:并行计算任务

安装完之后,最直接的上手方式就是先在单机模式下跑通示例。单机模式不需要配集群,启动时ray.init()会自动创建本地调度器和Worker池。

下面这段代码会并行计算一系列平方值,并在所有任务完成后汇总结果:

import ray import time ray.init() @ray.remote def square(x): time.sleep(1) return x * x start = time.time() futures = [square.remote(i) for i in range(10)] results = ray.get(futures) elapsed = time.time() - start print(results) print(f"总耗时: {elapsed:.2f}s")

跑完之后对比一下效果:串行循环计算10次,每次sleep 1秒,总共耗时10秒左右;用上面这段并行版本,如果机器有足够多核,总耗时大约在2-3秒以内(因为任务量是10,但并行度由机器核心数和调度策略决定,5核机器会分两轮跑完)。

注意:square.remote(i)不会阻塞,10个任务一次性提交,立刻返回10个ObjectRef。ray.get()一次性等待全部结果返回。如果任务太多,想边跑边收,可以用ray.wait()分批处理。

4.3 集群模式:把同一个脚本搬到多机

单机模式验证通过后,集群模式的切换成本极低。假设你有一台主节点,其他节点已经装好Ray并有相同代码目录,只需要在主节点上执行:

ray start --head --port=6379

在其他节点上执行:

ray start --address=主节点IP:6379

然后在应用代码里连接集群:

ray.init(address="ray://主节点IP:6379")

从单机到多机的切换就是这一个参数的变化。对于刚入门的人来说,我建议先在自己电脑上用容器或者虚拟机折腾两个节点的集群,不用买真实服务器,跑通了再上真环境。这一步能极大增强信心,因为你会看到同一个Task被调度到了不同节点上运行,Dashboard里也能看到多个节点的资源统计。

5. Ray与常见方案的对比:站在选型角度聊聊

5.1 对比Celery:任务分发与计算并行

很多人看到Ray的Task和Actor,会联想到Celery这个Python生态里非常成熟的任务队列。Celery的设计目标是异步任务分发,常与Web应用结合,任务丢进Broker(比如Redis、RabbitMQ),由Worker异步消费。缓存、邮件发送、定时任务这类场景,Celery是很好的选择。

但Celery的设计重心是“异步消息流转”,而不是“并行计算调度”。Celery里两个任务之间要传递数据,通常要借助结果后端;而Ray通过ObjectRef构建任务依赖图,Task之间直接传递对象引用,更贴近计算图的语义。另外,Ray原生的Actor和精细化资源调度(可以指定CPU、GPU、内存),也让它在计算密集场景下更有优势。

选型建议很简单:如果你的任务是短小的消息型任务,Celery够用;如果你的任务是计算密集、有依赖关系或者涉及分布式状态,Ray更顺手。

5.2 对比Spark/Dask:数据规模与编程模型

Spark是数据量极大(比如几十TB以上)时的标杆方案,它有成熟的数据帧、SQL和流处理能力,适合典型的批处理与数仓ETL。但Spark的核心不是Python,而是JVM,用PySpark跑数据时还要理解Spark的惰性执行、分区、宽窄依赖这些概念,心智成本不低。

Dask和Ray的定位有些重叠。Dask直接复用Pandas和NumPy接口,能让你在已有的DataFrame代码上加一个n_clusters参数就能并行。传统数据分析师如果不想改变写代码的习惯,用Dask会非常自然。

Ray的优势在于通用性和生态深度:它不只是处理表格数据,还能跑强化学习、超参数搜索、在线推理服务。有人把Ray比作“Python里的操作系统”,因为它像操作系统管理硬件资源一样管理分布式资源,并在此基础上提供各种服务。

5.3 什么时候不该用Ray?

聊完优势,也得说说边界。Ray不是银弹,以下场景我建议谨慎引入:

  • 单机数据量小、任务几十秒内能跑完:用Ray额外的进程调度、对象存储序列化传输,纯属杀鸡用牛刀。
  • 一次性脚本、没有反复运行需求:写个multiprocessing就够了,别上框架。
  • 特别低的延迟在线服务:Ray的调度增加了额外开销,如果单次推理控制在几毫秒内,还是直接起个同步Web服务更合适。

结合我做过的项目来看,Ray比较适合“反复运行、规模可增长、逻辑本身适合拆成并行单元”的应用。从工程长期投入来看,学Ray跟学Dask选择成本差不多,但Ray覆盖面更广一点。

6. 问题排查与实操心得

6.1 版本不匹配引发的连环报错

我遇到最多的问题是Ray版本不一致。尤其是多节点集群环境,有些节点用的是2.1版本,有些节点还是1.13,启动时偶尔能通过握手,但真正执行Task的时候经常报奇怪的序列化错误或者连接超时。

经历了两次定位耗时很长的排查之后,我的建议是:集群环境必须统一版本,最好精确到pip freeze锁定版本号。启动前挨个节点检查一下ray.__version__,一劳永逸。

6.2 序列化边界问题

Ray在传递数据时使用序列化框架,默认支持大部分Python原生对象,但遇到自定义类的实例、大型DataFrame、Torch模型等场景时,要保证相关库在Worker进程里也可导入,否则反序列化时报错。

实操经验是:尽量把自定义的类和工具函数放在独立的模块文件里,两边代码路径保持一样的结构,同时导入的包尽量在__init__.py里统一加载。这样可以减少因导入路径不一致导致的莫名其妙的序列化错误。

6.3 调度参数的坑

在分配计算资源时,一个容易忽视的细节是任务默认占用一个CPU核,在2核小机器上并行跑特别多的任务反而会频繁切换。建议在@ray.remote里显式指定资源:

@ray.remote(num_cpus=1) def task(): ...

如果任务不需要CPU计算(比如等待网络IO),还可以取消CPU占用:

@ray.remote(num_cpus=0) def io_task(): ...

很多教程不强调这点,但实际经验是,正确配置num_cpus之后,同一批任务吞吐翻了近一倍。原因在于Ray的调度器会严格按照资源约束来放置任务,不显式约束时默认1个CPU,但某些环境里Worker初始化会额外占资源,调度器误判导致任务堆积在同一个节点上。

6.4 Dashboard是排查利器

Ray提供了浏览器可视化界面Dashboard,启动时默认在localhost:8265访问。页面上能看到每个节点的CPU、内存、GPU占用,以及活跃任务、Actor分布、任务时间线。

对于定位“任务为什么只跑在其中一个节点上”这类问题,Dashboard是最直观的工具。点开节点状态,能看出哪些节点被分配到了任务,哪些节点处于空闲。如果某些节点一直零负载,先检查防火墙是否放行6379和8265端口,再检查集群构成日志。

6.5 长期运行的任务会僵死?

在某个长时间训练的项目中,我发现训练跑了几个小时之后,偶尔会出现“心跳超时,Worker被标记死亡”的报错。原因有两种,一是网络波动导致心跳丢失,二是训练循环陷入阻塞导致心跳线程没机会上报。

对于长任务,建议直接把心跳超时限制调大:

ray.init( address="auto", _node_ip_address_gcs=True, runtime_env={"env_vars": {"RAY_health_check_timeout_ms": "300000"}} )

另外也可以把训练逻辑设计成周期性产生中间结果,这样即使Worker崩溃,恢复后用checkpoint继续训练,不至于白跑。Ray的ray.util.state模块还提供了API查询各Worker的状态,适合写自定义监控脚本。

7. 浅谈Ray在机器学习链路中扮演的角色

如果你做机器学习,值得把Ray放在整个训练与上线链路的视角里看一下。数据准备阶段,Datasets组件可以并行业务数据读取与预处理;训练调参阶段,Tune负责搜参并自动调度资源;模型部署阶段,Serve承担高并发推理服务。还有RLlib,内置了大量强化学习算法,配合Ray的分布式环境模拟,可以低成本验证训练效果。

对我来说,Ray比较有吸引力的点在于,它不需要把不同阶段的工具拼拼凑凑来回切换。训练时我用Ray跑并行,上线时我把同一个Task包装到底层运行,基础设施的复用率会比较高。再加上它原生的GPU资源调度,写作时只需要给Task指定num_gpus=1,Ray会自动把任务调度到有GPU的节点上,免去了自己维护资源匹配的麻烦。

当然,每个框架都有自己的适用边界,Ray的强势领域是“Python编程模型 + 分布式调度 + 机器学习相关组件”。如果业务贴合这个坐标区,Ray就是非常合适的基座。

8. 一些操作细节与潜藏的小陷阱

8.1 惰性初始化与连接模式

第一次调用ray.init()时,Ray会检查环境并启动本地调度器。如果已经在后台启动过Ray集群,则应该用ray.init(address="auto"),让应用连接现有的集群,而不是自己新起一套资源。否则可能出现“应用分配到本机资源,但后台集群资源空闲”的割裂情况。

8.2 容错与自动重试

Task和Actor默认具备一定的容错能力。没有捕获异常的任务,会在符合条件时自动重跑。手动提交任务时,也可以自己定义重试策略:

@ray.remote(max_retries=3) def unstable_task(): ...

值得注意,max_retries并不是所有情况下都会生效,如果任务异常导致Worker崩溃,重试轻轻松会重启任务;但如果异常来自业务逻辑本身,Room还是慎重决定是否要自动重试。比如写文件的任务,自动重试可能造成重复写入。

8.3 使用Runtime Environment配置依赖

不同节点上环境不一致会让代码很难复现。Ray的Runtime Environment功能允许在提交任务时指定Python依赖包、环境变量和文件,节点会自动下载并激活环境。

ray.init( address="auto", runtime_env={"pip": ["numpy==1.24.3"], "env_vars": {"CUDA_VISIBLE_DEVICES": "0"}} )

这功能我第一次用的时候惊到了:不用手动去每台机器pip install,Ray的agent会自己处理环境搭建。对于多节点集群的开发体验,这个改进加分很大。

9. 从入门到进阶的学习顺序建议

如果你打算系统学Ray,我建议按这个顺序:

  1. 先吃透Task和Actor,把官方文档的示例改写几遍,重点理解remote和get这两个操作。
  2. 学会看Dashboard,明白任务在哪个节点上跑,资源饱和了多少,这是排查问题的基本能力。
  3. 尝试用一个计算密集的小项目从单机切到集群。有条件的用两台云主机,没条件的就用容器模拟。
  4. 再接触Tune和Serve,它们会给你带来更直观的收益。做一个搜索超参数的实验,再把它变成在线API。
  5. 最后看RLlib和Datasets,这两个组件跟具体业务关联度高,用到再看就行。

在接近Ray整个核心抽象和基础用法前,不建议一上来就啃分布式系统的内部原理。先建立“怎么写分布式代码”的直觉,等你开始深层优化性能时,再去理解调度器、对象存储和节点通信这些内核细节也不迟。

如果真要走得深,官方文档里“Design”相关章节是宝贵资料,把思路对照着自己跑过的程序验证一遍,收获会比单纯看博客大得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询