· Techtribe team · Tutorials  · 9 min read

The Actor Model in Go: A Practical Guide

Learn how to implement actors in Go for scalable and concurrent systems using message-passing architecture

Learn how to implement actors in Go for scalable and concurrent systems using message-passing architecture

Table of Contents

Introdução a Actor Model

What is the Actor Model

The actor model is a concurrent programming model within a single specific process. Instead of dealing directly with threads (and having associated problems such as race conditions, locking, deadlock, etc.), the logic is encapsulated within actors. Each actor generally represents an entity or client, and usually has a local state (which is not shared with other actors), and communicates with other actors by sending and receiving messages asynchronously.

In the actor model, message delivery is not always guaranteed, as there may be errors in a specific actor (while performing the specific task it was programmed to do), and this is perfectly normal. Therefore, the program should not stop but continue running.

It is a model widely used in reactive and distributed systems to scale applications across multiple nodes and servers since actors can be scheduled and managed on different servers, even within the same application. If on different nodes, the messages are encoded into a sequence of bytes and sent over the network and then decoded on the other side of the node (machine/server/docker/etc.).

source: https://berb.github.io/diploma-thesis/original/054_actors.html Source: https://berb.github.io/diploma-thesis/original/054_actors.html

Location transparency works better in the actor model than in RPC because the actor model already assumes that the message can be lost along the way, even within a single process. Although the latency in a network is likely higher than within a specific process, there will be fewer incompatibilities between local and remote communications when using the actor model.

The actor model extends message-oriented programming with two additional and mandatory properties. In response to a received message, actors:

  1. Send messages to other actors.
  2. Create other actors.
  3. Change their internal states.

Implementing Actor in Go

Let’s create a simple actor using Golang.

  • First, we need a Handler. This handler will be a Struct, and within our Handler, we’ll have a state property, which will be of type uint (unsigned int).
type Handler struct {
   state uint
}
  • Now we need a function to handle the messages since in the actor model messages are constantly transmitted with each action taken.
type Handler struct {
   state uint
}

func (h *Handler) handleMessage(msg uint) {
   h.state = msg
}

In this way, we can modify the value of a property in our struct without directly accessing it by using the handleMessage function. This is a design pattern often used in Go called the State design pattern

https://refactoring.guru/design-patterns/state/go/example

The problem with our code is that in a normal environment, each time a gRPC, HTTP, etc., call is received, it will be handled in goroutines most of the time (concurrency).

  • Let’s call this code in our main func. We’ll instantiate a new Handler and create a loop with 10 repetitions.
func main() {
   h := &Handler{}

   for i := 0; i < 10; i++ {
       h.handleMessage(uint(i))
   }
}

-If we run a data race test here, we won’t have any problems:

(base) victor\@pop-os:\~/Projects/actor/test\_actor$ go run --race main.go
(base) victor\@pop-os:\~/Projects/actor/test\_actor$

Calling it this way, we won’t have any problems with the calls because handleMessage is being invoked synchronously (single thread), meaning the function will be called with each loop repetition.

But in a real scenario, we wouldn’t call this handleMessage function like this; we would use goroutines to handle multiple simultaneous calls. So let’s modify our code and test for data races again.

func main() {
   h := &Handler{}

   for i := 0; i < 10; i++ {
       go h.handleMessage(uint(i))
   }
}

Testing for race conditions:

| (base) victor\@pop-os:\~/Projects/actor/test\_actor$ go run --race main.go
================== WARNING: DATA RACE Write at 0x00c0000a2008 by goroutine 7:   main.(\*Handler).handleMessage() ...
  • We can solve this in several ways. One way is to lock this struct on each call.
type Handler struct {
   lock sync.Mutex
   state uint
}

However, it’s not always good to use locks because it blocks that part of our program, and in many cases, crucial parts of the software will be waiting for that lock. In general, our software will not have the best performance and will be slow. If there’s a way to avoid locks, then avoid them, as they are also very difficult to debug. If not handled correctly and our software stops because of a lock, it can be very challenging to identify the problem.

This is where the actor model comes in. In the actor model, for each entity (something that can act on something), the actor model will play the role of sending messages. When anything happens or receives any type of data (it’s a very data-oriented approach), it will handle messages one by one, like a queue.

So, even though we are using concurrency, the actor model, within the threads, will manage the states through messages, and these messages will be processed one by one. In this case, we don’t need to use any locks :)

  • Let’s install the Hollywood actor package, which will simplify the entire actor architecture.

go get github.com/anthdm/hollywood/…

Now let’s transform our handler into a receiver (actor in Hollywood).

  • Let’s create a Receive function that will receive an actor context. Within this context, there will be messages, so let’s make a switch on the types of messages.

actor context do pacote Actor:

type Context struct {
   pid      *PID
   sender   *PID
   engine   *Engine
   receiver Receiver
   message  any
   // the context of the parent if we are a child.
   // we need this parentCtx, so we can remove the child from the parent Context
   // when the child dies.
   parentCtx *Context
   children  *safemap.SafeMap[string, *PID]
   context   context.Context
}

// Message returns the message that is currently being received.
func (c *Context) Message() any {
	return c.message
}

So we can make a type assertion on Message.

func (h *Handler) Receive(c *actor.Context) {
   switch msg := c.Message().(type) {
  
   }
}

By default, each time we start an engine and it initializes the actor, what will happen is that the system, the engine itself, will send messages to the actor, so we know it initialized or stopped, etc.

So let’s handle these types of messages in our switch case.

type Handler struct {
   state uint
}

