Dust:Java 开源 Actor

Dust 将强大的 Actor 系统与 Java 虚拟线程集成在一起。此范例消除了与大规模多线程应用程序相关的常见问题。

Java 21 引入了对虚拟线程的支持。与常规 Java 线程(通常对应于 OS 线程)不同,虚拟线程非常轻量,实际上应用程序可以同时创建和使用 100,000 个或更多虚拟线程。 

这一魔法是通过对JVM进行两项重大改变来实现的:

虚拟线程由 JVM 而非操作系统管理。如果正在执行,则将其绑定到平台线程(称为载体线程);如果未执行(例如,被阻塞以等待某种形式的通知),则 JVM 会“暂停”虚拟线程并释放载体线程,以便可以调度其他虚拟线程。

平台线程通常会预先分配大约 1 MB 的内存用于其堆栈等。相比之下,虚拟线程的堆栈在堆中进行管理,并且可以只有几百字节 - 可以根据需要增大或缩小。

用于管理虚拟线程之间的协作和通信的 API 与旧平台线程完全相同。这有优点也有缺点:

  • 优点:实施者熟悉接口。
  • 缺点:您仍然面临多线程应用程序中所有常见的“困难”部分 - 同步块,竞争条件等 - 只是现在问题的数量级增加了。
此外,虚拟线程不能停留在同步块中——因此,使用的同步块越多,虚拟线程的效率就越低。

我们需要一种新方法。这种方法可以充分利用运行数百万虚拟线程的能力,同时使多线程编程更容易。事实上,这样的模型是存在的,50 年前就首次被讨论过:Actors。

Dust与Actor
Actor 概念诞生于 20 世纪 70 年代的麻省理工学院,由 Carl Hewitt 进行研究。Actor 概念是 Erlang 和 Elixir 等语言以及Dust等框架的核心:Java 21+ 版 Actors 的开源(Apache2 许可证)实现。

不同的Actor的实现在细节上有所差异,所以从现在开始我们将描述具体的Dust Actor模型:  

  • Actor 是与一个虚拟线程关联的Java 对象。
  • Actor 有一个“邮箱”,用于接收和排队来自其他 Actor 的消息。线程wait()访问此邮箱,检索消息,处理消息,然后返回等待下一条消息。Actor 如何处理消息称为其行为。
    • 请注意,如果 Actor 没有待处理消息,那么由于邮箱线程是虚拟的,JVM 将“停放”Actor 并重用其线程。收到消息后,JVM 将取消停放 Actor 并为其提供一个线程来处理消息。对于只关心消息和行为的开发人员来说,这一切都是透明的。

Actor 可以有自己的可变状态,该状态在 Actor 之外无法访问。在响应收到的消息时,Actor 可以:

  • 改变其状态  
  • 向其他 Actor 发送不可变消息       
  • 创建或销毁其他 Actor       
  • 改变其行为   

就是这样。请注意,Actor 是单线程的,因此Actor中不存在锁定/同步问题。Actor 影响另一个 Actor 的唯一方法是向其发送不可变消息 - 因此 Actor之间不存在同步问题。

一个 Actor 发送给另一个 Actor 的消息的顺序由接收 Actor 保留,但不保证连续性。如果两个 Actor 同时向同一个 Actor 发送消息,则消息可能会交错,但每个流的顺序都会保留。

Actor 由 ActorSystem 管理。它有一个名称,以及可选的端口号。如果指定了端口,则 ActorSystem 中的 Actor 可以接收远程发送的消息 — 无论是从另一个端口还是从另一个主机。ActorSystem 负责远程情况下消息的(反)序列化。

每个 Actor 都有一个唯一的地址,类似于 URL:dust://host:port/actor-system-name/a1/a2/a3。 

如果你与同一个Actor系统中的Actor进行通信,则URL可以简化为:/a1/a2/a3。 

但这不仅仅是一个路径名:它表达了 Actors 之间的父/子关系,即:

  • 创建了一个名为 a1 的 Actor。
  • 然后又创建了一个名为 a2 的行为体:a1 是 a1 的 "父",a2 是 a1 的 "子"。
  • 然后,行为体 a2 又创建了自己的子行为体 a3。
Actor 可以创建多个子对象。唯一的要求是他们的名字必须与“兄弟姐妹”的名字不同。

Actor 结构
Actors 扩展了 Actor 类。需要注意的是,Actors 不是直接用“new”创建的,而是使用不同的机制。这是建立正确的父子关系所必需的。我们使用该类Props来实现这一点,如以下简单示例所示:

