2

I have been looking into Golang, and seeing how good its concurrency is with its enforcement of a coroutine-channel-only model through its innovative goroutines construct.

One thing that I immediately find troubling is the use of the Wait() method, used to wait until multiple outstanding goroutines spawned inside a parent goroutine have finished. To quote the Golang docs

Wait can be used to block until all goroutines have finished

The fact that many go developers prescribe Wait() as the preferred way to implement concurrency seems antithetical to Golang's mission of enabling developers to write efficient software, because blocking is inefficient, and truly asynchronous code never blocks.

A process [or thread] that is blocked is one that is waiting for some event, such as a resource becoming available or the completion of an I/O operation.

In other words, a blocked thread will spend CPU cycles doing nothing useful, just checking repeatedly to see if its currently running task can stop waiting and continue its execution.

In truly asynchronous code, when a coroutine encounters a situation where it cannot continue until a result arrives, it must yield its execution to the scheduler instead of blocking, by switching its state from running to waiting, so the scheduler can begin executing the next-in-line coroutine from the runnable queue. The waiting coroutine should have its state changed from waiting to runnable only once the result it needs has arrived.

Therefore, since Wait() blocks until x number of goroutines have invoked Done(), the goroutine which calls Wait() will always remain in either a runnable or running state, wasting CPU cycles and relying on the scheduler to preempt the long-running goroutine only to change its state from running to runnable, instead of changing it to waiting as it should be.

If all this is true, and I'm understanding how Wait() works correctly, then why aren't people using the built-in Go channels for the task of waiting for sub-goroutines to complete? If I understand correctly, sending to a buffered channel, and reading from any channel are both asynchronous operations, meaning that invoking them will put the goroutine into a waiting state, so why aren't they the preferred method?

The article I referenced gives a few examples. Here's what the author calls the "Old School" way:

package main

import (
    "fmt"
    "time"
)

func main() {
    messages := make(chan int)
    go func() {
        time.Sleep(time.Second * 3)
        messages <- 1
    }()
    go func() {
        time.Sleep(time.Second * 2)
        messages <- 2
    }()
    go func() {
        time.Sleep(time.Second * 1)
        messages <- 3
    }()
    for i := 0; i < 3; i++ {
        fmt.Println(<-messages)
    }
}

and here is the preferred, "Canonical" way:

package main

import (
    "fmt"
    "sync"
    "time"
)

func main() {
    messages := make(chan int)
    var wg sync.WaitGroup
    wg.Add(3)
    go func() {
        defer wg.Done()
        time.Sleep(time.Second * 3)
        messages <- 1
    }()
    go func() {
        defer wg.Done()
        time.Sleep(time.Second * 2)
        messages <- 2
    }() 
    go func() {
        defer wg.Done()
        time.Sleep(time.Second * 1)
        messages <- 3
    }()
    wg.Wait()
    for i := range messages {
        fmt.Println(i)
    }
}

I can understand that the second might be easier to understand than the first, but the first is asynchronous where no coroutines block, and the second has one coroutine which blocks: the one running the main function. Here is another example of Wait() being the generally accepted approach.

Why isn't Wait() considered an anti-pattern by the Go community if it creates an inefficient blocked thread? Why aren't channels preferred by most in this situation, since they can by used to keep all the code asynchronous and the thread optimized?