func (h *Handler) Receive(c *actor.Context) {
   switch msg := c.Message().(type) {
       case actor.Initialized:
           h.state = 10
           fmt.Println("handler initialized. My state:", h.state)
       case actor.Started:
           fmt.Println("handler started")
       case actor.Stopped:
           _ = msg
   }
}

We can simply delete handleMessage.

  • Let’s create a constructor for our handler.
func newHandler() actor.Producer {
   return func() actor.Receiver {
       return &Handler{}
   }
}
  • Now let’s initialize a new actor Engine in our main func.

In this engine, we have the possibility to Spawn the necessary functionalities for our actor.

func main() {
   e, err := actor.NewEngine(actor.NewEngineConfig())
   if err != nil {
       log.Fatal(err)
   }
   pid := e.Spawn(newHandler(), "handler")
   fmt.Println("handler pid:", pid)
}

Testing this:

(base) victor\@pop-os:\~/Projects/actor/test\_actor$ make
handler initialized. My state: 10
handler started
handler pid: local/handler/2999947451847993607
  • Let’s create our custom type to change the state value.
type SetState struct {
   value uint
}
  • Let’s pass msg.value as a new state in our Receive func, within the SetState case.
func (h *Handler) Receive(c *actor.Context) {
   switch msg := c.Message().(type) {
   case SetState:
       h.state = msg.value
       fmt.Println("handler received new state:", h.state)
   case actor.Initialized:
       h.state = 10
       fmt.Println("handler initialized, state:", h.state)
   case actor.Started:
       fmt.Println("handler started")
   case actor.Stopped:
   }
}
  • Now in our main func, let’s create a loop (as initially), but this time we will send through the Engine.
func main() {
   e, err := actor.NewEngine(actor.NewEngineConfig())
   if err != nil {
       log.Fatal(err)
   }
   pid := e.Spawn(newHandler(), "handler")
   for i := 0; i < 10; i++ {
       e.Send(pid, SetState{value: uint(i)})
   }
}

Let’s run this testing for data races:

(base) victor\@pop-os:\~/Projects/actor/test\_actor$ go run --race main.go
handler initialized, state: 10
handler started
handler received new state: 0
handler received new state: 1
handler received new state: 2
handler received new state: 3
handler received new state: 4
handler received new state: 5
handler received new state: 6
handler received new state: 7
handler received new state: 8
handler received new state: 9
  • We can create a case to reset the state, for example.
type ResetState struct {}

func (h *Handler) Receive(c *actor.Context) {
   switch msg := c.Message().(type) {
   case ResetState:
       h.state = 0
       fmt.Println("handler reset, state:", h.state)
   case SetState:
       h.state = msg.value
       fmt.Println("handler received new state:", h.state)
   case actor.Initialized:
       h.state = 10
       fmt.Println("handler initialized, state:", h.state)
   case actor.Started:
       fmt.Println("handler started")
   case actor.Stopped:
   }
}
  • Now, at the end of our loop, we can simply reset our state.
func main() {
   e, err := actor.NewEngine(actor.NewEngineConfig())
   if err != nil {
       log.Fatal(err)
   }
   pid := e.Spawn(newHandler(), "handler")
   for i := 0; i < 10; i++ {
       e.Send(pid, SetState{value: uint(i)})
   }
   e.Send(pid, ResetState{})
}

Running it again:

| (base) victor\@pop-os:\~/Projects/actor/test\_actor$ go run --race main.go
handler initialized, state: 10
handler started
handler received new state: 0
handler received new state: 1
handler received new state: 2
handler received new state: 3
handler received new state: 4
handler received new state: 5
handler received new state: 6
handler received new state: 7
handler received new state: 8
handler received new state: 9
handler reset, state: 0
  • We can send these messages in goroutines; obviously, they will not have a predefined order because this is a characteristic of this type of programming. There are many factors involved, especially at the hardware level.
func main() {
   e, err := actor.NewEngine(actor.NewEngineConfig())
   if err != nil {
       log.Fatal(err)
   }
   pid := e.Spawn(newHandler(), "handler")
   for i := 0; i < 10; i++ {
       go e.Send(pid, SetState{value: uint(i)})
   }
   e.Send(pid, ResetState{})
}
(base) victor\@pop-os:\~/Projects/actor/test\_actor$ go run --race main.go
handler initialized, state: 10
handler started
handler reset, state: 0
handler received new state: 3
handler received new state: 5
handler received new state: 1
handler received new state: 4
handler received new state: 0
handler received new state: 6
handler received new state: 2
handler received new state: 8
handler received new state: 7
handler received new state: 9

Without any race conditions :)

Here is our complete code:

package main

import (
	"fmt"
	"log"

	"github.com/anthdm/hollywood/actor"
)

type SetState struct {
	value uint
}

type Handler struct {
	state uint
}

type ResetState struct{}

func newHandler() actor.Producer {
	return func() actor.Receiver {
		return &Handler{}
	}
}

func (h *Handler) Receive(c *actor.Context) {
	switch msg := c.Message().(type) {
	case ResetState:
		h.state = 0
		fmt.Println("handler reset, state:", h.state)
	case SetState:
		h.state = msg.value
		fmt.Println("handler received new state:", h.state)
	case actor.Initialized:
		h.state = 10
		fmt.Println("handler initialized, state:", h.state)
	case actor.Started:
		fmt.Println("handler started")
	case actor.Stopped:
	}
}

func main() {
	e, err := actor.NewEngine(actor.NewEngineConfig())
	if err != nil {
		log.Fatal(err)
	}
	pid := e.Spawn(newHandler(), "handler")
	for i := 0; i < 10; i++ {
		go e.Send(pid, SetState{value: uint(i)})
	}
	e.Send(pid, ResetState{})
}
```go
Back to Blog

Related Posts

View All Posts »