/<strong> 
* 一个非常简单的 Actor 
*/ 
public class PingPongActor extends Actor { 

    private int max; 

    /</strong> 
    * 内部使用,调用适当的构造函数
    */ 
    public static Props props(int max) {          
        Props.create(PingPongActor.class, max);   
    } 
    
    public PingPongActor(int max) { this.max = max } 

    // 定义初始行为     
    @Override      
    protected ActorBehavior createBehavior() {          
        return message → {              
            switch(message) {                  
                case PingPongMsg → { 
                     sender.tell(message, self);                      
                     if (0 == --max)                          
                        stopSelf();                  
                }                  
                default → System.out.println(“Strange message …”);              
            }          
        }      
    } 
}

Actors 是由其Props (见下文)创建的,而Props 也可以包含初始化参数。

因此,在上文中,我们的 PingPongActor 初始化包括一个最大计数,我们很快就会展示它的用途。 Actors 是由其他 Actors 创建的,但这个链条必须从某个地方开始。 当创建 ActorSystem 时,它会创建几个默认的顶级 Actor,包括一个名为 /user 的 Actor。

然后,应用程序可以通过 ActorSystem 创建该 Actor 的子节点:

ActorSystem system = new ActorSystem('PingPong');    
ActorRef ping = system.context.actorOf(PingPongActor.props(1000000), ‘ping’);

ActorSystem 的上下文提供了 actorOf() 方法,用于创建 /user Actor 的子节点。 Actor 本身也有一个相同的 actorOf(),用于创建它们的子代。

如果我们现在查看 ActorSystem,我们会看到一个新的 PingPongActor,它的名称是 ping,路径是 /user/ping。 这个创建步骤返回的值是一个 ActorRef--指向该特定 Actor 的 "句柄"。 让我们再创建一个:

 ActorRef pong = system.context.actorOf(PingPongActor.props(1000000), ‘pong’);

现在我们有两个 PingPongActor 实例,它们的 "max "状态都设置为 1000000,都在等待接收邮箱中的消息。 有了消息后,它会将消息传递给 createBehavior() lambda,后者会实现我们的行为。

首先,我们需要一个漂亮的消息类来启动程序:

 public class PingPongMsg implements Serializable {}

对消息的唯一限制是它们必须是可序列化的。现在让我们看看我们的设置:

 ActorSystem system = new ActorSystem('PingPong');     
    ActorRef ping = system.context.actorOf(PingPongActor.props(1000000), ‘ping’);     
    ActorRef pong = system.context.actorOf(PingPongActor.props(1000000), ‘pong’); 

    pong.tell(new PingPongMsg(), ping);

ActorRef 有一个 tell() 方法,它接收一个 Serializable 消息对象和一个(可置空)ActorRef。 因此,在上面的代码中,PingPongMsg 的实例被传递给了 pong 的 Actor。 由于第二个参数不是空值,因此该 ActorRef(ping)可在接收者的行为中作为 "发送者 "变量使用。 回想一下,行为中处理 PingPongMsg 的部分是:

  case PingPongMsg → {             
            sender.tell(message, self);             
            if (0 == --max)                 
                stopSelf();         
        }

这条信息的发送者给了我他的 ActorRef(ping),所以我只是把信息发回给他,通过自变量告诉他我(pong)就是发送者。 如此反复一百万次。 因此,同样的信息在两个 "行为体 "之间总共来回传递了 200 万次,一旦它们的计数器归零,每个 "行为体 "都会自我毁灭。


超越 PingPong
PingPongActor只是能够让人感受到 Actors 和 Dust 的最简单的例子,但显然除此之外价值有限。GitHub 包含几个 Dust 存储库,它们构成了围绕 Dust 框架的小型库。

  • dust-core – Dust 的核心:Actors、持久 Actors、用于构建管道的各种结构 Actors、可扩展服务器等。 
  • dust-http – 小型库,使 Actors 可以轻松访问互联网端点等。
  • dust-html – 一个小型库,可让您以惯用的 Dust 轻松操作网页内容
  • dust-feeds – 访问 RSS 源、抓取网站并使用 SearXNG 进行网页搜索的攻击者
  • dust-nlp – 访问 ChatGPT(及类似)端点和 Hugging Face嵌入API 的参与者
Actor 范式非常适合事件驱动场景。Dust 已用于创建以下系统:
  • 使用 LLM 识别和关注热门话题的智能新闻阅读器  
  • 使用 WiFi 信号强度作为人员代理的建筑物占用管理
  • 玩具城的数字孪生  ——8000 名actor只为模拟成群的鸟儿!
  • 查找和分析并购活动数据的系统