Ozymandias
  • 2,533
  • 29
  • 33
  • 3
    "truly asynchronous code never blocks" -- Not true, at all. Asynchronicity just means that _the entire process_ isn't blocked. And nothing about `Wait()` makes the entire process block--it obviously only makes the single goroutine from which it is called, block. – Jonathan Hall Sep 07 '18 at 12:22
  • 1
    "Why isn't Wait() considered an anti-pattern ... if it creates an inefficient blocked thread?" -- Simply because it doesn't create an inefficient blocked thread. – Jonathan Hall Sep 07 '18 at 12:32
  • but `Wait()` is described in the documentation as a blocking function. – Ozymandias Sep 07 '18 at 12:33
  • 4
    `<- channel` is also blocking, though. – Jonathan Hall Sep 07 '18 at 12:33
  • It depends, really. `sync.WaitGroup` is useful (and indeed required) if you're processing something in multiple routines, and want to wait until all processing has been completed. On the other hand, if I write a consumer (e.g. a queue), that should consume and process using channels, I'd just pass in a context to the consumer that can get cancelled by the main routine if the program terminates (`cancelFunc()` + select case on `<-ctx.Done()` in consumer). There's no waitgroups involved there. It depends on where the data needs to get to – Elias Van Ootegem Sep 07 '18 at 12:34
  • It's basically dependent on the data-flow: If a routine produces data, it can propagate that to other routines through cannels. Those other routines can in turn be goroutines (not the main routine). In that case: use context. If you need the routines to terminate and return to the main routine, you'll use a waitgroup – Elias Van Ootegem Sep 07 '18 at 12:35
  • But shouldn't there be a nonblocking way to wait for some processing taking place over multiple routines to finish? For example in the async/await paradigm there is a way to `await all`. – Ozymandias Sep 07 '18 at 12:36
  • 5
    "nonblocking way to wait" is a contradiction in terms. Block *means* wait. – Jonathan Hall Sep 07 '18 at 12:36
  • 6
    A key misconception in the question is that a blocked goroutine spends CPU cycles checking repeatedly to see if the current goroutine can stop waiting and continue its execution. This is not how it works. The goroutine consumes no cycles until it's rescheduled to run. – Charlie Tumahai Sep 07 '18 at 12:41
  • I see. I guess my stackoverflow question should have been about the internals of the scheduler then. – Ozymandias Sep 07 '18 at 12:48

1 Answers1

17

Your understanding of "blocking" is incorrect. Blocking operations such as WaitGroup.Wait() or a channel receive (when there is no value to receive) only block the execution of the goroutine, they do not (necessarily) block the OS thread which is used to execute the (statements of the) goroutine.

Whenever a blocking operation (such as the above mentioned) is encountered, the goroutine scheduler may (and it will) switch to another goroutine that may continue to run. There are no (significant) CPU cycles lost during a WaitGroup.Wait() call, if there are other goroutines that may continue to run, they will.

Please check related question: Number of threads used by Go runtime

icza
  • 389,944
  • 63
  • 907
  • 827
  • 1
    I think blocking seems like the wrong word to use because in my experience it's always in reference to the OS thread. Anyways, are the operations of `WaitGroup.Wait()` and channel receive signals to the Go scheduler to halt execution of those goroutines immediately, or will they only be stopped due to a preemptive timeout which limits how long a goroutine can run? – Ozymandias Sep 07 '18 at 12:43
  • 3
    @AjaxLeung: "in my experience [blocking] always in reference to the OS thread." -- This isn't a problem with the term "blocking", it's just the result of not having used this execution model before. – Jonathan Hall Sep 07 '18 at 12:45
  • 2
    @AjaxLeung In the Go world almost all statements are related to goroutines, not threads. If someone says something is blocking, that means it blocks the goroutine–unless explicitly stated otherwise. – icza Sep 07 '18 at 12:45
  • Other languages describe tasks as being "paused" or "suspended" so as not to conflate the concept with threads which can be blocked. It's interesting that the Go world chose to use block when alternatives are used elsewhere. – Ozymandias Sep 07 '18 at 13:14
  • "Paused" and "suspended" have entirely different meanings from "blocked." If Go had chosen to use those terms, it would have created far more confusion. – Jonathan Hall Sep 07 '18 at 13:31
  • @AjaxLeung Yes, but at the same time those other languages use threads, not goroutines. – icza Sep 07 '18 at 13:32
  • https://stackoverflow.com/questions/19613444/a-pattern-to-pause-resume-an-async-task In this example, suspend/resume is language being used when referring to C# coroutines, no different than in Go. – Ozymandias Sep 07 '18 at 13:34
  • @AjaxLeung: That is different than Go, because Go doesn't provide the ability to pause/resume goroutines. – Jonathan Hall Sep 07 '18 at 13:35
  • It actually does. When you call `Wait()`, you are telling the scheduler to pause your goroutine and have another one begin/resume execution. – Ozymandias Sep 07 '18 at 13:36
  • 2
    @AjaxLeung `Wait()` is a good scheduling point, but it does not guarantee other goroutines will be scheduled. That's up to the scheduler. E.g. if all `Done()` calls preceed the call to `Wait()`, then `Wait()` will not block and the goroutine might continue (without the scheduler scheduling another goroutine). – icza Sep 07 '18 at 13:40
  • Fantastic article on this subject: https://medium.com/traveloka-engineering/cooperative-vs-preemptive-a-quest-to-maximize-concurrency-power-3b10c5a920fe Based on this article, wg.Wait() absolutely invokes the scheduler. Really cool stuff. – Ozymandias Oct 20 '19 at 03:33