Golang Actor Pattern

Viewed 267

This is a follow-up to a previous question covering the exact same topic Benefits of actor pattern in HTTP handler

Below I've repeated the code from that post:

func (a *API) handleNext(w http.ResponseWriter, r *http.Request) {
    var (
        notFound   = make(chan struct{})
        otherError = make(chan error)
        nextID     = make(chan string)
    )
    a.action <- func() {
        s, err := a.log.Oldest()
        if err == ErrNoSegmentsAvailable {
            close(notFound)
            return
        }
        if err != nil {
            otherError <- err
            return
        }
        id := uuid.New()
        a.pending[id] = pendingSegment{s, time.Now().Add(a.timeout), false}
        nextID <- id
    }
    select {
    case <-notFound:
        http.NotFound(w, r)
    case err := <-otherError:
        http.Error(w, err.Error(), http.StatusInternalServerError)
    case id := <-nextID:
        fmt.Fprint(w, id)
    }
}

A single goroutine runs the below loop behind the scenes listening for the action channel. All mutations happen here as the goroutine has exclusive access, acting as a synchronization point:

func (a *API) loop() {
    for {
        select {
        case f := <-a.action:
            f()
        }
    }
}

The original post questioned the utility of this pattern as the select loop at the bottom of handleNext blocks until the func sent to the action chan is fired (in the dedicated loop goroutine), making every call to handleNext run serially. Answers to the original question stated an overall benefit for the "sum of all goroutines" but I'm not sure I understand how that is the case.

My current expectation is that if we have say 10 clients connected, each calling handleNext, they are all immediately blocked until the single dedicated loop pulls an item off action chan. Since there is only one dedicated goroutine loop for firing actions, and those actions must fully complete before the next handleNext goroutine can proceed, there is never any concurrent execution of more than one handleNext.

I understand that this pattern avoids the need for locking since all mutation would be confined to the loop goroutine, but doesn't it also block more than one client from being worked on simultaneously? If inside of loop the call to f() was instead go f(), then there would be concurrent execution of handleNext funcs, but that would defeat the purpose of the pattern since then you would go back to needing to use locks inside the action func.

I must be misunderstanding somnething here.

So - I can see that we have lock-free synchronizatinon as a benefit of this pattern, but isn't that at the cost of only working on one client at a time? If that's true, then how would this be different than just handling one request at a time, serially, without locks or any other synchronization primitives?

1 Answers

Note that the anonymous function can start a goroutine and return earlier :

    a.action <- func() {
        gizmo, err := a.checkGizmoAvailable() // this is run synchronously
        if err != nil {
           otherErr <- err
           return
        }

        // the remainder is run asynchronously
        go func(){
            s, err := gizmo.log.Oldest()
            if err == ErrNoSegmentsAvailable {
                close(notFound)
                return
            }
            if err != nil {
                otherError <- err
                return
            }
            id := uuid.New()
            
            // this will require synchronization :
            a.pending[id] = pendingSegment{s, time.Now().Add(a.timeout), false}
            nextID <- id
        }()

        // returning here, a.loop() can start the next function
    }
Related